From a4f1caf54faded6c45d3f49c8863a74246f31029 Mon Sep 17 00:00:00 2001 From: pjoyce Date: Mon, 27 Apr 2026 09:24:25 -0400 Subject: [PATCH] Feat: region switching --- .gitignore | 1 + CHANGELOG.md | 6 ++ Makefile | 5 +- docs/awsd.md | 6 +- docs/awsd_list.md | 8 +-- docs/awsd_set.md | 21 +++++++ docs/awsd_set_profile.md | 19 ++++++ docs/awsd_set_region.md | 19 ++++++ docs/awsd_unset.md | 21 +++++++ docs/awsd_unset_profile.md | 19 ++++++ docs/awsd_unset_region.md | 19 ++++++ docs/awsd_version.md | 2 +- go.mod | 4 +- go.sum | 8 +-- scripts/_awsd | 34 +++++++++- scripts/_awsd_autocomplete | 29 ++++++++- scripts/powershell/awsd.ps1 | 43 ++++++++++++- src/cmd/list.go | 29 +++++++-- src/cmd/list_test.go | 10 ++- src/cmd/root.go | 7 ++- src/cmd/root_test.go | 14 ++++- src/cmd/set.go | 109 ++++++++++++++++++++++++++++++++ src/cmd/unset.go | 52 +++++++++++++++ src/cmd/version.go | 2 +- src/utils/common.go | 122 +++++++++++++++++++++++++++++++++--- src/utils/common_test.go | 85 ++++++++++++++++++++++++- src/utils/regions.go | 37 +++++++++++ tools/gendocs/go.mod | 22 +++++++ tools/gendocs/go.sum | 44 +++++++++++++ tools/gendocs/main.go | 25 ++++++++ 30 files changed, 778 insertions(+), 44 deletions(-) create mode 100644 docs/awsd_set.md create mode 100644 docs/awsd_set_profile.md create mode 100644 docs/awsd_set_region.md create mode 100644 docs/awsd_unset.md create mode 100644 docs/awsd_unset_profile.md create mode 100644 docs/awsd_unset_region.md create mode 100644 src/cmd/set.go create mode 100644 src/cmd/unset.go create mode 100644 src/utils/regions.go create mode 100644 tools/gendocs/go.mod create mode 100644 tools/gendocs/go.sum create mode 100644 tools/gendocs/main.go diff --git a/.gitignore b/.gitignore index 89675ff..5e16162 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ coverage.* # Dependency directories (remove the comment below to include it) # vendor/ bin/ +tools/gendocs/gendocs diff --git a/CHANGELOG.md b/CHANGELOG.md index e69c657..7dc6869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 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. +* `~/.awsd` now uses a `key=value` format (`profile=...` / `region=...`). Legacy single-line files are still readable; the next write upgrades them. +* Wrapper now also exports `AWS_DEFAULT_REGION` alongside `AWS_REGION`. + ## v0.1.3 (March 9, 2025) * Adds circular scrolling diff --git a/Makefile b/Makefile index 0f9a58d..29f5c5e 100644 --- a/Makefile +++ b/Makefile @@ -24,10 +24,13 @@ uninstall: ## Uninstall Target rm -f ${BINDIR}/_awsd_autocomplete rm -f ${BINDIR}/_awsd_prompt -.PHONY: test test-coverage +.PHONY: test test-coverage docs test: ## Run tests go test ./... +docs: ## Regenerate docs/*.md from the cobra command tree + cd tools/gendocs && go run . ../../docs + test-coverage: ## Run tests with coverage report go test ./... -coverprofile=coverage.out go tool cover -func=coverage.out diff --git a/docs/awsd.md b/docs/awsd.md index ea18faf..e042fbc 100644 --- a/docs/awsd.md +++ b/docs/awsd.md @@ -18,7 +18,9 @@ awsd [flags] ### SEE ALSO -* [awsd list](awsd_list.md) - List AWS profiles command. +* [awsd list](awsd_list.md) - List AWS profiles or regions. +* [awsd set](awsd_set.md) - Set the active AWS profile or region. +* [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 6-Oct-2023 +###### Auto generated by spf13/cobra on 27-Apr-2026 diff --git a/docs/awsd_list.md b/docs/awsd_list.md index d28bcd6..52d52b3 100644 --- a/docs/awsd_list.md +++ b/docs/awsd_list.md @@ -1,13 +1,13 @@ ## awsd list -List AWS profiles command. +List AWS profiles or regions. ### Synopsis -This lists all your AWS profiles. +List all your AWS profiles, or the known AWS regions. Defaults to profiles. ``` -awsd list [flags] +awsd list [profiles|regions] [flags] ``` ### Options @@ -20,4 +20,4 @@ awsd list [flags] * [awsd](awsd.md) - awsd - switch between AWS profiles. -###### Auto generated by spf13/cobra on 6-Oct-2023 +###### Auto generated by spf13/cobra on 27-Apr-2026 diff --git a/docs/awsd_set.md b/docs/awsd_set.md new file mode 100644 index 0000000..a69539b --- /dev/null +++ b/docs/awsd_set.md @@ -0,0 +1,21 @@ +## awsd set + +Set the active AWS profile or region. + +### Synopsis + +Set the active AWS profile or region. With no subcommand, prints help. + +### Options + +``` + -h, --help help for set +``` + +### SEE ALSO + +* [awsd](awsd.md) - awsd - switch between AWS profiles. +* [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 diff --git a/docs/awsd_set_profile.md b/docs/awsd_set_profile.md new file mode 100644 index 0000000..7d8154b --- /dev/null +++ b/docs/awsd_set_profile.md @@ -0,0 +1,19 @@ +## awsd set profile + +Set the active AWS profile (interactive picker if no name given). + +``` +awsd set profile [name] [flags] +``` + +### Options + +``` + -h, --help help for profile +``` + +### SEE ALSO + +* [awsd set](awsd_set.md) - Set the active AWS profile or region. + +###### Auto generated by spf13/cobra on 27-Apr-2026 diff --git a/docs/awsd_set_region.md b/docs/awsd_set_region.md new file mode 100644 index 0000000..127f3ab --- /dev/null +++ b/docs/awsd_set_region.md @@ -0,0 +1,19 @@ +## awsd set region + +Set the active AWS region (interactive picker if no region given). + +``` +awsd set region [region] [flags] +``` + +### Options + +``` + -h, --help help for region +``` + +### SEE ALSO + +* [awsd set](awsd_set.md) - Set the active AWS profile or region. + +###### Auto generated by spf13/cobra on 27-Apr-2026 diff --git a/docs/awsd_unset.md b/docs/awsd_unset.md new file mode 100644 index 0000000..5237958 --- /dev/null +++ b/docs/awsd_unset.md @@ -0,0 +1,21 @@ +## awsd unset + +Unset the active AWS profile or region. + +### Synopsis + +Clear the active AWS profile (back to default) or AWS region. + +### Options + +``` + -h, --help help for unset +``` + +### SEE ALSO + +* [awsd](awsd.md) - awsd - switch between AWS profiles. +* [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 diff --git a/docs/awsd_unset_profile.md b/docs/awsd_unset_profile.md new file mode 100644 index 0000000..951832d --- /dev/null +++ b/docs/awsd_unset_profile.md @@ -0,0 +1,19 @@ +## awsd unset profile + +Reset the active AWS profile to default. + +``` +awsd unset profile [flags] +``` + +### Options + +``` + -h, --help help for profile +``` + +### SEE ALSO + +* [awsd unset](awsd_unset.md) - Unset the active AWS profile or region. + +###### Auto generated by spf13/cobra on 27-Apr-2026 diff --git a/docs/awsd_unset_region.md b/docs/awsd_unset_region.md new file mode 100644 index 0000000..dbfbf16 --- /dev/null +++ b/docs/awsd_unset_region.md @@ -0,0 +1,19 @@ +## awsd unset region + +Clear the active AWS region. + +``` +awsd unset region [flags] +``` + +### Options + +``` + -h, --help help for region +``` + +### SEE ALSO + +* [awsd unset](awsd_unset.md) - Unset the active AWS profile or region. + +###### Auto generated by spf13/cobra on 27-Apr-2026 diff --git a/docs/awsd_version.md b/docs/awsd_version.md index 4b2acd6..20b7ad3 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 6-Oct-2023 +###### Auto generated by spf13/cobra on 27-Apr-2026 diff --git a/go.mod b/go.mod index 21b40ff..dd012b0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/radiusmethod/awsd go 1.23.5 require ( - github.com/radiusmethod/promptui v0.10.3 + github.com/radiusmethod/promptui v0.11.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 gopkg.in/ini.v1 v1.67.1 @@ -15,6 +15,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/sys v0.12.0 // indirect + golang.org/x/sys v0.30.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 22fdf73..a978677 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/radiusmethod/promptui v0.10.3 h1:JeayJuCR/bPvZp5cGqd+sAk4NDZG9CCt0vBVTFIiCo8= -github.com/radiusmethod/promptui v0.10.3/go.mod h1:DYozY3lsgSlf+M+LXX4QF7/QY246KqP69zhdIvPBg+8= +github.com/radiusmethod/promptui v0.11.0 h1:IUP99vU3WgzIrAKicJ1ZwyTFEdTSsa9B6DUbv6GS0HA= +github.com/radiusmethod/promptui v0.11.0/go.mod h1:w+XRl9+IuUv5Fvf/notZrM5mkyagzVCxEiNnyplHYA0= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -30,8 +30,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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= diff --git a/scripts/_awsd b/scripts/_awsd index e700add..c6c2b6b 100755 --- a/scripts/_awsd +++ b/scripts/_awsd @@ -11,11 +11,39 @@ else fi touch ~/.awsd -selected_profile="$(cat ~/.awsd)" -if [ -z "$selected_profile" ] +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)" +fi + +if [ -z "$_awsd_profile" ] then unset AWS_PROFILE else - export AWS_PROFILE="$selected_profile" + 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/_awsd_autocomplete b/scripts/_awsd_autocomplete index 1814a64..54f9f53 100755 --- a/scripts/_awsd_autocomplete +++ b/scripts/_awsd_autocomplete @@ -3,7 +3,34 @@ [ "$BASH_VERSION" ] && AWSD_CMD="awsd" || AWSD_CMD="_awsd" _awsd_completion() { local cur=${COMP_WORDS[COMP_CWORD]} - local suggestions=$(awsd list) + local prev=${COMP_WORDS[COMP_CWORD-1]} + local subcmd=${COMP_WORDS[1]} + local suggestions + + case "$COMP_CWORD" in + 1) + # awsd — profiles + top-level subcommands + suggestions="$(awsd 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="$(awsd list profiles)" ;; + "set region") suggestions="$(awsd list regions)" ;; + *) suggestions="" ;; + esac + ;; + *) + suggestions="" + ;; + esac + COMPREPLY=($(compgen -W "$suggestions" -- $cur)) return 0 } diff --git a/scripts/powershell/awsd.ps1 b/scripts/powershell/awsd.ps1 index 85d1adf..96d7893 100644 --- a/scripts/powershell/awsd.ps1 +++ b/scripts/powershell/awsd.ps1 @@ -12,13 +12,50 @@ else awsd_prompt $args } -$selected_profile = Get-Content "$env:USERPROFILE\.awsd" +$awsd_profile = $null +$awsd_region = $null +$awsd_has_region = $false -if (-not $selected_profile) +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 = $selected_profile + $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 + } } diff --git a/src/cmd/list.go b/src/cmd/list.go index 137d151..d2ad7cd 100644 --- a/src/cmd/list.go +++ b/src/cmd/list.go @@ -9,12 +9,24 @@ import ( ) var listCmd = &cobra.Command{ - Use: "list", - Short: "List AWS profiles command.", - Aliases: []string{"l"}, - Long: "This lists all your AWS profiles.", + Use: "list [profiles|regions]", + Short: "List AWS profiles or regions.", + Aliases: []string{"l"}, + Long: "List all your AWS profiles, or the known AWS regions. Defaults to profiles.", + ValidArgs: []string{"profiles", "regions"}, + Args: cobra.MatchAll(cobra.MaximumNArgs(1), cobra.OnlyValidArgs), Run: func(cmd *cobra.Command, args []string) { - err := runProfileLister() + target := "profiles" + if len(args) == 1 { + target = args[0] + } + var err error + switch target { + case "regions": + err = runRegionLister() + default: + err = runProfileLister() + } if err != nil { log.Fatal(err) } @@ -35,3 +47,10 @@ func runProfileLister() error { } return nil } + +func runRegionLister() error { + for _, r := range utils.GetRegions() { + fmt.Println(r) + } + return nil +} diff --git a/src/cmd/list_test.go b/src/cmd/list_test.go index 8861644..eb9d959 100644 --- a/src/cmd/list_test.go +++ b/src/cmd/list_test.go @@ -11,12 +11,16 @@ import ( func TestListCommand(t *testing.T) { cmd := listCmd assert.NotNil(t, cmd) - assert.Equal(t, "list", cmd.Use) - assert.Equal(t, "List AWS profiles command.", cmd.Short) - assert.Equal(t, "This lists all your AWS profiles.", cmd.Long) + assert.Equal(t, "list [profiles|regions]", cmd.Use) + assert.Equal(t, "List AWS profiles or regions.", cmd.Short) assert.Equal(t, []string{"l"}, cmd.Aliases) } +func TestRunRegionLister(t *testing.T) { + err := runRegionLister() + assert.NoError(t, err) +} + func TestRunProfileLister(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 9f58117..8976096 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -21,6 +21,11 @@ var rootCmd = &cobra.Command{ }, } +// RootCmd returns the root cobra command. Used by docs generation. +func RootCmd() *cobra.Command { + return rootCmd +} + // Entry point for the CLI tool func Execute() { if shouldRunDirectProfileSwitch() { @@ -61,7 +66,7 @@ func runProfileSwitcher() error { } func shouldRunDirectProfileSwitch() bool { - invalidProfiles := []string{"l", "list", "completion", "help", "--help", "-h", "v", "version"} + invalidProfiles := []string{"l", "list", "set", "unset", "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 9ceefd1..a80614f 100644 --- a/src/cmd/root_test.go +++ b/src/cmd/root_test.go @@ -35,6 +35,16 @@ func TestShouldRunDirectProfileSwitch(t *testing.T) { args: []string{"awsd", "version"}, expected: false, }, + { + name: "Set command", + args: []string{"awsd", "set"}, + expected: false, + }, + { + name: "Unset command", + args: []string{"awsd", "unset"}, + expected: false, + }, { name: "No arguments", args: []string{"awsd"}, @@ -71,7 +81,7 @@ func TestDirectProfileSwitch(t *testing.T) { profile: "dev", expectError: false, expectFile: true, - expectContent: "dev", + expectContent: "profile=dev\n", }, { name: "Invalid profile", @@ -85,7 +95,7 @@ func TestDirectProfileSwitch(t *testing.T) { profile: "default", expectError: false, expectFile: true, - expectContent: "", + expectContent: "profile=\n", }, } diff --git a/src/cmd/set.go b/src/cmd/set.go new file mode 100644 index 0000000..9834ff4 --- /dev/null +++ b/src/cmd/set.go @@ -0,0 +1,109 @@ +package cmd + +import ( + "fmt" + "log" + + "github.com/radiusmethod/awsd/src/utils" + "github.com/radiusmethod/promptui" + "github.com/spf13/cobra" +) + +var setCmd = &cobra.Command{ + Use: "set", + Short: "Set the active AWS profile or region.", + Long: "Set the active AWS profile or region. With no subcommand, prints help.", +} + +var setProfileCmd = &cobra.Command{ + Use: "profile [name]", + Short: "Set the active AWS profile (interactive picker if no name given).", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + if err := runProfileSwitcher(); err != nil { + log.Fatal(err) + } + return + } + if err := directProfileSwitch(args[0]); err != nil { + log.Fatal(err) + } + }, +} + +var setRegionCmd = &cobra.Command{ + Use: "region [region]", + Short: "Set the active AWS region (interactive picker if no region given).", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var region string + if len(args) == 0 { + r, err := runRegionSwitcher() + if err != nil { + log.Fatal(err) + } + if r == "" { + return + } + region = r + } else { + region = args[0] + } + homeDir, err := utils.GetHomeDir() + if err != nil { + log.Fatal(err) + } + if err := utils.WriteRegion(region, homeDir); err != nil { + log.Fatal(err) + } + printColoredMessage("Region ", utils.PromptColor) + printColoredMessage(region, utils.CyanColor) + printColoredMessage(" set.\n", utils.PromptColor) + }, +} + +func init() { + setCmd.AddCommand(setProfileCmd) + setCmd.AddCommand(setRegionCmd) + rootCmd.AddCommand(setCmd) +} + +func runRegionSwitcher() (string, error) { + regions := utils.GetRegions() + fmt.Printf(utils.NoticeColor, "AWS Region Switcher\n") + region, err := getRegionFromPrompt(regions) + if err != nil { + return "", err + } + fmt.Printf(utils.PromptColor, "Choose a region") + fmt.Printf(utils.NoticeColor, "? ") + fmt.Printf(utils.CyanColor, region) + fmt.Println() + return region, nil +} + +func getRegionFromPrompt(regions []string) (string, error) { + prompt := promptui.Select{ + Label: fmt.Sprintf(utils.PromptColor, "Choose a region"), + Items: regions, + HideHelp: true, + HideSelected: true, + Templates: &promptui.SelectTemplates{ + Label: "{{ . }}?", + Active: fmt.Sprintf("%s {{ . | cyan }}", promptui.IconSelect), + Inactive: " {{.}}", + Selected: " {{ . | cyan }}", + }, + Searcher: utils.NewPromptUISearcher(regions), + StartInSearchMode: true, + Stdout: &utils.BellSkipper{}, + } + + _, result, err := prompt.Run() + if err != nil { + utils.CheckError(err) + return "", nil + } + return result, nil +} diff --git a/src/cmd/unset.go b/src/cmd/unset.go new file mode 100644 index 0000000..d94c087 --- /dev/null +++ b/src/cmd/unset.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "log" + + "github.com/radiusmethod/awsd/src/utils" + "github.com/spf13/cobra" +) + +var unsetCmd = &cobra.Command{ + Use: "unset", + Short: "Unset the active AWS profile or region.", + Long: "Clear the active AWS profile (back to default) or AWS region.", +} + +var unsetProfileCmd = &cobra.Command{ + Use: "profile", + Short: "Reset the active AWS profile to default.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + homeDir, err := utils.GetHomeDir() + if err != nil { + log.Fatal(err) + } + if err := utils.WriteFile("default", homeDir); err != nil { + log.Fatal(err) + } + printColoredMessage("Profile reset to default.\n", utils.PromptColor) + }, +} + +var unsetRegionCmd = &cobra.Command{ + Use: "region", + Short: "Clear the active AWS region.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + homeDir, err := utils.GetHomeDir() + if err != nil { + log.Fatal(err) + } + if err := utils.UnsetRegion(homeDir); err != nil { + log.Fatal(err) + } + printColoredMessage("Region cleared.\n", utils.PromptColor) + }, +} + +func init() { + unsetCmd.AddCommand(unsetProfileCmd) + unsetCmd.AddCommand(unsetRegionCmd) + rootCmd.AddCommand(unsetCmd) +} diff --git a/src/cmd/version.go b/src/cmd/version.go index 89c8fd2..a4723f4 100644 --- a/src/cmd/version.go +++ b/src/cmd/version.go @@ -5,7 +5,7 @@ import ( "github.com/spf13/cobra" ) -var version string = "v0.1.3" +var version string = "v0.2.0" var versionCmd = &cobra.Command{ Use: "version", diff --git a/src/utils/common.go b/src/utils/common.go index 50f2c10..4816c30 100644 --- a/src/utils/common.go +++ b/src/utils/common.go @@ -5,6 +5,7 @@ import ( "log" "os" "path/filepath" + "strings" ) func TouchFile(name string) error { @@ -15,25 +16,128 @@ func TouchFile(name string) error { return file.Close() } -func WriteFile(config, loc string) error { - homeDir, err := GetHomeDir() +// State is the persisted contents of ~/.awsd. +// +// Profile: empty string means "default" — the wrapper unsets AWS_PROFILE. +// Region: paired with RegionSet to distinguish three states: +// - RegionSet=false → no `region=` line; wrapper leaves AWS_REGION alone. +// - RegionSet=true, "" → `region=` with empty value; wrapper unsets AWS_REGION. +// - RegionSet=true, "..." → wrapper exports AWS_REGION (and AWS_DEFAULT_REGION). +type State struct { + Profile string + Region string + RegionSet bool +} + +func statePath(loc string) string { + return filepath.Join(loc, ".awsd") +} + +func ReadState(loc string) (State, error) { + data, err := os.ReadFile(statePath(loc)) if err != nil { - return err + if os.IsNotExist(err) { + return State{}, nil + } + return State{}, err + } + text := strings.TrimRight(string(data), "\n") + if text == "" { + return State{}, nil + } + + hasKV := false + for _, line := range strings.Split(text, "\n") { + if strings.Contains(line, "=") { + hasKV = true + break + } + } + if !hasKV { + // Legacy single-line: whole file is a profile name. + return State{Profile: strings.TrimSpace(text)}, nil + } + + var s State + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + idx := strings.Index(line, "=") + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + switch key { + case "profile": + s.Profile = val + case "region": + s.Region = val + s.RegionSet = true + } } - if err := TouchFile(fmt.Sprintf("%s/.awsd", homeDir)); err != nil { + return s, nil +} + +func WriteState(s State, loc string) error { + path := statePath(loc) + if err := TouchFile(path); err != nil { return err } - s := []byte("") - if config != "default" { - s = []byte(config) + var b strings.Builder + b.WriteString("profile=") + b.WriteString(s.Profile) + b.WriteString("\n") + if s.RegionSet { + b.WriteString("region=") + b.WriteString(s.Region) + b.WriteString("\n") } - err = os.WriteFile(fmt.Sprintf("%s/.awsd", loc), s, 0644) - if err != nil { + if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil { log.Fatal(err) } return nil } +// WriteFile sets the active profile, preserving any existing region. +// "default" is stored as an empty profile so the wrapper unsets AWS_PROFILE. +func WriteFile(config, loc string) error { + s, err := ReadState(loc) + if err != nil { + return err + } + if config == "default" { + s.Profile = "" + } else { + s.Profile = config + } + return WriteState(s, loc) +} + +// WriteRegion sets the active region, preserving the existing profile. +func WriteRegion(region, loc string) error { + s, err := ReadState(loc) + if err != nil { + return err + } + s.Region = region + s.RegionSet = true + return WriteState(s, loc) +} + +// UnsetRegion writes an explicit empty region so the wrapper unsets AWS_REGION. +func UnsetRegion(loc string) error { + s, err := ReadState(loc) + if err != nil { + return err + } + s.Region = "" + s.RegionSet = true + return WriteState(s, loc) +} + func GetEnv(key, fallback string) string { value := os.Getenv(key) if len(value) == 0 { diff --git a/src/utils/common_test.go b/src/utils/common_test.go index bc9a666..dde452c 100644 --- a/src/utils/common_test.go +++ b/src/utils/common_test.go @@ -35,14 +35,95 @@ func TestWriteFile(t *testing.T) { filePath := filepath.Join(tempDir, ".awsd") content, err := os.ReadFile(filePath) assert.NoError(t, err, "Should read file without error") - assert.Equal(t, "test-profile", string(content), "File content should match") + assert.Equal(t, "profile=test-profile\n", string(content), "File content should match") err = WriteFile("default", tempDir) assert.NoError(t, err, "Should write default profile without error") content, err = os.ReadFile(filePath) assert.NoError(t, err, "Should read file without error") - assert.Equal(t, "", string(content), "Default profile should write empty string") + assert.Equal(t, "profile=\n", string(content), "Default profile should write empty profile value") +} + +func TestWriteFilePreservesRegion(t *testing.T) { + tempDir := testutils.CreateTempDir(t) + defer testutils.CleanupTempDir(t, tempDir) + + t.Setenv("HOME", tempDir) + + assert.NoError(t, WriteRegion("us-east-1", tempDir)) + assert.NoError(t, WriteFile("dev", tempDir)) + + s, err := ReadState(tempDir) + assert.NoError(t, err) + assert.Equal(t, "dev", s.Profile) + assert.Equal(t, "us-east-1", s.Region) + assert.True(t, s.RegionSet) +} + +func TestWriteRegionPreservesProfile(t *testing.T) { + tempDir := testutils.CreateTempDir(t) + defer testutils.CleanupTempDir(t, tempDir) + + t.Setenv("HOME", tempDir) + + assert.NoError(t, WriteFile("dev", tempDir)) + assert.NoError(t, WriteRegion("eu-west-1", tempDir)) + + s, err := ReadState(tempDir) + assert.NoError(t, err) + assert.Equal(t, "dev", s.Profile) + assert.Equal(t, "eu-west-1", s.Region) +} + +func TestUnsetRegion(t *testing.T) { + tempDir := testutils.CreateTempDir(t) + defer testutils.CleanupTempDir(t, tempDir) + + t.Setenv("HOME", tempDir) + + assert.NoError(t, WriteRegion("us-east-1", tempDir)) + assert.NoError(t, UnsetRegion(tempDir)) + + s, err := ReadState(tempDir) + assert.NoError(t, err) + assert.Equal(t, "", s.Region) + assert.True(t, s.RegionSet, "explicit unset should still mark region as set") +} + +func TestReadStateLegacyFormat(t *testing.T) { + tempDir := testutils.CreateTempDir(t) + defer testutils.CleanupTempDir(t, tempDir) + + filePath := filepath.Join(tempDir, ".awsd") + assert.NoError(t, os.WriteFile(filePath, []byte("legacy-profile"), 0644)) + + s, err := ReadState(tempDir) + assert.NoError(t, err) + assert.Equal(t, "legacy-profile", s.Profile) + assert.False(t, s.RegionSet) +} + +func TestReadStateEmptyFile(t *testing.T) { + tempDir := testutils.CreateTempDir(t) + defer testutils.CleanupTempDir(t, tempDir) + + filePath := filepath.Join(tempDir, ".awsd") + assert.NoError(t, os.WriteFile(filePath, []byte(""), 0644)) + + s, err := ReadState(tempDir) + assert.NoError(t, err) + assert.Equal(t, "", s.Profile) + assert.False(t, s.RegionSet) +} + +func TestReadStateMissingFile(t *testing.T) { + tempDir := testutils.CreateTempDir(t) + defer testutils.CleanupTempDir(t, tempDir) + + s, err := ReadState(tempDir) + assert.NoError(t, err) + assert.Equal(t, State{}, s) } func TestGetEnv(t *testing.T) { diff --git a/src/utils/regions.go b/src/utils/regions.go new file mode 100644 index 0000000..921d4a2 --- /dev/null +++ b/src/utils/regions.go @@ -0,0 +1,37 @@ +package utils + +import "sort" + +var awsRegions = []string{ + "us-east-1", "us-east-2", + "us-west-1", "us-west-2", + "af-south-1", + "ap-east-1", "ap-east-2", + "ap-south-1", "ap-south-2", + "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", + "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", + "ap-southeast-4", "ap-southeast-5", "ap-southeast-6", "ap-southeast-7", + "ca-central-1", "ca-west-1", + "eu-central-1", "eu-central-2", + "eu-west-1", "eu-west-2", "eu-west-3", + "eu-north-1", + "eu-south-1", "eu-south-2", + "il-central-1", + "mx-central-1", + "me-central-1", "me-south-1", + "sa-east-1", + "cn-north-1", "cn-northwest-1", + "us-gov-east-1", "us-gov-west-1", + "eusc-de-east-1", + "us-iso-east-1", "us-iso-west-1", + "us-isob-east-1", "us-isob-west-1", + "us-isof-east-1", "us-isof-south-1", + "eu-isoe-west-1", +} + +func GetRegions() []string { + out := make([]string, len(awsRegions)) + copy(out, awsRegions) + sort.Strings(out) + return out +} diff --git a/tools/gendocs/go.mod b/tools/gendocs/go.mod new file mode 100644 index 0000000..eb6ac53 --- /dev/null +++ b/tools/gendocs/go.mod @@ -0,0 +1,22 @@ +module github.com/radiusmethod/awsd/tools/gendocs + +go 1.26.2 + +replace github.com/radiusmethod/awsd => ../.. + +require ( + github.com/radiusmethod/awsd v0.0.0-00010101000000-000000000000 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/chzyer/readline v1.5.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/radiusmethod/promptui v0.11.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + 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 +) diff --git a/tools/gendocs/go.sum b/tools/gendocs/go.sum new file mode 100644 index 0000000..60063a1 --- /dev/null +++ b/tools/gendocs/go.sum @@ -0,0 +1,44 @@ +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/radiusmethod/promptui v0.11.0 h1:IUP99vU3WgzIrAKicJ1ZwyTFEdTSsa9B6DUbv6GS0HA= +github.com/radiusmethod/promptui v0.11.0/go.mod h1:w+XRl9+IuUv5Fvf/notZrM5mkyagzVCxEiNnyplHYA0= +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/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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/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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/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= diff --git a/tools/gendocs/main.go b/tools/gendocs/main.go new file mode 100644 index 0000000..808385c --- /dev/null +++ b/tools/gendocs/main.go @@ -0,0 +1,25 @@ +// Regenerates docs/*.md from the cobra command tree. +// +// Usage: cd tools/gendocs && go run . +// +// Lives in its own go module so the root module's go.mod isn't polluted with +// cobra/doc's transitive deps (go-md2man, blackfriday, yaml). +package main + +import ( + "log" + "os" + + "github.com/radiusmethod/awsd/src/cmd" + "github.com/spf13/cobra/doc" +) + +func main() { + out := "../../docs" + if len(os.Args) > 1 { + out = os.Args[1] + } + if err := doc.GenMarkdownTree(cmd.RootCmd(), out); err != nil { + log.Fatal(err) + } +}