From 2a4eff67ba9db0ed7bc5959774d5f6cf649b5fd9 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:19:44 +0000 Subject: [PATCH 1/2] feat: Let coding agents sign in and install every plugin Coding agents run datumctl without a terminal. Two things stopped them from setting it up on their own. datumctl login showed an org/project picker after the browser sign-in. Without a terminal the picker failed for anyone with more than one project, and login returned before saving the session, so the sign-in was lost. Login now honours --project, --organization, DATUM_PROJECT and DATUM_ORGANIZATION to choose the context without the picker. With none of them and no terminal, it saves the session, leaves the context unset and says how to pick one. A current context from another session is cleared so the new session becomes active. Installing every plugin meant parsing the search table and calling install once per name. plugin install --all-available installs every plugin in the official datum catalog (or --index), upgrades ones that are behind, and skips same-name plugins from another source. plugin search gains -o json and -o name for scripts. --- README.md | 4 +- docs/authentication.md | 19 +- docs/developer/plugins.md | 12 ++ internal/cmd/login/login.go | 96 ++++++++++- internal/cmd/login/login_test.go | 160 +++++++++++++++++ internal/cmd/plugin/install.go | 33 +++- internal/cmd/plugin/install_all_available.go | 128 ++++++++++++++ .../cmd/plugin/install_all_available_test.go | 162 ++++++++++++++++++ internal/cmd/plugin/search.go | 89 ++++++++-- 9 files changed, 683 insertions(+), 20 deletions(-) create mode 100644 internal/cmd/login/login_test.go create mode 100644 internal/cmd/plugin/install_all_available.go create mode 100644 internal/cmd/plugin/install_all_available_test.go diff --git a/README.md b/README.md index ab87018..f51abdb 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ See the [Installation Guide](https://www.datum.net/docs/quickstart/datumctl/) fo ```bash datumctl login ``` - Opens your browser for authentication, then fetches your organizations and projects and prompts you to pick a default context. If you only have a single project, the picker is skipped. + Opens your browser for authentication, then fetches your organizations and projects and prompts you to pick a default context. If you only have a single project, the picker is skipped. To skip it yourself, name the context: `datumctl login --project my-org/my-project`. 2. **Work with resources:** ```bash @@ -75,6 +75,8 @@ datumctl login --no-browser It prints a short code and a URL to open on any device. +Coding agents and other tools that run `datumctl` without a terminal can sign a person in too: `datumctl login --project ` (or with `DATUM_PROJECT` set) chooses the context without the picker. With no context named and no terminal, login still signs in and saves the session, and leaves the context for `datumctl ctx use /`. + To run one command as another signed-in account without switching the active one, name its session with `--session` or `DATUM_SESSION`. The value is a session name (`email@api-host`) or an email signed in on only one endpoint. The command uses that session's last context unless you pass a scope: ```bash diff --git a/docs/authentication.md b/docs/authentication.md index 922b7bf..2c9ceba 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -26,7 +26,7 @@ keyring. To authenticate with Datum Cloud, use the `login` command: ``` -datumctl login [--hostname ] [--no-browser] [--credentials ] [-v] +datumctl login [--hostname ] [--no-browser] [--credentials ] [--project | --organization ] [-v] ``` * `--hostname `: (Optional) Specify the hostname of the Datum @@ -37,6 +37,10 @@ datumctl login [--hostname ] [--no-browser] [--credentials * `--credentials `: (Optional) Sign in as a service account using its credentials JSON file. Non-interactive; see [Service accounts](#service-accounts-ci-and-automation). +* `--project ` / `--organization `: (Optional) The context to + switch to after signing in, instead of choosing one in the picker. A + project can be given as `/`. `DATUM_PROJECT` and + `DATUM_ORGANIZATION` work the same way when neither flag is set. * `-v, --verbose`: (Optional) Print the full ID token claims after successful login. @@ -54,6 +58,19 @@ Running this command will: Your credentials (including refresh tokens) are stored securely in the system keyring, associated with your user identifier (typically your email address). +### Choosing a context + +After signing in, `datumctl login` switches to a default context: + +* With `--project`, `--organization`, `DATUM_PROJECT` or `DATUM_ORGANIZATION`, + it switches to that context. If none of your organizations or projects + match, login keeps the session and exits with an error naming the value. +* With a single organization and no projects, it switches to that one. +* Otherwise it shows a picker. The picker needs an interactive terminal. + Without one (a coding agent's shell, a script), login still signs you in + and saves the session, but leaves the context unset. Choose one afterwards + with `datumctl ctx use /`; `datumctl ctx` lists them. + ## Service accounts (CI and automation) A service account is a non-human identity for CI pipelines, scheduled jobs, diff --git a/docs/developer/plugins.md b/docs/developer/plugins.md index 60af2b9..d9252d1 100644 --- a/docs/developer/plugins.md +++ b/docs/developer/plugins.md @@ -76,13 +76,25 @@ Plugins listed there can be installed by name: ```sh datumctl plugin install compute +datumctl plugin install --all-available datumctl plugin search +datumctl plugin search --index datum -o json datumctl plugin list datumctl plugin upgrade compute datumctl plugin upgrade --all datumctl plugin remove compute ``` +`plugin install --all-available` installs every plugin in the official `datum` +catalog, or in the catalog named by `--index`. It upgrades plugins that are +behind the catalog, leaves up-to-date ones alone, and skips a plugin whose name +is already installed from another catalog or from GitHub rather than replace +it. A plugin that fails is reported at the end without stopping the rest. + +`plugin search -o json` prints an array of `{name, index, version, trust, +description}` objects, and `-o name` prints one plugin name per line, for +scripts and agents. + `plugin upgrade` reinstalls a plugin from the catalog or GitHub repository it was installed from, skipping any plugin already at its latest version. With `--all`, every installed plugin is upgraded; a plugin that fails is reported diff --git a/internal/cmd/login/login.go b/internal/cmd/login/login.go index 70eefc8..d8f1e92 100644 --- a/internal/cmd/login/login.go +++ b/internal/cmd/login/login.go @@ -2,6 +2,7 @@ package login import ( "fmt" + "os" "strings" "github.com/pkg/browser" @@ -36,6 +37,12 @@ After authentication you will be prompted to select a default context (org or project) so subsequent commands do not require --project or --organization flags. +Pass --project or --organization (or set DATUM_PROJECT / DATUM_ORGANIZATION) +to choose the context without the prompt. Without a terminal and without +either, login still signs you in but leaves the context unset; pick one +afterwards with 'datumctl ctx use /'. Coding agents and other +tools that run datumctl without a terminal can use either route. + By default, opens your browser for OAuth2 PKCE authentication. Use --no-browser on a machine without a browser (SSH, containers) to sign in with a device code from another device. @@ -54,6 +61,10 @@ and ignores DATUM_SESSION.`, Example: ` # Log in (opens browser, then picks a context) datumctl login + # Log in straight into a project, with no context prompt + datumctl login --project my-project + datumctl login --project my-org/my-project + # CI / automation: log in as a service account, then scope each command datumctl login --credentials ./key.json DATUM_PROJECT=my-project datumctl get dnszones @@ -162,10 +173,24 @@ func runLogin(cmd *cobra.Command, _ []string) error { } fmt.Println() - selected, err := picker.SelectContext(dir, cfg.CurrentContextName()) + selected, err := chooseContext(dir, cfg.CurrentContextName(), requestedContext(cmd, os.Getenv), picker.IsTerminal()) if err != nil { + // The sign-in itself worked, so keep the session even though no + // context was chosen. + if saveErr := saveWithoutContext(cfg, sessionName); saveErr != nil { + return saveErr + } return err } + if selected == nil { + if err := saveWithoutContext(cfg, sessionName); err != nil { + return err + } + fmt.Println("Signed in. No context selected: choosing one needs an interactive terminal.") + fmt.Println("Run 'datumctl ctx use /' to pick one ('datumctl ctx' lists them),") + fmt.Println("or sign in with 'datumctl login --project ' next time.") + return nil + } onboardingResult, err := onboarding.CheckOrg( ctx, @@ -199,6 +224,75 @@ func runLogin(cmd *cobra.Command, _ []string) error { return nil } +// requestedContext returns the organization or project the caller asked to +// work in: --project or --organization, or, when neither flag is set, +// DATUM_PROJECT and DATUM_ORGANIZATION. With both environment variables set it +// returns "org/project". It returns "" when nothing was requested. +func requestedContext(cmd *cobra.Command, getenv func(string) string) string { + flagValue := func(name string) string { + if f := cmd.Flag(name); f != nil { + return f.Value.String() + } + return "" + } + project, org := flagValue("project"), flagValue("organization") + if project == "" && org == "" { + project, org = getenv("DATUM_PROJECT"), getenv("DATUM_ORGANIZATION") + } + switch { + case org != "" && project != "": + return org + "/" + project + case project != "": + return project + default: + return org + } +} + +// chooseContext picks the context login switches to. A requested context must +// match one the user can reach. Otherwise a lone context is chosen +// automatically, and with several the user picks one, if there is a terminal +// to show the picker in. Without one it returns nil and no error, so login can +// keep the session and tell the user how to choose a context. +func chooseContext(dir *discovery.Directory, current, requested string, interactive bool) (*datumconfig.DiscoveredContext, error) { + if requested != "" { + if c := dir.Resolve(requested); c != nil { + return c, nil + } + return nil, customerrors.NewUserErrorWithHint( + fmt.Sprintf("Signed in, but no organization or project matches %q.", requested), + "Run 'datumctl ctx' to list the ones you can use, then 'datumctl ctx use /'.", + ) + } + if contexts := dir.Contexts(); len(contexts) == 1 { + return &contexts[0], nil + } + if !interactive { + return nil, nil + } + return picker.SelectContext(dir, current) +} + +// saveWithoutContext saves the config after a sign-in that chose no context. +// A current context from another session is cleared, because the current +// context decides which session is active and the new session should be. +func saveWithoutContext(cfg *datumconfig.ConfigV1Beta1, sessionName string) error { + clearOtherSessionContext(cfg, sessionName) + if err := datumconfig.SaveV1Beta1(cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + return nil +} + +// clearOtherSessionContext unsets the current context when it belongs to a +// session other than sessionName. A context of the same session (signing in +// again) is kept. +func clearOtherSessionContext(cfg *datumconfig.ConfigV1Beta1, sessionName string) { + if cur := cfg.ContextByName(cfg.CurrentContext); cur != nil && cur.Session != sessionName { + cfg.CurrentContext = "" + } +} + func deriveAuthHostname(apiHostname string) (string, error) { if rest, ok := strings.CutPrefix(apiHostname, "api."); ok { return "auth." + rest, nil diff --git a/internal/cmd/login/login_test.go b/internal/cmd/login/login_test.go new file mode 100644 index 0000000..8616b93 --- /dev/null +++ b/internal/cmd/login/login_test.go @@ -0,0 +1,160 @@ +package login + +import ( + "testing" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl/internal/datumconfig" + "go.datum.net/datumctl/internal/discovery" +) + +func testDirectory() *discovery.Directory { + return &discovery.Directory{ + Session: "alice@example.com@api.datum.net", + Orgs: []discovery.DiscoveredOrg{ + {Name: "acme", DisplayName: "Acme"}, + {Name: "globex", DisplayName: "Globex"}, + }, + Projects: []discovery.DiscoveredProject{ + {Name: "acme-web", DisplayName: "Web", OrgName: "acme"}, + {Name: "acme-api", DisplayName: "API", OrgName: "acme"}, + {Name: "globex-web", DisplayName: "Web", OrgName: "globex"}, + }, + } +} + +func TestChooseContext(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dir *discovery.Directory + requested string + interactive bool + wantRef string + wantErr bool + }{ + {name: "project by id", dir: testDirectory(), requested: "acme-api", wantRef: "acme/acme-api"}, + {name: "org/project", dir: testDirectory(), requested: "globex/globex-web", wantRef: "globex/globex-web"}, + {name: "org only", dir: testDirectory(), requested: "acme", wantRef: "acme"}, + {name: "requested works without a terminal", dir: testDirectory(), requested: "acme/acme-web", interactive: false, wantRef: "acme/acme-web"}, + {name: "unknown requested context errors", dir: testDirectory(), requested: "nope", wantErr: true}, + {name: "ambiguous display name errors", dir: testDirectory(), requested: "Web", wantErr: true}, + {name: "no terminal and nothing requested selects nothing", dir: testDirectory(), interactive: false, wantRef: ""}, + { + name: "a lone context is chosen without a terminal", + dir: &discovery.Directory{ + Session: "bob@example.com@api.datum.net", + Orgs: []discovery.DiscoveredOrg{{Name: "solo"}}, + }, + interactive: false, + wantRef: "solo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := chooseContext(tt.dir, "", tt.requested, tt.interactive) + if tt.wantErr { + if err == nil { + t.Fatalf("expected an error, got context %+v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + gotRef := "" + if got != nil { + gotRef = got.Ref() + } + if gotRef != tt.wantRef { + t.Fatalf("chooseContext() = %q, want %q", gotRef, tt.wantRef) + } + }) + } +} + +// loginCmdWithScopeFlags mirrors how login is mounted: --project and +// --organization are persistent flags on the root command. +func loginCmdWithScopeFlags(t *testing.T, args ...string) *cobra.Command { + t.Helper() + root := &cobra.Command{Use: "datumctl"} + root.PersistentFlags().String("project", "", "") + root.PersistentFlags().String("organization", "", "") + login := &cobra.Command{Use: "login", RunE: func(*cobra.Command, []string) error { return nil }} + root.AddCommand(login) + root.SetArgs(append([]string{"login"}, args...)) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + return login +} + +func TestRequestedContext(t *testing.T) { + t.Parallel() + + env := func(vars map[string]string) func(string) string { + return func(k string) string { return vars[k] } + } + + tests := []struct { + name string + args []string + env map[string]string + want string + }{ + {name: "nothing", want: ""}, + {name: "project flag", args: []string{"--project", "acme-web"}, want: "acme-web"}, + {name: "project flag with org prefix", args: []string{"--project", "acme/acme-web"}, want: "acme/acme-web"}, + {name: "organization flag", args: []string{"--organization", "acme"}, want: "acme"}, + {name: "project env", env: map[string]string{"DATUM_PROJECT": "acme-web"}, want: "acme-web"}, + {name: "organization env", env: map[string]string{"DATUM_ORGANIZATION": "acme"}, want: "acme"}, + {name: "both env", env: map[string]string{"DATUM_ORGANIZATION": "acme", "DATUM_PROJECT": "acme-web"}, want: "acme/acme-web"}, + {name: "flag beats env", args: []string{"--project", "acme-api"}, env: map[string]string{"DATUM_PROJECT": "acme-web"}, want: "acme-api"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cmd := loginCmdWithScopeFlags(t, tt.args...) + if got := requestedContext(cmd, env(tt.env)); got != tt.want { + t.Fatalf("requestedContext() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestClearOtherSessionContext(t *testing.T) { + t.Parallel() + + newCfg := func() *datumconfig.ConfigV1Beta1 { + cfg := &datumconfig.ConfigV1Beta1{} + ctx := datumconfig.DiscoveredContext{Session: "old@example.com@api.datum.net", OrganizationID: "acme"} + ctx.Name = ctx.QualifiedName() + cfg.UpsertContext(ctx) + cfg.CurrentContext = ctx.Name + return cfg + } + + t.Run("another session's context is cleared", func(t *testing.T) { + t.Parallel() + cfg := newCfg() + clearOtherSessionContext(cfg, "new@example.com@api.datum.net") + if cfg.CurrentContext != "" { + t.Fatalf("CurrentContext = %q, want it cleared", cfg.CurrentContext) + } + }) + + t.Run("the same session's context is kept", func(t *testing.T) { + t.Parallel() + cfg := newCfg() + want := cfg.CurrentContext + clearOtherSessionContext(cfg, "old@example.com@api.datum.net") + if cfg.CurrentContext != want { + t.Fatalf("CurrentContext = %q, want %q", cfg.CurrentContext, want) + } + }) +} diff --git a/internal/cmd/plugin/install.go b/internal/cmd/plugin/install.go index b2b2a55..d816a61 100644 --- a/internal/cmd/plugin/install.go +++ b/internal/cmd/plugin/install.go @@ -18,6 +18,10 @@ import ( ) func installCmd(_ *client.DatumCloudFactory) *cobra.Command { + var ( + allAvailable bool + indexName string + ) cmd := &cobra.Command{ Use: "install [name | owner/repo[@version]]", Short: "Install a datumctl plugin", @@ -38,6 +42,12 @@ With an owner/repo argument, installs directly from a GitHub Release: - owner/repo installs the latest release - owner/repo@v1.2.0 installs a specific version +With --all-available, installs every plugin in the official datum catalog (or +the catalog named by --index), upgrades any that are behind, and leaves the rest +alone. A plugin of the same name installed from another catalog or from GitHub +is skipped, not replaced. One failed plugin does not stop the others; failures +are listed at the end and the command exits non-zero. + The plugin binary is written to the managed plugins directory (~/.datumctl/plugins/ by default).`, Example: ` # Install the dns plugin from the official datum catalog @@ -57,7 +67,13 @@ The plugin binary is written to the managed plugins directory datumctl plugin install acme/deploy@v2.0.0 # Restore all previously installed plugins - datumctl plugin install`, + datumctl plugin install + + # Install every plugin in the official datum catalog + datumctl plugin install --all-available + + # Install every plugin in another registered catalog + datumctl plugin install --all-available --index acme`, RunE: func(cmd *cobra.Command, args []string) error { pluginsDir, err := resolvePluginsDir(cmd) if err != nil { @@ -66,6 +82,19 @@ The plugin binary is written to the managed plugins directory currentVersion := componentversion.Get().GitVersion + if allAvailable { + if len(args) > 0 { + return customerrors.NewUserError("--all-available installs a whole catalog; it does not take a plugin name") + } + return installAllAvailable(cmd, pluginsDir, indexName, currentVersion) + } + if indexName != "" { + return customerrors.NewUserErrorWithHint( + "--index only applies with --all-available", + "To install one plugin from a catalog, name it as /, e.g. 'datumctl plugin install acme/deploy'.", + ) + } + if len(args) == 0 { return installAllFromManifest(cmd, pluginsDir, currentVersion) } @@ -91,6 +120,8 @@ The plugin binary is written to the managed plugins directory } return names, cobra.ShellCompDirectiveNoFileComp } + cmd.Flags().BoolVar(&allAvailable, "all-available", false, "Install every plugin in a catalog (the official datum catalog unless --index is set)") + cmd.Flags().StringVar(&indexName, "index", "", "Catalog to install from with --all-available") return cmd } diff --git a/internal/cmd/plugin/install_all_available.go b/internal/cmd/plugin/install_all_available.go new file mode 100644 index 0000000..cb932ce --- /dev/null +++ b/internal/cmd/plugin/install_all_available.go @@ -0,0 +1,128 @@ +package plugin + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + customerrors "go.datum.net/datumctl/internal/errors" + "go.datum.net/datumctl/internal/pluginstore" +) + +// catalogInstallPlan is what 'datumctl plugin install --all-available' does +// with each plugin in a catalog. +type catalogInstallPlan struct { + // install lists plugins that are missing or behind the catalog's version. + install []string + // upToDate lists plugins already installed from this catalog at its version. + upToDate []string + // skipped lists plugins installed from another catalog or from GitHub, + // which --all-available leaves alone rather than replace. + skipped []skippedPlugin +} + +type skippedPlugin struct { + name string + reason string +} + +// planCatalogInstall decides, for each plugin in idx, whether to install it, +// leave it because it is up to date, or skip it because a plugin of the same +// name came from somewhere else. It does no I/O. +func planCatalogInstall(catalogName string, idx *pluginstore.CachedIndex, manifest *pluginstore.Manifest) catalogInstallPlan { + var plan catalogInstallPlan + for i := range idx.Plugins { + p := &idx.Plugins[i] + entry := manifest.Plugins[p.Name] + if entry == nil { + plan.install = append(plan.install, p.Name) + continue + } + if from, _ := installedCatalogLabel(entry); from != catalogName { + plan.skipped = append(plan.skipped, skippedPlugin{ + name: p.Name, + reason: fmt.Sprintf("already installed from %s", from), + }) + continue + } + if entry.Version == p.Spec.Version { + plan.upToDate = append(plan.upToDate, p.Name) + continue + } + plan.install = append(plan.install, p.Name) + } + return plan +} + +// installAllAvailable installs every plugin in one catalog (the official datum +// catalog unless catalogName names another), upgrading any that are behind and +// leaving the rest alone. It carries on past a failed plugin and reports every +// failure at the end. +func installAllAvailable(cmd *cobra.Command, pluginsDir, catalogName, currentVersion string) error { + reg, err := pluginstore.LoadRegistry(pluginsDir) + if err != nil { + return fmt.Errorf("load catalog registry: %w", err) + } + warnDisabledCatalogs(cmd.ErrOrStderr(), reg) + + if catalogName == "" { + catalogName = pluginstore.OfficialCatalogName + } + cat := reg.Find(catalogName) + if cat == nil { + return customerrors.NewUserError(fmt.Sprintf("catalog %q is not registered; run 'datumctl plugin index list' to see registered catalogs", catalogName)) + } + if cat.Disabled { + return customerrors.NewUserError(fmt.Sprintf("catalog %q is disabled: %s", cat.Name, cat.DisabledReason)) + } + idx, err := loadOrRefreshCatalog(cmd, pluginsDir, *cat) + if err != nil { + return indexFetchUserError(err) + } + + manifest, err := pluginstore.Load(pluginsDir) + if err != nil { + return fmt.Errorf("load plugins manifest: %w", err) + } + if manifest.Plugins == nil { + manifest.Plugins = make(map[string]*pluginstore.InstalledPlugin) + } + + plan := planCatalogInstall(cat.Name, idx, manifest) + out := cmd.OutOrStdout() + if len(idx.Plugins) == 0 { + fmt.Fprintf(out, "Catalog %s has no plugins.\n", cat.Name) + return nil + } + for _, name := range plan.upToDate { + fmt.Fprintf(out, "%s %s is up to date\n", name, manifest.Plugins[name].Version) + } + for _, s := range plan.skipped { + fmt.Fprintf(out, "Skipped %s: %s\n", s.name, s.reason) + } + + var errs []string + installed := 0 + for _, name := range plan.install { + entry, _, _, installErr := installPlugin(cmd.Context(), pluginsDir, name, "", currentVersion, idx) + if installErr != nil { + errs = append(errs, fmt.Sprintf("%s: %v", name, installErr)) + continue + } + entry.Catalog = cat.Name + manifest.Plugins[name] = entry + installed++ + fmt.Fprintf(out, "Installed %s %s from %s [%s]\n", name, entry.Version, cat.Name, installBadge(cat.Name)) + } + + if installed > 0 { + if saveErr := pluginstore.Save(pluginsDir, manifest); saveErr != nil { + errs = append(errs, fmt.Sprintf("save plugins manifest: %v", saveErr)) + } + } + if len(errs) > 0 { + return customerrors.NewUserError("some plugins failed to install:\n " + strings.Join(errs, "\n ")) + } + return nil +} diff --git a/internal/cmd/plugin/install_all_available_test.go b/internal/cmd/plugin/install_all_available_test.go new file mode 100644 index 0000000..98117e7 --- /dev/null +++ b/internal/cmd/plugin/install_all_available_test.go @@ -0,0 +1,162 @@ +package plugin + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "go.datum.net/datumctl/internal/pluginstore" +) + +func TestPlanCatalogInstall(t *testing.T) { + t.Parallel() + + idx := &pluginstore.CachedIndex{Plugins: []pluginstore.Plugin{ + testPlugin("dns", "v1.2.0", "DNS"), + testPlugin("compute", "v2.0.0", "Compute"), + testPlugin("alb", "v0.5.0", "ALB"), + testPlugin("search", "v0.1.0", "Search"), + testPlugin("ipam", "v0.3.0", "IPAM"), + }} + manifest := &pluginstore.Manifest{Plugins: map[string]*pluginstore.InstalledPlugin{ + // Up to date from the official catalog. + "dns": {Source: "dns", Catalog: "datum", Version: "v1.2.0"}, + // Behind the catalog: upgrade. + "compute": {Source: "compute", Catalog: "datum", Version: "v1.9.0"}, + // Same name from a third-party catalog: leave it. + "alb": {Source: "alb", Catalog: "acme", Version: "v9.0.0"}, + // Direct GitHub install: leave it. + "search": {Source: "github.com/someone/datumctl-search", Version: "v0.0.1"}, + // "ipam" is not installed: install. + }} + + got := planCatalogInstall("datum", idx, manifest) + + if want := []string{"compute", "ipam"}; !reflect.DeepEqual(got.install, want) { + t.Errorf("install = %v, want %v", got.install, want) + } + if want := []string{"dns"}; !reflect.DeepEqual(got.upToDate, want) { + t.Errorf("upToDate = %v, want %v", got.upToDate, want) + } + wantSkipped := []skippedPlugin{ + {name: "alb", reason: "already installed from acme"}, + {name: "search", reason: "already installed from (direct)"}, + } + if !reflect.DeepEqual(got.skipped, wantSkipped) { + t.Errorf("skipped = %+v, want %+v", got.skipped, wantSkipped) + } +} + +func TestPlanCatalogInstall_legacyRecordsCountAsOfficial(t *testing.T) { + t.Parallel() + + idx := &pluginstore.CachedIndex{Plugins: []pluginstore.Plugin{testPlugin("dns", "v1.0.0", "DNS")}} + manifest := &pluginstore.Manifest{Plugins: map[string]*pluginstore.InstalledPlugin{ + // Recorded before the catalog field existed. + "dns": {Source: "dns", Version: "v1.0.0"}, + }} + + got := planCatalogInstall("datum", idx, manifest) + if len(got.install) != 0 || len(got.skipped) != 0 || !reflect.DeepEqual(got.upToDate, []string{"dns"}) { + t.Fatalf("legacy official record should be up to date, got %+v", got) + } +} + +func TestInstallAllAvailable_upToDateNeedsNoDownload(t *testing.T) { + pluginsDir := t.TempDir() + seedFreshCache(t, pluginsDir, "datum", testPlugin("dns", "v1.2.0", "DNS")) + if err := pluginstore.Save(pluginsDir, &pluginstore.Manifest{Plugins: map[string]*pluginstore.InstalledPlugin{ + "dns": {Source: "dns", Catalog: "datum", Version: "v1.2.0"}, + }}); err != nil { + t.Fatal(err) + } + + out, err := execPluginCmd(t, pluginsDir, installCmd(nil), "--all-available") + if err != nil { + t.Fatalf("install --all-available failed: %v\n%s", err, out) + } + if !strings.Contains(out, "dns v1.2.0 is up to date") { + t.Fatalf("expected an up-to-date line, got:\n%s", out) + } +} + +func TestInstallAllAvailable_emptyCatalog(t *testing.T) { + pluginsDir := t.TempDir() + seedFreshCache(t, pluginsDir, "datum") + + out, err := execPluginCmd(t, pluginsDir, installCmd(nil), "--all-available") + if err != nil { + t.Fatalf("install --all-available failed: %v\n%s", err, out) + } + if !strings.Contains(out, "Catalog datum has no plugins.") { + t.Fatalf("expected an empty-catalog message, got:\n%s", out) + } +} + +func TestInstallAllAvailable_flagErrors(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "plugin name with --all-available", args: []string{"--all-available", "dns"}, want: "does not take a plugin name"}, + {name: "--index without --all-available", args: []string{"--index", "datum", "dns"}, want: "--index only applies with --all-available"}, + {name: "unregistered catalog", args: []string{"--all-available", "--index", "nope"}, want: `catalog "nope" is not registered`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := execPluginCmd(t, t.TempDir(), installCmd(nil), tt.args...) + if err == nil { + t.Fatalf("expected an error, got output:\n%s", out) + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error %q does not mention %q", err.Error(), tt.want) + } + }) + } +} + +func TestSearch_outputFormats(t *testing.T) { + pluginsDir := t.TempDir() + seedFreshCache(t, pluginsDir, "datum", + testPlugin("dns", "v1.2.3", "Manage DNS"), + testPlugin("compute", "v2.0.0", "Run workloads"), + ) + + jsonOut, err := execPluginCmd(t, pluginsDir, searchCmd(), "--index", "datum", "-o", "json") + if err != nil { + t.Fatalf("search -o json failed: %v", err) + } + var results []searchResult + if err := json.Unmarshal([]byte(jsonOut), &results); err != nil { + t.Fatalf("search -o json is not a JSON array: %v\n%s", err, jsonOut) + } + want := []searchResult{ + {Name: "dns", Index: "datum", Version: "v1.2.3", Trust: pluginstore.TrustOfficial, Description: "Manage DNS"}, + {Name: "compute", Index: "datum", Version: "v2.0.0", Trust: pluginstore.TrustOfficial, Description: "Run workloads"}, + } + if !reflect.DeepEqual(results, want) { + t.Fatalf("search -o json = %+v, want %+v", results, want) + } + + nameOut, err := execPluginCmd(t, pluginsDir, searchCmd(), "--index", "datum", "-o", "name") + if err != nil { + t.Fatalf("search -o name failed: %v", err) + } + if nameOut != "dns\ncompute\n" { + t.Fatalf("search -o name = %q", nameOut) + } + + emptyOut, err := execPluginCmd(t, pluginsDir, searchCmd(), "nothing-matches", "-o", "json") + if err != nil { + t.Fatalf("search -o json with no matches failed: %v", err) + } + if strings.TrimSpace(emptyOut) != "[]" { + t.Fatalf("no matches should print [], got %q", emptyOut) + } + + if _, err := execPluginCmd(t, pluginsDir, searchCmd(), "-o", "xml"); err == nil { + t.Fatal("expected an error for -o xml") + } +} diff --git a/internal/cmd/plugin/search.go b/internal/cmd/plugin/search.go index aaa7312..3276636 100644 --- a/internal/cmd/plugin/search.go +++ b/internal/cmd/plugin/search.go @@ -1,7 +1,9 @@ package plugin import ( + "encoding/json" "fmt" + "io" "strings" "text/tabwriter" @@ -12,8 +14,24 @@ import ( "go.datum.net/datumctl/internal/pluginstore" ) +// searchResult is one plugin in 'datumctl plugin search' output. Its JSON form +// is what '-o json' prints, so scripts and agents can rely on these field names. +type searchResult struct { + Name string `json:"name"` + Index string `json:"index"` + Version string `json:"version"` + Trust string `json:"trust"` + Description string `json:"description"` +} + +const ( + searchOutputTable = "table" + searchOutputJSON = "json" + searchOutputName = "name" +) + func searchCmd() *cobra.Command { - var indexName string + var indexName, output string cmd := &cobra.Command{ Use: "search [query]", Short: "Search for available datumctl plugins", @@ -26,7 +44,10 @@ func searchCmd() *cobra.Command { you added). An optional query filters by name or description; use --index to scope to one catalog. - Run 'datumctl plugin install ' to install a plugin listed here.`), + Run 'datumctl plugin install ' to install a plugin listed here. + + Use -o json for machine-readable output, or -o name for one plugin name + per line.`), Example: templates.Examples(` # List all available plugins across catalogs datumctl plugin search @@ -35,7 +56,10 @@ func searchCmd() *cobra.Command { datumctl plugin search dns # Scope the search to one catalog - datumctl plugin search dns --index acme`), + datumctl plugin search dns --index acme + + # Machine-readable output + datumctl plugin search --index datum -o json`), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { pluginsDir, err := resolvePluginsDir(cmd) @@ -65,9 +89,16 @@ func searchCmd() *cobra.Command { query = strings.ToLower(args[0]) } - w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 3, ' ', 0) - fmt.Fprintln(w, "NAME\tINDEX\tVERSION\tTRUST\tDESCRIPTION") - var rows int + switch output { + case searchOutputTable, searchOutputJSON, searchOutputName: + default: + return customerrors.NewUserErrorWithHint( + fmt.Sprintf("invalid value %q for --output", output), + "Allowed values: table, json, name.", + ) + } + + results := []searchResult{} for i := range catalogs { cat := catalogs[i] idx, idxErr := loadOrRefreshCatalog(cmd, pluginsDir, cat) @@ -81,20 +112,46 @@ func searchCmd() *cobra.Command { !strings.Contains(strings.ToLower(p.Spec.ShortDescription), query) { continue } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", - p.Name, cat.Name, p.Spec.Version, cat.Trust(), p.Spec.ShortDescription) - rows++ + results = append(results, searchResult{ + Name: p.Name, + Index: cat.Name, + Version: p.Spec.Version, + Trust: cat.Trust(), + Description: p.Spec.ShortDescription, + }) } } - if err := w.Flush(); err != nil { - return err - } - if rows == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No matching plugins found.") - } - return nil + return printSearchResults(cmd.OutOrStdout(), output, results) }, } cmd.Flags().StringVar(&indexName, "index", "", "Scope the search to a single catalog") + cmd.Flags().StringVarP(&output, "output", "o", searchOutputTable, "Output format: table, json or name") return cmd } + +// printSearchResults writes search results in the requested format. JSON +// output is always an array, empty when nothing matched. +func printSearchResults(w io.Writer, output string, results []searchResult) error { + switch output { + case searchOutputJSON: + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(results) + case searchOutputName: + for _, r := range results { + fmt.Fprintln(w, r.Name) + } + return nil + } + + if len(results) == 0 { + fmt.Fprintln(w, "No matching plugins found.") + return nil + } + tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) + fmt.Fprintln(tw, "NAME\tINDEX\tVERSION\tTRUST\tDESCRIPTION") + for _, r := range results { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", r.Name, r.Index, r.Version, r.Trust, r.Description) + } + return tw.Flush() +} From 871fdc60a5128961c22ca74b7947e5c784a6d323 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:16:24 +0000 Subject: [PATCH 2/2] feat: Confirm before installing a whole third-party catalog plugin install --all-available --index installs every plugin in that catalog, so with a third-party catalog it now lists the plugins it would install and asks to confirm first, as plugin index add does. --yes skips the prompt. With no terminal and no --yes it exits with an error instead of installing anything. The official datum catalog, and a catalog with nothing to install, never prompt. --- docs/developer/plugins.md | 3 + internal/cmd/plugin/install.go | 18 ++- internal/cmd/plugin/install_all_available.go | 44 +++++- .../cmd/plugin/install_all_available_test.go | 130 ++++++++++++++++++ 4 files changed, 190 insertions(+), 5 deletions(-) diff --git a/docs/developer/plugins.md b/docs/developer/plugins.md index d9252d1..97db775 100644 --- a/docs/developer/plugins.md +++ b/docs/developer/plugins.md @@ -90,6 +90,9 @@ catalog, or in the catalog named by `--index`. It upgrades plugins that are behind the catalog, leaves up-to-date ones alone, and skips a plugin whose name is already installed from another catalog or from GitHub rather than replace it. A plugin that fails is reported at the end without stopping the rest. +With a third-party catalog it first lists the plugins it would install and asks +to confirm; pass `--yes` to skip the prompt. Without a terminal and without +`--yes`, it exits with an error rather than install anything. `plugin search -o json` prints an array of `{name, index, version, trust, description}` objects, and `-o name` prints one plugin name per line, for diff --git a/internal/cmd/plugin/install.go b/internal/cmd/plugin/install.go index d816a61..dc7b3a9 100644 --- a/internal/cmd/plugin/install.go +++ b/internal/cmd/plugin/install.go @@ -21,6 +21,7 @@ func installCmd(_ *client.DatumCloudFactory) *cobra.Command { var ( allAvailable bool indexName string + assumeYes bool ) cmd := &cobra.Command{ Use: "install [name | owner/repo[@version]]", @@ -46,7 +47,9 @@ With --all-available, installs every plugin in the official datum catalog (or the catalog named by --index), upgrades any that are behind, and leaves the rest alone. A plugin of the same name installed from another catalog or from GitHub is skipped, not replaced. One failed plugin does not stop the others; failures -are listed at the end and the command exits non-zero. +are listed at the end and the command exits non-zero. A third-party catalog +asks for confirmation first, listing the plugins it would install; pass --yes +to confirm without the prompt. The plugin binary is written to the managed plugins directory (~/.datumctl/plugins/ by default).`, @@ -72,8 +75,11 @@ The plugin binary is written to the managed plugins directory # Install every plugin in the official datum catalog datumctl plugin install --all-available - # Install every plugin in another registered catalog - datumctl plugin install --all-available --index acme`, + # Install every plugin in another registered catalog (asks to confirm) + datumctl plugin install --all-available --index acme + + # The same, without the prompt (scripts, CI) + datumctl plugin install --all-available --index acme --yes`, RunE: func(cmd *cobra.Command, args []string) error { pluginsDir, err := resolvePluginsDir(cmd) if err != nil { @@ -86,7 +92,10 @@ The plugin binary is written to the managed plugins directory if len(args) > 0 { return customerrors.NewUserError("--all-available installs a whole catalog; it does not take a plugin name") } - return installAllAvailable(cmd, pluginsDir, indexName, currentVersion) + return installAllAvailable(cmd, pluginsDir, indexName, currentVersion, assumeYes) + } + if assumeYes { + return customerrors.NewUserError("--yes only applies with --all-available") } if indexName != "" { return customerrors.NewUserErrorWithHint( @@ -122,6 +131,7 @@ The plugin binary is written to the managed plugins directory } cmd.Flags().BoolVar(&allAvailable, "all-available", false, "Install every plugin in a catalog (the official datum catalog unless --index is set)") cmd.Flags().StringVar(&indexName, "index", "", "Catalog to install from with --all-available") + cmd.Flags().BoolVar(&assumeYes, "yes", false, "With --all-available, install from a third-party catalog without the confirmation prompt") return cmd } diff --git a/internal/cmd/plugin/install_all_available.go b/internal/cmd/plugin/install_all_available.go index cb932ce..cbb979e 100644 --- a/internal/cmd/plugin/install_all_available.go +++ b/internal/cmd/plugin/install_all_available.go @@ -1,6 +1,7 @@ package plugin import ( + "bufio" "fmt" "strings" @@ -59,7 +60,10 @@ func planCatalogInstall(catalogName string, idx *pluginstore.CachedIndex, manife // catalog unless catalogName names another), upgrading any that are behind and // leaving the rest alone. It carries on past a failed plugin and reports every // failure at the end. -func installAllAvailable(cmd *cobra.Command, pluginsDir, catalogName, currentVersion string) error { +// +// A third-party catalog needs confirmation first, because it installs plugins +// nobody named one by one: a y/N prompt, or assumeYes (--yes) for scripts. +func installAllAvailable(cmd *cobra.Command, pluginsDir, catalogName, currentVersion string, assumeYes bool) error { reg, err := pluginstore.LoadRegistry(pluginsDir) if err != nil { return fmt.Errorf("load catalog registry: %w", err) @@ -102,6 +106,22 @@ func installAllAvailable(cmd *cobra.Command, pluginsDir, catalogName, currentVer fmt.Fprintf(out, "Skipped %s: %s\n", s.name, s.reason) } + if len(plan.install) > 0 && !cat.IsOfficial() && !assumeYes { + answered, ok := confirmThirdPartyInstallAll(cmd, cat.Name, plan.install) + if !answered { + // Nobody answered (a script, CI, a coding agent's shell). Fail + // rather than install third-party code nobody agreed to. + return customerrors.NewUserErrorWithHint( + fmt.Sprintf("installing every plugin from third-party catalog %q requires confirmation", cat.Name), + "Re-run with --yes to confirm without a prompt, or install the plugins you want one at a time.", + ) + } + if !ok { + fmt.Fprintln(out, "Aborted. No plugins were installed.") + return nil + } + } + var errs []string installed := 0 for _, name := range plan.install { @@ -126,3 +146,25 @@ func installAllAvailable(cmd *cobra.Command, pluginsDir, catalogName, currentVer } return nil } + +// confirmThirdPartyInstallAll lists the plugins a third-party --all-available +// would install and reads a y/N answer from stdin. answered is false when no +// one responds (EOF, non-interactive), so the caller can tell that apart from +// an explicit "no". The prompt goes to stderr to keep stdout clean. +func confirmThirdPartyInstallAll(cmd *cobra.Command, catalogName string, plugins []string) (answered, confirmed bool) { + out := cmd.ErrOrStderr() + fmt.Fprintf(out, "\n You're installing every plugin from the third-party catalog %q:\n\n", catalogName) + for _, name := range plugins { + fmt.Fprintf(out, " %s\n", name) + } + fmt.Fprintln(out, "\n Datum does not review, verify, or endorse plugins from this catalog.") + fmt.Fprintln(out, " Plugins are programs that run on your machine with your Datum credentials.") + fmt.Fprint(out, "\n Install all of them? [y/N] ") + + line, readErr := bufio.NewReader(cmd.InOrStdin()).ReadString('\n') + if readErr != nil && line == "" { + return false, false + } + answer := strings.ToLower(strings.TrimSpace(line)) + return true, answer == "y" || answer == "yes" +} diff --git a/internal/cmd/plugin/install_all_available_test.go b/internal/cmd/plugin/install_all_available_test.go index 98117e7..9902a11 100644 --- a/internal/cmd/plugin/install_all_available_test.go +++ b/internal/cmd/plugin/install_all_available_test.go @@ -1,6 +1,7 @@ package plugin import ( + "bytes" "encoding/json" "reflect" "strings" @@ -160,3 +161,132 @@ func TestSearch_outputFormats(t *testing.T) { t.Fatal("expected an error for -o xml") } } + +// execPluginCmdWithInput runs cmd with stdin set to input, for prompts. +func execPluginCmdWithInput(t *testing.T, pluginsDir, input string, args ...string) (string, error) { + t.Helper() + t.Setenv("DATUMCTL_PLUGINS_DIR", pluginsDir) + cmd := installCmd(nil) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetIn(strings.NewReader(input)) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), err +} + +// seedThirdPartyCatalog registers the third-party catalog "acme" with one +// plugin. The plugin has no platform builds, so an install attempt fails at +// platform selection, before any download: the tests can tell whether the +// confirmation let it through without touching the network. +func seedThirdPartyCatalog(t *testing.T, pluginsDir string) { + t.Helper() + if err := pluginstore.SaveRegistry(pluginsDir, &pluginstore.Registry{Catalogs: []pluginstore.Catalog{ + {Name: "acme", Source: "https://acme.example/index.yaml", Type: pluginstore.CatalogTypeCustom}, + }}); err != nil { + t.Fatal(err) + } + seedFreshCache(t, pluginsDir, "acme", testPlugin("deploy", "v1.0.0", "ACME deploy")) +} + +const confirmationRequired = "requires confirmation" + +func TestInstallAllAvailable_thirdPartyConfirmation(t *testing.T) { + tests := []struct { + name string + input string + args []string + // wantErr is a substring of the error, or "" for no error. + wantErr string + wantOut string + wantPrompt bool + }{ + { + name: "no answer fails and asks for --yes", + input: "", + args: []string{"--all-available", "--index", "acme"}, + wantErr: confirmationRequired, + wantOut: "deploy", + wantPrompt: true, + }, + { + name: "an explicit no aborts without an error", + input: "n\n", + args: []string{"--all-available", "--index", "acme"}, + wantOut: "Aborted. No plugins were installed.", + wantPrompt: true, + }, + { + name: "an explicit yes goes ahead", + input: "y\n", + args: []string{"--all-available", "--index", "acme"}, + wantErr: "deploy: select platform", + wantPrompt: true, + }, + { + name: "--yes skips the prompt", + input: "", + args: []string{"--all-available", "--index", "acme", "--yes"}, + wantErr: "deploy: select platform", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pluginsDir := t.TempDir() + seedThirdPartyCatalog(t, pluginsDir) + + out, err := execPluginCmdWithInput(t, pluginsDir, tt.input, tt.args...) + switch { + case tt.wantErr == "" && err != nil: + t.Fatalf("unexpected error: %v\n%s", err, out) + case tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)): + t.Fatalf("error = %v, want one containing %q\n%s", err, tt.wantErr, out) + } + if tt.wantOut != "" && !strings.Contains(out, tt.wantOut) { + t.Fatalf("output missing %q:\n%s", tt.wantOut, out) + } + if shown := strings.Contains(out, "Install all of them?"); shown != tt.wantPrompt { + t.Fatalf("prompt shown = %v, want %v:\n%s", shown, tt.wantPrompt, out) + } + }) + } +} + +func TestInstallAllAvailable_officialCatalogNeverPrompts(t *testing.T) { + pluginsDir := t.TempDir() + seedFreshCache(t, pluginsDir, "datum", testPlugin("dns", "v1.0.0", "DNS")) + + out, err := execPluginCmdWithInput(t, pluginsDir, "", "--all-available") + if err == nil || strings.Contains(err.Error(), confirmationRequired) { + t.Fatalf("official catalog should go straight to installing, got err=%v\n%s", err, out) + } + if strings.Contains(out, "Install all of them?") { + t.Fatalf("official catalog should not prompt:\n%s", out) + } +} + +func TestInstallAllAvailable_thirdPartyNothingToInstallNeverPrompts(t *testing.T) { + pluginsDir := t.TempDir() + seedThirdPartyCatalog(t, pluginsDir) + if err := pluginstore.Save(pluginsDir, &pluginstore.Manifest{Plugins: map[string]*pluginstore.InstalledPlugin{ + "deploy": {Source: "deploy", Catalog: "acme", Version: "v1.0.0"}, + }}); err != nil { + t.Fatal(err) + } + + out, err := execPluginCmdWithInput(t, pluginsDir, "", "--all-available", "--index", "acme") + if err != nil { + t.Fatalf("nothing to install should succeed without a prompt: %v\n%s", err, out) + } + if strings.Contains(out, "Install all of them?") || !strings.Contains(out, "deploy v1.0.0 is up to date") { + t.Fatalf("unexpected output:\n%s", out) + } +} + +func TestInstall_yesNeedsAllAvailable(t *testing.T) { + _, err := execPluginCmdWithInput(t, t.TempDir(), "", "dns", "--yes") + if err == nil || !strings.Contains(err.Error(), "--yes only applies with --all-available") { + t.Fatalf("error = %v, want the --yes/--all-available error", err) + } +}