diff --git a/CHANGELOG.md b/CHANGELOG.md index 6447b26..8f01983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # RELEASE NOTES +## 2.0.6 (Oct 28, 2026) + +### Enhancements + +* Removed an unmaintained `github.com/mitchellh/go-homedir` dependency, replacing its usage with the standard library's `os.UserHomeDir()`. +* Migrated to Go `1.26.8`. +* Updated dependencies and development tooling. +* Migrated the CLI framework from `urfave/cli` v2 to v3 to align with Akamai CLI updates. +* Migrated the public CLI integration API from `urfave/cli/v2` to `urfave/cli/v3`. Consumers must migrate their commands, actions, callbacks, and contexts to the v3 types. +* Changed `app.CreateAppTemplate` to return `*cli.Command` instead of `*cli.App`. +* Changed `autocomplete.Default` to accept `context.Context` and `*cli.Command`, and changed `apphelp.Setup` to accept `*cli.Command`. +* Replaced the hidden `--generate-bash-completion` flag with `--generate-shell-completion`. + +### Fixes + +* Fixed `help ` failing to display help for commands without subcommands after upgrading `urfave/cli` to `v3.14.0`. +* Fixed the `list` command to omit hidden internal commands. +* Validated package command names before creating binary output paths to prevent path traversal, and removed pre-existing Go build output entries to avoid following symlinks planted at those paths ([PR#227](https://github.com/akamai/cli/pull/227)). +* Fixed downloaded package binaries being written through symlinks planted in a package repository: a symlinked `bin` directory is now rejected, and an existing output entry is removed and recreated exclusively instead of being followed. +* Fixed installed package commands so the CLI uses the same binary name when installing and running them. + ## 2.0.5 (Jul 16, 2026) ### Enhancements diff --git a/Makefile b/Makefile index d8e2b10..0bd5d06 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,12 @@ -GOIMPORTS_VERSION = v0.24.0 +GOIMPORTS_VERSION = v0.50.0 GO_JUNIT_REPORT_VERSION = v2.1.0 GOCOV_VERSION = v1.1.0 GOCOVXML_VERSION = v1.1.0 -GOLANGCI_LINT_VERSION = v2.6.1 +GOLANGCI_LINT_VERSION = v2.13.2 BIN = $(CURDIR)/bin -GOCMD = go +GOWORK ?= off +GOCMD = GOWORK=$(GOWORK) go GOTEST = $(GOCMD) test GOBUILD = $(GOCMD) build M = $(shell echo ">") @@ -29,7 +30,7 @@ $(BIN)/go-junit-report: PACKAGE=github.com/jstemmer/go-junit-report/v2@$(GO_JUNI GOLANGCILINT = $(BIN)/golangci-lint $(BIN)/golangci-lint: ; $(info $(M) Installing golangci-lint...) - @curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(BIN) $(GOLANGCI_LINT_VERSION) + @curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(BIN) $(GOLANGCI_LINT_VERSION) COVERAGE_MODE = atomic COVERAGE_DIR = $(CURDIR)/test/coverage @@ -66,7 +67,7 @@ create-coverage-files: | $(GOCOV) $(GOCOVXML); $(info $(M) Creating coverage fil .PHONY: lint lint: | $(GOLANGCILINT); $(info $(M) Running linter...) @ ## Run golangci-lint on all source files - @$(BIN)/golangci-lint run + @GOWORK=$(GOWORK) $(BIN)/golangci-lint run .PHONY: fmt fmt: | $(GOIMPORTS); $(info $(M) Running goimports...) @ ## Run goimports on all source files diff --git a/README.md b/README.md index 0a7f30e..33d96cf 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ This command installs the CLI and persists the configuration and packages in `$H ### Compile from source -To compile Akamai CLI from source, you need [Go](https://golang.org/) 1.25.7 or later installed. +To compile Akamai CLI from source, you need [Go](https://golang.org/) 1.26.8 or later installed. 1. Change the working directory. diff --git a/cli/app/firstrun.go b/cli/app/firstrun.go index 5fb3474..2477522 100644 --- a/cli/app/firstrun.go +++ b/cli/app/firstrun.go @@ -136,7 +136,7 @@ func choosePath(ctx context.Context, writablePaths []string, selfPath string) { } answer, err := term.Prompt("Choose where you would like to install Akamai CLI:", writablePaths...) if err != nil { - term.Spinner().Start(string(terminal.SpinnerStatusFail)) + term.Spinner().Start("%s", terminal.SpinnerStatusFail) if _, err := term.Writeln(color.RedString(err.Error())); err != nil { term.WriteError(err.Error()) } @@ -148,7 +148,7 @@ func choosePath(ctx context.Context, writablePaths []string, selfPath string) { suffix = ".exe" } newPath := filepath.Join(answer, "akamai"+suffix) - term.Spinner().Start("Installing to " + newPath + "...") + term.Spinner().Start("Installing to %s...", newPath) err = tools.MoveFile(selfPath, newPath) diff --git a/cli/app/run.go b/cli/app/run.go index 4c88c3b..d40990b 100644 --- a/cli/app/run.go +++ b/cli/app/run.go @@ -16,7 +16,7 @@ import ( "github.com/akamai/cli/v2/pkg/terminal" "github.com/akamai/cli/v2/pkg/tools" "github.com/akamai/cli/v2/pkg/version" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) // Run is the entry point to the CLI @@ -85,7 +85,7 @@ func Run() int { return 7 } - if err := cliApp.RunContext(ctx, os.Args); err != nil { + if err := cliApp.Run(ctx, os.Args); err != nil { return 6 } diff --git a/cli/app/run_test.go b/cli/app/run_test.go index c8c7723..41c1519 100644 --- a/cli/app/run_test.go +++ b/cli/app/run_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func TestFindCollisions(t *testing.T) { diff --git a/go.mod b/go.mod index 455be04..68221c6 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,22 @@ module github.com/akamai/cli/v2 -go 1.25.0 +go 1.26.8 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/Masterminds/semver v1.5.0 github.com/briandowns/spinner v1.23.2 github.com/fatih/color v1.18.0 - github.com/go-git/go-git/v5 v5.19.1 - github.com/go-ini/ini v1.67.0 + github.com/go-git/go-git/v5 v5.19.2 github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 github.com/mattn/go-colorable v0.1.14 github.com/mattn/go-isatty v0.0.20 - github.com/mitchellh/go-homedir v1.1.0 - github.com/stretchr/testify v1.11.1 - github.com/urfave/cli/v2 v2.19.3 + github.com/stretchr/testify v1.12.1 + github.com/urfave/cli/v3 v3.14.0 golang.org/x/sys v0.47.0 - golang.org/x/text v0.40.0 + golang.org/x/text v0.41.0 + gopkg.in/ini.v1 v1.67.3 ) require ( @@ -25,9 +24,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect @@ -38,21 +35,18 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/skeema/knownhosts v1.3.2 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/net v0.56.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/term v0.45.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) replace ( github.com/yuin/goldmark v1.4.13 => github.com/yuin/goldmark v1.8.2 // Fix security vulnerability; can be removed once bumped by github.com/go-git/go-git/v5 - golang.org/x/crypto v0.53.0 => golang.org/x/crypto v0.54.0 // Fix security vulnerability; can be removed once bumped by golang.org/x/net + golang.org/x/crypto v0.54.0 => golang.org/x/crypto v0.55.0 // Fix security vulnerability; can be removed once bumped by golang.org/x/net ) diff --git a/go.sum b/go.sum index 36ea016..259687f 100644 --- a/go.sum +++ b/go.sum @@ -19,16 +19,12 @@ github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2 github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= -github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= @@ -43,10 +39,8 @@ github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmm github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= -github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= -github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= -github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= +github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -81,8 +75,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= @@ -90,37 +82,40 @@ github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/urfave/cli/v2 v2.19.3 h1:GsJ5D2qZWF6hk/F276qqf8v/Ff4IzAUTB+CUFSNhN6M= -github.com/urfave/cli/v2 v2.19.3/go.mod h1:1CNUng3PtjQMtRzJO4FMXBQvkGtuYRxxiR9xMa7jMwI= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/urfave/cli/v3 v3.14.0 h1:a8414NQlHJs0c/iBsulKLzlES0n/lEAskbL2LKpU4/s= +github.com/urfave/cli/v3 v3.14.0/go.mod h1:vXn6HxPNccJSzQr2QvwVncOKrgYGIHU0HY5h8B2nQj4= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= -github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -128,8 +123,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -154,8 +149,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -164,6 +159,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/app/cli.go b/pkg/app/cli.go index 63137c6..6e04d44 100644 --- a/pkg/app/cli.go +++ b/pkg/app/cli.go @@ -15,14 +15,13 @@ import ( "github.com/akamai/cli/v2/pkg/version" "github.com/kardianos/osext" - "github.com/mitchellh/go-homedir" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) const sleep24HDuration = time.Hour * 24 -// CreateApp creates and sets up *cli.App -func CreateApp(ctx context.Context) *cli.App { +// CreateApp creates and sets up *cli.Command +func CreateApp(ctx context.Context) *cli.Command { app := createAppTemplate(ctx, "", "Akamai CLI", "", version.Version, false) app.Flags = append(app.Flags, &cli.BoolFlag{ @@ -41,45 +40,45 @@ func CreateApp(ctx context.Context) *cli.App { Name: "daemon", Usage: "Keep Akamai CLI running in the background, particularly useful for Docker containers", Hidden: true, - EnvVars: []string{"AKAMAI_CLI_DAEMON"}, + Sources: cli.EnvVars("AKAMAI_CLI_DAEMON"), }, ) - app.Action = func(c *cli.Context) error { - return defaultAction(c) + app.Action = func(ctx context.Context, cmd *cli.Command) error { + return defaultAction(ctx, cmd) } - app.Before = func(c *cli.Context) error { - if c.IsSet("proxy") { - proxy := c.String("proxy") + app.Before = func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + if cmd.IsSet("proxy") { + proxy := cmd.String("proxy") if !strings.HasPrefix(proxy, "http://") && !strings.HasPrefix(proxy, "https://") { proxy = fmt.Sprintf("http://%s", proxy) } if err := os.Setenv("HTTP_PROXY", proxy); err != nil { - return err + return ctx, err } if err := os.Setenv("HTTPS_PROXY", proxy); err != nil { - return err + return ctx, err } } - if c.IsSet("daemon") { + if cmd.IsSet("daemon") { for { time.Sleep(sleep24HDuration) } } - return nil + return ctx, nil } return app } -// CreateAppTemplate creates a basic *cli.App template -func CreateAppTemplate(ctx context.Context, commandName, usage, description, version string) *cli.App { +// CreateAppTemplate creates a basic *cli.Command template +func CreateAppTemplate(ctx context.Context, commandName, usage, description, version string) *cli.Command { return createAppTemplate(ctx, commandName, usage, description, version, true) } -func createAppTemplate(ctx context.Context, commandName, usage, description, version string, useDefaults bool) *cli.App { +func createAppTemplate(ctx context.Context, commandName, usage, description, version string, useDefaults bool) *cli.Command { _, inCli := os.LookupEnv("AKAMAI_CLI") term := terminal.Get(ctx) @@ -91,9 +90,8 @@ func createAppTemplate(ctx context.Context, commandName, usage, description, ver } } - app := cli.NewApp() + app := &cli.Command{} app.Name = appName - app.HelpName = appName app.Usage = usage app.Description = description app.Version = version @@ -101,12 +99,12 @@ func createAppTemplate(ctx context.Context, commandName, usage, description, ver app.Copyright = "Copyright (C) Akamai Technologies, Inc" app.Writer = term app.ErrWriter = term.Error() - app.EnableBashCompletion = true - app.BashComplete = autocomplete.Default + app.EnableShellCompletion = true + app.ShellComplete = autocomplete.Default var edgercpath, section string if useDefaults { - edgercpath, _ = homedir.Dir() + edgercpath, _ = os.UserHomeDir() edgercpath = path.Join(edgercpath, ".edgerc") section = "default" @@ -118,20 +116,20 @@ func createAppTemplate(ctx context.Context, commandName, usage, description, ver Aliases: []string{"e"}, Usage: "Location of the credentials file", Value: edgercpath, - EnvVars: []string{"AKAMAI_EDGERC"}, + Sources: cli.EnvVars("AKAMAI_EDGERC"), }, &cli.StringFlag{ Name: "section", Aliases: []string{"s"}, Usage: "Section of the credentials file", Value: section, - EnvVars: []string{"AKAMAI_EDGERC_SECTION"}, + Sources: cli.EnvVars("AKAMAI_EDGERC_SECTION"), }, &cli.StringFlag{ Name: "accountkey", Aliases: []string{"account-key"}, Usage: "Account switch key", - EnvVars: []string{"AKAMAI_EDGERC_ACCOUNT_KEY"}, + Sources: cli.EnvVars("AKAMAI_EDGERC_ACCOUNT_KEY"), }, } @@ -139,13 +137,10 @@ func createAppTemplate(ctx context.Context, commandName, usage, description, ver Name: "version", Usage: "Output CLI version", } - cli.BashCompletionFlag = &cli.BoolFlag{ - Name: "generate-bash-completion", - Hidden: true, - } cli.HelpFlag = &cli.BoolFlag{ - Name: "help", - Usage: "show help", + Name: "help", + Aliases: []string{"h"}, + Usage: "show help", } apphelp.Setup(app) @@ -153,7 +148,7 @@ func createAppTemplate(ctx context.Context, commandName, usage, description, ver return app } -func defaultAction(c *cli.Context) error { +func defaultAction(ctx context.Context, c *cli.Command) error { cmd, err := osext.Executable() if err != nil { cmd = tools.Self() @@ -172,14 +167,14 @@ autoload -U bashcompinit && bashcompinit` local cur opts base COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" - opts=$( ${COMP_WORDS[@]:0:$COMP_CWORD} --generate-bash-completion ) + opts=$( ${COMP_WORDS[@]:0:$COMP_CWORD} --generate-shell-completion ) COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) return 0 } complete -F _akamai_cli_bash_autocomplete ` + tools.Self() - term := terminal.Get(c.Context) + term := terminal.Get(ctx) if c.Bool("bash") { if _, err = term.Writeln(bashComments); err != nil { diff --git a/pkg/app/cli_test.go b/pkg/app/cli_test.go index 2cbabed..03ae5be 100644 --- a/pkg/app/cli_test.go +++ b/pkg/app/cli_test.go @@ -3,17 +3,15 @@ package app import ( "bytes" "context" - "flag" "os" "regexp" "testing" - "github.com/akamai/cli/v2/pkg/log" "github.com/akamai/cli/v2/pkg/terminal" "github.com/akamai/cli/v2/pkg/version" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func TestCreateApp(t *testing.T) { @@ -25,7 +23,7 @@ func TestCreateApp(t *testing.T) { assert.Equal(t, version.Version, app.Version) assert.Equal(t, term, app.Writer) assert.Equal(t, term.Error(), app.ErrWriter) - assert.True(t, app.EnableBashCompletion) + assert.True(t, app.EnableShellCompletion) assert.True(t, hasFlag(app, "bash")) assert.True(t, hasFlag(app, "zsh")) assert.True(t, hasFlag(app, "proxy")) @@ -45,7 +43,7 @@ func TestCreateAppTemplate_EmptyCommandName(t *testing.T) { assert.Equal(t, term, app.Writer) assert.Equal(t, term.Error(), app.ErrWriter) assert.Equal(t, "Copyright (C) Akamai Technologies, Inc", app.Copyright) - assert.True(t, app.EnableBashCompletion) + assert.True(t, app.EnableShellCompletion) assert.True(t, hasFlag(app, "edgerc")) assert.True(t, hasFlag(app, "section")) assert.True(t, hasFlag(app, "accountkey")) @@ -73,7 +71,7 @@ func TestVersion(t *testing.T) { term := terminal.Color() ctx := terminal.Context(context.Background(), term) _ = CreateApp(ctx) - assert.Regexp(t, regexp.MustCompile(`^--version\s+Output CLI version \(default: false\)$`), cli.VersionFlag.String()) + assert.Regexp(t, regexp.MustCompile(`^--version\s+Output CLI version$`), cli.VersionFlag.String()) assert.Len(t, cli.VersionFlag.Names(), 1) assert.Equal(t, cli.VersionFlag.Names()[0], "version") } @@ -113,27 +111,27 @@ func TestCreateAppProxy(t *testing.T) { } for name, test := range tests { t.Run(name, func(t *testing.T) { + t.Setenv("HTTP_PROXY", "") + t.Setenv("HTTPS_PROXY", "") + term := terminal.Color() ctx := terminal.Context(context.Background(), term) app := CreateApp(ctx) - set := flag.NewFlagSet("test", 0) - set.String("proxy", "", "") - cliCtx := cli.NewContext(app, set, nil) + app.Action = func(context.Context, *cli.Command) error { return nil } + args := []string{"akamai"} if test.proxyValue != "" { - require.NoError(t, cliCtx.Set("proxy", test.proxyValue)) + args = append(args, "--proxy", test.proxyValue) } - err := app.Before(cliCtx) + err := app.Run(ctx, args) require.NoError(t, err) - assert.NotNil(t, log.FromContext(cliCtx.Context)) for k, v := range test.expectedEnvs { assert.Equal(t, v, os.Getenv(k)) - require.NoError(t, os.Unsetenv(k)) } }) } } -func hasFlag(app *cli.App, name string) bool { +func hasFlag(app *cli.Command, name string) bool { for _, f := range app.Flags { if f.Names()[0] == name { return true @@ -155,7 +153,7 @@ func TestAutocompletionFlag(t *testing.T) { app.Writer = stdout app.ErrWriter = stderr - err := app.RunContext(ctx, []string{"akamai", "--generate-bash-completion"}) + err := app.Run(ctx, []string{"akamai", "--generate-shell-completion"}) assert.NoError(t, err) assert.Empty(t, stderr.String()) diff --git a/pkg/apphelp/help.go b/pkg/apphelp/help.go index d95a89e..7a05d18 100644 --- a/pkg/apphelp/help.go +++ b/pkg/apphelp/help.go @@ -10,7 +10,7 @@ import ( "github.com/akamai/cli/v2/pkg/tools" "github.com/akamai/cli/v2/pkg/color" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) var ( @@ -37,17 +37,18 @@ func init() { // Default help command and flag from urfave/cli have some known discrepancies (https://github.com/urfave/cli/issues/557) // which are removed by the custom help command that is added by this function. Help flag needs to be added // manually as it is not appended by the library in case of custom help command being specified. -func Setup(app *cli.App) { +func Setup(app *cli.Command) { SetTemplates(app.Flags) app.Flags = append(app.Flags, cli.HelpFlag) app.Commands = []*cli.Command{ { Name: "help", + HideHelpCommand: true, ArgsUsage: "[command] [sub-command]", Description: "Displays help information", Action: cmdHelp, CustomHelpTemplate: SimplifiedHelpTemplate, - BashComplete: autocomplete.Default, + ShellComplete: autocomplete.Default, }, } } @@ -58,7 +59,7 @@ func SetTemplates(globalFlags []cli.Flag) { if err != nil { panic(ErrReadingTemplateFile) } - cli.AppHelpTemplate = string(tmpl) + cli.RootCommandHelpTemplate = string(tmpl) tmpl, err = files.ReadFile("templates/command_help.tmpl") if err != nil { diff --git a/pkg/apphelp/help_command.go b/pkg/apphelp/help_command.go index e396677..edd1fc7 100644 --- a/pkg/apphelp/help_command.go +++ b/pkg/apphelp/help_command.go @@ -1,37 +1,54 @@ package apphelp import ( + "context" "os" + "strings" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) -func cmdHelp(c *cli.Context) error { - if c.Args().Present() { - cmdName := c.Args().First() - cmd := c.App.Command(cmdName) - if cmd == nil { - return cli.ShowAppHelp(c) - } +func cmdHelp(ctx context.Context, cmd *cli.Command) error { + root := cmd.Root() + args := cmd.Args() + if !args.Present() { + return cli.ShowRootCommandHelp(root) + } - if subCmd := c.Args().Get(1); subCmd != "" || len(cmd.Subcommands) > 0 { - os.Args = append([]string{os.Args[0], cmdName}, c.Args().Tail()...) - os.Args = append(os.Args, "--help") - return c.App.Run(os.Args) - } + cmdName := args.First() + target := root.Command(cmdName) + if target == nil { + return cli.ShowRootCommandHelp(root) + } - if isBuiltinCommand(c, cmdName) { - if shouldAddHelpFlag(cmd) { - cmd.Flags = append(cmd.Flags, cli.HelpFlag) + if isBuiltinCommand(root, cmdName) { + if subCmdName := args.Get(1); subCmdName != "" && !strings.HasPrefix(subCmdName, "-") { + if subCmd := target.Command(subCmdName); subCmd != nil && shouldAddHelpFlag(subCmd) { + subCmd.Flags = append(subCmd.Flags, cli.HelpFlag) } - return cli.ShowCommandHelp(c, cmdName) + return cli.ShowCommandHelp(ctx, target, subCmdName) } - os.Args = append([]string{os.Args[0], cmdName, "help"}, c.Args().Tail()...) - return c.App.RunContext(c.Context, os.Args) + if hasVisibleSubcommands(target) { + return cli.ShowSubcommandHelp(target) + } + if shouldAddHelpFlag(target) { + target.Flags = append(target.Flags, cli.HelpFlag) + } + return cli.ShowCommandHelp(ctx, root, cmdName) + } + + commandArgs := []string{os.Args[0]} + if args.Get(1) != "" || len(target.Commands) > 0 { + commandArgs = append(commandArgs, args.Tail()...) + commandArgs = append(commandArgs, "--help") + } else { + commandArgs = append(commandArgs, "help") + commandArgs = append(commandArgs, args.Tail()...) } - return cli.ShowAppHelp(c) + command := *target + return command.Run(ctx, commandArgs) } func hasHelpFlag(cmd *cli.Command) bool { @@ -50,8 +67,18 @@ func shouldAddHelpFlag(cmd *cli.Command) bool { return false } -func isBuiltinCommand(c *cli.Context, cmdName string) bool { - for _, cmd := range c.App.Commands { +func hasVisibleSubcommands(cmd *cli.Command) bool { + for _, subcommand := range cmd.VisibleCommands() { + if subcommand.Name == "help" && subcommand.HideHelp && contains(subcommand.Aliases, "h") { + continue + } + return true + } + return false +} + +func isBuiltinCommand(root *cli.Command, cmdName string) bool { + for _, cmd := range root.Commands { if cmd.Category != "" { continue } diff --git a/pkg/apphelp/help_command_test.go b/pkg/apphelp/help_command_test.go index d25b3bf..38ab53c 100644 --- a/pkg/apphelp/help_command_test.go +++ b/pkg/apphelp/help_command_test.go @@ -14,7 +14,7 @@ import ( "github.com/akamai/cli/v2/pkg/terminal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func TestCmdHelp(t *testing.T) { @@ -43,9 +43,9 @@ Commands: test Global Flags: - --edgerc value, -e value edgerc config path passed to executed commands, defaults to ~/.edgerc - --section value, -s value edgerc section name passed to executed commands, defaults to 'default' - --help, -h show help (default: false)`, binarySuffix), + --edgerc string, -e string edgerc config path passed to executed commands, defaults to ~/.edgerc + --section string, -s string edgerc section name passed to executed commands, defaults to 'default' + --help, -h show help`, binarySuffix), }, "help for specific command": { @@ -73,12 +73,12 @@ Description: test command Command Flags: - --test-flag this is a test flag (default: false) - --help, -h show help (default: false) + --test-flag this is a test flag + --help, -h show help Global Flags: - --edgerc value, -e value edgerc config path passed to executed commands, defaults to ~/.edgerc - --section value, -s value edgerc section name passed to executed commands, defaults to 'default' + --edgerc string, -e string edgerc config path passed to executed commands, defaults to ~/.edgerc + --section string, -s string edgerc section name passed to executed commands, defaults to 'default' `, binarySuffix, binarySuffix), }, "help for specific command alias": { @@ -107,12 +107,12 @@ Description: test command Command Flags: - --test-flag this is a test flag (default: false) - --help, -h show help (default: false) + --test-flag this is a test flag + --help, -h show help Global Flags: - --edgerc value, -e value edgerc config path passed to executed commands, defaults to ~/.edgerc - --section value, -s value edgerc section name passed to executed commands, defaults to 'default' + --edgerc string, -e string edgerc config path passed to executed commands, defaults to ~/.edgerc + --section string, -s string edgerc section name passed to executed commands, defaults to 'default' `, binarySuffix, binarySuffix), }, @@ -123,7 +123,7 @@ Global Flags: Description: "test command", Category: "", Action: nil, - Subcommands: []*cli.Command{ + Commands: []*cli.Command{ { Name: "subcommand", Description: "a test subcommand without a category", @@ -147,12 +147,12 @@ Description: a test subcommand without a category Command Flags: - --test-flag value this is a test flag - --help, -h show help (default: false) + --test-flag string this is a test flag + --help, -h show help Global Flags: - --edgerc value, -e value edgerc config path passed to executed commands, defaults to ~/.edgerc - --section value, -s value edgerc section name passed to executed commands, defaults to 'default' + --edgerc string, -e string edgerc config path passed to executed commands, defaults to ~/.edgerc + --section string, -s string edgerc section name passed to executed commands, defaults to 'default' `, binarySuffix, binarySuffix), }, @@ -162,8 +162,8 @@ Global Flags: Name: "test", Description: "test command", Category: "", - Action: func(_ *cli.Context) error { fmt.Println("oops!"); return nil }, - Subcommands: []*cli.Command{ + Action: func(context.Context, *cli.Command) error { fmt.Println("oops!"); return nil }, + Commands: []*cli.Command{ { Name: "subcommand-no-category", Description: "a test subcommand without a category", @@ -205,11 +205,11 @@ category2: subcommand-in-category2 Command Flags: - --help, -h show help (default: false) + --help, -h show help Global Flags: - --edgerc value, -e value edgerc config path passed to executed commands, defaults to ~/.edgerc - --section value, -s value edgerc section name passed to executed commands, defaults to 'default' + --edgerc string, -e string edgerc config path passed to executed commands, defaults to ~/.edgerc + --section string, -s string edgerc section name passed to executed commands, defaults to 'default' `, binarySuffix, binarySuffix), }, @@ -220,8 +220,8 @@ Global Flags: Description: "test command", Category: "", ArgsUsage: "", - Action: func(_ *cli.Context) error { fmt.Println("oops!"); return nil }, - Subcommands: []*cli.Command{ + Action: func(context.Context, *cli.Command) error { fmt.Println("oops!"); return nil }, + Commands: []*cli.Command{ { Name: "subcommand-no-category", Description: "a test subcommand without a category", @@ -264,11 +264,11 @@ category2: subcommand-in-category2 Command Flags: - --help, -h show help (default: false) + --help, -h show help Global Flags: - --edgerc value, -e value edgerc config path passed to executed commands, defaults to ~/.edgerc - --section value, -s value edgerc section name passed to executed commands, defaults to 'default' + --edgerc string, -e string edgerc config path passed to executed commands, defaults to ~/.edgerc + --section string, -s string edgerc section name passed to executed commands, defaults to 'default' `, binarySuffix, binarySuffix, binarySuffix), }, @@ -278,8 +278,8 @@ Global Flags: Name: "test", Description: "test command", Category: "", - Action: func(_ *cli.Context) error { fmt.Println("oops!"); return nil }, - Subcommands: []*cli.Command{ + Action: func(context.Context, *cli.Command) error { fmt.Println("oops!"); return nil }, + Commands: []*cli.Command{ { Name: "subcommand-no-category", Description: "a test subcommand without a category", @@ -312,11 +312,11 @@ Description: a test subcommand without a category Command Flags: - --help, -h show help (default: false) + --help, -h show help Global Flags: - --edgerc value, -e value edgerc config path passed to executed commands, defaults to ~/.edgerc - --section value, -s value edgerc section name passed to executed commands, defaults to 'default' + --edgerc string, -e string edgerc config path passed to executed commands, defaults to ~/.edgerc + --section string, -s string edgerc section name passed to executed commands, defaults to 'default' `, binarySuffix, binarySuffix), }, @@ -326,8 +326,8 @@ Global Flags: Name: "test", Description: "test command", Category: "", - Action: func(_ *cli.Context) error { fmt.Println("oops!"); return nil }, - Subcommands: []*cli.Command{ + Action: func(context.Context, *cli.Command) error { fmt.Println("oops!"); return nil }, + Commands: []*cli.Command{ { Name: "subcommand-no-category", Description: "a test subcommand without a category", @@ -363,11 +363,11 @@ Description: a test subcommand in category 1 Command Flags: - --help, -h show help (default: false) + --help, -h show help Global Flags: - --edgerc value, -e value edgerc config path passed to executed commands, defaults to ~/.edgerc - --section value, -s value edgerc section name passed to executed commands, defaults to 'default' + --edgerc string, -e string edgerc config path passed to executed commands, defaults to ~/.edgerc + --section string, -s string edgerc section name passed to executed commands, defaults to 'default' `, binarySuffix, binarySuffix), }, @@ -397,8 +397,8 @@ Description: test command Command Flags: - --test-flag this is a test flag (default: false) - --help, -h show help (default: false) + --test-flag this is a test flag + --help, -h show help `, binarySuffix, binarySuffix), }, } @@ -416,27 +416,115 @@ Command Flags: }, } + originalOSArgs := os.Args + defer func() { os.Args = originalOSArgs }() + for name, test := range tests { appArgs := map[string][]string{ "command": append([]string{os.Args[0], "help"}, test.args...), "flag": append([]string{os.Args[0]}, append(test.args, "--help")...), } + ctx := terminal.Context(context.Background(), &terminal.Mock{}) + ctx = config.Context(ctx, &config.Mock{}) + + testApp := &cli.Command{} + testApp.Flags = globalFlags + Setup(testApp) + testApp.Commands = append(testApp.Commands, test.cmd) + for helpType, args := range appArgs { t.Run(fmt.Sprintf("%s - %s", name, helpType), func(t *testing.T) { wr := bytes.Buffer{} - ctx := terminal.Context(context.Background(), &terminal.Mock{}) - ctx = config.Context(ctx, &config.Mock{}) - - testApp := cli.NewApp() testApp.Writer = &wr - testApp.Flags = globalFlags - Setup(testApp) - testApp.Commands = append(testApp.Commands, test.cmd) + os.Args = originalOSArgs - err := testApp.RunContext(ctx, args) + err := testApp.Run(ctx, args) require.NoError(t, err) assert.Equal(t, strings.TrimPrefix(test.expectedOutput, "\n"), wr.String()) }) } } } + +func TestCmdHelpRunsExternalCommand(t *testing.T) { + output := &bytes.Buffer{} + var receivedArgs []string + + app := &cli.Command{ + Name: "akamai", + Writer: output, + } + Setup(app) + app.Commands = append(app.Commands, &cli.Command{ + Name: "dns", + Category: "Installed Commands:", + SkipFlagParsing: true, + HideHelpCommand: true, + Action: func(_ context.Context, cmd *cli.Command) error { + receivedArgs = cmd.Args().Slice() + _, err := fmt.Fprintln(cmd.Root().Writer, "plugin help") + return err + }, + }) + + err := app.Run(context.Background(), []string{"akamai", "help", "dns"}) + + require.NoError(t, err) + assert.Equal(t, []string{"help"}, receivedArgs) + assert.Equal(t, "plugin help\n", output.String()) +} + +func TestHelpFlagDoesNotRunCommand(t *testing.T) { + originalHelpFlag := cli.HelpFlag + cli.HelpFlag = &cli.BoolFlag{Name: "help", Aliases: []string{"h"}} + t.Cleanup(func() { cli.HelpFlag = originalHelpFlag }) + + for _, helpFlag := range []string{"--help", "-h"} { + t.Run(helpFlag, func(t *testing.T) { + output := &bytes.Buffer{} + actionRan := false + app := &cli.Command{Name: "akamai", Writer: output} + Setup(app) + app.Commands = append(app.Commands, &cli.Command{ + Name: "config", + HideHelpCommand: true, + Action: func(context.Context, *cli.Command) error { + actionRan = true + return nil + }, + }) + err := app.Run(context.Background(), []string{"akamai", "config", helpFlag}) + + require.NoError(t, err) + assert.False(t, actionRan) + assert.Contains(t, output.String(), "Usage:") + }) + } +} + +func TestHelpFlagDoesNotRunNestedCommand(t *testing.T) { + originalHelpFlag := cli.HelpFlag + cli.HelpFlag = &cli.BoolFlag{Name: "help", Aliases: []string{"h"}} + t.Cleanup(func() { cli.HelpFlag = originalHelpFlag }) + + output := &bytes.Buffer{} + actionRan := false + app := &cli.Command{Name: "akamai", Writer: output} + Setup(app) + app.Commands = append(app.Commands, &cli.Command{ + Name: "config", + HideHelpCommand: true, + Commands: []*cli.Command{{ + Name: "set", + Action: func(context.Context, *cli.Command) error { + actionRan = true + return nil + }, + }}, + }) + err := app.Run(context.Background(), []string{"akamai", "config", "set", "--help"}) + + require.NoError(t, err) + assert.False(t, actionRan) + assert.Contains(t, output.String(), "Usage:") +} diff --git a/pkg/apphelp/help_test.go b/pkg/apphelp/help_test.go index 014616e..ea652fb 100644 --- a/pkg/apphelp/help_test.go +++ b/pkg/apphelp/help_test.go @@ -4,11 +4,11 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func TestSetup(t *testing.T) { - testApp := cli.NewApp() + testApp := &cli.Command{} testApp.Flags = []cli.Flag{ &cli.BoolFlag{ Name: "test-flag", diff --git a/pkg/apphelp/templates/app_help.tmpl b/pkg/apphelp/templates/app_help.tmpl index 128cbaf..aaaf53e 100644 --- a/pkg/apphelp/templates/app_help.tmpl +++ b/pkg/apphelp/templates/app_help.tmpl @@ -3,7 +3,7 @@ {{- if .UsageText}} {{blue .UsageText}} {{- else}} - {{insertString .HelpName "[global flags]" 1 | blue}}{{if .Commands}} {{blue "command [command flags]"}}{{end}}{{if .ArgsUsage}} {{blue .ArgsUsage}}{{else}} {{blue "[arguments...]"}}{{end}} + {{insertString .FullName "[global flags]" 1 | blue}}{{if .Commands}} {{blue "command [command flags]"}}{{end}}{{if .ArgsUsage}} {{blue .ArgsUsage}}{{else}} {{blue "[arguments...]"}}{{end}} {{- end}} {{- if .Description}} diff --git a/pkg/apphelp/templates/command_help.tmpl b/pkg/apphelp/templates/command_help.tmpl index 7c464e1..1a29078 100644 --- a/pkg/apphelp/templates/command_help.tmpl +++ b/pkg/apphelp/templates/command_help.tmpl @@ -1,9 +1,9 @@ {{with .Command -}} {{yellow "Name:"}} - {{.HelpName}} + {{.FullName}} {{yellow "Usage:"}} - {{insertString .HelpName "[global flags]" 1 | blue}}{{if .VisibleFlags}} {{blue "[command flags]"}}{{end}} {{blue .ArgsUsage}} + {{insertString .FullName "[global flags]" 1 | blue}}{{if .VisibleFlags}} {{blue "[command flags]"}}{{end}} {{blue .ArgsUsage}} {{- if .Category}} diff --git a/pkg/apphelp/templates/simplified_command_help.tmpl b/pkg/apphelp/templates/simplified_command_help.tmpl index 20ecee6..f38a20f 100644 --- a/pkg/apphelp/templates/simplified_command_help.tmpl +++ b/pkg/apphelp/templates/simplified_command_help.tmpl @@ -1,9 +1,9 @@ {{with .Command -}} {{yellow "Name:"}} - {{.HelpName}} + {{.FullName}} {{yellow "Usage:"}} - {{blue .HelpName}}{{if .VisibleFlags}} {{blue "[command flags]"}}{{end}} + {{blue .FullName}}{{if .VisibleFlags}} {{blue "[command flags]"}}{{end}} {{- if .Category}} diff --git a/pkg/apphelp/templates/subcommand_help.tmpl b/pkg/apphelp/templates/subcommand_help.tmpl index 7d2cfab..bc0fb06 100644 --- a/pkg/apphelp/templates/subcommand_help.tmpl +++ b/pkg/apphelp/templates/subcommand_help.tmpl @@ -1,12 +1,12 @@ {{with .Command -}} {{yellow "Name:"}} - {{.HelpName}} + {{.FullName}} {{yellow "Usage:"}} {{- if .ArgsUsage}} - {{insertString .HelpName "[global flags]" 1 | blue}}{{if .VisibleFlags}} {{blue "[command flags]"}}{{end}} {{blue .ArgsUsage}} + {{insertString .FullName "[global flags]" 1 | blue}}{{if .VisibleFlags}} {{blue "[command flags]"}}{{end}} {{blue .ArgsUsage}} {{- end}} - {{insertString .HelpName "[global flags]" 1 | blue}}{{if .VisibleFlags}} {{blue "[command flags]"}}{{end}} {{blue ""}} + {{insertString .FullName "[global flags]" 1 | blue}}{{if .VisibleFlags}} {{blue "[command flags]"}}{{end}} {{blue ""}} {{- if .Description}} diff --git a/pkg/autocomplete/autocomplete.go b/pkg/autocomplete/autocomplete.go index d1cf4e2..55df342 100644 --- a/pkg/autocomplete/autocomplete.go +++ b/pkg/autocomplete/autocomplete.go @@ -2,56 +2,60 @@ package autocomplete import ( + "context" "fmt" "os" "strings" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) -// Default creates the default autocomplete -func Default(ctx *cli.Context) { - if ctx.Command.Name == "help" { - args := []string{"akamai"} - args = append(args, ctx.Args().Slice()...) - args = append(args, "--"+cli.BashCompletionFlag.Names()[0]) +// Default creates the default autocomplete. +func Default(_ context.Context, cmd *cli.Command) { + root := cmd.Root() + target := cmd - if err := ctx.App.RunContext(ctx.Context, args); err != nil { - _, _ = fmt.Fprintln(ctx.App.ErrWriter, err.Error()) + if cmd.Name == "help" { + target = root + for _, name := range cmd.Args().Slice() { + next := target.Command(name) + if next == nil { + break + } + target = next } - return } - commands := make([]*cli.Command, 0) - flags := make([]cli.Flag, 0) - - if ctx.Command.Name == "" { - commands = ctx.App.Commands - flags = ctx.App.VisibleFlags() - } else { - if len(ctx.Command.Subcommands) != 0 { - commands = ctx.Command.Subcommands + for _, command := range target.Commands { + if command.Hidden { + continue } - if len(ctx.Command.VisibleFlags()) != 0 { - flags = ctx.Command.VisibleFlags() - } + _, _ = fmt.Fprintln(root.Writer, command.Name) } - for _, command := range commands { - if command.Hidden { - continue + helpFlagName := "" + if cli.HelpFlag != nil { + if names := cli.HelpFlag.Names(); len(names) > 0 { + helpFlagName = names[0] } - - _, _ = fmt.Fprintln(ctx.App.Writer, command.Name) } + hideHelpFlagSuggestion := target != root && target.HideHelpCommand + + for _, flag := range target.VisibleFlags() { + flagNames := flag.Names() + if helpFlagName != "" && len(flagNames) > 0 && flagNames[0] == helpFlagName { + if hideHelpFlagSuggestion { + continue + } + flagNames = flagNames[:1] + } - for _, flag := range flags { nextFlag: - for _, name := range flag.Names() { + for _, name := range flagNames { name = strings.TrimSpace(name) - if len(cli.BashCompletionFlag.Names()) > 0 && name == cli.BashCompletionFlag.Names()[0] { + if len(cli.GenerateShellCompletionFlag.Names()) > 0 && name == cli.GenerateShellCompletionFlag.Names()[0] { continue } @@ -64,9 +68,9 @@ func Default(ctx *cli.Context) { switch len(name) { case 0: case 1: - _, _ = fmt.Fprintln(ctx.App.Writer, "-"+name) + _, _ = fmt.Fprintln(root.Writer, "-"+name) default: - _, _ = fmt.Fprintln(ctx.App.Writer, "--"+name) + _, _ = fmt.Fprintln(root.Writer, "--"+name) } } } diff --git a/pkg/autocomplete/autocomplete_test.go b/pkg/autocomplete/autocomplete_test.go index 54d83ac..be01c92 100644 --- a/pkg/autocomplete/autocomplete_test.go +++ b/pkg/autocomplete/autocomplete_test.go @@ -2,156 +2,208 @@ package autocomplete import ( "bytes" - "flag" + "context" + "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func TestComplete_root_cmd(t *testing.T) { outbuf := &bytes.Buffer{} - app := cli.NewApp() - app.Commands = []*cli.Command{ - { - Name: "test", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "flag", + app := &cli.Command{ + Name: "akamai", + EnableShellCompletion: true, + ShellComplete: Default, + HideHelp: true, + Writer: outbuf, + Commands: []*cli.Command{ + { + Name: "test", + ShellComplete: Default, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "flag", + }, }, }, - }, - { - Name: "test2", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "flag2", + { + Name: "test2", + ShellComplete: Default, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "flag2", + }, }, }, }, - } - app.Flags = []cli.Flag{ - &cli.BoolFlag{ - Name: "gflag1", - }, - &cli.StringFlag{ - Name: "gflag2", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "gflag1", + }, + &cli.StringFlag{ + Name: "gflag2", + }, }, } - app.Writer = outbuf - fs := flag.NewFlagSet("test", flag.PanicOnError) - ctx := cli.NewContext(app, fs, nil) - - Default(ctx) + complete(t, app, "akamai", "--generate-shell-completion") assert.Equal(t, "test\ntest2\n--gflag1\n--gflag2\n", outbuf.String()) } func TestComplete_subcommand(t *testing.T) { outbuf := &bytes.Buffer{} - app := cli.NewApp() - app.Writer = outbuf - - fs := flag.NewFlagSet("test", flag.PanicOnError) - ctx := cli.NewContext(app, fs, nil) - ctx.Command = &cli.Command{ - Name: "test", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "flag", - }, - }, - Subcommands: []*cli.Command{ + app := &cli.Command{ + Name: "akamai", + EnableShellCompletion: true, + HideHelp: true, + Writer: outbuf, + Commands: []*cli.Command{ { - Name: "sub1", + Name: "test", + ShellComplete: Default, Flags: []cli.Flag{ &cli.BoolFlag{ - Name: "sub1flag1", - }, - &cli.StringFlag{ - Name: "sub1flag2", + Name: "flag", }, }, - }, - { - Name: "sub2", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "sub2flag1", + Commands: []*cli.Command{ + { + Name: "sub1", }, - &cli.StringFlag{ - Name: "sub2flag2", + { + Name: "sub2", + Hidden: true, }, }, - Hidden: true, }, }, } - Default(ctx) + complete(t, app, "akamai", "test", "--generate-shell-completion") assert.Equal(t, "sub1\n--flag\n", outbuf.String()) } func TestComplete_short_flag(t *testing.T) { outbuf := &bytes.Buffer{} - app := cli.NewApp() - app.Writer = outbuf - - fs := flag.NewFlagSet("test", flag.PanicOnError) - ctx := cli.NewContext(app, fs, nil) - ctx.Command = &cli.Command{ - Name: "test", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "flag", - Aliases: []string{"f"}, + app := &cli.Command{ + Name: "akamai", + EnableShellCompletion: true, + HideHelp: true, + Writer: outbuf, + Commands: []*cli.Command{ + { + Name: "test", + ShellComplete: Default, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "flag", + Aliases: []string{"f"}, + }, + }, }, }, } - Default(ctx) + complete(t, app, "akamai", "test", "--generate-shell-completion") assert.Equal(t, "--flag\n-f\n", outbuf.String()) } -func TestComplete_help_command(t *testing.T) { +func TestComplete_hides_help_flag_for_hidden_help_command(t *testing.T) { + originalHelpFlag := cli.HelpFlag + cli.HelpFlag = &cli.BoolFlag{Name: "help", Aliases: []string{"h"}} + t.Cleanup(func() { cli.HelpFlag = originalHelpFlag }) + outbuf := &bytes.Buffer{} - app := cli.NewApp() - app.Writer = outbuf - app.EnableBashCompletion = true - app.BashComplete = Default - - app.Commands = []*cli.Command{ - { - Name: "help", - BashComplete: Default, - }, - { - Name: "test", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "flag", - Aliases: []string{"f"}, + app := &cli.Command{ + Name: "akamai", + EnableShellCompletion: true, + ShellComplete: Default, + Writer: outbuf, + Commands: []*cli.Command{ + { + Name: "config", + HideHelpCommand: true, + ShellComplete: Default, + Flags: []cli.Flag{ + cli.HelpFlag, + &cli.StringFlag{Name: "setting"}, }, }, - HideHelp: true, - BashComplete: Default, }, - { - Name: "test2", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "flag2", + } + + complete(t, app, "akamai", "config", "--generate-shell-completion") + + assert.Equal(t, "--setting\n", outbuf.String()) +} + +func TestComplete_root_omits_help_alias(t *testing.T) { + originalHelpFlag := cli.HelpFlag + cli.HelpFlag = &cli.BoolFlag{Name: "help", Aliases: []string{"h"}} + t.Cleanup(func() { cli.HelpFlag = originalHelpFlag }) + + outbuf := &bytes.Buffer{} + app := &cli.Command{ + Name: "akamai", + EnableShellCompletion: true, + ShellComplete: Default, + Writer: outbuf, + Flags: []cli.Flag{cli.HelpFlag}, + } + + complete(t, app, "akamai", "--generate-shell-completion") + + assert.Equal(t, "help\n--help\n", outbuf.String()) +} + +func TestComplete_help_command(t *testing.T) { + outbuf := &bytes.Buffer{} + app := &cli.Command{ + Name: "akamai", + EnableShellCompletion: true, + HideHelp: true, + Writer: outbuf, + Commands: []*cli.Command{ + { + Name: "help", + ShellComplete: Default, + }, + { + Name: "test", + ShellComplete: Default, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "flag", + Aliases: []string{"f"}, + }, }, + HideHelp: true, + }, + { + Name: "test2", }, }, } - err := app.Run([]string{"akamai", "help", "test", "--generate-bash-completion"}) - require.NoError(t, err) + complete(t, app, "akamai", "help", "test", "--generate-shell-completion") assert.Equal(t, "--flag\n-f\n", outbuf.String()) } + +func complete(t *testing.T, cmd *cli.Command, args ...string) { + t.Helper() + + previousArgs := os.Args + os.Args = args + t.Cleanup(func() { + os.Args = previousArgs + }) + + require.NoError(t, cmd.Run(context.Background(), args)) +} diff --git a/pkg/commands/command.go b/pkg/commands/command.go index 7697b44..7ec3f4f 100644 --- a/pkg/commands/command.go +++ b/pkg/commands/command.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "os/exec" "path/filepath" @@ -15,10 +16,12 @@ import ( "github.com/akamai/cli/v2/pkg/autocomplete" "github.com/akamai/cli/v2/pkg/color" "github.com/akamai/cli/v2/pkg/git" + "github.com/akamai/cli/v2/pkg/log" "github.com/akamai/cli/v2/pkg/packages" + "github.com/akamai/cli/v2/pkg/terminal" "github.com/akamai/cli/v2/pkg/tools" "github.com/akamai/cli/v2/pkg/version" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -54,22 +57,22 @@ type ( } ) -func getBuiltinCommands(c *cli.Context) []subcommands { +func getBuiltinCommands(cmd *cli.Command) []subcommands { commands := make([]subcommands, 0) - for _, cmd := range c.App.Commands { + for _, subcommand := range cmd.Root().Commands { // builtin commands do not have Category set - if cmd.Category != "" { + if subcommand.Category != "" { continue } - commands = append(commands, cliCommandToSubcommand(cmd)) + commands = append(commands, cliCommandToSubcommand(subcommand)) } return commands } -func getCommands(c *cli.Context) []subcommands { +func getCommands(cmd *cli.Command) []subcommands { commands := make([]subcommands, 0) - for _, cmd := range c.App.Commands { - commands = append(commands, cliCommandToSubcommand(cmd)) + for _, subcommand := range cmd.Root().Commands { + commands = append(commands, cliCommandToSubcommand(subcommand)) } return commands } @@ -85,7 +88,7 @@ func cliCommandToSubcommand(from *cli.Command) subcommands { Arguments: from.ArgsUsage, Flags: from.Flags, Docs: from.UsageText, - Subcommands: from.Subcommands, + Subcommands: from.Commands, }, }, Action: from.Action, @@ -107,9 +110,10 @@ func subcommandToCliCommands(from subcommands, gitRepo git.Repository, langManag Action: cmdSubcommand(gitRepo, langManager), Category: color.YellowString("Installed Commands:"), SkipFlagParsing: true, - BashComplete: func(c *cli.Context) { + HideHelpCommand: true, + ShellComplete: func(ctx context.Context, completionCommand *cli.Command) { if command.AutoComplete { - executable, packageReqs, err := findExec(c.Context, langManager, c.Command.Name) + executable, packageReqs, err := findExec(ctx, langManager, completionCommand.Name) if err != nil { return } @@ -121,7 +125,7 @@ func subcommandToCliCommands(from subcommands, gitRepo git.Repository, langManag executable = append(executable, os.Args[2:]...) subCmd := createCommand(executable[0], executable[1:]) - if err = passthruCommand(c.Context, subCmd, langManager, *packageReqs, packageDir); err != nil { + if err = passthruCommand(ctx, subCmd, langManager, *packageReqs, packageDir); err != nil { return } } @@ -157,7 +161,7 @@ func createBuiltinCommands() []*cli.Command { Name: "config", ArgsUsage: " [value]", Description: "Manages configuration.", - Subcommands: []*cli.Command{ + Commands: []*cli.Command{ { Name: "get", ArgsUsage: "", @@ -180,8 +184,8 @@ func createBuiltinCommands() []*cli.Command { Action: cmdConfigUnset, }, }, - HideHelp: true, - BashComplete: autocomplete.Default, + HideHelpCommand: true, + ShellComplete: autocomplete.Default, }, { Name: "install", @@ -194,8 +198,8 @@ func createBuiltinCommands() []*cli.Command { "akamai install akamai/cli-property", "akamai install git@github.com:akamai/cli-property.git", "akamai install https://github.com/akamai/cli-property.git"), - HideHelp: true, - BashComplete: autocomplete.Default, + HideHelpCommand: true, + ShellComplete: autocomplete.Default, }, { Name: "list", @@ -207,64 +211,81 @@ func createBuiltinCommands() []*cli.Command { Usage: "Displays all available packages.", }, }, - HideHelp: true, - BashComplete: autocomplete.Default, + HideHelpCommand: true, + ShellComplete: autocomplete.Default, CustomHelpTemplate: apphelp.SimplifiedHelpTemplate, }, { - Name: "search", - ArgsUsage: "...", - Description: "Searches for packages in the official Akamai CLI package repository.", - Action: cmdSearch, - UsageText: "Examples:\n\n akamai search property", - HideHelp: true, - BashComplete: autocomplete.Default, + Name: "search", + ArgsUsage: "...", + Description: "Searches for packages in the official Akamai CLI package repository.", + Action: cmdSearch, + UsageText: "Examples:\n\n akamai search property", + HideHelpCommand: true, + ShellComplete: autocomplete.Default, }, { - Name: "uninstall", - ArgsUsage: "...", - Description: "Uninstalls a package containing a given .", - Action: cmdUninstall(langManager), - HideHelp: true, - BashComplete: autocomplete.Default, + Name: "uninstall", + ArgsUsage: "...", + Description: "Uninstalls a package containing a given .", + Action: cmdUninstall(langManager), + HideHelpCommand: true, + ShellComplete: autocomplete.Default, }, { - Name: "update", - ArgsUsage: "[...]", - Description: "Updates one or more commands. If no command is specified, all commands are updated.", - Action: cmdUpdate(gitRepo, langManager), - HideHelp: true, - BashComplete: autocomplete.Default, + Name: "update", + ArgsUsage: "[...]", + Description: "Updates one or more commands. If no command is specified, all commands are updated.", + Action: cmdUpdate(gitRepo, langManager), + HideHelpCommand: true, + ShellComplete: autocomplete.Default, }, { - Name: "upgrade", - Description: "Upgrades the Akamai CLI to the latest version.", - Action: cmdUpgrade, - BashComplete: autocomplete.Default, + Name: "upgrade", + Description: "Upgrades the Akamai CLI to the latest version.", + Action: cmdUpgrade, + ShellComplete: autocomplete.Default, }, } } -func createInstalledCommands(_ context.Context, gitRepo git.Repository, langManager packages.LangManager) []*cli.Command { +func createInstalledCommands(ctx context.Context, gitRepo git.Repository, langManager packages.LangManager) []*cli.Command { + logger := log.FromContext(ctx) commands := make([]*cli.Command, 0) packagePaths := getPackagePaths() for _, dir := range packagePaths { - pkg, err := readPackage(dir) - if err == nil { - commands = append(commands, subcommandToCliCommands(pkg, gitRepo, langManager)...) + pkg, invalidCommands, err := readInstalledPackageWithDiagnostics(dir) + if err != nil { + warnInstalledPackage(ctx, logger, fmt.Sprintf("Skipping installed package in %q: %v", dir, err)) + continue + } + for _, err := range invalidCommands { + warnInstalledPackage(ctx, logger, fmt.Sprintf("Skipping command in installed package %q: %v", dir, err)) } + commands = append(commands, subcommandToCliCommands(pkg, gitRepo, langManager)...) } return commands } +func warnInstalledPackage(ctx context.Context, logger *slog.Logger, message string) { + logger.Warn(message) + term := terminal.Get(ctx) + if _, err := term.Writeln(color.YellowString("%s", message)); err != nil { + logger.Error("Unable to display installed package warning", "error", err) + } +} + // findExec returns paths to language interpreter (if necessary) and package binary func findExec(ctx context.Context, langManager packages.LangManager, cmd string) ([]string, *packages.LanguageRequirements, error) { + cmdName, err := tools.CommandBinaryName(cmd) + if err != nil { + return nil, nil, err + } + // "command" becomes: akamai-command, and akamaiCommand // "command-name" becomes: akamai-command-name, and akamaiCommandName - cmdName := "akamai" cmdNameTitle := "akamai" for _, cmdPart := range strings.Split(cmd, "-") { - cmdName += "-" + strings.ToLower(cmdPart) cmdNameTitle += cases.Title(language.Und, cases.NoLower).String(strings.ToLower(cmdPart)) } @@ -276,7 +297,7 @@ func findExec(ctx context.Context, langManager packages.LangManager, cmd string) // Quick look for executables on the path var path string - path, err := exec.LookPath(cmdName) + path, err = exec.LookPath(cmdName) if err != nil { path, _ = exec.LookPath(cmdNameTitle) } @@ -322,7 +343,7 @@ func findExec(ctx context.Context, langManager packages.LangManager, cmd string) cmdBinary := files[0] packageDir := findPackageDir(filepath.Dir(cmdBinary)) - cmdPackage, err := readPackage(packageDir) + cmdPackage, err := readInstalledPackage(packageDir) if err != nil { return nil, nil, err } diff --git a/pkg/commands/command_config.go b/pkg/commands/command_config.go index 9ee0a8b..c29a735 100644 --- a/pkg/commands/command_config.go +++ b/pkg/commands/command_config.go @@ -1,6 +1,7 @@ package commands import ( + "context" "fmt" "strings" "time" @@ -9,12 +10,12 @@ import ( "github.com/akamai/cli/v2/pkg/config" "github.com/akamai/cli/v2/pkg/log" "github.com/akamai/cli/v2/pkg/terminal" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) -func cmdConfigSet(c *cli.Context) (e error) { - c.Context = log.WithCommandContext(c.Context, c.Command.Name) - logger := log.FromContext(c.Context) +func cmdConfigSet(ctx context.Context, cmd *cli.Command) (e error) { + ctx = log.WithCommandContext(ctx, cmd.Name) + logger := log.FromContext(ctx) start := time.Now() logger.Debug("CONFIG SET START") defer func() { @@ -24,17 +25,17 @@ func cmdConfigSet(c *cli.Context) (e error) { logger.Error(fmt.Sprintf("CONFIG SET ERROR: %v", e)) } }() - cfg := config.Get(c.Context) + cfg := config.Get(ctx) - section, key, err := parseConfigPath(c) + section, key, err := parseConfigPath(cmd) if err != nil { logger.Error(fmt.Sprintf("Error parsing config path: %v", err)) return cli.Exit(color.RedString("Unable to set config value: %v", err), 1) } - value := strings.Join(c.Args().Tail(), " ") + value := strings.Join(cmd.Args().Tail(), " ") cfg.SetValue(section, key, value) - if err := cfg.Save(c.Context); err != nil { + if err := cfg.Save(ctx); err != nil { logger.Error(fmt.Sprintf("Error saving config: %v", err)) return cli.Exit(color.RedString("Unable to set config value: %v", err), 1) } @@ -42,9 +43,9 @@ func cmdConfigSet(c *cli.Context) (e error) { return nil } -func cmdConfigGet(c *cli.Context) (e error) { - c.Context = log.WithCommandContext(c.Context, c.Command.Name) - logger := log.FromContext(c.Context) +func cmdConfigGet(ctx context.Context, cmd *cli.Command) (e error) { + ctx = log.WithCommandContext(ctx, cmd.Name) + logger := log.FromContext(ctx) start := time.Now() logger.Debug("CONFIG GET START") defer func() { @@ -54,16 +55,16 @@ func cmdConfigGet(c *cli.Context) (e error) { logger.Error(fmt.Sprintf("CONFIG GET ERROR: %v", e)) } }() - cfg := config.Get(c.Context) + cfg := config.Get(ctx) - section, key, err := parseConfigPath(c) + section, key, err := parseConfigPath(cmd) if err != nil { logger.Error(fmt.Sprintf("Error parsing config path: %v", err)) return cli.Exit(color.RedString("Unable to get config value: %v", err), 1) } val, _ := cfg.GetValue(section, key) - if _, err := terminal.Get(c.Context).Writeln(val); err != nil { + if _, err := terminal.Get(ctx).Writeln(val); err != nil { return err } logger.Debug(val) @@ -71,9 +72,9 @@ func cmdConfigGet(c *cli.Context) (e error) { return nil } -func cmdConfigUnset(c *cli.Context) (e error) { - c.Context = log.WithCommandContext(c.Context, c.Command.Name) - logger := log.FromContext(c.Context) +func cmdConfigUnset(ctx context.Context, cmd *cli.Command) (e error) { + ctx = log.WithCommandContext(ctx, cmd.Name) + logger := log.FromContext(ctx) start := time.Now() logger.Debug("CONFIG UNSET START") defer func() { @@ -83,24 +84,24 @@ func cmdConfigUnset(c *cli.Context) (e error) { logger.Error(fmt.Sprintf("CONFIG UNSET ERROR: %v", e)) } }() - cfg := config.Get(c.Context) - section, key, err := parseConfigPath(c) + cfg := config.Get(ctx) + section, key, err := parseConfigPath(cmd) if err != nil { logger.Error(fmt.Sprintf("Error parsing config path: %v", err)) return cli.Exit(color.RedString("Unable to unset config value: %v", err), 1) } cfg.UnsetValue(section, key) - if err := cfg.Save(c.Context); err != nil { + if err := cfg.Save(ctx); err != nil { logger.Error(fmt.Sprintf("Error saving config: %v", err)) return cli.Exit(color.RedString("Unable to set config value: %v", err), 1) } return nil } -func cmdConfigList(c *cli.Context) (e error) { - c.Context = log.WithCommandContext(c.Context, c.Command.Name) - logger := log.FromContext(c.Context) +func cmdConfigList(ctx context.Context, cmd *cli.Command) (e error) { + ctx = log.WithCommandContext(ctx, cmd.Name) + logger := log.FromContext(ctx) start := time.Now() logger.Debug("CONFIG LIST START") defer func() { @@ -110,12 +111,12 @@ func cmdConfigList(c *cli.Context) (e error) { logger.Error(fmt.Sprintf("CONFIG LIST ERROR: %v", e)) } }() - cfg := config.Get(c.Context) - term := terminal.Get(c.Context) + cfg := config.Get(ctx) + term := terminal.Get(ctx) allValues := cfg.Values() - if c.NArg() > 0 { - sectionName := c.Args().First() + if cmd.Args().Present() { + sectionName := cmd.Args().First() section, ok := allValues[sectionName] if !ok { return nil @@ -135,8 +136,8 @@ func cmdConfigList(c *cli.Context) (e error) { return nil } -func parseConfigPath(c *cli.Context) (string, string, error) { - path := strings.Split(c.Args().First(), ".") +func parseConfigPath(cmd *cli.Command) (string, string, error) { + path := strings.Split(cmd.Args().First(), ".") if len(path) < 2 { return "", "", fmt.Errorf("section key has to be provided in
. format") } diff --git a/pkg/commands/command_config_test.go b/pkg/commands/command_config_test.go index ff29e0c..f1e5152 100644 --- a/pkg/commands/command_config_test.go +++ b/pkg/commands/command_config_test.go @@ -9,7 +9,7 @@ import ( "github.com/akamai/cli/v2/pkg/terminal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func TestCmdConfigSet(t *testing.T) { @@ -45,7 +45,7 @@ func TestCmdConfigSet(t *testing.T) { m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil, nil} command := &cli.Command{ Name: "config", - Subcommands: []*cli.Command{ + Commands: []*cli.Command{ { Name: "set", Action: cmdConfigSet, @@ -58,7 +58,7 @@ func TestCmdConfigSet(t *testing.T) { args = append(args, test.args...) test.init(m.cfg) - err := app.RunContext(ctx, args) + err := app.Run(ctx, args) m.cfg.AssertExpectations(t) if test.withError != "" { @@ -97,7 +97,7 @@ func TestCmdConfigGet(t *testing.T) { m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil, nil} command := &cli.Command{ Name: "config", - Subcommands: []*cli.Command{ + Commands: []*cli.Command{ { Name: "get", Action: cmdConfigGet, @@ -110,7 +110,7 @@ func TestCmdConfigGet(t *testing.T) { args = append(args, test.args...) test.init(m) - err := app.RunContext(ctx, args) + err := app.Run(ctx, args) m.cfg.AssertExpectations(t) m.term.AssertExpectations(t) @@ -157,7 +157,7 @@ func TestCmdConfigUnset(t *testing.T) { m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil, nil} command := &cli.Command{ Name: "config", - Subcommands: []*cli.Command{ + Commands: []*cli.Command{ { Name: "unset", Action: cmdConfigUnset, @@ -170,7 +170,7 @@ func TestCmdConfigUnset(t *testing.T) { args = append(args, test.args...) test.init(m.cfg) - err := app.RunContext(ctx, args) + err := app.Run(ctx, args) m.cfg.AssertExpectations(t) if test.withError != "" { @@ -227,7 +227,7 @@ func TestCmdConfigList(t *testing.T) { m := &mocked{&terminal.Mock{}, &config.Mock{}, nil, nil, nil} command := &cli.Command{ Name: "config", - Subcommands: []*cli.Command{ + Commands: []*cli.Command{ { Name: "list", Action: cmdConfigList, @@ -240,7 +240,7 @@ func TestCmdConfigList(t *testing.T) { args = append(args, test.args...) test.init(m) - err := app.RunContext(ctx, args) + err := app.Run(ctx, args) m.cfg.AssertExpectations(t) if test.withError != "" { diff --git a/pkg/commands/command_install.go b/pkg/commands/command_install.go index ec8cda6..67be4f6 100644 --- a/pkg/commands/command_install.go +++ b/pkg/commands/command_install.go @@ -17,7 +17,7 @@ import ( "github.com/akamai/cli/v2/pkg/packages" "github.com/akamai/cli/v2/pkg/terminal" "github.com/akamai/cli/v2/pkg/tools" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -31,10 +31,10 @@ var buildRawGitHubURL = func(owner, repo, branch string) string { } func cmdInstall(git git.Repository, langManager packages.LangManager) cli.ActionFunc { - return func(c *cli.Context) (e error) { + return func(ctx context.Context, cmd *cli.Command) (e error) { start := time.Now() - c.Context = log.WithCommandContext(c.Context, c.Command.Name) - logger := log.FromContext(c.Context) + ctx = log.WithCommandContext(ctx, cmd.Name) + logger := log.FromContext(ctx) logger.Debug("INSTALL START") defer func() { if e == nil { @@ -48,13 +48,14 @@ func cmdInstall(git git.Repository, langManager packages.LangManager) cli.Action } } }() - if !c.Args().Present() { + if !cmd.Args().Present() { return cli.Exit(color.RedString("You must specify a repository URL"), 1) } - oldCmds := getCommands(c) + root := cmd.Root() + oldCmds := getCommands(root) - for _, repo := range c.Args().Slice() { + for _, repo := range cmd.Args().Slice() { repo = strings.TrimSpace(repo) if repo == "" { @@ -65,26 +66,26 @@ func cmdInstall(git git.Repository, langManager packages.LangManager) cli.Action repo = tools.Githubize(repo) } - subCmd, err := installPackage(c.Context, git, langManager, repo) + subCmd, err := installPackage(ctx, git, langManager, repo) if err != nil { logger.Error(fmt.Sprintf("Error installing package: %v", err)) return err } - c.App.Commands = append(c.App.Commands, subcommandToCliCommands(*subCmd, git, langManager)...) - sortCommands(c.App.Commands) + root.Commands = append(root.Commands, subcommandToCliCommands(*subCmd, git, langManager)...) + sortCommands(root.Commands) } - packageListDiff(c, oldCmds) + packageListDiff(ctx, cmd, oldCmds) return nil } } -func packageListDiff(c *cli.Context, oldcmds []subcommands) { - cmds := getCommands(c) +func packageListDiff(ctx context.Context, cmd *cli.Command, oldCmds []subcommands) { + cmds := getCommands(cmd) var old []command - for _, oldcmd := range oldcmds { + for _, oldcmd := range oldCmds { old = append(old, oldcmd.Commands...) } @@ -124,7 +125,7 @@ func packageListDiff(c *cli.Context, oldcmds []subcommands) { } } - listInstalledCommands(c, added, removed) + listInstalledCommands(ctx, cmd, added, removed) } func installPackage(ctx context.Context, gitRepo git.Repository, langManager packages.LangManager, repo string) (*subcommands, error) { diff --git a/pkg/commands/command_install_test.go b/pkg/commands/command_install_test.go index 44cc020..76ab3a3 100644 --- a/pkg/commands/command_install_test.go +++ b/pkg/commands/command_install_test.go @@ -19,7 +19,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func TestCmdInstall(t *testing.T) { @@ -32,13 +32,15 @@ func TestCmdInstall(t *testing.T) { cliTestInvalidJSONRepo := filepath.Join(".", "testdata", ".akamai-cli", "src", "cli-test-invalid-json") tests := map[string]struct { args []string + expectedInstalledCmd string init func(*testing.T, *mocked) teardown func(*testing.T) binaryResponseStatus int withError string }{ "install from official akamai repository, build from source": { - args: []string{"test-cmd"}, + args: []string{"test-cmd"}, + expectedInstalledCmd: "app-1-cmd-1", init: func(t *testing.T, m *mocked) { m.term.On("Spinner").Return(m.term).Once() @@ -733,7 +735,7 @@ func TestCmdInstall(t *testing.T) { if test.teardown != nil { defer test.teardown(t) } - err := app.RunContext(ctx, args) + err := app.Run(ctx, args) m.cfg.AssertExpectations(t) if test.withError != "" { @@ -742,6 +744,9 @@ func TestCmdInstall(t *testing.T) { return } require.NoError(t, err) + if test.expectedInstalledCmd != "" { + require.NotNil(t, app.Command(test.expectedInstalledCmd)) + } }) } } diff --git a/pkg/commands/command_list.go b/pkg/commands/command_list.go index bdadc00..37da28e 100644 --- a/pkg/commands/command_list.go +++ b/pkg/commands/command_list.go @@ -1,6 +1,7 @@ package commands import ( + "context" "fmt" "time" @@ -8,18 +9,18 @@ import ( "github.com/akamai/cli/v2/pkg/log" "github.com/akamai/cli/v2/pkg/terminal" "github.com/akamai/cli/v2/pkg/tools" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) -func cmdList(c *cli.Context) (e error) { +func cmdList(ctx context.Context, cmd *cli.Command) (e error) { pr := newPackageReader(embeddedPackages) - return cmdListWithPackageReader(c, pr) + return cmdListWithPackageReader(ctx, cmd, pr) } -func cmdListWithPackageReader(c *cli.Context, pr packageReader) (e error) { - c.Context = log.WithCommandContext(c.Context, c.Command.Name) +func cmdListWithPackageReader(ctx context.Context, cmd *cli.Command, pr packageReader) (e error) { + ctx = log.WithCommandContext(ctx, cmd.Name) start := time.Now() - logger := log.FromContext(c.Context) + logger := log.FromContext(ctx) logger.Debug("LIST START") defer func() { if e == nil { @@ -28,11 +29,11 @@ func cmdListWithPackageReader(c *cli.Context, pr packageReader) (e error) { logger.Error(fmt.Sprintf("LIST ERROR: %v", e)) } }() - term := terminal.Get(c.Context) + term := terminal.Get(ctx) - commands := listInstalledCommands(c, nil, nil) + commands := listInstalledCommands(ctx, cmd, nil, nil) - if c.IsSet("remote") { + if cmd.IsSet("remote") { packages, err := pr.readPackage() if err != nil { logger.Error(fmt.Sprintf("Failed to read package: %v", err)) @@ -83,8 +84,8 @@ func cmdListWithPackageReader(c *cli.Context, pr packageReader) (e error) { return nil } -func listInstalledCommands(c *cli.Context, added map[string]bool, removed map[string]bool) map[string]bool { - term := terminal.Get(c.Context) +func listInstalledCommands(ctx context.Context, cmd *cli.Command, added map[string]bool, removed map[string]bool) map[string]bool { + term := terminal.Get(ctx) commands := make(map[string]bool) installedCmds := color.YellowString("\nInstalled Commands:\n") @@ -93,46 +94,47 @@ func listInstalledCommands(c *cli.Context, added map[string]bool, removed map[st return nil } - cmds := getCommands(c) - for _, cmd := range cmds { - for _, command := range cmd.Commands { - commands[command.Name] = true - if _, ok := added[command.Name]; ok { - term.Printf(color.GreenString(" %s", command.Name)) - } else if _, ok := removed[command.Name]; ok { - term.Printf(color.RedString(" %s", command.Name)) - } else { - term.Printf(color.BoldString(" %s", command.Name)) - } + for _, command := range cmd.Root().Commands { + if command.Hidden { + continue + } - if len(command.Aliases) > 0 { - var aliases string + commands[command.Name] = true + if _, ok := added[command.Name]; ok { + term.Printf(color.GreenString(" %s", command.Name)) + } else if _, ok := removed[command.Name]; ok { + term.Printf(color.RedString(" %s", command.Name)) + } else { + term.Printf(color.BoldString(" %s", command.Name)) + } - if len(command.Aliases) == 1 { - aliases = "alias" - } else { - aliases = "aliases" - } + if len(command.Aliases) > 0 { + var aliases string - term.Printf(" (%s: ", aliases) - for i, alias := range command.Aliases { - term.Printf(color.BoldString("%s", alias)) - if i < len(command.Aliases)-1 { - term.Printf(", ") - } - } - term.Printf(")") + if len(command.Aliases) == 1 { + aliases = "alias" + } else { + aliases = "aliases" } - if _, err := term.Writeln(); err != nil { - term.WriteError(err.Error()) - return nil + term.Printf(" (%s: ", aliases) + for i, alias := range command.Aliases { + term.Printf(color.BoldString("%s", alias)) + if i < len(command.Aliases)-1 { + term.Printf(", ") + } } + term.Printf(")") + } - if len(command.Description) > 0 { - cmdDescription := fmt.Sprintf(" %s\n", command.Description) - term.Printf(cmdDescription) - } + if _, err := term.Writeln(); err != nil { + term.WriteError(err.Error()) + return nil + } + + if len(command.Description) > 0 { + cmdDescription := fmt.Sprintf(" %s\n", command.Description) + term.Printf(cmdDescription) } } term.Printf("\nSee \"%s\" for details.\n", color.BlueString("%s help [command]", tools.Self())) diff --git a/pkg/commands/command_list_test.go b/pkg/commands/command_list_test.go index 6d461d5..a9bdb34 100644 --- a/pkg/commands/command_list_test.go +++ b/pkg/commands/command_list_test.go @@ -1,6 +1,7 @@ package commands import ( + "context" "fmt" "os" "testing" @@ -11,7 +12,7 @@ import ( "github.com/akamai/cli/v2/pkg/tools" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func TestCmdListWithRemote(t *testing.T) { @@ -99,18 +100,24 @@ func TestCmdListWithRemote(t *testing.T) { }, Description: "Displays available commands", Aliases: []string{"ls", "show"}, - Action: func(context *cli.Context) error { - return cmdListWithPackageReader(context, pr) + Action: func(ctx context.Context, cmd *cli.Command) error { + return cmdListWithPackageReader(ctx, cmd, pr) }, } app, ctx := setupTestApp(commandToExecute, m) + app.Commands = append(app.Commands, &cli.Command{ + Name: "internal-helper", + Description: "Internal helper command", + Hidden: true, + }) args := os.Args[0:1] args = append(args, "list", "--remote") test.init(m) - err := app.RunContext(ctx, args) + err := app.Run(ctx, args) + m.term.AssertNotCalled(t, "Printf", color.BoldString(" internal-helper"), []interface{}(nil)) m.cfg.AssertExpectations(t) m.term.AssertExpectations(t) if test.withError != "" { diff --git a/pkg/commands/command_search.go b/pkg/commands/command_search.go index 36bdb79..1faf296 100644 --- a/pkg/commands/command_search.go +++ b/pkg/commands/command_search.go @@ -18,22 +18,22 @@ import ( "github.com/akamai/cli/v2/pkg/log" "github.com/akamai/cli/v2/pkg/terminal" "github.com/akamai/cli/v2/pkg/tools" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) var ( githubURLTemplate = "https://raw.githubusercontent.com/akamai/%s/master/cli.json" ) -func cmdSearch(c *cli.Context) (e error) { +func cmdSearch(ctx context.Context, cmd *cli.Command) (e error) { pr := newPackageReader(embeddedPackages) - return cmdSearchWithPackageReader(c, pr) + return cmdSearchWithPackageReader(ctx, cmd, pr) } -func cmdSearchWithPackageReader(c *cli.Context, pr packageReader) (e error) { - c.Context = log.WithCommandContext(c.Context, c.Command.Name) +func cmdSearchWithPackageReader(ctx context.Context, cmd *cli.Command, pr packageReader) (e error) { + ctx = log.WithCommandContext(ctx, cmd.Name) start := time.Now() - logger := log.FromContext(c.Context) + logger := log.FromContext(ctx) logger.Debug("SEARCH START") defer func() { if e == nil { @@ -42,7 +42,7 @@ func cmdSearchWithPackageReader(c *cli.Context, pr packageReader) (e error) { logger.Error(fmt.Sprintf("SEARCH ERROR: %v", e)) } }() - if !c.Args().Present() { + if !cmd.Args().Present() { logger.Error("No keywords specified") return cli.Exit(color.RedString("You must specify one or more keywords"), 1) } @@ -53,7 +53,7 @@ func cmdSearchWithPackageReader(c *cli.Context, pr packageReader) (e error) { return cli.Exit(color.RedString("%s", err.Error()), 1) } - err = searchPackages(c.Context, c.Args().Slice(), packages) + err = searchPackages(ctx, cmd.Args().Slice(), packages) if err != nil { logger.Error(fmt.Sprintf("Failed to search packages: %v", err)) return cli.Exit(color.RedString("%s", err.Error()), 1) diff --git a/pkg/commands/command_search_test.go b/pkg/commands/command_search_test.go index 5561223..3efd108 100644 --- a/pkg/commands/command_search_test.go +++ b/pkg/commands/command_search_test.go @@ -1,6 +1,7 @@ package commands import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -16,7 +17,7 @@ import ( "github.com/akamai/cli/v2/pkg/tools" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func TestCmdSearch(t *testing.T) { @@ -225,8 +226,8 @@ func TestCmdSearch(t *testing.T) { commandToExecute := &cli.Command{ Name: "search", - Action: func(context *cli.Context) error { - return cmdSearchWithPackageReader(context, pr) + Action: func(context context.Context, cmd *cli.Command) error { + return cmdSearchWithPackageReader(context, cmd, pr) }, } @@ -236,7 +237,7 @@ func TestCmdSearch(t *testing.T) { args = append(args, test.args...) test.init(m) - err := app.RunContext(ctx, args) + err := app.Run(ctx, args) m.cfg.AssertExpectations(t) m.term.AssertExpectations(t) diff --git a/pkg/commands/command_subcommand.go b/pkg/commands/command_subcommand.go index 558eacc..4a3ec79 100644 --- a/pkg/commands/command_subcommand.go +++ b/pkg/commands/command_subcommand.go @@ -1,6 +1,7 @@ package commands import ( + "context" "fmt" "os" "path/filepath" @@ -12,14 +13,14 @@ import ( "github.com/akamai/cli/v2/pkg/log" "github.com/akamai/cli/v2/pkg/packages" "github.com/akamai/cli/v2/pkg/terminal" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func cmdSubcommand(git git.Repository, langManager packages.LangManager) cli.ActionFunc { - return func(c *cli.Context) (e error) { - c.Context = log.WithCommandContext(c.Context, c.Command.Name) - logger := log.FromContext(c.Context) - term := terminal.Get(c.Context) + return func(ctx context.Context, cmd *cli.Command) (e error) { + ctx = log.WithCommandContext(ctx, cmd.Name) + logger := log.FromContext(ctx) + term := terminal.Get(ctx) defer func() { if e != nil { @@ -29,11 +30,11 @@ func cmdSubcommand(git git.Repository, langManager packages.LangManager) cli.Act } }() - logger.Info(fmt.Sprintf("Executing subcommand: %s", c.Command.Name)) + logger.Info(fmt.Sprintf("Executing subcommand: %s", cmd.Name)) - commandName := strings.ToLower(c.Command.Name) + commandName := strings.ToLower(cmd.Name) - executable, _, err := findExec(c.Context, langManager, commandName) + executable, _, err := findExec(ctx, langManager, commandName) if err != nil { errMsg := color.RedString("Executable \"%s\" not found.", commandName) logger.Error(errMsg) @@ -47,14 +48,14 @@ func cmdSubcommand(git git.Repository, langManager packages.LangManager) cli.Act packageDir = findPackageDir(executable[1]) } - cmdPackage, err := readPackage(packageDir) + cmdPackage, err := readInstalledPackage(packageDir) if err != nil { logger.Error(fmt.Sprintf("Error reading package: %v", err)) return err } if cmdPackage.Requirements.Python != "" { - exec, err := langManager.FindExec(c.Context, cmdPackage.Requirements, packageDir) + exec, err := langManager.FindExec(ctx, cmdPackage.Requirements, packageDir) if err != nil { logger.Error(fmt.Sprintf("Error finding executable: %v", err)) return err @@ -90,11 +91,11 @@ func cmdSubcommand(git git.Repository, langManager packages.LangManager) cli.Act return cli.Exit(color.RedString("%s", packages.ErrPackageNeedsReinstall.Error()), -1) } - if err = uninstallPackage(c.Context, langManager, commandName, logger); err != nil { + if err = uninstallPackage(ctx, langManager, commandName, logger); err != nil { return err } - if _, err = installPackage(c.Context, git, langManager, commandName); err != nil { + if _, err = installPackage(ctx, git, langManager, commandName); err != nil { return err } } @@ -105,15 +106,15 @@ func cmdSubcommand(git git.Repository, langManager packages.LangManager) cli.Act } var currentCmd command - for _, cmd := range cmdPackage.Commands { - if strings.EqualFold(cmd.Name, commandName) { - currentCmd = cmd + for _, c := range cmdPackage.Commands { + if strings.EqualFold(c.Name, commandName) { + currentCmd = c break } - for _, alias := range cmd.Aliases { + for _, alias := range c.Aliases { if strings.EqualFold(alias, commandName) { - currentCmd = cmd + currentCmd = c } } } @@ -127,20 +128,26 @@ func cmdSubcommand(git git.Repository, langManager packages.LangManager) cli.Act return err } - cmdPackage, err = readPackage(packageDir) + cmdPackage, err = readInstalledPackage(packageDir) if err != nil { logger.Error(fmt.Sprintf("Error reading package: %v", err)) return err } - executable = prepareCommand(c, executable, c.Args().Slice(), "edgerc", "section", "accountkey") + if len(cmdPackage.Commands) == 0 { + err := fmt.Errorf("package in %q does not contain any valid commands", packageDir) + logger.Error(err.Error()) + return err + } + + executable = prepareCommand(cmd, executable, cmd.Args().Slice(), "edgerc", "section", "accountkey") subCmd := createCommand(executable[0], executable[1:]) - return passthruCommand(c.Context, subCmd, langManager, cmdPackage.Requirements, fmt.Sprintf("cli-%s", cmdPackage.Commands[0].Name)) + return passthruCommand(ctx, subCmd, langManager, cmdPackage.Requirements, fmt.Sprintf("cli-%s", cmdPackage.Commands[0].Name)) } } -func prepareCommand(c *cli.Context, command, args []string, flags ...string) []string { +func prepareCommand(c *cli.Command, command, args []string, flags ...string) []string { // dont search for flags is there are no args if len(args) == 0 { return command @@ -159,7 +166,7 @@ func prepareCommand(c *cli.Context, command, args []string, flags ...string) []s return command } -func findFlags(c *cli.Context, target []string, flags ...string) []string { +func findFlags(c *cli.Command, target []string, flags ...string) []string { var ret []string for _, flagName := range flags { if flagVal := c.String(flagName); flagVal != "" && !containsString(target, fmt.Sprintf("--%s", flagName)) { diff --git a/pkg/commands/command_subcommand_test.go b/pkg/commands/command_subcommand_test.go index 5f4528a..91ed5fb 100644 --- a/pkg/commands/command_subcommand_test.go +++ b/pkg/commands/command_subcommand_test.go @@ -1,7 +1,6 @@ package commands import ( - "flag" "os" "os/exec" "path/filepath" @@ -13,7 +12,7 @@ import ( "github.com/akamai/cli/v2/pkg/terminal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func TestCmdSubcommand(t *testing.T) { @@ -139,7 +138,7 @@ func TestCmdSubcommand(t *testing.T) { args = append(args, test.args...) test.init(m) - err := app.RunContext(ctx, args) + err := app.Run(ctx, args) m.cfg.AssertExpectations(t) if test.withError != "" { @@ -152,6 +151,28 @@ func TestCmdSubcommand(t *testing.T) { } } +// TestCmdSubcommandRunsValidCommandFromMixedValidityManifest verifies that a +// manifest containing both a valid command and an invalid command name still +// allows the valid command to run. +func TestCmdSubcommandRunsValidCommandFromMixedValidityManifest(t *testing.T) { + t.Setenv("AKAMAI_CLI_HOME", "./testdata") + validBin := filepath.Join("testdata", ".akamai-cli", "src", "cli-mixed-validity", "bin", "akamai-valid") + m := &mocked{&terminal.Mock{}, &config.Mock{}, &git.MockRepo{}, &packages.Mock{}, nil} + command := &cli.Command{ + Name: "valid", + Action: cmdSubcommand(m.gitRepo, m.langManager), + } + app, ctx := setupTestApp(command, m) + + m.langManager.On("FindExec", packages.LanguageRequirements{}, validBin).Return([]string{validBin}, nil) + m.langManager.On("FinishExecution", packages.LanguageRequirements{}, "cli-valid").Once() + + err := app.Run(ctx, append(os.Args[0:1], "valid", "abc")) + + require.NoError(t, err) + m.langManager.AssertExpectations(t) +} + func TestPythonCmdSubcommand(t *testing.T) { // run installed akamai echo command with python required t.Run("run installed akamai echo command with python required", func(t *testing.T) { @@ -186,7 +207,7 @@ func TestPythonCmdSubcommand(t *testing.T) { m.term.On("OK").Return().Once() } - err := app.RunContext(ctx, args) + err := app.Run(ctx, args) m.cfg.AssertExpectations(t) require.NoError(t, err) @@ -321,11 +342,13 @@ func TestPrepareCommand(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - flagSet := flag.NewFlagSet("flags", flag.ExitOnError) - flagSet.String("flag_1", "", "") - flagSet.String("flag_2", "", "") - flagSet.String("flag_3", "", "") - c := cli.NewContext(nil, flagSet, nil) + c := &cli.Command{ + Flags: []cli.Flag{ + &cli.StringFlag{Name: "flag_1"}, + &cli.StringFlag{Name: "flag_2"}, + &cli.StringFlag{Name: "flag_3"}, + }, + } for name, value := range test.flagsInCtx { require.NoError(t, c.Set(name, value)) } diff --git a/pkg/commands/command_test.go b/pkg/commands/command_test.go index e6f0723..c960225 100644 --- a/pkg/commands/command_test.go +++ b/pkg/commands/command_test.go @@ -1,10 +1,12 @@ package commands import ( + "bytes" "context" "errors" "fmt" "io/fs" + "log/slog" "os" "os/exec" "path/filepath" @@ -12,20 +14,52 @@ import ( "testing" "github.com/akamai/cli/v2/pkg/git" + "github.com/akamai/cli/v2/pkg/log" "github.com/akamai/cli/v2/pkg/packages" + "github.com/akamai/cli/v2/pkg/terminal" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func TestCommandsLocator(t *testing.T) { require.NoError(t, os.Setenv("AKAMAI_CLI_HOME", "./testdata")) - res := CommandLocator(context.Background()) + ctx := terminal.Context(context.Background(), terminal.New(terminal.DiscardWriter(), os.Stdin, os.Stderr)) + res := CommandLocator(ctx) for i := 0; i < len(res)-1; i++ { assert.True(t, strings.Compare(res[i].Name, res[i+1].Name) == -1) } } +func TestCreateInstalledCommandsWarnsForInvalidPackageMetadata(t *testing.T) { + cliHome := t.TempDir() + packageDir := filepath.Join(cliHome, ".akamai-cli", "src", "cli-invalid") + require.NoError(t, os.MkdirAll(packageDir, 0700)) + require.NoError(t, os.WriteFile( + filepath.Join(packageDir, "cli.json"), + []byte(`{"commands":[{"name":"valid"},{"name":"invalid.name"}]}`), + 0600, + )) + t.Setenv("AKAMAI_CLI_HOME", cliHome) + + var logs bytes.Buffer + logger := slog.New(log.NewHandler(&logs, false, &slog.HandlerOptions{Level: slog.LevelWarn})) + ctx := log.NewContext(context.Background(), *logger) + term := new(terminal.Mock) + term.On("Writeln", mock.Anything).Return(0, nil).Once() + ctx = terminal.Context(ctx, term) + + commands := createInstalledCommands(ctx, &git.MockRepo{}, &packages.Mock{}) + + term.AssertExpectations(t) + require.Len(t, commands, 1) + assert.Equal(t, "valid", commands[0].Name) + assert.Contains(t, logs.String(), "Skipping command in installed package") + assert.Contains(t, logs.String(), fmt.Sprintf("%q", packageDir)) + assert.Contains(t, logs.String(), `invalid command name "invalid.name" in cli.json`) +} + func TestSubcommandsToCliCommands_packagePrefix(t *testing.T) { from := subcommands{ Commands: []command{{ @@ -44,6 +78,26 @@ func TestSubcommandsToCliCommands_packagePrefix(t *testing.T) { } } +func TestSubcommandsToCliCommandsForwardsHelpArgument(t *testing.T) { + commands := subcommandToCliCommands(subcommands{ + Pkg: "cli-dns", + Commands: []command{{Name: "dns"}}, + }, &git.MockRepo{}, &packages.Mock{}) + + var forwardedArgs []string + commands[0].Action = func(_ context.Context, cmd *cli.Command) error { + forwardedArgs = cmd.Args().Slice() + return nil + } + + app := &cli.Command{ + Name: "akamai", + Commands: commands, + } + require.NoError(t, app.Run(context.Background(), []string{"akamai", "dns", "help"})) + assert.Equal(t, []string{"help"}, forwardedArgs) +} + func TestPassthruCommand(t *testing.T) { akaEchoBin := filepath.Join(".", "testdata", ".akamai-cli", "src", "cli-echo", "bin", "akamai-echo") akaEchoPythonBin := filepath.Join(".", "testdata", ".akamai-cli", "src", "cli-echo", "bin", "akamai-echo-python") diff --git a/pkg/commands/command_uninstall.go b/pkg/commands/command_uninstall.go index 6578a8b..7e86448 100644 --- a/pkg/commands/command_uninstall.go +++ b/pkg/commands/command_uninstall.go @@ -15,14 +15,13 @@ import ( "github.com/akamai/cli/v2/pkg/packages" "github.com/akamai/cli/v2/pkg/terminal" "github.com/akamai/cli/v2/pkg/tools" - "github.com/mitchellh/go-homedir" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func cmdUninstall(langManager packages.LangManager) cli.ActionFunc { - return func(c *cli.Context) (e error) { - c.Context = log.WithCommandContext(c.Context, c.Command.Name) - logger := log.FromContext(c.Context) + return func(ctx context.Context, cmd *cli.Command) (e error) { + ctx = log.WithCommandContext(ctx, cmd.Name) + logger := log.FromContext(ctx) start := time.Now() logger.Debug("UNINSTALL START") defer func() { @@ -32,8 +31,8 @@ func cmdUninstall(langManager packages.LangManager) cli.ActionFunc { logger.Error(fmt.Sprintf("UNINSTALL ERROR: %v", e)) } }() - for _, cmd := range c.Args().Slice() { - if err := uninstallPackage(c.Context, langManager, cmd, logger); err != nil { + for _, c := range cmd.Args().Slice() { + if err := uninstallPackage(ctx, langManager, c, logger); err != nil { logger.Error(fmt.Sprintf("Error uninstalling package: %v", err)) return cli.Exit(color.RedString("%s", err.Error()), 1) } @@ -46,7 +45,7 @@ func cmdUninstall(langManager packages.LangManager) cli.ActionFunc { func uninstallPackage(ctx context.Context, langManager packages.LangManager, cmd string, logger *slog.Logger) error { term := terminal.Get(ctx) - home, err := homedir.Dir() + home, err := os.UserHomeDir() if err != nil { logger.Error(fmt.Sprintf("No home directory detected: %v", err)) return fmt.Errorf("no home directory detected: %v", err) @@ -78,7 +77,7 @@ func uninstallPackage(ctx context.Context, langManager packages.LangManager, cmd return fmt.Errorf("command \"%s\" not found. Try \"%s help\"", cmd, tools.Self()) } - term.Spinner().Start(fmt.Sprintf("Attempting to uninstall \"%s\" command...", cmd)) + term.Spinner().Start("Attempting to uninstall %q command...", cmd) logger.Debug(fmt.Sprintf("Attempting to uninstall \"%s\" command...", cmd)) var repoDir string diff --git a/pkg/commands/command_uninstall_test.go b/pkg/commands/command_uninstall_test.go index 94e188c..f9d8bf3 100644 --- a/pkg/commands/command_uninstall_test.go +++ b/pkg/commands/command_uninstall_test.go @@ -15,7 +15,7 @@ import ( "github.com/akamai/cli/v2/pkg/tools" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func TestCmdUninstall(t *testing.T) { @@ -43,7 +43,7 @@ func TestCmdUninstall(t *testing.T) { m.langManager.On("FindExec", packages.LanguageRequirements{Go: "1.14.0"}, cliEchoUninstallBin).Return([]string{cliEchoUninstallBin}, nil).Once() m.term.On("Spinner").Return(m.term).Once() - m.term.On("Start", `Attempting to uninstall "echo-uninstall" command...`, []interface{}(nil)).Return().Once() + m.term.On("Start", "Attempting to uninstall %q command...", []any{"echo-uninstall"}).Return().Once() m.term.On("Spinner").Return(m.term).Once() m.term.On("OK").Return().Once() }, @@ -73,7 +73,7 @@ func TestCmdUninstall(t *testing.T) { m.langManager.On("FindExec", packages.LanguageRequirements{Go: "1.14.0"}, cliEchoUninstallBin).Return([]string{}, nil).Once() m.term.On("Spinner").Return(m.term).Once() - m.term.On("Start", `Attempting to uninstall "echo-uninstall" command...`, []interface{}(nil)).Return().Once() + m.term.On("Start", "Attempting to uninstall %q command...", []any{"echo-uninstall"}).Return().Once() m.term.On("Spinner").Return(m.term).Once() m.term.On("Fail").Return().Once() m.term.On("OK").Return().Once() @@ -89,7 +89,7 @@ func TestCmdUninstall(t *testing.T) { m.langManager.On("GetPackageBinPaths").Return("/path/to/echo-uninstall").Once() m.term.On("Spinner").Return(m.term).Once() - m.term.On("Start", `Attempting to uninstall "echo-uninstall" command...`, []interface{}(nil)).Return().Once() + m.term.On("Start", "Attempting to uninstall %q command...", []any{"echo-uninstall"}).Return().Once() m.term.On("Spinner").Return(m.term).Once() m.term.On("OK").Return().Once() @@ -116,7 +116,7 @@ func TestCmdUninstall(t *testing.T) { args = append(args, test.args...) test.init(t, m) - err := app.RunContext(ctx, args) + err := app.Run(ctx, args) m.cfg.AssertExpectations(t) if test.withError != "" { diff --git a/pkg/commands/command_update.go b/pkg/commands/command_update.go index 14a8768..f0df58e 100644 --- a/pkg/commands/command_update.go +++ b/pkg/commands/command_update.go @@ -18,13 +18,13 @@ import ( "github.com/akamai/cli/v2/pkg/terminal" "github.com/akamai/cli/v2/pkg/tools" gogit "github.com/go-git/go-git/v5" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func cmdUpdate(gitRepo git.Repository, langManager packages.LangManager) cli.ActionFunc { - return func(c *cli.Context) (e error) { - c.Context = log.WithCommandContext(c.Context, c.Command.Name) - logger := log.FromContext(c.Context) + return func(ctx context.Context, cmd *cli.Command) (e error) { + ctx = log.WithCommandContext(ctx, cmd.Name) + logger := log.FromContext(ctx) start := time.Now() logger.Debug("UPDATE START") defer func() { @@ -34,16 +34,16 @@ func cmdUpdate(gitRepo git.Repository, langManager packages.LangManager) cli.Act logger.Error(fmt.Sprintf("UPDATE ERROR: %v", e)) } }() - if !c.Args().Present() { + if !cmd.Args().Present() { var builtinCmds = make(map[string]bool) - for _, cmd := range getBuiltinCommands(c) { - builtinCmds[strings.ToLower(cmd.Commands[0].Name)] = true + for _, c := range getBuiltinCommands(cmd) { + builtinCmds[strings.ToLower(c.Commands[0].Name)] = true } - for _, cmd := range getCommands(c) { - for _, command := range cmd.Commands { + for _, c := range getCommands(cmd) { + for _, command := range c.Commands { if _, ok := builtinCmds[command.Name]; !ok { - if err := updatePackage(c.Context, gitRepo, langManager, logger, command.Name); err != nil { + if err := updatePackage(ctx, gitRepo, langManager, logger, command.Name); err != nil { logger.Error(fmt.Sprintf("Error updating package: %v", err)) return err } @@ -54,8 +54,8 @@ func cmdUpdate(gitRepo git.Repository, langManager packages.LangManager) cli.Act return nil } - for _, cmd := range c.Args().Slice() { - if err := updatePackage(c.Context, gitRepo, langManager, logger, cmd); err != nil { + for _, c := range cmd.Args().Slice() { + if err := updatePackage(ctx, gitRepo, langManager, logger, c); err != nil { logger.Error(fmt.Sprintf("Error updating package: %v", err)) return err } @@ -97,7 +97,7 @@ func updatePackage(ctx context.Context, gitRepo git.Repository, langManager pack if err != nil { logger.Debug("Unable to open repo") - cmdPackage, err := readPackage(repoDir) + cmdPackage, err := readInstalledPackage(repoDir) if err != nil { term.Spinner().Fail() logger.Error(fmt.Sprintf("Failed to read package: %v", err)) diff --git a/pkg/commands/command_update_test.go b/pkg/commands/command_update_test.go index 2161571..486824e 100644 --- a/pkg/commands/command_update_test.go +++ b/pkg/commands/command_update_test.go @@ -21,7 +21,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func TestCmdUpdate(t *testing.T) { @@ -449,16 +449,18 @@ func TestCmdUpdate(t *testing.T) { Action: cmdUpdate(m.gitRepo, m.langManager), } app, ctx := setupTestApp(command, m) - app.Commands = append(app.Commands, &cli.Command{ - Name: "echo", - Category: "Installed", - }) + app.Commands = append(app.Commands, + &cli.Command{ + Name: "echo", + Category: "Installed", + }, + ) args := os.Args[0:1] args = append(args, "update") args = append(args, test.args...) test.init(t, m) - err := app.RunContext(ctx, args) + err := app.Run(ctx, args) if test.teardown != nil { test.teardown(t) } diff --git a/pkg/commands/command_upgrade.go b/pkg/commands/command_upgrade.go index c948ffd..4ff3a51 100644 --- a/pkg/commands/command_upgrade.go +++ b/pkg/commands/command_upgrade.go @@ -3,6 +3,7 @@ package commands import ( + "context" "fmt" "os" "time" @@ -11,26 +12,26 @@ import ( "github.com/akamai/cli/v2/pkg/log" "github.com/akamai/cli/v2/pkg/terminal" "github.com/akamai/cli/v2/pkg/version" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) -func cmdUpgrade(c *cli.Context) error { - c.Context = log.WithCommandContext(c.Context, c.Command.Name) - logger := log.FromContext(c.Context) +func cmdUpgrade(ctx context.Context, cmd *cli.Command) error { + ctx = log.WithCommandContext(ctx, cmd.Name) + logger := log.FromContext(ctx) start := time.Now() logger.Debug("UPGRADE START") defer func() { logger.Debug(fmt.Sprintf("UPGRADE FINISH: %v", time.Since(start))) }() - term := terminal.Get(c.Context) + term := terminal.Get(ctx) term.Spinner().Start("Checking for upgrades...") - latestVersion := CheckUpgradeVersion(c.Context, true) + latestVersion := CheckUpgradeVersion(ctx, true) if latestVersion != "" && latestVersion != version.Version { term.Spinner().Stop(terminal.SpinnerStatusOK) os.Args = []string{os.Args[0], "--version"} - return UpgradeCli(c.Context, latestVersion) + return UpgradeCli(ctx, latestVersion) } term.Spinner().Stop(terminal.SpinnerStatusWarnOK) if latestVersion == version.Version { diff --git a/pkg/commands/command_upgrade_noop.go b/pkg/commands/command_upgrade_noop.go index 798c8f4..a3ee389 100644 --- a/pkg/commands/command_upgrade_noop.go +++ b/pkg/commands/command_upgrade_noop.go @@ -6,10 +6,10 @@ import ( "context" "github.com/akamai/cli/v2/pkg/color" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) -func cmdUpgrade(c *cli.Context) error { +func cmdUpgrade(_ context.Context, _ *cli.Command) error { return cli.Exit(color.RedString("[WARNING] Upgrade command is not available for your installation. If you installed Akamai CLI with Homebrew, please run 'brew upgrade akamai' in order to perform upgrade."), 1) } diff --git a/pkg/commands/command_upgrade_test.go b/pkg/commands/command_upgrade_test.go index 09f83fc..a666dcb 100644 --- a/pkg/commands/command_upgrade_test.go +++ b/pkg/commands/command_upgrade_test.go @@ -1,3 +1,5 @@ +//go:build !noautoupgrade + package commands import ( @@ -17,7 +19,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func mockIsTTY(term *terminal.Mock, isTTY bool) { @@ -216,7 +218,7 @@ func TestCmdUpgrade(t *testing.T) { } }) } - err := app.RunContext(ctx, args) + err := app.Run(ctx, args) m.cfg.AssertExpectations(t) m.term.AssertExpectations(t) diff --git a/pkg/commands/helpers_test.go b/pkg/commands/helpers_test.go index 63973b8..58ab9c7 100644 --- a/pkg/commands/helpers_test.go +++ b/pkg/commands/helpers_test.go @@ -17,12 +17,13 @@ import ( "github.com/akamai/cli/v2/pkg/packages" "github.com/akamai/cli/v2/pkg/terminal" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) var testFiles = map[string][]string{ "cli-echo": {"akamai-e", "akamai-e.cmd", "akamai-echo", "akamai-echo.cmd", "akamai-echo-cmd.cmd"}, "cli-echo-invalid-json": {"akamai-echo-invalid-json", "akamai-echo-invalid-json.cmd"}, + "cli-mixed-validity": {"akamai-valid"}, } // TestMain prepares test binary used as cli command in tests and copies it to each directory specified in 'testFiles' variable @@ -91,23 +92,25 @@ type mocked struct { cmd *MockCmd } -func setupTestApp(command *cli.Command, m *mocked) (*cli.App, context.Context) { +func setupTestApp(command *cli.Command, m *mocked) (*cli.Command, context.Context) { cli.OsExiter = func(_ int) {} ctx := terminal.Context(context.Background(), m.term) ctx = config.Context(ctx, m.cfg) - app := cli.NewApp() - app.Commands = []*cli.Command{command} - app.Flags = []cli.Flag{ - &cli.StringFlag{ - Name: "edgerc", - Usage: "edgerc config path passed to executed commands, defaults to ~/.edgerc", - Aliases: []string{"e"}, - }, - &cli.StringFlag{ - Name: "section", - Usage: "edgerc section name passed to executed commands, defaults to 'default'", - Aliases: []string{"s"}, + app := &cli.Command{ + Name: "akamai", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "edgerc", + Usage: "edgerc config path passed to executed commands, defaults to ~/.edgerc", + Aliases: []string{"e"}, + }, + &cli.StringFlag{ + Name: "section", + Usage: "edgerc section name passed to executed commands, defaults to 'default'", + Aliases: []string{"s"}, + }, }, + Commands: []*cli.Command{command}, } return app, ctx } diff --git a/pkg/commands/subcommands.go b/pkg/commands/subcommands.go index ef3fdaf..8f8df91 100644 --- a/pkg/commands/subcommands.go +++ b/pkg/commands/subcommands.go @@ -16,7 +16,7 @@ import ( "github.com/akamai/cli/v2/pkg/log" "github.com/akamai/cli/v2/pkg/packages" "github.com/akamai/cli/v2/pkg/tools" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) type subcommands struct { @@ -28,64 +28,112 @@ type subcommands struct { } func readPackage(dir string) (subcommands, error) { + cliJSON, err := readPackageJSON(dir) + if err != nil { + return subcommands{}, err + } + + return parsePackage(cliJSON, dir) +} + +func readPackageJSON(dir string) ([]byte, error) { if _, err := os.Stat(filepath.Join(dir, "cli.json")); err != nil { dir = filepath.Dir(dir) if _, err = os.Stat(filepath.Join(dir, "cli.json")); err != nil { - return subcommands{}, fmt.Errorf("package does not contain a cli.json file: %v", err) + return nil, fmt.Errorf("package does not contain a cli.json file: %v", err) } } - var packageData subcommands cliJSON, err := os.ReadFile(filepath.Join(dir, "cli.json")) if err != nil { - return subcommands{}, fmt.Errorf("unable to read package: %v", err) + return nil, fmt.Errorf("unable to read package: %v", err) } - err = json.Unmarshal(cliJSON, &packageData) + return cliJSON, nil +} + +func readPackageFromGithub(url, dir string) (subcommands, error) { + response, err := http.Get(url) if err != nil { - return subcommands{}, fmt.Errorf("unable to unmarshal package: %v", err) + return subcommands{}, fmt.Errorf("unable to get package from github: %v", err) + } + if response.StatusCode != http.StatusOK { + return subcommands{}, fmt.Errorf("invalid response status while fetching cli.json: %d", response.StatusCode) } - for key := range packageData.Commands { - packageData.Commands[key].Name = strings.ToLower(packageData.Commands[key].Name) + cliJSON, err := io.ReadAll(response.Body) + if err != nil { + return subcommands{}, fmt.Errorf("unable to read package: %v", err) } - packageData.Pkg = filepath.Base(strings.Replace(dir, "cli-", "", 1)) + packageData, err := parsePackage(cliJSON, dir) + if err != nil { + return subcommands{}, err + } + packageData.raw = cliJSON return packageData, nil } -func readPackageFromGithub(url, dir string) (subcommands, error) { - response, err := http.Get(url) +func parsePackage(cliJSON []byte, dir string) (subcommands, error) { + packageData, err := unmarshalPackage(cliJSON, dir) if err != nil { - return subcommands{}, fmt.Errorf("unable to get package from github: %v", err) + return subcommands{}, err } - if response.StatusCode == http.StatusOK { - cliJSON, err := io.ReadAll(response.Body) + + for i := range packageData.Commands { + name, err := tools.NormalizeCommandName(packageData.Commands[i].Name) if err != nil { - return subcommands{}, fmt.Errorf("unable to read package: %v", err) + return subcommands{}, fmt.Errorf("invalid command name in cli.json: %w", err) } + packageData.Commands[i].Name = name + } - var packageData subcommands + return packageData, nil +} - err = json.Unmarshal(cliJSON, &packageData) - if err != nil { - return subcommands{}, fmt.Errorf("unable to unmarshal package: %v", err) - } +func readInstalledPackage(dir string) (subcommands, error) { + packageData, _, err := readInstalledPackageWithDiagnostics(dir) + return packageData, err +} + +// readInstalledPackageWithDiagnostics tolerates invalid command names so that valid commands of an installed package stay usable. +// The skipped commands are reported as invalidCommands. +func readInstalledPackageWithDiagnostics(dir string) (subcommands, []error, error) { + cliJSON, err := readPackageJSON(dir) + if err != nil { + return subcommands{}, nil, err + } - packageData.raw = cliJSON + packageData, err := unmarshalPackage(cliJSON, dir) + if err != nil { + return subcommands{}, nil, err + } - for key := range packageData.Commands { - packageData.Commands[key].Name = strings.ToLower(packageData.Commands[key].Name) + var invalidCommands []error + validCommands := make([]command, 0, len(packageData.Commands)) + for _, cmd := range packageData.Commands { + name, err := tools.NormalizeCommandName(cmd.Name) + if err != nil { + invalidCommands = append(invalidCommands, fmt.Errorf("invalid command name %q in cli.json: %w", cmd.Name, err)) + continue } + cmd.Name = name + validCommands = append(validCommands, cmd) + } - packageData.Pkg = filepath.Base(strings.Replace(dir, "cli-", "", 1)) - - return packageData, nil + packageData.Commands = validCommands + return packageData, invalidCommands, nil +} +func unmarshalPackage(cliJSON []byte, dir string) (subcommands, error) { + var packageData subcommands + if err := json.Unmarshal(cliJSON, &packageData); err != nil { + return subcommands{}, fmt.Errorf("unable to unmarshal package: %v", err) } - return subcommands{}, fmt.Errorf("invalid response status while fetching cli.json: %d", response.StatusCode) + packageData.Pkg = filepath.Base(strings.Replace(dir, "cli-", "", 1)) + return packageData, nil } func getPackagePaths() []string { @@ -131,6 +179,12 @@ func findPackageDir(dir string) string { func downloadBin(ctx context.Context, dir string, cmd command) error { logger := log.FromContext(ctx) + binaryName, err := tools.CommandBinaryName(cmd.Name) + if err != nil { + logger.Error(fmt.Sprintf("Invalid command name: %v", err)) + return err + } + cmd.Arch = runtime.GOARCH cmd.OS = runtime.GOOS @@ -152,8 +206,9 @@ func downloadBin(ctx context.Context, dir string, cmd command) error { url := buf.String() logger.Debug(fmt.Sprintf("Fetching binary from %s", url)) - binName := filepath.Join(dir, "akamai-"+strings.ToLower(cmd.Name)+cmd.BinSuffix) - bin, err := os.Create(binName) + binaryName += cmd.BinSuffix + binName := filepath.Join(dir, binaryName) + bin, err := tools.CreateExclusive(dir, binaryName, 0775) if err != nil { logger.Error(fmt.Sprintf("Unable to create %s file: %v", binName, err)) return err @@ -164,11 +219,6 @@ func downloadBin(ctx context.Context, dir string, cmd command) error { } }() - if err := os.Chmod(binName, 0775); err != nil { - logger.Error(fmt.Sprintf("Unable to change the %s file mode: %v", binName, err)) - return err - } - res, err := http.Get(url) if err != nil { logger.Error(fmt.Sprintf("Unable to get command binary: %v", err)) diff --git a/pkg/commands/subcommands_test.go b/pkg/commands/subcommands_test.go index c0eb2db..84ca24a 100644 --- a/pkg/commands/subcommands_test.go +++ b/pkg/commands/subcommands_test.go @@ -1,6 +1,13 @@ package commands import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -44,3 +51,113 @@ func TestReadPackage(t *testing.T) { }) } } + +func TestReadPackageRejectsTraversalCommandName(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "cli.json"), []byte(`{"commands":[{"name":"x/../../escaped"}]}`), 0600)) + + _, err := readPackage(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid command name in cli.json") +} + +func TestReadInstalledPackageOmitsInvalidCommandDiagnostics(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "cli.json"), + []byte(`{"commands":[{"name":"valid"},{"name":"invalid.name"}]}`), + 0600, + )) + + packageData, err := readInstalledPackage(dir) + + require.NoError(t, err) + require.Len(t, packageData.Commands, 1) + assert.Equal(t, "valid", packageData.Commands[0].Name) +} + +func TestReadPackageFromGithubRejectsTraversalCommandName(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(`{"commands":[{"name":"x/../../escaped"}]}`)) + assert.NoError(t, err) + })) + defer server.Close() + + _, err := readPackageFromGithub(server.URL, t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid command name in cli.json") +} + +func TestDownloadBinReplacesSymlinkedOutputWithoutFollowingIt(t *testing.T) { + dir := t.TempDir() + externalFile := filepath.Join(t.TempDir(), "external") + require.NoError(t, os.WriteFile(externalFile, []byte("original"), 0600)) + binaryName := "akamai-test" + if runtime.GOOS == "windows" { + binaryName += ".exe" + } + binaryPath := filepath.Join(dir, binaryName) + if err := os.Symlink(externalFile, binaryPath); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + + err := downloadBin(context.Background(), dir, command{Name: "test"}) + + require.Error(t, err) + content, readErr := os.ReadFile(externalFile) + require.NoError(t, readErr) + assert.Equal(t, "original", string(content)) + info, statErr := os.Lstat(binaryPath) + require.NoError(t, statErr) + assert.Zero(t, info.Mode()&os.ModeSymlink) +} + +func TestDownloadBinRejectsInvalidCommandName(t *testing.T) { + err := downloadBin(context.Background(), t.TempDir(), command{ + Name: "x/../../escaped", + Bin: "https://example.com/akamai-test", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "must match") +} + +func TestDownloadBinRejectsSymlinkedBinaryDirectory(t *testing.T) { + externalDir := t.TempDir() + externalFile := filepath.Join(externalDir, "akamai-test") + require.NoError(t, os.WriteFile(externalFile, []byte("original"), 0600)) + + dir := filepath.Join(t.TempDir(), "bin") + if err := os.Symlink(externalDir, dir); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + + err := downloadBin(context.Background(), dir, command{Name: "test"}) + + require.EqualError(t, err, fmt.Sprintf("output directory %q must be a non-symlink directory", dir)) + content, readErr := os.ReadFile(externalFile) + require.NoError(t, readErr) + assert.Equal(t, "original", string(content)) +} + +func TestDownloadBinSetsExecutablePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not support Unix executable permissions") + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte("binary")) + require.NoError(t, err) + })) + defer server.Close() + + dir := t.TempDir() + require.NoError(t, downloadBin(context.Background(), dir, command{ + Name: "test", + Bin: server.URL, + })) + + info, err := os.Stat(filepath.Join(dir, "akamai-test")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0775), info.Mode().Perm()) +} diff --git a/pkg/commands/testdata/.akamai-cli/src/cli-mixed-validity/cli.json b/pkg/commands/testdata/.akamai-cli/src/cli-mixed-validity/cli.json new file mode 100644 index 0000000..283c5ff --- /dev/null +++ b/pkg/commands/testdata/.akamai-cli/src/cli-mixed-validity/cli.json @@ -0,0 +1,14 @@ +{ + "commands": [ + { + "name": "valid", + "description": "valid command", + "version": "1.0.0" + }, + { + "name": "invalid.name", + "description": "command with an invalid name", + "version": "1.0.0" + } + ] +} diff --git a/pkg/commands/upgrade_common.go b/pkg/commands/upgrade_common.go index 030cac0..89410c7 100644 --- a/pkg/commands/upgrade_common.go +++ b/pkg/commands/upgrade_common.go @@ -18,7 +18,7 @@ import ( "github.com/akamai/cli/v2/pkg/packages" "github.com/akamai/cli/v2/pkg/terminal" "github.com/inconshreveable/go-update" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) // UpgradeCli pulls from GitHub the latest released CLI binary and performs the upgrade of the current executable diff --git a/pkg/config/config.go b/pkg/config/config.go index 2a973f1..a09e47b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -11,7 +11,7 @@ import ( "github.com/akamai/cli/v2/pkg/log" "github.com/akamai/cli/v2/pkg/terminal" "github.com/akamai/cli/v2/pkg/tools" - "github.com/go-ini/ini" + "gopkg.in/ini.v1" ) const ( diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index ee8602f..2af60d9 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -7,9 +7,9 @@ import ( "testing" "github.com/akamai/cli/v2/pkg/terminal" - "github.com/go-ini/ini" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/ini.v1" ) var ( diff --git a/pkg/packages/golang.go b/pkg/packages/golang.go index 3bde44a..2b501f5 100644 --- a/pkg/packages/golang.go +++ b/pkg/packages/golang.go @@ -10,18 +10,30 @@ import ( "os/exec" "path/filepath" "regexp" - "strings" "github.com/akamai/cli/v2/pkg/color" "github.com/akamai/cli/v2/pkg/log" "github.com/akamai/cli/v2/pkg/tools" "github.com/akamai/cli/v2/pkg/version" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) func (l *langManager) installGolang(ctx context.Context, dir, ver string, commands, ldFlags []string) error { logger := log.FromContext(ctx) + if len(commands) != len(ldFlags) { + return fmt.Errorf("commands and ldFlags should have the same length") + } + + binaryNames := make([]string, len(commands)) + for i, command := range commands { + name, err := tools.CommandBinaryName(command) + if err != nil { + return fmt.Errorf("invalid command name: %w", err) + } + binaryNames[i] = name + } + goBin, err := l.commandExecutor.LookPath("go") if err != nil { logger.Error("Go executable not found") @@ -66,13 +78,14 @@ func (l *langManager) installGolang(ctx context.Context, dir, ver string, comman return err } - if len(commands) != len(ldFlags) { - return fmt.Errorf("commands and ldFlags should have the same length") - } - for n, command := range commands { ldFlag := ldFlags[n] - execName := "akamai-" + strings.ToLower(command) + execName := binaryNames[n] + outputPath := filepath.Join(dir, execName) + + if err := tools.RemoveOutput(outputPath); err != nil { + return fmt.Errorf("remove existing Go build output %q: %w", outputPath, err) + } var cmd *exec.Cmd params := []string{"build", "-o", execName} diff --git a/pkg/packages/golang_test.go b/pkg/packages/golang_test.go index 28292a3..8895b00 100644 --- a/pkg/packages/golang_test.go +++ b/pkg/packages/golang_test.go @@ -4,11 +4,13 @@ import ( "context" "errors" "fmt" + "os" "os/exec" "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -219,3 +221,47 @@ func TestInstallGolang(t *testing.T) { }) } } + +func TestInstallGolangRejectsTraversalCommandName(t *testing.T) { + l := langManager{new(mocked)} + err := l.installGolang(context.Background(), t.TempDir(), "*", []string{"x/../../escaped"}, []string{""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid command name") +} + +func TestInstallGolangRejectsMismatchedArgumentsBeforeSideEffects(t *testing.T) { + m := new(mocked) + l := langManager{m} + + err := l.installGolang(context.Background(), t.TempDir(), "*", []string{"test"}, nil) + + require.EqualError(t, err, "commands and ldFlags should have the same length") + assert.Empty(t, m.Calls) +} + +func TestInstallGolangRemovesExistingOutputSymlinkBeforeBuild(t *testing.T) { + dir := t.TempDir() + t.Setenv("AKAMAI_CLI_HOME", dir) + t.Setenv("GOPATH", "") + outputPath := filepath.Join(dir, "akamai-test") + if err := os.Symlink(t.TempDir(), outputPath); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + + m := new(mocked) + m.On("LookPath", "go").Return("/test/go", nil) + m.On("FileExists", filepath.Join(dir, "go.sum")).Return(true, nil) + m.On("ExecCommand", mock.MatchedBy(func(cmd *exec.Cmd) bool { + return cmd.Args[1] == "mod" + })).Return(nil, nil) + m.On("ExecCommand", mock.MatchedBy(func(cmd *exec.Cmd) bool { + return cmd.Args[1] == "build" + })).Run(func(_ mock.Arguments) { + _, err := os.Lstat(outputPath) + require.True(t, os.IsNotExist(err), "build output symlink still exists") + }).Return(nil, nil) + + l := langManager{m} + require.NoError(t, l.installGolang(context.Background(), dir, "*", []string{"test"}, []string{""})) + m.AssertExpectations(t) +} diff --git a/pkg/terminal/terminal.go b/pkg/terminal/terminal.go index ed0980d..9736e8a 100644 --- a/pkg/terminal/terminal.go +++ b/pkg/terminal/terminal.go @@ -256,11 +256,11 @@ func ShowBanner(ctx context.Context) { if _, err := term.Writeln(); err != nil { term.WriteError(err.Error()) } - term.Printf(color.ReverseVideoString("%s", strings.Repeat(" ", 60)+"\n")) + term.Printf("%s", color.ReverseVideoString("%s", strings.Repeat(" ", 60)+"\n")) title := "Welcome to Akamai CLI v" + version.Version ws := strings.Repeat(" ", 16) - term.Printf(color.ReverseVideoString("%s", ws+title+ws+"\n")) - term.Printf(color.ReverseVideoString("%s", strings.Repeat(" ", 60)+"\n")) + term.Printf("%s", color.ReverseVideoString("%s", ws+title+ws+"\n")) + term.Printf("%s", color.ReverseVideoString("%s", strings.Repeat(" ", 60)+"\n")) if _, err := term.Writeln(); err != nil { term.WriteError(err.Error()) } diff --git a/pkg/tools/files.go b/pkg/tools/files.go index 9e9a135..3f24482 100644 --- a/pkg/tools/files.go +++ b/pkg/tools/files.go @@ -4,8 +4,51 @@ import ( "fmt" "io" "os" + "path/filepath" + "runtime" ) +// RemoveOutput removes an existing output file or symlink without failing when it is absent. +func RemoveOutput(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// CreateExclusive removes any existing output and creates name exclusively in dir. +func CreateExclusive(dir, name string, perm os.FileMode) (*os.File, error) { + if name == "" || name == "." || filepath.IsAbs(name) || filepath.Base(name) != name { + return nil, fmt.Errorf("output name %q must be a file name", name) + } + + dirInfo, err := os.Lstat(dir) + if err != nil { + return nil, fmt.Errorf("inspect output directory %q: %w", dir, err) + } + if !dirInfo.IsDir() { + return nil, fmt.Errorf("output directory %q must be a non-symlink directory", dir) + } + + outputPath := filepath.Join(dir, name) + if err := RemoveOutput(outputPath); err != nil { + return nil, fmt.Errorf("remove existing output %q: %w", outputPath, err) + } + + file, err := os.OpenFile(outputPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm) + if err != nil { + return nil, err + } + if runtime.GOOS != "windows" { + if err := file.Chmod(perm); err != nil { + _ = file.Close() + return nil, fmt.Errorf("set output permissions %q: %w", outputPath, err) + } + } + + return file, nil +} + // MoveFile must copy+unlink the file because moving files is broken across filesystems func MoveFile(src, dst string) (err error) { sourceFileStat, err := os.Stat(src) diff --git a/pkg/tools/util.go b/pkg/tools/util.go index cf0ea91..38ad773 100644 --- a/pkg/tools/util.go +++ b/pkg/tools/util.go @@ -1,14 +1,37 @@ package tools import ( + "fmt" "os" "path/filepath" + "regexp" "strings" - "github.com/mitchellh/go-homedir" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" ) +var commandNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`) + +// NormalizeCommandName converts a package command name to its canonical form +// and rejects values that cannot safely be used as a command identifier. +func NormalizeCommandName(name string) (string, error) { + if !commandNamePattern.MatchString(name) { + return "", fmt.Errorf("%q must match %s", name, commandNamePattern) + } + + return strings.ToLower(name), nil +} + +// CommandBinaryName returns the canonical binary name for a package command. +func CommandBinaryName(name string) (string, error) { + name, err := NormalizeCommandName(name) + if err != nil { + return "", err + } + + return "akamai-" + name, nil +} + // Self ... func Self() string { return filepath.Base(os.Args[0]) @@ -25,7 +48,7 @@ func GetAkamaiCliPath() (string, error) { cliHome := os.Getenv("AKAMAI_CLI_HOME") if cliHome == "" { var err error - cliHome, err = homedir.Dir() + cliHome, err = os.UserHomeDir() if err != nil { return "", cli.Exit("Package install directory could not be found. Please set $AKAMAI_CLI_HOME.", -1) } diff --git a/pkg/tools/util_test.go b/pkg/tools/util_test.go index 73d612a..acfaffb 100644 --- a/pkg/tools/util_test.go +++ b/pkg/tools/util_test.go @@ -57,6 +57,80 @@ func TestGithubize(t *testing.T) { } } +func TestNormalizeCommandName(t *testing.T) { + tests := map[string]struct { + name string + normalized string + wantErr bool + }{ + "lowercases valid ASCII names": { + name: "Command_123", + normalized: "command_123", + }, + "rejects Unicode characters that lowercase to ASCII": { + name: "\u212Aommand", + wantErr: true, + }, + "rejects Windows traversal": { + name: `x\..\..\escaped`, + wantErr: true, + }, + "rejects empty names": { + name: "", + wantErr: true, + }, + "rejects leading hyphens": { + name: "-command", + wantErr: true, + }, + "rejects leading underscores": { + name: "_command", + wantErr: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + normalized, err := NormalizeCommandName(test.name) + if test.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, test.normalized, normalized) + }) + } +} + +func TestCommandBinaryName(t *testing.T) { + tests := map[string]struct { + name string + binaryName string + wantErr bool + }{ + "prefixes and lowercases valid names": { + name: "Command_123", + binaryName: "akamai-command_123", + }, + "rejects invalid names": { + name: "x/../../escaped", + wantErr: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + binaryName, err := CommandBinaryName(test.name) + if test.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, test.binaryName, binaryName) + }) + } +} + func TestCapitalizeFirstWord(t *testing.T) { capitalizeTests := []struct { input string diff --git a/pkg/version/version.go b/pkg/version/version.go index 831fbb7..6eb26f2 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -4,7 +4,7 @@ import "github.com/Masterminds/semver" const ( // Version Application Version - Version = "2.0.5" + Version = "2.0.6" // Equals p1==p2 in version.Compare(p1, p2) Equals = 0 // Error failure parsing one of the parameters in version.Compare(p1, p2)