From 7f456ce1e52a940a096db5d514b570936b3e07f3 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 24 Sep 2026 22:26:51 +0100 Subject: [PATCH 1/5] fix: resolve abbreviations of native commands Abbreviations like "p:init" or "a:config-v" failed because the Go layer only matched exact names, and the legacy CLI does not know about the native commands. The build now embeds the legacy CLI's "list --all --format=json" output, generated from the phar with all experiments enabled, so it includes every command regardless of config. The Go layer resolves abbreviations with Symfony Console's rules across native and legacy commands, and expands only a unique match to a native command. Everything else is passed to the legacy CLI as before. The index is only parsed when a name abbreviates a native command. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 + CLAUDE.md | 1 + Makefile | 23 +++-- commands/abbreviation.go | 116 +++++++++++++++++++++++++ commands/abbreviation_test.go | 69 +++++++++++++++ commands/help.go | 6 ++ commands/root.go | 8 +- integration-tests/abbreviation_test.go | 31 +++++++ internal/legacy/commands.go | 34 ++++++++ 9 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 commands/abbreviation.go create mode 100644 commands/abbreviation_test.go create mode 100644 integration-tests/abbreviation_test.go create mode 100644 internal/legacy/commands.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1d4daf5a..2ab12852e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +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/php_windows_amd64 touch internal/legacy/archives/php_linux_amd64 touch internal/legacy/archives/php_linux_arm64 @@ -163,6 +164,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 - name: Run Windows tests shell: bash diff --git a/CLAUDE.md b/CLAUDE.md index 8ba3afb20..213f1851d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +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 ### Key Components diff --git a/Makefile b/Makefile index 599095077..dafbee881 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,15 @@ 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. +internal/legacy/archives/commands.json: internal/legacy/archives/platform.phar + tmp=$$(mktemp -d) && \ + HOME=$$tmp PLATFORMSH_CLI_EXPERIMENTAL_ALL_EXPERIMENTS=1 PLATFORMSH_CLI_NO_LEGACY_WARNING=1 \ + php $< list --all --format=json > $$tmp/commands.json && \ + mv $$tmp/commands.json $@; \ + status=$$?; rm -rf $$tmp; exit $$status + legacy/vendor/autoload.php: cd legacy && composer install --no-interaction @@ -80,11 +89,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 php ## Build a single target release +single: goreleaser internal/legacy/archives/platform.phar internal/legacy/archives/commands.json 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 php internal/legacy/archives/cacert.pem ## Build a snapshot release +snapshot: goreleaser internal/legacy/archives/platform.phar internal/legacy/archives/commands.json 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 @@ -94,16 +103,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 php ## Build a snapshot release without package signing +snapshot-no-nfpm: goreleaser internal/legacy/archives/platform.phar internal/legacy/archives/commands.json 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 + 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 php internal/legacy/archives/cacert.pem ## Create and publish a 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 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 @@ -145,11 +154,11 @@ ifndef VENDOR_BINARY endif .PHONY: vendor-release -vendor-release: check-vendor .goreleaser.vendor.yaml goreleaser clean-phar internal/legacy/archives/platform.phar php ## Release a vendor CLI +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 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 php ## Build a vendor CLI 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 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 new file mode 100644 index 000000000..01b39f2af --- /dev/null +++ b/commands/abbreviation.go @@ -0,0 +1,116 @@ +package commands + +import ( + "regexp" + "slices" + "strings" + + "github.com/spf13/cobra" + + "github.com/upsun/cli/internal/legacy" +) + +// abbrevCandidate is a command that an abbreviation may resolve to. +type abbrevCandidate struct { + names []string // The command name, followed by its aliases. + hidden bool + native bool +} + +// expandAbbreviation replaces an abbreviated native command name in args (e.g. "p:init") with the full name. +// +// Abbreviations are resolved the same way as the legacy CLI (Symfony Console's Application::find()), across both +// native and legacy commands. Only a unique match to a native command is expanded, as anything else is handled by the +// legacy CLI. The legacy commands are only loaded if needed. +func expandAbbreviation( + root *cobra.Command, + loadLegacyCmds func() ([]legacy.Command, error), + args []string, +) (expanded []string, ok bool, err error) { + pos := slices.IndexFunc(args, func(a string) bool { return !strings.HasPrefix(a, "-") }) + if pos == -1 || slices.Contains(args[:pos], "--") { + return nil, false, nil + } + name := args[pos] + + var candidates []abbrevCandidate + nativeNames := map[string]bool{} + for _, c := range root.Commands() { + names := append([]string{c.Name()}, c.Aliases...) + for _, n := range names { + nativeNames[n] = true + } + candidates = append(candidates, abbrevCandidate{names: names, hidden: c.Hidden, native: true}) + } + if nativeNames[name] || resolveAbbreviation(name, candidates) == nil { + return nil, false, nil + } + + legacyCmds, err := loadLegacyCmds() + if err != nil { + return nil, false, err + } + for _, c := range legacyCmds { + if nativeNames[c.Name] { + // Overridden by a native command. + continue + } + names := append([]string{c.Name}, c.Aliases...) + if slices.Contains(names, name) { + return nil, false, nil + } + candidates = append(candidates, abbrevCandidate{names: names, hidden: c.Hidden}) + } + + target := resolveAbbreviation(name, candidates) + if target == nil || !target.native { + return nil, false, nil + } + expanded = slices.Clone(args) + expanded[pos] = target.names[0] + return expanded, true, nil +} + +// resolveAbbreviation follows Symfony Console's rules to find the command abbreviated by name, if it is unique. +func resolveAbbreviation(name string, candidates []abbrevCandidate) *abbrevCandidate { + parts := strings.Split(name, ":") + for i, p := range parts { + parts[i] = regexp.QuoteMeta(p) + } + expr := "^" + strings.Join(parts, "[^:]*:") + "[^:]*" + + matchAll := func(re *regexp.Regexp) (matched []int, fullMatch bool) { + for i, c := range candidates { + isMatch := false + for _, n := range c.names { + if loc := re.FindStringIndex(n); loc != nil { + isMatch = true + fullMatch = fullMatch || loc[1] == len(n) + } + } + if isMatch { + matched = append(matched, i) + } + } + return matched, fullMatch + } + + // Try a case-sensitive match first, then case-insensitive. + matched, fullMatch := matchAll(regexp.MustCompile(expr)) + if len(matched) == 0 { + matched, fullMatch = matchAll(regexp.MustCompile("(?i)" + expr)) + } + // 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 + } + + if len(matched) > 1 { + matched = slices.DeleteFunc(matched, func(i int) bool { return candidates[i].hidden }) + } + if len(matched) != 1 || candidates[matched[0]].hidden { + return nil + } + return &candidates[matched[0]] +} diff --git a/commands/abbreviation_test.go b/commands/abbreviation_test.go new file mode 100644 index 000000000..030bdb20b --- /dev/null +++ b/commands/abbreviation_test.go @@ -0,0 +1,69 @@ +package commands + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/internal/legacy" +) + +func TestExpandAbbreviation(t *testing.T) { + root := &cobra.Command{Use: "upsun"} + root.AddCommand( + &cobra.Command{Use: "init", Aliases: []string{"project:init", "ify"}}, + &cobra.Command{Use: "project:convert", Aliases: []string{"convert"}}, + &cobra.Command{Use: "app:config-validate", Aliases: []string{"validate", "lint"}}, + &cobra.Command{Use: "list"}, + &cobra.Command{Use: "version"}, + &cobra.Command{Use: "_complete", Hidden: true}, + ) + legacyCmds := []legacy.Command{ + {Name: "list"}, + {Name: "project:info", Aliases: []string{"pinfo"}}, + {Name: "project:create", Aliases: []string{"create"}}, + {Name: "project:curl", Hidden: true}, + {Name: "app:list", Aliases: []string{"apps"}}, + {Name: "app:config-get"}, + {Name: "integration:list"}, + {Name: "version:list", Aliases: []string{"versions"}, Hidden: true}, + } + + cases := []struct { + name string + args []string + want []string + }{ + {"namespace abbreviation", []string{"p:init"}, []string{"init"}}, + {"both parts abbreviated", []string{"pro:ini", "--yes"}, []string{"init", "--yes"}}, + {"case-insensitive fallback", []string{"P:Init"}, []string{"init"}}, + {"after flags", []string{"-v", "p:conv"}, []string{"-v", "project:convert"}}, + {"multi-word part", []string{"a:config-v"}, []string{"app:config-validate"}}, + {"unique abbreviation", []string{"p:con"}, []string{"project:convert"}}, + {"hidden legacy namespace ignored", []string{"ver"}, []string{"version"}}, + {"ambiguous with a legacy command", []string{"p:c"}, nil}, + {"ambiguous between legacy commands", []string{"a:c"}, nil}, + {"prefix of a longer legacy name", []string{"in"}, nil}, + {"legacy command", []string{"p:info"}, nil}, + {"exact native command", []string{"init"}, nil}, + {"exact legacy command", []string{"pinfo"}, nil}, + {"unknown command", []string{"p:nope"}, nil}, + {"hidden native command", []string{"_comp"}, nil}, + {"no command", []string{"--version"}, nil}, + {"after double dash", []string{"--", "p:init"}, nil}, + {"empty", nil, nil}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok, err := expandAbbreviation(root, func() ([]legacy.Command, error) { return legacyCmds, nil }, c.args) + assert.NoError(t, err) + if c.want == nil { + assert.False(t, ok) + return + } + assert.True(t, ok) + assert.Equal(t, c.want, got) + }) + } +} diff --git a/commands/help.go b/commands/help.go index 45964c21e..04fe690fa 100644 --- a/commands/help.go +++ b/commands/help.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/upsun/cli/internal/config" + "github.com/upsun/cli/internal/legacy" ) func newHelpCommand(_ *config.Config) *cobra.Command { @@ -12,6 +13,11 @@ func newHelpCommand(_ *config.Config) *cobra.Command { // Disable flag parsing so flags like --format are preserved for the legacy CLI. DisableFlagParsing: true, Run: func(cmd *cobra.Command, args []string) { + if expanded, ok, err := expandAbbreviation(cmd.Root(), legacy.Commands, args); err != nil { + debugLogf("Failed to load the legacy command index: %s", err) + } else if ok { + args = expanded + } foundCmd, _, e := cmd.Root().Find(args) if foundCmd == nil || e != nil || foundCmd == cmd.Root() { // Unknown command or root: delegate to root's HelpFunc for legacy CLI. diff --git a/commands/root.go b/commands/root.go index 2c7b060b4..c992e68e8 100644 --- a/commands/root.go +++ b/commands/root.go @@ -36,7 +36,13 @@ func Execute(cnf *config.Config) error { } ctx := vendorization.WithVendorAssets(config.ToContext(context.Background(), cnf), assets) - return newRootCommand(cnf, assets).ExecuteContext(ctx) + cmd := newRootCommand(cnf, assets) + if args, ok, err := expandAbbreviation(cmd, legacy.Commands, os.Args[1:]); err != nil { + debugLogf("Failed to load the legacy command index: %s", err) + } else if ok { + cmd.SetArgs(args) + } + return cmd.ExecuteContext(ctx) } func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cobra.Command { diff --git a/integration-tests/abbreviation_test.go b/integration-tests/abbreviation_test.go new file mode 100644 index 000000000..926c89f06 --- /dev/null +++ b/integration-tests/abbreviation_test.go @@ -0,0 +1,31 @@ +package tests + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestAbbreviation checks that native commands can be abbreviated like legacy ones. +func TestAbbreviation(t *testing.T) { + f := newCommandFactory(t, "", "") + + cases := []struct { + args []string + expected string + }{ + {[]string{"p:init", "--help"}, "Command: project:init"}, + {[]string{"help", "p:init"}, "Command: project:init"}, + {[]string{"a:config-v", "--help"}, "Command: app:config-validate"}, + {[]string{"env:info", "--help"}, "Command: environment:info"}, + } + for _, c := range cases { + t.Run(c.args[0], func(t *testing.T) { + assert.Contains(t, f.Run(c.args...), c.expected) + }) + } + + _, stdErr, err := f.RunCombinedOutput("p:c") + assert.Error(t, err) + assert.Contains(t, stdErr, `Command "p:c" is ambiguous`) +} diff --git a/internal/legacy/commands.go b/internal/legacy/commands.go new file mode 100644 index 000000000..a5b47eacd --- /dev/null +++ b/internal/legacy/commands.go @@ -0,0 +1,34 @@ +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"` + Hidden bool `json:"hidden"` +} + +// Commands returns every legacy command, regardless of whether it is enabled by config. +var Commands = sync.OnceValues(func() ([]Command, error) { + var list struct { + Commands map[string]Command `json:"commands"` + } + if err := json.Unmarshal(commandIndex, &list); err != nil { + return nil, err + } + cmds := make([]Command, 0, len(list.Commands)) + for _, c := range list.Commands { + cmds = append(cmds, c) + } + return cmds, nil +}) From 41b28dc43341d6570a45c61681e01fccb73891de Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 24 Sep 2026 22:29:07 +0100 Subject: [PATCH 2/5] fix: only skip boolean root flags before an abbreviated command A value-taking flag before the command, e.g. "--context p:init init", could have its value mistaken for the command name. Abbreviations are now only expanded after known boolean root flags. Co-Authored-By: Claude Opus 5.5 --- commands/abbreviation.go | 25 +++++++++++++++++++++++-- commands/abbreviation_test.go | 9 ++++++++- go.mod | 2 +- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/commands/abbreviation.go b/commands/abbreviation.go index 01b39f2af..b26d21e6b 100644 --- a/commands/abbreviation.go +++ b/commands/abbreviation.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/upsun/cli/internal/legacy" ) @@ -27,8 +28,9 @@ func expandAbbreviation( loadLegacyCmds func() ([]legacy.Command, error), args []string, ) (expanded []string, ok bool, err error) { - pos := slices.IndexFunc(args, func(a string) bool { return !strings.HasPrefix(a, "-") }) - if pos == -1 || slices.Contains(args[:pos], "--") { + // Only boolean root flags may precede the command, so that a flag's value is not mistaken for it. + pos := slices.IndexFunc(args, func(a string) bool { return !isRootBoolFlag(root, a) }) + if pos == -1 || strings.HasPrefix(args[pos], "-") { return nil, false, nil } name := args[pos] @@ -71,6 +73,25 @@ func expandAbbreviation( return expanded, true, nil } +// isRootBoolFlag tests if arg consists of boolean root flags, e.g. "--yes" or "-vq". +func isRootBoolFlag(root *cobra.Command, arg string) bool { + isBool := func(f *pflag.Flag) bool { return f != nil && f.Value.Type() == "bool" } + if name, ok := strings.CutPrefix(arg, "--"); ok { + name, _, _ = strings.Cut(name, "=") + return isBool(root.PersistentFlags().Lookup(name)) + } + shorthands, ok := strings.CutPrefix(arg, "-") + if !ok || shorthands == "" { + return false + } + for _, c := range shorthands { + if !isBool(root.PersistentFlags().ShorthandLookup(string(c))) { + return false + } + } + return true +} + // resolveAbbreviation follows Symfony Console's rules to find the command abbreviated by name, if it is unique. func resolveAbbreviation(name string, candidates []abbrevCandidate) *abbrevCandidate { parts := strings.Split(name, ":") diff --git a/commands/abbreviation_test.go b/commands/abbreviation_test.go index 030bdb20b..0dd0cdd1a 100644 --- a/commands/abbreviation_test.go +++ b/commands/abbreviation_test.go @@ -11,6 +11,9 @@ import ( func TestExpandAbbreviation(t *testing.T) { root := &cobra.Command{Use: "upsun"} + root.PersistentFlags().BoolP("verbose", "v", false, "") + root.PersistentFlags().BoolP("yes", "y", false, "") + root.PersistentFlags().String("context", "", "") root.AddCommand( &cobra.Command{Use: "init", Aliases: []string{"project:init", "ify"}}, &cobra.Command{Use: "project:convert", Aliases: []string{"convert"}}, @@ -38,7 +41,11 @@ func TestExpandAbbreviation(t *testing.T) { {"namespace abbreviation", []string{"p:init"}, []string{"init"}}, {"both parts abbreviated", []string{"pro:ini", "--yes"}, []string{"init", "--yes"}}, {"case-insensitive fallback", []string{"P:Init"}, []string{"init"}}, - {"after flags", []string{"-v", "p:conv"}, []string{"-v", "project:convert"}}, + {"after flags", []string{"-v", "--yes", "-vy", "p:conv"}, []string{"-v", "--yes", "-vy", "project:convert"}}, + {"after a flag with a value", []string{"--context", "p:init", "init"}, nil}, + {"after a flag with a separate value", []string{"--context", "foo", "p:init"}, nil}, + {"after an unknown flag", []string{"--foo", "p:init"}, nil}, + {"after a shorthand with a value", []string{"-vc", "p:init"}, nil}, {"multi-word part", []string{"a:config-v"}, []string{"app:config-validate"}}, {"unique abbreviation", []string{"p:con"}, []string{"project:convert"}}, {"hidden legacy namespace ignored", []string{"ver"}, []string{"version"}}, diff --git a/go.mod b/go.mod index db0fd6adc..29df951f5 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/oklog/ulid/v2 v2.1.2 github.com/platformsh/platformify v0.5.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.12.1 github.com/symfony-cli/terminal v1.0.9 @@ -113,7 +114,6 @@ require ( github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect github.com/tidwall/jsonc v0.3.3 // indirect From 03c393a6733e9ddc639e9e5f01486022c4f0a0ef Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 24 Sep 2026 23:14:08 +0100 Subject: [PATCH 3/5] fix: address review findings on abbreviation resolution - Set the version when generating the command index, as the Go wrapper does, so the phar does not run "git describe", which crashed in CI. - Run the index generation non-interactively, so that local builds do not stop at the self-install prompt. - Count hidden commands toward ambiguity: the legacy CLI can resolve to them, e.g. "ver" to the hidden "version:list". - Allow --help and -h before an abbreviated command. Co-Authored-By: Claude Opus 5.5 --- Makefile | 4 +++- commands/abbreviation.go | 9 ++++++--- commands/abbreviation_test.go | 3 ++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index dafbee881..82af03573 100644 --- a/Makefile +++ b/Makefile @@ -34,10 +34,12 @@ internal/legacy/archives/platform.phar: legacy/vendor/autoload.php # 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 tmp=$$(mktemp -d) && \ HOME=$$tmp PLATFORMSH_CLI_EXPERIMENTAL_ALL_EXPERIMENTS=1 PLATFORMSH_CLI_NO_LEGACY_WARNING=1 \ - php $< list --all --format=json > $$tmp/commands.json && \ + PLATFORMSH_CLI_APPLICATION_VERSION=0.0.0 \ + php $< list --all --format=json --no-interaction < /dev/null > $$tmp/commands.json && \ mv $$tmp/commands.json $@; \ status=$$?; rm -rf $$tmp; exit $$status diff --git a/commands/abbreviation.go b/commands/abbreviation.go index b26d21e6b..63a7b3fc4 100644 --- a/commands/abbreviation.go +++ b/commands/abbreviation.go @@ -75,6 +75,10 @@ func expandAbbreviation( // isRootBoolFlag tests if arg consists of boolean root flags, e.g. "--yes" or "-vq". func isRootBoolFlag(root *cobra.Command, arg string) bool { + if arg == "--help" || arg == "-h" { + // Cobra only adds the help flag during execution. + return true + } isBool := func(f *pflag.Flag) bool { return f != nil && f.Value.Type() == "bool" } if name, ok := strings.CutPrefix(arg, "--"); ok { name, _, _ = strings.Cut(name, "=") @@ -127,9 +131,8 @@ func resolveAbbreviation(name string, candidates []abbrevCandidate) *abbrevCandi return nil } - if len(matched) > 1 { - matched = slices.DeleteFunc(matched, func(i int) bool { return candidates[i].hidden }) - } + // 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 } diff --git a/commands/abbreviation_test.go b/commands/abbreviation_test.go index 0dd0cdd1a..d6ec4c7d6 100644 --- a/commands/abbreviation_test.go +++ b/commands/abbreviation_test.go @@ -48,7 +48,8 @@ func TestExpandAbbreviation(t *testing.T) { {"after a shorthand with a value", []string{"-vc", "p:init"}, nil}, {"multi-word part", []string{"a:config-v"}, []string{"app:config-validate"}}, {"unique abbreviation", []string{"p:con"}, []string{"project:convert"}}, - {"hidden legacy namespace ignored", []string{"ver"}, []string{"version"}}, + {"ambiguous with a hidden legacy command", []string{"ver"}, nil}, + {"after help flag", []string{"--help", "p:init"}, []string{"--help", "init"}}, {"ambiguous with a legacy command", []string{"p:c"}, nil}, {"ambiguous between legacy commands", []string{"a:c"}, nil}, {"prefix of a longer legacy name", []string{"in"}, nil}, From 7811cf0bb67a5c9ce1890f9ca040f5dab7049db5 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 00:52:20 +0100 Subject: [PATCH 4/5] fix: treat hidden legacy aliases and disabled commands like the legacy CLI - Read the new hidden_aliases field of the command index. Hidden aliases only work in full, so they prevent expansion when typed exactly, but are not matched by abbreviations. - Leave out legacy commands disabled by the config (disabled_commands and wrapped_disabled_commands), as the legacy CLI does not register them. The index itself stays config-independent, as one index is shared by all builds and the config can change at runtime. Co-Authored-By: Claude Opus 5.5 --- commands/abbreviation.go | 17 ++++++++++++++++- commands/abbreviation_test.go | 18 +++++++++++++++++- commands/help.go | 5 +++-- commands/root.go | 2 +- internal/config/schema.go | 4 ++++ internal/legacy/commands.go | 4 +++- 6 files changed, 44 insertions(+), 6 deletions(-) diff --git a/commands/abbreviation.go b/commands/abbreviation.go index 63a7b3fc4..ac6bbe566 100644 --- a/commands/abbreviation.go +++ b/commands/abbreviation.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + "github.com/upsun/cli/internal/config" "github.com/upsun/cli/internal/legacy" ) @@ -58,7 +59,7 @@ func expandAbbreviation( continue } names := append([]string{c.Name}, c.Aliases...) - if slices.Contains(names, name) { + if slices.Contains(names, name) || slices.Contains(c.HiddenAliases, name) { return nil, false, nil } candidates = append(candidates, abbrevCandidate{names: names, hidden: c.Hidden}) @@ -96,6 +97,20 @@ func isRootBoolFlag(root *cobra.Command, arg string) bool { return true } +// enabledLegacyCommands returns a loader for the legacy commands that are not disabled by config. +func enabledLegacyCommands(cnf *config.Config, load func() ([]legacy.Command, error)) func() ([]legacy.Command, error) { + return func() ([]legacy.Command, error) { + cmds, err := load() + if err != nil { + return nil, err + } + return slices.DeleteFunc(slices.Clone(cmds), func(c legacy.Command) bool { + return slices.Contains(cnf.Application.DisabledCommands, c.Name) || + slices.Contains(cnf.Application.WrappedDisabledCommands, c.Name) + }), nil + } +} + // resolveAbbreviation follows Symfony Console's rules to find the command abbreviated by name, if it is unique. func resolveAbbreviation(name string, candidates []abbrevCandidate) *abbrevCandidate { parts := strings.Split(name, ":") diff --git a/commands/abbreviation_test.go b/commands/abbreviation_test.go index d6ec4c7d6..44ea16c24 100644 --- a/commands/abbreviation_test.go +++ b/commands/abbreviation_test.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/upsun/cli/internal/config" "github.com/upsun/cli/internal/legacy" ) @@ -24,7 +25,7 @@ func TestExpandAbbreviation(t *testing.T) { ) legacyCmds := []legacy.Command{ {Name: "list"}, - {Name: "project:info", Aliases: []string{"pinfo"}}, + {Name: "project:info", Aliases: []string{"pinfo"}, HiddenAliases: []string{"p:in", "project:initx"}}, {Name: "project:create", Aliases: []string{"create"}}, {Name: "project:curl", Hidden: true}, {Name: "app:list", Aliases: []string{"apps"}}, @@ -56,6 +57,8 @@ func TestExpandAbbreviation(t *testing.T) { {"legacy command", []string{"p:info"}, nil}, {"exact native command", []string{"init"}, nil}, {"exact legacy command", []string{"pinfo"}, nil}, + {"exact hidden legacy alias", []string{"p:in"}, nil}, + {"hidden legacy alias not abbreviated", []string{"project:ini"}, []string{"init"}}, {"unknown command", []string{"p:nope"}, nil}, {"hidden native command", []string{"_comp"}, nil}, {"no command", []string{"--version"}, nil}, @@ -75,3 +78,16 @@ func TestExpandAbbreviation(t *testing.T) { }) } } + +func TestEnabledLegacyCommands(t *testing.T) { + cnf := &config.Config{} + cnf.Application.DisabledCommands = []string{"self:install"} + cnf.Application.WrappedDisabledCommands = []string{"self:update"} + load := func() ([]legacy.Command, error) { + return []legacy.Command{{Name: "self:install"}, {Name: "self:update"}, {Name: "self:stats"}}, nil + } + + cmds, err := enabledLegacyCommands(cnf, load)() + assert.NoError(t, err) + assert.Equal(t, []legacy.Command{{Name: "self:stats"}}, cmds) +} diff --git a/commands/help.go b/commands/help.go index 04fe690fa..c7c433cdc 100644 --- a/commands/help.go +++ b/commands/help.go @@ -7,13 +7,14 @@ import ( "github.com/upsun/cli/internal/legacy" ) -func newHelpCommand(_ *config.Config) *cobra.Command { +func newHelpCommand(cnf *config.Config) *cobra.Command { return &cobra.Command{ Use: "help", // Disable flag parsing so flags like --format are preserved for the legacy CLI. DisableFlagParsing: true, Run: func(cmd *cobra.Command, args []string) { - if expanded, ok, err := expandAbbreviation(cmd.Root(), legacy.Commands, args); err != nil { + loadLegacyCmds := enabledLegacyCommands(cnf, legacy.Commands) + if expanded, ok, err := expandAbbreviation(cmd.Root(), loadLegacyCmds, args); err != nil { debugLogf("Failed to load the legacy command index: %s", err) } else if ok { args = expanded diff --git a/commands/root.go b/commands/root.go index c992e68e8..d45b1e176 100644 --- a/commands/root.go +++ b/commands/root.go @@ -37,7 +37,7 @@ func Execute(cnf *config.Config) error { ctx := vendorization.WithVendorAssets(config.ToContext(context.Background(), cnf), assets) cmd := newRootCommand(cnf, assets) - if args, ok, err := expandAbbreviation(cmd, legacy.Commands, os.Args[1:]); err != nil { + if args, ok, err := expandAbbreviation(cmd, enabledLegacyCommands(cnf, legacy.Commands), os.Args[1:]); err != nil { debugLogf("Failed to load the legacy command index: %s", err) } else if ok { cmd.SetArgs(args) diff --git a/internal/config/schema.go b/internal/config/schema.go index 476f5f6a3..4824b54f3 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -37,6 +37,10 @@ type Config struct { UserStateFile string `validate:"omitempty" yaml:"user_state_file,omitempty"` // defaults to "state.json" WritableUserDir string `validate:"omitempty" yaml:"writable_user_dir,omitempty"` // defaults to UserConfigDir TempSubDir string `validate:"omitempty" yaml:"tmp_sub_dir,omitempty"` // defaults to Slug+"-tmp" + + // Legacy commands disabled by config. As the Go layer always wraps the legacy CLI, both lists apply. + DisabledCommands []string `validate:"omitempty" yaml:"disabled_commands,omitempty"` + WrappedDisabledCommands []string `validate:"omitempty" yaml:"wrapped_disabled_commands,omitempty"` } `validate:"required"` Updates struct { Check bool `validate:"omitempty"` // defaults to true diff --git a/internal/legacy/commands.go b/internal/legacy/commands.go index a5b47eacd..25512cff6 100644 --- a/internal/legacy/commands.go +++ b/internal/legacy/commands.go @@ -15,7 +15,9 @@ var commandIndex []byte type Command struct { Name string `json:"name"` Aliases []string `json:"aliases"` - Hidden bool `json:"hidden"` + // HiddenAliases only work in full: they are not matched by abbreviations. + HiddenAliases []string `json:"hidden_aliases"` + Hidden bool `json:"hidden"` } // Commands returns every legacy command, regardless of whether it is enabled by config. From 7711c9ff15cc7f2edcf8fd433ca7452cfde7312b Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 00:59:53 +0100 Subject: [PATCH 5/5] fix: define the root help flag before command lookup Cobra adds the help flag during execution, after looking up the command, so "--help init" treated "init" as the flag's value and passed the arguments to the legacy CLI, which has no "init" command. This also made expansion after "--help" ineffective. Co-Authored-By: Claude Opus 5.5 --- commands/abbreviation.go | 16 ++++++++++------ commands/abbreviation_test.go | 1 + commands/root.go | 3 +++ integration-tests/abbreviation_test.go | 5 ++++- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/commands/abbreviation.go b/commands/abbreviation.go index ac6bbe566..40f51dc4b 100644 --- a/commands/abbreviation.go +++ b/commands/abbreviation.go @@ -76,21 +76,25 @@ func expandAbbreviation( // isRootBoolFlag tests if arg consists of boolean root flags, e.g. "--yes" or "-vq". func isRootBoolFlag(root *cobra.Command, arg string) bool { - if arg == "--help" || arg == "-h" { - // Cobra only adds the help flag during execution. - return true + flagSets := []*pflag.FlagSet{root.Flags(), root.PersistentFlags()} + isBool := func(lookup func(*pflag.FlagSet) *pflag.Flag) bool { + for _, fs := range flagSets { + if f := lookup(fs); f != nil { + return f.Value.Type() == "bool" + } + } + return false } - isBool := func(f *pflag.Flag) bool { return f != nil && f.Value.Type() == "bool" } if name, ok := strings.CutPrefix(arg, "--"); ok { name, _, _ = strings.Cut(name, "=") - return isBool(root.PersistentFlags().Lookup(name)) + return isBool(func(fs *pflag.FlagSet) *pflag.Flag { return fs.Lookup(name) }) } shorthands, ok := strings.CutPrefix(arg, "-") if !ok || shorthands == "" { return false } for _, c := range shorthands { - if !isBool(root.PersistentFlags().ShorthandLookup(string(c))) { + if !isBool(func(fs *pflag.FlagSet) *pflag.Flag { return fs.ShorthandLookup(string(c)) }) { return false } } diff --git a/commands/abbreviation_test.go b/commands/abbreviation_test.go index 44ea16c24..f8670a1a5 100644 --- a/commands/abbreviation_test.go +++ b/commands/abbreviation_test.go @@ -15,6 +15,7 @@ func TestExpandAbbreviation(t *testing.T) { root.PersistentFlags().BoolP("verbose", "v", false, "") root.PersistentFlags().BoolP("yes", "y", false, "") root.PersistentFlags().String("context", "", "") + root.InitDefaultHelpFlag() root.AddCommand( &cobra.Command{Use: "init", Aliases: []string{"project:init", "ify"}}, &cobra.Command{Use: "project:convert", Aliases: []string{"convert"}}, diff --git a/commands/root.go b/commands/root.go index d45b1e176..0db2a8240 100644 --- a/commands/root.go +++ b/commands/root.go @@ -173,6 +173,9 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob cmd.AddCommand(newProjectConvertCommand(cnf)) } + // Define the help flag before Cobra looks up the command, so that "--help init" does not treat "init" as its value. + cmd.InitDefaultHelpFlag() + //nolint:errcheck viper.BindPFlags(cmd.PersistentFlags()) diff --git a/integration-tests/abbreviation_test.go b/integration-tests/abbreviation_test.go index 926c89f06..96b8a9618 100644 --- a/integration-tests/abbreviation_test.go +++ b/integration-tests/abbreviation_test.go @@ -1,6 +1,7 @@ package tests import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -16,11 +17,13 @@ func TestAbbreviation(t *testing.T) { }{ {[]string{"p:init", "--help"}, "Command: project:init"}, {[]string{"help", "p:init"}, "Command: project:init"}, + {[]string{"--help", "p:init"}, "Command: project:init"}, + {[]string{"--help", "init"}, "Command: project:init"}, {[]string{"a:config-v", "--help"}, "Command: app:config-validate"}, {[]string{"env:info", "--help"}, "Command: environment:info"}, } for _, c := range cases { - t.Run(c.args[0], func(t *testing.T) { + t.Run(strings.Join(c.args, " "), func(t *testing.T) { assert.Contains(t, f.Run(c.args...), c.expected) }) }