-
Notifications
You must be signed in to change notification settings - Fork 0
feat(plugin): external command discovery; remove stack refresh-db #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
10953bf
099614a
507689f
c43ccb9
8f1030c
c12b62b
cdcb53d
d1d7618
51c0043
35da51d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,214 @@ | ||||||||
| package cmd | ||||||||
|
|
||||||||
| import ( | ||||||||
| "fmt" | ||||||||
| "os" | ||||||||
| "os/exec" | ||||||||
| "path/filepath" | ||||||||
| "regexp" | ||||||||
| "runtime" | ||||||||
| "strings" | ||||||||
|
|
||||||||
| "github.com/spf13/cobra" | ||||||||
| ) | ||||||||
|
|
||||||||
| // pluginPrefix is the filename prefix that marks an executable as a stackctl | ||||||||
| // plugin. A binary at PATH/stackctl-foo becomes the subcommand `stackctl foo`. | ||||||||
| const pluginPrefix = "stackctl-" | ||||||||
|
|
||||||||
| // pluginNamePattern restricts plugin names to lowercase ASCII letters, digits, | ||||||||
| // and dashes, and requires the first character to be a letter or digit so names | ||||||||
| // form valid Cobra command names. A name like "stackctl- bad" (with whitespace) | ||||||||
| // or "stackctl--help" breaks help routing and is skipped at discovery time. | ||||||||
| // Uppercase filenames (stackctl-Foo) are skipped — Cobra is case-sensitive and | ||||||||
| // mixed-case subcommands are a footgun more than a feature. | ||||||||
| var pluginNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) | ||||||||
|
|
||||||||
| // registerPlugins scans $PATH for executables named stackctl-<name> and adds | ||||||||
| // each as a top-level subcommand that proxies to the external binary. | ||||||||
| // | ||||||||
| // Behaviour: | ||||||||
| // - Built-in subcommands always win on name collision; the plugin is skipped. | ||||||||
| // - Earlier entries in $PATH win over later duplicates (standard PATH semantics). | ||||||||
| // - Non-regular files, directories, and non-executables are ignored. | ||||||||
| // - Plugin stdout/stderr/stdin pass through the parent tty; exit codes propagate. | ||||||||
| // | ||||||||
| // Pattern modelled on git, kubectl, and gh. See also: | ||||||||
| // | ||||||||
| // https://git-scm.com/docs/git#_git_commands | ||||||||
| // https://kubernetes.io/docs/tasks/extend-kubectl/kubectl-plugins/ | ||||||||
| func registerPlugins(root *cobra.Command, pathEnv string) { | ||||||||
| builtins := existingCommandNames(root) | ||||||||
| // Cobra registers "help" and "completion" implicitly — make sure a | ||||||||
| // stackctl-help or stackctl-completion on PATH can't shadow them. | ||||||||
| builtins["help"] = struct{}{} | ||||||||
| builtins["completion"] = struct{}{} | ||||||||
| for name, path := range discoverPlugins(pathEnv) { | ||||||||
| if _, collides := builtins[name]; collides { | ||||||||
| continue | ||||||||
| } | ||||||||
| root.AddCommand(newPluginCommand(name, path)) | ||||||||
| builtins[name] = struct{}{} | ||||||||
| } | ||||||||
|
Comment on lines
+40
to
+52
|
||||||||
| } | ||||||||
|
|
||||||||
| // discoverPlugins walks pathEnv (the process $PATH) and returns a map of | ||||||||
| // plugin name to absolute path. First-win semantics: earlier PATH entries | ||||||||
| // take precedence over later ones when multiple binaries share a name. | ||||||||
| // | ||||||||
| // Paths are resolved to absolute via filepath.Abs so execution is safe | ||||||||
| // regardless of relative entries in $PATH — the captured path is what we | ||||||||
| // later exec, which means rebinding $PATH after this function runs cannot | ||||||||
| // change which binary a plugin routes to. | ||||||||
| // | ||||||||
| // Windows: discovery strips a trailing .exe so stackctl-foo.exe surfaces as | ||||||||
| // the subcommand `foo`. Other PATHEXT extensions (.bat, .cmd, .ps1) are not | ||||||||
| // currently recognised — if you need them, name the plugin binary .exe or | ||||||||
| // front it with a .exe shim. | ||||||||
| func discoverPlugins(pathEnv string) map[string]string { | ||||||||
| found := make(map[string]string) | ||||||||
| for _, dir := range filepath.SplitList(pathEnv) { | ||||||||
| if dir == "" { | ||||||||
| continue | ||||||||
| } | ||||||||
| entries, err := os.ReadDir(dir) | ||||||||
| if err != nil { | ||||||||
| continue | ||||||||
| } | ||||||||
| for _, entry := range entries { | ||||||||
| name := entry.Name() | ||||||||
| if !strings.HasPrefix(name, pluginPrefix) || name == pluginPrefix { | ||||||||
| continue | ||||||||
| } | ||||||||
| pluginName := strings.TrimPrefix(name, pluginPrefix) | ||||||||
| if runtime.GOOS == "windows" { | ||||||||
| if low := strings.ToLower(pluginName); strings.HasSuffix(low, ".exe") { | ||||||||
| pluginName = pluginName[:len(pluginName)-4] | ||||||||
| } | ||||||||
| } | ||||||||
| if !pluginNamePattern.MatchString(pluginName) { | ||||||||
| continue | ||||||||
| } | ||||||||
|
Comment on lines
+83
to
+91
|
||||||||
| if _, seen := found[pluginName]; seen { | ||||||||
|
Comment on lines
+79
to
+92
|
||||||||
| continue | ||||||||
| } | ||||||||
| full, err := filepath.Abs(filepath.Join(dir, name)) | ||||||||
| if err != nil { | ||||||||
| continue | ||||||||
| } | ||||||||
| info, err := os.Stat(full) | ||||||||
| if err != nil || !info.Mode().IsRegular() { | ||||||||
| continue | ||||||||
| } | ||||||||
| if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { | ||||||||
| continue | ||||||||
| } | ||||||||
|
Comment on lines
+83
to
+105
|
||||||||
| found[pluginName] = full | ||||||||
| } | ||||||||
| } | ||||||||
| return found | ||||||||
| } | ||||||||
|
|
||||||||
| // pluginEnv returns the environment to pass to a plugin subprocess. It | ||||||||
| // preserves the full parent environment — plugins might legitimately need | ||||||||
| // unrelated variables (AWS_PROFILE, KUBECONFIG, etc.) — and injects | ||||||||
| // STACKCTL_* values resolved from flags so a plugin sees the same effective | ||||||||
| // config stackctl itself would use for a built-in command. | ||||||||
| // | ||||||||
| // Precedence: an explicitly-passed flag wins over a pre-existing env var. | ||||||||
| // Flags that weren't set on the command line leave the inherited env | ||||||||
| // untouched, so STACKCTL_INSECURE=1 in the parent shell keeps working for | ||||||||
| // plugin invocations when no --insecure flag is passed. | ||||||||
| // | ||||||||
| // Flag-to-env wiring documented in EXTENDING.md as a plugin-author contract. | ||||||||
| func pluginEnv(cmd *cobra.Command) []string { | ||||||||
| env := os.Environ() | ||||||||
| if cmd == nil { | ||||||||
| return env | ||||||||
| } | ||||||||
| flags := cmd.Root().PersistentFlags() | ||||||||
| if flags.Changed("insecure") { | ||||||||
| if insecure, err := flags.GetBool("insecure"); err == nil { | ||||||||
| env = setEnv(env, "STACKCTL_INSECURE", boolEnvValue(insecure)) | ||||||||
| } | ||||||||
|
Comment on lines
+124
to
+133
|
||||||||
| } | ||||||||
| if flags.Changed("quiet") { | ||||||||
| if quiet, err := flags.GetBool("quiet"); err == nil { | ||||||||
| env = setEnv(env, "STACKCTL_QUIET", boolEnvValue(quiet)) | ||||||||
| } | ||||||||
| } | ||||||||
| if flags.Changed("output") { | ||||||||
| if output, err := flags.GetString("output"); err == nil { | ||||||||
|
||||||||
| if output, err := flags.GetString("output"); err == nil { | |
| if output, err := flags.GetString("output"); err == nil { | |
| output = strings.ToLower(strings.TrimSpace(output)) |
Copilot
AI
Apr 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pluginEnv only injects STACKCTL_QUIET/STACKCTL_OUTPUT when the corresponding persistent flags were explicitly set (Flags().Changed). This means plugins may (a) see no STACKCTL_OUTPUT at all when the user relies on the default "table", and (b) accidentally inherit STACKCTL_QUIET/STACKCTL_OUTPUT from the parent shell even when the user did not request quiet/custom output for this invocation—diverging from stackctl’s own flag behavior. Consider always setting STACKCTL_QUIET and STACKCTL_OUTPUT from the resolved flag values for each invocation (and only preserving an existing env var for STACKCTL_INSECURE if that’s an intentional compatibility feature).
Copilot
AI
Apr 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The os.Exit(exitErr.ExitCode()) path (exit-code propagation) isn't covered by tests right now, but it’s a key part of the plugin contract. Consider adding a test that runs a failing plugin via a subprocess (e.g., invoking a small helper test binary) and asserts stackctl exits with the plugin’s exact exit code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Plugin collision protection currently only considers commands already present in root.Commands(). Since stackctl doesn’t explicitly register Cobra’s default
helpcommand before calling registerPlugins(), astackctl-helpexecutable on PATH can be registered asstackctl help, effectively shadowing the built-in help UX. Consider reserving names likehelp(and potentially other Cobra-reserved commands) or explicitly initializing the default help command before computingexistingCommandNames()so plugins cannot override it.