diff --git a/internal/cli/commands.go b/internal/cli/commands.go index c06733eeb..5126d0609 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -39,7 +39,27 @@ type commandNode struct { RequiresAuth bool `json:"requiresAuth"` Flags []commandFlag `json:"flags,omitempty"` Note string `json:"note,omitempty"` - Subcommands []commandNode `json:"subcommands,omitempty"` + + // Source is "plugin" for a command that an installed plugin adds, and is left + // out for a core command of the CLI, so no source means a core command and the + // output for the core commands does not change. The fields below are set for + // plugin commands only. + Source string `json:"source,omitempty"` + PluginID string `json:"pluginId,omitempty"` + PluginVersion string `json:"pluginVersion,omitempty"` + RequiredScopes []string `json:"requiredScopes,omitempty"` + Interactive bool `json:"interactive,omitempty"` + Output *commandOutput `json:"output,omitempty"` + + Subcommands []commandNode `json:"subcommands,omitempty"` +} + +// commandOutput is the output a plugin command supports, as its manifest declares +// it: the --json and --json-compact flags, the --csv flag, and agent mode. +type commandOutput struct { + JSON bool `json:"json"` + CSV bool `json:"csv"` + AgentMode bool `json:"agentMode"` } // rawAPIFallbackNote points an agent to `auth0 api` when the listed flags don't @@ -175,6 +195,15 @@ func buildNode(cmd *cobra.Command, level, maxDepth int, detailed bool) commandNo RequiresAuth: commandRequiresAuthentication(cmd.CommandPath()), } + if info, ok := pluginInfoOf(cmd); ok { + node.Source = "plugin" + node.PluginID = info.ID + node.PluginVersion = info.Version + node.RequiredScopes = info.Scopes + node.Interactive = info.Interactive + node.Output = info.Output + } + if detailed { // Prefer the longer description when it adds detail beyond Short. if long := strings.TrimSpace(cmd.Long); long != "" && long != cmd.Short { diff --git a/internal/cli/exit.go b/internal/cli/exit.go index f9464f794..e51f7f11c 100644 --- a/internal/cli/exit.go +++ b/internal/cli/exit.go @@ -109,6 +109,11 @@ type pluginError struct { // ExitCode is the code the plugin exited with, or zero when it did not exit by // itself. exitCode int + + // DeniedCalls is the summary of the calls that the proxy of the run denied, + // and is empty when there were none. An agent reads it from the envelope, + // because in agent mode the CLI does not print it. + deniedCalls string } func (e pluginError) Error() string { return e.err.Error() } @@ -117,10 +122,44 @@ func (e pluginError) Unwrap() error { return e.err } func (e pluginError) ErrorDetails() json.RawMessage { details, err := json.Marshal(struct { - PluginID string `json:"pluginId"` - Version string `json:"version"` - ExitCode int `json:"exitCode,omitempty"` - }{e.pluginID, e.version, e.exitCode}) + PluginID string `json:"pluginId"` + Version string `json:"version"` + ExitCode int `json:"exitCode,omitempty"` + DeniedCalls string `json:"deniedCalls,omitempty"` + }{e.pluginID, e.version, e.exitCode, e.deniedCalls}) + if err != nil { + return nil + } + + return details +} + +// pluginNotInstalledError is the failure of a command that is not a command of +// the CLI but is the name of a plugin in the registry. It tells how to install the +// plugin. It classifies as "not_found", with the reason "plugin_not_installed", +// and carries the plugin and the install command in the envelope's "details". +type pluginNotInstalledError struct { + // The ID of the plugin, such as auth0/checkmate, the part of it that is typed + // as a command, and the command that installs it. + id string + name string + command string +} + +// installCommand is the command that installs the plugin. +func (e pluginNotInstalledError) installCommand() string { + return e.command +} + +func (e pluginNotInstalledError) Error() string { + return fmt.Sprintf("Plugin %q is available. Install it with: %s", e.name, e.installCommand()) +} + +func (e pluginNotInstalledError) ErrorDetails() json.RawMessage { + details, err := json.Marshal(struct { + PluginID string `json:"pluginId"` + InstallCommand string `json:"installCommand"` + }{e.id, e.installCommand()}) if err != nil { return nil } @@ -156,6 +195,11 @@ func errorClass(err error) string { return "plugin" } + var notInstalledErr pluginNotInstalledError + if errors.As(err, ¬InstalledErr) { + return "not_found" + } + if errors.Is(err, config.ErrInvalidToken) || errors.Is(err, config.ErrMalformedToken) || errors.Is(err, config.ErrStoredTokenUnavailable) || @@ -245,6 +289,11 @@ func errorReason(err error) string { return pluginErr.reason } + var notInstalledErr pluginNotInstalledError + if errors.As(err, ¬InstalledErr) { + return "plugin_not_installed" + } + if status, ok := managementHTTPStatus(err); ok { return reasonForHTTPStatus(status) } diff --git a/internal/cli/plugin.go b/internal/cli/plugin.go index 5c5568b89..aed627d01 100644 --- a/internal/cli/plugin.go +++ b/internal/cli/plugin.go @@ -1243,6 +1243,11 @@ type pluginService struct { // no terminal. interactive func(cmd *cobra.Command) bool + // OutputTerminals says whether stdout and stderr are terminals, which the hint + // about plugin updates needs. When it is nil, the real streams are checked. + // Tests set it, because they have no terminal. + outputTerminals func() bool + // Confirmer asks the person a yes or no question. When it is nil, the prompt // of the CLI is used. Tests set it for the same reason. confirmer func(message string) bool diff --git a/internal/cli/plugin_discovery.go b/internal/cli/plugin_discovery.go new file mode 100644 index 000000000..b42c860e7 --- /dev/null +++ b/internal/cli/plugin_discovery.go @@ -0,0 +1,288 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/iostream" + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/registry" +) + +const ( + // PluginUpdateHintEnv turns the hint about plugin updates off when it is set to + // a false value or to a value that is not a boolean. + pluginUpdateHintEnv = "AUTH0_CLI_PLUGIN_UPDATE_HINT" + + // PluginHintFile is the file in the plugins directory that has the time of the + // last hint. + pluginHintFile = "hint.json" + + // PluginHintInterval is how long the hint stays quiet after it was shown. + pluginHintInterval = 24 * time.Hour + + // PluginHintMaxBytes bounds what is read from the hint file, which is a few + // dozen bytes when the CLI wrote it. + pluginHintMaxBytes = 4096 + + // PluginLookupTimeout bounds the read of the saved registry index when a + // command is not found. It reads a few files, and never goes to the network. + pluginLookupTimeout = 2 * time.Second +) + +// offerPluginInstall turns the failure of a command that is not found into the +// suggestion to install a plugin, when the registry has a plugin of that name. +// Every other error is returned as it is. +// +// It only reads the saved index of the registry, and it fails silently: the saved +// index may not exist, may not be trusted any more, or the plugins may be off, and +// the CLI then says what it always said, that the command is unknown. It does +// nothing in a build without the plugin feature and when plugins are turned off +// with AUTH0_CLI_PLUGINS_DISABLED. +func (c *cli) offerPluginInstall(rootCmd *cobra.Command, version string, err error) error { + unknown, ok := errors.AsType[unknownCommandError](err) + if !ok || unknown.parent != rootCmd.CommandPath() || !plugins.FeatureEnabled(version) || checkPluginsEnabled(rootCmd) != nil { + return err + } + + svc, svcErr := c.getPlugins() + if svcErr != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), pluginLookupTimeout) + defer cancel() + + index, _, loadErr := svc.client.Load(ctx) + if loadErr != nil { + return err + } + + // The suggestion is only made for a plugin of the registry of the bare name that + // `auth0 plugin install` would install here. The schema of the index only allows + // IDs that the bare name is read back as, so the bare name is the command. + id := registry.DefaultRegistryName + "/" + unknown.token + + if _, runnable := runnableLatest(index, svc.store.CLIVersion, id); !runnable { + return err + } + + return pluginNotInstalledError{id: id, name: unknown.token, command: "auth0 plugin install " + unknown.token} +} + +// hintUpdate prints one line on stderr after a plugin ran, when a newer version of +// the plugin can be installed or the installed one is deprecated, and does so at +// most once a day. It is for a person at a terminal: nothing is printed in agent +// mode, with --no-input, with an output format for machines, when stdout or stderr +// is not a terminal, or when AUTH0_CLI_PLUGIN_UPDATE_HINT turns it off. It reads +// the saved index only. It never fails and never changes the result of the +// command, so a hint file that cannot be read or written is ignored. +func (p *pluginCommand) hintUpdate(svc *pluginService, index *registry.Index, installed string) { + c := p.cli + + if c.agentMode || c.noInput || c.json || c.jsonCompact || c.csv || !pluginHintEnabled() || !svc.terminals() { + return + } + + file := filepath.Join(svc.store.Dir, pluginHintFile) + now := time.Now() + + if last, ok := readPluginHint(file); ok && !last.After(now) && now.Sub(last) <= pluginHintInterval { + return + } + + line := pluginHintLine(index, svc.store.CLIVersion, p.id, installed) + if line == "" { + return + } + + c.renderer.Infof("%s", line) + + writePluginHint(file, now) +} + +// terminals reports whether stdout and stderr are terminals. +func (s *pluginService) terminals() bool { + if s.outputTerminals != nil { + return s.outputTerminals() + } + + return isatty.IsTerminal(iostream.Output.Fd()) && isatty.IsTerminal(iostream.Messages.Fd()) +} + +// pluginHintEnabled reports whether the hint is not turned off. A value that is +// not a boolean turns it off, as it does for the other switches. +func pluginHintEnabled() bool { + value := strings.TrimSpace(os.Getenv(pluginUpdateHintEnv)) + if value == "" { + return true + } + + enabled, err := strconv.ParseBool(value) + + return err == nil && enabled +} + +// pluginHintLine returns the hint about the installed version of a plugin, or an +// empty string when there is nothing to say. The hint comes from the registry, so +// its text is made safe for a terminal. +func pluginHintLine(index *registry.Index, cliVersion, id, installed string) string { + name := plugins.NameOf(id) + + var sentences []string + + if newer := newerRunnableVersion(index, cliVersion, id, installed); newer != "" { + sentences = append(sentences, fmt.Sprintf("%s %s is available. Run: auth0 plugin update %s", name, newer, name)) + } + + if deprecation, deprecated := index.Deprecation(id, installed); deprecated { + sentence := fmt.Sprintf("%s is deprecated: %s.", name, strings.TrimRight(plugins.SafeText(deprecation.Message), ". ")) + + if deprecation.Replacement != "" { + sentence += fmt.Sprintf(" Use %s.", strings.TrimRight(plugins.SafeText(deprecation.Replacement), ". ")) + } + + sentences = append(sentences, sentence) + } + + if len(sentences) == 0 { + return "" + } + + // The first sentence has no full stop, so that the line reads the same with and + // without the second one. + if len(sentences) == 2 { + sentences[0] += "." + } + + return strings.Join(sentences, " ") +} + +// newerRunnableVersion returns the version that `auth0 plugin update` would +// install, when it is newer than the installed one and can run here: it is not +// revoked, the CLI meets its engines, and it has a build for this machine. The +// versions that are listed in a separate file are not read, because that is a +// download, so those plugins get no hint about a new version. +func newerRunnableVersion(index *registry.Index, cliVersion, id, installed string) string { + latest, runnable := runnableLatest(index, cliVersion, id) + if !runnable { + return "" + } + + newest, _ := plugins.ParseVersion(latest) + + if current, valid := plugins.ParseVersion(installed); !valid || newest.Compare(current) <= 0 { + return "" + } + + return latest +} + +// runnableLatest returns the latest version of the plugin, when `auth0 plugin +// install` would install it here: it is a version, it is not revoked, the CLI +// meets its engines, and it has a build for this machine. The versions that are +// listed in a separate file are not read, because that is a download, so those +// plugins have no runnable latest version here. +func runnableLatest(index *registry.Index, cliVersion, id string) (string, bool) { + listed, found := index.FindPlugin(id) + if !found { + return "", false + } + + latest, valid := plugins.ParseVersion(listed.Latest) + if !valid || latest.String() != listed.Latest { + return "", false + } + + if _, revoked := index.Revoked(id, listed.Latest); revoked { + return "", false + } + + record, found := listed.Version(listed.Latest) + if !found { + return "", false + } + + if record.Engines != nil { + if met, err := plugins.EnginesSatisfied(record.Engines.Auth0CLI, cliVersion); err != nil || !met { + return "", false + } + } + + machine := plugins.CurrentPlatform() + + for _, artifact := range record.Artifacts { + if built, ok := artifact.Platform(); ok && built == machine { + return listed.Latest, true + } + } + + return "", false +} + +// pluginHint is the content of the hint file. +type pluginHint struct { + Shown time.Time `json:"shown"` +} + +// readPluginHint returns the time of the last hint. A file that is missing, is not +// a regular file, is too big or is not what the CLI wrote means no hint was shown, +// and a hint cannot block a command: only a regular file is opened, so a named pipe +// does not hold the command up. +func readPluginHint(path string) (time.Time, bool) { + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() { + return time.Time{}, false + } + + file, err := os.Open(path) + if err != nil { + return time.Time{}, false + } + + defer file.Close() + + data, err := io.ReadAll(io.LimitReader(file, pluginHintMaxBytes+1)) + if err != nil || len(data) > pluginHintMaxBytes { + return time.Time{}, false + } + + var hint pluginHint + if json.Unmarshal(data, &hint) != nil || hint.Shown.IsZero() { + return time.Time{}, false + } + + return hint.Shown, true +} + +// writePluginHint saves the time of a hint. The file is replaced as a whole, so a +// reader never sees half of it, and a failure is ignored. +func writePluginHint(path string, shown time.Time) { + data, err := json.Marshal(pluginHint{Shown: shown.UTC()}) + if err != nil { + return + } + + temporary, err := os.CreateTemp(filepath.Dir(path), ".hint-*.tmp") + if err != nil { + return + } + + _, writeErr := temporary.Write(data) + closeErr := temporary.Close() + + if writeErr != nil || closeErr != nil || os.Rename(temporary.Name(), path) != nil { + _ = os.Remove(temporary.Name()) + } +} diff --git a/internal/cli/plugin_discovery_test.go b/internal/cli/plugin_discovery_test.go new file mode 100644 index 000000000..d63a1e116 --- /dev/null +++ b/internal/cli/plugin_discovery_test.go @@ -0,0 +1,942 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/registry" + "github.com/auth0/auth0-cli/internal/plugins/registry/registrytest" +) + +// execute runs a command line on a new command tree the way Execute does, with +// the flag errors wrapped and the suggestion to install a plugin offered, and +// returns the error that Execute would report. +func (e *pluginEnv) execute(version string, args ...string) error { + e.t.Helper() + + e.stdout.Reset() + e.stderr.Reset() + + e.cli.renderer = &display.Renderer{MessageWriter: e.stderr, ResultWriter: e.stdout} + + root := newRootTree(e.cli) + root.SetFlagErrorFunc(wrapFlagError) + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs(args) + + return e.cli.offerPluginInstall(root, version, classifyRequiredFlagError(root.ExecuteContext(context.Background()))) +} + +func TestOfferPluginInstall(t *testing.T) { + t.Setenv(agentModeEnvVar, "false") + + // A registry that lists the plugin, whose index the client has saved, and + // nothing installed. + catalog := func(t *testing.T) *pluginEnv { + t.Helper() + + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"))) + + _, _, err := e.svc.client.EnsureFresh(context.Background()) + require.NoError(t, err) + + return e + } + + unchanged := func(t *testing.T, err error) { + t.Helper() + + require.Error(t, err) + + _, offered := errors.AsType[pluginNotInstalledError](err) + assert.False(t, offered, err.Error()) + assert.Equal(t, "usage", errorClass(err)) + } + + t.Run("a plugin that is only in the registry", func(t *testing.T) { + e := catalog(t) + + err := e.execute("dev", "checkmate") + + require.Error(t, err) + assert.Equal(t, `Plugin "checkmate" is available. Install it with: auth0 plugin install checkmate`, err.Error()) + assert.Equal(t, exitGeneric, exitCodeForError(err)) + + envelope := buildErrorEnvelope(err) + + assert.Equal(t, "not_found", envelope.Error.Code) + assert.Equal(t, "plugin_not_installed", envelope.Error.Reason) + assert.Equal(t, err.Error(), envelope.Error.Message) + assert.JSONEq(t, `{"pluginId":"auth0/checkmate","installCommand":"auth0 plugin install checkmate"}`, string(envelope.Error.Details)) + }) + + t.Run("the person sees one line, without a full stop after the command", func(t *testing.T) { + e := catalog(t) + + renderErrorMessage(e.cli.renderer, e.execute("dev", "checkmate")) + + out := e.stderr.String() + + assert.Contains(t, out, `Plugin "checkmate" is available. Install it with: auth0 plugin install checkmate`+"\n") + assert.NotContains(t, out, "checkmate.") + assert.NotContains(t, out, "Did you mean") + }) + + t.Run("with arguments and flags after the name", func(t *testing.T) { + for _, args := range [][]string{ + {"checkmate", "scan"}, + {"checkmate", "scan", "--json"}, + {"checkmate", "--depth", "3"}, + {"--no-input", "checkmate"}, + } { + e := catalog(t) + + err := e.execute("dev", args...) + + assert.Equal(t, "plugin_not_installed", errorReason(err), args) + } + }) + + t.Run("a prerelease build has it too", func(t *testing.T) { + assert.Equal(t, "plugin_not_installed", errorReason(catalog(t).execute("1.39.0-beta.1", "checkmate"))) + }) + + t.Run("an installed plugin is a command", func(t *testing.T) { + e := catalog(t) + e.installPlugin(pluginTestID, "1.0.0") + + err := e.execute("dev", "checkmate", "--help") + + assert.NoError(t, err) + }) + + t.Run("a core command stays a core command", func(t *testing.T) { + e := newPluginEnv(t) + + apps := e.releaseAs("auth0/apps", e.platform, "1.0.0") + e.publish(registrytest.NewIndexBuilder().AddPlugin(registrytest.NewPlugin("auth0/apps", apps))) + + _, _, err := e.svc.client.EnsureFresh(context.Background()) + require.NoError(t, err) + + // The core command is found, so there is nothing to offer for its name. + assert.NoError(t, e.execute("dev", "apps", "--help")) + + // Its own unknown subcommand is not at the root. + err = e.execute("dev", "apps", "bogus") + unchanged(t, err) + assert.Contains(t, err.Error(), `unknown command "bogus" for "auth0 apps"`) + + // The same name under another command is not offered either. + err = e.execute("dev", "tenants", "apps") + unchanged(t, err) + }) + + t.Run("a name that is not in the registry", func(t *testing.T) { + e := catalog(t) + + err := e.execute("dev", "nothing") + + unchanged(t, err) + assert.Contains(t, err.Error(), `unknown command "nothing" for "auth0"`) + + // A near miss is not the plugin. + unchanged(t, e.execute("dev", "checkmat")) + unchanged(t, e.execute("dev", "Checkmate")) + unchanged(t, e.execute("dev", "auth0/checkmate")) + }) + + t.Run("without a saved index", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"))) + + err := e.execute("dev", "checkmate") + + unchanged(t, err) + assert.Zero(t, e.server.Requests("/timestamp.json"), "the lookup never goes to the network") + assert.Empty(t, e.stderr.String()) + }) + + t.Run("an index that cannot be trusted any more", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0")).SetExpires(e.Now.Add(48 * time.Hour))) + + _, _, err := e.svc.client.EnsureFresh(context.Background()) + require.NoError(t, err) + + e.Now = e.Now.Add(72 * time.Hour) + + _, _, err = e.svc.client.Load(context.Background()) + require.ErrorIs(t, err, registry.ErrExpired) + + unchanged(t, e.execute("dev", "checkmate")) + assert.Empty(t, e.stderr.String()) + }) + + t.Run("a damaged saved index", func(t *testing.T) { + e := catalog(t) + + // The saved copy has files in several directories, so every file is damaged. + var damaged int + + err := filepath.WalkDir(filepath.Join(e.Dir, "registries", registry.DefaultRegistryName), func(path string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + + damaged++ + + return os.WriteFile(path, []byte("{broken"), 0o600) + }) + require.NoError(t, err) + require.NotZero(t, damaged) + + unchanged(t, e.execute("dev", "checkmate")) + assert.Empty(t, e.stderr.String()) + }) + + t.Run("a saved index that is due for a refresh is not refreshed", func(t *testing.T) { + e := catalog(t) + e.Now = e.Now.Add(25 * time.Hour) + + _, status, err := e.svc.client.Load(context.Background()) + require.NoError(t, err) + require.True(t, status.Stale, "the index must be due for a refresh for this test") + + before := e.server.Requests("/timestamp.json") + + err = e.execute("dev", "checkmate") + + assert.Equal(t, "plugin_not_installed", errorReason(err)) + assert.Equal(t, before, e.server.Requests("/timestamp.json")) + assert.Empty(t, e.stderr.String()) + }) + + t.Run("a build without the plugin feature", func(t *testing.T) { + unchanged(t, catalog(t).execute("1.39.0", "checkmate")) + }) + + t.Run("plugins are turned off", func(t *testing.T) { + for _, value := range []string{"true", "1", "perhaps"} { + t.Run(value, func(t *testing.T) { + e := catalog(t) + t.Setenv(pluginsDisabledEnv, value) + + unchanged(t, e.execute("dev", "checkmate")) + }) + } + + t.Run("false leaves them on", func(t *testing.T) { + e := catalog(t) + t.Setenv(pluginsDisabledEnv, "false") + + assert.Equal(t, "plugin_not_installed", errorReason(e.execute("dev", "checkmate"))) + }) + }) + + t.Run("a plugin that cannot be installed here", func(t *testing.T) { + elsewhere := plugins.PlatformDarwinArm64 + if elsewhere == plugins.CurrentPlatform() { + elsewhere = plugins.PlatformWindowsX64 + } + + tests := []struct { + name string + build func(e *pluginEnv) *registrytest.IndexBuilder + offer bool + }{ + { + name: "a latest version that can be installed", + build: func(e *pluginEnv) *registrytest.IndexBuilder { + return e.index(e.release("1.0.0"), e.release("1.1.0")) + }, + offer: true, + }, + { + name: "an older version that is revoked", + build: func(e *pluginEnv) *registrytest.IndexBuilder { + return e.index(e.release("1.0.0"), e.release("1.1.0")).Revoke(pluginTestID, "1.0.0", "unsafe") + }, + offer: true, + }, + { + name: "a latest version that is revoked", + build: func(e *pluginEnv) *registrytest.IndexBuilder { + return e.index(e.release("1.0.0"), e.release("1.1.0")).Revoke(pluginTestID, "1.1.0", "unsafe") + }, + }, + { + name: "a latest version that needs a newer CLI", + build: func(e *pluginEnv) *registrytest.IndexBuilder { + return e.index(e.release("1.0.0"), e.release("1.1.0", func(r *registry.VersionRecord) { + r.Engines = &plugins.Engines{Auth0CLI: ">=9.0.0"} + })) + }, + }, + { + name: "a latest version without a build for this platform", + build: func(e *pluginEnv) *registrytest.IndexBuilder { + return e.index(e.release("1.0.0"), e.releaseAs(pluginTestID, elsewhere, "1.1.0")) + }, + }, + { + name: "a plugin of another registry", + build: func(e *pluginEnv) *registrytest.IndexBuilder { + return registrytest.NewIndexBuilder().AddPlugin(registrytest.NewPlugin("acme/checkmate", e.releaseAs("acme/checkmate", e.platform, "1.0.0"))) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := newPluginEnv(t) + e.publish(tt.build(e)) + + _, _, err := e.svc.client.EnsureFresh(context.Background()) + require.NoError(t, err) + + err = e.execute("dev", "checkmate") + + if !tt.offer { + unchanged(t, err) + assert.Contains(t, err.Error(), `unknown command "checkmate" for "auth0"`) + + return + } + + assert.Equal(t, "plugin_not_installed", errorReason(err)) + assert.JSONEq(t, `{"pluginId":"auth0/checkmate","installCommand":"auth0 plugin install checkmate"}`, string(buildErrorEnvelope(err).Error.Details)) + }) + } + }) + + t.Run("a plugin service that cannot be built", func(t *testing.T) { + e := catalog(t) + e.cli.plugins = func() (*pluginService, error) { return nil, errors.New("no config") } + + unchanged(t, e.execute("dev", "checkmate")) + }) + + t.Run("an error that is not an unknown command is left alone", func(t *testing.T) { + e := catalog(t) + root := newRootTree(e.cli) + failure := errors.New("boom") + + assert.Same(t, failure, e.cli.offerPluginInstall(root, "dev", failure)) + assert.NoError(t, e.cli.offerPluginInstall(root, "dev", nil)) + }) +} + +func TestPluginHintLine(t *testing.T) { + machine := plugins.CurrentPlatform() + if machine == "" { + t.Skip("this machine is not one of the plugin platforms") + } + + other := plugins.PlatformDarwinArm64 + if machine == other { + other = plugins.PlatformLinuxX64 + } + + artifact := func(platform plugins.Platform) registry.Artifact { + return registry.Artifact{OS: platform.OS(), Arch: platform.Arch(), Libc: platform.Libc()} + } + + record := func(version string, change ...func(*registry.VersionRecord)) registry.VersionRecord { + r := registry.VersionRecord{Version: version, Artifacts: []registry.Artifact{artifact(machine)}} + + for _, apply := range change { + apply(&r) + } + + return r + } + + index := func(latest string, mutate func(*registry.Index), records ...registry.VersionRecord) *registry.Index { + i := ®istry.Index{Plugins: []registry.Plugin{{ID: pluginTestID, Latest: latest, Versions: records}}} + + if mutate != nil { + mutate(i) + } + + return i + } + + const update = "checkmate 1.1.0 is available. Run: auth0 plugin update checkmate" + + tests := []struct { + name string + index *registry.Index + cli string + installed string + want string + }{ + { + name: "a newer version", + index: index("1.1.0", nil, record("1.0.0"), record("1.1.0")), + installed: "1.0.0", + want: update, + }, + { + name: "the same version", + index: index("1.0.0", nil, record("1.0.0")), + installed: "1.0.0", + }, + { + name: "an installed version that is newer than the latest", + index: index("1.0.0", nil, record("1.0.0")), + installed: "1.2.0", + }, + { + name: "a version that is not a version", + index: index("latest", nil, record("latest")), + installed: "1.0.0", + }, + { + name: "a version that is not in the canonical form", + index: index("1.01.0", nil, record("1.01.0")), + installed: "1.0.0", + }, + { + name: "a version with a leading v", + index: index("v1.1.0", nil, record("v1.1.0")), + installed: "1.0.0", + }, + { + name: "a version with build metadata", + index: index("1.1.0+build", nil, record("1.1.0+build")), + installed: "1.0.0", + }, + { + name: "a plugin that the index does not list", + index: ®istry.Index{}, + installed: "1.0.0", + }, + { + name: "a newer version that is revoked", + index: index("1.1.0", func(i *registry.Index) { + i.Revocations = []registry.Revocation{{ID: pluginTestID, Version: "1.1.0", Reason: "Leaked."}} + }, record("1.1.0")), + installed: "1.0.0", + }, + { + name: "a newer version for a newer CLI", + index: index("1.1.0", nil, record("1.1.0", func(r *registry.VersionRecord) { + r.Engines = &plugins.Engines{Auth0CLI: ">=1.50.0"} + })), + cli: "1.39.0", + installed: "1.0.0", + }, + { + name: "a newer version that the CLI meets", + index: index("1.1.0", nil, record("1.1.0", func(r *registry.VersionRecord) { + r.Engines = &plugins.Engines{Auth0CLI: ">=1.38.0"} + })), + cli: "1.39.0", + installed: "1.0.0", + want: update, + }, + { + name: "a newer version with an engines constraint that cannot be read", + index: index("1.1.0", nil, record("1.1.0", func(r *registry.VersionRecord) { + r.Engines = &plugins.Engines{Auth0CLI: "newer"} + })), + cli: "1.39.0", + installed: "1.0.0", + }, + { + name: "a newer version without a build for this machine", + index: index("1.1.0", nil, record("1.1.0", func(r *registry.VersionRecord) { + r.Artifacts = []registry.Artifact{artifact(other)} + })), + installed: "1.0.0", + }, + { + name: "a newer version in a separate file", + index: func() *registry.Index { + i := index("1.1.0", nil) + i.Plugins[0].Ref = ®istry.Ref{Path: "checkmate.json"} + + return i + }(), + installed: "1.0.0", + }, + { + name: "deprecated with a newer version", + index: index("1.1.0", func(i *registry.Index) { + i.Deprecations = []registry.Deprecation{{ID: pluginTestID, Message: "Moved.", Replacement: "auth0/other"}} + }, record("1.1.0")), + installed: "1.0.0", + want: update + ". checkmate is deprecated: Moved. Use auth0/other.", + }, + { + name: "deprecated with nothing newer", + index: index("1.0.0", func(i *registry.Index) { + i.Deprecations = []registry.Deprecation{{ID: pluginTestID, Message: "Moved.", Replacement: "auth0/other"}} + }, record("1.0.0")), + installed: "1.0.0", + want: "checkmate is deprecated: Moved. Use auth0/other.", + }, + { + name: "deprecated without a replacement", + index: index("1.0.0", func(i *registry.Index) { + i.Deprecations = []registry.Deprecation{{ID: pluginTestID, Message: "Unmaintained"}} + }, record("1.0.0")), + installed: "1.0.0", + want: "checkmate is deprecated: Unmaintained.", + }, + { + name: "only another version is deprecated", + index: index("1.0.0", func(i *registry.Index) { + i.Deprecations = []registry.Deprecation{{ID: pluginTestID, Version: "0.9.0", Message: "Old."}} + }, record("1.0.0")), + installed: "1.0.0", + }, + { + name: "the installed version is deprecated", + index: index("1.0.0", func(i *registry.Index) { + i.Deprecations = []registry.Deprecation{{ID: pluginTestID, Version: "1.0.0", Message: "Broken."}} + }, record("1.0.0")), + installed: "1.0.0", + want: "checkmate is deprecated: Broken.", + }, + { + name: "text from the registry is made safe for a terminal", + index: index("1.0.0", func(i *registry.Index) { + i.Deprecations = []registry.Deprecation{{ID: pluginTestID, Message: "Gone\x1b[2J\nnow", Replacement: "auth0/\u202eother"}} + }, record("1.0.0")), + installed: "1.0.0", + want: "checkmate is deprecated: Gone�[2J�now. Use auth0/�other.", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, pluginHintLine(test.index, test.cli, pluginTestID, test.installed)) + }) + } +} + +// hintEnvOptions changes the environment that newHintEnv returns. +type hintEnvOptions struct { + // WithoutNewer leaves the newer version out of the saved index, Newer changes + // it, and Index changes the index before it is published. + withoutNewer bool + newer func(*registry.VersionRecord) + index []func(*registrytest.IndexBuilder) +} + +// hintWithoutNewer is the option for an index that lists nothing newer than the +// installed version. +func hintWithoutNewer(o *hintEnvOptions) { o.withoutNewer = true } + +// hintNewer is the option that changes the newer version in the saved index. +func hintNewer(change func(*registry.VersionRecord)) func(*hintEnvOptions) { + return func(o *hintEnvOptions) { o.newer = change } +} + +// hintIndex is the option that changes the index before it is published. +func hintIndex(change ...func(*registrytest.IndexBuilder)) func(*hintEnvOptions) { + return func(o *hintEnvOptions) { o.index = change } +} + +// newHintEnv returns a runEnv whose plugin is approved and installed in version 1.0.0, +// and whose saved index lists the version 1.1.0 as the latest, unless an option says +// otherwise. +func newHintEnv(t *testing.T, options ...func(*hintEnvOptions)) *runEnv { + t.Helper() + + var o hintEnvOptions + + for _, apply := range options { + apply(&o) + } + + t.Setenv(agentModeEnvVar, "false") + t.Setenv(pluginUpdateHintEnv, "") + + r := newRunEnv(t, fixtureCommands()) + r.approve() + + records := []registry.VersionRecord{r.record} + + if !o.withoutNewer { + newer := r.releaseFixture("1.1.0", fixtureCommands()) + + if o.newer != nil { + o.newer(&newer) + } + + records = append(records, newer) + } + + r.publishFixture(append([]func(*registrytest.IndexBuilder){func(b *registrytest.IndexBuilder) { b.SetVersion(2) }}, o.index...), records...) + + _, err := r.svc.client.Refresh(context.Background()) + require.NoError(t, err) + + r.svc.outputTerminals = func() bool { return true } + + return r +} + +func hintPath(r *runEnv) string { + return filepath.Join(r.Dir, pluginHintFile) +} + +func writeHint(t *testing.T, r *runEnv, contents string) { + t.Helper() + + require.NoError(t, os.WriteFile(hintPath(r), []byte(contents), 0o600)) +} + +const hintLine = "checkmate 1.1.0 is available. Run: auth0 plugin update checkmate" + +func TestPluginRun_UpdateHint(t *testing.T) { + t.Run("a plugin that is pinned gets it once, after it ran", func(t *testing.T) { + r := newHintEnv(t) + + installed, err := r.svc.store.List() + require.NoError(t, err) + require.Len(t, installed, 1) + require.True(t, installed[0].Pinned, "an install of a version is a pin") + + require.NoError(t, r.exec("checkmate")) + + assert.Equal(t, 1, strings.Count(r.stderr.String(), hintLine), r.stderr.String()) + assert.Equal(t, 1, strings.Count(r.stderr.String(), "available"), "one line, and not one for every plugin") + assert.Less(t, strings.Index(r.stderr.String(), "fixture stderr"), strings.Index(r.stderr.String(), hintLine), "after the plugin") + + // The plugin was run, and its output is untouched. + assert.Equal(t, []string{}, r.report(t).Args) + + data, err := os.ReadFile(hintPath(r)) + require.NoError(t, err) + + var hint pluginHint + + require.NoError(t, json.Unmarshal(data, &hint)) + assert.WithinDuration(t, time.Now(), hint.Shown, time.Minute) + + if runtime.GOOS != "windows" { + info, err := os.Stat(hintPath(r)) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + } + + entries, err := os.ReadDir(r.Dir) + require.NoError(t, err) + + for _, entry := range entries { + assert.False(t, strings.HasPrefix(entry.Name(), ".hint-"), "no temporary file is left: %s", entry.Name()) + } + }) + + t.Run("a command of the plugin gets it as well", func(t *testing.T) { + r := newHintEnv(t) + + require.NoError(t, r.exec("checkmate", "scan")) + + assert.Contains(t, r.stderr.String(), hintLine) + }) + + t.Run("not again within a day, and again after it", func(t *testing.T) { + r := newHintEnv(t) + + require.NoError(t, r.exec("checkmate")) + require.Contains(t, r.stderr.String(), hintLine) + + first, err := os.ReadFile(hintPath(r)) + require.NoError(t, err) + + require.NoError(t, r.exec("checkmate")) + assert.NotContains(t, r.stderr.String(), hintLine) + + again, err := os.ReadFile(hintPath(r)) + require.NoError(t, err) + assert.Equal(t, string(first), string(again), "a run without a hint does not touch the file") + + for _, ago := range []time.Duration{time.Minute, 23 * time.Hour} { + writeHint(t, r, `{"shown":"`+time.Now().Add(-ago).UTC().Format(time.RFC3339)+`"}`) + + require.NoError(t, r.exec("checkmate")) + assert.NotContains(t, r.stderr.String(), hintLine, ago.String()) + } + + stale := time.Now().Add(-25 * time.Hour).UTC().Format(time.RFC3339) + writeHint(t, r, `{"shown":"`+stale+`"}`) + + require.NoError(t, r.exec("checkmate")) + assert.Contains(t, r.stderr.String(), hintLine) + + data, err := os.ReadFile(hintPath(r)) + require.NoError(t, err) + assert.NotContains(t, string(data), stale, "the time of the new hint is saved") + }) + + t.Run("a hint in the future means no hint was shown", func(t *testing.T) { + r := newHintEnv(t) + writeHint(t, r, `{"shown":"`+time.Now().Add(72*time.Hour).UTC().Format(time.RFC3339)+`"}`) + + require.NoError(t, r.exec("checkmate")) + + assert.Contains(t, r.stderr.String(), hintLine) + }) + + t.Run("a hint file that cannot be written never fails the command", func(t *testing.T) { + r := newHintEnv(t) + + // A directory that is not empty cannot be replaced by the file. + require.NoError(t, os.MkdirAll(filepath.Join(hintPath(r), "inside"), 0o700)) + + require.NoError(t, r.exec("checkmate")) + + assert.Contains(t, r.stderr.String(), hintLine) + assert.Equal(t, []string{}, r.report(t).Args) + + entries, err := os.ReadDir(r.Dir) + require.NoError(t, err) + + for _, entry := range entries { + assert.False(t, strings.HasPrefix(entry.Name(), ".hint-"), "no temporary file is left: %s", entry.Name()) + } + }) + + t.Run("a failed run gets none", func(t *testing.T) { + r := newHintEnv(t) + + require.Error(t, r.exec("checkmate", "--mode=exit", "--code=2")) + + assert.NotContains(t, r.stderr.String(), hintLine) + assert.NoFileExists(t, hintPath(r)) + }) + + t.Run("a refused run and the help get none", func(t *testing.T) { + r := newHintEnv(t) + + require.NoError(t, r.exec("checkmate", "--help")) + assert.NotContains(t, r.stderr.String(), hintLine) + assert.NotContains(t, r.stdout.String(), hintLine) + + t.Setenv(pluginsDisabledEnv, "true") + + require.Error(t, r.exec("checkmate")) + assert.NotContains(t, r.stderr.String(), hintLine) + assert.NoFileExists(t, hintPath(r)) + }) + + // Each of these turns the hint off, and nothing else changes. + off := []struct { + name string + args []string + setup func(t *testing.T, r *runEnv) + }{ + {name: "stdout or stderr is not a terminal", setup: func(_ *testing.T, r *runEnv) { r.svc.outputTerminals = func() bool { return false } }}, + {name: "agent mode", args: []string{"--agent-mode"}}, + {name: "agent mode alone", args: []string{"--agent-mode", "--json=false", "--no-input=false"}}, + {name: "no input", args: []string{"--no-input"}}, + {name: "json", args: []string{"--json"}}, + {name: "compact json", args: []string{"--json-compact"}}, + {name: "csv", args: []string{"--csv"}}, + {name: "the switch is off", setup: func(t *testing.T, _ *runEnv) { t.Setenv(pluginUpdateHintEnv, "false") }}, + {name: "the last hint is recent", setup: func(t *testing.T, r *runEnv) { + writeHint(t, r, `{"shown":"`+time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)+`"}`) + }}, + } + + for _, test := range off { + t.Run("off when "+test.name, func(t *testing.T) { + r := newHintEnv(t) + + if test.setup != nil { + test.setup(t, r) + } + + before, _ := os.ReadFile(hintPath(r)) + + require.NoError(t, r.exec(append([]string{"checkmate"}, test.args...)...)) + + assert.NotContains(t, r.stderr.String(), "available", r.stderr.String()) + assert.NotContains(t, r.stdout.String(), "available") + + after, _ := os.ReadFile(hintPath(r)) + assert.Equal(t, string(before), string(after), "a hint that is not shown is not recorded") + }) + } + + t.Run("the switch set to true leaves it on", func(t *testing.T) { + r := newHintEnv(t) + t.Setenv(pluginUpdateHintEnv, "true") + + require.NoError(t, r.exec("checkmate")) + + assert.Contains(t, r.stderr.String(), hintLine) + }) + + t.Run("nothing to say when the plugin is current", func(t *testing.T) { + r := newHintEnv(t, hintWithoutNewer) + + require.NoError(t, r.exec("checkmate")) + + assert.NotContains(t, r.stderr.String(), "available") + assert.NoFileExists(t, hintPath(r), "nothing was shown, so nothing is recorded") + }) + + t.Run("a deprecated plugin that has no newer version still says so", func(t *testing.T) { + r := newHintEnv(t, hintWithoutNewer, hintIndex(func(b *registrytest.IndexBuilder) { + b.Deprecate(registry.Deprecation{ID: pluginTestID, Message: "Moved.", Replacement: "auth0/other"}) + })) + + require.NoError(t, r.exec("checkmate")) + + assert.Contains(t, r.stderr.String(), "checkmate is deprecated: Moved. Use auth0/other.\n") + assert.NotContains(t, r.stderr.String(), "is available") + + // It keeps running, and it is said once a day. + assert.Equal(t, []string{}, r.report(t).Args) + + require.NoError(t, r.exec("checkmate")) + assert.NotContains(t, r.stderr.String(), "deprecated") + }) + + t.Run("a newer version that cannot run here is not offered", func(t *testing.T) { + r := newHintEnv(t, hintNewer(func(newer *registry.VersionRecord) { + newer.Engines = &plugins.Engines{Auth0CLI: ">=9.0.0"} + })) + + require.NoError(t, r.exec("checkmate")) + + assert.NotContains(t, r.stderr.String(), "available") + }) +} + +func TestPluginHintEnabled(t *testing.T) { + for value, want := range map[string]bool{ + "": true, + " ": true, + "true": true, + "1": true, + " true": true, + "false": false, + "0": false, + "never": false, + } { + t.Run("the switch is "+strconv.Quote(value), func(t *testing.T) { + t.Setenv(pluginUpdateHintEnv, value) + + assert.Equal(t, want, pluginHintEnabled()) + }) + } +} + +func TestReadPluginHint(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, pluginHintFile) + + t.Run("a missing file", func(t *testing.T) { + _, ok := readPluginHint(path) + assert.False(t, ok) + }) + + t.Run("what the CLI wrote", func(t *testing.T) { + shown := time.Date(2026, time.October, 1, 12, 0, 0, 0, time.UTC) + + writePluginHint(path, shown) + + got, ok := readPluginHint(path) + require.True(t, ok) + assert.True(t, shown.Equal(got)) + }) + + t.Run("a file that is not what the CLI wrote", func(t *testing.T) { + now := time.Now().UTC() + + for name, contents := range map[string]string{ + "empty": "", + "broken": "{", + "null": "null", + "not an object": "[1]", + "not a time": `{"shown":"yesterday"}`, + "zero time": `{"shown":"0001-01-01T00:00:00Z"}`, + "a number": `{"shown":42}`, + "too big": `{"shown":"` + now.Format(time.RFC3339) + `","padding":"` + strings.Repeat("a", pluginHintMaxBytes) + `"}`, + "padded": `{"shown":"` + now.Add(-time.Hour).Format(time.RFC3339) + `"}` + strings.Repeat(" ", pluginHintMaxBytes), + } { + t.Run(name, func(t *testing.T) { + damaged := filepath.Join(t.TempDir(), pluginHintFile) + require.NoError(t, os.WriteFile(damaged, []byte(contents), 0o600)) + + _, ok := readPluginHint(damaged) + assert.False(t, ok) + }) + } + }) + + t.Run("a directory", func(t *testing.T) { + directory := filepath.Join(dir, "directory") + require.NoError(t, os.Mkdir(directory, 0o700)) + + _, ok := readPluginHint(directory) + assert.False(t, ok) + }) + + t.Run("a named pipe does not hold the command up", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no named pipes here") + } + + pipe := filepath.Join(dir, "pipe") + require.NoError(t, syscall.Mkfifo(pipe, 0o600)) + + done := make(chan bool, 1) + + go func() { + _, ok := readPluginHint(pipe) + done <- ok + }() + + select { + case ok := <-done: + assert.False(t, ok) + case <-time.After(10 * time.Second): + t.Fatal("reading the hint file blocked on a named pipe") + } + }) + + t.Run("a symbolic link is not followed", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no symbolic links without a privilege") + } + + target := filepath.Join(dir, "target.json") + writePluginHint(target, time.Now()) + + link := filepath.Join(dir, "link.json") + require.NoError(t, os.Symlink(target, link)) + + _, ok := readPluginHint(link) + assert.False(t, ok) + }) + + t.Run("writing where there is no directory does nothing", func(t *testing.T) { + assert.NotPanics(t, func() { writePluginHint(filepath.Join(dir, "missing", pluginHintFile), time.Now()) }) + assert.NoDirExists(t, filepath.Join(dir, "missing")) + }) +} diff --git a/internal/cli/plugin_run.go b/internal/cli/plugin_run.go index d48fe642a..9aa1e6ed8 100644 --- a/internal/cli/plugin_run.go +++ b/internal/cli/plugin_run.go @@ -2,6 +2,7 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" "maps" @@ -37,6 +38,19 @@ const ( // PluginCommandAnnotation marks the commands that installed plugins add. pluginCommandAnnotation = "plugin" + // PluginInfoAnnotation holds, as JSON, what the list of commands says about a + // command of a plugin, see pluginInfo. + pluginInfoAnnotation = "pluginInfo" + + // The groups of the root command in the help, once a plugin adds commands. A + // root command that has a group for the plugin commands needs one for the core + // commands too. + coreGroupID = "core" + pluginGroupID = "plugins" + + coreGroupTitle = "Available Resources:" + pluginGroupTitle = "Plugin Commands:" + // PluginRefreshTimeout bounds the refresh of a stale registry index before a // plugin runs, so that a slow network does not hold up a plugin that may run // from the saved index. @@ -81,6 +95,7 @@ func registerPluginCommands(rootCmd *cobra.Command, cli *cli, version string, tr cli.shadowedPlugins = nil core := map[string]bool{"help": true, "completion": true} + registered := false for _, command := range slices.Concat(rootCmd.Commands(), trailing) { core[command.Name()] = true @@ -108,10 +123,60 @@ func registerPluginCommands(rootCmd *cobra.Command, cli *cli, version string, tr continue } - rootCmd.AddCommand(newPluginCommand(cli, rootCmd, id, manifests[id])) + command := newPluginCommand(cli, rootCmd, id, manifests[id]) + command.GroupID = pluginGroupID + + rootCmd.AddCommand(command) + + registered = true + } + + if registered { + groupPluginCommands(rootCmd, trailing) } } +// groupPluginCommands shows the commands of the plugins in the help of the root +// under their own heading. The root help lists the commands under one heading, so +// the core commands, which are the ones that are not a plugin command, get a group +// of their own with the heading they have without plugins, and the usage template +// of the root lists the groups. It is only called when a plugin added a command, +// so the help of a CLI without plugins is not touched. +func groupPluginCommands(rootCmd *cobra.Command, trailing []*cobra.Command) { + rootCmd.AddGroup( + &cobra.Group{ID: coreGroupID, Title: coreGroupTitle}, + &cobra.Group{ID: pluginGroupID, Title: pluginGroupTitle}, + ) + + for _, command := range slices.Concat(rootCmd.Commands(), trailing) { + if command.GroupID == "" { + command.GroupID = coreGroupID + } + } + + rootCmd.SetUsageTemplate(groupedRootUsageTemplate()) +} + +// groupedRootUsageTemplate is the usage template of the root with the commands +// listed by group: the core commands, and then the commands of the plugins. The +// list of the template is replaced as it is, so when the template of the root +// changes and the list is not found, the template stays as it is and the commands +// are listed together. +func groupedRootUsageTemplate() string { + const list = `{{range .Commands}}{{if (.IsAvailableCommand)}} + {{rpad .Name .NamePadding }} {{.Short}} {{WrappedAliases .}}{{end}}{{end}}` + + grouped := func(match string) string { + return `{{range .Commands}}{{if (and .IsAvailableCommand ` + match + `)}} + {{rpad .Name .NamePadding }} {{.Short}} {{WrappedAliases .}}{{end}}{{end}}` + } + + return strings.Replace(namespaceUsageTemplate(), + ansi.Bold(coreGroupTitle)+list, + ansi.Bold(coreGroupTitle)+grouped(`(ne .GroupID "`+pluginGroupID+`")`)+"\n\n"+ansi.Bold(pluginGroupTitle)+grouped(`(eq .GroupID "`+pluginGroupID+`")`), + 1) +} + // pluginStore returns the store of the installed plugins, for registering their // commands. It reads no configuration: the plugins directory is a path next to // the config file, whether or not the config is read, as it is not in env auth @@ -184,7 +249,8 @@ func newPluginCommand(cli *cli, rootCmd *cobra.Command, id string, manifest *plu Short: plugins.SafeText(manifest.Description), Args: cobra.ArbitraryArgs, DisableFlagParsing: true, - Annotations: pluginAnnotations(), + Annotations: pluginAnnotations(id, manifest.Version, nil), + ValidArgsFunction: completePluginFlags, PersistentPreRunE: p.preRun, } @@ -192,6 +258,7 @@ func newPluginCommand(cli *cli, rootCmd *cobra.Command, id string, manifest *plu if rootSpec != nil { top.Short = plugins.SafeText(rootSpec.Description) + top.Annotations = pluginAnnotations(id, manifest.Version, rootSpec) top.Annotations[pluginRootAnnotation] = "true" top.RunE = func(cmd *cobra.Command, _ []string) error { return p.run(cmd, *rootSpec, nil) } @@ -215,7 +282,8 @@ func newPluginCommand(cli *cli, rootCmd *cobra.Command, id string, manifest *plu Long: pluginLong(id, manifest, &spec), Args: cobra.ArbitraryArgs, DisableFlagParsing: true, - Annotations: pluginAnnotations(), + Annotations: pluginAnnotations(id, manifest.Version, &spec), + ValidArgsFunction: completePluginFlags, RunE: func(cmd *cobra.Command, _ []string) error { return p.run(cmd, spec, []string{spec.Name}) }, } @@ -227,14 +295,104 @@ func newPluginCommand(cli *cli, rootCmd *cobra.Command, id string, manifest *plu } // pluginAnnotations returns the annotations of a plugin command: it is a plugin -// command, it stays out of the generated docs while the feature is gated, and the -// root pre-run hook does not log in for it. -func pluginAnnotations() map[string]string { - return map[string]string{ +// command, it stays out of the generated docs while the feature is gated, the root +// pre-run hook does not log in for it, and the list of commands finds out about +// the plugin from pluginInfo. The spec is nil for the top command of a plugin that +// has no root command, which only groups the others. +func pluginAnnotations(id, version string, spec *plugins.Command) map[string]string { + annotations := map[string]string{ pluginCommandAnnotation: "true", skipDocsAnnotation: "true", skipAuthAnnotation: "true", } + + info := pluginInfo{ID: id, Version: version} + + if spec != nil { + info.Interactive = spec.Interactive + info.Output = &commandOutput{} + + if spec.Output != nil { + *info.Output = commandOutput{JSON: spec.Output.JSON, CSV: spec.Output.CSV, AgentMode: spec.Output.AgentMode} + } + + if spec.Permissions != nil && len(spec.Permissions.Scopes) > 0 { + info.Scopes = plugins.NormalizePermissions(*spec.Permissions).Scopes + } + } + + if data, err := json.Marshal(info); err == nil { + annotations[pluginInfoAnnotation] = string(data) + } + + return annotations +} + +// pluginInfo is what the list of commands says about a command of a plugin, which +// is kept in an annotation of the command. The scopes, the output and whether it +// is interactive are those of the command that the call runs, and are not set for +// the top command of a plugin that has no root command. +type pluginInfo struct { + ID string `json:"id"` + Version string `json:"version"` + Scopes []string `json:"scopes,omitempty"` + Interactive bool `json:"interactive,omitempty"` + Output *commandOutput `json:"output,omitempty"` +} + +// pluginInfoOf returns the information about a command of a plugin, and reports +// false for every other command. +func pluginInfoOf(cmd *cobra.Command) (pluginInfo, bool) { + var info pluginInfo + + data, ok := cmd.Annotations[pluginInfoAnnotation] + if !ok || json.Unmarshal([]byte(data), &info) != nil { + return pluginInfo{}, false + } + + return info, true +} + +// completePluginFlags completes the flags that the manifest declares, and the +// output flags that the command supports. The value of a flag is not completed. +// +// Cobra completes the flags of a command by itself, but it first looks for flags +// that are marked as required, and lists only those when it finds any. The CLI +// marks its own flags that way before a command runs, and that includes the +// completion command, so a flag of a plugin is left out whenever a flag of the CLI +// starts with what was typed. This adds the flags that cobra left out, and nothing +// else, so that no flag is listed twice. +func completePluginFlags(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if !strings.HasPrefix(toComplete, "-") { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // This is the test that cobra makes for the flags of the CLI, see + // completeRequireFlags in cobra. + var onlyRequired bool + + cmd.InheritedFlags().VisitAll(func(flag *pflag.Flag) { + _, required := flag.Annotations[cobra.BashCompOneRequiredFlag] + + onlyRequired = onlyRequired || (required && !flag.Changed && !flag.Hidden && flag.Deprecated == "" && + (strings.HasPrefix("--"+flag.Name, toComplete) || (flag.Shorthand != "" && strings.HasPrefix("-"+flag.Shorthand, toComplete)))) + }) + + if !onlyRequired { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var completions []string + + cmd.LocalFlags().VisitAll(func(flag *pflag.Flag) { + if flag.Hidden || flag.Name == "help" || !strings.HasPrefix("--"+flag.Name, toComplete) { + return + } + + completions = append(completions, cobra.CompletionWithDesc("--"+flag.Name, flag.Usage)) + }) + + return completions, cobra.ShellCompDirectiveNoFileComp } // addPluginFlags registers the flags of a command of the manifest, and the output @@ -656,14 +814,19 @@ func (p *pluginCommand) run(cmd *cobra.Command, spec plugins.Command, lead []str Interactive: command.Interactive, }) - if summary := gate.Summary(); summary != "" { + // In agent mode the renderer prints nothing, and a failed run carries the + // summary in its error instead. + summary := gate.Summary() + if summary != "" { c.renderer.Warnf("%s", summary) } if err != nil { - return p.failure(err) + return p.failure(err, summary) } + p.hintUpdate(svc, index, again.Version) + return nil } @@ -853,10 +1016,11 @@ func environment(cmd *cobra.Command, command plugins.Command, tenant string, pro } // failure turns how the plugin ended into the error of the command. The plugin -// did not end well: err is not nil. -func (p *pluginCommand) failure(err error) error { +// did not end well: err is not nil. The summary is the line about the calls that +// the proxy denied, when there is one. +func (p *pluginCommand) failure(err error, summary string) error { name := plugins.NameOf(p.id) - failed := pluginError{err: err, pluginID: p.id, version: p.version} + failed := pluginError{err: err, pluginID: p.id, version: p.version, deniedCalls: summary} if exit, ok := errors.AsType[*runner.ExitError](err); ok { failed.reason, failed.exitCode = "plugin_exit", exit.Code diff --git a/internal/cli/plugin_run_test.go b/internal/cli/plugin_run_test.go index e12d69b5c..9f62fe6b9 100644 --- a/internal/cli/plugin_run_test.go +++ b/internal/cli/plugin_run_test.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "maps" "net/http" "os" "os/exec" @@ -29,6 +30,7 @@ import ( "github.com/stretchr/testify/require" gokeyring "github.com/zalando/go-keyring" + "github.com/auth0/auth0-cli/internal/buildinfo" "github.com/auth0/auth0-cli/internal/config" "github.com/auth0/auth0-cli/internal/display" "github.com/auth0/auth0-cli/internal/keyring" @@ -94,7 +96,14 @@ func runPluginFixture(args []string) (code int, ok bool) { return 0, true case "call": - return fixtureCall(options["--path"]), true + code := fixtureCall(options["--path"]) + + // A call that is followed by a failure, with --code. + if exit, err := strconv.Atoi(options["--code"]); err == nil && code == 0 { + code = exit + } + + return code, true default: return fixtureReport(args[1:]), true } @@ -537,12 +546,17 @@ func TestRegisterPluginCommands_Shape(t *testing.T) { assert.Equal(t, "Check a tenant", top.Short) assert.True(t, top.DisableFlagParsing) assert.True(t, top.Runnable()) + annotations := maps.Clone(top.Annotations) + info := annotations[pluginInfoAnnotation] + delete(annotations, pluginInfoAnnotation) + assert.Equal(t, map[string]string{ pluginCommandAnnotation: "true", pluginRootAnnotation: "true", skipDocsAnnotation: "true", skipAuthAnnotation: "true", - }, top.Annotations) + }, annotations) + assert.JSONEq(t, `{"id":"auth0/checkmate","version":"1.0.0","scopes":["read:clients"],"output":{"json":true,"csv":true,"agentMode":true}}`, info) // The root command of the manifest is the top command and not a subcommand. var names []string @@ -2188,7 +2202,7 @@ func TestPluginFailure(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - err := p.failure(test.err) + err := p.failure(test.err, "") var failed pluginError @@ -2455,3 +2469,515 @@ func TestInterruptGuard(t *testing.T) { assert.False(t, interrupted(context.Background())) }) } + +// tree returns the command tree the way Execute builds it, so that the help and +// the completion are those of the real CLI. +func (r *runEnv) tree() *cobra.Command { + return newRootTree(r.cli) +} + +// newRootTree returns the command tree the way Execute builds it. +func newRootTree(c *cli) *cobra.Command { + root := buildRootCmd(c) + root.SetUsageTemplate(namespaceUsageTemplate()) + + addPersistentFlags(root, c) + addSubCommands(root, c) + enforceUnknownSubcommand(root) + overrideHelpAndVersionFlagText(root) + + return root +} + +func TestRegisterPluginCommands_HelpGroups(t *testing.T) { + t.Setenv(agentModeEnvVar, "false") + + // The part of the usage of the root that lists the commands. + section := func(usage, heading string) string { + start := strings.Index(usage, heading) + require.GreaterOrEqual(t, start, 0, "no %q in\n%s", heading, usage) + + rest := usage[start+len(heading):] + end := strings.Index(rest, "\n\n") + require.GreaterOrEqual(t, end, 0) + + return rest[:end] + } + + t.Run("without a plugin the help is the one it was", func(t *testing.T) { + e := newPluginEnv(t) + root := newRootTree(e.cli) + + assert.Empty(t, root.Groups()) + assert.Equal(t, namespaceUsageTemplate(), root.UsageTemplate()) + + for _, command := range root.Commands() { + assert.Empty(t, command.GroupID, command.Name()) + } + + usage := root.UsageString() + + assert.Contains(t, usage, "Available Resources:") + assert.NotContains(t, usage, "Plugin Commands:") + }) + + t.Run("a build without the plugin feature has no heading either", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + + original := buildinfo.Version + buildinfo.Version = "1.39.0" + + t.Cleanup(func() { buildinfo.Version = original }) + + root := r.tree() + + assert.Empty(t, root.Groups()) + assert.NotContains(t, root.UsageString(), "Plugin Commands:") + assert.NotContains(t, root.UsageString(), "checkmate") + }) + + t.Run("a plugin has its own heading after the core commands", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + root := r.tree() + + require.Len(t, root.Groups(), 2) + assert.Equal(t, coreGroupID, root.Groups()[0].ID) + assert.Equal(t, pluginGroupID, root.Groups()[1].ID) + + usage := root.UsageString() + + require.Less(t, strings.Index(usage, "Available Resources:"), strings.Index(usage, "Plugin Commands:")) + + core := section(usage, "Available Resources:") + pluginSection := section(usage, "Plugin Commands:") + + for _, name := range []string{"login", "apps", "plugin", "docs", "commands", "completion"} { + assert.Contains(t, core, name) + assert.NotContains(t, pluginSection, name+" ") + } + + assert.NotContains(t, core, "checkmate") + assert.Regexp(t, `^\s+checkmate\s+Check a tenant\s*$`, pluginSection) + + for _, command := range root.Commands() { + want := coreGroupID + if command.Name() == pluginTestBin { + want = pluginGroupID + } + + assert.Equal(t, want, command.GroupID, command.Name()) + } + + // The help renders for a person the same way. + var help bytes.Buffer + + root.SetOut(&help) + require.NoError(t, root.Help()) + assert.Contains(t, help.String(), "Plugin Commands:") + }) + + t.Run("a plugin that a core command shadows is not listed", func(t *testing.T) { + e := newPluginEnv(t) + + apps := e.releaseAs("auth0/apps", e.platform, "1.0.0") + fine := e.releaseAs("auth0/pluginless", e.platform, "1.0.0") + + e.publish(registrytest.NewIndexBuilder(). + AddPlugin(registrytest.NewPlugin("auth0/apps", apps)). + AddPlugin(registrytest.NewPlugin("auth0/pluginless", fine))) + + e.installPlugin("auth0/apps", "1.0.0") + + // Alone, the shadowed plugin adds no heading. + assert.Empty(t, newRootTree(e.cli).Groups()) + + e.installPlugin("auth0/pluginless", "1.0.0") + + usage := newRootTree(e.cli).UsageString() + + assert.Regexp(t, `^\s+pluginless\s`, section(usage, "Plugin Commands:")) + assert.NotContains(t, strings.TrimSpace(section(usage, "Plugin Commands:")), "\n", "one plugin is listed") + assert.Contains(t, section(usage, "Available Resources:"), "apps") + }) +} + +func TestPluginCommand_Completion(t *testing.T) { + t.Setenv(agentModeEnvVar, "false") + + r := newRunEnv(t, fixtureCommands()) + + // Complete runs the hidden command of Cobra and returns the lines it printed + // before the directive. + complete := func(args ...string) []string { + t.Helper() + + require.NoError(t, r.exec(append([]string{cobra.ShellCompRequestCmd}, args...)...)) + + var lines []string + + for _, line := range strings.Split(strings.TrimSpace(r.stdout.String()), "\n") { + if strings.HasPrefix(line, ":") || strings.HasPrefix(line, "Completion ended") { + continue + } + + lines = append(lines, line) + } + + assert.False(t, r.started(), "completing never runs the plugin") + assert.Zero(t, r.logins) + + return lines + } + + t.Run("the name of the plugin with its description", func(t *testing.T) { + assert.Contains(t, complete(""), "checkmate\tCheck a tenant") + assert.Equal(t, []string{"checkmate\tCheck a tenant"}, complete("check")) + }) + + t.Run("the subcommands", func(t *testing.T) { + lines := complete("checkmate", "") + + assert.Contains(t, lines, "scan\tScan the users") + assert.Contains(t, lines, "report\tWrite a report") + + for _, line := range lines { + assert.False(t, strings.HasPrefix(line, "--validators") || strings.HasPrefix(line, "--json") || strings.HasPrefix(line, "--csv"), "the flags of the plugin wait for a dash: %s", line) + } + + for _, line := range complete("checkmate", "scan", "") { + assert.False(t, strings.HasPrefix(line, "--depth"), "the flags of the plugin wait for a dash: %s", line) + } + }) + + t.Run("the flags of the manifest", func(t *testing.T) { + assert.Contains(t, complete("checkmate", "scan", "--"), "--depth\tHow deep to scan") + assert.Contains(t, complete("checkmate", "scan", "--de"), "--depth\tHow deep to scan") + assert.NotContains(t, complete("checkmate", "scan", "--va"), "--validators\tThe validators to run", "the flags of another command") + + lines := complete("checkmate", "--") + + assert.Contains(t, lines, "--validators\tThe validators to run") + assert.Contains(t, lines, "--json\tOutput in json format.") + assert.Contains(t, lines, "--csv\tOutput in csv format.") + assert.NotContains(t, lines, "--depth\tHow deep to scan") + }) + + t.Run("an output flag that the command does not support", func(t *testing.T) { + for _, line := range complete("checkmate", "scan", "--") { + assert.False(t, strings.HasPrefix(line, "--json"), line) + assert.False(t, strings.HasPrefix(line, "--csv"), line) + } + + assert.Contains(t, complete("checkmate", "report", "--"), "--json\tOutput in json format.") + }) + + t.Run("each flag once, whatever has been typed", func(t *testing.T) { + tree := r.tree() + + for _, path := range [][]string{{"checkmate"}, {"checkmate", "scan"}, {"checkmate", "report"}} { + command, _, err := tree.Find(path) + require.NoError(t, err) + + // The flags of the CLI and of the command that are not hidden. + var names []string + + for _, flags := range []*pflag.FlagSet{command.InheritedFlags(), command.LocalFlags()} { + flags.VisitAll(func(flag *pflag.Flag) { + if !flag.Hidden && flag.Name != "help" { + names = append(names, "--"+flag.Name) + } + }) + } + + for _, typed := range []string{"-", "--", "--d", "--de", "--dep", "--depth", "--j", "--js", "--json", "--json-c", "--v", "--n", "--no", "--c", "--cs", "--t", "--a", "--z"} { + var want []string + + for _, name := range names { + if strings.HasPrefix(name, typed) { + want = append(want, name) + } + } + + var got []string + + for _, line := range complete(append(path, typed)...) { + got = append(got, strings.SplitN(line, "\t", 2)[0]) + } + + assert.ElementsMatch(t, want, got, "%v %s", path, typed) + } + } + }) + + t.Run("no value and no completion after a flag is written", func(t *testing.T) { + assert.Empty(t, complete("checkmate", "scan", "--depth=")) + assert.Empty(t, complete("checkmate", "scan", "x")) + }) +} + +func TestCompletePluginFlags_NotTheHelpFlag(t *testing.T) { + t.Setenv(agentModeEnvVar, "false") + + r := newRunEnv(t, fixtureCommands()) + tree := r.tree() + + scan, _, err := tree.Find([]string{"checkmate", "scan"}) + require.NoError(t, err) + + // A command that has run before has the help flag, and a flag of the CLI that + // is marked the way the CLI marks them makes cobra leave the flags of the plugin + // out. + scan.InitDefaultHelpFlag() + require.NoError(t, tree.PersistentFlags().SetAnnotation("debug", cobra.BashCompOneRequiredFlag, []string{"false"})) + + completions, directive := completePluginFlags(scan, nil, "--") + + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Contains(t, completions, "--depth\tHow deep to scan") + + for _, completion := range completions { + assert.False(t, strings.HasPrefix(completion, "--help"), completion) + } +} + +func TestCommands_PluginSource(t *testing.T) { + t.Setenv(agentModeEnvVar, "false") + + commands := fixtureCommands() + commands[2].Interactive = true + + r := newRunEnv(t, commands) + root := r.tree() + + var find func(nodes []commandNode, path string) (commandNode, bool) + + find = func(nodes []commandNode, path string) (commandNode, bool) { + for _, node := range nodes { + if node.Path == path { + return node, true + } + + if found, ok := find(node.Subcommands, path); ok { + return found, true + } + } + + return commandNode{}, false + } + + // Every node of the plugin, whatever the form of the list, says where it comes + // from, and no other node does. + checkSource := func(t *testing.T, nodes []commandNode) { + t.Helper() + + seen := 0 + + var walk func(nodes []commandNode) + + walk = func(nodes []commandNode) { + for _, node := range nodes { + if node.Path == "auth0 checkmate" || strings.HasPrefix(node.Path, "auth0 checkmate ") { + seen++ + + assert.Equal(t, "plugin", node.Source, node.Path) + assert.Equal(t, "auth0/checkmate", node.PluginID, node.Path) + assert.Equal(t, "1.0.0", node.PluginVersion, node.Path) + } else { + assert.Empty(t, node.Source, node.Path) + assert.Empty(t, node.PluginID, node.Path) + assert.Empty(t, node.PluginVersion, node.Path) + assert.Empty(t, node.RequiredScopes, node.Path) + assert.False(t, node.Interactive, node.Path) + assert.Nil(t, node.Output, node.Path) + } + + walk(node.Subcommands) + } + } + + walk(nodes) + + assert.Positive(t, seen) + } + + t.Run("the tree", func(t *testing.T) { + for _, detailed := range []bool{false, true} { + tree := buildCommandTree(root, 0, detailed) + + checkSource(t, tree) + + top, ok := find(tree, "auth0 checkmate") + require.True(t, ok) + assert.Equal(t, []string{"read:clients"}, top.RequiredScopes) + assert.Equal(t, &commandOutput{JSON: true, CSV: true, AgentMode: true}, top.Output) + assert.True(t, top.RequiresAuth) + + scan, ok := find(tree, "auth0 checkmate scan") + require.True(t, ok) + assert.Equal(t, []string{"read:users"}, scan.RequiredScopes) + assert.Equal(t, &commandOutput{}, scan.Output, "a command without output declares none") + assert.False(t, scan.Interactive) + assert.True(t, scan.RequiresAuth) + + report, ok := find(tree, "auth0 checkmate report") + require.True(t, ok) + assert.Empty(t, report.RequiredScopes) + assert.True(t, report.Interactive) + assert.Equal(t, &commandOutput{JSON: true}, report.Output) + } + }) + + t.Run("the flat list", func(t *testing.T) { + for _, detailed := range []bool{false, true} { + nodes := flattenCommands(root, false, detailed) + + checkSource(t, nodes) + + scan, ok := find(nodes, "auth0 checkmate scan") + require.True(t, ok) + assert.Equal(t, []string{"read:users"}, scan.RequiredScopes) + } + }) + + t.Run("one command", func(t *testing.T) { + scan, _, err := root.Find([]string{"checkmate", "scan"}) + require.NoError(t, err) + + checkSource(t, []commandNode{buildNode(scan, 1, 1, true)}) + + nodes := flattenCommands(scan, true, true) + require.Len(t, nodes, 1) + assert.Equal(t, "plugin", nodes[0].Source) + }) + + t.Run("the command prints it", func(t *testing.T) { + var nodes []commandNode + + out := captureOutput(t, func() { require.NoError(t, r.exec("commands", "checkmate", "scan", "--json", "--detailed")) }) + require.NoError(t, json.Unmarshal([]byte(out), &nodes), out) + require.Len(t, nodes, 1) + + assert.Equal(t, "plugin", nodes[0].Source) + assert.Equal(t, []string{"read:users"}, nodes[0].RequiredScopes) + assert.Contains(t, out, `"source": "plugin"`) + assert.Contains(t, out, `"requiredScopes"`) + + out = captureOutput(t, func() { require.NoError(t, r.exec("commands", "checkmate", "--flat", "--json")) }) + require.NoError(t, json.Unmarshal([]byte(out), &nodes), out) + + checkSource(t, nodes) + + out = captureOutput(t, func() { require.NoError(t, r.exec("commands", "checkmate")) }) + assert.Contains(t, out, "scan") + assert.Contains(t, out, "Scan the users") + }) + + t.Run("a core command is the same with and without plugins", func(t *testing.T) { + // Both trees are new, because listing a tree changes how a command writes + // its usage line. + with, without := buildCommandTree(r.tree(), 0, true), buildCommandTree(newRootTree(&cli{}), 0, true) + + for _, node := range with { + if node.Source != "" { + continue + } + + expected, ok := find(without, node.Path) + require.True(t, ok, node.Path) + + got, err := json.Marshal(node) + require.NoError(t, err) + + want, err := json.Marshal(expected) + require.NoError(t, err) + + assert.Equal(t, string(want), string(got), node.Path) + } + }) + + t.Run("a core node has no new field", func(t *testing.T) { + data, err := json.Marshal(commandNode{Path: "auth0 login", Name: "login", Short: "Authenticate", Runnable: true, RequiresAuth: false}) + require.NoError(t, err) + + assert.JSONEq(t, `{"path":"auth0 login","name":"login","short":"Authenticate","runnable":true,"requiresAuth":false}`, string(data)) + }) +} + +func TestPluginRun_DeniedCallsInTheEnvelope(t *testing.T) { + t.Setenv(agentModeEnvVar, "false") + + denied := func(t *testing.T, err error) string { + t.Helper() + + var details struct { + PluginID string `json:"pluginId"` + ExitCode int `json:"exitCode"` + DeniedCalls string `json:"deniedCalls"` + } + + require.NoError(t, json.Unmarshal(buildErrorEnvelope(err).Error.Details, &details)) + assert.Equal(t, "auth0/checkmate", details.PluginID) + + return details.DeniedCalls + } + + t.Run("a failed run in agent mode carries the summary and prints nothing", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + err := r.exec("checkmate", "--agent-mode", "--mode=call", "--path=/api/v2/users", "--code=3") + + require.Error(t, err) + assert.Equal(t, "plugin_exit", errorReason(err)) + assert.Contains(t, denied(t, err), "API call was denied") + assert.NotContains(t, r.stderr.String(), "API call was denied", "stderr stays free of the human line") + assert.Empty(t, r.stderr.String()) + }) + + t.Run("a failed run with a person still gets the line, and the envelope has it too", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + err := r.exec("checkmate", "--mode=call", "--path=/api/v2/users", "--code=3") + + require.Error(t, err) + assert.Contains(t, r.stderr.String(), "API call was denied") + assert.Contains(t, denied(t, err), "API call was denied") + }) + + t.Run("a run that ends well adds nothing", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + require.NoError(t, r.exec("checkmate", "--agent-mode", "--mode=call", "--path=/api/v2/users")) + + assert.Contains(t, r.stdout.String(), "status=403") + assert.Empty(t, r.stderr.String()) + }) + + t.Run("a failed run without a denied call adds nothing", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + r.approve() + + err := r.exec("checkmate", "--agent-mode", "--mode=call", "--path=/api/v2/clients", "--code=3") + + require.Error(t, err) + assert.JSONEq(t, `{"pluginId":"auth0/checkmate","version":"1.0.0","exitCode":3}`, string(buildErrorEnvelope(err).Error.Details)) + }) + + t.Run("every way a run ends badly carries it", func(t *testing.T) { + p := &pluginCommand{id: pluginTestID, version: "1.0.0"} + + for _, failure := range []error{ + &runner.ExitError{Code: 2}, + &runner.TimeoutError{After: time.Second}, + &runner.InterruptedError{}, + errors.New("could not start"), + } { + err := p.failure(failure, "1 API call was denied.") + + assert.Equal(t, "1 API call was denied.", denied(t, err), failure) + assert.NotContains(t, string(buildErrorEnvelope(p.failure(failure, "")).Error.Details), "deniedCalls", failure) + } + }) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 08a6a4fa0..efe2da54f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -184,6 +184,10 @@ func Execute() { cancelCtx := contextWithCancel() err := classifyRequiredFlagError(rootCmd.ExecuteContext(cancelCtx)) + // A command that is not found may be a plugin that is in the registry but is not + // installed. + err = cli.offerPluginInstall(rootCmd, buildinfo.Version, err) + // A command that held the interrupt has stopped what it started by now. if interrupted(cancelCtx) { os.Exit(exitInterrupted) // nolint:gocritic @@ -703,6 +707,13 @@ func overrideHelpAndVersionFlagText(cmd *cobra.Command) { func renderErrorMessage(display *display.Renderer, err error) { display.Heading(ansi.Red("error")) + // The message ends with a command, so it gets no full stop. + if _, ok := errors.AsType[pluginNotInstalledError](err); ok { + display.Errorf("%s", err.Error()) + display.Newline() + return + } + rawErrorMessage := []rune(err.Error()) if len(rawErrorMessage) == 0 { display.Errorf("An unknown error occurred.") diff --git a/internal/cli/templates.go b/internal/cli/templates.go index 4daf44dd8..1d6b6425b 100644 --- a/internal/cli/templates.go +++ b/internal/cli/templates.go @@ -87,7 +87,7 @@ Use "auth0 commands" for an overview of every command, and add --json to any --h ansi.Bold("Usage:"), ansi.Bold("Aliases:"), ansi.Bold("Examples:"), - ansi.Bold("Available Resources:"), + ansi.Bold(coreGroupTitle), ansi.Bold("Flags:"), ansi.Bold("Global Flags:"), ansi.Bold("Additional help topics:"),