diff --git a/cmd/floop/cmd_pack.go b/cmd/floop/cmd_pack.go index ab1fabd..83a6731 100644 --- a/cmd/floop/cmd_pack.go +++ b/cmd/floop/cmd_pack.go @@ -135,23 +135,28 @@ Examples: func newPackInstallCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "install ", - Short: "Install a skill pack from a .fpack file", - Long: `Install behaviors from a skill pack file into the store. + Use: "install ", + Short: "Install a skill pack from a file, URL, or GitHub repo", + Long: `Install behaviors from a skill pack into the store. +Supports local files, HTTP URLs, and GitHub shorthand sources. Follows the seeder pattern: forgotten behaviors are not re-added, existing behaviors are version-gated for updates, and provenance is stamped on each installed behavior. Examples: floop pack install my-pack.fpack - floop pack install ~/.floop/packs/go-best-practices.fpack`, + floop pack install https://example.com/pack.fpack + floop pack install gh:owner/repo + floop pack install gh:owner/repo@v1.0.0 + floop pack install gh:owner/repo --all-assets`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - filePath := args[0] + source := args[0] root, _ := cmd.Flags().GetString("root") jsonOut, _ := cmd.Flags().GetBool("json") deriveEdges, _ := cmd.Flags().GetBool("derive-edges") + allAssets, _ := cmd.Flags().GetBool("all-assets") cfg, err := config.Load() if err != nil { @@ -165,8 +170,9 @@ Examples: } defer graphStore.Close() - result, err := pack.Install(ctx, graphStore, filePath, cfg, pack.InstallOptions{ + results, err := pack.InstallFromSource(ctx, graphStore, source, cfg, pack.InstallFromSourceOptions{ DeriveEdges: deriveEdges, + AllAssets: allAssets, }) if err != nil { return fmt.Errorf("pack install failed: %w", err) @@ -178,32 +184,41 @@ Examples: } if jsonOut { + jsonResults := make([]map[string]interface{}, 0, len(results)) + for _, result := range results { + jsonResults = append(jsonResults, map[string]interface{}{ + "pack_id": result.PackID, + "version": result.Version, + "added": result.Added, + "updated": result.Updated, + "skipped": result.Skipped, + "edges_added": result.EdgesAdded, + "edges_skipped": result.EdgesSkipped, + "derived_edges": result.DerivedEdges, + "message": fmt.Sprintf("Installed %s v%s: %d added, %d updated, %d skipped", result.PackID, result.Version, len(result.Added), len(result.Updated), len(result.Skipped)), + }) + } return json.NewEncoder(os.Stdout).Encode(map[string]interface{}{ - "pack_id": result.PackID, - "version": result.Version, - "added": result.Added, - "updated": result.Updated, - "skipped": result.Skipped, - "edges_added": result.EdgesAdded, - "edges_skipped": result.EdgesSkipped, - "derived_edges": result.DerivedEdges, - "message": fmt.Sprintf("Installed %s v%s: %d added, %d updated, %d skipped", result.PackID, result.Version, len(result.Added), len(result.Updated), len(result.Skipped)), + "results": jsonResults, }) } - fmt.Printf("Installed %s v%s\n", result.PackID, result.Version) - fmt.Printf(" Added: %d behaviors\n", len(result.Added)) - fmt.Printf(" Updated: %d behaviors\n", len(result.Updated)) - fmt.Printf(" Skipped: %d behaviors\n", len(result.Skipped)) - fmt.Printf(" Edges: %d added, %d skipped\n", result.EdgesAdded, result.EdgesSkipped) - if result.DerivedEdges > 0 { - fmt.Printf(" Derived edges: %d\n", result.DerivedEdges) + for _, result := range results { + fmt.Printf("Installed %s v%s\n", result.PackID, result.Version) + fmt.Printf(" Added: %d behaviors\n", len(result.Added)) + fmt.Printf(" Updated: %d behaviors\n", len(result.Updated)) + fmt.Printf(" Skipped: %d behaviors\n", len(result.Skipped)) + fmt.Printf(" Edges: %d added, %d skipped\n", result.EdgesAdded, result.EdgesSkipped) + if result.DerivedEdges > 0 { + fmt.Printf(" Derived edges: %d\n", result.DerivedEdges) + } } return nil }, } cmd.Flags().Bool("derive-edges", false, "Automatically derive edges between pack behaviors and existing behaviors") + cmd.Flags().Bool("all-assets", false, "Install all .fpack assets from a multi-asset release") return cmd } @@ -338,26 +353,42 @@ Examples: func newPackUpdateCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "update ", - Short: "Update an installed pack from a newer .fpack file", - Long: `Reinstall a pack with a newer version. This is equivalent to running install -with a newer pack file -- existing behaviors are version-gated for updates. + Use: "update [pack-id|source]", + Short: "Update installed packs from their remote sources", + Long: `Update an installed pack by re-fetching from its recorded source, or update +all packs that have remote sources. + +When given a pack ID, looks up the installed pack's source and re-fetches it. +When given a source string (file path, URL, or gh: shorthand), installs directly. +When used with --all, updates every installed pack that has a recorded source. + +For GitHub sources, the remote release version is checked first; if the +installed version already matches, the download is skipped. Examples: - floop pack update my-pack-v2.fpack`, - Args: cobra.ExactArgs(1), + floop pack update my-org/my-pack + floop pack update gh:owner/repo@v2.0.0 + floop pack update my-pack-v2.fpack + floop pack update --all`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // Update is the same as install -- the version-gating handles the upgrade - filePath := args[0] root, _ := cmd.Flags().GetString("root") jsonOut, _ := cmd.Flags().GetBool("json") deriveEdges, _ := cmd.Flags().GetBool("derive-edges") + allPacks, _ := cmd.Flags().GetBool("all") cfg, err := config.Load() if err != nil { cfg = config.Default() } + if allPacks && len(args) > 0 { + return fmt.Errorf("cannot use --all with a specific pack") + } + if !allPacks && len(args) == 0 { + return fmt.Errorf("provide a pack ID or source, or use --all") + } + ctx := context.Background() graphStore, err := store.NewMultiGraphStore(root) if err != nil { @@ -365,11 +396,98 @@ Examples: } defer graphStore.Close() - result, err := pack.Install(ctx, graphStore, filePath, cfg, pack.InstallOptions{ + opts := pack.InstallFromSourceOptions{ DeriveEdges: deriveEdges, - }) - if err != nil { - return fmt.Errorf("pack update failed: %w", err) + } + + // Collect (source, packID) pairs to update + type updateTarget struct { + source string + packID string + installedVersion string + } + var targets []updateTarget + + if allPacks { + for _, p := range cfg.Packs.Installed { + if p.Source == "" { + fmt.Fprintf(os.Stderr, "skipping %s: no recorded source\n", p.ID) + continue + } + targets = append(targets, updateTarget{ + source: p.Source, + packID: p.ID, + installedVersion: p.Version, + }) + } + if len(targets) == 0 { + fmt.Println("No packs with remote sources to update.") + return nil + } + } else { + arg := args[0] + source := "" + + // Check if arg is an installed pack ID + for _, p := range cfg.Packs.Installed { + if p.ID == arg { + if p.Source == "" { + return fmt.Errorf("pack %q has no recorded source; reinstall from a remote source or provide one directly", arg) + } + source = p.Source + targets = append(targets, updateTarget{ + source: source, + packID: p.ID, + installedVersion: p.Version, + }) + break + } + } + + // Not found as pack ID -- treat as a source string + if len(targets) == 0 { + targets = append(targets, updateTarget{ + source: arg, + }) + } + } + + var allResults []*pack.InstallResult + + for _, t := range targets { + // Version check for GitHub sources: skip if already up-to-date + resolved, err := pack.ResolveSource(t.source) + if err != nil { + return fmt.Errorf("resolving source %q: %w", t.source, err) + } + + if resolved.Kind == pack.SourceGitHub && t.installedVersion != "" { + gh := pack.NewGitHubClient() + release, err := gh.ResolveRelease(ctx, resolved.Owner, resolved.Repo, resolved.Version) + if err != nil { + return fmt.Errorf("checking release for %s: %w", t.source, err) + } + remoteVersion := strings.TrimPrefix(release.TagName, "v") + installedVersion := strings.TrimPrefix(t.installedVersion, "v") + if remoteVersion == installedVersion { + label := t.packID + if label == "" { + label = t.source + } + fmt.Printf("%s is already up-to-date (v%s)\n", label, remoteVersion) + continue + } + } + + results, err := pack.InstallFromSource(ctx, graphStore, t.source, cfg, opts) + if err != nil { + if allPacks { + fmt.Fprintf(os.Stderr, "warning: failed to update %s: %v\n", t.packID, err) + continue + } + return fmt.Errorf("pack update failed: %w", err) + } + allResults = append(allResults, results...) } if saveErr := cfg.Save(); saveErr != nil { @@ -377,32 +495,41 @@ Examples: } if jsonOut { + jsonResults := make([]map[string]interface{}, 0, len(allResults)) + for _, result := range allResults { + jsonResults = append(jsonResults, map[string]interface{}{ + "pack_id": result.PackID, + "version": result.Version, + "added": result.Added, + "updated": result.Updated, + "skipped": result.Skipped, + "edges_added": result.EdgesAdded, + "edges_skipped": result.EdgesSkipped, + "derived_edges": result.DerivedEdges, + "message": fmt.Sprintf("Updated %s to v%s: %d added, %d updated, %d skipped", result.PackID, result.Version, len(result.Added), len(result.Updated), len(result.Skipped)), + }) + } return json.NewEncoder(os.Stdout).Encode(map[string]interface{}{ - "pack_id": result.PackID, - "version": result.Version, - "added": result.Added, - "updated": result.Updated, - "skipped": result.Skipped, - "edges_added": result.EdgesAdded, - "edges_skipped": result.EdgesSkipped, - "derived_edges": result.DerivedEdges, - "message": fmt.Sprintf("Updated %s to v%s: %d added, %d updated, %d skipped", result.PackID, result.Version, len(result.Added), len(result.Updated), len(result.Skipped)), + "results": jsonResults, }) } - fmt.Printf("Updated %s to v%s\n", result.PackID, result.Version) - fmt.Printf(" Added: %d behaviors\n", len(result.Added)) - fmt.Printf(" Updated: %d behaviors\n", len(result.Updated)) - fmt.Printf(" Skipped: %d behaviors\n", len(result.Skipped)) - fmt.Printf(" Edges: %d added, %d skipped\n", result.EdgesAdded, result.EdgesSkipped) - if result.DerivedEdges > 0 { - fmt.Printf(" Derived edges: %d\n", result.DerivedEdges) + for _, result := range allResults { + fmt.Printf("Updated %s to v%s\n", result.PackID, result.Version) + fmt.Printf(" Added: %d behaviors\n", len(result.Added)) + fmt.Printf(" Updated: %d behaviors\n", len(result.Updated)) + fmt.Printf(" Skipped: %d behaviors\n", len(result.Skipped)) + fmt.Printf(" Edges: %d added, %d skipped\n", result.EdgesAdded, result.EdgesSkipped) + if result.DerivedEdges > 0 { + fmt.Printf(" Derived edges: %d\n", result.DerivedEdges) + } } return nil }, } cmd.Flags().Bool("derive-edges", false, "Automatically derive edges between pack behaviors and existing behaviors") + cmd.Flags().Bool("all", false, "Update all installed packs that have remote sources") return cmd } diff --git a/cmd/floop/cmd_pack_test.go b/cmd/floop/cmd_pack_test.go index 11cd350..411330b 100644 --- a/cmd/floop/cmd_pack_test.go +++ b/cmd/floop/cmd_pack_test.go @@ -61,13 +61,17 @@ func TestNewPackCreateCmd_Flags(t *testing.T) { func TestNewPackInstallCmd_Args(t *testing.T) { cmd := newPackInstallCmd() - if cmd.Use != "install " { - t.Errorf("Use = %q, want %q", cmd.Use, "install ") + if cmd.Use != "install " { + t.Errorf("Use = %q, want %q", cmd.Use, "install ") } if f := cmd.Flags().Lookup("derive-edges"); f == nil { t.Error("missing --derive-edges flag") } + + if f := cmd.Flags().Lookup("all-assets"); f == nil { + t.Error("missing --all-assets flag") + } } func TestNewPackListCmd(t *testing.T) { @@ -89,13 +93,17 @@ func TestNewPackInfoCmd_Args(t *testing.T) { func TestNewPackUpdateCmd_Args(t *testing.T) { cmd := newPackUpdateCmd() - if cmd.Use != "update " { - t.Errorf("Use = %q, want %q", cmd.Use, "update ") + if cmd.Use != "update [pack-id|source]" { + t.Errorf("Use = %q, want %q", cmd.Use, "update [pack-id|source]") } if f := cmd.Flags().Lookup("derive-edges"); f == nil { t.Error("missing --derive-edges flag") } + + if f := cmd.Flags().Lookup("all"); f == nil { + t.Error("missing --all flag") + } } func TestNewPackRemoveCmd_Args(t *testing.T) { diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 3fd76b1..de84061 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -938,10 +938,10 @@ Skill packs are portable behavior collections (`.fpack` files) that can be share | Subcommand | Description | |------------|-------------| | `create` | Create a pack from current behaviors | -| `install` | Install a pack from a `.fpack` file | +| `install` | Install a pack from a file, URL, or GitHub repo | | `list` | List installed packs | | `info` | Show details of an installed pack | -| `update` | Update a pack from a newer `.fpack` file | +| `update` | Update installed packs from their remote sources | | `remove` | Remove an installed pack | --- @@ -996,15 +996,29 @@ floop pack create my-pack.fpack --id my-org/my-pack --version 1.0.0 --json #### pack install -Install a skill pack from a `.fpack` file. +Install a skill pack from a file, URL, or GitHub repo. ``` -floop pack install +floop pack install [flags] ``` -Installs behaviors from a pack file into the store. Follows the seeder pattern: forgotten behaviors are not re-added, existing behaviors are version-gated for updates, and provenance is stamped on each installed behavior. +Installs behaviors from a pack source into the store. Supports local files, HTTP/HTTPS URLs, and GitHub shorthand (`gh:owner/repo`). Follows the seeder pattern: forgotten behaviors are not re-added, existing behaviors are version-gated for updates, and provenance is stamped on each installed behavior. -No command-specific flags. +**Source formats:** + +| Format | Example | +|--------|---------| +| Local file | `./my-pack.fpack`, `~/.floop/packs/pack.fpack` | +| HTTP URL | `https://example.com/pack.fpack` | +| GitHub (latest) | `gh:owner/repo` | +| GitHub (version) | `gh:owner/repo@v1.2.3` | + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--derive-edges` | bool | `false` | Derive edges between pack behaviors and existing behaviors | +| `--all-assets` | bool | `false` | Install all `.fpack` assets from a multi-asset GitHub release | + +**GitHub authentication:** Set `GITHUB_TOKEN` env var or log in with `gh auth login` to avoid rate limits and access private repos. **Examples:** @@ -1012,11 +1026,20 @@ No command-specific flags. # Install a local pack file floop pack install my-pack.fpack -# Install from packs directory -floop pack install ~/.floop/packs/go-best-practices.fpack +# Install from a URL +floop pack install https://example.com/go-best-practices.fpack + +# Install from GitHub (latest release) +floop pack install gh:my-org/my-packs + +# Install a specific version from GitHub +floop pack install gh:my-org/my-packs@v1.2.0 + +# Install all packs from a multi-asset release +floop pack install gh:my-org/my-packs --all-assets # JSON output -floop pack install my-pack.fpack --json +floop pack install gh:my-org/my-packs --json ``` **See also:** [pack create](#pack-create), [pack update](#pack-update), [pack remove](#pack-remove) @@ -1077,24 +1100,38 @@ floop pack info my-org/my-pack --json #### pack update -Update an installed pack from a newer `.fpack` file. +Update installed packs from their remote sources. ``` -floop pack update +floop pack update [pack-id|source] [flags] ``` -Reinstalls a pack with a newer version. Equivalent to running `pack install` with a newer pack file. Existing behaviors are version-gated for updates, and forgotten behaviors are respected. +Updates an installed pack by re-fetching from its recorded source. For GitHub sources, the remote version is checked first; if already up-to-date, the download is skipped. -No command-specific flags. +Can also accept a source string directly (file path, URL, or GitHub shorthand) to update from a specific source. + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--derive-edges` | bool | `false` | Derive edges between pack behaviors and existing behaviors | +| `--all` | bool | `false` | Update all installed packs that have remote sources | **Examples:** ```bash -# Update from a newer pack file +# Update a specific pack (uses its recorded source) +floop pack update my-org/my-pack + +# Update from a specific GitHub version +floop pack update gh:owner/repo@v2.0.0 + +# Update from a local file floop pack update my-pack-v2.fpack +# Update all packs with remote sources +floop pack update --all + # JSON output -floop pack update my-pack-v2.fpack --json +floop pack update my-org/my-pack --json ``` **See also:** [pack install](#pack-install) diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index 80ff32f..bd1bec6 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -95,7 +95,7 @@ func (s *Server) registerTools() error { // Register floop_pack_install tool sdk.AddTool(s.server, &sdk.Tool{ Name: "floop_pack_install", - Description: "Install a skill pack from a .fpack file", + Description: "Install a skill pack from a local path, URL, or GitHub shorthand (gh:owner/repo)", }, s.handleFloopPackInstall) return nil @@ -1550,10 +1550,16 @@ func (s *Server) handleFloopFeedback(ctx context.Context, req *sdk.CallToolReque // handleFloopPackInstall implements the floop_pack_install tool. func (s *Server) handleFloopPackInstall(ctx context.Context, req *sdk.CallToolRequest, args FloopPackInstallInput) (_ *sdk.CallToolResult, _ FloopPackInstallOutput, retErr error) { + // Resolve source: prefer Source, fall back to deprecated FilePath + source := args.Source + if source == "" { + source = args.FilePath + } + start := time.Now() defer func() { s.auditTool("floop_pack_install", start, retErr, sanitizeToolParams("floop_pack_install", map[string]interface{}{ - "file_path": args.FilePath, + "source": source, }), "local") }() @@ -1561,18 +1567,8 @@ func (s *Server) handleFloopPackInstall(ctx context.Context, req *sdk.CallToolRe return nil, FloopPackInstallOutput{}, err } - if args.FilePath == "" { - return nil, FloopPackInstallOutput{}, fmt.Errorf("file_path is required") - } - - // Validate path: restrict to ~/.floop/packs/ only - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, FloopPackInstallOutput{}, fmt.Errorf("getting home directory: %w", err) - } - packsDir := filepath.Join(homeDir, ".floop", "packs") - if err := pathutil.ValidatePath(args.FilePath, []string{packsDir}); err != nil { - return nil, FloopPackInstallOutput{}, fmt.Errorf("pack install path rejected (must be under ~/.floop/packs/): %w", err) + if source == "" { + return nil, FloopPackInstallOutput{}, fmt.Errorf("source is required (or use deprecated file_path)") } cfg := s.floopConfig @@ -1580,11 +1576,45 @@ func (s *Server) handleFloopPackInstall(ctx context.Context, req *sdk.CallToolRe return nil, FloopPackInstallOutput{}, fmt.Errorf("config not available") } - result, err := pack.Install(ctx, s.store, args.FilePath, cfg, pack.InstallOptions{ - DeriveEdges: true, // Always derive edges for MCP callers (agent workflows) - }) + // Resolve source kind to determine install path + resolved, err := pack.ResolveSource(source) if err != nil { - return nil, FloopPackInstallOutput{}, fmt.Errorf("pack install failed: %w", err) + return nil, FloopPackInstallOutput{}, fmt.Errorf("invalid pack source: %w", err) + } + + var result *pack.InstallResult + + switch resolved.Kind { + case pack.SourceLocal: + // Validate path: restrict to ~/.floop/packs/ only + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, FloopPackInstallOutput{}, fmt.Errorf("getting home directory: %w", err) + } + packsDir := filepath.Join(homeDir, ".floop", "packs") + if err := pathutil.ValidatePath(resolved.FilePath, []string{packsDir}); err != nil { + return nil, FloopPackInstallOutput{}, fmt.Errorf("pack install path rejected (must be under ~/.floop/packs/): %w", err) + } + + result, err = pack.Install(ctx, s.store, resolved.FilePath, cfg, pack.InstallOptions{ + DeriveEdges: true, // Always derive edges for MCP callers (agent workflows) + }) + if err != nil { + return nil, FloopPackInstallOutput{}, fmt.Errorf("pack install failed: %w", err) + } + + case pack.SourceHTTP, pack.SourceGitHub: + // Remote sources bypass path validation, go through InstallFromSource + results, err := pack.InstallFromSource(ctx, s.store, source, cfg, pack.InstallFromSourceOptions{ + DeriveEdges: true, + }) + if err != nil { + return nil, FloopPackInstallOutput{}, fmt.Errorf("pack install failed: %w", err) + } + if len(results) == 0 { + return nil, FloopPackInstallOutput{}, fmt.Errorf("pack install returned no results") + } + result = results[0] } // Save config with updated pack list diff --git a/internal/mcp/schema.go b/internal/mcp/schema.go index b11655a..c2408a7 100644 --- a/internal/mcp/schema.go +++ b/internal/mcp/schema.go @@ -231,7 +231,8 @@ type FloopFeedbackOutput struct { // FloopPackInstallInput defines the input for floop_pack_install tool. type FloopPackInstallInput struct { - FilePath string `json:"file_path" jsonschema:"Path to .fpack file to install,required"` + Source string `json:"source" jsonschema:"Pack source: local path, URL (https://...), or GitHub shorthand (gh:owner/repo[@version]),required"` + FilePath string `json:"file_path,omitempty" jsonschema:"Deprecated: use source instead. Path to .fpack file to install"` } // FloopPackInstallOutput defines the output for floop_pack_install tool. diff --git a/internal/pack/fetch.go b/internal/pack/fetch.go new file mode 100644 index 0000000..01ed07c --- /dev/null +++ b/internal/pack/fetch.go @@ -0,0 +1,120 @@ +package pack + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" +) + +const ( + // MaxPackSize is the maximum allowed download size (50MB). + MaxPackSize = 50 << 20 + // FetchTimeout is the default HTTP timeout for downloads. + FetchTimeout = 120 * time.Second +) + +// FetchOptions configures pack file downloading. +type FetchOptions struct { + CacheDir string // override cache dir (default: ~/.floop/cache/packs) + Force bool // re-download even if cached + AuthToken string // optional Bearer token for authenticated downloads +} + +// FetchResult reports the outcome of a fetch operation. +type FetchResult struct { + LocalPath string // path to the downloaded file + Cached bool // true if served from cache (no download) + Size int64 // file size in bytes +} + +// DefaultCacheDir returns the default pack cache directory. +func DefaultCacheDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("getting home directory: %w", err) + } + return filepath.Join(home, ".floop", "cache", "packs"), nil +} + +// Fetch downloads a URL to the given cachePath. If the file already exists +// and Force is false, it returns immediately with Cached=true. +// +// Downloads use atomic write (tmp file + rename) to prevent partial files. +// File size is limited to MaxPackSize (50MB). +func Fetch(ctx context.Context, url string, cachePath string, opts FetchOptions) (*FetchResult, error) { + // Check cache + if !opts.Force { + if info, err := os.Stat(cachePath); err == nil { + return &FetchResult{ + LocalPath: cachePath, + Cached: true, + Size: info.Size(), + }, nil + } + } + + // Ensure parent directory exists + dir := filepath.Dir(cachePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("creating cache directory: %w", err) + } + + // Download + client := &http.Client{Timeout: FetchTimeout} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating download request: %w", err) + } + if opts.AuthToken != "" { + req.Header.Set("Authorization", "Bearer "+opts.AuthToken) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("downloading %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("download failed: HTTP %d from %s", resp.StatusCode, url) + } + + // Atomic write: download to tmp, then rename + tmpFile, err := os.CreateTemp(dir, "fpack-download-*.tmp") + if err != nil { + return nil, fmt.Errorf("creating temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer func() { + tmpFile.Close() + os.Remove(tmpPath) // cleanup on error; no-op after successful rename + }() + + // Copy with size limit + n, err := io.Copy(tmpFile, io.LimitReader(resp.Body, MaxPackSize+1)) + if err != nil { + return nil, fmt.Errorf("writing download: %w", err) + } + if n > MaxPackSize { + return nil, fmt.Errorf("download exceeds maximum size (%dMB)", MaxPackSize>>20) + } + + if err := tmpFile.Close(); err != nil { + return nil, fmt.Errorf("closing temp file: %w", err) + } + + // Rename to final path + if err := os.Rename(tmpPath, cachePath); err != nil { + return nil, fmt.Errorf("moving download to cache: %w", err) + } + + return &FetchResult{ + LocalPath: cachePath, + Cached: false, + Size: n, + }, nil +} diff --git a/internal/pack/fetch_test.go b/internal/pack/fetch_test.go new file mode 100644 index 0000000..f0741b4 --- /dev/null +++ b/internal/pack/fetch_test.go @@ -0,0 +1,179 @@ +package pack + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFetch_Download(t *testing.T) { + content := "fake-fpack-content-for-testing" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(content)) + })) + defer srv.Close() + + cacheDir := t.TempDir() + cachePath := filepath.Join(cacheDir, "test.fpack") + + result, err := Fetch(context.Background(), srv.URL+"/test.fpack", cachePath, FetchOptions{}) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + + if result.Cached { + t.Error("expected Cached = false for fresh download") + } + if result.Size != int64(len(content)) { + t.Errorf("Size = %d, want %d", result.Size, len(content)) + } + if result.LocalPath != cachePath { + t.Errorf("LocalPath = %q, want %q", result.LocalPath, cachePath) + } + + // Verify file contents + data, err := os.ReadFile(cachePath) + if err != nil { + t.Fatalf("reading cached file: %v", err) + } + if string(data) != content { + t.Errorf("file content = %q, want %q", string(data), content) + } +} + +func TestFetch_CacheHit(t *testing.T) { + cacheDir := t.TempDir() + cachePath := filepath.Join(cacheDir, "cached.fpack") + + // Pre-populate cache + if err := os.WriteFile(cachePath, []byte("cached-content"), 0644); err != nil { + t.Fatal(err) + } + + // Server should not be called + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("server should not be called for cache hit") + })) + defer srv.Close() + + result, err := Fetch(context.Background(), srv.URL+"/test.fpack", cachePath, FetchOptions{}) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + + if !result.Cached { + t.Error("expected Cached = true for cache hit") + } +} + +func TestFetch_ForceRedownload(t *testing.T) { + newContent := "new-content" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(newContent)) + })) + defer srv.Close() + + cacheDir := t.TempDir() + cachePath := filepath.Join(cacheDir, "force.fpack") + + // Pre-populate cache with old content + if err := os.WriteFile(cachePath, []byte("old-content"), 0644); err != nil { + t.Fatal(err) + } + + result, err := Fetch(context.Background(), srv.URL+"/test.fpack", cachePath, FetchOptions{Force: true}) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + + if result.Cached { + t.Error("expected Cached = false for force redownload") + } + + data, err := os.ReadFile(cachePath) + if err != nil { + t.Fatal(err) + } + if string(data) != newContent { + t.Errorf("content = %q, want %q", string(data), newContent) + } +} + +func TestFetch_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + cachePath := filepath.Join(t.TempDir(), "fail.fpack") + + _, err := Fetch(context.Background(), srv.URL+"/missing.fpack", cachePath, FetchOptions{}) + if err == nil { + t.Fatal("expected error for HTTP 404") + } +} + +func TestFetch_SizeLimit(t *testing.T) { + // Serve content larger than MaxPackSize + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Write just over the limit + w.Write([]byte(strings.Repeat("x", MaxPackSize+1))) + })) + defer srv.Close() + + cachePath := filepath.Join(t.TempDir(), "big.fpack") + + _, err := Fetch(context.Background(), srv.URL+"/big.fpack", cachePath, FetchOptions{}) + if err == nil { + t.Fatal("expected error for oversized download") + } +} + +func TestFetch_AuthToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer my-token" { + t.Errorf("Authorization = %q, want %q", auth, "Bearer my-token") + } + w.Write([]byte("ok")) + })) + defer srv.Close() + + cachePath := filepath.Join(t.TempDir(), "auth.fpack") + + _, err := Fetch(context.Background(), srv.URL+"/test.fpack", cachePath, FetchOptions{AuthToken: "my-token"}) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } +} + +func TestFetch_CreatesParentDirs(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("content")) + })) + defer srv.Close() + + cachePath := filepath.Join(t.TempDir(), "deep", "nested", "dir", "test.fpack") + + result, err := Fetch(context.Background(), srv.URL+"/test.fpack", cachePath, FetchOptions{}) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + if result.LocalPath != cachePath { + t.Errorf("LocalPath = %q, want %q", result.LocalPath, cachePath) + } +} + +func TestDefaultCacheDir(t *testing.T) { + dir, err := DefaultCacheDir() + if err != nil { + t.Fatalf("DefaultCacheDir() error = %v", err) + } + if !strings.Contains(dir, ".floop") { + t.Errorf("DefaultCacheDir() = %q, want to contain '.floop'", dir) + } +} diff --git a/internal/pack/github.go b/internal/pack/github.go new file mode 100644 index 0000000..8cbd983 --- /dev/null +++ b/internal/pack/github.go @@ -0,0 +1,182 @@ +package pack + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// GitHubRelease represents a GitHub release. +type GitHubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Assets []GitHubAsset `json:"assets"` +} + +// GitHubAsset represents a file attached to a release. +type GitHubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int `json:"size"` + ContentType string `json:"content_type"` +} + +// GitHubClient interacts with the GitHub REST API. +type GitHubClient struct { + httpClient *http.Client + token string + baseURL string // for testing; defaults to https://api.github.com +} + +// NewGitHubClient creates a GitHubClient with token resolved from environment. +// +// Token resolution order: +// 1. GITHUB_TOKEN env var +// 2. `gh auth token` command output +// 3. empty (unauthenticated, subject to rate limits) +func NewGitHubClient() *GitHubClient { + return &GitHubClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + token: resolveGitHubToken(), + baseURL: "https://api.github.com", + } +} + +// newGitHubClientForTest creates a GitHubClient pointed at a test server. +func newGitHubClientForTest(baseURL, token string) *GitHubClient { + return &GitHubClient{ + httpClient: &http.Client{Timeout: 5 * time.Second}, + token: token, + baseURL: baseURL, + } +} + +// ResolveRelease fetches release metadata from GitHub. +// If version is empty, it fetches the latest release. +// If version is set, it fetches the release tagged with that version. +func (c *GitHubClient) ResolveRelease(ctx context.Context, owner, repo, version string) (*GitHubRelease, error) { + var endpoint string + if version == "" { + endpoint = fmt.Sprintf("%s/repos/%s/%s/releases/latest", c.baseURL, owner, repo) + } else { + endpoint = fmt.Sprintf("%s/repos/%s/%s/releases/tags/%s", c.baseURL, owner, repo, version) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching release: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit for JSON response + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + switch resp.StatusCode { + case http.StatusOK: + // success + case http.StatusNotFound: + if version != "" { + return nil, fmt.Errorf("release %q not found for %s/%s", version, owner, repo) + } + return nil, fmt.Errorf("no releases found for %s/%s", owner, repo) + case http.StatusForbidden: + return nil, fmt.Errorf("GitHub API rate limit exceeded for %s/%s; set GITHUB_TOKEN env var to authenticate", owner, repo) + default: + return nil, fmt.Errorf("GitHub API error %d for %s/%s: %s", resp.StatusCode, owner, repo, string(body)) + } + + var release GitHubRelease + if err := json.Unmarshal(body, &release); err != nil { + return nil, fmt.Errorf("parsing release JSON: %w", err) + } + + return &release, nil +} + +// FindPackAssets returns all .fpack assets from a release. +func FindPackAssets(release *GitHubRelease) []GitHubAsset { + var assets []GitHubAsset + for _, a := range release.Assets { + if strings.HasSuffix(a.Name, ".fpack") { + assets = append(assets, a) + } + } + return assets +} + +// AssetDownloadURL returns the download URL for a release asset. +// It prefers browser_download_url which works without authentication. +func AssetDownloadURL(asset GitHubAsset) string { + return asset.BrowserDownloadURL +} + +// ReleaseVersion returns the version from a release tag, normalizing the v prefix. +func ReleaseVersion(release *GitHubRelease) string { + return strings.TrimPrefix(release.TagName, "v") +} + +// resolveGitHubToken tries to find a GitHub token from environment or gh CLI. +func resolveGitHubToken() string { + // 1. GITHUB_TOKEN env var + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + return token + } + + // 2. gh auth token (with timeout to avoid hanging) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "gh", "auth", "token") + out, err := cmd.Output() + if err == nil { + token := strings.TrimSpace(string(out)) + if token != "" { + return token + } + } + + return "" +} + +// CachePath returns the cache file path for a GitHub release asset. +func GitHubCachePath(cacheDir, owner, repo, version, assetName string) string { + return filepath.Join(cacheDir, owner, repo, version, assetName) +} + +// HTTPCachePath returns the cache file path for an HTTP URL download. +func HTTPCachePath(cacheDir, url string) string { + // Use a simple hash of the URL for the filename + h := fnvHash(url) + return filepath.Join(cacheDir, "url", fmt.Sprintf("%x.fpack", h)) +} + +// fnvHash computes a simple FNV-1a hash for cache key generation. +func fnvHash(s string) uint64 { + const ( + offset64 = 14695981039346656037 + prime64 = 1099511628211 + ) + h := uint64(offset64) + for i := 0; i < len(s); i++ { + h ^= uint64(s[i]) + h *= prime64 + } + return h +} diff --git a/internal/pack/github_test.go b/internal/pack/github_test.go new file mode 100644 index 0000000..8e461f9 --- /dev/null +++ b/internal/pack/github_test.go @@ -0,0 +1,202 @@ +package pack + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestResolveRelease_Latest(t *testing.T) { + release := GitHubRelease{ + TagName: "v1.0.0", + Name: "Release 1.0.0", + Assets: []GitHubAsset{ + {Name: "floop-core.fpack", BrowserDownloadURL: "https://example.com/floop-core.fpack", Size: 1024}, + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/test-owner/test-repo/releases/latest" { + t.Errorf("unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(release) + })) + defer srv.Close() + + client := newGitHubClientForTest(srv.URL, "") + got, err := client.ResolveRelease(context.Background(), "test-owner", "test-repo", "") + if err != nil { + t.Fatalf("ResolveRelease() error = %v", err) + } + + if got.TagName != "v1.0.0" { + t.Errorf("TagName = %q, want %q", got.TagName, "v1.0.0") + } + if len(got.Assets) != 1 { + t.Errorf("Assets = %d, want 1", len(got.Assets)) + } +} + +func TestResolveRelease_SpecificVersion(t *testing.T) { + release := GitHubRelease{ + TagName: "v2.0.0", + Name: "Release 2.0.0", + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/owner/repo/releases/tags/v2.0.0" { + w.WriteHeader(http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(release) + })) + defer srv.Close() + + client := newGitHubClientForTest(srv.URL, "") + got, err := client.ResolveRelease(context.Background(), "owner", "repo", "v2.0.0") + if err != nil { + t.Fatalf("ResolveRelease() error = %v", err) + } + + if got.TagName != "v2.0.0" { + t.Errorf("TagName = %q, want %q", got.TagName, "v2.0.0") + } +} + +func TestResolveRelease_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := newGitHubClientForTest(srv.URL, "") + _, err := client.ResolveRelease(context.Background(), "owner", "repo", "v99.0.0") + if err == nil { + t.Fatal("expected error for not found release") + } +} + +func TestResolveRelease_RateLimit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + client := newGitHubClientForTest(srv.URL, "") + _, err := client.ResolveRelease(context.Background(), "owner", "repo", "") + if err == nil { + t.Fatal("expected error for rate limit") + } + if got := err.Error(); !contains(got, "rate limit") { + t.Errorf("error = %q, want to contain 'rate limit'", got) + } +} + +func TestResolveRelease_AuthHeader(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test-token" { + t.Errorf("Authorization = %q, want %q", auth, "Bearer test-token") + } + json.NewEncoder(w).Encode(GitHubRelease{TagName: "v1.0.0"}) + })) + defer srv.Close() + + client := newGitHubClientForTest(srv.URL, "test-token") + _, err := client.ResolveRelease(context.Background(), "owner", "repo", "") + if err != nil { + t.Fatalf("ResolveRelease() error = %v", err) + } +} + +func TestFindPackAssets(t *testing.T) { + tests := []struct { + name string + assets []GitHubAsset + want int + }{ + { + name: "no assets", + assets: nil, + want: 0, + }, + { + name: "no fpack assets", + assets: []GitHubAsset{ + {Name: "README.md"}, + {Name: "checksums.txt"}, + }, + want: 0, + }, + { + name: "one fpack asset", + assets: []GitHubAsset{ + {Name: "floop-core.fpack"}, + {Name: "checksums.txt"}, + }, + want: 1, + }, + { + name: "multiple fpack assets", + assets: []GitHubAsset{ + {Name: "floop-core.fpack"}, + {Name: "floop-testing.fpack"}, + {Name: "checksums.txt"}, + }, + want: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + release := &GitHubRelease{Assets: tt.assets} + got := FindPackAssets(release) + if len(got) != tt.want { + t.Errorf("FindPackAssets() = %d assets, want %d", len(got), tt.want) + } + }) + } +} + +func TestReleaseVersion(t *testing.T) { + tests := []struct { + tag string + want string + }{ + {"v1.0.0", "1.0.0"}, + {"1.0.0", "1.0.0"}, + {"v0.1.0-beta", "0.1.0-beta"}, + } + + for _, tt := range tests { + t.Run(tt.tag, func(t *testing.T) { + release := &GitHubRelease{TagName: tt.tag} + if got := ReleaseVersion(release); got != tt.want { + t.Errorf("ReleaseVersion() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGitHubCachePath(t *testing.T) { + got := GitHubCachePath("/cache", "owner", "repo", "v1.0.0", "pack.fpack") + want := "/cache/owner/repo/v1.0.0/pack.fpack" + if got != want { + t.Errorf("GitHubCachePath() = %q, want %q", got, want) + } +} + +func TestHTTPCachePath(t *testing.T) { + p1 := HTTPCachePath("/cache", "https://example.com/a.fpack") + p2 := HTTPCachePath("/cache", "https://example.com/b.fpack") + if p1 == p2 { + t.Error("different URLs should produce different cache paths") + } +} + +// contains is defined in format_test.go (same package) diff --git a/internal/pack/install.go b/internal/pack/install.go index a579628..35475bf 100644 --- a/internal/pack/install.go +++ b/internal/pack/install.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "time" "github.com/nvandessel/floop/internal/config" @@ -13,7 +14,8 @@ import ( // InstallOptions configures pack installation. type InstallOptions struct { - DeriveEdges bool // Automatically derive edges between pack behaviors and existing behaviors + DeriveEdges bool // Automatically derive edges between pack behaviors and existing behaviors + Source string // Canonical source string to record (e.g., "gh:owner/repo@v1.0.0") } // InstallResult reports what was installed. @@ -115,7 +117,7 @@ func Install(ctx context.Context, s store.GraphStore, filePath string, cfg *conf // 5. Record in config if cfg != nil { - recordInstall(cfg, manifest, result) + recordInstall(cfg, manifest, result, opts.Source) } return result, nil @@ -138,7 +140,8 @@ func stampProvenance(node *store.Node, manifest *PackManifest) { } // recordInstall updates the config's installed packs list. -func recordInstall(cfg *config.FloopConfig, manifest *PackManifest, result *InstallResult) { +// source is the canonical source string (e.g., "gh:owner/repo@v1.0.0"); falls back to manifest.Source. +func recordInstall(cfg *config.FloopConfig, manifest *PackManifest, result *InstallResult, source string) { // Remove existing entry for this pack if present filtered := make([]config.InstalledPack, 0, len(cfg.Packs.Installed)) for _, p := range cfg.Packs.Installed { @@ -147,14 +150,128 @@ func recordInstall(cfg *config.FloopConfig, manifest *PackManifest, result *Inst } } + // Resolve source: prefer explicit source, fall back to manifest + recordedSource := source + if recordedSource == "" { + recordedSource = manifest.Source + } + // Add new entry filtered = append(filtered, config.InstalledPack{ ID: string(manifest.ID), Version: manifest.Version, InstalledAt: time.Now(), + Source: recordedSource, BehaviorCount: len(result.Added) + len(result.Updated) + len(result.Skipped), EdgeCount: result.EdgesAdded, }) cfg.Packs.Installed = filtered } + +// InstallFromSourceOptions configures remote pack installation. +type InstallFromSourceOptions struct { + DeriveEdges bool + AllAssets bool // install all .fpack assets from a multi-asset GitHub release +} + +// InstallFromSource resolves a source string, fetches remote packs if needed, +// and installs them. Returns one InstallResult per installed pack file. +// +// Supported source formats: +// - Local path: ./pack.fpack, /abs/path.fpack +// - HTTP URL: https://example.com/pack.fpack +// - GitHub shorthand: gh:owner/repo, gh:owner/repo@v1.2.3 +func InstallFromSource(ctx context.Context, s store.GraphStore, source string, cfg *config.FloopConfig, opts InstallFromSourceOptions) ([]*InstallResult, error) { + resolved, err := ResolveSource(source) + if err != nil { + return nil, fmt.Errorf("resolving source: %w", err) + } + + installOpts := InstallOptions{ + DeriveEdges: opts.DeriveEdges, + Source: resolved.Canonical, + } + + switch resolved.Kind { + case SourceLocal: + result, err := Install(ctx, s, resolved.FilePath, cfg, installOpts) + if err != nil { + return nil, err + } + return []*InstallResult{result}, nil + + case SourceHTTP: + cacheDir, err := DefaultCacheDir() + if err != nil { + return nil, fmt.Errorf("getting cache directory: %w", err) + } + cachePath := HTTPCachePath(cacheDir, resolved.URL) + + fetchResult, err := Fetch(ctx, resolved.URL, cachePath, FetchOptions{}) + if err != nil { + return nil, fmt.Errorf("fetching %s: %w", resolved.URL, err) + } + + result, err := Install(ctx, s, fetchResult.LocalPath, cfg, installOpts) + if err != nil { + return nil, err + } + return []*InstallResult{result}, nil + + case SourceGitHub: + gh := NewGitHubClient() + + release, err := gh.ResolveRelease(ctx, resolved.Owner, resolved.Repo, resolved.Version) + if err != nil { + return nil, err + } + + packAssets := FindPackAssets(release) + if len(packAssets) == 0 { + assetNames := make([]string, len(release.Assets)) + for i, a := range release.Assets { + assetNames[i] = a.Name + } + return nil, fmt.Errorf("no .fpack assets found in release %s; available assets: %s", + release.TagName, strings.Join(assetNames, ", ")) + } + + if len(packAssets) > 1 && !opts.AllAssets { + names := make([]string, len(packAssets)) + for i, a := range packAssets { + names[i] = a.Name + } + return nil, fmt.Errorf("release %s contains multiple .fpack assets: %s; use --all-assets to install all", + release.TagName, strings.Join(names, ", ")) + } + + cacheDir, err := DefaultCacheDir() + if err != nil { + return nil, fmt.Errorf("getting cache directory: %w", err) + } + + version := ReleaseVersion(release) + + var results []*InstallResult + for _, asset := range packAssets { + cachePath := GitHubCachePath(cacheDir, resolved.Owner, resolved.Repo, version, asset.Name) + downloadURL := AssetDownloadURL(asset) + + fetchResult, err := Fetch(ctx, downloadURL, cachePath, FetchOptions{}) + if err != nil { + return nil, fmt.Errorf("fetching %s: %w", asset.Name, err) + } + + result, err := Install(ctx, s, fetchResult.LocalPath, cfg, installOpts) + if err != nil { + return nil, fmt.Errorf("installing %s: %w", asset.Name, err) + } + results = append(results, result) + } + return results, nil + + default: + return nil, fmt.Errorf("unsupported source kind: %s", resolved.Kind) + } +} diff --git a/internal/pack/resolve.go b/internal/pack/resolve.go new file mode 100644 index 0000000..7dea1d5 --- /dev/null +++ b/internal/pack/resolve.go @@ -0,0 +1,132 @@ +package pack + +import ( + "fmt" + "path/filepath" + "strings" +) + +// SourceKind classifies the type of pack source. +type SourceKind int + +const ( + // SourceLocal is a local file path. + SourceLocal SourceKind = iota + // SourceHTTP is an HTTP/HTTPS URL. + SourceHTTP + // SourceGitHub is a GitHub shorthand (gh:owner/repo[@version]). + SourceGitHub +) + +// String returns a human-readable name for the source kind. +func (k SourceKind) String() string { + switch k { + case SourceLocal: + return "local" + case SourceHTTP: + return "http" + case SourceGitHub: + return "github" + default: + return "unknown" + } +} + +// ResolvedSource contains the parsed components of a pack source string. +type ResolvedSource struct { + Kind SourceKind + Raw string // original input + Canonical string // normalized for storage in config + FilePath string // for SourceLocal: absolute path + URL string // for SourceHTTP: full URL + Owner string // for SourceGitHub + Repo string // for SourceGitHub + Version string // for SourceGitHub ("" = latest) +} + +// ResolveSource parses a source string into its components. +// +// Supported formats: +// - gh:owner/repo → SourceGitHub (latest release) +// - gh:owner/repo@v1.2.3 → SourceGitHub (specific version) +// - https://example.com/x → SourceHTTP +// - http://example.com/x → SourceHTTP +// - ./path or /abs/path → SourceLocal +func ResolveSource(source string) (*ResolvedSource, error) { + if source == "" { + return nil, fmt.Errorf("source is required") + } + + // GitHub shorthand: gh:owner/repo[@version] + if strings.HasPrefix(source, "gh:") { + return resolveGitHub(source) + } + + // HTTP/HTTPS URL + if strings.HasPrefix(source, "https://") || strings.HasPrefix(source, "http://") { + return &ResolvedSource{ + Kind: SourceHTTP, + Raw: source, + Canonical: source, + URL: source, + }, nil + } + + // Everything else is a local path + return resolveLocal(source) +} + +// resolveGitHub parses gh:owner/repo[@version]. +func resolveGitHub(source string) (*ResolvedSource, error) { + rest := strings.TrimPrefix(source, "gh:") + if rest == "" { + return nil, fmt.Errorf("invalid GitHub source %q: expected gh:owner/repo", source) + } + + // Split on @ for version + var repoRef, version string + if idx := strings.Index(rest, "@"); idx >= 0 { + repoRef = rest[:idx] + version = rest[idx+1:] + if version == "" { + return nil, fmt.Errorf("invalid GitHub source %q: version after @ is empty", source) + } + } else { + repoRef = rest + } + + // Split owner/repo + parts := strings.SplitN(repoRef, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return nil, fmt.Errorf("invalid GitHub source %q: expected gh:owner/repo", source) + } + + canonical := "gh:" + parts[0] + "/" + parts[1] + if version != "" { + canonical += "@" + version + } + + return &ResolvedSource{ + Kind: SourceGitHub, + Raw: source, + Canonical: canonical, + Owner: parts[0], + Repo: parts[1], + Version: version, + }, nil +} + +// resolveLocal resolves a local file path to an absolute path. +func resolveLocal(source string) (*ResolvedSource, error) { + absPath, err := filepath.Abs(source) + if err != nil { + return nil, fmt.Errorf("resolving local path %q: %w", source, err) + } + + return &ResolvedSource{ + Kind: SourceLocal, + Raw: source, + Canonical: absPath, + FilePath: absPath, + }, nil +} diff --git a/internal/pack/resolve_test.go b/internal/pack/resolve_test.go new file mode 100644 index 0000000..04b1637 --- /dev/null +++ b/internal/pack/resolve_test.go @@ -0,0 +1,193 @@ +package pack + +import ( + "path/filepath" + "testing" +) + +func TestResolveSource(t *testing.T) { + tests := []struct { + name string + source string + wantKind SourceKind + wantOwner string + wantRepo string + wantVer string + wantURL string + wantErr bool + }{ + { + name: "empty source", + source: "", + wantErr: true, + }, + { + name: "github latest", + source: "gh:nvandessel/floop", + wantKind: SourceGitHub, + wantOwner: "nvandessel", + wantRepo: "floop", + }, + { + name: "github with version", + source: "gh:nvandessel/floop@v1.2.3", + wantKind: SourceGitHub, + wantOwner: "nvandessel", + wantRepo: "floop", + wantVer: "v1.2.3", + }, + { + name: "github missing repo", + source: "gh:nvandessel", + wantErr: true, + }, + { + name: "github empty after prefix", + source: "gh:", + wantErr: true, + }, + { + name: "github empty owner", + source: "gh:/repo", + wantErr: true, + }, + { + name: "github empty repo", + source: "gh:owner/", + wantErr: true, + }, + { + name: "github empty version after @", + source: "gh:owner/repo@", + wantErr: true, + }, + { + name: "https url", + source: "https://example.com/pack.fpack", + wantKind: SourceHTTP, + wantURL: "https://example.com/pack.fpack", + }, + { + name: "http url", + source: "http://example.com/pack.fpack", + wantKind: SourceHTTP, + wantURL: "http://example.com/pack.fpack", + }, + { + name: "local relative path", + source: "./my-pack.fpack", + wantKind: SourceLocal, + }, + { + name: "local absolute path", + source: "/tmp/my-pack.fpack", + wantKind: SourceLocal, + }, + { + name: "local filename only", + source: "my-pack.fpack", + wantKind: SourceLocal, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveSource(tt.source) + if (err != nil) != tt.wantErr { + t.Fatalf("ResolveSource() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + + if got.Kind != tt.wantKind { + t.Errorf("Kind = %v, want %v", got.Kind, tt.wantKind) + } + if got.Raw != tt.source { + t.Errorf("Raw = %q, want %q", got.Raw, tt.source) + } + + switch tt.wantKind { + case SourceGitHub: + if got.Owner != tt.wantOwner { + t.Errorf("Owner = %q, want %q", got.Owner, tt.wantOwner) + } + if got.Repo != tt.wantRepo { + t.Errorf("Repo = %q, want %q", got.Repo, tt.wantRepo) + } + if got.Version != tt.wantVer { + t.Errorf("Version = %q, want %q", got.Version, tt.wantVer) + } + case SourceHTTP: + if got.URL != tt.wantURL { + t.Errorf("URL = %q, want %q", got.URL, tt.wantURL) + } + case SourceLocal: + if !filepath.IsAbs(got.FilePath) { + t.Errorf("FilePath = %q, want absolute path", got.FilePath) + } + } + }) + } +} + +func TestResolveSource_Canonical(t *testing.T) { + tests := []struct { + name string + source string + wantCanonical string + }{ + { + name: "github canonical normalizes", + source: "gh:owner/repo", + wantCanonical: "gh:owner/repo", + }, + { + name: "github canonical with version", + source: "gh:owner/repo@v1.0.0", + wantCanonical: "gh:owner/repo@v1.0.0", + }, + { + name: "http canonical is identity", + source: "https://example.com/pack.fpack", + wantCanonical: "https://example.com/pack.fpack", + }, + { + name: "local canonical is absolute", + source: "/tmp/test.fpack", + wantCanonical: "/tmp/test.fpack", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveSource(tt.source) + if err != nil { + t.Fatalf("ResolveSource() error = %v", err) + } + if got.Canonical != tt.wantCanonical { + t.Errorf("Canonical = %q, want %q", got.Canonical, tt.wantCanonical) + } + }) + } +} + +func TestSourceKind_String(t *testing.T) { + tests := []struct { + kind SourceKind + want string + }{ + {SourceLocal, "local"}, + {SourceHTTP, "http"}, + {SourceGitHub, "github"}, + {SourceKind(99), "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.kind.String(); got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + }) + } +}