diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 4b8bd2b..a31f4b8 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -26,8 +26,18 @@ jobs: git diff --exit-code -- go.mod go.sum test: - name: test - runs-on: ubuntu-latest + name: test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + # Git Bash on Windows too; make is not installed there, so call go directly. + shell: bash + env: + BIN: bin/devlyft${{ matrix.os == 'windows-latest' && '.exe' || '' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -35,9 +45,14 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - - run: make test - - run: make build - - run: ./bin/devlyft version + - run: go test -race ./... + - run: go build -trimpath -o "$BIN" ./cmd/devlyft + - run: '"./$BIN" version' + - name: Completion scripts generate + run: | + for shell in bash zsh fish powershell; do + "./$BIN" completion "$shell" > /dev/null + done security: name: security diff --git a/internal/cli/completion.go b/internal/cli/completion.go new file mode 100644 index 0000000..7f890fe --- /dev/null +++ b/internal/cli/completion.go @@ -0,0 +1,57 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// newCompletionCmd replaces Cobra's default completion command (Cobra skips +// its own when one named "completion" exists) so the help text is ours. +func newCompletionCmd() *cobra.Command { + return &cobra.Command{ + Use: "completion bash|zsh|fish|powershell", + Short: "Generate a shell completion script", + Long: `Generate a completion script for devlyft for the given shell. + +The script is printed to stdout. Load it in your shell's start-up file so +completions are available in every session. + +Bash (needs the bash-completion package): + + echo 'eval "$(devlyft completion bash)"' >> ~/.bashrc + +Zsh (enable completion first with "autoload -U compinit && compinit"): + + devlyft completion zsh > "${fpath[1]}/_devlyft" + +Fish: + + devlyft completion fish > ~/.config/fish/completions/devlyft.fish + +PowerShell: + + devlyft completion powershell | Out-String | Invoke-Expression + + To load it in every session, add that line to the file shown by $PROFILE.`, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + root := cmd.Root() + out := cmd.OutOrStdout() + switch args[0] { + case "bash": + return root.GenBashCompletionV2(out, true) + case "zsh": + return root.GenZshCompletion(out) + case "fish": + return root.GenFishCompletion(out, true) + case "powershell": + return root.GenPowerShellCompletionWithDesc(out) + default: + return fmt.Errorf("unsupported shell %q", args[0]) + } + }, + } +} diff --git a/internal/cli/completion_test.go b/internal/cli/completion_test.go new file mode 100644 index 0000000..d88843d --- /dev/null +++ b/internal/cli/completion_test.go @@ -0,0 +1,70 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/devlyft/cli/internal/exitcode" + "github.com/devlyft/cli/internal/iostreams" +) + +func TestCompletion(t *testing.T) { + t.Parallel() + + // Each script must mention the binary so it registers completions for it. + for _, shell := range []string{"bash", "zsh", "fish", "powershell"} { + t.Run(shell, func(t *testing.T) { + t.Parallel() + + ios, _, stdout, stderr := iostreams.Test() + code := Execute(t.Context(), ios, []string{"completion", shell}) + + if code != exitcode.OK { + t.Fatalf("exit code = %d, want %d (stderr: %q)", code, exitcode.OK, stderr.String()) + } + if !strings.Contains(stdout.String(), "devlyft") { + t.Errorf("%s script does not mention devlyft", shell) + } + if stderr.Len() != 0 { + t.Errorf("stderr = %q, want empty", stderr.String()) + } + }) + } +} + +func TestCompletionInvalidArgs(t *testing.T) { + t.Parallel() + + tests := map[string][]string{ + "unknown shell": {"completion", "tcsh"}, + "no shell": {"completion"}, + "two shells": {"completion", "bash", "zsh"}, + } + for name, args := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ios, _, stdout, _ := iostreams.Test() + code := Execute(t.Context(), ios, args) + + if code != exitcode.Usage { + t.Fatalf("exit code = %d, want %d", code, exitcode.Usage) + } + if stdout.Len() != 0 { + t.Errorf("stdout = %q, want empty", stdout.String()) + } + }) + } +} + +func TestCompletionReplacesCobraDefault(t *testing.T) { + t.Parallel() + + ios, _, stdout, _ := iostreams.Test() + if code := Execute(t.Context(), ios, []string{"completion", "--help"}); code != exitcode.OK { + t.Fatalf("exit code = %d, want %d", code, exitcode.OK) + } + if !strings.Contains(stdout.String(), "devlyft completion powershell | Out-String") { + t.Errorf("help text is not ours:\n%s", stdout.String()) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 3b19460..796f862 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -58,6 +58,7 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { pf.StringVar(&flags.verbosity, "verbosity", "warning", "Log level for diagnostics on stderr: "+strings.Join(cmdutil.Verbosities, ", ")+" (DEVLYFT_DEBUG=1 for debug)") root.AddCommand(newVersionCmd()) + root.AddCommand(newCompletionCmd()) return root } diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go new file mode 100644 index 0000000..8f5d591 --- /dev/null +++ b/internal/cli/version_test.go @@ -0,0 +1,34 @@ +package cli + +import ( + "regexp" + "testing" + + "github.com/devlyft/cli/internal/exitcode" + "github.com/devlyft/cli/internal/iostreams" +) + +// The version line is read by scripts and bug reports, so its shape is +// pinned: "devlyft [ ([, ])] /". +var versionLine = regexp.MustCompile(`^devlyft \S+( \([0-9a-f]{1,12}(, \S+)?\))? go\S+ [a-z0-9]+/[a-z0-9]+\n$`) + +func TestVersionLineFormat(t *testing.T) { + t.Parallel() + + ios, _, stdout, stderr := iostreams.Test() + if code := Execute(t.Context(), ios, []string{"version"}); code != exitcode.OK { + t.Fatalf("exit code = %d, want %d (stderr: %q)", code, exitcode.OK, stderr.String()) + } + if !versionLine.MatchString(stdout.String()) { + t.Errorf("version line %q does not match %s", stdout.String(), versionLine) + } +} + +func TestVersionRejectsArgs(t *testing.T) { + t.Parallel() + + ios, _, _, _ := iostreams.Test() + if code := Execute(t.Context(), ios, []string{"version", "extra"}); code != exitcode.Usage { + t.Fatalf("exit code = %d, want %d for unexpected argument", code, exitcode.Usage) + } +}