From a53cb99d023efce29e10a89e77a060f21516ff03 Mon Sep 17 00:00:00 2001 From: Patrick Joyce Date: Wed, 5 Aug 2026 10:04:18 -0400 Subject: [PATCH 1/2] init --- CHANGELOG.md | 8 ++ Makefile | 22 ++-- README.md | 116 +++++++++++------ docs/awsd.md | 4 +- docs/awsd_init.md | 37 ++++++ docs/awsd_list.md | 2 +- docs/awsd_set.md | 2 +- docs/awsd_set_profile.md | 2 +- docs/awsd_set_region.md | 2 +- docs/awsd_shellenv.md | 23 ++++ docs/awsd_unset.md | 2 +- docs/awsd_unset_profile.md | 2 +- docs/awsd_unset_region.md | 2 +- docs/awsd_version.md | 2 +- scripts/_awsd | 59 ++------- scripts/powershell/awsd.ps1 | 71 ++-------- src/cmd/init.go | 66 ++++++++++ src/cmd/init_test.go | 142 ++++++++++++++++++++ src/cmd/root.go | 5 +- src/cmd/root_test.go | 10 ++ src/cmd/version.go | 2 +- src/utils/shell.go | 249 ++++++++++++++++++++++++++++++++++++ src/utils/shell_test.go | 217 +++++++++++++++++++++++++++++++ tools/gendocs/go.mod | 2 +- tools/gendocs/go.sum | 4 +- 25 files changed, 879 insertions(+), 174 deletions(-) create mode 100644 docs/awsd_init.md create mode 100644 docs/awsd_shellenv.md create mode 100644 src/cmd/init.go create mode 100644 src/cmd/init_test.go create mode 100644 src/utils/shell.go create mode 100644 src/utils/shell_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc6869..2a68ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## v0.3.0 (August 5, 2026) +* Added `awsd init ` — one line in your rc file (`eval "$(awsd init zsh)"`) now replaces the `awsd` alias, the completion `source`, and the persistence snippet from the README. [#53] +* Added fish support, and PowerShell tab completion. +* Added `awsd shellenv [shell]`, which prints the export/unset statements for the active profile and region. This is what the generated function evals. +* `~/.awsd` parsing now lives only in the Go code. `scripts/_awsd` and `scripts/powershell/awsd.ps1` are thin shims over `awsd shellenv`. +* Values are now shell-quoted, so profile names containing spaces or quotes work. +* Deprecated `alias awsd="source _awsd"` and `source _awsd_autocomplete`. Both still work; use `awsd init` instead. + ## v0.2.0 (April 27, 2026) * Added region switching: `awsd set region [name]` (interactive picker if no name given), `awsd unset region`, `awsd list regions`. * Added `awsd set profile [name]` and `awsd unset profile` as explicit forms — bare `awsd ` still works. diff --git a/Makefile b/Makefile index 29f5c5e..d28c737 100644 --- a/Makefile +++ b/Makefile @@ -7,17 +7,17 @@ install: ## Install Target GOOS= GOARCH= GOARM= GOFLAGS= go build -o ${BINDIR}/_awsd_prompt cp scripts/_awsd ${BINDIR}/_awsd cp scripts/_awsd_autocomplete ${BINDIR}/_awsd_autocomplete - @echo " -=-=--=-=-=-=-=-=-=-=-=-=-=-=- " - @echo " " - @echo " To Finish Installation add " - @echo " " - @echo " alias awsd=\"source _awsd\" " - @echo " " - @echo " to your bash profile or zshrc " - @echo " then open new terminal or " - @echo " source that file " - @echo " " - @echo " -=-=--=-=-=-=-=-=-=-=-=-=-=-=- " + @echo " -=-=--=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- " + @echo " " + @echo " To Finish Installation add " + @echo " " + @echo " eval \"\$$(_awsd_prompt init zsh)\" " + @echo " " + @echo " to your zshrc (or bash profile, " + @echo " with 'init bash') then open a new " + @echo " terminal or source that file " + @echo " " + @echo " -=-=--=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- " uninstall: ## Uninstall Target rm -f ${BINDIR}/_awsd diff --git a/README.md b/README.md index fdbd71a..e239849 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,13 @@ awsd is a command-line utility that allows you to easily switch between AWS Prof - [Makefile](#makefile) - [To Finish Installation](#to-finish-installation) - [Upgrading](#upgrading) + - [Upgrading from pre-v0.3.0](#upgrading-from-pre-v030) - [Usage](#usage) - [Switching AWS Profiles](#switching-aws-profiles) - [Switching AWS Regions](#switching-aws-regions) - - [Persist Profile and Region across new shells](#persist-profile-and-region-across-new-shells) - [Show your AWS Profile in your shell prompt](#show-your-aws-profile-in-your-shell-prompt) - [Add autocompletion](#add-autocompletion) - - [TL;DR (full config example)](#tldr-full-config-example) +- [Why a shell function?](#why-a-shell-function) - [Contributing](#contributing) - [License](#license) @@ -43,13 +43,35 @@ make install ``` ### To Finish Installation -Add the following to your bash profile or zshrc then open new terminal or source that file +Add one line to your shell's startup file, then open a new terminal or source that file. +**zsh** (`~/.zshrc`): ```sh -alias awsd="source _awsd" +eval "$(awsd init zsh)" ``` -Ex. `echo 'alias awsd="source _awsd"' >> ~/.zshrc` +**bash** (`~/.bashrc` or `~/.bash_profile`): +```sh +eval "$(awsd init bash)" +``` + +**fish** (`~/.config/fish/config.fish`): +```fish +awsd init fish | source +``` + +**PowerShell** (`$PROFILE`): +```powershell +awsd init powershell | Out-String | Invoke-Expression +``` + +Ex. `echo 'eval "$(awsd init zsh)"' >> ~/.zshrc` + +That one line defines the `awsd` command, sets up tab completion, and applies the profile and +region you last selected to every new shell. Nothing else to configure. + +If `awsd` isn't on your `PATH` yet (the binary installs as `_awsd_prompt`), use +`eval "$(_awsd_prompt init zsh)"` instead. ### Upgrading Upgrading consists of just doing a brew update and brew upgrade. @@ -58,6 +80,30 @@ Upgrading consists of just doing a brew update and brew upgrade. brew update && brew upgrade radiusmethod/awsd/awsd ``` +### Upgrading from pre-v0.3.0 +Before v0.3.0 you needed a hand-written alias, a separate completion `source`, and a block of +shell copied out of this README to persist your profile across shells: + +```sh +alias awsd="source _awsd" # no longer needed +source _awsd_autocomplete # no longer needed +if [ -f ~/.awsd ]; then ... # no longer needed +``` + +Replace all of it with `eval "$(awsd init zsh)"`. The old alias still works for now, but it is +deprecated and will be removed in a future release. + +Two things to check when you upgrade: + +- **Remove the old alias.** In zsh an alias shadows a function of the same name, so leaving + `alias awsd="source _awsd"` in place means the new `awsd` function never gets used. If the alias + is defined *before* the `eval` line, the eval fails outright with + `defining function based on alias 'awsd'`. +- **Put the `eval` line after any `PATH` changes** that point at your awsd install. It runs + `_awsd_prompt` at startup, so if an older copy is earlier in `PATH` at that moment you get + `(eval):1: bad pattern: ^[[0`. That is a pre-v0.3.0 binary printing `Profile init does not + exist` and zsh trying to eval the color codes. `type -a _awsd_prompt` shows you every copy. + ## Usage ### Switching AWS Profiles @@ -93,23 +139,8 @@ Region us-east-1 set. Setting a region exports `AWS_REGION` and `AWS_DEFAULT_REGION` in the calling shell. Profile and region are independent — `awsd set profile` does not change your region, and vice versa. -### Persist Profile and Region across new shells -To persist the active profile (and region) when you open new terminal windows, add the following to your bash profile or zshrc. It handles both the current `key=value` format and the legacy single-line format. - -```bash -if [ -f ~/.awsd ]; then - if grep -q '=' ~/.awsd; then - while IFS='=' read -r k v; do - case "$k" in - profile) [ -n "$v" ] && export AWS_PROFILE="$v" ;; - region) [ -n "$v" ] && export AWS_REGION="$v" AWS_DEFAULT_REGION="$v" ;; - esac - done < ~/.awsd - else - export AWS_PROFILE=$(cat ~/.awsd) - fi -fi -``` +Your selection persists across new terminal windows automatically, since `awsd init` applies +whatever is in `~/.awsd` when each shell starts. ### Show your AWS Profile in your shell prompt For better visibility into what your shell is set to it can be helpful to configure your prompt to show the value of the env variable `AWS_PROFILE`. @@ -132,30 +163,31 @@ PROMPT='OTHER_PROMPT_STUFF $(aws_info)' ``` ### Add autocompletion -Source the installed completion script from your bash profile or zshrc: +Tab completion comes with `awsd init`. It completes profile names on `awsd `, the +`set`/`unset`/`list` subcommands, and their arguments, so `awsd set region ` lists regions +and `awsd set profile ` lists profiles. -```bash -source _awsd_autocomplete +## Why a shell function? + +`awsd init` generates a shell function rather than shipping a plain binary, because a child +process cannot change its parent shell's environment. Anything that sets `AWS_PROFILE` for your +current shell has to run *in* that shell. + +So the binary does the picking and writes your choice to `~/.awsd`, and the generated function +asks it for the matching shell code and evals that: + +```sh +awsd() { + command _awsd_prompt "$@" || return + eval "$(command _awsd_prompt shellenv bash)" +} ``` -This completes profile names on `awsd `, the `set`/`unset`/`list` subcommands, and their arguments — e.g. `awsd set region ` lists regions, `awsd set profile ` lists profiles. +You can see exactly what gets eval'd at any time: -### TL;DR (full config example) -```bash -alias awsd="source _awsd" -source ~/bin/awsd_autocomplete.sh -if [ -f ~/.awsd ]; then - if grep -q '=' ~/.awsd; then - while IFS='=' read -r k v; do - case "$k" in - profile) [ -n "$v" ] && export AWS_PROFILE="$v" ;; - region) [ -n "$v" ] && export AWS_REGION="$v" AWS_DEFAULT_REGION="$v" ;; - esac - done < ~/.awsd - else - export AWS_PROFILE=$(cat ~/.awsd) - fi -fi +```sh +awsd init zsh # the whole integration +awsd shellenv zsh # just the exports for the current selection ``` ## Contributing diff --git a/docs/awsd.md b/docs/awsd.md index e042fbc..3036363 100644 --- a/docs/awsd.md +++ b/docs/awsd.md @@ -18,9 +18,11 @@ awsd [flags] ### SEE ALSO +* [awsd init](awsd_init.md) - Print the shell integration for the given shell. * [awsd list](awsd_list.md) - List AWS profiles or regions. * [awsd set](awsd_set.md) - Set the active AWS profile or region. +* [awsd shellenv](awsd_shellenv.md) - Print the shell code that applies ~/.awsd to the current shell. * [awsd unset](awsd_unset.md) - Unset the active AWS profile or region. * [awsd version](awsd_version.md) - awsd version command -###### Auto generated by spf13/cobra on 27-Apr-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/awsd_init.md b/docs/awsd_init.md new file mode 100644 index 0000000..bb6de29 --- /dev/null +++ b/docs/awsd_init.md @@ -0,0 +1,37 @@ +## awsd init + +Print the shell integration for the given shell. + +### Synopsis + +Print the shell integration for the given shell: the awsd function, completion, and the hook that applies the profile/region in ~/.awsd to new shells. + +awsd has to run inside your shell to export AWS_PROFILE, because a child process cannot change its parent's environment. Eval this from your rc file: + + bash/zsh: eval "$(awsd init zsh)" + fish: awsd init fish | source + PowerShell: awsd init powershell | Out-String | Invoke-Expression + +``` +awsd init [flags] +``` + +### Examples + +``` + eval "$(awsd init zsh)" + + # supported: bash, zsh, fish, powershell +``` + +### Options + +``` + -h, --help help for init +``` + +### SEE ALSO + +* [awsd](awsd.md) - awsd - switch between AWS profiles. + +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/awsd_list.md b/docs/awsd_list.md index 52d52b3..4df135b 100644 --- a/docs/awsd_list.md +++ b/docs/awsd_list.md @@ -20,4 +20,4 @@ awsd list [profiles|regions] [flags] * [awsd](awsd.md) - awsd - switch between AWS profiles. -###### Auto generated by spf13/cobra on 27-Apr-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/awsd_set.md b/docs/awsd_set.md index a69539b..70fbc65 100644 --- a/docs/awsd_set.md +++ b/docs/awsd_set.md @@ -18,4 +18,4 @@ Set the active AWS profile or region. With no subcommand, prints help. * [awsd set profile](awsd_set_profile.md) - Set the active AWS profile (interactive picker if no name given). * [awsd set region](awsd_set_region.md) - Set the active AWS region (interactive picker if no region given). -###### Auto generated by spf13/cobra on 27-Apr-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/awsd_set_profile.md b/docs/awsd_set_profile.md index 7d8154b..fa24a47 100644 --- a/docs/awsd_set_profile.md +++ b/docs/awsd_set_profile.md @@ -16,4 +16,4 @@ awsd set profile [name] [flags] * [awsd set](awsd_set.md) - Set the active AWS profile or region. -###### Auto generated by spf13/cobra on 27-Apr-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/awsd_set_region.md b/docs/awsd_set_region.md index 127f3ab..74941f4 100644 --- a/docs/awsd_set_region.md +++ b/docs/awsd_set_region.md @@ -16,4 +16,4 @@ awsd set region [region] [flags] * [awsd set](awsd_set.md) - Set the active AWS profile or region. -###### Auto generated by spf13/cobra on 27-Apr-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/awsd_shellenv.md b/docs/awsd_shellenv.md new file mode 100644 index 0000000..0888eb7 --- /dev/null +++ b/docs/awsd_shellenv.md @@ -0,0 +1,23 @@ +## awsd shellenv + +Print the shell code that applies ~/.awsd to the current shell. + +### Synopsis + +Print the export/unset statements for the active profile and region. Used by the function that `awsd init` generates; you should not need to call it directly. Defaults to POSIX (bash/zsh) syntax. + +``` +awsd shellenv [shell] [flags] +``` + +### Options + +``` + -h, --help help for shellenv +``` + +### SEE ALSO + +* [awsd](awsd.md) - awsd - switch between AWS profiles. + +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/awsd_unset.md b/docs/awsd_unset.md index 5237958..68a115b 100644 --- a/docs/awsd_unset.md +++ b/docs/awsd_unset.md @@ -18,4 +18,4 @@ Clear the active AWS profile (back to default) or AWS region. * [awsd unset profile](awsd_unset_profile.md) - Reset the active AWS profile to default. * [awsd unset region](awsd_unset_region.md) - Clear the active AWS region. -###### Auto generated by spf13/cobra on 27-Apr-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/awsd_unset_profile.md b/docs/awsd_unset_profile.md index 951832d..3844ff8 100644 --- a/docs/awsd_unset_profile.md +++ b/docs/awsd_unset_profile.md @@ -16,4 +16,4 @@ awsd unset profile [flags] * [awsd unset](awsd_unset.md) - Unset the active AWS profile or region. -###### Auto generated by spf13/cobra on 27-Apr-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/awsd_unset_region.md b/docs/awsd_unset_region.md index dbfbf16..358f01f 100644 --- a/docs/awsd_unset_region.md +++ b/docs/awsd_unset_region.md @@ -16,4 +16,4 @@ awsd unset region [flags] * [awsd unset](awsd_unset.md) - Unset the active AWS profile or region. -###### Auto generated by spf13/cobra on 27-Apr-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/awsd_version.md b/docs/awsd_version.md index 20b7ad3..1d1077e 100644 --- a/docs/awsd_version.md +++ b/docs/awsd_version.md @@ -20,4 +20,4 @@ awsd version [flags] * [awsd](awsd.md) - awsd - switch between AWS profiles. -###### Auto generated by spf13/cobra on 27-Apr-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/scripts/_awsd b/scripts/_awsd index c6c2b6b..3ddec23 100755 --- a/scripts/_awsd +++ b/scripts/_awsd @@ -1,49 +1,14 @@ #!/usr/bin/env bash - -# check if $1 is empty -if [ -z "$1" ] -then - # no argument passed - AWS_PROFILE="$AWS_PROFILE" _awsd_prompt -else - # argument passed, assume it's a profile name - AWS_PROFILE="$AWS_PROFILE" _awsd_prompt "$@" -fi - -touch ~/.awsd - -unset _awsd_profile _awsd_region _awsd_has_region - -# Detect legacy single-line format (no '=' anywhere): whole file is a profile name. -if grep -q '=' ~/.awsd -then - while IFS='=' read -r _awsd_k _awsd_v - do - case "$_awsd_k" in - profile) _awsd_profile=$_awsd_v ;; - region) _awsd_region=$_awsd_v; _awsd_has_region=1 ;; - esac - done < ~/.awsd -else - _awsd_profile="$(cat ~/.awsd)" +# +# Deprecated. Prefer the generated integration, which also wires up completion +# and picks up the active profile in new shells: +# +# eval "$(awsd init bash)" # or: awsd init zsh +# +# This file is kept so existing `alias awsd="source _awsd"` setups keep +# working. It must be sourced, not executed. The ~/.awsd parsing that used to +# live here now lives in the binary, behind `_awsd_prompt shellenv`. + +if _awsd_prompt "$@"; then + eval "$(_awsd_prompt shellenv bash)" fi - -if [ -z "$_awsd_profile" ] -then - unset AWS_PROFILE -else - export AWS_PROFILE="$_awsd_profile" -fi - -if [ -n "$_awsd_has_region" ] -then - if [ -z "$_awsd_region" ] - then - unset AWS_REGION AWS_DEFAULT_REGION - else - export AWS_REGION="$_awsd_region" - export AWS_DEFAULT_REGION="$_awsd_region" - fi -fi - -unset _awsd_profile _awsd_region _awsd_has_region _awsd_k _awsd_v diff --git a/scripts/powershell/awsd.ps1 b/scripts/powershell/awsd.ps1 index 96d7893..43ee55b 100644 --- a/scripts/powershell/awsd.ps1 +++ b/scripts/powershell/awsd.ps1 @@ -1,61 +1,12 @@ -# check if $1 is empty -if (-not $args) -{ - # no argument passed - Set-Variable -Name "AWS_PROFILE" -Value "$env:AWS_PROFILE" - awsd_prompt -} -else -{ - # argument passed, assume it's a profile name - Set-Variable -Name "AWS_PROFILE" -Value "$env:AWS_PROFILE" - awsd_prompt $args -} - -$awsd_profile = $null -$awsd_region = $null -$awsd_has_region = $false - -if (Test-Path "$env:USERPROFILE\.awsd") -{ - $lines = Get-Content "$env:USERPROFILE\.awsd" - $hasKV = $false - foreach ($line in $lines) { - if ($line -match '=') { $hasKV = $true; break } - } - - if ($hasKV) { - foreach ($line in $lines) { - if ($line -match '^\s*([^=]+?)\s*=\s*(.*)$') { - $key = $Matches[1] - $val = $Matches[2].Trim() - switch ($key) { - 'profile' { $awsd_profile = $val } - 'region' { $awsd_region = $val; $awsd_has_region = $true } - } - } - } - } else { - # Legacy single-line format: whole file is a profile name. - $awsd_profile = ($lines | Out-String).Trim() - } -} - -if (-not $awsd_profile) -{ - $env:AWS_PROFILE = $null -} -else -{ - $env:AWS_PROFILE = $awsd_profile -} - -if ($awsd_has_region) { - if (-not $awsd_region) { - $env:AWS_REGION = $null - $env:AWS_DEFAULT_REGION = $null - } else { - $env:AWS_REGION = $awsd_region - $env:AWS_DEFAULT_REGION = $awsd_region - } +# Deprecated. Prefer the generated integration, which also wires up completion +# and picks up the active profile in new shells. Add to your $PROFILE: +# +# awsd init powershell | Out-String | Invoke-Expression +# +# This file is kept so existing setups keep working. The ~/.awsd parsing that +# used to live here now lives in the binary, behind `awsd_prompt shellenv`. + +& awsd_prompt @args +if ($LASTEXITCODE -eq 0) { + & awsd_prompt shellenv powershell | Out-String | Invoke-Expression } diff --git a/src/cmd/init.go b/src/cmd/init.go new file mode 100644 index 0000000..44de9b5 --- /dev/null +++ b/src/cmd/init.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "fmt" + "log" + "strings" + + "github.com/radiusmethod/awsd/src/utils" + "github.com/spf13/cobra" +) + +var initCmd = &cobra.Command{ + Use: "init ", + Short: "Print the shell integration for the given shell.", + Long: "Print the shell integration for the given shell: the awsd function, completion, " + + "and the hook that applies the profile/region in ~/.awsd to new shells.\n\n" + + "awsd has to run inside your shell to export AWS_PROFILE, because a child process " + + "cannot change its parent's environment. Eval this from your rc file:\n\n" + + " bash/zsh: eval \"$(awsd init zsh)\"\n" + + " fish: awsd init fish | source\n" + + " PowerShell: awsd init powershell | Out-String | Invoke-Expression", + ValidArgs: utils.AcceptedShells, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + Run: func(cmd *cobra.Command, args []string) { + shell, err := utils.ParseShell(args[0]) + if err != nil { + log.Fatal(err) + } + fmt.Print(utils.InitScript(shell)) + }, +} + +var shellenvCmd = &cobra.Command{ + Use: "shellenv [shell]", + Short: "Print the shell code that applies ~/.awsd to the current shell.", + Long: "Print the export/unset statements for the active profile and region. " + + "Used by the function that `awsd init` generates; you should not need to call it directly. " + + "Defaults to POSIX (bash/zsh) syntax.", + ValidArgs: utils.AcceptedShells, + Args: cobra.MatchAll(cobra.MaximumNArgs(1), cobra.OnlyValidArgs), + Run: func(cmd *cobra.Command, args []string) { + shell := utils.Bash + if len(args) == 1 { + s, err := utils.ParseShell(args[0]) + if err != nil { + log.Fatal(err) + } + shell = s + } + homeDir, err := utils.GetHomeDir() + if err != nil { + log.Fatal(err) + } + state, err := utils.ReadState(homeDir) + if err != nil { + log.Fatal(err) + } + fmt.Print(utils.ShellEnv(state, shell)) + }, +} + +func init() { + initCmd.Example = fmt.Sprintf(" eval \"$(awsd init zsh)\"\n\n # supported: %s", strings.Join(utils.SupportedShells, ", ")) + rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(shellenvCmd) +} diff --git a/src/cmd/init_test.go b/src/cmd/init_test.go new file mode 100644 index 0000000..f45995c --- /dev/null +++ b/src/cmd/init_test.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/radiusmethod/awsd/src/utils/testutils" + "github.com/stretchr/testify/assert" +) + +// captureStdout runs fn with os.Stdout redirected and returns what it printed. +// The commands print with fmt.Print rather than through cobra's writer, since +// their output is meant to be eval'd by the shell. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("Failed to create pipe: %v", err) + } + os.Stdout = w + + fn() + + if err := w.Close(); err != nil { + t.Fatalf("Failed to close pipe: %v", err) + } + os.Stdout = orig + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("Failed to read pipe: %v", err) + } + return buf.String() +} + +func TestShellenvCommand(t *testing.T) { + tempDir := testutils.CreateTempDir(t) + defer testutils.CleanupTempDir(t, tempDir) + t.Setenv("HOME", tempDir) + + tests := []struct { + name string + awsdFile string + args []string + expected string + }{ + { + name: "Profile and region, default shell", + awsdFile: "profile=dev\nregion=us-east-1\n", + args: []string{}, + expected: "export AWS_PROFILE='dev'\nexport AWS_REGION='us-east-1'\nexport AWS_DEFAULT_REGION='us-east-1'\n", + }, + { + name: "Explicit shell argument", + awsdFile: "profile=dev\n", + args: []string{"fish"}, + expected: "set -gx AWS_PROFILE 'dev'\n", + }, + { + name: "Empty profile unsets", + awsdFile: "profile=\n", + args: []string{"bash"}, + expected: "unset AWS_PROFILE\n", + }, + { + name: "Legacy single-line file", + awsdFile: "dev\n", + args: []string{"bash"}, + expected: "export AWS_PROFILE='dev'\n", + }, + { + name: "No region line leaves region alone", + awsdFile: "profile=dev\n", + args: []string{"bash"}, + expected: "export AWS_PROFILE='dev'\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + awsdPath := filepath.Join(tempDir, ".awsd") + if err := os.WriteFile(awsdPath, []byte(tt.awsdFile), 0644); err != nil { + t.Fatalf("Failed to write .awsd: %v", err) + } + out := captureStdout(t, func() { + shellenvCmd.Run(shellenvCmd, tt.args) + }) + assert.Equal(t, tt.expected, out) + }) + } +} + +func TestShellenvCommandNoStateFile(t *testing.T) { + tempDir := testutils.CreateTempDir(t) + defer testutils.CleanupTempDir(t, tempDir) + t.Setenv("HOME", tempDir) + + out := captureStdout(t, func() { + shellenvCmd.Run(shellenvCmd, []string{"bash"}) + }) + assert.Equal(t, "unset AWS_PROFILE\n", out) +} + +func TestInitCommand(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish", "powershell", "pwsh"} { + t.Run(shell, func(t *testing.T) { + out := captureStdout(t, func() { + initCmd.Run(initCmd, []string{shell}) + }) + assert.NotEmpty(t, out) + assert.Contains(t, out, "awsd") + }) + } +} + +func TestInitCommandArgValidation(t *testing.T) { + tests := []struct { + name string + args []string + expectError bool + }{ + {name: "Valid shell", args: []string{"zsh"}}, + {name: "Unsupported shell", args: []string{"csh"}, expectError: true}, + {name: "No shell", args: []string{}, expectError: true}, + {name: "Too many args", args: []string{"zsh", "bash"}, expectError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := initCmd.Args(initCmd, tt.args) + if tt.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} diff --git a/src/cmd/root.go b/src/cmd/root.go index 8976096..59eb993 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -66,7 +66,10 @@ func runProfileSwitcher() error { } func shouldRunDirectProfileSwitch() bool { - invalidProfiles := []string{"l", "list", "set", "unset", "completion", "help", "--help", "-h", "v", "version"} + // Any argv[1] not in this list is treated as a profile name, so every + // subcommand and alias has to be listed here. Escape hatch for a profile + // that collides with one of these: awsd set profile . + invalidProfiles := []string{"l", "list", "set", "unset", "init", "shellenv", "completion", "help", "--help", "-h", "v", "version"} return len(os.Args) > 1 && !utils.Contains(invalidProfiles, os.Args[1]) } diff --git a/src/cmd/root_test.go b/src/cmd/root_test.go index a80614f..a190e8a 100644 --- a/src/cmd/root_test.go +++ b/src/cmd/root_test.go @@ -45,6 +45,16 @@ func TestShouldRunDirectProfileSwitch(t *testing.T) { args: []string{"awsd", "unset"}, expected: false, }, + { + name: "Init command", + args: []string{"awsd", "init"}, + expected: false, + }, + { + name: "Shellenv command", + args: []string{"awsd", "shellenv"}, + expected: false, + }, { name: "No arguments", args: []string{"awsd"}, diff --git a/src/cmd/version.go b/src/cmd/version.go index a4723f4..7becc24 100644 --- a/src/cmd/version.go +++ b/src/cmd/version.go @@ -5,7 +5,7 @@ import ( "github.com/spf13/cobra" ) -var version string = "v0.2.0" +var version string = "v0.3.0" var versionCmd = &cobra.Command{ Use: "version", diff --git a/src/utils/shell.go b/src/utils/shell.go new file mode 100644 index 0000000..1487fa2 --- /dev/null +++ b/src/utils/shell.go @@ -0,0 +1,249 @@ +package utils + +import ( + "fmt" + "strings" +) + +// Shell identifies a supported shell dialect for `awsd init` / `awsd shellenv`. +type Shell string + +const ( + Bash Shell = "bash" + Zsh Shell = "zsh" + Fish Shell = "fish" + PowerShell Shell = "powershell" +) + +// SupportedShells is the canonical list, in help/doc order. +var SupportedShells = []string{"bash", "zsh", "fish", "powershell"} + +// AcceptedShells is everything ParseShell takes, including aliases. Used for +// argument validation and completion. +var AcceptedShells = append(append([]string{}, SupportedShells...), "pwsh") + +// ParseShell maps a user-supplied shell name to a Shell. +func ParseShell(name string) (Shell, error) { + switch strings.ToLower(strings.TrimSpace(name)) { + case "bash": + return Bash, nil + case "zsh": + return Zsh, nil + case "fish": + return Fish, nil + case "powershell", "pwsh": + return PowerShell, nil + } + return "", fmt.Errorf("unsupported shell %q (supported: %s)", name, strings.Join(SupportedShells, ", ")) +} + +// quote wraps a value in single quotes, escaped for the given shell. Profile +// names come from ~/.aws/config and can contain spaces and quotes, so this has +// to hold up against anything that ends up in ~/.awsd. +func quote(sh Shell, value string) string { + switch sh { + case Fish: + // Inside fish single quotes only \ and ' are special. + r := strings.NewReplacer(`\`, `\\`, `'`, `\'`) + return "'" + r.Replace(value) + "'" + case PowerShell: + // Inside PowerShell single quotes only ' is special, doubled to escape. + return "'" + strings.ReplaceAll(value, "'", "''") + "'" + default: + // POSIX: end the quoted run, emit an escaped quote, reopen. + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" + } +} + +func exportLine(sh Shell, name, value string) string { + switch sh { + case Fish: + return fmt.Sprintf("set -gx %s %s", name, quote(sh, value)) + case PowerShell: + return fmt.Sprintf("$env:%s = %s", name, quote(sh, value)) + default: + return fmt.Sprintf("export %s=%s", name, quote(sh, value)) + } +} + +func unsetLines(sh Shell, names ...string) []string { + switch sh { + case Fish: + return []string{"set -e " + strings.Join(names, " ")} + case PowerShell: + lines := make([]string, 0, len(names)) + for _, n := range names { + lines = append(lines, fmt.Sprintf("$env:%s = $null", n)) + } + return lines + default: + return []string{"unset " + strings.Join(names, " ")} + } +} + +// ShellEnv renders the env mutations for a given state as shell code, ready to +// be eval'd by the function that `awsd init` generates. It is the single +// implementation of the ~/.awsd contract that the wrapper scripts used to +// duplicate per shell: +// +// - empty profile -> unset AWS_PROFILE (this is "default") +// - no region= line -> leave AWS_REGION/AWS_DEFAULT_REGION alone +// - region= with no value -> unset both +func ShellEnv(s State, sh Shell) string { + var lines []string + + if s.Profile == "" { + lines = append(lines, unsetLines(sh, "AWS_PROFILE")...) + } else { + lines = append(lines, exportLine(sh, "AWS_PROFILE", s.Profile)) + } + + if s.RegionSet { + if s.Region == "" { + lines = append(lines, unsetLines(sh, "AWS_REGION", "AWS_DEFAULT_REGION")...) + } else { + lines = append(lines, + exportLine(sh, "AWS_REGION", s.Region), + exportLine(sh, "AWS_DEFAULT_REGION", s.Region), + ) + } + } + + return strings.Join(lines, "\n") + "\n" +} + +// InitScript returns the shell integration to be eval'd from a shell rc file. +// It defines the awsd function, wires up completion, and applies the state +// already in ~/.awsd so a new shell starts on the active profile. +func InitScript(sh Shell) string { + switch sh { + case Bash: + return posixInit(Bash, "") + case Zsh: + return posixInit(Zsh, zshCompinit) + case Fish: + return fishInit + case PowerShell: + return powershellInit + } + return "" +} + +// zsh needs bashcompinit to get the bash-style `complete` builtin, and +// bashcompinit needs compinit to have run first for compdef to exist. Most +// setups (oh-my-zsh and friends) have already run it, so only pay for it here +// if nothing else did. +const zshCompinit = `if (( ! $+functions[compdef] )); then + autoload -Uz compinit && compinit +fi +autoload -U +X bashcompinit && bashcompinit +` + +// posixInit covers bash and zsh, which differ only in needing bashcompinit to +// provide the bash-style `complete` builtin. +func posixInit(sh Shell, compinit string) string { + return fmt.Sprintf(posixInitTemplate, string(sh), compinit) +} + +const posixInitTemplate = `# awsd shell integration (%[1]s). Generated by: awsd init %[1]s +# Add to your rc file with: eval "$(awsd init %[1]s)" + +awsd() { + command _awsd_prompt "$@" || return + eval "$(command _awsd_prompt shellenv %[1]s)" +} + +_awsd_completion() { + local cur=${COMP_WORDS[COMP_CWORD]} + local prev=${COMP_WORDS[COMP_CWORD-1]} + local subcmd=${COMP_WORDS[1]} + local suggestions + + case "$COMP_CWORD" in + 1) + suggestions="$(command _awsd_prompt list profiles) list set unset" + ;; + 2) + case "$subcmd" in + set|unset) suggestions="profile region" ;; + list|l) suggestions="profiles regions" ;; + *) suggestions="" ;; + esac + ;; + 3) + case "$subcmd $prev" in + "set profile") suggestions="$(command _awsd_prompt list profiles)" ;; + "set region") suggestions="$(command _awsd_prompt list regions)" ;; + *) suggestions="" ;; + esac + ;; + *) + suggestions="" + ;; + esac + + COMPREPLY=($(compgen -W "$suggestions" -- "$cur")) + return 0 +} + +%[2]scomplete -o nospace -F _awsd_completion awsd + +# Apply the profile/region already in ~/.awsd to this shell. +eval "$(command _awsd_prompt shellenv %[1]s)" +` + +const fishInit = `# awsd shell integration (fish). Generated by: awsd init fish +# Add to ~/.config/fish/config.fish with: awsd init fish | source + +function awsd + command _awsd_prompt $argv; or return + command _awsd_prompt shellenv fish | source +end + +complete -c awsd -f +complete -c awsd -n __fish_use_subcommand -a '(command _awsd_prompt list profiles)' +complete -c awsd -n __fish_use_subcommand -a 'list set unset' +complete -c awsd -n '__fish_seen_subcommand_from set unset' -a 'profile region' +complete -c awsd -n '__fish_seen_subcommand_from list l' -a 'profiles regions' +complete -c awsd -n '__fish_seen_subcommand_from profile' -a '(command _awsd_prompt list profiles)' +complete -c awsd -n '__fish_seen_subcommand_from region' -a '(command _awsd_prompt list regions)' + +# Apply the profile/region already in ~/.awsd to this shell. +command _awsd_prompt shellenv fish | source +` + +const powershellInit = `# awsd shell integration (PowerShell). Generated by: awsd init powershell +# Add to your $PROFILE with: awsd init powershell | Out-String | Invoke-Expression + +function awsd { + param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $Arguments) + & _awsd_prompt @Arguments + if ($LASTEXITCODE -ne 0) { return } + & _awsd_prompt shellenv powershell | Out-String | Invoke-Expression +} + +Register-ArgumentCompleter -CommandName awsd -ParameterName Arguments -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + + $words = @($commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { $_.ToString() }) + if ($wordToComplete) { $words = @($words | Select-Object -SkipLast 1) } + + $suggestions = @() + if ($words.Count -eq 0) { + $suggestions = @(& _awsd_prompt list profiles) + @('list', 'set', 'unset') + } elseif ($words.Count -eq 1) { + if ($words[0] -in @('set', 'unset')) { $suggestions = @('profile', 'region') } + elseif ($words[0] -in @('list', 'l')) { $suggestions = @('profiles', 'regions') } + } elseif ($words.Count -eq 2 -and $words[0] -eq 'set') { + if ($words[1] -eq 'profile') { $suggestions = @(& _awsd_prompt list profiles) } + elseif ($words[1] -eq 'region') { $suggestions = @(& _awsd_prompt list regions) } + } + + $suggestions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } +} + +# Apply the profile/region already in ~/.awsd to this shell. +& _awsd_prompt shellenv powershell | Out-String | Invoke-Expression +` diff --git a/src/utils/shell_test.go b/src/utils/shell_test.go new file mode 100644 index 0000000..6ad2389 --- /dev/null +++ b/src/utils/shell_test.go @@ -0,0 +1,217 @@ +package utils + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseShell(t *testing.T) { + tests := []struct { + name string + input string + expected Shell + expectError bool + }{ + {name: "bash", input: "bash", expected: Bash}, + {name: "zsh", input: "zsh", expected: Zsh}, + {name: "fish", input: "fish", expected: Fish}, + {name: "powershell", input: "powershell", expected: PowerShell}, + {name: "pwsh alias", input: "pwsh", expected: PowerShell}, + {name: "case insensitive", input: "PowerShell", expected: PowerShell}, + {name: "surrounding space", input: " zsh ", expected: Zsh}, + {name: "unknown shell", input: "csh", expectError: true}, + {name: "empty", input: "", expectError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + shell, err := ParseShell(tt.input) + if tt.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.expected, shell) + }) + } +} + +func TestShellEnv(t *testing.T) { + tests := []struct { + name string + state State + shell Shell + expected string + }{ + { + name: "Profile only, posix", + state: State{Profile: "dev"}, + shell: Bash, + expected: "export AWS_PROFILE='dev'\n", + }, + { + name: "Profile and region, posix", + state: State{Profile: "dev", Region: "us-east-1", RegionSet: true}, + shell: Zsh, + expected: "export AWS_PROFILE='dev'\nexport AWS_REGION='us-east-1'\nexport AWS_DEFAULT_REGION='us-east-1'\n", + }, + { + name: "Empty profile unsets, posix", + state: State{}, + shell: Bash, + expected: "unset AWS_PROFILE\n", + }, + { + name: "Explicit empty region unsets both, posix", + state: State{Profile: "dev", RegionSet: true}, + shell: Bash, + expected: "export AWS_PROFILE='dev'\nunset AWS_REGION AWS_DEFAULT_REGION\n", + }, + { + name: "Profile and region, fish", + state: State{Profile: "dev", Region: "us-east-1", RegionSet: true}, + shell: Fish, + expected: "set -gx AWS_PROFILE 'dev'\nset -gx AWS_REGION 'us-east-1'\nset -gx AWS_DEFAULT_REGION 'us-east-1'\n", + }, + { + name: "Empty profile and empty region, fish", + state: State{RegionSet: true}, + shell: Fish, + expected: "set -e AWS_PROFILE\nset -e AWS_REGION AWS_DEFAULT_REGION\n", + }, + { + name: "Profile and region, powershell", + state: State{Profile: "dev", Region: "us-east-1", RegionSet: true}, + shell: PowerShell, + expected: "$env:AWS_PROFILE = 'dev'\n$env:AWS_REGION = 'us-east-1'\n$env:AWS_DEFAULT_REGION = 'us-east-1'\n", + }, + { + name: "Empty profile and empty region, powershell", + state: State{RegionSet: true}, + shell: PowerShell, + expected: "$env:AWS_PROFILE = $null\n$env:AWS_REGION = $null\n$env:AWS_DEFAULT_REGION = $null\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, ShellEnv(tt.state, tt.shell)) + }) + } +} + +// Profile names come from ~/.aws/config, so they can contain anything a section +// header allows. Unquoted output would let that run as shell code. +func TestShellEnvQuoting(t *testing.T) { + tests := []struct { + name string + shell Shell + profile string + expected string + }{ + { + name: "Space, posix", + shell: Bash, + profile: "my profile", + expected: "export AWS_PROFILE='my profile'\n", + }, + { + name: "Single quote, posix", + shell: Bash, + profile: "we'ird", + expected: `export AWS_PROFILE='we'\''ird'` + "\n", + }, + { + name: "Command substitution stays literal, posix", + shell: Bash, + profile: "$(whoami)`id`", + expected: "export AWS_PROFILE='$(whoami)`id`'\n", + }, + { + name: "Single quote, fish", + shell: Fish, + profile: "we'ird", + expected: `set -gx AWS_PROFILE 'we\'ird'` + "\n", + }, + { + name: "Backslash, fish", + shell: Fish, + profile: `back\slash`, + expected: `set -gx AWS_PROFILE 'back\\slash'` + "\n", + }, + { + name: "Single quote, powershell", + shell: PowerShell, + profile: "we'ird", + expected: "$env:AWS_PROFILE = 'we''ird'\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ShellEnv(State{Profile: tt.profile}, tt.shell) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestInitScript(t *testing.T) { + tests := []struct { + name string + shell Shell + contains []string + }{ + { + name: "bash", + shell: Bash, + contains: []string{"awsd() {", `eval "$(command _awsd_prompt shellenv bash)"`, "complete -o nospace -F _awsd_completion awsd"}, + }, + { + name: "zsh", + shell: Zsh, + contains: []string{"awsd() {", `eval "$(command _awsd_prompt shellenv zsh)"`, "bashcompinit"}, + }, + { + name: "fish", + shell: Fish, + contains: []string{"function awsd", "command _awsd_prompt shellenv fish | source", "complete -c awsd"}, + }, + { + name: "powershell", + shell: PowerShell, + contains: []string{"function awsd", "& _awsd_prompt shellenv powershell | Out-String | Invoke-Expression", "Register-ArgumentCompleter"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + script := InitScript(tt.shell) + assert.NotEmpty(t, script) + for _, want := range tt.contains { + assert.Contains(t, script, want) + } + }) + } +} + +// bashcompinit only provides `complete` for zsh; bash must not carry it. +func TestInitScriptBashHasNoCompinit(t *testing.T) { + assert.NotContains(t, InitScript(Bash), "bashcompinit") +} + +func TestInitScriptUnknownShell(t *testing.T) { + assert.Empty(t, InitScript(Shell("csh"))) +} + +// Every accepted shell has to produce a script, or `awsd init ` would +// validate the argument and then print nothing. +func TestInitScriptCoversAcceptedShells(t *testing.T) { + for _, name := range AcceptedShells { + shell, err := ParseShell(name) + assert.NoError(t, err) + assert.NotEmpty(t, InitScript(shell), "no init script for %s", name) + assert.True(t, strings.HasSuffix(InitScript(shell), "\n"), "%s script must end in a newline", name) + } +} diff --git a/tools/gendocs/go.mod b/tools/gendocs/go.mod index eb6ac53..dc52690 100644 --- a/tools/gendocs/go.mod +++ b/tools/gendocs/go.mod @@ -18,5 +18,5 @@ require ( github.com/spf13/pflag v1.0.9 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.30.0 // indirect - gopkg.in/ini.v1 v1.67.1 // indirect + gopkg.in/ini.v1 v1.67.3 // indirect ) diff --git a/tools/gendocs/go.sum b/tools/gendocs/go.sum index 60063a1..073031c 100644 --- a/tools/gendocs/go.sum +++ b/tools/gendocs/go.sum @@ -37,8 +37,8 @@ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= -gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 97defc81b1c35f2ae4e34e3f357971ad4719f78c Mon Sep 17 00:00:00 2001 From: Patrick Joyce Date: Wed, 5 Aug 2026 10:07:46 -0400 Subject: [PATCH 2/2] change exit code --- CHANGELOG.md | 1 + src/cmd/init_test.go | 24 +++++++++++++++++------- src/cmd/root.go | 38 +++++++++++++++++++++++++++++++------- src/cmd/root_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++-- src/cmd/set.go | 4 +--- 5 files changed, 92 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a68ade..b7501a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Added `awsd shellenv [shell]`, which prints the export/unset statements for the active profile and region. This is what the generated function evals. * `~/.awsd` parsing now lives only in the Go code. `scripts/_awsd` and `scripts/powershell/awsd.ps1` are thin shims over `awsd shellenv`. * Values are now shell-quoted, so profile names containing spaces or quotes work. +* **Behavior change:** `awsd ` now writes its warning to stderr and exits 1, instead of writing to stdout and exiting 0. Scripts that relied on the old exit code need updating. This keeps the warning out of the command substitutions the shell integration evals, where ANSI color codes surfaced as `bad pattern: ^[[0` rather than a readable message. * Deprecated `alias awsd="source _awsd"` and `source _awsd_autocomplete`. Both still work; use `awsd init` instead. ## v0.2.0 (April 27, 2026) diff --git a/src/cmd/init_test.go b/src/cmd/init_test.go index f45995c..1f05466 100644 --- a/src/cmd/init_test.go +++ b/src/cmd/init_test.go @@ -11,24 +11,24 @@ import ( "github.com/stretchr/testify/assert" ) -// captureStdout runs fn with os.Stdout redirected and returns what it printed. -// The commands print with fmt.Print rather than through cobra's writer, since -// their output is meant to be eval'd by the shell. -func captureStdout(t *testing.T, fn func()) string { +// captureStream runs fn with the given stream redirected and returns what was +// written to it. The commands print with fmt.Print rather than through cobra's +// writer, since their output is meant to be eval'd by the shell. +func captureStream(t *testing.T, stream **os.File, fn func()) string { t.Helper() - orig := os.Stdout + orig := *stream r, w, err := os.Pipe() if err != nil { t.Fatalf("Failed to create pipe: %v", err) } - os.Stdout = w + *stream = w fn() if err := w.Close(); err != nil { t.Fatalf("Failed to close pipe: %v", err) } - os.Stdout = orig + *stream = orig var buf bytes.Buffer if _, err := io.Copy(&buf, r); err != nil { @@ -37,6 +37,16 @@ func captureStdout(t *testing.T, fn func()) string { return buf.String() } +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + return captureStream(t, &os.Stdout, fn) +} + +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + return captureStream(t, &os.Stderr, fn) +} + func TestShellenvCommand(t *testing.T) { tempDir := testutils.CreateTempDir(t) defer testutils.CleanupTempDir(t, tempDir) diff --git a/src/cmd/root.go b/src/cmd/root.go index 59eb993..871991b 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "log" "os" @@ -10,6 +11,11 @@ import ( "github.com/spf13/cobra" ) +// errProfileNotFound is returned when argv named a profile that isn't in the +// AWS config. The generated shell function keys off the exit code, so this has +// to fail rather than exit 0. +var errProfileNotFound = errors.New("profile does not exist") + var rootCmd = &cobra.Command{ Use: "awsd", Short: "awsd - switch between AWS profiles.", @@ -30,9 +36,7 @@ func RootCmd() *cobra.Command { func Execute() { if shouldRunDirectProfileSwitch() { profile := os.Args[1] - if err := directProfileSwitch(profile); err != nil { - log.Fatal(err) - } + handleSwitchError(directProfileSwitch(profile)) return } runRootCmd() @@ -88,10 +92,23 @@ func directProfileSwitch(desiredProfile string) error { } return utils.WriteFile(desiredProfile, homeDir) } - printColoredMessage("WARNING: Profile ", utils.NoticeColor) - printColoredMessage(desiredProfile, utils.CyanColor) - printColoredMessage(" does not exist.\n", utils.PromptColor) - return nil + printColoredWarning("WARNING: Profile ", utils.NoticeColor) + printColoredWarning(desiredProfile, utils.CyanColor) + printColoredWarning(" does not exist.\n", utils.PromptColor) + return fmt.Errorf("%w: %s", errProfileNotFound, desiredProfile) +} + +// handleSwitchError exits non-zero when a profile switch fails. A missing +// profile has already been reported on stderr, so exit quietly instead of +// printing it a second time through log.Fatal. +func handleSwitchError(err error) { + if err == nil { + return + } + if errors.Is(err, errProfileNotFound) { + os.Exit(1) + } + log.Fatal(err) } func getProfileFromPrompt(profiles []string) (string, error) { @@ -123,3 +140,10 @@ func getProfileFromPrompt(profiles []string) (string, error) { func printColoredMessage(msg, color string) { fmt.Printf(color, msg) } + +// printColoredWarning writes to stderr. Anything the shell integration might +// capture in a command substitution has to stay off stdout, or the caller ends +// up trying to eval ANSI escapes. +func printColoredWarning(msg, color string) { + fmt.Fprintf(os.Stderr, color, msg) +} diff --git a/src/cmd/root_test.go b/src/cmd/root_test.go index a190e8a..864e270 100644 --- a/src/cmd/root_test.go +++ b/src/cmd/root_test.go @@ -96,7 +96,7 @@ func TestDirectProfileSwitch(t *testing.T) { { name: "Invalid profile", profile: "invalid", - expectError: false, + expectError: true, expectFile: false, expectContent: "", }, @@ -116,7 +116,9 @@ func TestDirectProfileSwitch(t *testing.T) { err := directProfileSwitch(tt.profile) if tt.expectError { - assert.Error(t, err) + assert.ErrorIs(t, err, errProfileNotFound) + _, statErr := os.Stat(awsdFile) + assert.True(t, os.IsNotExist(statErr), "File should not exist for invalid profile") return } assert.NoError(t, err) @@ -133,6 +135,44 @@ func TestDirectProfileSwitch(t *testing.T) { } } +// The shell integration evals command substitutions of this binary, so a +// warning on stdout gets executed rather than shown. An older binary printing +// "Profile init does not exist" to stdout is what turns a plain version skew +// into "(eval):1: bad pattern: ^[[0". +func TestDirectProfileSwitchWarningGoesToStderr(t *testing.T) { + tempDir := testutils.CreateTempDir(t) + defer testutils.CleanupTempDir(t, tempDir) + + configPath := testutils.CreateMockAWSConfig(t, tempDir) + t.Setenv("AWS_CONFIG_FILE", configPath) + t.Setenv("HOME", tempDir) + + var stderr string + stdout := captureStdout(t, func() { + stderr = captureStderr(t, func() { + assert.ErrorIs(t, directProfileSwitch("invalid"), errProfileNotFound) + }) + }) + + assert.Empty(t, stdout, "warning must not reach stdout, the shell integration evals it") + assert.Contains(t, stderr, "does not exist") +} + +// A successful switch still reports on stdout. +func TestDirectProfileSwitchSuccessGoesToStdout(t *testing.T) { + tempDir := testutils.CreateTempDir(t) + defer testutils.CleanupTempDir(t, tempDir) + + configPath := testutils.CreateMockAWSConfig(t, tempDir) + t.Setenv("AWS_CONFIG_FILE", configPath) + t.Setenv("HOME", tempDir) + + stdout := captureStdout(t, func() { + assert.NoError(t, directProfileSwitch("dev")) + }) + assert.Contains(t, stdout, "dev") +} + func TestRootCommand(t *testing.T) { cmd := rootCmd assert.NotNil(t, cmd) diff --git a/src/cmd/set.go b/src/cmd/set.go index 9834ff4..3d1e751 100644 --- a/src/cmd/set.go +++ b/src/cmd/set.go @@ -26,9 +26,7 @@ var setProfileCmd = &cobra.Command{ } return } - if err := directProfileSwitch(args[0]); err != nil { - log.Fatal(err) - } + handleSwitchError(directProfileSwitch(args[0])) }, }