From 006f146a9b0d5ba5a08c4d56d89e90c45c8aa6f9 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Fri, 19 Dec 2025 13:50:29 -0800 Subject: [PATCH] Integrate extensions into help --- internal/temporalcli/commands.extension.go | 108 +++++++++++++++++++-- internal/temporalcli/commands.go | 3 + internal/temporalcli/commands.help.go | 88 +++++++++++++++++ internal/temporalcli/commands.help_test.go | 106 ++++++++++++++++++++ 4 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 internal/temporalcli/commands.help.go create mode 100644 internal/temporalcli/commands.help_test.go diff --git a/internal/temporalcli/commands.extension.go b/internal/temporalcli/commands.extension.go index 870573034..d46de64dc 100644 --- a/internal/temporalcli/commands.extension.go +++ b/internal/temporalcli/commands.extension.go @@ -3,7 +3,10 @@ package temporalcli import ( "context" "fmt" + "os" "os/exec" + "path/filepath" + "runtime" "slices" "strings" @@ -42,7 +45,7 @@ func tryExecuteExtension(cctx *CommandContext, tcmd *TemporalCommand) (error, bo cliParseArgs, cliPassArgs, extArgs := groupArgs(foundCmd, remainingArgs) // Search for an extension executable. - cmdPrefix := strings.Split(foundCmd.CommandPath(), " ")[1:] + cmdPrefix := strings.Fields(foundCmd.CommandPath()) extPath, extArgs := lookupExtension(cmdPrefix, extArgs) // Parse CLI args that need validation. @@ -159,21 +162,110 @@ func lookupExtension(cmdPrefix, extArgs []string) (string, []string) { if !isPosArg(arg) { break } - // Dashes are converted to underscores so "foo bar-baz" finds "temporal-foo-bar_baz". - posArgs = append(posArgs, strings.ReplaceAll(arg, extensionSeparator, argDashReplacement)) + posArgs = append(posArgs, arg) } // Try most-specific to least-specific. parts := append(cmdPrefix, posArgs...) for n := len(parts); n > len(cmdPrefix); n-- { - path, err := exec.LookPath(extensionPrefix + strings.Join(parts[:n], extensionSeparator)) + binName := extensionCommandToBinary(parts[:n]) + if fullPath, _ := isExecutable(binName); fullPath != "" { + // Remove matched positionals from extArgs (they come first). + matched := n - len(cmdPrefix) + return fullPath, extArgs[matched:] + } + } + + return "", extArgs +} + +// discoverExtensions scans the PATH for executables with the "temporal-" prefix +// and returns their command parts (without the prefix). +func discoverExtensions() [][]string { + var extensions [][]string + seen := make(map[string]bool) + + for _, dir := range filepath.SplitList(os.Getenv("PATH")) { + if dir == "" { + continue + } + + entries, err := os.ReadDir(dir) if err != nil { continue } - // Remove matched positionals from extArgs (they come first). - matched := n - len(cmdPrefix) - return path, extArgs[matched:] + + for _, entry := range entries { + name := entry.Name() + + // Look for extensions. + if !strings.HasPrefix(name, extensionPrefix) { + continue + } + + // Check if the file is executable. + fullPath, baseName := isExecutable(filepath.Join(dir, name)) + if fullPath == "" { + continue + } + + path := extensionBinaryToCommandPath(baseName) + key := strings.Join(path, "/") + if seen[key] { + continue + } + + seen[key] = true + extensions = append(extensions, path) + } } + return extensions +} - return "", extArgs +// isExecutable checks if a file or command is executable. +// On Windows, it validates PATHEXT suffix and strips it from the base name. +// Returns the full path and base name (without Windows extension suffix). +func isExecutable(name string) (fullPath, baseName string) { + path, err := exec.LookPath(name) + if err != nil { + return "", "" + } + + base := filepath.Base(path) + if runtime.GOOS == "windows" { + pathext := os.Getenv("PATHEXT") + if pathext == "" { + pathext = ".exe;.bat" + } + lower := strings.ToLower(base) + for ext := range strings.SplitSeq(strings.ToLower(pathext), ";") { + if ext != "" && strings.HasSuffix(lower, ext) { + return path, base[:len(base)-len(ext)] + } + } + return "", "" + } + return path, base +} + +// extensionBinaryToCommandPath converts a binary name to command path. +// Underscores are converted to dashes. +// For example: "temporal-foo-bar_baz" -> ["temporal", "foo", "bar-baz"] +func extensionBinaryToCommandPath(binary string) []string { + path := strings.Split(binary, extensionSeparator) + for i, p := range path { + path[i] = strings.ReplaceAll(p, argDashReplacement, extensionSeparator) + } + return path +} + +// extensionCommandToBinary converts command path to a binary name. +// Dashes in path are converted to underscores. +// For example: ["temporal", "foo", "bar-baz"] -> "temporal-foo-bar_baz" +func extensionCommandToBinary(path []string) string { + converted := make([]string, len(path)) + for i, p := range path { + converted[i] = strings.ReplaceAll(p, extensionSeparator, argDashReplacement) + } + return strings.Join(converted, extensionSeparator) } diff --git a/internal/temporalcli/commands.go b/internal/temporalcli/commands.go index acf98142b..4b5e854d3 100644 --- a/internal/temporalcli/commands.go +++ b/internal/temporalcli/commands.go @@ -443,6 +443,9 @@ func (c *TemporalCommand) initCommand(cctx *CommandContext) { // Set custom usage template with proper flag wrapping c.Command.SetUsageTemplate(getUsageTemplate()) + // Customize the built-in help command to support --all/-a for listing extensions + customizeHelpCommand(&c.Command) + // Unfortunately color is a global option, so we can set in pre-run but we // must unset in post-run origNoColor := color.NoColor diff --git a/internal/temporalcli/commands.help.go b/internal/temporalcli/commands.help.go new file mode 100644 index 000000000..f821473bc --- /dev/null +++ b/internal/temporalcli/commands.help.go @@ -0,0 +1,88 @@ +package temporalcli + +import ( + "slices" + "strings" + + "github.com/spf13/cobra" +) + +// customizeHelpCommand adds the --all/-a flag to Cobra's built-in help command +// and customizes its behavior to include extensions when the flag is set. +func customizeHelpCommand(rootCmd *cobra.Command) { + // Ensure the default help command is initialized + rootCmd.InitDefaultHelpCmd() + + // Find the help command + var helpCmd *cobra.Command + for _, c := range rootCmd.Commands() { + if c.Name() == "help" { + helpCmd = c + break + } + } + if helpCmd == nil { + return + } + + // Add --all/-a flag + var showAll bool + helpCmd.Flags().BoolVarP(&showAll, "all", "a", false, "Show all commands including extensions found in PATH.") + + // Store the original help function + originalRun := helpCmd.Run + + // Override the run function + helpCmd.Run = func(cmd *cobra.Command, args []string) { + // Find target command + targetCmd := rootCmd + if len(args) > 0 { + if found, _, err := rootCmd.Find(args); err == nil { + targetCmd = found + } + } + + // If --all is set, register extensions as commands before showing help + if showAll { + registerExtensionCommands(targetCmd) + } + + // Run original help + originalRun(cmd, args) + } +} + +// registerExtensionCommands adds discovered extensions as placeholder commands +// so they appear in the default help output. It filters extensions based on +// the current command's path in the hierarchy. +func registerExtensionCommands(cmd *cobra.Command) { + cmdPath := strings.Fields(cmd.CommandPath()) + seen := make(map[string]bool) + + for _, ext := range discoverExtensions() { + // Extension must be deeper than current command and share the same prefix + if len(ext) <= len(cmdPath) || !slices.Equal(ext[:len(cmdPath)], cmdPath) { + continue + } + + // Get the next level command name + nextPart := ext[len(cmdPath)] + + // Skip if already added + if seen[nextPart] { + continue + } + + // Skip if a built-in command exists + if found, _, _ := cmd.Find([]string{nextPart}); found != cmd { + continue + } + + seen[nextPart] = true + cmd.AddCommand(&cobra.Command{ + Use: nextPart, + DisableFlagParsing: true, + Run: func(*cobra.Command, []string) {}, + }) + } +} diff --git a/internal/temporalcli/commands.help_test.go b/internal/temporalcli/commands.help_test.go new file mode 100644 index 000000000..ebf6757cd --- /dev/null +++ b/internal/temporalcli/commands.help_test.go @@ -0,0 +1,106 @@ +package temporalcli_test + +import ( + "os" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHelp_Root(t *testing.T) { + h := NewCommandHarness(t) + + res := h.Execute("help") + + assert.Contains(t, res.Stdout.String(), "Available Commands:") + assert.Contains(t, res.Stdout.String(), "workflow") + assert.NoError(t, res.Err) +} + +func TestHelp_Subcommand(t *testing.T) { + h := NewCommandHarness(t) + + res := h.Execute("help", "workflow") + + assert.Contains(t, res.Stdout.String(), "Workflow commands") + assert.NoError(t, res.Err) +} + +func TestHelp_HelpShowsAllFlag(t *testing.T) { + h := NewCommandHarness(t) + res := h.Execute("help", "--help") + + assert.Contains(t, res.Stdout.String(), "-a, --all") + assert.Contains(t, res.Stdout.String(), "extensions found in PATH") +} + +func TestHelp_AllFlag_ShowsExtensions(t *testing.T) { + h := newExtensionHarness(t) + fooPath := h.createExtension("temporal-foo", codeEchoArgs) + fooBarPath := h.createExtension("temporal-foo-bar", codeEchoArgs) + h.createExtension("temporal-workflow-bar_baz", codeEchoArgs) + + // Without --all, no extensions are shown + res := h.Execute("help") + assert.NotContains(t, res.Stdout.String(), "foo") + assert.NotContains(t, res.Stdout.String(), "bar-baz") + assert.NoError(t, res.Err) + + // With --all, extensions on root level are shown in Available Commands (not Additional help topics) + res = h.Execute("help", "--all") + out := res.Stdout.String() + assert.Contains(t, out, "foo") // shown now! + assert.NotContains(t, out, "bar-baz") // is under workflow + + // Verify foo appears in Available Commands section (between "Available Commands:" and "Flags:") + availableIdx := strings.Index(out, "Available Commands:") + fooIdx := strings.Index(out, "foo") + flagsIdx := strings.Index(out, "Flags:") + assert.Greater(t, fooIdx, availableIdx, "foo should appear after Available Commands:") + assert.Less(t, fooIdx, flagsIdx, "foo should appear before Flags:") + assert.NoError(t, res.Err) + + // Non-executable extensions are skipped + // On Unix, remove executable permission; on Windows, rename to .bak extension + if runtime.GOOS == "windows" { + require.NoError(t, os.Rename(fooPath, fooPath+".bak")) + require.NoError(t, os.Rename(fooBarPath, fooBarPath+".bak")) + } else { + require.NoError(t, os.Chmod(fooPath, 0644)) + require.NoError(t, os.Chmod(fooBarPath, 0644)) + } + res = h.Execute("help", "--all") + assert.NotContains(t, res.Stdout.String(), "foo") + assert.NoError(t, res.Err) + + // With --all on built-in subcommand, shows nested extensions + res = h.Execute("help", "workflow", "--all") + assert.Contains(t, res.Stdout.String(), "bar-baz") + assert.NoError(t, res.Err) +} + +func TestHelp_AllFlag_FirstInPathWins(t *testing.T) { + h := newExtensionHarness(t) + binDir1 := h.binDir + binDir2 := t.TempDir() + + // Set PATH with binDir1 before binDir2 + oldPath := os.Getenv("PATH") + os.Setenv("PATH", binDir1+string(os.PathListSeparator)+binDir2+string(os.PathListSeparator)+oldPath) + t.Cleanup(func() { os.Setenv("PATH", oldPath) }) + + // Create extension in binDir1 that outputs "first" + h.createExtension("temporal-foo", `fmt.Println("first")`) + + // Create extension in binDir2 that outputs "second" + h.binDir = binDir2 + h.createExtension("temporal-foo", `fmt.Println("second")`) + + // Should use the first one found in PATH + res := h.Execute("foo") + assert.Equal(t, "first\n", res.Stdout.String()) + assert.NoError(t, res.Err) +}