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..82af03573 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,17 @@ 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 + 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 && \ + mv $$tmp/commands.json $@; \ + status=$$?; rm -rf $$tmp; exit $$status + legacy/vendor/autoload.php: cd legacy && composer install --no-interaction @@ -80,11 +91,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 +105,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 +156,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..40f51dc4b --- /dev/null +++ b/commands/abbreviation.go @@ -0,0 +1,159 @@ +package commands + +import ( + "regexp" + "slices" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/upsun/cli/internal/config" + "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) { + // 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] + + 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) || slices.Contains(c.HiddenAliases, 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 +} + +// isRootBoolFlag tests if arg consists of boolean root flags, e.g. "--yes" or "-vq". +func isRootBoolFlag(root *cobra.Command, arg string) bool { + 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 + } + if name, ok := strings.CutPrefix(arg, "--"); ok { + name, _, _ = strings.Cut(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(func(fs *pflag.FlagSet) *pflag.Flag { return fs.ShorthandLookup(string(c)) }) { + return false + } + } + 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, ":") + 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 + } + + // 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 &candidates[matched[0]] +} diff --git a/commands/abbreviation_test.go b/commands/abbreviation_test.go new file mode 100644 index 000000000..f8670a1a5 --- /dev/null +++ b/commands/abbreviation_test.go @@ -0,0 +1,94 @@ +package commands + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/internal/config" + "github.com/upsun/cli/internal/legacy" +) + +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.InitDefaultHelpFlag() + 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"}, HiddenAliases: []string{"p:in", "project:initx"}}, + {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", "--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"}}, + {"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}, + {"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}, + {"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) + }) + } +} + +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 45964c21e..c7c433cdc 100644 --- a/commands/help.go +++ b/commands/help.go @@ -4,14 +4,21 @@ import ( "github.com/spf13/cobra" "github.com/upsun/cli/internal/config" + "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) { + 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 + } 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..0db2a8240 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, 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) + } + return cmd.ExecuteContext(ctx) } func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cobra.Command { @@ -167,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/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 diff --git a/integration-tests/abbreviation_test.go b/integration-tests/abbreviation_test.go new file mode 100644 index 000000000..96b8a9618 --- /dev/null +++ b/integration-tests/abbreviation_test.go @@ -0,0 +1,34 @@ +package tests + +import ( + "strings" + "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{"--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(strings.Join(c.args, " "), 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/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 new file mode 100644 index 000000000..25512cff6 --- /dev/null +++ b/internal/legacy/commands.go @@ -0,0 +1,36 @@ +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"` +} + +// 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 +})