diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a62e6154..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 @@ -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 @@ -164,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 877ee04f0..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`) 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` 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/Makefile b/Makefile index f50d2f545..2677deb58 100644 --- a/Makefile +++ b/Makefile @@ -32,16 +32,25 @@ 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=$$(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 $@; \ 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 +100,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 +114,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 +164,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 $(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.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/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..2c29525b4 --- /dev/null +++ b/commands/complete.go @@ -0,0 +1,342 @@ +package commands + +import ( + "cmp" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "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 +} + +// 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( + 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 { + // 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 +} + +// 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(flags *pflag.FlagSet) []completionOption { + var opts []completionOption + 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} + } + 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..c597f7877 --- /dev/null +++ b/commands/complete_test.go @@ -0,0 +1,219 @@ +package commands + +import ( + "os" + "path/filepath" + "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", "--quiet", "--verbose", "--version", "--yes"} + projectInfoOpts := []string{ + "--help", "--no-interaction", "--project", "--quiet", "--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", "--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}, + {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()) + }) + } +} + +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 61520969c..6fc989507 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,13 @@ 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, unless the config differs from the one indexed at build +// time (see legacyConfigOverridden); 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 +88,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 && cnf.IsEmbedded() && !legacyConfigOverridden(cnf) { + 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..f63cb8cd0 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,85 @@ func TestComplete(t *testing.T) { }) } } + +// shellHarnesses print the suggestions of a completion script ($1) for a +// 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" +COMP_LINE=$2 +COMP_POINT=${#COMP_LINE} +read -ra COMP_WORDS <<< "$COMP_LINE" +[[ $COMP_LINE == *' ' ]] && COMP_WORDS+=('') +COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 )) +"_sf_$3" +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_$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) { + // 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 { + args string + expected string + }{ + {args: "ini", expected: "init"}, + {args: "env:info --pro", expected: "--project"}, + {args: "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") + } + 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, b, 0o600)) + + for _, c := range cases { + 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, line) + } + cmd.Env = env + out, err := cmd.Output() + require.NoError(t, err) + var values []string + 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()) +} diff --git a/internal/legacy/commands.go b/internal/legacy/commands.go index 25512cff6..18e34dce6 100644 --- a/internal/legacy/commands.go +++ b/internal/legacy/commands.go @@ -1,23 +1,33 @@ 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"` 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. 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 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) +}