From 14bebc60ea47e9019ef29ffe67e9f9fe6eb25642 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 11:02:19 +0100 Subject: [PATCH 1/6] feat(completion): answer command and option names in Go The completion scripts call `_complete` on each Tab, which used to start the legacy PHP CLI every time. Command and option names are now suggested in Go, from the embedded legacy command index and the Cobra commands, in the output format of Symfony's completion for bash, zsh and fish. The installed scripts keep working without being regenerated. PHP is only started for argument and option values (projects, environments, etc.), or for requests that Go does not parse. Other changes: - Native commands (e.g. init, lint) are now suggested, and commands disabled by config are not. - Option descriptions are flattened to one line, as a multi-line description broke zsh and fish suggestions. - Hidden options are not suggested. - Integration tests run the generated scripts in bash, zsh and fish. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 + CLAUDE.md | 2 +- commands/abbreviation.go | 17 +- commands/complete.go | 317 +++++++++++++++++++++++++++ commands/complete_test.go | 185 ++++++++++++++++ commands/completion.go | 19 +- integration-tests/completion_test.go | 80 +++++++ internal/legacy/commands.go | 20 +- 8 files changed, 629 insertions(+), 14 deletions(-) create mode 100644 commands/complete.go create mode 100644 commands/complete_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a62e6154..d69121b4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,9 @@ jobs: - name: Build CLI run: make single + - name: Install shells for completion tests + run: sudo apt-get update && sudo apt-get install -y bash-completion zsh fish + - name: Run integration tests run: make integration-test diff --git a/CLAUDE.md b/CLAUDE.md index 877ee04f0..d590a9e76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ The CLI operates as a wrapper around a legacy PHP CLI: - Go layer: Handles new commands (init, list, version, config:install, project:convert) and core infrastructure - PHP layer: Legacy commands are proxied through `internal/legacy/CLIWrapper` - The PHP CLI (platform.phar) is embedded at build time via go:embed -- An index of legacy commands (commands.json, from `list --all --format=json`) is embedded too, so the Go layer can resolve abbreviations like `p:init` in the same way as Symfony Console +- An index of legacy commands (commands.json, from `list --all --format=json`) is embedded too, so the Go layer can resolve abbreviations like `p:init` in the same way as Symfony Console, and answer shell completion (`_complete`) for command and option names; PHP is only started for argument and option values ### Key Components diff --git a/commands/abbreviation.go b/commands/abbreviation.go index 40f51dc4b..43f86f17e 100644 --- a/commands/abbreviation.go +++ b/commands/abbreviation.go @@ -45,7 +45,7 @@ func expandAbbreviation( } candidates = append(candidates, abbrevCandidate{names: names, hidden: c.Hidden, native: true}) } - if nativeNames[name] || resolveAbbreviation(name, candidates) == nil { + if nativeNames[name] || resolveAbbreviation(name, candidates) == -1 { return nil, false, nil } @@ -65,12 +65,12 @@ func expandAbbreviation( candidates = append(candidates, abbrevCandidate{names: names, hidden: c.Hidden}) } - target := resolveAbbreviation(name, candidates) - if target == nil || !target.native { + i := resolveAbbreviation(name, candidates) + if i == -1 || !candidates[i].native { return nil, false, nil } expanded = slices.Clone(args) - expanded[pos] = target.names[0] + expanded[pos] = candidates[i].names[0] return expanded, true, nil } @@ -116,7 +116,8 @@ func enabledLegacyCommands(cnf *config.Config, load func() ([]legacy.Command, er } // resolveAbbreviation follows Symfony Console's rules to find the command abbreviated by name, if it is unique. -func resolveAbbreviation(name string, candidates []abbrevCandidate) *abbrevCandidate { +// It returns the index of the candidate, or -1. +func resolveAbbreviation(name string, candidates []abbrevCandidate) int { parts := strings.Split(name, ":") for i, p := range parts { parts[i] = regexp.QuoteMeta(p) @@ -147,13 +148,13 @@ func resolveAbbreviation(name string, candidates []abbrevCandidate) *abbrevCandi // Prefix-only matches (e.g. "project" for "project:variable:get") count toward ambiguity, but at least one // command must match fully. if !fullMatch { - return nil + return -1 } // Hidden commands still count toward ambiguity: the legacy CLI's lazy-loaded commands do not reliably report // whether they are hidden, so it can resolve to them. if len(matched) != 1 || candidates[matched[0]].hidden { - return nil + return -1 } - return &candidates[matched[0]] + return matched[0] } diff --git a/commands/complete.go b/commands/complete.go new file mode 100644 index 000000000..2cb38b03a --- /dev/null +++ b/commands/complete.go @@ -0,0 +1,317 @@ +package commands + +import ( + "cmp" + "io" + "slices" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/upsun/cli/internal/config" + "github.com/upsun/cli/internal/legacy" +) + +// completeRequest is a request for suggestions from the Symfony completion scripts. +type completeRequest struct { + shell string + tokens []string // The command line, starting with the program name. + current int // The index of the token at the cursor, or len(tokens) after a space. +} + +// suggestion is a completion suggestion, with an optional description. +type suggestion struct { + value string + description string +} + +// completionCommand is a command that can be completed: native, legacy, or both if a native command replaces a +// legacy one with the same name. +type completionCommand struct { + names []string // The command name, followed by its aliases. + hiddenAliases []string + description string + hidden bool + native *cobra.Command + legacy *legacy.Command +} + +// completionOption is an option of a command, without its "-" or "--" prefix. +type completionOption struct { + name string + shorthands []string + description string + hidden bool +} + +// parseCompleteRequest parses the arguments that the completion scripts pass to _complete. +// It returns false for any other form, which is left to the legacy CLI. +func parseCompleteRequest(args []string) (r completeRequest, ok bool) { + var current, apiVersion string + for _, a := range args { + if a == "--no-interaction" { + continue + } + v, name, ok := cutCompleteOption(a) + if !ok { + return r, false + } + switch name { + case "shell": + r.shell = v + case "current": + current = v + case "api-version": + apiVersion = v + case "input": + r.tokens = append(r.tokens, v) + } + } + if !slices.Contains([]string{"bash", "zsh", "fish"}, r.shell) || apiVersion != "1" { + return r, false + } + var err error + if r.current, err = strconv.Atoi(current); err != nil || r.current < 1 || r.current > len(r.tokens) { + return r, false + } + return r, true +} + +// cutCompleteOption splits an option of _complete with a glued value, e.g. "-szsh" or "--shell=zsh". +func cutCompleteOption(arg string) (value, name string, ok bool) { + for _, o := range []struct{ short, long string }{ + {"s", "shell"}, {"c", "current"}, {"a", "api-version"}, {"i", "input"}, + } { + if v, found := strings.CutPrefix(arg, "--"+o.long+"="); found { + return v, o.long, v != "" + } + if v, found := strings.CutPrefix(arg, "-"+o.short); found && !strings.HasPrefix(arg, "--") { + return v, o.long, v != "" + } + } + return "", "", false +} + +// completeInGo answers a completion request for command or option names, mimicking Symfony's CompleteCommand. +// It returns false for argument and option values, which are left to the legacy CLI. +func completeInGo( + root *cobra.Command, + cnf *config.Config, + legacyCmds []legacy.Command, + r completeRequest, +) ([]suggestion, bool) { + cursorFree := r.current == len(r.tokens) + var atCursor string + if !cursorFree { + atCursor = r.tokens[r.current] + } + // The legacy CLI's global options take no value, so the first token without a "-" prefix is the command. + cmdIdx := slices.IndexFunc(r.tokens[1:], func(t string) bool { return t != "" && !strings.HasPrefix(t, "-") }) + if cmdIdx != -1 { + cmdIdx++ + } + isOptionName := !cursorFree && strings.HasPrefix(atCursor, "-") + + cmds := completionCommands(root, legacyCmds) + if cmdIdx == -1 { + if isOptionName { + return optionSuggestions(legacyGlobalOptions(cnf)), true + } + return commandSuggestions(cmds), true + } + + c := findCompletionCommand(cmds, r.tokens[cmdIdx]) + if !cursorFree && r.current == cmdIdx { + if c == nil { + return commandSuggestions(cmds), true + } + // Expand an abbreviation (e.g. "env:i") to the full name or alias. + names := c.names + if i := slices.IndexFunc(names, func(n string) bool { return strings.HasPrefix(n, atCursor) }); i != -1 { + names = names[i : i+1] + } + suggestions := make([]suggestion, len(names)) + for i, n := range names { + suggestions[i] = suggestion{value: n, description: c.description} + } + return suggestions, true + } + if c == nil { + return nil, false + } + + opts := c.options(cnf) + if isOptionName && !hasOption(opts, atCursor) { + return optionSuggestions(opts), true + } + if c.legacy != nil { + return nil, false + } + // Native commands do not complete values. + return nil, true +} + +// completionCommands lists the native commands and the enabled legacy commands that they do not replace. +func completionCommands(root *cobra.Command, legacyCmds []legacy.Command) []completionCommand { + legacyByName := make(map[string]*legacy.Command, len(legacyCmds)) + for i := range legacyCmds { + legacyByName[legacyCmds[i].Name] = &legacyCmds[i] + } + + var cmds []completionCommand + nativeNames := map[string]bool{} + for _, c := range root.Commands() { + names := append([]string{c.Name()}, c.Aliases...) + for _, n := range names { + nativeNames[n] = true + } + cmds = append(cmds, completionCommand{ + names: names, + description: c.Short, + hidden: c.Hidden, + native: c, + legacy: legacyByName[c.Name()], + }) + } + for i := range legacyCmds { + c := &legacyCmds[i] + if nativeNames[c.Name] { + continue + } + cmds = append(cmds, completionCommand{ + names: append([]string{c.Name}, c.Aliases...), + hiddenAliases: c.HiddenAliases, + description: c.Description, + hidden: c.Hidden, + legacy: c, + }) + } + return cmds +} + +// findCompletionCommand finds a command by its name, an alias or an abbreviation. +func findCompletionCommand(cmds []completionCommand, name string) *completionCommand { + candidates := make([]abbrevCandidate, len(cmds)) + for i, c := range cmds { + if slices.Contains(c.names, name) || slices.Contains(c.hiddenAliases, name) { + return &cmds[i] + } + candidates[i] = abbrevCandidate{names: c.names, hidden: c.hidden} + } + if i := resolveAbbreviation(name, candidates); i != -1 { + return &cmds[i] + } + return nil +} + +// commandSuggestions suggests the names and aliases of the visible commands. +func commandSuggestions(cmds []completionCommand) []suggestion { + var suggestions []suggestion + for _, c := range cmds { + if c.hidden { + continue + } + for _, n := range c.names { + suggestions = append(suggestions, suggestion{value: n, description: c.description}) + } + } + slices.SortFunc(suggestions, func(a, b suggestion) int { return cmp.Compare(a.value, b.value) }) + return suggestions +} + +// options lists the command's options, including global ones. A legacy definition takes precedence, as a native +// command with the same name hands its input to the legacy CLI. +func (c *completionCommand) options(cnf *config.Config) []completionOption { + if c.legacy == nil { + c.native.InitDefaultHelpFlag() + return append(flagOptions(c.native.LocalFlags()), flagOptions(c.native.InheritedFlags())...) + } + opts := legacyGlobalOptions(cnf) + for _, o := range c.legacy.Definition.Options { + opts = append(opts, newCompletionOption(o.Name, o.Shortcut, o.Description, o.Hidden)) + } + return opts +} + +// legacyGlobalOptions lists the legacy CLI's global options, which its commands' definitions leave out. +func legacyGlobalOptions(cnf *config.Config) []completionOption { + globals := globalOptions(cnf) + opts := make([]completionOption, 0, len(globals)) + for _, o := range globals { + opts = append(opts, newCompletionOption(o.Name, o.Shortcut, string(o.Description), o.Hidden)) + } + return opts +} + +// newCompletionOption converts a Symfony option, e.g. with the name "--verbose" and the shortcut "-v|vv|vvv". +func newCompletionOption(name, shortcut, description string, hidden bool) completionOption { + var shorthands []string + for s := range strings.SplitSeq(shortcut, "|") { + if s = strings.TrimLeft(s, "-"); s != "" { + shorthands = append(shorthands, s[:1]) + } + } + return completionOption{ + name: strings.TrimPrefix(name, "--"), + shorthands: shorthands, + description: description, + hidden: hidden, + } +} + +func flagOptions(fs *pflag.FlagSet) []completionOption { + var opts []completionOption + fs.VisitAll(func(f *pflag.Flag) { + o := completionOption{name: f.Name, description: f.Usage, hidden: f.Hidden} + if f.Shorthand != "" { + o.shorthands = []string{f.Shorthand} + } + opts = append(opts, o) + }) + return opts +} + +// hasOption tests if a token such as "--project=foo" or "-pfoo" names one of the options, like Symfony's +// CompletionInput. If not, option names are suggested. +func hasOption(opts []completionOption, token string) bool { + token, _, _ = strings.Cut(token, "=") + if name, ok := strings.CutPrefix(token, "--"); ok { + return name != "" && slices.ContainsFunc(opts, func(o completionOption) bool { return o.name == name }) + } + if len(token) < 2 { + return false + } + return slices.ContainsFunc(opts, func(o completionOption) bool { return slices.Contains(o.shorthands, token[1:2]) }) +} + +// optionSuggestions suggests the visible options. +func optionSuggestions(opts []completionOption) []suggestion { + var suggestions []suggestion + for _, o := range opts { + if !o.hidden { + suggestions = append(suggestions, suggestion{value: "--" + o.name, description: o.description}) + } + } + slices.SortFunc(suggestions, func(a, b suggestion) int { return cmp.Compare(a.value, b.value) }) + return slices.CompactFunc(suggestions, func(a, b suggestion) bool { return a.value == b.value }) +} + +// writeSuggestions writes suggestions in the format of Symfony's completion output for the shell. +func writeSuggestions(w io.Writer, shell string, suggestions []suggestion) error { + lines := make([]string, len(suggestions)) + for i, s := range suggestions { + lines[i] = s.value + if shell != "bash" && s.description != "" { + // Each suggestion must fit on one line. + lines[i] += "\t" + strings.Join(strings.Fields(s.description), " ") + } + } + out := strings.Join(lines, "\n") + if shell != "fish" { + out += "\n" + } + _, err := io.WriteString(w, out) + return err +} diff --git a/commands/complete_test.go b/commands/complete_test.go new file mode 100644 index 000000000..65a8ae942 --- /dev/null +++ b/commands/complete_test.go @@ -0,0 +1,185 @@ +package commands + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/internal/legacy" +) + +func TestParseCompleteRequest(t *testing.T) { + cases := []struct { + name string + args []string + want *completeRequest + }{ + { + name: "short options", + args: []string{"--no-interaction", "-szsh", "-a1", "-c1", "-iupsun", "-ienv"}, + want: &completeRequest{shell: "zsh", tokens: []string{"upsun", "env"}, current: 1}, + }, + { + name: "long options", + args: []string{"--no-interaction", "--shell=bash", "--api-version=1", "--current=2", "--input=upsun", + "--input=env", "--input=--pro"}, + want: &completeRequest{shell: "bash", tokens: []string{"upsun", "env", "--pro"}, current: 2}, + }, + { + name: "cursor after the last token", + args: []string{"-sfish", "-a1", "-iupsun", "-ienv", "-c2"}, + want: &completeRequest{shell: "fish", tokens: []string{"upsun", "env"}, current: 2}, + }, + {name: "separate value", args: []string{"-s", "zsh", "-a1", "-c1", "-iupsun"}}, + {name: "unknown option", args: []string{"-szsh", "-a1", "-c1", "-iupsun", "-S5.4"}}, + {name: "unsupported shell", args: []string{"-spwsh", "-a1", "-c1", "-iupsun"}}, + {name: "old API version", args: []string{"-szsh", "-a0", "-c1", "-iupsun"}}, + {name: "no API version", args: []string{"-szsh", "-c1", "-iupsun"}}, + {name: "cursor on the program", args: []string{"-szsh", "-a1", "-c0", "-iupsun"}}, + {name: "cursor out of range", args: []string{"-szsh", "-a1", "-c3", "-iupsun"}}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := parseCompleteRequest(c.args) + if c.want == nil { + assert.False(t, ok) + return + } + require.True(t, ok) + assert.Equal(t, *c.want, got) + }) + } +} + +func TestCompleteInGo(t *testing.T) { + cnf := testConfig() + root := &cobra.Command{Use: "upsun"} + root.PersistentFlags().Bool("debug", false, "Enable debug logging") + initCmd := &cobra.Command{Use: "init", Short: "Initialize a project", Aliases: []string{"project:init"}} + initCmd.Flags().Bool("ai", false, "Use AI") + initCmd.Flags().String("secret", "", "") + require.NoError(t, initCmd.Flags().MarkHidden("secret")) + root.AddCommand( + initCmd, + &cobra.Command{Use: "list", Short: "List commands"}, + &cobra.Command{Use: "_complete", Hidden: true}, + ) + + legacyCmds := []legacy.Command{ + { + Name: "list", + Description: "Lists commands", + Definition: legacy.Definition{Options: map[string]legacy.Option{ + "raw": {Name: "--raw", Description: "To output raw command list"}, + }}, + }, + { + Name: "project:info", + Aliases: []string{"pinfo"}, + HiddenAliases: []string{"project:metadata"}, + Description: "Read project properties", + Definition: legacy.Definition{Options: map[string]legacy.Option{ + "project": {Name: "--project", Shortcut: "-p", AcceptValue: true, Description: "The project ID"}, + "refresh": {Name: "--refresh", Description: "Refresh the cache"}, + "legacy": {Name: "--legacy", Hidden: true}, + }}, + }, + {Name: "project:init", Description: "Replaced by a native command"}, + {Name: "project:list", Aliases: []string{"projects"}, Description: "List projects"}, + {Name: "welcome", Hidden: true}, + } + + globals := []string{"--help", "--no-interaction", "--verbose", "--version", "--yes"} + projectInfoOpts := []string{ + "--help", "--no-interaction", "--project", "--refresh", "--verbose", "--version", "--yes", + } + allCommands := []string{"init", "list", "pinfo", "project:info", "project:init", "project:list", "projects"} + + cases := []struct { + name string + tokens []string + current int + want []string + toPHP bool + }{ + {name: "no command", tokens: []string{"upsun"}, current: 1, want: allCommands}, + {name: "after a global option", tokens: []string{"upsun", "-v"}, current: 2, want: allCommands}, + {name: "ambiguous command", tokens: []string{"upsun", "pro"}, current: 1, want: allCommands}, + {name: "unknown command", tokens: []string{"upsun", "foo"}, current: 1, want: allCommands}, + {name: "abbreviation", tokens: []string{"upsun", "p:inf"}, current: 1, want: []string{"project:info", "pinfo"}}, + {name: "prefix of a name", tokens: []string{"upsun", "project:inf"}, current: 1, want: []string{"project:info"}}, + {name: "alias", tokens: []string{"upsun", "pinfo"}, current: 1, want: []string{"pinfo"}}, + {name: "hidden alias", tokens: []string{"upsun", "project:metadata"}, current: 1, + want: []string{"project:info", "pinfo"}}, + {name: "global options", tokens: []string{"upsun", "--"}, current: 1, want: globals}, + {name: "legacy options", tokens: []string{"upsun", "project:info", "--"}, current: 2, want: projectInfoOpts}, + {name: "partial option", tokens: []string{"upsun", "pinfo", "--pro"}, current: 2, want: projectInfoOpts}, + {name: "single dash", tokens: []string{"upsun", "pinfo", "-"}, current: 2, want: projectInfoOpts}, + {name: "option before the command", tokens: []string{"upsun", "-", "pinfo"}, current: 1, + want: projectInfoOpts}, + {name: "native options", tokens: []string{"upsun", "init", "--"}, current: 2, + want: []string{"--ai", "--debug", "--help"}}, + {name: "native command replacing a legacy one", tokens: []string{"upsun", "list", "--"}, current: 2, + want: []string{"--help", "--no-interaction", "--raw", "--verbose", "--version", "--yes"}}, + {name: "native argument", tokens: []string{"upsun", "init"}, current: 2, want: nil}, + {name: "legacy argument", tokens: []string{"upsun", "project:info"}, current: 2, toPHP: true}, + {name: "option value", tokens: []string{"upsun", "pinfo", "--project"}, current: 3, toPHP: true}, + {name: "complete option", tokens: []string{"upsun", "pinfo", "--project"}, current: 2, toPHP: true}, + {name: "option with a value", tokens: []string{"upsun", "pinfo", "--project=a"}, current: 2, toPHP: true}, + {name: "short option", tokens: []string{"upsun", "pinfo", "-pa"}, current: 2, toPHP: true}, + {name: "argument of a command replacing a legacy one", tokens: []string{"upsun", "list"}, current: 2, + toPHP: true}, + {name: "options of an unknown command", tokens: []string{"upsun", "foo", "--"}, current: 2, toPHP: true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := completeInGo(root, cnf, legacyCmds, completeRequest{shell: "zsh", tokens: c.tokens, + current: c.current}) + if c.toPHP { + assert.False(t, ok) + return + } + require.True(t, ok) + var values []string + for _, s := range got { + values = append(values, s.value) + } + assert.Equal(t, c.want, values) + }) + } +} + +func TestWriteSuggestions(t *testing.T) { + suggestions := []suggestion{ + {value: "--columns", description: "Columns to display.\nValues may be split by commas."}, + {value: "--yes"}, + } + + cases := []struct { + shell string + suggestions []suggestion + want string + }{ + {shell: "bash", suggestions: suggestions, want: "--columns\n--yes\n"}, + {shell: "zsh", suggestions: suggestions, + want: "--columns\tColumns to display. Values may be split by commas.\n--yes\n"}, + {shell: "fish", suggestions: suggestions, + want: "--columns\tColumns to display. Values may be split by commas.\n--yes"}, + {shell: "bash", want: "\n"}, + {shell: "zsh", want: "\n"}, + {shell: "fish", want: ""}, + } + + for _, c := range cases { + t.Run(c.shell, func(t *testing.T) { + var b strings.Builder + require.NoError(t, writeSuggestions(&b, c.shell, c.suggestions)) + assert.Equal(t, c.want, b.String()) + }) + } +} diff --git a/commands/completion.go b/commands/completion.go index 61520969c..e80428098 100644 --- a/commands/completion.go +++ b/commands/completion.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/viper" "github.com/upsun/cli/internal/config" + "github.com/upsun/cli/internal/legacy" ) // completeCommandName is the hidden legacy (Symfony Console) command that the @@ -67,10 +68,11 @@ func newCompletionCommand(cnf *config.Config) *cobra.Command { } } -// newCompleteCommand proxies the hidden _complete command of the legacy CLI, -// which the completion scripts call to fetch suggestions. +// newCompleteCommand answers the hidden _complete command, which the +// completion scripts call to fetch suggestions. Command and option names are +// suggested in Go; argument and option values are left to the legacy CLI. // -// It only exists to keep Cobra from parsing those arguments. The scripts pass +// Cobra must not parse its arguments. The scripts pass // the shell as a glued short option (-szsh, -sbash, -sfish), which Cobra // splits into single-letter flags; as every supported shell name contains an // "h", that always produced a -h flag and the CLI printed help instead of @@ -84,6 +86,17 @@ func newCompleteCommand(cnf *config.Config) *cobra.Command { DisableFlagParsing: true, SilenceErrors: true, Run: func(cmd *cobra.Command, args []string) { + if r, ok := parseCompleteRequest(args); ok { + legacyCmds, err := enabledLegacyCommands(cnf, legacy.Commands)() + if err == nil { + if suggestions, ok := completeInGo(cmd.Root(), cnf, legacyCmds, r); ok { + if err := writeSuggestions(cmd.OutOrStdout(), r.shell, suggestions); err != nil { + exitSilently(err) + } + return + } + } + } err := holdStderr(cmd.ErrOrStderr(), func(stderr io.Writer) error { c := makeLegacyCLIWrapper(cnf, cmd.OutOrStdout(), stderr, cmd.InOrStdin()) return c.Exec(cmd.Context(), append([]string{completeCommandName}, args...)...) diff --git a/integration-tests/completion_test.go b/integration-tests/completion_test.go index 19a925a86..5b7fdbf26 100644 --- a/integration-tests/completion_test.go +++ b/integration-tests/completion_test.go @@ -1,9 +1,14 @@ package tests import ( + "os" + "os/exec" + "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestCompletionScript checks that the generated completion scripts request @@ -67,3 +72,78 @@ func TestComplete(t *testing.T) { }) } } + +// shellHarnesses print the suggestions of a completion script ($1) for a +// command line ($2), one per line, without an interactive shell. +var shellHarnesses = map[string]string{ + "bash": `source /usr/share/bash-completion/bash_completion +source "$1" +COMP_LINE=$2 +COMP_POINT=${#COMP_LINE} +read -ra COMP_WORDS <<< "$COMP_LINE" +[[ $COMP_LINE == *' ' ]] && COMP_WORDS+=('') +COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 )) +_sf_platform-test +printf '%s\n' "${COMPREPLY[@]}"`, + // The completion system is stubbed out, to print the values that the script passes to it. + "zsh": `compdef() {} +_describe() { print -rl -- "${(@)${(@P)2}%%:*}" } +source "$1" +words=(${(z)2}) +[[ $2 == *' ' ]] && words+=('') +CURRENT=${#words} +_sf_platform-test`, + "fish": `source $argv[1]; complete -C $argv[2]`, +} + +// TestShellCompletion runs the completion scripts in each shell, for +// suggestions answered in Go (command and option names) and by the legacy CLI +// (argument values). +func TestShellCompletion(t *testing.T) { + f := newCommandFactory(t, "", "") + + // The scripts run the CLI by the configured executable name. + binDir := t.TempDir() + require.NoError(t, os.Symlink(getCommandName(t), filepath.Join(binDir, "platform-test"))) + env := append(testEnv(t.TempDir()), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + cases := []struct { + line string + expected string + }{ + {line: "platform-test ini", expected: "init"}, + {line: "platform-test env:info --pro", expected: "--project"}, + {line: "platform-test completion ", expected: "zsh"}, + } + + for shell, harness := range shellHarnesses { + t.Run(shell, func(t *testing.T) { + if _, err := exec.LookPath(shell); err != nil { + t.Skipf("%s is not installed", shell) + } + if _, err := os.Stat("/usr/share/bash-completion/bash_completion"); shell == "bash" && err != nil { + t.Skip("bash-completion is not installed") + } + script := filepath.Join(t.TempDir(), "completion."+shell) + require.NoError(t, os.WriteFile(script, []byte(f.Run("completion", shell)), 0o600)) + + for _, c := range cases { + t.Run(c.line, func(t *testing.T) { + cmd := exec.Command(shell, "-c", harness, shell, script, c.line) + if shell == "fish" { + cmd = exec.Command(shell, "-c", harness, script, c.line) + } + cmd.Env = env + out, err := cmd.Output() + require.NoError(t, err) + var values []string + for line := range strings.Lines(string(out)) { + value, _, _ := strings.Cut(strings.TrimSuffix(line, "\n"), "\t") + values = append(values, value) + } + assert.Contains(t, values, c.expected) + }) + } + }) + } +} diff --git a/internal/legacy/commands.go b/internal/legacy/commands.go index 25512cff6..1e92c7f3a 100644 --- a/internal/legacy/commands.go +++ b/internal/legacy/commands.go @@ -16,8 +16,24 @@ type Command struct { Name string `json:"name"` Aliases []string `json:"aliases"` // HiddenAliases only work in full: they are not matched by abbreviations. - HiddenAliases []string `json:"hidden_aliases"` - Hidden bool `json:"hidden"` + HiddenAliases []string `json:"hidden_aliases"` + Hidden bool `json:"hidden"` + Description string `json:"description"` + Definition Definition `json:"definition"` +} + +// Definition describes a legacy command's input, without the application's global options. +type Definition struct { + Options map[string]Option `json:"options"` +} + +// Option describes an option of a legacy command. +type Option struct { + Name string `json:"name"` // With the "--" prefix. + Shortcut string `json:"shortcut"` // With the "-" prefix, e.g. "-p", or "-a|-b" for several. + AcceptValue bool `json:"accept_value"` + Description string `json:"description"` + Hidden bool `json:"hidden"` } // Commands returns every legacy command, regardless of whether it is enabled by config. From be5d88c193533ad0afcfb33f066c3bd111de116d Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 11:11:09 +0100 Subject: [PATCH 2/6] fix(legacy): index the legacy commands with each embedded config The command index was generated with the legacy CLI's default config, so config-dependent commands and options did not match the Upsun or Platform.sh CLIs: for example, resources:get was marked hidden and backup:restore lacked --no-resources in the Upsun CLI. Completion now relies on the index, so an index is generated for each embedded config (commands-upsun.json, commands-platformsh.json, commands-vendor.json) and embedded with the matching build tag. Experiments are no longer enabled when generating it, so that it lists what the default config offers. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 +-- CLAUDE.md | 2 +- Makefile | 38 ++++++++++++++++---------- internal/legacy/commands.go | 6 ---- internal/legacy/commands_platformsh.go | 10 +++++++ internal/legacy/commands_upsun.go | 10 +++++++ internal/legacy/commands_vendor.go | 10 +++++++ 7 files changed, 57 insertions(+), 23 deletions(-) create mode 100644 internal/legacy/commands_platformsh.go create mode 100644 internal/legacy/commands_upsun.go create mode 100644 internal/legacy/commands_vendor.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d69121b4d..c3a95449f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: # These are needed so that the linter does not complain mkdir -p internal/legacy/archives touch internal/legacy/archives/platform.phar - touch internal/legacy/archives/commands.json + touch internal/legacy/archives/commands-upsun.json internal/legacy/archives/commands-platformsh.json internal/legacy/archives/commands-vendor.json touch internal/legacy/archives/php_windows_amd64 touch internal/legacy/archives/php_linux_amd64 touch internal/legacy/archives/php_linux_arm64 @@ -167,7 +167,7 @@ jobs: curl -fSL https://curl.se/ca/cacert.pem -o internal/legacy/archives/cacert.pem # Only needed so that the package builds. touch internal/legacy/archives/platform.phar - touch internal/legacy/archives/commands.json + touch internal/legacy/archives/commands-upsun.json internal/legacy/archives/commands-platformsh.json - name: Run Windows tests shell: bash diff --git a/CLAUDE.md b/CLAUDE.md index d590a9e76..5b24208f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ The CLI operates as a wrapper around a legacy PHP CLI: - Go layer: Handles new commands (init, list, version, config:install, project:convert) and core infrastructure - PHP layer: Legacy commands are proxied through `internal/legacy/CLIWrapper` - The PHP CLI (platform.phar) is embedded at build time via go:embed -- An index of legacy commands (commands.json, from `list --all --format=json`) is embedded too, so the Go layer can resolve abbreviations like `p:init` in the same way as Symfony Console, and answer shell completion (`_complete`) for command and option names; PHP is only started for argument and option values +- An index of legacy commands (`commands-.json`, from `list --all --format=json` with the embedded config) is embedded too, so the Go layer can resolve abbreviations like `p:init` in the same way as Symfony Console, and answer shell completion (`_complete`) for command and option names; PHP is only started for argument and option values ### Key Components diff --git a/Makefile b/Makefile index f50d2f545..b3b16a746 100644 --- a/Makefile +++ b/Makefile @@ -32,16 +32,26 @@ internal/legacy/archives/platform.phar: legacy/vendor/autoload.php mkdir -p internal/legacy/archives cd legacy && php bin/platform self:build --no-interaction --output=../internal/legacy/archives/platform.phar -# Index the legacy CLI's commands, so the Go layer can resolve abbreviations of its own commands. -# Experiments are enabled so that every command is included. HOME is isolated from user config. -# The version is set, as by the Go wrapper, so that the CLI does not try to read it from Git. -internal/legacy/archives/commands.json: internal/legacy/archives/platform.phar +# Index the legacy CLI's commands for each embedded config, so the Go layer can resolve abbreviations of its own +# commands and complete command and option names. HOME is isolated from user config. The version is set, as by the Go +# wrapper, so that the CLI does not try to read it from Git. Usage: $(call legacy-index,phar,config-file) +define legacy-index tmp=$$(mktemp -d) && \ - HOME=$$tmp PLATFORMSH_CLI_EXPERIMENTAL_ALL_EXPERIMENTS=1 PLATFORMSH_CLI_NO_LEGACY_WARNING=1 \ - PLATFORMSH_CLI_APPLICATION_VERSION=0.0.0 \ - php $< list --all --format=json --no-interaction < /dev/null > $$tmp/commands.json && \ + prefix=$$(sed -n 's/^ env_prefix: *["'\'']*\([A-Z0-9_]*\).*/\1/p' $(2) | head -n 1) && \ + test -n "$$prefix" && \ + env HOME=$$tmp CLI_CONFIG_FILE=$(abspath $(2)) "$${prefix}NO_LEGACY_WARNING=1" "$${prefix}APPLICATION_VERSION=0.0.0" \ + php $(1) list --all --format=json --no-interaction < /dev/null > $$tmp/commands.json && \ mv $$tmp/commands.json $@; \ status=$$?; rm -rf $$tmp; exit $$status +endef + +LEGACY_INDEXES = internal/legacy/archives/commands-upsun.json internal/legacy/archives/commands-platformsh.json + +internal/legacy/archives/commands-%.json: internal/legacy/archives/platform.phar internal/config/%-cli.yaml + $(call legacy-index,$<,internal/config/$*-cli.yaml) + +internal/legacy/archives/commands-vendor.json: internal/legacy/archives/platform.phar internal/config/embedded-config.yaml + $(call legacy-index,$<,internal/config/embedded-config.yaml) legacy/vendor/autoload.php: cd legacy && composer install --no-interaction @@ -91,11 +101,11 @@ repogen: command -v repogen >/dev/null || go install github.com/ralt/repogen/cmd/repogen@$(REPOGEN_VERSION) .PHONY: single -single: goreleaser internal/legacy/archives/platform.phar internal/legacy/archives/commands.json php ## Build a single target release +single: goreleaser internal/legacy/archives/platform.phar $(LEGACY_INDEXES) php ## Build a single target release PHP_VERSION=$(PHP_VERSION) goreleaser build --single-target --id=$(GORELEASER_ID) --snapshot --clean .PHONY: snapshot -snapshot: goreleaser internal/legacy/archives/platform.phar internal/legacy/archives/commands.json php internal/legacy/archives/cacert.pem ## Build a snapshot release +snapshot: goreleaser internal/legacy/archives/platform.phar $(LEGACY_INDEXES) php internal/legacy/archives/cacert.pem ## Build a snapshot release ifndef RSA_SIGNING_KEY_FILE $(error RSA_SIGNING_KEY_FILE is not set. Set it to the path of your RSA private key for APK signing, or use 'make snapshot-no-nfpm' to skip packaging.) endif @@ -105,16 +115,16 @@ endif PHP_VERSION=$(PHP_VERSION) goreleaser release --snapshot --clean --skip=publish,announce .PHONY: snapshot-no-nfpm -snapshot-no-nfpm: goreleaser internal/legacy/archives/platform.phar internal/legacy/archives/commands.json php ## Build a snapshot release without package signing +snapshot-no-nfpm: goreleaser internal/legacy/archives/platform.phar $(LEGACY_INDEXES) php ## Build a snapshot release without package signing PHP_VERSION=$(PHP_VERSION) goreleaser release --snapshot --clean --skip=publish,announce,nfpm .PHONY: clean-phar clean-phar: ## Clean up the legacy CLI phar - rm -f internal/legacy/archives/platform.phar internal/legacy/archives/commands.json + rm -f internal/legacy/archives/platform.phar internal/legacy/archives/commands-*.json rm -rf legacy/vendor .PHONY: release -release: goreleaser clean-phar internal/legacy/archives/platform.phar internal/legacy/archives/commands.json php internal/legacy/archives/cacert.pem ## Create and publish a release +release: goreleaser clean-phar internal/legacy/archives/platform.phar $(LEGACY_INDEXES) php internal/legacy/archives/cacert.pem ## Create and publish a release ifndef RSA_SIGNING_KEY_FILE $(error RSA_SIGNING_KEY_FILE is not set. Set it to the path of your RSA private key for APK signing.) endif @@ -155,11 +165,11 @@ ifndef VENDOR_BINARY endif .PHONY: vendor-release -vendor-release: check-vendor .goreleaser.vendor.yaml goreleaser clean-phar internal/legacy/archives/platform.phar internal/legacy/archives/commands.json php ## Release a vendor CLI +vendor-release: check-vendor .goreleaser.vendor.yaml goreleaser clean-phar internal/legacy/archives/platform.phar internal/legacy/archives/commands-vendor.json php ## Release a vendor CLI PHP_VERSION=$(PHP_VERSION) VENDOR_BINARY="$(VENDOR_BINARY)" VENDOR_NAME="$(VENDOR_NAME)" goreleaser release --clean --config=.goreleaser.vendor.yaml .PHONY: vendor-snapshot -vendor-snapshot: check-vendor .goreleaser.vendor.yaml goreleaser internal/legacy/archives/platform.phar internal/legacy/archives/commands.json php ## Build a vendor CLI snapshot +vendor-snapshot: check-vendor .goreleaser.vendor.yaml goreleaser internal/legacy/archives/platform.phar internal/legacy/archives/commands-vendor.json php ## Build a vendor CLI snapshot PHP_VERSION=$(PHP_VERSION) VENDOR_BINARY="$(VENDOR_BINARY)" VENDOR_NAME="$(VENDOR_NAME)" goreleaser build --snapshot --clean --config=.goreleaser.vendor.yaml .PHONY: goreleaser-check diff --git a/internal/legacy/commands.go b/internal/legacy/commands.go index 1e92c7f3a..18e34dce6 100644 --- a/internal/legacy/commands.go +++ b/internal/legacy/commands.go @@ -1,16 +1,10 @@ package legacy import ( - _ "embed" "encoding/json" "sync" ) -// commandIndex is the legacy CLI's "list --all --format=json" output, generated at build time with all commands enabled. -// -//go:embed archives/commands.json -var commandIndex []byte - // Command describes a legacy CLI command. type Command struct { Name string `json:"name"` diff --git a/internal/legacy/commands_platformsh.go b/internal/legacy/commands_platformsh.go new file mode 100644 index 000000000..7940127ed --- /dev/null +++ b/internal/legacy/commands_platformsh.go @@ -0,0 +1,10 @@ +//go:build platformsh && !vendor + +package legacy + +import _ "embed" + +// commandIndex is the legacy CLI's "list --all --format=json" output, generated at build time with the embedded config. +// +//go:embed archives/commands-platformsh.json +var commandIndex []byte diff --git a/internal/legacy/commands_upsun.go b/internal/legacy/commands_upsun.go new file mode 100644 index 000000000..6c258ab70 --- /dev/null +++ b/internal/legacy/commands_upsun.go @@ -0,0 +1,10 @@ +//go:build !platformsh && !vendor + +package legacy + +import _ "embed" + +// commandIndex is the legacy CLI's "list --all --format=json" output, generated at build time with the embedded config. +// +//go:embed archives/commands-upsun.json +var commandIndex []byte diff --git a/internal/legacy/commands_vendor.go b/internal/legacy/commands_vendor.go new file mode 100644 index 000000000..d488f5cb5 --- /dev/null +++ b/internal/legacy/commands_vendor.go @@ -0,0 +1,10 @@ +//go:build vendor + +package legacy + +import _ "embed" + +// commandIndex is the legacy CLI's "list --all --format=json" output, generated at build time with the embedded config. +// +//go:embed archives/commands-vendor.json +var commandIndex []byte From 84939c304baa93343371c2ac4f5fa42d65f03fc1 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 11:15:34 +0100 Subject: [PATCH 3/6] fix(completion): suggest --quiet like the legacy CLI Co-Authored-By: Claude Opus 5.5 --- commands/complete.go | 4 +++- commands/complete_test.go | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/commands/complete.go b/commands/complete.go index 2cb38b03a..70648f265 100644 --- a/commands/complete.go +++ b/commands/complete.go @@ -240,7 +240,9 @@ func legacyGlobalOptions(cnf *config.Config) []completionOption { globals := globalOptions(cnf) opts := make([]completionOption, 0, len(globals)) for _, o := range globals { - opts = append(opts, newCompletionOption(o.Name, o.Shortcut, string(o.Description), o.Hidden)) + // The list hides --quiet, unlike the legacy CLI's input definition. + hidden := o.Hidden && o.Name != QuietOption.Name + opts = append(opts, newCompletionOption(o.Name, o.Shortcut, string(o.Description), hidden)) } return opts } diff --git a/commands/complete_test.go b/commands/complete_test.go index 65a8ae942..0cd251bd6 100644 --- a/commands/complete_test.go +++ b/commands/complete_test.go @@ -93,9 +93,9 @@ func TestCompleteInGo(t *testing.T) { {Name: "welcome", Hidden: true}, } - globals := []string{"--help", "--no-interaction", "--verbose", "--version", "--yes"} + globals := []string{"--help", "--no-interaction", "--quiet", "--verbose", "--version", "--yes"} projectInfoOpts := []string{ - "--help", "--no-interaction", "--project", "--refresh", "--verbose", "--version", "--yes", + "--help", "--no-interaction", "--project", "--quiet", "--refresh", "--verbose", "--version", "--yes", } allCommands := []string{"init", "list", "pinfo", "project:info", "project:init", "project:list", "projects"} @@ -124,7 +124,7 @@ func TestCompleteInGo(t *testing.T) { {name: "native options", tokens: []string{"upsun", "init", "--"}, current: 2, want: []string{"--ai", "--debug", "--help"}}, {name: "native command replacing a legacy one", tokens: []string{"upsun", "list", "--"}, current: 2, - want: []string{"--help", "--no-interaction", "--raw", "--verbose", "--version", "--yes"}}, + want: []string{"--help", "--no-interaction", "--quiet", "--raw", "--verbose", "--version", "--yes"}}, {name: "native argument", tokens: []string{"upsun", "init"}, current: 2, want: nil}, {name: "legacy argument", tokens: []string{"upsun", "project:info"}, current: 2, toPHP: true}, {name: "option value", tokens: []string{"upsun", "pinfo", "--project"}, current: 3, toPHP: true}, From 0033aa1dd69cf0e5bcdfe27a2a14edf9b9f3fa44 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 11:29:48 +0100 Subject: [PATCH 4/6] fix(completion): leave names to PHP when user config may change them The embedded index reflects the default config. A user config file, or an API_, APPLICATION_ or EXPERIMENTAL_ environment override, can hide commands, enable experiments or change options, so command and option names are then completed by the legacy CLI. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- commands/complete.go | 27 +++++++++++++++++++++++++-- commands/complete_test.go | 34 ++++++++++++++++++++++++++++++++++ commands/completion.go | 5 +++-- 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5b24208f1..4e243d42e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ The CLI operates as a wrapper around a legacy PHP CLI: - Go layer: Handles new commands (init, list, version, config:install, project:convert) and core infrastructure - PHP layer: Legacy commands are proxied through `internal/legacy/CLIWrapper` - The PHP CLI (platform.phar) is embedded at build time via go:embed -- An index of legacy commands (`commands-.json`, from `list --all --format=json` with the embedded config) is embedded too, so the Go layer can resolve abbreviations like `p:init` in the same way as Symfony Console, and answer shell completion (`_complete`) for command and option names; PHP is only started for argument and option values +- An index of legacy commands (`commands-.json`, from `list --all --format=json` with the embedded config) is embedded too, so the Go layer can resolve abbreviations like `p:init` in the same way as Symfony Console, and answer shell completion (`_complete`) for command and option names; PHP is only started for argument and option values, or when a user config file or environment variable may change the commands ### Key Components diff --git a/commands/complete.go b/commands/complete.go index 70648f265..2c29525b4 100644 --- a/commands/complete.go +++ b/commands/complete.go @@ -2,7 +2,11 @@ package commands import ( "cmp" + "errors" "io" + "io/fs" + "os" + "path/filepath" "slices" "strconv" "strings" @@ -94,6 +98,25 @@ func cutCompleteOption(arg string) (value, name string, ok bool) { return "", "", false } +// legacyConfigOverridden tests if the user's config file or environment may change the legacy CLI's commands and +// options (e.g. hidden commands or experiments), so that the embedded index does not apply. +func legacyConfigOverridden(cnf *config.Config) bool { + for _, e := range os.Environ() { + name, _, _ := strings.Cut(e, "=") + for _, section := range []string{"API_", "APPLICATION_", "EXPERIMENTAL_"} { + if strings.HasPrefix(name, cnf.Application.EnvPrefix+section) { + return true + } + } + } + home, err := cnf.HomeDir() + if err != nil { + return true + } + _, err = os.Stat(filepath.Join(home, cnf.Application.UserConfigDir, "config.yaml")) + return !errors.Is(err, fs.ErrNotExist) +} + // completeInGo answers a completion request for command or option names, mimicking Symfony's CompleteCommand. // It returns false for argument and option values, which are left to the legacy CLI. func completeInGo( @@ -263,9 +286,9 @@ func newCompletionOption(name, shortcut, description string, hidden bool) comple } } -func flagOptions(fs *pflag.FlagSet) []completionOption { +func flagOptions(flags *pflag.FlagSet) []completionOption { var opts []completionOption - fs.VisitAll(func(f *pflag.Flag) { + flags.VisitAll(func(f *pflag.Flag) { o := completionOption{name: f.Name, description: f.Usage, hidden: f.Hidden} if f.Shorthand != "" { o.shorthands = []string{f.Shorthand} diff --git a/commands/complete_test.go b/commands/complete_test.go index 0cd251bd6..c597f7877 100644 --- a/commands/complete_test.go +++ b/commands/complete_test.go @@ -1,6 +1,8 @@ package commands import ( + "os" + "path/filepath" "strings" "testing" @@ -183,3 +185,35 @@ func TestWriteSuggestions(t *testing.T) { }) } } + +func TestLegacyConfigOverridden(t *testing.T) { + cases := []struct { + name string + env map[string]string + userConfig bool + want bool + }{ + {name: "default"}, + {name: "unrelated variable", env: map[string]string{"TEST_TOKEN": "abc"}}, + {name: "experiment", env: map[string]string{"TEST_EXPERIMENTAL_ALL_EXPERIMENTS": "1"}, want: true}, + {name: "API setting", env: map[string]string{"TEST_API_SIZING": "0"}, want: true}, + {name: "user config file", userConfig: true, want: true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cnf := testConfig() + home := t.TempDir() + t.Setenv("TEST_HOME", home) + for k, v := range c.env { + t.Setenv(k, v) + } + if c.userConfig { + dir := filepath.Join(home, cnf.Application.UserConfigDir) + require.NoError(t, os.MkdirAll(dir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("{}"), 0o600)) + } + assert.Equal(t, c.want, legacyConfigOverridden(cnf)) + }) + } +} diff --git a/commands/completion.go b/commands/completion.go index e80428098..f1c4e5d18 100644 --- a/commands/completion.go +++ b/commands/completion.go @@ -70,7 +70,8 @@ func newCompletionCommand(cnf *config.Config) *cobra.Command { // newCompleteCommand answers the hidden _complete command, which the // completion scripts call to fetch suggestions. Command and option names are -// suggested in Go; argument and option values are left to the legacy CLI. +// suggested in Go, unless user config may change them; argument and option +// values are left to the legacy CLI. // // Cobra must not parse its arguments. The scripts pass // the shell as a glued short option (-szsh, -sbash, -sfish), which Cobra @@ -86,7 +87,7 @@ func newCompleteCommand(cnf *config.Config) *cobra.Command { DisableFlagParsing: true, SilenceErrors: true, Run: func(cmd *cobra.Command, args []string) { - if r, ok := parseCompleteRequest(args); ok { + if r, ok := parseCompleteRequest(args); ok && !legacyConfigOverridden(cnf) { legacyCmds, err := enabledLegacyCommands(cnf, legacy.Commands)() if err == nil { if suggestions, ok := completeInGo(cmd.Root(), cnf, legacyCmds, r); ok { From bf54757077bb343cdc786c7b75d0096085206912 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 11:34:49 +0100 Subject: [PATCH 5/6] fix(completion): leave names to PHP with an external config An external config (CLI_CONFIG_FILE, as used by alternative CLIs from config:install) can change the legacy commands and options, so the embedded index only applies to the embedded config. The shell integration test now runs the CLI with its embedded config. Vendor builds also generate the default indexes, which the completion script hook needs. Co-Authored-By: Claude Opus 5.5 --- Makefile | 4 +-- commands/completion.go | 5 ++-- integration-tests/completion_test.go | 45 ++++++++++++++++------------ internal/config/config.go | 6 ++++ internal/config/config_test.go | 13 ++++++++ 5 files changed, 50 insertions(+), 23 deletions(-) diff --git a/Makefile b/Makefile index b3b16a746..44595e2b1 100644 --- a/Makefile +++ b/Makefile @@ -165,11 +165,11 @@ ifndef VENDOR_BINARY endif .PHONY: vendor-release -vendor-release: check-vendor .goreleaser.vendor.yaml goreleaser clean-phar internal/legacy/archives/platform.phar internal/legacy/archives/commands-vendor.json php ## Release a vendor CLI +vendor-release: check-vendor .goreleaser.vendor.yaml goreleaser clean-phar internal/legacy/archives/platform.phar $(LEGACY_INDEXES) internal/legacy/archives/commands-vendor.json php ## Release a vendor CLI PHP_VERSION=$(PHP_VERSION) VENDOR_BINARY="$(VENDOR_BINARY)" VENDOR_NAME="$(VENDOR_NAME)" goreleaser release --clean --config=.goreleaser.vendor.yaml .PHONY: vendor-snapshot -vendor-snapshot: check-vendor .goreleaser.vendor.yaml goreleaser internal/legacy/archives/platform.phar internal/legacy/archives/commands-vendor.json php ## Build a vendor CLI snapshot +vendor-snapshot: check-vendor .goreleaser.vendor.yaml goreleaser internal/legacy/archives/platform.phar $(LEGACY_INDEXES) internal/legacy/archives/commands-vendor.json php ## Build a vendor CLI snapshot PHP_VERSION=$(PHP_VERSION) VENDOR_BINARY="$(VENDOR_BINARY)" VENDOR_NAME="$(VENDOR_NAME)" goreleaser build --snapshot --clean --config=.goreleaser.vendor.yaml .PHONY: goreleaser-check diff --git a/commands/completion.go b/commands/completion.go index f1c4e5d18..6fc989507 100644 --- a/commands/completion.go +++ b/commands/completion.go @@ -70,7 +70,8 @@ func newCompletionCommand(cnf *config.Config) *cobra.Command { // newCompleteCommand answers the hidden _complete command, which the // completion scripts call to fetch suggestions. Command and option names are -// suggested in Go, unless user config may change them; argument and option +// suggested in Go, unless the config differs from the one indexed at build +// time (see legacyConfigOverridden); argument and option // values are left to the legacy CLI. // // Cobra must not parse its arguments. The scripts pass @@ -87,7 +88,7 @@ func newCompleteCommand(cnf *config.Config) *cobra.Command { DisableFlagParsing: true, SilenceErrors: true, Run: func(cmd *cobra.Command, args []string) { - if r, ok := parseCompleteRequest(args); ok && !legacyConfigOverridden(cnf) { + if r, ok := parseCompleteRequest(args); ok && cnf.IsEmbedded() && !legacyConfigOverridden(cnf) { legacyCmds, err := enabledLegacyCommands(cnf, legacy.Commands)() if err == nil { if suggestions, ok := completeInGo(cmd.Root(), cnf, legacyCmds, r); ok { diff --git a/integration-tests/completion_test.go b/integration-tests/completion_test.go index 5b7fdbf26..f63cb8cd0 100644 --- a/integration-tests/completion_test.go +++ b/integration-tests/completion_test.go @@ -74,7 +74,8 @@ func TestComplete(t *testing.T) { } // shellHarnesses print the suggestions of a completion script ($1) for a -// command line ($2), one per line, without an interactive shell. +// command line ($2) of an executable ($3), one per line, without an +// interactive shell. var shellHarnesses = map[string]string{ "bash": `source /usr/share/bash-completion/bash_completion source "$1" @@ -83,7 +84,7 @@ COMP_POINT=${#COMP_LINE} read -ra COMP_WORDS <<< "$COMP_LINE" [[ $COMP_LINE == *' ' ]] && COMP_WORDS+=('') COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 )) -_sf_platform-test +"_sf_$3" printf '%s\n' "${COMPREPLY[@]}"`, // The completion system is stubbed out, to print the values that the script passes to it. "zsh": `compdef() {} @@ -92,28 +93,29 @@ source "$1" words=(${(z)2}) [[ $2 == *' ' ]] && words+=('') CURRENT=${#words} -_sf_platform-test`, +"_sf_$3"`, "fish": `source $argv[1]; complete -C $argv[2]`, } // TestShellCompletion runs the completion scripts in each shell, for // suggestions answered in Go (command and option names) and by the legacy CLI // (argument values). +// +// It uses the embedded config, as completion in Go relies on the command index +// generated for it. func TestShellCompletion(t *testing.T) { - f := newCommandFactory(t, "", "") - - // The scripts run the CLI by the configured executable name. - binDir := t.TempDir() - require.NoError(t, os.Symlink(getCommandName(t), filepath.Join(binDir, "platform-test"))) - env := append(testEnv(t.TempDir()), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + // The binary is named after its configured executable, which the scripts run. + exe := getCommandName(t) + name := filepath.Base(exe) + env := []string{"HOME=" + t.TempDir(), "PATH=" + filepath.Dir(exe) + string(os.PathListSeparator) + os.Getenv("PATH")} cases := []struct { - line string + args string expected string }{ - {line: "platform-test ini", expected: "init"}, - {line: "platform-test env:info --pro", expected: "--project"}, - {line: "platform-test completion ", expected: "zsh"}, + {args: "ini", expected: "init"}, + {args: "env:info --pro", expected: "--project"}, + {args: "completion ", expected: "zsh"}, } for shell, harness := range shellHarnesses { @@ -124,21 +126,26 @@ func TestShellCompletion(t *testing.T) { if _, err := os.Stat("/usr/share/bash-completion/bash_completion"); shell == "bash" && err != nil { t.Skip("bash-completion is not installed") } + gen := exec.Command(exe, "completion", shell) + gen.Env = env + b, err := gen.Output() + require.NoError(t, err) script := filepath.Join(t.TempDir(), "completion."+shell) - require.NoError(t, os.WriteFile(script, []byte(f.Run("completion", shell)), 0o600)) + require.NoError(t, os.WriteFile(script, b, 0o600)) for _, c := range cases { - t.Run(c.line, func(t *testing.T) { - cmd := exec.Command(shell, "-c", harness, shell, script, c.line) + t.Run(c.args, func(t *testing.T) { + line := name + " " + c.args + cmd := exec.Command(shell, "-c", harness, shell, script, line, name) if shell == "fish" { - cmd = exec.Command(shell, "-c", harness, script, c.line) + cmd = exec.Command(shell, "-c", harness, script, line) } cmd.Env = env out, err := cmd.Output() require.NoError(t, err) var values []string - for line := range strings.Lines(string(out)) { - value, _, _ := strings.Cut(strings.TrimSuffix(line, "\n"), "\t") + for l := range strings.Lines(string(out)) { + value, _, _ := strings.Cut(strings.TrimSuffix(l, "\n"), "\t") values = append(values, value) } assert.Contains(t, values, c.expected) diff --git a/internal/config/config.go b/internal/config/config.go index 7e32d0c4d..2f22c3f5d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "context" "fmt" "os" @@ -21,6 +22,11 @@ func LoadYAML() ([]byte, error) { return embedded, nil } +// IsEmbedded tests if the config was loaded from the embedded YAML, rather than from CLI_CONFIG_FILE. +func (c *Config) IsEmbedded() bool { + return bytes.Equal(c.raw, embedded) +} + // FromYAML parses YAML configuration. func FromYAML(b []byte) (*Config, error) { c := &Config{} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3607a0668..d7d6aae14 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -55,3 +55,16 @@ func TestFromYAML(t *testing.T) { assert.Equal(t, filepath.Join(tempDir, "tmp", cnf.Application.TempSubDir), d) }) } + +func TestIsEmbedded(t *testing.T) { + t.Setenv("CLI_CONFIG_FILE", "") + b, err := config.LoadYAML() + require.NoError(t, err) + cnf, err := config.FromYAML(b) + require.NoError(t, err) + assert.True(t, cnf.IsEmbedded()) + + cnf, err = config.FromYAML([]byte(validConfig)) + require.NoError(t, err) + assert.False(t, cnf.IsEmbedded()) +} From 198737f3f8b0cbf9ddf7499b2ebbc1c9709910ce Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 12:42:39 +0100 Subject: [PATCH 6/6] build: read the env prefix of the config with a Go helper The legacy index recipe took the first "env_prefix:" line of the config, which depends on the layout of the vendor config downloaded at build time. It now parses the config like the CLI does. Co-Authored-By: Claude Opus 5.5 --- Makefile | 3 +-- scripts/env-prefix/main.go | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 scripts/env-prefix/main.go diff --git a/Makefile b/Makefile index 44595e2b1..2677deb58 100644 --- a/Makefile +++ b/Makefile @@ -37,8 +37,7 @@ internal/legacy/archives/platform.phar: legacy/vendor/autoload.php # wrapper, so that the CLI does not try to read it from Git. Usage: $(call legacy-index,phar,config-file) define legacy-index tmp=$$(mktemp -d) && \ - prefix=$$(sed -n 's/^ env_prefix: *["'\'']*\([A-Z0-9_]*\).*/\1/p' $(2) | head -n 1) && \ - test -n "$$prefix" && \ + prefix=$$(go run ./scripts/env-prefix $(2)) && \ env HOME=$$tmp CLI_CONFIG_FILE=$(abspath $(2)) "$${prefix}NO_LEGACY_WARNING=1" "$${prefix}APPLICATION_VERSION=0.0.0" \ php $(1) list --all --format=json --no-interaction < /dev/null > $$tmp/commands.json && \ mv $$tmp/commands.json $@; \ diff --git a/scripts/env-prefix/main.go b/scripts/env-prefix/main.go new file mode 100644 index 000000000..259099b12 --- /dev/null +++ b/scripts/env-prefix/main.go @@ -0,0 +1,27 @@ +// Command env-prefix prints the application's environment variable prefix from a CLI config file. +package main + +import ( + "fmt" + "os" + + "github.com/upsun/cli/internal/config" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "Usage: env-prefix ") + os.Exit(2) + } + b, err := os.ReadFile(os.Args[1]) //nolint:gosec // The path is the build's own config file. + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + cnf, err := config.FromYAML(b) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println(cnf.Application.EnvPrefix) +}