diff --git a/README.md b/README.md index c027f97..0fec54e 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,21 @@ cu api /list/abc123/task -X POST -d '{"name": "New Task"}' cu api "/list/abc123/task?archived=false" ``` +## Extensions + +Any executable named `cu-` on your `PATH` becomes `cu `, the same +convention `kubectl` and `gh` use: + +```bash +# an executable called cu-worklog on PATH +cu worklog plan --since 1d # runs: cu-worklog plan --since 1d +``` + +Builtins always win, so an extension can never shadow a cu command — a shadowed +extension stays runnable directly as `cu-`. The child inherits +`CU_CONFIG_DIR`, `CU_WORKSPACE` and `CU_PROJECT_CONFIG` so it does not have to +rediscover cu's context, and its exit code is passed through unchanged. + ## Documentation Full documentation is available at [https://timimsms.github.io/cu/](https://timimsms.github.io/cu/) diff --git a/internal/cmd/extension.go b/internal/cmd/extension.go new file mode 100644 index 0000000..d5076d3 --- /dev/null +++ b/internal/cmd/extension.go @@ -0,0 +1,178 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/spf13/pflag" + "github.com/timimsms/cu/internal/config" +) + +// extensionPrefix is the naming convention for external subcommands: +// `cu worklog` runs `cu-worklog` from PATH, the same shape kubectl and gh use. +const extensionPrefix = "cu-" + +// extensionName constrains a word to a bare command name. Without this, +// exec.LookPath treats anything containing a separator as a *path* rather than +// a PATH search, so `cu ../../tmp/evil` would resolve and run an executable +// that was never on PATH at all. Extensions are found on PATH, by name, or not +// at all. +var extensionName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) + +// tryExtension runs an external subcommand when cobra cannot resolve the word, +// and never returns if it does. It is deliberately the *last* resort: builtins +// always win, so an extension can never shadow a cu command. +// +// There is no PATH scan at startup — one exec.LookPath only once a word is +// known to be unresolvable — so this costs nothing on the normal path. +func tryExtension(args []string) { + if len(args) == 0 { + return + } + + // Only consider a word cobra itself could not resolve. + if _, _, err := rootCmd.Find(args); err == nil { + return + } + + word := firstCommandWord(args, rootCmd.PersistentFlags()) + if word == "" || strings.HasPrefix(word, extensionPrefix) || !extensionName.MatchString(word) { + return + } + + path, err := exec.LookPath(extensionPrefix + word) + if err != nil { + // No such extension: fall through so cobra reports the unknown + // command with its did-you-mean suggestions. + return + } + + runExtension(path, word, args) +} + +// runExtension execs the extension and exits with its status. It does not +// return. +func runExtension(path, word string, args []string) { + // Pass every argument after the extension word through verbatim, including + // flags that look like cu's own — they belong to the extension. + rest := argsAfter(args, word) + + // #nosec G702 G204 -- the resolved path is not attacker-controlled: the word + // is constrained to ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ so it cannot contain a path + // separator, meaning LookPath can only return an executable already on the + // user's own PATH. The arguments are the user's own command line, forwarded + // verbatim, and cross no privilege boundary — this is the same trust model + // as the shell that invoked cu. + cmd := exec.Command(path, rest...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = extensionEnv(args) + + err := cmd.Run() + if err == nil { + os.Exit(0) + } + + // Propagate the child's exit status; a cu-shaped failure code from an + // extension should reach the caller's shell unchanged. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode()) + } + + fmt.Fprintf(os.Stderr, "failed to run %s: %v\n", filepath.Base(path), err) + os.Exit(1) +} + +// extensionEnv hands the child the context it would otherwise have to +// rediscover: which config cu resolved, and which workspace and project file +// are in play. +func extensionEnv(args []string) []string { + // The extension runs before PersistentPreRunE, so load config here. A + // failure is not fatal — the extension simply gets less context. + _ = config.Init(configFileFrom(args)) + + env := append(os.Environ(), + "CU_CONFIG_DIR="+filepath.Dir(config.GlobalConfigPath()), + "CU_WORKSPACE="+config.GetString("default_workspace"), + "CU_PROJECT_CONFIG="+config.GetProjectConfigPath(), + ) + return env +} + +// configFileFrom extracts a --config value so the child sees the same config +// cu would have used. +func configFileFrom(args []string) string { + for i := 0; i < len(args); i++ { + a := args[i] + if v, ok := strings.CutPrefix(a, "--config="); ok { + return v + } + if a == "--config" && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +// argsAfter returns everything following the extension word, so cu's own +// global flags that preceded it are not forwarded. +func argsAfter(args []string, word string) []string { + for i, a := range args { + if a == word { + return args[i+1:] + } + } + return nil +} + +// firstCommandWord returns the first positional argument — the candidate +// command — skipping global flags and the values they consume. Getting this +// wrong would treat a flag's value as a command name, so flag arity is read +// from the flag set rather than guessed. +func firstCommandWord(args []string, flags *pflag.FlagSet) string { + for i := 0; i < len(args); i++ { + a := args[i] + + switch { + case a == "--": + return "" + + case strings.HasPrefix(a, "--"): + if strings.Contains(a, "=") { + continue // --flag=value consumes nothing further + } + name := strings.TrimPrefix(a, "--") + if takesValue(flags.Lookup(name)) { + i++ + } + + case strings.HasPrefix(a, "-") && len(a) > 1: + if strings.Contains(a, "=") { + continue + } + // In a shorthand cluster only the final flag can take a value. + last := a[len(a)-1:] + if takesValue(flags.ShorthandLookup(last)) { + i++ + } + + default: + return a + } + } + return "" +} + +// takesValue reports whether a flag consumes the following argument. An +// unknown flag is assumed not to, so an unrecognised flag cannot swallow the +// command word. +func takesValue(f *pflag.Flag) bool { + return f != nil && f.Value.Type() != "bool" +} diff --git a/internal/cmd/extension_test.go b/internal/cmd/extension_test.go new file mode 100644 index 0000000..24a55f0 --- /dev/null +++ b/internal/cmd/extension_test.go @@ -0,0 +1,104 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" +) + +func testFlags() *pflag.FlagSet { + fs := pflag.NewFlagSet("cu", pflag.ContinueOnError) + fs.String("config", "", "") + fs.Bool("debug", false, "") + fs.StringP("output", "o", "table", "") + return fs +} + +func TestFirstCommandWord(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {"bare word", []string{"worklog", "plan"}, "worklog"}, + {"no args", nil, ""}, + {"only flags", []string{"--debug"}, ""}, + + // A value-taking flag must not have its value mistaken for the + // command — that would exec cu-x instead of cu-worklog. + {"long flag with value", []string{"--config", "x", "worklog"}, "worklog"}, + {"long flag with equals", []string{"--config=x", "worklog"}, "worklog"}, + {"shorthand with value", []string{"-o", "json", "worklog"}, "worklog"}, + {"shorthand with equals", []string{"-o=json", "worklog"}, "worklog"}, + + // Boolean flags consume nothing. + {"bool flag", []string{"--debug", "worklog"}, "worklog"}, + {"bool then valued", []string{"--debug", "--config", "x", "worklog"}, "worklog"}, + + // An unknown flag is assumed not to take a value, so it cannot + // swallow the command word. + {"unknown flag", []string{"--mystery", "worklog"}, "worklog"}, + + {"double dash ends the search", []string{"--", "worklog"}, ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, firstCommandWord(tc.args, testFlags())) + }) + } +} + +func TestArgsAfter(t *testing.T) { + t.Run("forwards everything after the word", func(t *testing.T) { + got := argsAfter([]string{"worklog", "plan", "--since", "1d"}, "worklog") + assert.Equal(t, []string{"plan", "--since", "1d"}, got) + }) + + t.Run("cu's own preceding flags are not forwarded", func(t *testing.T) { + got := argsAfter([]string{"--output", "json", "worklog", "plan"}, "worklog") + assert.Equal(t, []string{"plan"}, got) + }) + + t.Run("word with no trailing args", func(t *testing.T) { + assert.Empty(t, argsAfter([]string{"worklog"}, "worklog")) + }) + + t.Run("absent word", func(t *testing.T) { + assert.Nil(t, argsAfter([]string{"task", "list"}, "worklog")) + }) +} + +func TestConfigFileFrom(t *testing.T) { + assert.Equal(t, "/tmp/c.yaml", configFileFrom([]string{"--config", "/tmp/c.yaml", "worklog"})) + assert.Equal(t, "/tmp/c.yaml", configFileFrom([]string{"--config=/tmp/c.yaml", "worklog"})) + assert.Equal(t, "", configFileFrom([]string{"worklog", "plan"})) + assert.Equal(t, "", configFileFrom([]string{"--config"}), "a dangling --config must not panic") +} + +func TestTryExtensionLeavesBuiltinsAlone(t *testing.T) { + // tryExtension exits the process when it dispatches, so the safe + // assertion is that a resolvable builtin returns normally — proving + // builtins are never routed to an extension. + tryExtension([]string{"version"}) + tryExtension([]string{"task", "list"}) + tryExtension([]string{}) +} + +func TestExtensionNameRejectsPaths(t *testing.T) { + // exec.LookPath treats a word containing a separator as a path rather than + // a PATH search, so without this constraint `cu ../../tmp/evil` would run + // an executable that was never on PATH. Extensions are found on PATH, by + // name, or not at all. + for _, bad := range []string{ + "../evil", "../../tmp/evil", "/abs/evil", "dir/evil", + ".hidden", "-leading-dash", "", "has space", "semi;colon", "dot.dot", + } { + assert.False(t, extensionName.MatchString(bad), "must reject %q", bad) + } + + for _, good := range []string{"worklog", "trailer", "my-ext", "my_ext", "ext2"} { + assert.True(t, extensionName.MatchString(good), "must accept %q", good) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index e1738df..49ae424 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -40,6 +40,9 @@ enabling efficient task management and seamless integration with development wor // Execute adds all child commands to the root command and sets flags appropriately. func Execute() error { + // Builtins first: tryExtension only acts on a word cobra cannot resolve, + // and does not return when it finds one. + tryExtension(os.Args[1:]) return rootCmd.Execute() }