Skip to content
Merged
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
108 changes: 100 additions & 8 deletions internal/temporalcli/commands.extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package temporalcli
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was finding files on Windows that weren't actually executables; the new isExecutable addresses that.

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)
}
3 changes: 3 additions & 0 deletions internal/temporalcli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions internal/temporalcli/commands.help.go
Original file line number Diff line number Diff line change
@@ -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) {},
})
}
}
106 changes: 106 additions & 0 deletions internal/temporalcli/commands.help_test.go
Original file line number Diff line number Diff line change
@@ -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)
}