Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
57 changes: 53 additions & 4 deletions internal/cli/exit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand All @@ -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
}
Expand Down Expand Up @@ -156,6 +195,11 @@ func errorClass(err error) string {
return "plugin"
}

var notInstalledErr pluginNotInstalledError
if errors.As(err, &notInstalledErr) {
return "not_found"
}

if errors.Is(err, config.ErrInvalidToken) ||
errors.Is(err, config.ErrMalformedToken) ||
errors.Is(err, config.ErrStoredTokenUnavailable) ||
Expand Down Expand Up @@ -245,6 +289,11 @@ func errorReason(err error) string {
return pluginErr.reason
}

var notInstalledErr pluginNotInstalledError
if errors.As(err, &notInstalledErr) {
return "plugin_not_installed"
}

if status, ok := managementHTTPStatus(err); ok {
return reasonForHTTPStatus(status)
}
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading