Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 18 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
159 changes: 159 additions & 0 deletions commands/abbreviation.go
Original file line number Diff line number Diff line change
@@ -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) })
Comment thread
pjcdawkins marked this conversation as resolved.
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]]
}
94 changes: 94 additions & 0 deletions commands/abbreviation_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 8 additions & 1 deletion commands/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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())

Expand Down
Loading
Loading