diff --git a/README.md b/README.md index 0ee60cd9ea..e520df37ce 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ With Entire, you can: - [Strategy](#strategy) - [Local Device Auth Testing](#local-device-auth-testing) - [Commands Reference](#commands-reference) +- [Plugins](#plugins) - [Configuration](#configuration) - [Security & Privacy](#security--privacy) - [Troubleshooting](#troubleshooting) @@ -247,6 +248,7 @@ go test -tags=integration ./cmd/entire/cli/integration_test -run TestLogin | `entire checkpoint explain` | Explain a session, commit, or checkpoint | | `entire checkpoint rewind` | Rewind to a previous checkpoint (deprecated, will be removed in a future release) | | `entire login` | Authenticate the CLI with Entire device auth | +| `entire plugin` | Discover, install, upgrade, and remove plugins (see [Plugins](#plugins)) | | `entire session` | View and manage agent sessions tracked by Entire | | `entire session resume` | Switch to a branch, restore latest checkpointed session metadata, and show command(s) | | `entire session attach` | Attach to a previously detached session | @@ -315,6 +317,27 @@ entire agent add claude-code entire agent remove claude-code ``` +## Plugins + +Plugins extend the CLI with new verbs: any executable named `entire-` on `$PATH` runs as `entire `, kubectl-style — stdio passes through, exit codes propagate, no SDK or protocol required. + +```sh +entire plugin search # browse the plugin index +entire plugin install upgrade # install by name (index lookup) +entire plugin install https://github.com/you/entire-x # install from any git host +entire plugin install ./dist/entire-x # link a local build +entire upgrade # run an installed plugin +entire plugin upgrade --all # update remote-installed plugins +entire plugin doctor # check for broken installs and missing dependencies +entire plugin remove x +``` + +Remote installs are forge-agnostic: the newest semver tag is resolved over the git protocol, and the platform's release asset is downloaded and verified against the release's `checksums.txt`. Plugins can declare dependencies on other plugins in an `entire-plugin.yml`; missing ones are installed after a single confirmation. + +Discovery uses a git-synced catalog, [entireio/plugin-index](https://github.com/entireio/plugin-index) by default. Organizations can point the CLI at an internal catalog via `plugins.index_url` in `.entire/settings.json`, the `ENTIRE_PLUGIN_INDEX_URL` environment variable, or `--index`. + +For the full contract — resolution rules, environment filtering, release-asset conventions, and how to author a plugin — see [External Commands](docs/architecture/external-commands.md). + ## Configuration Entire uses two configuration files in the `.entire/` directory: diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 0e91bdc068..ff844565e2 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -52,6 +52,7 @@ const ( lessPagerName = "less" lessRawControlEnv = "LESS=-R" windowsGOOS = "windows" + darwinGOOS = "darwin" ) var checkpointSummaryTimeout = defaultCheckpointSummaryTimeout diff --git a/cmd/entire/cli/integration_test/plugin_remote_install_test.go b/cmd/entire/cli/integration_test/plugin_remote_install_test.go new file mode 100644 index 0000000000..e0a50b021f --- /dev/null +++ b/cmd/entire/cli/integration_test/plugin_remote_install_test.go @@ -0,0 +1,275 @@ +//go:build integration + +package integration + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/execx" + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +// Integration tests for remote plugin install: the spawned binary resolves +// tags over the git protocol (file:// repos), downloads release assets from +// a local HTTP server (download_url template), installs into an isolated +// ENTIRE_PLUGIN_DIR, and then the kubectl dispatcher actually runs the +// installed plugin — the full M1+M2+M3 path with zero real network. + +// makeScriptTarGz builds a tar.gz holding an executable shell script named +// entire- that echoes a version marker. +func makeScriptTarGz(t *testing.T, name, marker string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + script := "#!/bin/sh\necho " + marker + "\n" + if err := tw.WriteHeader(&tar.Header{Name: "entire-" + name, Mode: 0o755, Size: int64(len(script)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(script)); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// startReleaseServer serves tar.gz assets for the named plugins/versions. +func startReleaseServer(t *testing.T, plugins map[string][]string) *httptest.Server { + t.Helper() + assets := map[string][]byte{} + for name, versions := range plugins { + for _, v := range versions { + asset := fmt.Sprintf("entire-%s_%s_%s_%s.tar.gz", name, v, runtime.GOOS, runtime.GOARCH) + assets[asset] = makeScriptTarGz(t, name, name+"-ran-v"+v) + } + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + base := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + if data, ok := assets[base]; ok { + _, _ = w.Write(data) //nolint:errcheck // test server write; failure surfaces as a client error + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + return srv +} + +// newPluginRepo creates a tagged git repo with entire-plugin.yml. +func newPluginRepo(t *testing.T, metadata string, tags ...string) string { + t.Helper() + dir := t.TempDir() + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, "entire-plugin.yml", metadata) + testutil.GitAdd(t, dir, "entire-plugin.yml") + testutil.GitCommit(t, dir, "init") + for _, tag := range tags { + if out, err := exec.CommandContext(t.Context(), "git", "-C", dir, "tag", tag).CombinedOutput(); err != nil { + t.Fatalf("git tag: %v: %s", err, out) + } + } + return "file://" + filepath.ToSlash(dir) +} + +// newIndexRepo creates a git repo holding index.json. +func newIndexRepo(t *testing.T, indexJSON string) string { + t.Helper() + dir := t.TempDir() + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, "index.json", indexJSON) + testutil.GitAdd(t, dir, "index.json") + testutil.GitCommit(t, dir, "index") + return "file://" + filepath.ToSlash(dir) +} + +// pluginEnv builds the child env: isolated plugin dir + cache + index URL. +func pluginTestEnv(t *testing.T, indexURL string) ([]string, string) { + t.Helper() + pluginDir := t.TempDir() + env := os.Environ() + env = append(env, + "ENTIRE_PLUGIN_DIR="+pluginDir, + "XDG_CACHE_HOME="+t.TempDir(), + ) + if indexURL != "" { + env = append(env, "ENTIRE_PLUGIN_INDEX_URL="+indexURL) + } + return env, pluginDir +} + +func runEntire(t *testing.T, env []string, args ...string) (stdout, stderr string, err error) { + t.Helper() + cmd := execx.NonInteractive(context.Background(), getTestBinary(), args...) + cmd.Env = env + var out, errBuf bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errBuf + err = cmd.Run() + return out.String(), errBuf.String(), err +} + +func TestPluginRemoteInstall_FromIndexAndDispatch(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("shell-script plugin payloads are Unix-only") + } + srv := startReleaseServer(t, map[string][]string{"demo": {"0.1.0"}}) + repoURL := newPluginRepo(t, fmt.Sprintf("name: demo\ndownload_url: \"%s/dl/{tag}/{asset}\"\n", srv.URL), "v0.1.0") + indexURL := newIndexRepo(t, fmt.Sprintf(`{"version":1,"plugins":[{"name":"demo","repo_url":"%s","description":"Demo plugin","official":true}]}`, repoURL)) + env, _ := pluginTestEnv(t, indexURL) + + // Bare-name install resolves through the index; index-listed repos + // need no --yes. + stdout, stderr, err := runEntire(t, env, "plugin", "install", "demo") + if err != nil { + t.Fatalf("plugin install demo: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + if !strings.Contains(stdout, `Installed plugin "demo" v0.1.0`) { + t.Errorf("install output = %q", stdout) + } + + // The dispatcher must now run it as `entire demo`. + stdout, stderr, err = runEntire(t, env, "demo") + if err != nil { + t.Fatalf("entire demo: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, "demo-ran-v0.1.0") { + t.Errorf("dispatched plugin output = %q, want demo-ran-v0.1.0", stdout) + } + + // list shows the tag from the manifest. + stdout, _, err = runEntire(t, env, "plugin", "list") + if err != nil { + t.Fatalf("plugin list: %v", err) + } + if !strings.Contains(stdout, "demo") || !strings.Contains(stdout, "v0.1.0") { + t.Errorf("plugin list output = %q, want name+tag", stdout) + } +} + +func TestPluginRemoteInstall_URLNeedsYesWhenUnlisted(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("shell-script plugin payloads are Unix-only") + } + srv := startReleaseServer(t, map[string][]string{"solo": {"0.1.0"}}) + repoURL := newPluginRepo(t, fmt.Sprintf("name: solo\ndownload_url: \"%s/dl/{tag}/{asset}\"\n", srv.URL), "v0.1.0") + // Empty index: the URL is unlisted → untrusted. + indexURL := newIndexRepo(t, `{"version":1,"plugins":[]}`) + env, _ := pluginTestEnv(t, indexURL) + + // Non-interactive without --yes refuses. + _, stderr, err := runEntire(t, env, "plugin", "install", repoURL) + if err == nil { + t.Fatal("unlisted URL install without --yes succeeded in non-interactive mode") + } + if !strings.Contains(stderr, "--yes") { + t.Errorf("stderr = %q, want a hint about --yes", stderr) + } + + // With --yes it proceeds. + stdout, stderr, err := runEntire(t, env, "plugin", "install", repoURL, "--yes") + if err != nil { + t.Fatalf("install --yes: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, `Installed plugin "solo" v0.1.0`) { + t.Errorf("install output = %q", stdout) + } +} + +func TestPluginRemoteInstall_DependenciesAndRemoveGuard(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("shell-script plugin payloads are Unix-only") + } + srv := startReleaseServer(t, map[string][]string{"brainy": {"0.1.0"}, "semy": {"0.1.0"}}) + semRepo := newPluginRepo(t, fmt.Sprintf("name: semy\ndownload_url: \"%s/dl/{tag}/{asset}\"\n", srv.URL), "v0.1.0") + brainRepo := newPluginRepo(t, fmt.Sprintf( + "name: brainy\ndownload_url: \"%s/dl/{tag}/{asset}\"\nrequires:\n - name: semy\n repo_url: %s\n", srv.URL, semRepo), "v0.1.0") + indexURL := newIndexRepo(t, fmt.Sprintf( + `{"version":1,"plugins":[{"name":"brainy","repo_url":"%s"},{"name":"semy","repo_url":"%s"}]}`, brainRepo, semRepo)) + env, _ := pluginTestEnv(t, indexURL) + + stdout, stderr, err := runEntire(t, env, "plugin", "install", "brainy", "--yes") + if err != nil { + t.Fatalf("install brainy: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + if !strings.Contains(stdout, `Installed dependency "semy"`) { + t.Errorf("install output = %q, want dependency install", stdout) + } + + // Both dispatchable. + for _, name := range []string{"brainy", "semy"} { + out, _, err := runEntire(t, env, name) + if err != nil || !strings.Contains(out, name+"-ran-v0.1.0") { + t.Errorf("entire %s = %q, %v", name, out, err) + } + } + + // Remove guard: semy is required by brainy. + _, stderr, err = runEntire(t, env, "plugin", "remove", "semy") + if err == nil { + t.Fatal("remove of depended-on plugin succeeded without --force") + } + if !strings.Contains(stderr, "brainy") { + t.Errorf("remove stderr = %q, want dependent named", stderr) + } + if _, _, err := runEntire(t, env, "plugin", "remove", "semy", "--force"); err != nil { + t.Errorf("remove --force failed: %v", err) + } + + // Doctor now reports the missing dependency, exit code 1. + stdout, _, err = runEntire(t, env, "plugin", "doctor") + if err == nil { + t.Error("doctor exit code = 0 with missing dependency, want failure") + } + if !strings.Contains(stdout, `requires "semy"`) { + t.Errorf("doctor output = %q, want missing-dep report", stdout) + } +} + +func TestPluginSearchAndInfo(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("shell-script plugin payloads are Unix-only") + } + indexURL := newIndexRepo(t, `{"version":1,"plugins":[ + {"name":"alpha","repo_url":"https://example.com/entire-alpha","description":"First letter","official":true}, + {"name":"beta","repo_url":"https://example.com/entire-beta","description":"Second letter"}]}`) + env, _ := pluginTestEnv(t, indexURL) + + stdout, stderr, err := runEntire(t, env, "plugin", "search", "letter") + if err != nil { + t.Fatalf("plugin search: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, "alpha") || !strings.Contains(stdout, "beta") || !strings.Contains(stdout, "[official]") { + t.Errorf("search output = %q", stdout) + } + + stdout, _, err = runEntire(t, env, "plugin", "info", "alpha") + if err != nil { + t.Fatalf("plugin info: %v", err) + } + for _, want := range []string{"First letter", "https://example.com/entire-alpha", "Official: true"} { + if !strings.Contains(stdout, want) { + t.Errorf("info output missing %q: %s", want, stdout) + } + } +} diff --git a/cmd/entire/cli/login.go b/cmd/entire/cli/login.go index a6c2af98f7..44479fdfcf 100644 --- a/cmd/entire/cli/login.go +++ b/cmd/entire/cli/login.go @@ -531,7 +531,7 @@ func openBrowser(ctx context.Context, browserURL string) error { var args []string switch runtime.GOOS { - case "darwin": + case darwinGOOS: command = "open" args = []string{browserURL} case "linux": diff --git a/cmd/entire/cli/plugin_deps.go b/cmd/entire/cli/plugin_deps.go new file mode 100644 index 0000000000..c6519439c7 --- /dev/null +++ b/cmd/entire/cli/plugin_deps.go @@ -0,0 +1,321 @@ +package cli + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "strings" + + "golang.org/x/mod/semver" +) + +// Dependency handling is install-time only. Dispatch stays zero-cost: a +// plugin invokes its deps as entire- via PATH at runtime (the managed +// bin dir is prepended for plugin children), and `entire plugin doctor` +// reports drift after the fact. There is deliberately no version-range +// solver — requirements carry at most a minimum version. + +// maxDepDepth bounds transitive resolution. Plugin graphs are shallow in +// practice; the bound turns a metadata cycle that slips past the visited +// set (e.g. two names aliasing one repo) into an error instead of a hang. +const maxDepDepth = 10 + +// DepAction is one planned dependency install or upgrade. +type DepAction struct { + Name string + RepoURL string + MinVersion string + // Upgrade is true when the dependency is installed but below + // MinVersion; the action reinstalls at the latest tag. + Upgrade bool + // CurrentTag is the installed tag for upgrades. + CurrentTag string + // FromIndex is true when the repo URL came from the index (trusted) + // rather than from another plugin's metadata. + FromIndex bool +} + +// DepPlan is the result of resolving a plugin's transitive requirements. +type DepPlan struct { + Actions []DepAction + // Warnings are non-blocking observations, e.g. a dependency satisfied + // from raw $PATH whose version cannot be verified. + Warnings []string +} + +// PlanDependencyInstalls resolves the transitive requirements of rootReqs +// into ordered install/upgrade actions. Missing dependencies must be +// resolvable to a repo URL via the requirement itself or the index. The +// visited set plus a depth bound make cycles an error path, not a hang. +func PlanDependencyInstalls(ctx context.Context, rootReqs []PluginRequirement, idx *PluginIndex) (*DepPlan, error) { + plan := &DepPlan{} + visited := map[string]bool{} + if err := planDeps(ctx, rootReqs, idx, plan, visited, 0); err != nil { + return nil, err + } + return plan, nil +} + +func planDeps(ctx context.Context, reqs []PluginRequirement, idx *PluginIndex, plan *DepPlan, visited map[string]bool, depth int) error { + if depth > maxDepDepth { + return fmt.Errorf("dependency chain deeper than %d; cycle in plugin metadata?", maxDepDepth) + } + for _, req := range reqs { + if visited[req.Name] { + continue + } + visited[req.Name] = true + + satisfied, warning, err := dependencySatisfied(req) + if err != nil { + return err + } + if warning != "" { + plan.Warnings = append(plan.Warnings, warning) + } + if satisfied { + // An already-satisfied managed dependency can itself have + // gaps (e.g. its own dep was removed with --force since + // install). Walk its recorded requirements — offline, from + // the manifest — so installing a parent repairs the whole + // chain instead of stopping at the first satisfied node. + // PATH/local-dev installs have no manifest; doctor covers + // those. + m, err := LoadPluginManifest(req.Name) + if err != nil { + return err + } + if m != nil { + if err := planDeps(ctx, m.Requires, idx, plan, visited, depth+1); err != nil { + return err + } + } + continue + } + + m, err := LoadPluginManifest(req.Name) + if err != nil { + return err + } + action := DepAction{Name: req.Name, MinVersion: req.MinVersion} + if m != nil { + // Installed but below min_version: upgrade in place from its + // recorded repo. + action.Upgrade = true + action.CurrentTag = m.Tag + action.RepoURL = m.RepoURL + } else { + action.RepoURL = req.RepoURL + if action.RepoURL == "" { + if e := idx.Find(req.Name); e != nil { + action.RepoURL = e.RepoURL + action.FromIndex = true + } + } else if idx.HasRepoURL(action.RepoURL) { + action.FromIndex = true + } + if action.RepoURL == "" { + return fmt.Errorf("dependency %q is not installed and has no repo URL (not in the plugin index either); add repo_url to the requirement or install it manually", req.Name) + } + } + plan.Actions = append(plan.Actions, action) + + // Recurse into what the dependency itself requires, using metadata + // only — nothing is downloaded during planning. Inspection + // failures don't fail the plan (the install will surface hard + // errors itself), but they must not be silent either: a confirmed + // plan that quietly omitted nested dependencies would look + // complete while leaving gaps for doctor to find. + tag := "" + if tags, err := listRemoteSemverTags(ctx, action.RepoURL); err == nil && len(tags) > 0 { + tag = tags[0] + } + if tag == "" { + plan.Warnings = append(plan.Warnings, fmt.Sprintf("could not list tags for %q (%s); its own dependencies were not inspected — 'entire plugin doctor' will report any gaps", req.Name, action.RepoURL)) + continue + } + meta, err := fetchPluginMetadataAtTag(ctx, action.RepoURL, tag) + if err != nil { + plan.Warnings = append(plan.Warnings, fmt.Sprintf("could not read %s for %q at %s; its own dependencies were not inspected — 'entire plugin doctor' will report any gaps", pluginMetadataFileName, req.Name, tag)) + continue + } + if meta == nil { + continue // no metadata file: no declared dependencies + } + if err := planDeps(ctx, meta.Requires, idx, plan, visited, depth+1); err != nil { + return err + } + } + return nil +} + +// dependencySatisfied checks whether a requirement is already met, in +// order: managed install with manifest (version-checkable) → managed entry +// without manifest (local-dev, version unknown) → raw $PATH (version +// unknown). Note `entire plugin ` runs with the user's original PATH +// (main.go restores it for built-ins), so LookPath here sees only +// raw-PATH plugins — managed entries are checked explicitly first. +func dependencySatisfied(req PluginRequirement) (satisfied bool, warning string, err error) { + m, err := LoadPluginManifest(req.Name) + if err != nil { + return false, "", err + } + if m != nil { + if req.MinVersion == "" || semver.Compare(canonicalSemver(m.Tag), canonicalSemver(req.MinVersion)) >= 0 { + return true, "", nil + } + return false, "", nil // triggers the upgrade path in planDeps + } + installed, err := FindInstalledPlugin(req.Name) + if err != nil { + return false, "", err + } + if installed != nil { + return true, unverifiableVersionWarning(req, "a local-dev install"), nil + } + if _, err := exec.LookPath(pluginBinaryPrefix + req.Name); err == nil { + return true, unverifiableVersionWarning(req, "$PATH"), nil + } + return false, "", nil +} + +func unverifiableVersionWarning(req PluginRequirement, source string) string { + if req.MinVersion == "" { + return "" + } + return fmt.Sprintf("dependency %q is satisfied from %s; cannot verify min_version %s", req.Name, source, req.MinVersion) +} + +// ExecuteDepPlan runs the planned installs. Upgrades pass Force. +func ExecuteDepPlan(ctx context.Context, plan *DepPlan) error { + for _, a := range plan.Actions { + if _, err := InstallPluginFromRepo(ctx, RemoteInstallOptions{RepoURL: a.RepoURL, Force: a.Upgrade}); err != nil { + return fmt.Errorf("install dependency %q: %w", a.Name, err) + } + } + return nil +} + +// DependentsOf returns the names of managed plugins whose manifests list +// name as a requirement — the remove guard. Computed by scanning +// pkg/*/manifest.yml; no extra state to maintain. +func DependentsOf(name string) ([]string, error) { + manifests, err := ListPluginManifests() + if err != nil { + return nil, err + } + var out []string + for _, m := range manifests { + for _, req := range m.Requires { + if req.Name == name { + out = append(out, m.Name) + break + } + } + } + return out, nil +} + +// PluginDoctorIssue is one problem found by RunPluginDoctor. +type PluginDoctorIssue struct { + Plugin string + Problem string + Fix string +} + +// RunPluginDoctor checks every managed plugin: bin entry present, dangling +// local-dev symlinks, dependency presence and min_versions, and (macOS) a +// quarantine attribute that would block execution. +func RunPluginDoctor(ctx context.Context) ([]PluginDoctorIssue, error) { + var issues []PluginDoctorIssue + + installed, err := ListInstalledPlugins() + if err != nil { + return nil, err + } + installedByName := map[string]*InstalledPlugin{} + for _, p := range installed { + installedByName[p.Name] = p + if p.Symlink { + if _, err := exec.LookPath(p.Path); err != nil { + issues = append(issues, PluginDoctorIssue{ + Plugin: p.Name, + Problem: "managed entry is a dangling or non-executable link to " + p.LinkTarget, + Fix: "rebuild the target or run: entire plugin remove " + p.Name, + }) + } + } + if q := quarantinedPath(ctx, p.Path); q != "" { + issues = append(issues, PluginDoctorIssue{ + Plugin: p.Name, + Problem: "binary carries the macOS quarantine attribute; Gatekeeper will block it", + Fix: "run: xattr -d com.apple.quarantine " + q, + }) + } + } + + manifests, err := ListPluginManifests() + if err != nil { + return nil, err + } + for _, m := range manifests { + if installedByName[m.Name] == nil { + issues = append(issues, PluginDoctorIssue{ + Plugin: m.Name, + Problem: "has an install manifest but no entry in the managed bin dir", + Fix: fmt.Sprintf("reinstall: entire plugin install %s --force", m.RepoURL), + }) + } + for _, req := range m.Requires { + satisfied, warning, err := dependencySatisfied(req) + if err != nil { + return nil, err + } + switch { + case satisfied && warning != "": + issues = append(issues, PluginDoctorIssue{Plugin: m.Name, Problem: warning, Fix: ""}) + case !satisfied: + dep, depErr := LoadPluginManifest(req.Name) + if depErr != nil { + return nil, depErr + } + if dep != nil { + issues = append(issues, PluginDoctorIssue{ + Plugin: m.Name, + Problem: fmt.Sprintf("requires %s >= %s but %s is installed", req.Name, req.MinVersion, dep.Tag), + Fix: "run: entire plugin upgrade " + req.Name, + }) + } else { + issues = append(issues, PluginDoctorIssue{ + Plugin: m.Name, + Problem: fmt.Sprintf("requires %q, which is not installed", req.Name), + Fix: "run: entire plugin install " + req.Name, + }) + } + } + } + } + return issues, nil +} + +// quarantinedPath returns the path to flag when the file (or its symlink +// target) carries com.apple.quarantine. Best-effort, macOS only; any error +// means "not quarantined". The attribute appears when a user manually +// drops a browser-downloaded binary into the managed dir — CLI downloads +// don't set it. +// +// Checking the bin entry is sufficient even though remote installs link +// bin/entire- to the real binary under pkg/: macOS xattr follows +// symlinks by default (-s is the flag to act on the link itself), so both +// the probe here and the suggested `xattr -d` fix operate on the target. +func quarantinedPath(ctx context.Context, path string) string { + if runtime.GOOS != darwinGOOS { + return "" + } + out, err := exec.CommandContext(ctx, "xattr", "-p", "com.apple.quarantine", path).Output() + if err != nil || strings.TrimSpace(string(out)) == "" { + return "" + } + return path +} diff --git a/cmd/entire/cli/plugin_deps_test.go b/cmd/entire/cli/plugin_deps_test.go new file mode 100644 index 0000000000..d5715c7fee --- /dev/null +++ b/cmd/entire/cli/plugin_deps_test.go @@ -0,0 +1,221 @@ +package cli + +import ( + "context" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// depTestOldTag is the below-min_version tag used across dependency tests. +const depTestOldTag = "v0.1.0" + +// withIsolatedPath shrinks $PATH to git's dir plus the system basics, so a +// developer whose shell PATH includes a real managed plugin dir (the normal +// case for anyone using entire plugins) can't leak entire-* binaries into +// dependencySatisfied's LookPath fallback. +func withIsolatedPath(t *testing.T) { + t.Helper() + gitPath, err := exec.LookPath("git") + if err != nil { + t.Skip("git not on PATH") + } + t.Setenv("PATH", strings.Join([]string{filepath.Dir(gitPath), "/usr/bin", "/bin"}, string(filepath.ListSeparator))) +} + +func TestDependentsOf(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + mustSave := func(m *PluginManifest) { + t.Helper() + if err := SavePluginManifest(m); err != nil { + t.Fatal(err) + } + } + mustSave(&PluginManifest{Name: "brain", RepoURL: "https://x.example/entire-brain", Tag: "v1.0.0", + Requires: []PluginRequirement{{Name: "sem"}}}) + mustSave(&PluginManifest{Name: "viz", RepoURL: "https://x.example/entire-viz", Tag: "v1.0.0", + Requires: []PluginRequirement{{Name: "sem"}, {Name: "run"}}}) + mustSave(&PluginManifest{Name: "sem", RepoURL: "https://x.example/entire-sem", Tag: "v1.0.0"}) + + deps, err := DependentsOf("sem") + if err != nil { + t.Fatalf("DependentsOf: %v", err) + } + if len(deps) != 2 || deps[0] != "brain" || deps[1] != "viz" { + t.Errorf("DependentsOf(sem) = %v, want [brain viz]", deps) + } + if deps, err := DependentsOf("brain"); err != nil || len(deps) != 0 { + t.Errorf("DependentsOf(brain) = %v, want none", deps) + } +} + +func TestDependencySatisfied_ManagedManifest(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + if err := SavePluginManifest(&PluginManifest{Name: "sem", RepoURL: "https://x.example/entire-sem", Tag: "v0.3.0"}); err != nil { + t.Fatal(err) + } + ok, warn, err := dependencySatisfied(PluginRequirement{Name: "sem", MinVersion: "v0.2.0"}) + if err != nil || !ok || warn != "" { + t.Errorf("satisfied above min: ok=%t warn=%q err=%v", ok, warn, err) + } + ok, _, err = dependencySatisfied(PluginRequirement{Name: "sem", MinVersion: "v0.4.0"}) + if err != nil || ok { + t.Errorf("below min must be unsatisfied: ok=%t err=%v", ok, err) + } + ok, _, err = dependencySatisfied(PluginRequirement{Name: "ghost"}) + if err != nil || ok { + t.Errorf("missing dep must be unsatisfied: ok=%t err=%v", ok, err) + } +} + +func TestPlanDependencyInstalls(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + ctx := context.Background() + + // sem installed but old; run missing and resolvable via index; + // path-only is satisfied via local-dev managed entry (no manifest). + if err := SavePluginManifest(&PluginManifest{Name: "sem", RepoURL: "https://x.example/entire-sem", Tag: depTestOldTag}); err != nil { + t.Fatal(err) + } + idx := &PluginIndex{Version: 1, Plugins: []PluginIndexEntry{ + {Name: "run", RepoURL: newTaggedPluginRepo(t, "", "v1.0.0")}, + }} + + plan, err := PlanDependencyInstalls(ctx, []PluginRequirement{ + {Name: "sem", MinVersion: "v0.2.0"}, + {Name: "run"}, + }, idx) + if err != nil { + t.Fatalf("PlanDependencyInstalls: %v", err) + } + if len(plan.Actions) != 2 { + t.Fatalf("actions = %+v, want 2", plan.Actions) + } + bySemName := map[string]DepAction{} + for _, a := range plan.Actions { + bySemName[a.Name] = a + } + if a := bySemName["sem"]; !a.Upgrade || a.CurrentTag != depTestOldTag || a.RepoURL != "https://x.example/entire-sem" { + t.Errorf("sem action = %+v, want upgrade from its recorded repo", a) + } + if a := bySemName["run"]; a.Upgrade || !a.FromIndex { + t.Errorf("run action = %+v, want fresh install from index", a) + } +} + +func TestPlanDependencyInstalls_UnresolvableDep(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + _, err := PlanDependencyInstalls(context.Background(), []PluginRequirement{{Name: "mystery"}}, &PluginIndex{Version: 1}) + if err == nil || !strings.Contains(err.Error(), "mystery") { + t.Errorf("err = %v, want unresolvable-dependency error naming the plugin", err) + } +} + +func TestPlanDependencyInstalls_VisitedSetBreaksCycles(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + // a requires b; b requires a. Both missing, both resolvable. The + // visited set must terminate planning with each appearing once. + repoA := newTaggedPluginRepo(t, "name: cyca\nrequires:\n - name: cycb\n", "v1.0.0") + repoB := newTaggedPluginRepo(t, "name: cycb\nrequires:\n - name: cyca\n", "v1.0.0") + idx := &PluginIndex{Version: 1, Plugins: []PluginIndexEntry{ + {Name: "cyca", RepoURL: repoA}, + {Name: "cycb", RepoURL: repoB}, + }} + plan, err := PlanDependencyInstalls(context.Background(), []PluginRequirement{{Name: "cyca"}}, idx) + if err != nil { + t.Fatalf("PlanDependencyInstalls: %v", err) + } + if len(plan.Actions) != 2 { + t.Errorf("actions = %+v, want exactly [cyca cycb]", plan.Actions) + } +} + +func TestRunPluginDoctor_FlagsMissingAndOutdatedDeps(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + if err := SavePluginManifest(&PluginManifest{Name: "brain", RepoURL: "https://x.example/entire-brain", Tag: "v1.0.0", + Requires: []PluginRequirement{{Name: "sem", MinVersion: "v0.2.0"}, {Name: "ghost"}}}); err != nil { + t.Fatal(err) + } + if err := SavePluginManifest(&PluginManifest{Name: "sem", RepoURL: "https://x.example/entire-sem", Tag: depTestOldTag}); err != nil { + t.Fatal(err) + } + issues, err := RunPluginDoctor(context.Background()) + if err != nil { + t.Fatalf("RunPluginDoctor: %v", err) + } + var problems []string + for _, i := range issues { + problems = append(problems, i.Problem) + } + joined := strings.Join(problems, " | ") + if !strings.Contains(joined, "requires sem >= v0.2.0") { + t.Errorf("doctor missed outdated dep: %s", joined) + } + if !strings.Contains(joined, `requires "ghost"`) { + t.Errorf("doctor missed missing dep: %s", joined) + } + // brain and sem have manifests but no bin entries in this synthetic + // setup — doctor flags that too. + if !strings.Contains(joined, "no entry in the managed bin dir") { + t.Errorf("doctor missed manifest-without-bin: %s", joined) + } +} + +func TestPlanDependencyInstalls_WalksSatisfiedTransitives(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + // sem is installed and satisfied, but its own recorded requirement + // ("leaf") is missing — e.g. removed with --force after install. + // Planning a parent that requires sem must surface leaf. + if err := SavePluginManifest(&PluginManifest{ + Name: "sem", RepoURL: "https://x.example/entire-sem", Tag: "v1.0.0", + Requires: []PluginRequirement{{Name: "leaf"}}, + }); err != nil { + t.Fatal(err) + } + idx := &PluginIndex{Version: 1, Plugins: []PluginIndexEntry{ + {Name: "leaf", RepoURL: newTaggedPluginRepo(t, "", "v1.0.0")}, + }} + plan, err := PlanDependencyInstalls(context.Background(), []PluginRequirement{{Name: "sem"}}, idx) + if err != nil { + t.Fatalf("PlanDependencyInstalls: %v", err) + } + if len(plan.Actions) != 1 || plan.Actions[0].Name != "leaf" { + t.Errorf("actions = %+v, want [leaf] via satisfied sem's manifest", plan.Actions) + } +} + +func TestPlanDependencyInstalls_WarnsOnUninspectableDep(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + // The dependency's repo has no tags, so its own requirements can't be + // inspected during planning. The action must still be planned, with a + // warning instead of silence. + untaggedRepo := newTaggedPluginRepo(t, "") // commit, no tags + idx := &PluginIndex{Version: 1, Plugins: []PluginIndexEntry{ + {Name: "leaf", RepoURL: untaggedRepo}, + }} + plan, err := PlanDependencyInstalls(context.Background(), []PluginRequirement{{Name: "leaf"}}, idx) + if err != nil { + t.Fatalf("PlanDependencyInstalls: %v", err) + } + if len(plan.Actions) != 1 || plan.Actions[0].Name != "leaf" { + t.Fatalf("actions = %+v, want [leaf]", plan.Actions) + } + found := false + for _, w := range plan.Warnings { + if strings.Contains(w, "leaf") && strings.Contains(w, "not inspected") { + found = true + } + } + if !found { + t.Errorf("warnings = %v, want uninspectable-dep warning naming leaf", plan.Warnings) + } +} diff --git a/cmd/entire/cli/plugin_fetch.go b/cmd/entire/cli/plugin_fetch.go new file mode 100644 index 0000000000..e823bd793a --- /dev/null +++ b/cmd/entire/cli/plugin_fetch.go @@ -0,0 +1,436 @@ +package cli + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "runtime" + "strings" + "time" +) + +// Release-asset download. The one irreducibly forge-specific piece of the +// plugin system is where release binaries live; it's contained here as a +// small per-host URL convention table with a declarative escape hatch +// (download_url in entire-plugin.yml). Everything else — version listing, +// metadata, the index — runs on the git protocol. + +// errAssetNotFound distinguishes "this tag has no matching asset" (worth +// trying the next-highest tag — a pushed tag whose release isn't published +// yet) from transport errors (not worth retrying on another tag). +var errAssetNotFound = errors.New("no matching release asset") + +// pluginHTTPClient bounds the total time for a single asset download. +// Plugins can be tens of MB; 5 minutes is generous even on slow links. +var pluginHTTPClient = &http.Client{Timeout: 5 * time.Minute} + +// maxPluginAssetSize caps a downloaded asset. Real plugin binaries are tens +// of MB; the cap exists so a misconfigured URL serving an endless stream +// can't fill the disk. +const maxPluginAssetSize = 512 << 20 // 512 MiB + +// releaseAssetBaseURL returns the URL prefix release assets live under for +// the repo's host. GitHub and the Gitea family share a convention; GitLab +// has its own. Unknown hosts default to the GitHub-style convention (most +// self-hosted forges mirror it) — authors on hosts that don't can declare +// download_url in entire-plugin.yml. +func releaseAssetBaseURL(repoURL, tag string) (string, error) { + u, err := url.Parse(strings.TrimSuffix(repoURL, ".git")) + if err != nil { + return "", fmt.Errorf("parse repo URL: %w", err) + } + if u.Scheme != "https" && u.Scheme != "http" { + return "", fmt.Errorf("release downloads need an http(s) repo URL, got %q (declare download_url in %s for non-HTTP remotes)", repoURL, pluginMetadataFileName) + } + base := strings.TrimSuffix(u.String(), "/") + host := strings.ToLower(u.Hostname()) + if host == "gitlab.com" || strings.HasPrefix(host, "gitlab.") { + return base + "/-/releases/" + url.PathEscape(tag) + "/downloads/", nil + } + return base + "/releases/download/" + url.PathEscape(tag) + "/", nil +} + +// expandDownloadTemplate expands the author-declared download_url template. +// Placeholders: {name} {tag} {version} {os} {arch} {asset}. +func expandDownloadTemplate(tmpl, name, tag, asset string) string { + r := strings.NewReplacer( + "{name}", name, + "{tag}", tag, + "{version}", strings.TrimPrefix(tag, "v"), + "{os}", runtime.GOOS, + "{arch}", runtime.GOARCH, + "{asset}", asset, + ) + return r.Replace(tmpl) +} + +// archAliases maps a GOARCH to the spellings seen in release asset names. +func archAliases(goarch string) []string { + switch goarch { + case "amd64": + return []string{"amd64", "x86_64"} + case "arm64": + return []string{"arm64", "aarch64"} + default: + return []string{goarch} + } +} + +// osAliases maps a GOOS to asset-name spellings, most specific first. +// "all" covers macOS universal binaries. +func osAliases(goos string) []string { + if goos == darwinGOOS { + return []string{"darwin", "macos", "all"} + } + return []string{goos} +} + +// assetCandidates returns release asset filenames to try for this platform, +// in preference order: archives before raw binaries, version-in-name +// (goreleaser's default template) before version-less. +func assetCandidates(name, tag string) []string { + binName := pluginBinaryPrefix + name + version := strings.TrimPrefix(tag, "v") + stems := make([]string, 0, 16) + for _, osName := range osAliases(runtime.GOOS) { + for _, arch := range archAliases(runtime.GOARCH) { + stems = append(stems, + fmt.Sprintf("%s_%s_%s_%s", binName, version, osName, arch), + fmt.Sprintf("%s_%s_%s", binName, osName, arch), + ) + } + } + exts := []string{".tar.gz", ".zip", ""} + if runtime.GOOS == windowsGOOS { + exts = []string{".zip", ".tar.gz", ".exe"} + } + var out []string + for _, ext := range exts { + for _, stem := range stems { + out = append(out, stem+ext) + } + } + return out +} + +// checksumCandidates returns filenames the checksum manifest may go by. +func checksumCandidates(name, tag string) []string { + binName := pluginBinaryPrefix + name + version := strings.TrimPrefix(tag, "v") + return []string{ + "checksums.txt", + fmt.Sprintf("%s_%s_checksums.txt", binName, version), + binName + "_checksums.txt", + } +} + +// parseChecksums parses "hex filename" lines (sha256sum format) into a +// filename → digest map. +func parseChecksums(data []byte) map[string]string { + out := make(map[string]string) + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + // sha256sum marks binary-mode files with a leading *. + out[strings.TrimPrefix(fields[1], "*")] = strings.ToLower(fields[0]) + } + return out +} + +// selectAssetFromChecksums picks the preferred candidate present in the +// checksum manifest. The manifest lists what was actually published, so +// this avoids probing candidate URLs one by one. +func selectAssetFromChecksums(sums map[string]string, name, tag string) (asset, digest string, ok bool) { + for _, c := range assetCandidates(name, tag) { + if d, present := sums[c]; present { + return c, d, true + } + } + return "", "", false +} + +// fetchedAsset is the result of downloadPluginAsset: a verified asset on +// disk in a caller-owned staging dir. +type fetchedAsset struct { + // Path is the downloaded asset file inside the staging dir. + Path string + // Asset is the asset filename that matched. + Asset string + // SHA256 is the hex digest of the downloaded bytes. + SHA256 string +} + +// downloadPluginAsset locates and downloads the release asset for name@tag +// into stagingDir, verifying against the release's checksum manifest when +// one is published. Returns errAssetNotFound (possibly wrapped) when the +// tag has no asset for this platform. +func downloadPluginAsset(ctx context.Context, meta *PluginMetadata, repoURL, name, tag, stagingDir string) (*fetchedAsset, error) { + assetURL := func(asset string) (string, error) { + if meta != nil && meta.DownloadURL != "" { + return expandDownloadTemplate(meta.DownloadURL, name, tag, asset), nil + } + base, err := releaseAssetBaseURL(repoURL, tag) + if err != nil { + return "", err + } + return base + asset, nil + } + + // A download_url template without {asset} is a single fully-specified + // URL; there is nothing to select. + if meta != nil && meta.DownloadURL != "" && !strings.Contains(meta.DownloadURL, "{asset}") { + u := expandDownloadTemplate(meta.DownloadURL, name, tag, "") + return fetchAndVerify(ctx, u, path.Base(u), "", stagingDir) + } + + // Preferred path: fetch the checksum manifest and pick from what was + // actually published. + for _, cs := range checksumCandidates(name, tag) { + u, err := assetURL(cs) + if err != nil { + return nil, err + } + data, err := httpGetSmall(ctx, u) + if err != nil { + continue + } + asset, digest, ok := selectAssetFromChecksums(parseChecksums(data), name, tag) + if !ok { + // This manifest lists nothing for the platform. Keep going: a + // stale or hand-written root checksums.txt must not mask a + // goreleaser manifest under another candidate name, or a + // published asset still reachable by the probe fallback. + // Falling through doesn't weaken verification — an attacker + // who controls the manifest could list a malicious digest + // directly. + continue + } + au, err := assetURL(asset) + if err != nil { + return nil, err + } + return fetchAndVerify(ctx, au, asset, digest, stagingDir) + } + + // Fallback: probe candidates directly (release without a checksum + // manifest). No digest to verify against; the manifest records the + // digest we computed so upgrades can at least detect drift. + for _, asset := range assetCandidates(name, tag) { + u, err := assetURL(asset) + if err != nil { + return nil, err + } + fa, err := fetchAndVerify(ctx, u, asset, "", stagingDir) + if err == nil { + return fa, nil + } + if !errors.Is(err, errAssetNotFound) { + return nil, err + } + } + return nil, fmt.Errorf("%w for %s/%s at %s %s", errAssetNotFound, runtime.GOOS, runtime.GOARCH, repoURL, tag) +} + +// httpGetSmall fetches a small text resource (checksum manifests). +func httpGetSmall(ctx context.Context, rawURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, fmt.Errorf("build request for %s: %w", rawURL, err) + } + resp, err := pluginHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", rawURL, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: %s", rawURL, resp.Status) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("read %s: %w", rawURL, err) + } + return data, nil +} + +// fetchAndVerify downloads rawURL into stagingDir, streaming the SHA-256 as +// it writes. A non-empty wantDigest is enforced; an empty one records the +// computed digest. 404/410 map to errAssetNotFound. Partial files are +// removed on verification failure so a hostile or truncated download never +// lingers in staging. +func fetchAndVerify(ctx context.Context, rawURL, asset, wantDigest, stagingDir string) (*fetchedAsset, error) { + // The asset name is normally one of our own candidates, but the + // single-URL download_url template path derives it from the URL and a + // future caller could pass a name straight from a remote manifest. + // Refuse anything that wouldn't stay inside stagingDir. Backslash is + // rejected on every platform — it's a separator on Windows, and a + // literal backslash in an asset name is never legitimate. + if asset == "" || asset == "." || asset == ".." || + strings.ContainsAny(asset, `/\`) || asset != filepath.Base(asset) { + return nil, fmt.Errorf("unsafe release asset name %q", asset) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, fmt.Errorf("build request for %s: %w", rawURL, err) + } + resp, err := pluginHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("download %s: %w", rawURL, err) + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotFound, http.StatusGone: + return nil, fmt.Errorf("%w: GET %s: %s", errAssetNotFound, rawURL, resp.Status) + default: + return nil, fmt.Errorf("download %s: %s", rawURL, resp.Status) + } + + dest := filepath.Join(stagingDir, asset) + out, err := os.Create(dest) //nolint:gosec // dest is inside the caller-owned staging dir; asset name came from our candidate list or checksum manifest + if err != nil { + return nil, fmt.Errorf("create staging file: %w", err) + } + h := sha256.New() + n, err := io.Copy(io.MultiWriter(out, h), io.LimitReader(resp.Body, maxPluginAssetSize+1)) + closeErr := out.Close() + if err != nil { + _ = os.Remove(dest) + return nil, fmt.Errorf("download %s: %w", rawURL, err) + } + if closeErr != nil { + _ = os.Remove(dest) + return nil, fmt.Errorf("write staging file: %w", closeErr) + } + if n > maxPluginAssetSize { + _ = os.Remove(dest) + return nil, fmt.Errorf("download %s: exceeds %d byte limit", rawURL, int64(maxPluginAssetSize)) + } + got := hex.EncodeToString(h.Sum(nil)) + if wantDigest != "" && !strings.EqualFold(got, wantDigest) { + _ = os.Remove(dest) + return nil, fmt.Errorf("checksum mismatch for %s: got %s, want %s", asset, got, wantDigest) + } + return &fetchedAsset{Path: dest, Asset: asset, SHA256: got}, nil +} + +// extractPluginBinary locates the plugin executable inside the downloaded +// asset and writes it to destPath (mode 0755 on Unix). Raw binaries are +// copied; .tar.gz/.tgz and .zip archives are searched for an entry whose +// basename matches entire-[.exe], wherever it sits in the archive. +func extractPluginBinary(assetPath, name, destPath string) error { + binName := pluginBinaryPrefix + name + lower := strings.ToLower(assetPath) + switch { + case strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz"): + return extractFromTarGz(assetPath, binName, destPath) + case strings.HasSuffix(lower, ".zip"): + return extractFromZip(assetPath, binName, destPath) + default: + src, err := os.Open(assetPath) //nolint:gosec // staging-dir file we just wrote + if err != nil { + return fmt.Errorf("open asset: %w", err) + } + defer src.Close() + return writeExecutable(src, destPath) + } +} + +// matchesPluginBinary reports whether an archive entry basename is the +// plugin binary, tolerating a Windows extension. +func matchesPluginBinary(entryName, binName string) bool { + base := path.Base(filepath.ToSlash(entryName)) + if base == binName { + return true + } + return runtime.GOOS == windowsGOOS && strings.EqualFold(base, binName+".exe") +} + +// safeArchiveEntry rejects entry names that could escape an extraction +// root. We only ever extract a single matched file to a fixed dest, so this +// is defense in depth against a hostile archive masking as a plugin. +func safeArchiveEntry(entryName string) bool { + clean := path.Clean(filepath.ToSlash(entryName)) + return !strings.HasPrefix(clean, "../") && !path.IsAbs(clean) && !strings.Contains(entryName, "\x00") +} + +func extractFromTarGz(archivePath, binName, destPath string) error { + f, err := os.Open(archivePath) //nolint:gosec // staging-dir file we just wrote + if err != nil { + return fmt.Errorf("open archive: %w", err) + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("open gzip: %w", err) + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("read tar: %w", err) + } + if hdr.Typeflag != tar.TypeReg || !safeArchiveEntry(hdr.Name) || !matchesPluginBinary(hdr.Name, binName) { + continue + } + return writeExecutable(io.LimitReader(tr, maxPluginAssetSize), destPath) + } + return fmt.Errorf("archive %s contains no %s entry", filepath.Base(archivePath), binName) +} + +func extractFromZip(archivePath, binName, destPath string) error { + zr, err := zip.OpenReader(archivePath) + if err != nil { + return fmt.Errorf("open zip: %w", err) + } + defer zr.Close() + for _, f := range zr.File { + if f.FileInfo().IsDir() || !safeArchiveEntry(f.Name) || !matchesPluginBinary(f.Name, binName) { + continue + } + rc, err := f.Open() + if err != nil { + return fmt.Errorf("open zip entry: %w", err) + } + defer rc.Close() + return writeExecutable(io.LimitReader(rc, maxPluginAssetSize), destPath) + } + return fmt.Errorf("archive %s contains no %s entry", filepath.Base(archivePath), binName) +} + +// writeExecutable writes r to destPath with the executable bit set. +// Explicit chmod (not inherited archive mode) because zip-built and +// raw-binary releases routinely lose the bit — the #1 "downloaded plugin +// doesn't run" failure. +func writeExecutable(r io.Reader, destPath string) error { + out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) //nolint:gosec // dest is inside the managed pkg tree; the binary must be executable + if err != nil { + return fmt.Errorf("create binary: %w", err) + } + if _, err := io.Copy(out, r); err != nil { + _ = out.Close() + _ = os.Remove(destPath) + return fmt.Errorf("write binary: %w", err) + } + if err := out.Close(); err != nil { + _ = os.Remove(destPath) + return fmt.Errorf("close binary: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/plugin_fetch_test.go b/cmd/entire/cli/plugin_fetch_test.go new file mode 100644 index 0000000000..a12941929e --- /dev/null +++ b/cmd/entire/cli/plugin_fetch_test.go @@ -0,0 +1,427 @@ +package cli + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestReleaseAssetBaseURL(t *testing.T) { + t.Parallel() + tests := []struct { + repo, tag, want string + wantErr bool + }{ + {repo: "https://github.com/entireio/entire-run", tag: "v1.0.0", want: "https://github.com/entireio/entire-run/releases/download/v1.0.0/"}, + {repo: "https://github.com/entireio/entire-run.git", tag: "v1.0.0", want: "https://github.com/entireio/entire-run/releases/download/v1.0.0/"}, + {repo: "https://gitlab.com/group/entire-foo", tag: "v2.1.0", want: "https://gitlab.com/group/entire-foo/-/releases/v2.1.0/downloads/"}, + {repo: "https://gitlab.example.com/group/entire-foo", tag: "v2.1.0", want: "https://gitlab.example.com/group/entire-foo/-/releases/v2.1.0/downloads/"}, + {repo: "https://codeberg.org/me/entire-bar", tag: "v0.1.0", want: "https://codeberg.org/me/entire-bar/releases/download/v0.1.0/"}, + // Unknown hosts default to the GitHub-style convention. + {repo: "https://git.example.com/me/entire-bar", tag: "v0.1.0", want: "https://git.example.com/me/entire-bar/releases/download/v0.1.0/"}, + // Non-HTTP remotes can't derive a download URL. + {repo: "git@github.com:entireio/entire-run.git", tag: "v1.0.0", wantErr: true}, + {repo: "ssh://git@example.com/entire-run", tag: "v1.0.0", wantErr: true}, + } + for _, tt := range tests { + got, err := releaseAssetBaseURL(tt.repo, tt.tag) + if tt.wantErr { + if err == nil { + t.Errorf("releaseAssetBaseURL(%q) = %q, want error", tt.repo, got) + } + continue + } + if err != nil { + t.Errorf("releaseAssetBaseURL(%q): %v", tt.repo, err) + continue + } + if got != tt.want { + t.Errorf("releaseAssetBaseURL(%q) = %q, want %q", tt.repo, got, tt.want) + } + } +} + +func TestExpandDownloadTemplate(t *testing.T) { + t.Parallel() + got := expandDownloadTemplate("https://dl.example.com/{name}/{tag}/{version}/{os}_{arch}/{asset}", "run", "v1.2.3", "a.tar.gz") + want := fmt.Sprintf("https://dl.example.com/run/v1.2.3/1.2.3/%s_%s/a.tar.gz", runtime.GOOS, runtime.GOARCH) + if got != want { + t.Errorf("expandDownloadTemplate = %q, want %q", got, want) + } +} + +func TestAssetCandidates_CoverConventions(t *testing.T) { + t.Parallel() + cands := assetCandidates("run", "v1.2.3") + mustContain := []string{ + fmt.Sprintf("entire-run_1.2.3_%s_%s.tar.gz", runtime.GOOS, runtime.GOARCH), + fmt.Sprintf("entire-run_%s_%s.tar.gz", runtime.GOOS, runtime.GOARCH), + fmt.Sprintf("entire-run_1.2.3_%s_%s.zip", runtime.GOOS, runtime.GOARCH), + } + for _, want := range mustContain { + found := false + for _, c := range cands { + if c == want { + found = true + break + } + } + if !found { + t.Errorf("assetCandidates missing %q in %v", want, cands) + } + } + // Arch aliases: amd64 hosts must also try x86_64 spellings. + if runtime.GOARCH == "amd64" { + found := false + for _, c := range cands { + if strings.Contains(c, "x86_64") { + found = true + break + } + } + if !found { + t.Error("assetCandidates lacks x86_64 alias on amd64") + } + } +} + +func TestParseChecksums_AndSelect(t *testing.T) { + t.Parallel() + osName, arch := runtime.GOOS, runtime.GOARCH + manifest := fmt.Sprintf(` +abc123 entire-run_1.0.0_%s_%s.tar.gz +def456 *entire-run_1.0.0_other_other.zip + +malformed line without two fields maybe three +`, osName, arch) + sums := parseChecksums([]byte(manifest)) + if len(sums) != 2 { + t.Fatalf("parseChecksums = %d entries, want 2: %v", len(sums), sums) + } + asset, digest, ok := selectAssetFromChecksums(sums, "run", "v1.0.0") + if !ok { + t.Fatal("selectAssetFromChecksums found nothing") + } + if digest != "abc123" || !strings.Contains(asset, osName) { + t.Errorf("selected %q/%q, want platform asset with digest abc123", asset, digest) + } + // A manifest with no matching platform asset must report not-ok. + if _, _, ok := selectAssetFromChecksums(map[string]string{"entire-run_1.0.0_plan9_mips.tar.gz": "x"}, "run", "v1.0.0"); ok { + t.Error("selectAssetFromChecksums matched a foreign platform") + } +} + +// makeTarGz builds an in-memory tar.gz with the given entries. +func makeTarGz(t *testing.T, entries map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for name, content := range entries { + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(content)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func makeZip(t *testing.T, entries map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, content := range entries { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(content); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestExtractPluginBinary_TarGz(t *testing.T) { + t.Parallel() + dir := t.TempDir() + archive := filepath.Join(dir, "a.tar.gz") + payload := []byte("#!/bin/sh\necho run\n") + if err := os.WriteFile(archive, makeTarGz(t, map[string][]byte{ + "README.md": []byte("docs"), + "subdir/entire-run": payload, + "../escape-attempt": []byte("nope"), + "unrelated/entire-runner": []byte("close but no"), + }), 0o600); err != nil { + t.Fatal(err) + } + dest := filepath.Join(dir, "out") + if err := extractPluginBinary(archive, "run", dest); err != nil { + t.Fatalf("extractPluginBinary: %v", err) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Errorf("extracted content mismatch") + } + if runtime.GOOS != windowsGOOS { + info, err := os.Stat(dest) + if err != nil { + t.Fatal(err) + } + if info.Mode()&0o111 == 0 { + t.Error("extracted binary is not executable") + } + } +} + +func TestExtractPluginBinary_Zip(t *testing.T) { + t.Parallel() + dir := t.TempDir() + archive := filepath.Join(dir, "a.zip") + payload := []byte("zip payload") + if err := os.WriteFile(archive, makeZip(t, map[string][]byte{"entire-run": payload}), 0o600); err != nil { + t.Fatal(err) + } + dest := filepath.Join(dir, "out") + if err := extractPluginBinary(archive, "run", dest); err != nil { + t.Fatalf("extractPluginBinary: %v", err) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Error("extracted content mismatch") + } +} + +func TestExtractPluginBinary_MissingEntry(t *testing.T) { + t.Parallel() + dir := t.TempDir() + archive := filepath.Join(dir, "a.tar.gz") + if err := os.WriteFile(archive, makeTarGz(t, map[string][]byte{"other": []byte("x")}), 0o600); err != nil { + t.Fatal(err) + } + if err := extractPluginBinary(archive, "run", filepath.Join(dir, "out")); err == nil { + t.Error("extractPluginBinary succeeded on archive without the binary") + } +} + +func TestExtractPluginBinary_RawBinary(t *testing.T) { + t.Parallel() + dir := t.TempDir() + raw := filepath.Join(dir, "entire-run_1.0.0_x_y") + payload := []byte("raw binary bytes") + if err := os.WriteFile(raw, payload, 0o600); err != nil { + t.Fatal(err) + } + dest := filepath.Join(dir, "out") + if err := extractPluginBinary(raw, "run", dest); err != nil { + t.Fatalf("extractPluginBinary: %v", err) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Error("raw copy mismatch") + } +} + +func TestSafeArchiveEntry(t *testing.T) { + t.Parallel() + for entry, want := range map[string]bool{ + "entire-run": true, + "dist/entire-run": true, + "../evil": false, + "a/../../evil": false, + "/abs/path": false, + "with\x00null": false, + "./fine/entire-run": true, + } { + if got := safeArchiveEntry(entry); got != want { + t.Errorf("safeArchiveEntry(%q) = %t, want %t", entry, got, want) + } + } +} + +func TestFetchAndVerify_ChecksumEnforced(t *testing.T) { + t.Parallel() + payload := []byte("plugin bytes") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(payload) //nolint:errcheck // test server write; failure surfaces as a client error + })) + defer srv.Close() + + dir := t.TempDir() + sum := sha256.Sum256(payload) + good := hex.EncodeToString(sum[:]) + + fa, err := fetchAndVerify(context.Background(), srv.URL+"/asset", "asset", good, dir) + if err != nil { + t.Fatalf("fetchAndVerify with good digest: %v", err) + } + if fa.SHA256 != good { + t.Errorf("SHA256 = %s, want %s", fa.SHA256, good) + } + + if _, err := fetchAndVerify(context.Background(), srv.URL+"/asset", "asset2", strings.Repeat("0", 64), dir); err == nil { + t.Error("fetchAndVerify accepted a wrong digest") + } +} + +func TestFetchAndVerify_404IsAssetNotFound(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + _, err := fetchAndVerify(context.Background(), srv.URL+"/missing", "missing", "", t.TempDir()) + if !errors.Is(err, errAssetNotFound) { + t.Errorf("404 error = %v, want errAssetNotFound", err) + } +} + +func TestDownloadPluginAsset_ViaChecksumManifest(t *testing.T) { + t.Parallel() + payload := makeTarGz(t, map[string][]byte{"entire-run": []byte("bin")}) + asset := fmt.Sprintf("entire-run_1.0.0_%s_%s.tar.gz", runtime.GOOS, runtime.GOARCH) + sum := sha256.Sum256(payload) + checksums := hex.EncodeToString(sum[:]) + " " + asset + "\n" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "checksums.txt"): + _, _ = w.Write([]byte(checksums)) //nolint:errcheck // test server write; failure surfaces as a client error + case strings.HasSuffix(r.URL.Path, asset): + _, _ = w.Write(payload) //nolint:errcheck // test server write; failure surfaces as a client error + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + meta := &PluginMetadata{DownloadURL: srv.URL + "/dl/{tag}/{asset}"} + fa, err := downloadPluginAsset(context.Background(), meta, "https://example.invalid/entire-run", "run", "v1.0.0", t.TempDir()) + if err != nil { + t.Fatalf("downloadPluginAsset: %v", err) + } + if fa.Asset != asset { + t.Errorf("Asset = %q, want %q", fa.Asset, asset) + } +} + +func TestDownloadPluginAsset_ProbeFallbackWithoutChecksums(t *testing.T) { + t.Parallel() + payload := makeTarGz(t, map[string][]byte{"entire-run": []byte("bin")}) + asset := fmt.Sprintf("entire-run_1.0.0_%s_%s.tar.gz", runtime.GOOS, runtime.GOARCH) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, asset) { + _, _ = w.Write(payload) //nolint:errcheck // test server write; failure surfaces as a client error + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + meta := &PluginMetadata{DownloadURL: srv.URL + "/dl/{asset}"} + fa, err := downloadPluginAsset(context.Background(), meta, "https://example.invalid/entire-run", "run", "v1.0.0", t.TempDir()) + if err != nil { + t.Fatalf("downloadPluginAsset: %v", err) + } + if fa.Asset != asset { + t.Errorf("Asset = %q, want %q", fa.Asset, asset) + } +} + +func TestDownloadPluginAsset_NoAssetForPlatform(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + meta := &PluginMetadata{DownloadURL: srv.URL + "/dl/{asset}"} + _, err := downloadPluginAsset(context.Background(), meta, "https://example.invalid/entire-run", "run", "v1.0.0", t.TempDir()) + if !errors.Is(err, errAssetNotFound) { + t.Errorf("err = %v, want errAssetNotFound", err) + } +} + +func TestFetchAndVerify_RejectsUnsafeAssetNames(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("x")) //nolint:errcheck // test server write; failure surfaces as a client error + })) + defer srv.Close() + for _, asset := range []string{"", ".", "..", "../escape", "a/b", `a\b`} { + if _, err := fetchAndVerify(context.Background(), srv.URL, asset, "", t.TempDir()); err == nil { + t.Errorf("fetchAndVerify accepted unsafe asset name %q", asset) + } + } +} + +func TestFetchAndVerify_RemovesPartialFileOnMismatch(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("payload")) //nolint:errcheck // test server write; failure surfaces as a client error + })) + defer srv.Close() + dir := t.TempDir() + if _, err := fetchAndVerify(context.Background(), srv.URL+"/a", "a", strings.Repeat("0", 64), dir); err == nil { + t.Fatal("fetchAndVerify accepted a wrong digest") + } + if _, err := os.Stat(filepath.Join(dir, "a")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("partial download left behind after checksum mismatch: stat err = %v", err) + } +} + +func TestDownloadPluginAsset_StaleManifestFallsThroughToProbe(t *testing.T) { + t.Parallel() + // The root checksums.txt lists only a foreign platform, but the real + // asset is published under its conventional name. A stale manifest + // must not block the install: selection falls through to the probe. + payload := makeTarGz(t, map[string][]byte{"entire-run": []byte("bin")}) + asset := fmt.Sprintf("entire-run_1.0.0_%s_%s.tar.gz", runtime.GOOS, runtime.GOARCH) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "checksums.txt"): + _, _ = w.Write([]byte("abc entire-run_1.0.0_plan9_mips.tar.gz\n")) //nolint:errcheck // test server write + case strings.HasSuffix(r.URL.Path, asset): + _, _ = w.Write(payload) //nolint:errcheck // test server write + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + meta := &PluginMetadata{DownloadURL: srv.URL + "/dl/{asset}"} + fa, err := downloadPluginAsset(context.Background(), meta, "https://example.invalid/entire-run", "run", "v1.0.0", t.TempDir()) + if err != nil { + t.Fatalf("downloadPluginAsset with stale manifest: %v", err) + } + if fa.Asset != asset { + t.Errorf("Asset = %q, want %q via probe fallback", fa.Asset, asset) + } +} diff --git a/cmd/entire/cli/plugin_gitremote.go b/cmd/entire/cli/plugin_gitremote.go new file mode 100644 index 0000000000..60c05724f0 --- /dev/null +++ b/cmd/entire/cli/plugin_gitremote.go @@ -0,0 +1,140 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "sort" + "strings" + + "golang.org/x/mod/semver" +) + +// Host-agnostic remote inspection over the git protocol. Version listing +// uses `git ls-remote --tags`, which behaves identically on GitHub, GitLab, +// Gitea/Forgejo, and any self-hosted server, inherits the user's existing +// git auth (SSH agent, credential helpers), and needs no forge REST client. +// The shell-out (vs go-git) is deliberate: this path needs the user's +// credential helpers and proxy config, the same reason hooks.go shells out +// for network-touching operations. + +// gitQuiet returns an exec.Cmd for git with terminal prompts disabled, so +// an auth-requiring remote fails fast instead of hanging a non-interactive +// run on a username prompt. +func gitQuiet(ctx context.Context, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + return cmd +} + +// listRemoteSemverTags returns the repo's semver tags ("v"-prefixed or +// bare), sorted descending by semantic version. Non-semver tags are +// ignored. +func listRemoteSemverTags(ctx context.Context, repoURL string) ([]string, error) { + // --refs suppresses peeled ^{} entries for annotated tags. + out, err := gitQuiet(ctx, "ls-remote", "--tags", "--refs", repoURL).Output() + if err != nil { + return nil, fmt.Errorf("list tags of %s: %w%s", repoURL, err, stderrSuffix(err)) + } + var tags []string + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + tag := strings.TrimPrefix(fields[1], "refs/tags/") + if semver.IsValid(canonicalSemver(tag)) { + tags = append(tags, tag) + } + } + sort.Slice(tags, func(i, j int) bool { + return semver.Compare(canonicalSemver(tags[i]), canonicalSemver(tags[j])) > 0 + }) + return tags, nil +} + +// canonicalSemver maps a tag to the "v"-prefixed form x/mod/semver expects. +func canonicalSemver(tag string) string { + if strings.HasPrefix(tag, "v") { + return tag + } + return "v" + tag +} + +// stderrSuffix extracts captured stderr from an exec.ExitError for error +// messages; git's stderr is where the actionable detail lives. Only +// populated when the command was started via Output(). +func stderrSuffix(err error) string { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { + return ": " + strings.TrimSpace(string(exitErr.Stderr)) + } + return "" +} + +// fetchPluginMetadataAtTag reads entire-plugin.yml at the given tag without +// a user-visible clone: a blobless shallow clone into a discarded temp dir, +// falling back to a plain shallow clone for servers that don't allow +// partial-clone filters (uploadpack.allowFilter defaults to off outside the +// big forges). Returns (nil, nil) when the repo has no metadata file — +// metadata is optional by design. +func fetchPluginMetadataAtTag(ctx context.Context, repoURL, tag string) (*PluginMetadata, error) { + tmp, err := os.MkdirTemp("", "entire-plugin-meta-") + if err != nil { + return nil, fmt.Errorf("create temp dir: %w", err) + } + defer os.RemoveAll(tmp) + + cloneArgs := func(filter bool) []string { + args := []string{"clone", "--depth", "1", "--no-checkout", "--branch", tag, "--quiet"} + if filter { + args = append(args, "--filter=blob:none") + } + return append(args, repoURL, tmp) + } + // Output() (not Run()) so stderr is captured into the ExitError for + // error messages; clone --quiet writes nothing to stdout. + if _, err := gitQuiet(ctx, cloneArgs(true)...).Output(); err != nil { + // Retry without the filter; the clone dir may be half-created. + if rmErr := os.RemoveAll(tmp); rmErr != nil { + return nil, fmt.Errorf("clean temp clone dir: %w", rmErr) + } + if _, err := gitQuiet(ctx, cloneArgs(false)...).Output(); err != nil { + return nil, fmt.Errorf("clone %s at %s: %w%s", repoURL, tag, err, stderrSuffix(err)) + } + } + // `git show :` peels annotated tags and, in a blobless + // clone, lazily fetches just this one blob from the promisor remote. + out, err := gitQuiet(ctx, "-C", tmp, "show", tag+":"+pluginMetadataFileName).Output() + if err != nil { + // Missing file and missing tag both surface as exit 128; only the + // missing-file case is benign. Disambiguate via stderr. + if strings.Contains(stderrSuffix(err), "does not exist") || + strings.Contains(stderrSuffix(err), "exists on disk, but not in") { + return nil, nil //nolint:nilnil // metadata is optional + } + return nil, fmt.Errorf("read %s at %s: %w%s", pluginMetadataFileName, tag, err, stderrSuffix(err)) + } + return ParsePluginMetadata(out) +} + +// pluginNameFromRepoURL derives the bare plugin name from a repository URL +// basename: .../entire-run(.git) → "run". Used when entire-plugin.yml is +// absent or doesn't declare a name. +func pluginNameFromRepoURL(repoURL string) (string, error) { + trimmed := strings.TrimSuffix(strings.TrimSuffix(repoURL, "/"), ".git") + base := trimmed + if i := strings.LastIndexAny(trimmed, "/:"); i >= 0 { + base = trimmed[i+1:] + } + if !strings.HasPrefix(base, pluginBinaryPrefix) { + return "", fmt.Errorf("repository basename %q does not start with %q; declare a name in %s", base, pluginBinaryPrefix, pluginMetadataFileName) + } + name := strings.TrimPrefix(base, pluginBinaryPrefix) + if err := validatePluginName(name); err != nil { + return "", err + } + return name, nil +} diff --git a/cmd/entire/cli/plugin_gitremote_test.go b/cmd/entire/cli/plugin_gitremote_test.go new file mode 100644 index 0000000000..42ffd88110 --- /dev/null +++ b/cmd/entire/cli/plugin_gitremote_test.go @@ -0,0 +1,111 @@ +package cli + +import ( + "context" + "os/exec" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +// newTaggedPluginRepo creates a git repo with entire-plugin.yml (when +// metadata is non-empty) and the given tags, returning its file:// URL. +// file:// (not a bare path) forces git's transport machinery, matching how +// a real remote behaves for ls-remote and shallow clones. +func newTaggedPluginRepo(t *testing.T, metadata string, tags ...string) string { + t.Helper() + dir := t.TempDir() + testutil.InitRepo(t, dir) + if metadata != "" { + testutil.WriteFile(t, dir, pluginMetadataFileName, metadata) + testutil.GitAdd(t, dir, pluginMetadataFileName) + } else { + testutil.WriteFile(t, dir, "README.md", "readme") + testutil.GitAdd(t, dir, "README.md") + } + testutil.GitCommit(t, dir, "init") + for _, tag := range tags { + if out, err := exec.CommandContext(t.Context(), "git", "-C", dir, "tag", tag).CombinedOutput(); err != nil { + t.Fatalf("git tag %s: %v: %s", tag, err, out) + } + } + return "file://" + filepath.ToSlash(dir) +} + +func TestListRemoteSemverTags(t *testing.T) { + t.Parallel() + url := newTaggedPluginRepo(t, "", "v0.1.0", "v0.10.0", "v0.2.0", "not-a-version", "1.0.0") + tags, err := listRemoteSemverTags(context.Background(), url) + if err != nil { + t.Fatalf("listRemoteSemverTags: %v", err) + } + // Bare "1.0.0" is valid semver (canonicalized); "not-a-version" is dropped. + want := []string{"1.0.0", "v0.10.0", "v0.2.0", "v0.1.0"} + if len(tags) != len(want) { + t.Fatalf("tags = %v, want %v", tags, want) + } + for i := range want { + if tags[i] != want[i] { + t.Errorf("tags[%d] = %q, want %q (full: %v)", i, tags[i], want[i], tags) + } + } +} + +func TestListRemoteSemverTags_BadRemote(t *testing.T) { + t.Parallel() + if _, err := listRemoteSemverTags(context.Background(), "file:///nonexistent/repo"); err == nil { + t.Error("listRemoteSemverTags succeeded on missing repo") + } +} + +func TestFetchPluginMetadataAtTag(t *testing.T) { + t.Parallel() + url := newTaggedPluginRepo(t, "name: demo\ndescription: a demo\n", "v1.0.0") + meta, err := fetchPluginMetadataAtTag(context.Background(), url, "v1.0.0") + if err != nil { + t.Fatalf("fetchPluginMetadataAtTag: %v", err) + } + if meta == nil || meta.Name != "demo" { + t.Errorf("meta = %+v, want name demo", meta) + } +} + +func TestFetchPluginMetadataAtTag_NoFileIsNilNil(t *testing.T) { + t.Parallel() + url := newTaggedPluginRepo(t, "", "v1.0.0") + meta, err := fetchPluginMetadataAtTag(context.Background(), url, "v1.0.0") + if err != nil { + t.Fatalf("fetchPluginMetadataAtTag: %v", err) + } + if meta != nil { + t.Errorf("meta = %+v, want nil for repo without %s", meta, pluginMetadataFileName) + } +} + +func TestPluginNameFromRepoURL(t *testing.T) { + t.Parallel() + tests := []struct { + url, want string + wantErr bool + }{ + {url: "https://github.com/entireio/entire-run", want: "run"}, + {url: "https://github.com/entireio/entire-run.git", want: "run"}, + {url: "https://github.com/entireio/entire-run/", want: "run"}, + {url: "git@github.com:entireio/entire-brain.git", want: "brain"}, + {url: "https://github.com/entireio/some-tool", wantErr: true}, + {url: "https://github.com/entireio/entire-agent-x", wantErr: true}, // reserved + } + for _, tt := range tests { + got, err := pluginNameFromRepoURL(tt.url) + if tt.wantErr { + if err == nil { + t.Errorf("pluginNameFromRepoURL(%q) = %q, want error", tt.url, got) + } + continue + } + if err != nil || got != tt.want { + t.Errorf("pluginNameFromRepoURL(%q) = %q, %v; want %q", tt.url, got, err, tt.want) + } + } +} diff --git a/cmd/entire/cli/plugin_group.go b/cmd/entire/cli/plugin_group.go index 64332e012d..2af29e3ffd 100644 --- a/cmd/entire/cli/plugin_group.go +++ b/cmd/entire/cli/plugin_group.go @@ -1,9 +1,17 @@ package cli import ( + "context" + "errors" "fmt" "io" + "os" + "runtime" + "slices" + "strings" + "charm.land/huh/v2" + "github.com/entireio/cli/cmd/entire/cli/interactive" "github.com/spf13/cobra" ) @@ -11,13 +19,10 @@ import ( // dispatcher in plugin.go is the runtime mechanism — these commands manage a // per-user managed directory that the dispatcher discovers because main.go // prepends it to PATH at startup. -// -// Currently only local symlink installs are supported. GitHub-release -// asset and git-clone install paths are deferred until there's demand. func newPluginGroupCmd() *cobra.Command { cmd := &cobra.Command{ Use: "plugin", - Short: "Manage Entire plugins (install, list, remove)", + Short: "Manage Entire plugins (install, list, upgrade, search, remove)", Long: `Manage Entire plugins. Plugins are external executables named 'entire-'. The CLI discovers @@ -31,62 +36,293 @@ precedence: %LOCALAPPDATA%\entire\plugins\bin (Windows, when set) ~\AppData\Local\entire\plugins\bin (Windows fallback when LOCALAPPDATA is unset) -Commands: - install Install a plugin by linking or copying an existing executable - list List plugins installed in the managed directory - remove Remove a plugin from the managed directory +Install sources: + entire plugin install run index lookup + entire plugin install https://github.com/entireio/entire-run repository URL + entire plugin install ./dist/entire-run local executable -Examples: - entire plugin install ./dist/entire-pgr - entire plugin list - entire plugin remove pgr`, +Remote installs resolve the newest semver tag over the git protocol, then +download the platform's release asset (verified against the release's +checksums.txt when published). Discovery uses a git-synced plugin index; +see 'entire plugin search' and 'entire plugin index update'.`, } cmd.AddCommand(newPluginInstallCmd()) cmd.AddCommand(newPluginListCmd()) cmd.AddCommand(newPluginRemoveCmd()) + cmd.AddCommand(newPluginUpgradeCmd()) + cmd.AddCommand(newPluginSearchCmd()) + cmd.AddCommand(newPluginInfoCmd()) + cmd.AddCommand(newPluginBrowseCmd()) + cmd.AddCommand(newPluginDoctorCmd()) + cmd.AddCommand(newPluginIndexCmd()) return cmd } +// installArgKind classifies the install argument. +type installArgKind int + +const ( + installFromPath installArgKind = iota + installFromURL + installFromIndex +) + +// classifyInstallArg distinguishes the three install sources. URLs are +// anything with a scheme or scp-like git@ prefix; paths must be explicit — +// a separator or a leading dot (./entire-foo) — and everything else is a +// bare name for index lookup. Deliberately NOT stat-based: a stray file or +// directory in the CWD sharing a plugin's name must not shadow the index +// (and could never install anyway — path installs require an entire- +// basename). The spaces stay disjoint because validatePluginName rejects +// separators in plugin names. +func classifyInstallArg(arg string) installArgKind { + if strings.Contains(arg, "://") || strings.HasPrefix(arg, "git@") { + return installFromURL + } + if strings.ContainsAny(arg, `/\`) || strings.HasPrefix(arg, ".") { + return installFromPath + } + return installFromIndex +} + func newPluginInstallCmd() *cobra.Command { - var force bool + var force, yes, noDeps bool + var pin, indexFlag string cmd := &cobra.Command{ - Use: "install ", - Short: "Link or copy a plugin executable into the managed directory", - Long: `Link or copy a plugin executable into the managed directory. + Use: "install ", + Short: "Install a plugin from the index, a git repository URL, or a local executable", + Long: `Install a plugin. -The source must be a file whose basename starts with 'entire-' (the -dispatcher only resolves names of that shape). On Unix the file must be -executable. +Three source forms: -The CLI prefers a symlink so rebuilds of the source are reflected -immediately, and falls back to a hardlink, then a copy, if symlinks aren't -available (notably Windows without Developer Mode). + name Bare names resolve through the plugin index: + entire plugin install run + url Full git repository URLs install from any git host. The newest + semver tag is resolved with 'git ls-remote'; the platform's release + asset is downloaded and verified against the release's + checksums.txt when one is published: + entire plugin install https://github.com/entireio/entire-run + path Local executables are linked into the managed directory (symlink + first, so rebuilds are picked up immediately). Paths must be + explicit — a separator or leading ./ — so a stray local file can + never shadow an index name: + entire plugin install ./dist/entire-run + entire plugin install ./entire-run -After install, 'entire ' invokes the plugin via the kubectl-style -dispatcher — the managed directory is auto-prepended to $PATH. +Installing from a URL that is not listed in the plugin index asks for +confirmation; pass --yes to skip (required in non-interactive runs). -Examples: - entire plugin install ./dist/entire-pgr - entire plugin install /usr/local/bin/entire-pgr --force`, +Plugins may declare dependencies in entire-plugin.yml. Missing dependencies +are listed and installed after a single confirmation (or with --yes); +--no-deps opts out.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - p, err := InstallPluginFromPath(InstallPluginOptions{ - SourcePath: args[0], - Force: force, - }) - if err != nil { - return fmt.Errorf("install plugin: %w", err) + ctx := cmd.Context() + arg := args[0] + + if classifyInstallArg(arg) == installFromPath { + p, err := InstallPluginFromPath(InstallPluginOptions{SourcePath: arg, Force: force}) + if err != nil { + return fmt.Errorf("install plugin: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Installed plugin %q → %s\n", p.Name, p.Path) + warnIfShadowsBuiltin(cmd, p.Name) + return nil } - fmt.Fprintf(cmd.OutOrStdout(), "Installed plugin %q → %s\n", p.Name, p.Path) - warnIfShadowsBuiltin(cmd, p.Name) - return nil + return silencePluginCancel(ctx, runRemoteInstall(ctx, cmd, arg, remoteInstallFlags{ + force: force, yes: yes, noDeps: noDeps, pin: pin, index: indexFlag, + })) }, } cmd.Flags().BoolVar(&force, "force", false, "Replace an existing entry with the same name") + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompts (non-index sources, dependency installs)") + cmd.Flags().BoolVar(&noDeps, "no-deps", false, "Do not install declared dependencies") + cmd.Flags().StringVar(&pin, "pin", "", "Install exactly this tag and skip it during 'plugin upgrade'") + cmd.Flags().StringVar(&indexFlag, "index", "", "Plugin index URL (overrides settings and "+pluginIndexEnvVar+")") return cmd } +type remoteInstallFlags struct { + force, yes, noDeps bool + pin, index string +} + +func runRemoteInstall(ctx context.Context, cmd *cobra.Command, arg string, flags remoteInstallFlags) error { + out, errOut := cmd.OutOrStdout(), cmd.ErrOrStderr() + + repoURL := arg + var trusted bool + var idx *PluginIndex + + if classifyInstallArg(arg) == installFromIndex { + var err error + idx, err = SyncPluginIndex(ctx, resolvePluginIndexURL(ctx, flags.index), false) + if err != nil { + return fmt.Errorf("resolve %q via plugin index: %w", arg, err) + } + entry := idx.Find(arg) + if entry == nil { + // Bare names never resolve to local files (see + // classifyInstallArg), but a user who typed one expecting a + // path install deserves the pointer. + if _, statErr := os.Stat(arg); statErr == nil { + return fmt.Errorf("plugin %q is not in the index; to install the local file, use an explicit path: entire plugin install ./%s", arg, arg) + } + return fmt.Errorf("plugin %q is not in the index; pass the repository URL to install from a specific repo (try 'entire plugin search %s')", arg, arg) + } + if len(entry.Platforms) > 0 && !slices.Contains(entry.Platforms, runtime.GOOS) { + fmt.Fprintf(errOut, "Warning: index lists %q for %s only; this is %s — continuing anyway.\n", + arg, strings.Join(entry.Platforms, "/"), runtime.GOOS) + } + repoURL = entry.RepoURL + trusted = true + } else { + // URL install: the index is only consulted for the trust check. + // An unreachable index degrades to "not listed" rather than + // blocking the install. + var idxErr error + idx, idxErr = SyncPluginIndex(ctx, resolvePluginIndexURL(ctx, flags.index), false) + trusted = idxErr == nil && idx.HasRepoURL(repoURL) + } + + if !trusted { + ok, err := confirmPluginAction(ctx, + fmt.Sprintf("Install from %s? The repository is not listed in the plugin index.", repoURL), + flags.yes) + switch { + case errors.Is(err, errConfirmNeedsTerminal): + return err // untrusted source can't proceed unconfirmed + case err != nil: + // Ctrl+C/Esc in the prompt prints "Install cancelled." and + // exits cleanly; real prompt failures surface wrapped. + return handleFormCancellation(out, "Install", err) + case !ok: + // Same outcome as an abort: nothing installed, clean exit. + // Exit codes must not differ between Esc and answering "No" — + // and automation never reaches this prompt at all (the + // non-interactive path fails above with the --yes hint). + fmt.Fprintln(out, "Install cancelled.") + return nil + } + } + + res, err := InstallPluginFromRepo(ctx, RemoteInstallOptions{RepoURL: repoURL, Pin: flags.pin, Force: flags.force}) + if err != nil { + return fmt.Errorf("install plugin: %w", err) + } + for _, t := range res.SkippedTags { + fmt.Fprintf(errOut, "Warning: tag %s has no release asset for this platform; fell back to %s.\n", t, res.Manifest.Tag) + } + fmt.Fprintf(out, "Installed plugin %q %s from %s\n", res.Manifest.Name, res.Manifest.Tag, repoURL) + warnIfShadowsBuiltin(cmd, res.Manifest.Name) + + if flags.noDeps || res.Metadata == nil || len(res.Metadata.Requires) == 0 { + return nil + } + return installPlannedDeps(ctx, cmd, res.Metadata.Requires, idx, flags.yes) +} + +// installPlannedDeps plans, confirms once (apt-style), and executes +// dependency installs. The main plugin is already installed at this point, +// so a declined or non-confirmable plan degrades to a warning, not an +// error — doctor reports the gap afterwards. +func installPlannedDeps(ctx context.Context, cmd *cobra.Command, reqs []PluginRequirement, idx *PluginIndex, yes bool) error { + out, errOut := cmd.OutOrStdout(), cmd.ErrOrStderr() + plan, err := PlanDependencyInstalls(ctx, reqs, idx) + if err != nil { + return fmt.Errorf("resolve dependencies: %w", err) + } + for _, w := range plan.Warnings { + fmt.Fprintf(errOut, "Warning: %s\n", w) + } + if len(plan.Actions) == 0 { + return nil + } + + fmt.Fprintf(out, "\nThis plugin requires %d additional plugin(s):\n", len(plan.Actions)) + for _, a := range plan.Actions { + switch { + case a.Upgrade: + fmt.Fprintf(out, " %s (installed %s, needs >= %s — will upgrade)\n", a.Name, a.CurrentTag, a.MinVersion) + default: + fmt.Fprintf(out, " %s (%s)\n", a.Name, a.RepoURL) + } + } + ok, err := confirmPluginAction(ctx, "Install them now?", yes) + switch { + case errors.Is(err, errConfirmNeedsTerminal): + // Non-interactive without --yes: the main install already + // succeeded, so skip with a pointer instead of failing late. + fmt.Fprintln(errOut, "Skipping dependency installs (no terminal for confirmation; re-run with --yes). 'entire plugin doctor' will report what's missing.") + return nil + case err != nil: + // User abort prints "Dependency install cancelled." and falls + // through to the skip note; real prompt failures are returned — + // claiming "skipped" for an error the user never saw would be + // misreporting. + if cancelErr := handleFormCancellation(errOut, "Dependency install", err); cancelErr != nil { + return cancelErr + } + fmt.Fprintln(errOut, "'entire plugin doctor' will report what's missing.") + return nil + case !ok: + fmt.Fprintln(errOut, "Skipping dependency installs; 'entire plugin doctor' will report what's missing.") + return nil + } + if err := ExecuteDepPlan(ctx, plan); err != nil { + return err + } + for _, a := range plan.Actions { + fmt.Fprintf(out, "Installed dependency %q\n", a.Name) + } + return nil +} + +// errConfirmNeedsTerminal signals that a confirmation was required but no +// terminal is available and --yes was not passed. Callers decide whether +// that is fatal (untrusted install) or an informed skip (dependency +// installs after the main install already succeeded). +var errConfirmNeedsTerminal = errors.New("confirmation required but no terminal available; re-run with --yes") + +// confirmPluginAction asks a yes/no question. assumeYes short-circuits; +// non-interactive runs without --yes return errConfirmNeedsTerminal rather +// than guessing. Prompt errors (including huh.ErrUserAborted on Ctrl+C/Esc) +// are returned raw for callers to map via handleFormCancellation. +func confirmPluginAction(ctx context.Context, prompt string, assumeYes bool) (bool, error) { + if assumeYes { + return true, nil + } + if !interactive.CanPromptInteractively() { + return false, fmt.Errorf("%w (%s)", errConfirmNeedsTerminal, prompt) + } + confirmed := false + form := NewAccessibleForm(huh.NewGroup( + huh.NewConfirm().Title(prompt).Value(&confirmed), + )) + if err := form.RunWithContext(ctx); err != nil { + // %w keeps huh.ErrUserAborted reachable for handleFormCancellation. + return false, fmt.Errorf("confirm: %w", err) + } + return confirmed, nil +} + +// silencePluginCancel maps Ctrl+C-induced failures to a SilentError per the +// codebase convention (clean.go, activity_cmd.go) — printing "context +// canceled" at a user who just interrupted a clone or download is noise. +// The ctx.Err() check matters because a killed git child surfaces as +// "signal: killed", not context.Canceled, when the cancellation raced the +// subprocess. +func silencePluginCancel(ctx context.Context, err error) error { + if err == nil { + return nil + } + if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return NewSilentError(err) + } + return err +} + // warnIfShadowsBuiltin prints a one-line note to stderr when the just-installed // plugin name matches a built-in command. The dispatcher's resolvePlugin gates // dispatch on rootCmd.Find, so the built-in always wins at runtime — without @@ -126,35 +362,328 @@ func runPluginList(w io.Writer) error { } if len(plugins) == 0 { fmt.Fprintf(w, "No plugins installed in %s.\n", dir) - fmt.Fprintln(w, "Install one with 'entire plugin install ', or drop an entire- binary anywhere on $PATH.") + fmt.Fprintln(w, "Install one with 'entire plugin install ', or drop an entire- binary anywhere on $PATH.") return nil } + manifestTag := map[string]string{} + if manifests, err := ListPluginManifests(); err == nil { + for _, m := range manifests { + tag := m.Tag + if m.Pinned { + tag += " (pinned)" + } + manifestTag[m.Name] = tag + } + } fmt.Fprintf(w, "Managed plugin directory: %s\n\n", dir) for _, p := range plugins { + tag := manifestTag[p.Name] if p.Symlink { - fmt.Fprintf(w, " %-20s → %s\n", p.Name, p.LinkTarget) + fmt.Fprintf(w, " %-20s %-18s → %s\n", p.Name, tag, p.LinkTarget) } else { - fmt.Fprintf(w, " %-20s %s\n", p.Name, p.Path) + fmt.Fprintf(w, " %-20s %-18s %s\n", p.Name, tag, p.Path) } } return nil } func newPluginRemoveCmd() *cobra.Command { - return &cobra.Command{ + var force bool + cmd := &cobra.Command{ Use: "remove ", Short: "Remove a plugin from the managed directory", Long: `Remove a plugin from the managed directory. Only entries in the managed directory are affected. Plugins installed by -dropping a binary elsewhere on $PATH are unmanaged — remove those by hand.`, +dropping a binary elsewhere on $PATH are unmanaged — remove those by hand. + +When other installed plugins declare the target as a dependency, removal +is refused unless --force is given.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := RemoveInstalledPlugin(args[0]); err != nil { + name := args[0] + if !force { + dependents, err := DependentsOf(name) + if err != nil { + return err + } + if len(dependents) > 0 { + return fmt.Errorf("plugin %q is required by %s; use --force to remove anyway", name, strings.Join(dependents, ", ")) + } + } + if err := RemoveManagedPlugin(name); err != nil { return fmt.Errorf("remove plugin: %w", err) } - fmt.Fprintf(cmd.OutOrStdout(), "Removed plugin %q\n", args[0]) + fmt.Fprintf(cmd.OutOrStdout(), "Removed plugin %q\n", name) return nil }, } + cmd.Flags().BoolVar(&force, "force", false, "Remove even when other plugins depend on it") + return cmd +} + +func newPluginUpgradeCmd() *cobra.Command { + var all bool + cmd := &cobra.Command{ + Use: "upgrade [name]", + Short: "Upgrade remote-installed plugins to their newest tag", + Long: `Upgrade remote-installed plugins to their newest semver tag. + +Only plugins installed from a repository URL or the index carry the install +manifest upgrades need; local-dev symlink installs are skipped. Plugins +installed with --pin are skipped until reinstalled without the pin.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + out := cmd.OutOrStdout() + var names []string + switch { + case len(args) == 1: + names = []string{args[0]} + case all: + manifests, err := ListPluginManifests() + if err != nil { + return err + } + for _, m := range manifests { + names = append(names, m.Name) + } + if len(names) == 0 { + fmt.Fprintln(out, "No upgradable plugins (none were installed from a repository).") + return nil + } + default: + return errors.New("specify a plugin name or --all") + } + var firstErr error + for _, name := range names { + o, err := UpgradeInstalledPlugin(ctx, name) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Upgrade %q failed: %v\n", name, err) + if firstErr == nil { + firstErr = err + } + continue + } + switch { + case o.Pinned: + fmt.Fprintf(out, "%-20s pinned, skipped\n", name) + case o.UpToDate: + fmt.Fprintf(out, "%-20s up to date\n", name) + default: + fmt.Fprintf(out, "%-20s %s → %s\n", name, o.FromTag, o.ToTag) + } + } + return silencePluginCancel(ctx, firstErr) + }, + } + cmd.Flags().BoolVar(&all, "all", false, "Upgrade every remote-installed plugin") + return cmd +} + +func newPluginSearchCmd() *cobra.Command { + var indexFlag string + cmd := &cobra.Command{ + Use: "search [term]", + Short: "Search the plugin index", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + term := "" + if len(args) == 1 { + term = args[0] + } + idx, err := SyncPluginIndex(ctx, resolvePluginIndexURL(ctx, indexFlag), false) + if err != nil { + return silencePluginCancel(ctx, err) + } + entries := idx.Search(term) + if len(entries) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No plugins matching %q in the index.\n", term) + return nil + } + printIndexEntries(cmd.OutOrStdout(), entries) + return nil + }, + } + cmd.Flags().StringVar(&indexFlag, "index", "", "Plugin index URL (overrides settings and "+pluginIndexEnvVar+")") + return cmd +} + +func printIndexEntries(w io.Writer, entries []PluginIndexEntry) { + installedNames := map[string]bool{} + if installed, err := ListInstalledPlugins(); err == nil { + for _, p := range installed { + installedNames[p.Name] = true + } + } + for _, e := range entries { + mark := " " + if installedNames[e.Name] { + mark = "*" + } + official := "" + if e.Official { + official = " [official]" + } + fmt.Fprintf(w, "%s %-20s %s%s\n", mark, e.Name, e.Description, official) + } + fmt.Fprintln(w, "\n* = installed. Install with 'entire plugin install '.") +} + +func newPluginInfoCmd() *cobra.Command { + var indexFlag string + cmd := &cobra.Command{ + Use: "info ", + Short: "Show index and install details for a plugin", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + name := args[0] + out := cmd.OutOrStdout() + + entry := (*PluginIndexEntry)(nil) + if idx, err := SyncPluginIndex(ctx, resolvePluginIndexURL(ctx, indexFlag), false); err == nil { + entry = idx.Find(name) + } + m, err := LoadPluginManifest(name) + if err != nil { + return err + } + installed, err := FindInstalledPlugin(name) + if err != nil { + return err + } + if entry == nil && m == nil && installed == nil { + return fmt.Errorf("plugin %q: not installed and not in the index", name) + } + + fmt.Fprintf(out, "Name: %s\n", name) + if entry != nil { + fmt.Fprintf(out, "Description: %s\n", entry.Description) + fmt.Fprintf(out, "Repository: %s\n", entry.RepoURL) + fmt.Fprintf(out, "Official: %t\n", entry.Official) + if len(entry.Platforms) > 0 { + fmt.Fprintf(out, "Platforms: %s\n", strings.Join(entry.Platforms, ", ")) + } + } + switch { + case m != nil: + fmt.Fprintf(out, "Installed: %s (from %s", m.Tag, m.RepoURL) + if m.Pinned { + fmt.Fprint(out, ", pinned") + } + fmt.Fprintln(out, ")") + for _, r := range m.Requires { + line := "Requires: " + r.Name + if r.MinVersion != "" { + line += " >= " + r.MinVersion + } + fmt.Fprintln(out, line) + } + case installed != nil: + fmt.Fprintf(out, "Installed: local (%s)\n", installed.Path) + default: + fmt.Fprintln(out, "Installed: no") + } + return nil + }, + } + cmd.Flags().StringVar(&indexFlag, "index", "", "Plugin index URL (overrides settings and "+pluginIndexEnvVar+")") + return cmd +} + +func newPluginBrowseCmd() *cobra.Command { + var indexFlag string + cmd := &cobra.Command{ + Use: "browse", + Short: "Interactively browse the plugin index and install", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + if !interactive.CanPromptInteractively() { + return errors.New("browse needs a terminal; use 'entire plugin search' instead") + } + idx, err := SyncPluginIndex(ctx, resolvePluginIndexURL(ctx, indexFlag), false) + if err != nil { + return silencePluginCancel(ctx, err) + } + if len(idx.Plugins) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "The plugin index is empty.") + return nil + } + options := make([]huh.Option[string], 0, len(idx.Plugins)+1) + for _, e := range idx.Plugins { + label := e.Name + if e.Description != "" { + label = fmt.Sprintf("%s — %s", e.Name, e.Description) + } + options = append(options, huh.NewOption(label, e.Name)) + } + options = append(options, huh.NewOption("(cancel)", "")) + choice := "" + form := NewAccessibleForm(huh.NewGroup( + huh.NewSelect[string]().Title("Install a plugin").Options(options...).Value(&choice), + )) + if err := form.RunWithContext(ctx); err != nil { + return handleFormCancellation(cmd.OutOrStdout(), "Browse", err) + } + if choice == "" { + return nil + } + return silencePluginCancel(ctx, runRemoteInstall(ctx, cmd, choice, remoteInstallFlags{index: indexFlag})) + }, + } + cmd.Flags().StringVar(&indexFlag, "index", "", "Plugin index URL (overrides settings and "+pluginIndexEnvVar+")") + return cmd +} + +func newPluginDoctorCmd() *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Check installed plugins for missing dependencies and broken entries", + RunE: func(cmd *cobra.Command, _ []string) error { + issues, err := RunPluginDoctor(cmd.Context()) + if err != nil { + return err + } + out := cmd.OutOrStdout() + if len(issues) == 0 { + fmt.Fprintln(out, "All plugins healthy.") + return nil + } + for _, i := range issues { + fmt.Fprintf(out, "%s: %s\n", i.Plugin, i.Problem) + if i.Fix != "" { + fmt.Fprintf(out, " fix: %s\n", i.Fix) + } + } + cmd.SilenceUsage = true + return NewSilentError(fmt.Errorf("%d plugin issue(s) found", len(issues))) + }, + } +} + +func newPluginIndexCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "index", + Short: "Manage the plugin index", + } + var indexFlag string + update := &cobra.Command{ + Use: "update", + Short: "Force a refresh of the plugin index", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + url := resolvePluginIndexURL(ctx, indexFlag) + idx, err := SyncPluginIndex(ctx, url, true) + if err != nil { + return silencePluginCancel(ctx, err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Index %s: %d plugin(s).\n", url, len(idx.Plugins)) + return nil + }, + } + update.Flags().StringVar(&indexFlag, "index", "", "Plugin index URL (overrides settings and "+pluginIndexEnvVar+")") + cmd.AddCommand(update) + return cmd } diff --git a/cmd/entire/cli/plugin_index.go b/cmd/entire/cli/plugin_index.go new file mode 100644 index 0000000000..26ba092299 --- /dev/null +++ b/cmd/entire/cli/plugin_index.go @@ -0,0 +1,243 @@ +package cli + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/internal/entireclient/userdirs" +) + +// Plugin discovery rides on a git-synced index, krew-style: the index is +// itself a git repository containing index.json, shallow-cloned into the +// user cache and re-pulled on a TTL. No hosted service, no forge REST API — +// any git server can host an index, and a repo can point contributors at an +// internal catalog via plugins.index_url in .entire/settings.json. +const ( + // defaultPluginIndexURL is the built-in curated index. + defaultPluginIndexURL = "https://github.com/entireio/plugin-index" + // pluginIndexEnvVar overrides the index URL (forwarded to plugin + // children automatically via the ENTIRE_ allowlist prefix). + pluginIndexEnvVar = "ENTIRE_PLUGIN_INDEX_URL" + // pluginIndexFileName is the catalog file at the index repo root. + pluginIndexFileName = "index.json" + // pluginIndexSyncMarker records the last successful sync time as the + // marker file's mtime. Untracked, so fetch/reset never touches it. + pluginIndexSyncMarker = ".entire-last-sync" +) + +// PluginIndex is the parsed catalog. Decoding is deliberately lenient +// (unknown fields ignored) so an index that grows new fields doesn't break +// older CLI versions fleet-wide. +type PluginIndex struct { + Version int `json:"version"` + Plugins []PluginIndexEntry `json:"plugins"` +} + +// PluginIndexEntry describes one plugin in the catalog. +type PluginIndexEntry struct { + Name string `json:"name"` + RepoURL string `json:"repo_url"` + Description string `json:"description,omitempty"` + Official bool `json:"official,omitempty"` + // Platforms lists supported GOOS values when the plugin doesn't ship + // the full matrix; empty means all. + Platforms []string `json:"platforms,omitempty"` +} + +// Find returns the entry with the given bare name, or nil. +func (idx *PluginIndex) Find(name string) *PluginIndexEntry { + if idx == nil { + return nil + } + for i := range idx.Plugins { + if idx.Plugins[i].Name == name { + return &idx.Plugins[i] + } + } + return nil +} + +// Search returns entries whose name or description contains term +// (case-insensitive). An empty term returns everything. +func (idx *PluginIndex) Search(term string) []PluginIndexEntry { + if idx == nil { + return nil + } + if term == "" { + return idx.Plugins + } + t := strings.ToLower(term) + var out []PluginIndexEntry + for _, e := range idx.Plugins { + if strings.Contains(strings.ToLower(e.Name), t) || strings.Contains(strings.ToLower(e.Description), t) { + out = append(out, e) + } + } + return out +} + +// HasRepoURL reports whether a repo URL is listed in the index — the trust +// check for install confirmations. Compared with the .git suffix and +// trailing slashes normalized. +func (idx *PluginIndex) HasRepoURL(repoURL string) bool { + if idx == nil { + return false + } + want := normalizeRepoURL(repoURL) + for _, e := range idx.Plugins { + if normalizeRepoURL(e.RepoURL) == want { + return true + } + } + return false +} + +func normalizeRepoURL(u string) string { + return strings.TrimSuffix(strings.TrimRight(strings.TrimSpace(u), "/"), ".git") +} + +// resolvePluginIndexURL applies the documented precedence: --index flag > +// ENTIRE_PLUGIN_INDEX_URL > settings (local over project) > built-in +// default. Settings load failures fall through to the default rather than +// blocking discovery; the failure is logged at debug. +func resolvePluginIndexURL(ctx context.Context, flagValue string) string { + if flagValue != "" { + return flagValue + } + if v := os.Getenv(pluginIndexEnvVar); v != "" { + return v + } + s, err := LoadEntireSettings(ctx) + if err != nil { + logging.Debug(ctx, "plugin index: settings load failed, using default index", slog.String("error", err.Error())) + return defaultPluginIndexURL + } + if u := s.PluginIndexURL(); u != "" { + return u + } + return defaultPluginIndexURL +} + +// pluginIndexTTL returns the freshness window from settings (default 24h). +func pluginIndexTTL(ctx context.Context) time.Duration { + s, err := LoadEntireSettings(ctx) + if err != nil { + return 24 * time.Hour + } + return s.PluginIndexTTL() +} + +// pluginIndexCacheDir is the per-URL local copy. Keyed by a hash of the +// URL so switching indexes (or per-repo overrides) never serves one +// catalog's cache for another. +func pluginIndexCacheDir(indexURL string) (string, error) { + cache := userdirs.Cache() + if cache == "" { + return "", errors.New("cannot resolve user cache directory") + } + sum := sha256.Sum256([]byte(normalizeRepoURL(indexURL))) + return filepath.Join(cache, "plugin-index", hex.EncodeToString(sum[:6])), nil +} + +// SyncPluginIndex returns the catalog for indexURL, cloning or refreshing +// the local copy as needed. force bypasses the TTL. When a refresh fails +// but a previous copy exists, the stale copy is used with a warning logged +// — discovery shouldn't hard-fail because a laptop is offline. +func SyncPluginIndex(ctx context.Context, indexURL string, force bool) (*PluginIndex, error) { + dir, err := pluginIndexCacheDir(indexURL) + if err != nil { + return nil, err + } + marker := filepath.Join(dir, pluginIndexSyncMarker) + _, statErr := os.Stat(filepath.Join(dir, ".git")) + cloned := statErr == nil + + fresh := false + if cloned && !force { + if info, err := os.Stat(marker); err == nil { + fresh = time.Since(info.ModTime()) < pluginIndexTTL(ctx) + } + } + + switch { + case !cloned: + // A previously interrupted clone can leave a partial directory + // without .git. git refuses to clone into a non-empty target, so + // without this sweep, discovery would stay wedged until the user + // cleared the cache by hand. + if err := os.RemoveAll(dir); err != nil { + return nil, fmt.Errorf("clear stale index cache: %w", err) + } + if err := os.MkdirAll(filepath.Dir(dir), 0o750); err != nil { + return nil, fmt.Errorf("create index cache dir: %w", err) + } + if _, err := gitQuiet(ctx, "clone", "--depth", "1", "--quiet", indexURL, dir).Output(); err != nil { + return nil, fmt.Errorf("clone plugin index %s: %w%s", indexURL, err, stderrSuffix(err)) + } + touchFile(marker) + case !fresh: + if err := refreshPluginIndexClone(ctx, dir); err != nil { + logging.Warn(ctx, "plugin index refresh failed; using cached copy", + slog.String("index", indexURL), slog.String("error", err.Error())) + } else { + touchFile(marker) + } + } + + return loadPluginIndexFromDir(dir, indexURL) +} + +// refreshPluginIndexClone updates an existing shallow clone to the remote +// tip regardless of the remote's default branch name. +func refreshPluginIndexClone(ctx context.Context, dir string) error { + if _, err := gitQuiet(ctx, "-C", dir, "fetch", "--depth", "1", "--quiet", "origin", "HEAD").Output(); err != nil { + return fmt.Errorf("fetch: %w%s", err, stderrSuffix(err)) + } + if _, err := gitQuiet(ctx, "-C", dir, "reset", "--hard", "--quiet", "FETCH_HEAD").Output(); err != nil { + return fmt.Errorf("reset: %w%s", err, stderrSuffix(err)) + } + return nil +} + +func touchFile(path string) { + now := time.Now() + if err := os.Chtimes(path, now, now); err == nil { + return + } + if f, err := os.Create(path); err == nil { //nolint:gosec // marker file inside our cache dir + _ = f.Close() + } +} + +func loadPluginIndexFromDir(dir, indexURL string) (*PluginIndex, error) { + data, err := os.ReadFile(filepath.Join(dir, pluginIndexFileName)) //nolint:gosec // file inside our cache dir + if err != nil { + return nil, fmt.Errorf("plugin index %s has no readable %s: %w", indexURL, pluginIndexFileName, err) + } + var idx PluginIndex + if err := json.Unmarshal(data, &idx); err != nil { + return nil, fmt.Errorf("parse %s from %s: %w", pluginIndexFileName, indexURL, err) + } + if idx.Version != 1 { + return nil, fmt.Errorf("plugin index %s declares unsupported version %d (this CLI understands 1; upgrade entire?)", indexURL, idx.Version) + } + var valid []PluginIndexEntry + for _, e := range idx.Plugins { + if validatePluginName(e.Name) != nil || e.RepoURL == "" { + continue // tolerate bad entries rather than failing the catalog + } + valid = append(valid, e) + } + idx.Plugins = valid + return &idx, nil +} diff --git a/cmd/entire/cli/plugin_index_test.go b/cmd/entire/cli/plugin_index_test.go new file mode 100644 index 0000000000..bca58d5a01 --- /dev/null +++ b/cmd/entire/cli/plugin_index_test.go @@ -0,0 +1,182 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +// newIndexRepo creates a git repo holding index.json and returns its +// file:// URL. +func newIndexRepo(t *testing.T, indexJSON string) (url, dir string) { + t.Helper() + dir = t.TempDir() + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, pluginIndexFileName, indexJSON) + testutil.GitAdd(t, dir, pluginIndexFileName) + testutil.GitCommit(t, dir, "index") + return "file://" + filepath.ToSlash(dir), dir +} + +const testIndexJSON = `{ + "version": 1, + "plugins": [ + {"name": "run", "repo_url": "https://github.com/entireio/entire-run", "description": "Run apps", "official": true}, + {"name": "sem", "repo_url": "https://github.com/entireio/entire-sem", "description": "Semantic search"}, + {"name": "agent-bad", "repo_url": "https://example.com/x", "description": "invalid, filtered"}, + {"name": "noend", "repo_url": "", "description": "missing repo, filtered"} + ] +}` + +func TestSyncPluginIndex_CloneSearchFind(t *testing.T) { //nolint:paralleltest // mutates env via cache isolation + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + url, _ := newIndexRepo(t, testIndexJSON) + idx, err := SyncPluginIndex(context.Background(), url, false) + if err != nil { + t.Fatalf("SyncPluginIndex: %v", err) + } + // Invalid entries (reserved name, empty repo) are filtered, not fatal. + if len(idx.Plugins) != 2 { + t.Fatalf("plugins = %+v, want 2 valid entries", idx.Plugins) + } + if e := idx.Find("run"); e == nil || !e.Official { + t.Errorf("Find(run) = %+v", e) + } + if got := idx.Search("semantic"); len(got) != 1 || got[0].Name != "sem" { + t.Errorf("Search(semantic) = %+v", got) + } + if !idx.HasRepoURL("https://github.com/entireio/entire-run.git") { + t.Error("HasRepoURL should normalize .git suffix") + } + if idx.HasRepoURL("https://github.com/entireio/entire-other") { + t.Error("HasRepoURL matched an unlisted repo") + } +} + +func TestSyncPluginIndex_RefreshPicksUpNewEntries(t *testing.T) { //nolint:paralleltest // mutates env via cache isolation + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + url, dir := newIndexRepo(t, `{"version":1,"plugins":[{"name":"run","repo_url":"https://x.example/entire-run"}]}`) + ctx := context.Background() + idx, err := SyncPluginIndex(ctx, url, false) + if err != nil { + t.Fatalf("initial sync: %v", err) + } + if len(idx.Plugins) != 1 { + t.Fatalf("initial plugins = %d, want 1", len(idx.Plugins)) + } + + testutil.WriteFile(t, dir, pluginIndexFileName, `{"version":1,"plugins":[{"name":"run","repo_url":"https://x.example/entire-run"},{"name":"sem","repo_url":"https://x.example/entire-sem"}]}`) + testutil.GitAdd(t, dir, pluginIndexFileName) + testutil.GitCommit(t, dir, "add sem") + + // Within TTL the cached copy is served... + idx, err = SyncPluginIndex(ctx, url, false) + if err != nil { + t.Fatalf("cached sync: %v", err) + } + if len(idx.Plugins) != 1 { + t.Errorf("TTL-fresh sync re-fetched: got %d plugins", len(idx.Plugins)) + } + // ...force bypasses the TTL. + idx, err = SyncPluginIndex(ctx, url, true) + if err != nil { + t.Fatalf("forced sync: %v", err) + } + if len(idx.Plugins) != 2 { + t.Errorf("forced sync plugins = %d, want 2", len(idx.Plugins)) + } +} + +func TestSyncPluginIndex_OfflineUsesStaleCopy(t *testing.T) { //nolint:paralleltest // mutates env via cache isolation + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + url, dir := newIndexRepo(t, `{"version":1,"plugins":[{"name":"run","repo_url":"https://x.example/entire-run"}]}`) + ctx := context.Background() + if _, err := SyncPluginIndex(ctx, url, false); err != nil { + t.Fatalf("initial sync: %v", err) + } + // Simulate the remote disappearing (laptop offline / index moved). + if err := os.RemoveAll(dir); err != nil { + t.Fatalf("remove remote dir: %v", err) + } + idx, err := SyncPluginIndex(ctx, url, true) // force → refresh fails → stale copy + if err != nil { + t.Fatalf("offline sync should fall back to cache: %v", err) + } + if len(idx.Plugins) != 1 { + t.Errorf("stale copy plugins = %d, want 1", len(idx.Plugins)) + } +} + +func TestSyncPluginIndex_UnsupportedVersion(t *testing.T) { //nolint:paralleltest // mutates env via cache isolation + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + url, _ := newIndexRepo(t, `{"version":99,"plugins":[]}`) + if _, err := SyncPluginIndex(context.Background(), url, false); err == nil { + t.Error("SyncPluginIndex accepted unsupported index version") + } +} + +func TestResolvePluginIndexURL_Precedence(t *testing.T) { //nolint:paralleltest // mutates env + ctx := context.Background() + t.Setenv(pluginIndexEnvVar, "") + if got := resolvePluginIndexURL(ctx, "https://flag.example/idx"); got != "https://flag.example/idx" { + t.Errorf("flag should win: %q", got) + } + t.Setenv(pluginIndexEnvVar, "https://env.example/idx") + if got := resolvePluginIndexURL(ctx, ""); got != "https://env.example/idx" { + t.Errorf("env should win over settings/default: %q", got) + } + if got := resolvePluginIndexURL(ctx, "https://flag.example/idx"); got != "https://flag.example/idx" { + t.Errorf("flag should win over env: %q", got) + } +} + +func TestClassifyInstallArg(t *testing.T) { + t.Parallel() + for arg, want := range map[string]installArgKind{ + "https://github.com/entireio/entire-run": installFromURL, + "git@github.com:entireio/entire-run.git": installFromURL, + "file:///tmp/repo": installFromURL, + "./dist/entire-run": installFromPath, + "dist/entire-run": installFromPath, + "../entire-run": installFromPath, + "run": installFromIndex, + "brain": installFromIndex, + // Bare names are index lookups even when a same-named file exists + // in the CWD — classification is pure string logic, never stat, + // so a stray local file can't shadow an index name. Local files + // need an explicit ./ prefix. + "entire-run": installFromIndex, + } { + if got := classifyInstallArg(arg); got != want { + t.Errorf("classifyInstallArg(%q) = %d, want %d", arg, got, want) + } + } +} + +func TestSyncPluginIndex_RecoversFromPartialClone(t *testing.T) { //nolint:paralleltest // mutates env via cache isolation + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + url, _ := newIndexRepo(t, `{"version":1,"plugins":[{"name":"run","repo_url":"https://x.example/entire-run"}]}`) + // Simulate an interrupted first clone: cache dir exists, is non-empty, + // but has no .git. git clone refuses non-empty targets, so sync must + // sweep the partial dir instead of staying wedged. + dir, err := pluginIndexCacheDir(url) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "leftover"), []byte("partial"), 0o600); err != nil { + t.Fatal(err) + } + idx, err := SyncPluginIndex(context.Background(), url, false) + if err != nil { + t.Fatalf("SyncPluginIndex after partial clone: %v", err) + } + if len(idx.Plugins) != 1 { + t.Errorf("plugins = %d, want 1", len(idx.Plugins)) + } +} diff --git a/cmd/entire/cli/plugin_install_remote.go b/cmd/entire/cli/plugin_install_remote.go new file mode 100644 index 0000000000..02249b0352 --- /dev/null +++ b/cmd/entire/cli/plugin_install_remote.go @@ -0,0 +1,270 @@ +package cli + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +// Remote install orchestration: resolve tag → fetch metadata → download + +// verify + extract → place under pkg// → link into bin/ → write +// manifest. The bin/ link goes through the same InstallPluginFromPath used +// by local-dev installs, so conflict handling, atomic replace, and the +// Windows symlink fallbacks are shared, not duplicated. + +// maxTagFallbacks bounds how many tags we walk down when the newest tag has +// no published assets (tag pushed, release not finished). +const maxTagFallbacks = 3 + +// RemoteInstallOptions configures InstallPluginFromRepo. +type RemoteInstallOptions struct { + // RepoURL is the full git URL of the plugin repository. + RepoURL string + // Pin, when non-empty, installs exactly this tag and marks the + // manifest pinned so upgrade skips it. + Pin string + // Force replaces an existing managed entry with the same name. + Force bool +} + +// RemoteInstallResult is what a successful remote install produced. +type RemoteInstallResult struct { + Installed *InstalledPlugin + Manifest *PluginManifest + Metadata *PluginMetadata + // SkippedTags lists newer tags that were passed over for missing + // assets, newest first. Callers surface these as warnings. + SkippedTags []string +} + +// InstallPluginFromRepo installs a plugin from a git repository URL. +// Dependency resolution deliberately does not happen here — callers +// (the install command) plan and confirm dependency installs first. +func InstallPluginFromRepo(ctx context.Context, opts RemoteInstallOptions) (*RemoteInstallResult, error) { + repoURL := strings.TrimRight(opts.RepoURL, "/") + + var tags []string + if opts.Pin != "" { + tags = []string{opts.Pin} + } else { + var err error + tags, err = listRemoteSemverTags(ctx, repoURL) + if err != nil { + return nil, err + } + if len(tags) == 0 { + return nil, fmt.Errorf("%s has no semver tags; pass --pin to install a non-semver tag", repoURL) + } + if len(tags) > maxTagFallbacks { + tags = tags[:maxTagFallbacks] + } + } + + var lastErr error + for i, tag := range tags { + res, err := installRepoAtTag(ctx, repoURL, tag, opts) + if err == nil { + res.SkippedTags = tags[:i] + return res, nil + } + lastErr = err + if !errors.Is(err, errAssetNotFound) { + return nil, err + } + } + return nil, lastErr +} + +func installRepoAtTag(ctx context.Context, repoURL, tag string, opts RemoteInstallOptions) (*RemoteInstallResult, error) { + meta, err := fetchPluginMetadataAtTag(ctx, repoURL, tag) + if err != nil { + return nil, err + } + var name string + if meta != nil && meta.Name != "" { + name = meta.Name + } else if name, err = pluginNameFromRepoURL(repoURL); err != nil { + return nil, err + } + + if existing, err := FindInstalledPlugin(name); err != nil { + return nil, err + } else if existing != nil && !opts.Force { + return nil, fmt.Errorf("plugin %q already installed at %s; use --force to replace", name, existing.Path) + } + + staging, err := os.MkdirTemp("", "entire-plugin-fetch-") + if err != nil { + return nil, fmt.Errorf("create staging dir: %w", err) + } + defer os.RemoveAll(staging) + + asset, err := downloadPluginAsset(ctx, meta, repoURL, name, tag, staging) + if err != nil { + return nil, err + } + + binBase := pluginBinaryPrefix + name + if runtime.GOOS == windowsGOOS { + binBase += ".exe" + } + stagedBin := filepath.Join(staging, "extracted-"+binBase) + if err := extractPluginBinary(asset.Path, name, stagedBin); err != nil { + return nil, err + } + + pkgDir, err := EnsurePluginPkgDir(name) + if err != nil { + return nil, err + } + sweepOldBinaries(pkgDir) + pkgBin := filepath.Join(pkgDir, binBase) + if err := replaceBinary(stagedBin, pkgBin); err != nil { + return nil, fmt.Errorf("place plugin binary: %w", err) + } + + installed, err := InstallPluginFromPath(InstallPluginOptions{SourcePath: pkgBin, Force: true}) + if err != nil { + return nil, err + } + + manifest := &PluginManifest{ + Name: name, + RepoURL: repoURL, + Tag: tag, + Asset: asset.Asset, + SHA256: asset.SHA256, + Pinned: opts.Pin != "", + InstalledAt: time.Now().UTC(), + } + if meta != nil { + manifest.Requires = meta.Requires + } + if err := SavePluginManifest(manifest); err != nil { + return nil, err + } + return &RemoteInstallResult{Installed: installed, Manifest: manifest, Metadata: meta}, nil +} + +// UpgradeOutcome describes what UpgradeInstalledPlugin did. +type UpgradeOutcome struct { + Name string + // Pinned: skipped because the manifest is pinned. + Pinned bool + // UpToDate: already at the newest tag. + UpToDate bool + // FromTag/ToTag are set when an upgrade actually happened. + FromTag, ToTag string +} + +// UpgradeInstalledPlugin re-resolves the newest tag for a remote-installed +// plugin and reinstalls when it differs from the manifest's tag. Plugins +// without a manifest (local-dev symlinks) are not upgradable. +func UpgradeInstalledPlugin(ctx context.Context, name string) (*UpgradeOutcome, error) { + m, err := LoadPluginManifest(name) + if err != nil { + return nil, err + } + if m == nil { + return nil, fmt.Errorf("plugin %q has no install manifest (local-dev install?); reinstall it from its repository URL to make it upgradable", name) + } + if m.Pinned { + return &UpgradeOutcome{Name: name, Pinned: true}, nil + } + tags, err := listRemoteSemverTags(ctx, m.RepoURL) + if err != nil { + return nil, err + } + if len(tags) == 0 { + return nil, fmt.Errorf("%s has no semver tags", m.RepoURL) + } + // Semver comparison, not string equality: "v0.2.0" and "0.2.0" are the + // same version, and a remote that re-tagged with the other spelling + // must not trigger a reinstall. + if semver.Compare(canonicalSemver(tags[0]), canonicalSemver(m.Tag)) <= 0 { + return &UpgradeOutcome{Name: name, UpToDate: true}, nil + } + res, err := InstallPluginFromRepo(ctx, RemoteInstallOptions{RepoURL: m.RepoURL, Force: true}) + if err != nil { + return nil, err + } + // The install may have fallen back past an asset-less newest tag and + // landed on the version already installed — that's up-to-date, not an + // upgrade line claiming X → X. + if semver.Compare(canonicalSemver(res.Manifest.Tag), canonicalSemver(m.Tag)) <= 0 { + return &UpgradeOutcome{Name: name, UpToDate: true}, nil + } + return &UpgradeOutcome{Name: name, FromTag: m.Tag, ToTag: res.Manifest.Tag}, nil +} + +// replaceBinary moves src over dest atomically. On Windows, a running +// executable can't be replaced (sharing violation) but it *can* be renamed: +// move the old binary aside to a .old- file and retry; leftovers are +// swept on the next install. os.Rename fails across filesystems (staging is +// in the system temp dir), so a copy fallback covers that case. +func replaceBinary(src, dest string) error { + err := os.Rename(src, dest) + if err == nil { + return nil + } + if runtime.GOOS == windowsGOOS { + if asideErr := os.Rename(dest, oldBinaryAsidePath(dest)); asideErr == nil { + if err = os.Rename(src, dest); err == nil { + return nil + } + } + } + // Cross-device rename: copy + fsync-free write, then remove src. + in, openErr := os.Open(src) //nolint:gosec // staging file we created + if openErr != nil { + return errors.Join(err, openErr) + } + defer in.Close() + if writeErr := writeExecutable(in, dest); writeErr != nil { + return errors.Join(err, writeErr) + } + _ = os.Remove(src) + return nil +} + +// oldBinaryAsidePath returns a unique .old- sibling path for the +// rename-aside trick. Random suffix so concurrent upgrades can't collide. +func oldBinaryAsidePath(dest string) string { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + return dest + ".old-fallback" + } + return dest + ".old-" + hex.EncodeToString(b[:]) +} + +// sweepOldBinaries best-effort removes .old-* leftovers from the Windows +// rename-aside fallback. Failures are ignored — the files are inert. +func sweepOldBinaries(pkgDir string) { + entries, err := os.ReadDir(pkgDir) + if err != nil { + return + } + for _, e := range entries { + if strings.Contains(e.Name(), ".old-") { + _ = os.Remove(filepath.Join(pkgDir, e.Name())) + } + } +} + +// RemoveManagedPlugin removes a plugin's bin entries and its pkg dir. +// The dependency guard lives in the command layer so --force can bypass it. +func RemoveManagedPlugin(name string) error { + if err := RemoveInstalledPlugin(name); err != nil { + return err + } + return RemovePluginPkg(name) +} diff --git a/cmd/entire/cli/plugin_install_remote_test.go b/cmd/entire/cli/plugin_install_remote_test.go new file mode 100644 index 0000000000..db18b3a0e1 --- /dev/null +++ b/cmd/entire/cli/plugin_install_remote_test.go @@ -0,0 +1,255 @@ +package cli + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "runtime" + "strings" + "testing" +) + +// Tags used across the remote-install lifecycle tests. +const ( + remoteTestTagOld = "v0.1.0" + remoteTestTagMid = "v0.2.0" +) + +// pluginReleaseServer serves goreleaser-shaped release assets for the "demo" +// plugin at the given versions. Assets are tar.gz archives whose entire- entry content is +// "payload-", letting tests assert which version got installed. +func pluginReleaseServer(t *testing.T, versions ...string) *httptest.Server { + t.Helper() + binName := pluginBinaryPrefix + "demo" + assets := map[string][]byte{} + for _, v := range versions { + archive := makeTarGz(t, map[string][]byte{binName: []byte("payload-" + v)}) + assets[fmt.Sprintf("%s_%s_%s_%s.tar.gz", binName, v, runtime.GOOS, runtime.GOARCH)] = archive + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + base := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + if data, ok := assets[base]; ok { + _, _ = w.Write(data) //nolint:errcheck // test server write; failure surfaces as a client error + return + } + http.NotFound(w, r) + })) +} + +func gitTag(t *testing.T, repoURL, tag string) { + t.Helper() + dir := strings.TrimPrefix(repoURL, "file://") + if out, err := exec.CommandContext(t.Context(), "git", "-C", dir, "tag", tag).CombinedOutput(); err != nil { + t.Fatalf("git tag: %v: %s", err, out) + } +} + +func installedPayload(t *testing.T, name string) string { + t.Helper() + p, err := FindInstalledPlugin(name) + if err != nil || p == nil { + t.Fatalf("FindInstalledPlugin(%s) = %v, %v", name, p, err) + } + data, err := os.ReadFile(p.Path) + if err != nil { + t.Fatalf("read installed binary: %v", err) + } + return string(data) +} + +func TestInstallPluginFromRepo_EndToEnd(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + ctx := context.Background() + + srv := pluginReleaseServer(t, "0.1.0", "0.2.0") + defer srv.Close() + meta := fmt.Sprintf("name: demo\ndownload_url: \"%s/dl/{tag}/{asset}\"\n", srv.URL) + repoURL := newTaggedPluginRepo(t, meta, remoteTestTagOld, remoteTestTagMid) + + res, err := InstallPluginFromRepo(ctx, RemoteInstallOptions{RepoURL: repoURL}) + if err != nil { + t.Fatalf("InstallPluginFromRepo: %v", err) + } + if res.Manifest.Tag != remoteTestTagMid { + t.Errorf("installed tag = %s, want newest v0.2.0", res.Manifest.Tag) + } + if res.Manifest.SHA256 == "" || res.Manifest.Asset == "" { + t.Errorf("manifest missing provenance: %+v", res.Manifest) + } + if got := installedPayload(t, "demo"); got != "payload-0.2.0" { + t.Errorf("installed payload = %q, want payload-0.2.0", got) + } + + // Second install without --force refuses. + if _, err := InstallPluginFromRepo(ctx, RemoteInstallOptions{RepoURL: repoURL}); err == nil || !strings.Contains(err.Error(), "--force") { + t.Errorf("reinstall without force = %v, want already-installed error", err) + } + + // Upgrade with no new tag reports up-to-date. + o, err := UpgradeInstalledPlugin(ctx, "demo") + if err != nil || !o.UpToDate { + t.Errorf("upgrade with no new tag = %+v, %v; want UpToDate", o, err) + } + + // New tag + new asset → upgrade replaces the binary. + srv2 := pluginReleaseServer(t, "0.1.0", "0.2.0", "0.3.0") + defer srv2.Close() + updateRepoMetadata(t, repoURL, fmt.Sprintf("name: demo\ndownload_url: \"%s/dl/{tag}/{asset}\"\n", srv2.URL)) + gitTag(t, repoURL, "v0.3.0") + o, err = UpgradeInstalledPlugin(ctx, "demo") + if err != nil { + t.Fatalf("UpgradeInstalledPlugin: %v", err) + } + if o.FromTag != remoteTestTagMid || o.ToTag != "v0.3.0" { + t.Errorf("upgrade outcome = %+v, want v0.2.0 → v0.3.0", o) + } + if got := installedPayload(t, "demo"); got != "payload-0.3.0" { + t.Errorf("post-upgrade payload = %q, want payload-0.3.0", got) + } +} + +// updateRepoMetadata commits a new entire-plugin.yml to the test repo. +func updateRepoMetadata(t *testing.T, repoURL, metadata string) { + t.Helper() + dir := strings.TrimPrefix(repoURL, "file://") + if err := os.WriteFile(dir+"/"+pluginMetadataFileName, []byte(metadata), 0o600); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"-C", dir, "add", pluginMetadataFileName}, + {"-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "--no-gpg-sign", "-m", "update metadata"}, + } { + if out, err := exec.CommandContext(t.Context(), "git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } +} + +func TestInstallPluginFromRepo_PinnedSkipsUpgrade(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + ctx := context.Background() + + srv := pluginReleaseServer(t, "0.1.0", "0.2.0") + defer srv.Close() + meta := fmt.Sprintf("name: demo\ndownload_url: \"%s/dl/{tag}/{asset}\"\n", srv.URL) + repoURL := newTaggedPluginRepo(t, meta, remoteTestTagOld, remoteTestTagMid) + + res, err := InstallPluginFromRepo(ctx, RemoteInstallOptions{RepoURL: repoURL, Pin: remoteTestTagOld}) + if err != nil { + t.Fatalf("pinned install: %v", err) + } + if res.Manifest.Tag != remoteTestTagOld || !res.Manifest.Pinned { + t.Errorf("manifest = %+v, want pinned v0.1.0", res.Manifest) + } + if got := installedPayload(t, "demo"); got != "payload-0.1.0" { + t.Errorf("payload = %q, want payload-0.1.0", got) + } + o, err := UpgradeInstalledPlugin(ctx, "demo") + if err != nil || !o.Pinned { + t.Errorf("upgrade of pinned = %+v, %v; want Pinned skip", o, err) + } +} + +func TestInstallPluginFromRepo_FallsBackPastAssetlessTag(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + ctx := context.Background() + + // Assets exist for 0.1.0 only; v0.2.0 is a pushed tag with no + // published release. Install must fall back with the skipped tag + // reported. + srv := pluginReleaseServer(t, "0.1.0") + defer srv.Close() + meta := fmt.Sprintf("name: demo\ndownload_url: \"%s/dl/{tag}/{asset}\"\n", srv.URL) + repoURL := newTaggedPluginRepo(t, meta, remoteTestTagOld, remoteTestTagMid) + + res, err := InstallPluginFromRepo(ctx, RemoteInstallOptions{RepoURL: repoURL}) + if err != nil { + t.Fatalf("InstallPluginFromRepo: %v", err) + } + if res.Manifest.Tag != remoteTestTagOld { + t.Errorf("tag = %s, want fallback to v0.1.0", res.Manifest.Tag) + } + if len(res.SkippedTags) != 1 || res.SkippedTags[0] != remoteTestTagMid { + t.Errorf("SkippedTags = %v, want [v0.2.0]", res.SkippedTags) + } +} + +func TestUpgradeInstalledPlugin_NoManifest(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + if _, err := UpgradeInstalledPlugin(context.Background(), "localdev"); err == nil || !strings.Contains(err.Error(), "manifest") { + t.Errorf("err = %v, want no-manifest explanation", err) + } +} + +func TestRemoveManagedPlugin_CleansBinAndPkg(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + ctx := context.Background() + + srv := pluginReleaseServer(t, "0.1.0") + defer srv.Close() + meta := fmt.Sprintf("name: demo\ndownload_url: \"%s/dl/{tag}/{asset}\"\n", srv.URL) + repoURL := newTaggedPluginRepo(t, meta, remoteTestTagOld) + if _, err := InstallPluginFromRepo(ctx, RemoteInstallOptions{RepoURL: repoURL}); err != nil { + t.Fatalf("install: %v", err) + } + if err := RemoveManagedPlugin("demo"); err != nil { + t.Fatalf("RemoveManagedPlugin: %v", err) + } + if p, err := FindInstalledPlugin("demo"); err != nil || p != nil { + t.Error("bin entry survived removal") + } + if m, err := LoadPluginManifest("demo"); err != nil || m != nil { + t.Error("manifest survived removal") + } +} + +func TestUpgradeInstalledPlugin_EquivalentTagSpellingIsUpToDate(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + // Manifest recorded the bare spelling; the remote tag carries the v + // prefix. Equivalent semver must not trigger a reinstall — the repo + // has no release server at all, so any download attempt would fail. + repoURL := newTaggedPluginRepo(t, "", "v0.2.0") + if err := SavePluginManifest(&PluginManifest{Name: "demo", RepoURL: repoURL, Tag: "0.2.0"}); err != nil { + t.Fatal(err) + } + o, err := UpgradeInstalledPlugin(context.Background(), "demo") + if err != nil { + t.Fatalf("UpgradeInstalledPlugin: %v", err) + } + if !o.UpToDate { + t.Errorf("outcome = %+v, want UpToDate for equivalent tag spellings", o) + } +} + +func TestUpgradeInstalledPlugin_AssetlessNewestTagReportsUpToDate(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + withIsolatedPath(t) + ctx := context.Background() + // Assets exist only for 0.1.0; v0.2.0 is tagged but unpublished. + // Upgrade falls back to the installed version and must report + // up-to-date, not a misleading "v0.1.0 → v0.1.0" upgrade line. + srv := pluginReleaseServer(t, "0.1.0") + defer srv.Close() + meta := fmt.Sprintf("name: demo\ndownload_url: \"%s/dl/{tag}/{asset}\"\n", srv.URL) + repoURL := newTaggedPluginRepo(t, meta, remoteTestTagOld) + if _, err := InstallPluginFromRepo(ctx, RemoteInstallOptions{RepoURL: repoURL}); err != nil { + t.Fatalf("install: %v", err) + } + gitTag(t, repoURL, remoteTestTagMid) // newer tag, no assets + o, err := UpgradeInstalledPlugin(ctx, "demo") + if err != nil { + t.Fatalf("UpgradeInstalledPlugin: %v", err) + } + if !o.UpToDate { + t.Errorf("outcome = %+v, want UpToDate when fallback lands on the installed tag", o) + } +} diff --git a/cmd/entire/cli/plugin_manifest.go b/cmd/entire/cli/plugin_manifest.go new file mode 100644 index 0000000000..c6cc5dc776 --- /dev/null +++ b/cmd/entire/cli/plugin_manifest.go @@ -0,0 +1,220 @@ +package cli + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "gopkg.in/yaml.v3" +) + +// Managed-install manifests. A plugin installed from a remote repository +// gets a per-plugin home under the managed dir: +// +// /pkg//entire-[.exe] the binary +// /pkg//manifest.yml install provenance +// +// The bin/ dir remains the only dispatch surface — pkg/ binaries are +// linked into bin/ via the same symlink→hardlink→copy fallback as local +// installs, so the kubectl-style resolver in plugin.go never changes. +// +// Local-dev installs (`entire plugin install ./path`) bypass pkg/ entirely +// and have no manifest; ListPluginManifests simply doesn't see them. +const ( + pluginManagedPkgSubdir = "pkg" + pluginManifestFileName = "manifest.yml" +) + +// PluginManifest records how a managed plugin was installed. Settings +// configure behavior; manifests record facts. The dependency list is +// copied from the plugin's metadata at install time so reverse-dependency +// checks (remove guard, doctor) work offline. +type PluginManifest struct { + // Name is the bare plugin name ("run" for entire-run). + Name string `yaml:"name"` + // RepoURL is the full git URL the plugin was installed from. + RepoURL string `yaml:"repo_url"` + // Tag is the git tag that was installed (e.g. "v0.2.1"). + Tag string `yaml:"tag"` + // Asset is the release asset filename the binary came from. Empty for + // raw-binary downloads where the asset name equals the binary name. + Asset string `yaml:"asset,omitempty"` + // SHA256 is the hex digest of the downloaded asset. + SHA256 string `yaml:"sha256,omitempty"` + // Pinned marks installs done with --pin; upgrade skips them. + Pinned bool `yaml:"pinned,omitempty"` + // InstalledAt is when the install (or last upgrade) completed. + InstalledAt time.Time `yaml:"installed_at,omitempty"` + // Requires is the dependency list from the plugin's entire-plugin.yml + // at the installed tag. + Requires []PluginRequirement `yaml:"requires,omitempty"` +} + +// PluginRequirement declares a dependency on another plugin. Shared between +// the author-side metadata file (entire-plugin.yml) and the install-side +// manifest so the two can never drift. +type PluginRequirement struct { + // Name is the bare plugin name of the dependency. + Name string `yaml:"name"` + // RepoURL is where to install the dependency from when it's missing. + // Optional when the dependency is resolvable through the index. + RepoURL string `yaml:"repo_url,omitempty"` + // MinVersion is the minimum acceptable tag (e.g. "v0.2.0"). Minimum + // only — there is deliberately no range syntax. + MinVersion string `yaml:"min_version,omitempty"` +} + +// PluginMetadata is the author-side declaration committed at the root of a +// plugin repository as entire-plugin.yml. Everything is optional — a repo +// without the file installs fine; the name then derives from the repo URL. +type PluginMetadata struct { + // Name is the bare plugin name. When empty, derived from the repo URL + // basename (entire-run → run). + Name string `yaml:"name,omitempty"` + // Description is a one-line summary shown by info/search. + Description string `yaml:"description,omitempty"` + // DownloadURL overrides the per-forge release-asset URL convention. + // Template placeholders: {name} {tag} {version} {os} {arch} {asset}. + // When {asset} is present, candidate asset filenames are substituted; + // otherwise the expanded template is fetched as-is. + DownloadURL string `yaml:"download_url,omitempty"` + // Requires lists plugins this plugin needs at runtime. + Requires []PluginRequirement `yaml:"requires,omitempty"` +} + +// pluginMetadataFileName is the well-known path of the author-side metadata +// file at the root of a plugin repository. +const pluginMetadataFileName = "entire-plugin.yml" + +// ParsePluginMetadata decodes entire-plugin.yml content. Strict decoding: +// unknown keys are an error, surfacing author typos at install time rather +// than silently ignoring a misspelled "requires". +func ParsePluginMetadata(data []byte) (*PluginMetadata, error) { + var meta PluginMetadata + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&meta); err != nil { + return nil, fmt.Errorf("parsing %s: %w", pluginMetadataFileName, err) + } + if meta.Name != "" { + if err := validatePluginName(meta.Name); err != nil { + return nil, fmt.Errorf("%s declares invalid name: %w", pluginMetadataFileName, err) + } + } + for _, req := range meta.Requires { + if err := validatePluginName(req.Name); err != nil { + return nil, fmt.Errorf("%s declares invalid requirement: %w", pluginMetadataFileName, err) + } + } + return &meta, nil +} + +// PluginPkgDir returns the per-plugin package directory for the given bare +// name. Not created — callers use EnsurePluginPkgDir when writing. +func PluginPkgDir(name string) (string, error) { + if err := validatePluginName(name); err != nil { + return "", err + } + parent, err := pluginParentDir() + if err != nil { + return "", err + } + return filepath.Join(parent, pluginManagedPkgSubdir, name), nil +} + +// EnsurePluginPkgDir creates the package directory for name. +func EnsurePluginPkgDir(name string) (string, error) { + dir, err := PluginPkgDir(name) + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0o750); err != nil { + return "", fmt.Errorf("create plugin pkg dir: %w", err) + } + return dir, nil +} + +// LoadPluginManifest reads the manifest for name. Returns (nil, nil) when +// the plugin has no manifest — local-dev installs and raw-PATH plugins. +func LoadPluginManifest(name string) (*PluginManifest, error) { + dir, err := PluginPkgDir(name) + if err != nil { + return nil, err + } + data, err := os.ReadFile(filepath.Join(dir, pluginManifestFileName)) //nolint:gosec // path is inside the managed pkg tree + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil //nolint:nilnil // no-manifest signal + } + return nil, fmt.Errorf("read plugin manifest: %w", err) + } + var m PluginManifest + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse plugin manifest for %q: %w", name, err) + } + return &m, nil +} + +// SavePluginManifest writes the manifest into the plugin's pkg dir. +func SavePluginManifest(m *PluginManifest) error { + dir, err := EnsurePluginPkgDir(m.Name) + if err != nil { + return err + } + data, err := yaml.Marshal(m) + if err != nil { + return fmt.Errorf("marshal plugin manifest: %w", err) + } + path := filepath.Join(dir, pluginManifestFileName) + if err := os.WriteFile(path, data, 0o644); err != nil { //nolint:gosec // manifest is non-secret provenance metadata + return fmt.Errorf("write plugin manifest: %w", err) + } + return nil +} + +// ListPluginManifests returns the manifests of every remote-installed +// plugin, sorted by name. Pkg entries without a readable manifest are +// skipped — a half-removed plugin shouldn't break listing. +func ListPluginManifests() ([]*PluginManifest, error) { + parent, err := pluginParentDir() + if err != nil { + return nil, err + } + entries, err := os.ReadDir(filepath.Join(parent, pluginManagedPkgSubdir)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("read plugin pkg dir: %w", err) + } + var out []*PluginManifest + for _, e := range entries { + if !e.IsDir() { + continue + } + m, err := LoadPluginManifest(e.Name()) + if err != nil || m == nil { + continue + } + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +// RemovePluginPkg deletes the plugin's pkg dir (binary + manifest). +// Missing dir is not an error — local-dev installs never had one. +func RemovePluginPkg(name string) error { + dir, err := PluginPkgDir(name) + if err != nil { + return err + } + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("remove plugin pkg dir: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/plugin_manifest_test.go b/cmd/entire/cli/plugin_manifest_test.go new file mode 100644 index 0000000000..3efd2132ed --- /dev/null +++ b/cmd/entire/cli/plugin_manifest_test.go @@ -0,0 +1,110 @@ +package cli + +import ( + "strings" + "testing" + "time" +) + +func TestPluginManifest_Roundtrip(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + in := &PluginManifest{ + Name: "run", + RepoURL: "https://github.com/entireio/entire-run", + Tag: "v1.2.3", + Asset: "entire-run_1.2.3_darwin_arm64.tar.gz", + SHA256: "abc", + Pinned: true, + InstalledAt: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC), + Requires: []PluginRequirement{{Name: "sem", RepoURL: "https://github.com/entireio/entire-sem", MinVersion: "v0.2.0"}}, + } + if err := SavePluginManifest(in); err != nil { + t.Fatalf("SavePluginManifest: %v", err) + } + out, err := LoadPluginManifest("run") + if err != nil { + t.Fatalf("LoadPluginManifest: %v", err) + } + if out == nil || out.Tag != in.Tag || out.RepoURL != in.RepoURL || !out.Pinned || len(out.Requires) != 1 || out.Requires[0].MinVersion != "v0.2.0" { + t.Errorf("roundtrip mismatch: %+v", out) + } +} + +func TestLoadPluginManifest_AbsentIsNilNil(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + m, err := LoadPluginManifest("ghost") + if err != nil || m != nil { + t.Errorf("LoadPluginManifest(ghost) = %v, %v; want nil, nil", m, err) + } +} + +func TestListPluginManifests_SortedAndTolerant(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + for _, name := range []string{"zeta", "alpha"} { + if err := SavePluginManifest(&PluginManifest{Name: name, RepoURL: "https://x.example/" + name, Tag: "v1.0.0"}); err != nil { + t.Fatal(err) + } + } + // A pkg dir without a manifest (half-removed plugin) must not break listing. + if _, err := EnsurePluginPkgDir("broken"); err != nil { + t.Fatal(err) + } + got, err := ListPluginManifests() + if err != nil { + t.Fatalf("ListPluginManifests: %v", err) + } + if len(got) != 2 || got[0].Name != "alpha" || got[1].Name != "zeta" { + t.Errorf("ListPluginManifests = %+v, want [alpha zeta]", got) + } +} + +func TestRemovePluginPkg(t *testing.T) { //nolint:paralleltest // mutates env + withPluginDir(t) + if err := SavePluginManifest(&PluginManifest{Name: "run", RepoURL: "https://x.example/run", Tag: "v1.0.0"}); err != nil { + t.Fatal(err) + } + if err := RemovePluginPkg("run"); err != nil { + t.Fatalf("RemovePluginPkg: %v", err) + } + if m, err := LoadPluginManifest("run"); err != nil || m != nil { + t.Error("manifest survived RemovePluginPkg") + } + // Removing a never-installed pkg is not an error. + if err := RemovePluginPkg("ghost"); err != nil { + t.Errorf("RemovePluginPkg(ghost) = %v, want nil", err) + } +} + +func TestParsePluginMetadata(t *testing.T) { + t.Parallel() + meta, err := ParsePluginMetadata([]byte(` +name: brain +description: Repository memory +download_url: "https://dl.example.com/{tag}/{asset}" +requires: + - name: sem + repo_url: https://github.com/entireio/entire-sem + min_version: v0.2.0 +`)) + if err != nil { + t.Fatalf("ParsePluginMetadata: %v", err) + } + if meta.Name != "brain" || len(meta.Requires) != 1 || meta.Requires[0].Name != "sem" { + t.Errorf("parsed %+v", meta) + } + + // Unknown keys are author typos; strict decoding surfaces them. The + // key must NOT be a near-miss spelling of a real field: a spell-fixing + // formatter pass once rewrote such a key into the correctly-spelled + // field name, which made the input valid and the test vacuous. + if _, err := ParsePluginMetadata([]byte("name: x\nnot_a_real_field:\n - name: y\n")); err == nil { + t.Error("ParsePluginMetadata accepted unknown key") + } + // Reserved names rejected. + if _, err := ParsePluginMetadata([]byte("name: agent-evil\n")); err == nil || !strings.Contains(err.Error(), "reserved") { + t.Errorf("ParsePluginMetadata(agent-evil) = %v, want reserved-name error", err) + } + if _, err := ParsePluginMetadata([]byte("name: ok\nrequires:\n - name: agent-evil\n")); err == nil { + t.Error("ParsePluginMetadata accepted reserved requirement name") + } +} diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index fe2bef0a9a..9cc41babc3 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -125,11 +125,83 @@ type EntireSettings struct { // nil/true = sign (default), false = skip signing. SignCheckpointCommits *bool `json:"sign_checkpoint_commits,omitempty"` + // Plugins configures the external-command plugin layer (index discovery + // and remote install). Plugin state (what's installed, pins, manifests) + // lives in the managed plugin directory, never in settings. Nil means + // all defaults. + Plugins *PluginSettings `json:"plugins,omitempty"` + // Deprecated: no longer used. Exists to tolerate old settings files // that still contain "strategy": "auto-commit" or similar. Strategy string `json:"strategy,omitempty"` } +// PluginSettings configures plugin discovery and remote install behavior. +// +// Settings are repo-level by design: a repository can commit plugins.index_url +// to point contributors at an internal plugin catalog. The managed plugin +// store itself is per-user; these settings only steer discovery. Precedence +// for the effective index URL (resolved in the cli package, not here): +// --index flag > ENTIRE_PLUGIN_INDEX_URL > settings.local.json > +// settings.json > built-in default. +type PluginSettings struct { + // IndexURL is the git URL of the plugin index repository. Empty means + // the built-in default index. + IndexURL string `json:"index_url,omitempty"` + + // IndexTTLHours is how long a synced index copy is considered fresh + // before plugin search/install trigger a re-sync. Zero means "unset" + // — the caller applies the default (24). `entire plugin index update` + // always forces a refresh regardless of TTL. + IndexTTLHours int `json:"index_ttl_hours,omitempty"` +} + +// Validate returns an error for semantically invalid plugin settings. +// IndexURL must look like a git-cloneable URL: https://, ssh://, file://, +// or scp-like git@host:path. The load path calls this after merging. +func (p *PluginSettings) Validate() error { + if p == nil { + return nil + } + if p.IndexTTLHours < 0 { + return fmt.Errorf("plugins.index_ttl_hours must be >= 0, got %d", p.IndexTTLHours) + } + if p.IndexURL == "" { + return nil + } + for _, prefix := range []string{"https://", "ssh://", "file://", "git@"} { + if strings.HasPrefix(p.IndexURL, prefix) { + return nil + } + } + return fmt.Errorf("plugins.index_url %q must start with https://, ssh://, file://, or git@", p.IndexURL) +} + +// IndexTTL returns the configured index freshness window. Zero or negative +// IndexTTLHours (or a nil receiver) yields the 24h default. +func (p *PluginSettings) IndexTTL() time.Duration { + if p == nil || p.IndexTTLHours < 1 { + return 24 * time.Hour + } + return time.Duration(p.IndexTTLHours) * time.Hour +} + +// PluginIndexURL returns the configured index URL, or "" when unset. +func (s *EntireSettings) PluginIndexURL() string { + if s == nil || s.Plugins == nil { + return "" + } + return s.Plugins.IndexURL +} + +// PluginIndexTTL returns the effective index freshness window (default 24h). +func (s *EntireSettings) PluginIndexTTL() time.Duration { + if s == nil { + return 24 * time.Hour + } + return s.Plugins.IndexTTL() +} + // ClonePreferences stores clone-local, uncommitted preferences that should be // shared by linked worktrees in the same git clone. // @@ -411,6 +483,9 @@ func loadMergedSettings(settingsFileAbs, preferencesFileAbs, localSettingsFileAb if err := settings.SummaryGeneration.Validate(); err != nil { return nil, fmt.Errorf("merged settings invalid: %w", err) } + if err := settings.Plugins.Validate(); err != nil { + return nil, fmt.Errorf("merged settings invalid: %w", err) + } return settings, nil } @@ -681,6 +756,26 @@ func mergeJSON(settings *EntireSettings, data []byte) error { return err } + if err := mergePlugins(settings, raw); err != nil { + return err + } + + return nil +} + +// mergePlugins replaces the plugin config from the override (whole-object +// replacement, parallel to mergeInvestigate — the schema is small and +// per-field merge semantics aren't worth the machinery). +func mergePlugins(settings *EntireSettings, raw map[string]json.RawMessage) error { + pluginsRaw, ok := raw["plugins"] + if !ok { + return nil + } + var cfg PluginSettings + if err := unmarshalField("plugins", pluginsRaw, &cfg); err != nil { + return err + } + settings.Plugins = &cfg return nil } diff --git a/cmd/entire/cli/settings/settings_plugins_test.go b/cmd/entire/cli/settings/settings_plugins_test.go new file mode 100644 index 0000000000..08a16159c1 --- /dev/null +++ b/cmd/entire/cli/settings/settings_plugins_test.go @@ -0,0 +1,106 @@ +package settings + +import ( + "strings" + "testing" + "time" +) + +func TestPluginSettings_Validate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + in *PluginSettings + wantErr string + }{ + {name: "nil", in: nil}, + {name: "empty", in: &PluginSettings{}}, + {name: "https", in: &PluginSettings{IndexURL: "https://github.com/entireio/plugin-index"}}, + {name: "ssh", in: &PluginSettings{IndexURL: "ssh://git@example.com/idx.git"}}, + {name: "scp-like", in: &PluginSettings{IndexURL: "git@github.com:entireio/plugin-index.git"}}, + {name: "file", in: &PluginSettings{IndexURL: "file:///tmp/plugin-index"}}, + {name: "bad scheme", in: &PluginSettings{IndexURL: "http://insecure.example.com/idx"}, wantErr: "index_url"}, + {name: "bare path", in: &PluginSettings{IndexURL: "/tmp/plugin-index"}, wantErr: "index_url"}, + {name: "negative ttl", in: &PluginSettings{IndexTTLHours: -1}, wantErr: "index_ttl_hours"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.in.Validate() + if tt.wantErr == "" { + if err != nil { + t.Fatalf("Validate() = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Validate() = %v, want error containing %q", err, tt.wantErr) + } + }) + } +} + +func TestPluginSettings_IndexTTL(t *testing.T) { + t.Parallel() + var nilSettings *PluginSettings + if got := nilSettings.IndexTTL(); got != 24*time.Hour { + t.Errorf("nil receiver TTL = %v, want 24h", got) + } + if got := (&PluginSettings{}).IndexTTL(); got != 24*time.Hour { + t.Errorf("zero TTL = %v, want 24h", got) + } + if got := (&PluginSettings{IndexTTLHours: 2}).IndexTTL(); got != 2*time.Hour { + t.Errorf("TTL = %v, want 2h", got) + } +} + +func TestMergeJSON_PluginsWholeObjectReplace(t *testing.T) { + t.Parallel() + base := &EntireSettings{ + Enabled: true, + Plugins: &PluginSettings{IndexURL: "https://example.com/base", IndexTTLHours: 48}, + } + // Override sets only index_url; whole-object replacement means the + // TTL from base is dropped, matching investigate's semantics. + if err := mergeJSON(base, []byte(`{"plugins":{"index_url":"https://example.com/override"}}`)); err != nil { + t.Fatalf("mergeJSON: %v", err) + } + if base.Plugins.IndexURL != "https://example.com/override" { + t.Errorf("IndexURL = %q, want override", base.Plugins.IndexURL) + } + if base.Plugins.IndexTTLHours != 0 { + t.Errorf("IndexTTLHours = %d, want 0 (whole-object replace)", base.Plugins.IndexTTLHours) + } +} + +func TestMergeJSON_PluginsAbsentKeyPreservesBase(t *testing.T) { + t.Parallel() + base := &EntireSettings{ + Enabled: true, + Plugins: &PluginSettings{IndexURL: "https://example.com/base"}, + } + if err := mergeJSON(base, []byte(`{"log_level":"debug"}`)); err != nil { + t.Fatalf("mergeJSON: %v", err) + } + if base.Plugins == nil || base.Plugins.IndexURL != "https://example.com/base" { + t.Errorf("Plugins = %+v, want base preserved", base.Plugins) + } +} + +func TestEntireSettings_PluginAccessors(t *testing.T) { + t.Parallel() + var nilSettings *EntireSettings + if got := nilSettings.PluginIndexURL(); got != "" { + t.Errorf("nil PluginIndexURL = %q, want empty", got) + } + if got := nilSettings.PluginIndexTTL(); got != 24*time.Hour { + t.Errorf("nil PluginIndexTTL = %v, want 24h", got) + } + s := &EntireSettings{Plugins: &PluginSettings{IndexURL: "https://example.com/idx", IndexTTLHours: 1}} + if got := s.PluginIndexURL(); got != "https://example.com/idx" { + t.Errorf("PluginIndexURL = %q", got) + } + if got := s.PluginIndexTTL(); got != time.Hour { + t.Errorf("PluginIndexTTL = %v, want 1h", got) + } +} diff --git a/docs/architecture/external-commands.md b/docs/architecture/external-commands.md index 0e4cc5903f..ec71ca7820 100644 --- a/docs/architecture/external-commands.md +++ b/docs/architecture/external-commands.md @@ -26,7 +26,52 @@ Users can drop binaries anywhere on `$PATH`, but a per-user managed directory is The CLI prepends this directory to `$PATH` at startup via `cli.PrependPluginBinDirToPATH()` so the existing `exec.LookPath` resolution finds managed installs without any special-casing. This is purely additive — the kubectl-style `$PATH` model is unchanged. -`entire plugin install/list/remove` manage the contents of this directory. Authors who prefer the raw "drop a binary on `$PATH`" model don't need to use it. +`entire plugin install/list/remove/upgrade` manage the contents of this directory. Authors who prefer the raw "drop a binary on `$PATH`" model don't need to use it. + +### Remote install + +`entire plugin install` accepts three source forms: + +| Form | Example | Behavior | +|---|---|---| +| bare name | `entire plugin install run` | Resolved through the [plugin index](#plugin-index-discovery) | +| repository URL | `entire plugin install https://github.com/entireio/entire-run` | Installs from any git host | +| local path | `entire plugin install ./dist/entire-run` | Symlink/copy into the managed dir (unchanged) | + +Remote installs are deliberately forge-agnostic: + +1. **Version resolution** uses `git ls-remote --tags` — identical on GitHub, GitLab, Gitea/Forgejo, and self-hosted servers; inherits the user's git auth and proxy config. The highest semver tag wins; `--pin ` installs exactly that tag and marks the manifest pinned (skipped by `upgrade`). +2. **Metadata** is read from `entire-plugin.yml` at the repo root via a blobless shallow clone (with a plain shallow-clone fallback for servers that don't allow partial-clone filters). The file is optional; without it the plugin name derives from the repo basename (`entire-run` → `run`). +3. **Asset download** is the one forge-specific step, contained in a small URL-convention table: GitHub/Gitea-style `/releases/download//`, GitLab-style `/-/releases//downloads/`, unknown hosts default to GitHub-style. Authors on other hosts declare a `download_url` template in `entire-plugin.yml` (placeholders: `{name}` `{tag}` `{version}` `{os}` `{arch}` `{asset}`). +4. **Asset selection** prefers the release's `checksums.txt`: the manifest lists what was actually published, and the download is verified against it. Without one, goreleaser-conventional candidate names are probed (`entire-___.tar.gz` and friends, with `x86_64`/`aarch64` aliases and a `darwin_all` universal-binary fallback). A pushed tag with no published assets falls back to the next-highest tag with a warning. +5. The binary lands in `pkg//` next to a `manifest.yml` recording provenance (repo URL, tag, asset, SHA-256, pin state, dependency list), and is linked into `bin/` through the same symlink→hardlink→copy fallback as local installs. The dispatcher never changes. + +Installing from a URL not listed in the index prints the source and asks for confirmation (`--yes` to skip; required in non-interactive runs). Index-listed repos install without prompting. + +### Plugin index (discovery) + +Discovery rides on a git-synced index, krew-style: the index is itself a git repository containing `index.json`, shallow-cloned into the user cache (keyed by a hash of the URL) and refreshed on a TTL. `entire plugin search [term]`, `info `, and `browse` read it; `entire plugin index update` forces a refresh. When a refresh fails but a cached copy exists, the stale copy is used — discovery doesn't hard-fail offline. + +The effective index URL resolves as: `--index` flag > `ENTIRE_PLUGIN_INDEX_URL` > `plugins.index_url` in `.entire/settings.local.json`/`.entire/settings.json` > the built-in default (`https://github.com/entireio/plugin-index`). The repo-level setting is deliberate: a company can commit `plugins.index_url` to point contributors at an internal catalog. `plugins.index_ttl_hours` tunes freshness (default 24). + +`index.json` schema (version 1): `{"version": 1, "plugins": [{"name", "repo_url", "description", "official", "platforms"}]}`. Entries with invalid names (e.g. the reserved `agent-` prefix) or missing repo URLs are filtered on load, not fatal. + +### Dependencies + +A plugin declares dependencies in `entire-plugin.yml`: + +```yaml +name: brain +requires: + - name: sem + repo_url: https://github.com/entireio/entire-sem # where to get it if missing + min_version: v0.2.0 # minimum only; no ranges +``` + +Resolution is **install-time only** — dispatch stays zero-cost. After installing the main plugin, missing dependencies are resolved transitively (metadata-only, nothing downloaded during planning; a visited set plus depth bound make metadata cycles an error, not a hang), listed apt-style, and installed after one confirmation (`--yes` skips, `--no-deps` opts out). A declined plan is a warning, not a failure. Dependencies already satisfied from raw `$PATH` or a local-dev install count as satisfied, with a warning when `min_version` can't be verified. The requirement list is copied into the install manifest so reverse-dependency checks work offline: + +- `entire plugin remove sem` refuses when another manifest requires it (`--force` overrides). +- `entire plugin doctor` reports missing/outdated dependencies, manifest/bin-dir drift, dangling local-dev symlinks, and (macOS) a `com.apple.quarantine` attribute that would block execution. Exit code 1 when issues are found. > **Compatibility note:** the `entire plugin` command group is itself a built-in. Per the "built-ins win" rule above, it shadows any external command named `entire-plugin` that may have existed on `$PATH` previously. The collision is intentional — managing plugins is a built-in concern — but worth flagging for anyone who shipped an `entire-plugin` external command before this layer landed. @@ -152,6 +197,13 @@ Key files: - `cmd/entire/cli/plugin_env.go` — `pluginEnv`, the allowlist, and `ENTIRE_PLUGIN_ENV` parsing - `cmd/entire/cli/plugin_official.go` — `officialPlugins` allowlist, `IsOfficialPlugin` - `cmd/entire/cli/plugin_store.go` — managed install directory, `PluginBinDir`, `PluginDataDir`, `InstallPluginFromPath`, `ListInstalledPlugins`, `RemoveInstalledPlugin`, `PrependPluginBinDirToPATH` -- `cmd/entire/cli/plugin_group.go` — `entire plugin install/list/remove` Cobra commands +- `cmd/entire/cli/plugin_manifest.go` — `pkg//manifest.yml` provenance records, `entire-plugin.yml` schema +- `cmd/entire/cli/plugin_gitremote.go` — `git ls-remote` tag resolution, blobless metadata fetch +- `cmd/entire/cli/plugin_fetch.go` — forge URL conventions, checksum verification, archive extraction +- `cmd/entire/cli/plugin_install_remote.go` — remote install/upgrade orchestration +- `cmd/entire/cli/plugin_index.go` — git-synced index cache, URL precedence +- `cmd/entire/cli/plugin_deps.go` — dependency planning, remove guard, `plugin doctor` +- `cmd/entire/cli/plugin_group.go` — `entire plugin install/list/remove/upgrade/search/info/browse/doctor/index` Cobra commands - `cmd/entire/cli/telemetry/detached.go` — `BuildPluginEventPayload`, `TrackPluginDetached` - `cmd/entire/cli/integration_test/external_command_test.go` — end-to-end coverage of the resolution path +- `cmd/entire/cli/integration_test/plugin_remote_install_test.go` — end-to-end remote install, dependencies, doctor