diff --git a/README.md b/README.md index edacb9f..eab73ce 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,9 @@ claws -r us-west-2 claws -s ec2 # EC2 instances claws -s rds/snapshots # RDS snapshots +# Multiple profiles/regions (comma-separated or repeated) +claws -p dev,prod -r us-east-1,ap-northeast-1 + # Read-only mode (disables destructive actions) claws --read-only ``` diff --git a/cmd/claws/main.go b/cmd/claws/main.go index c5733eb..4899e8b 100644 --- a/cmd/claws/main.go +++ b/cmd/claws/main.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "os" + "slices" "strings" tea "charm.land/bubbletea/v2" @@ -40,15 +41,19 @@ func main() { } cfg.SetReadOnly(opts.readOnly) - if opts.profile != "" && !config.IsValidProfileName(opts.profile) { - fmt.Fprintf(os.Stderr, "Error: invalid profile name: %s\n", opts.profile) - fmt.Fprintln(os.Stderr, "Valid characters: alphanumeric, hyphen, underscore, period") - os.Exit(1) + for _, p := range opts.profiles { + if !config.IsValidProfileName(p) { + fmt.Fprintf(os.Stderr, "Error: invalid profile name: %s\n", p) + fmt.Fprintln(os.Stderr, "Valid characters: alphanumeric, hyphen, underscore, period") + os.Exit(1) + } } - if opts.region != "" && !config.IsValidRegion(opts.region) { - fmt.Fprintf(os.Stderr, "Error: invalid region format: %s\n", opts.region) - fmt.Fprintln(os.Stderr, "Expected: xx-xxxx-N (e.g., us-east-1, ap-northeast-1)") - os.Exit(1) + for _, r := range opts.regions { + if !config.IsValidRegion(r) { + fmt.Fprintf(os.Stderr, "Error: invalid region format: %s\n", r) + fmt.Fprintln(os.Stderr, "Expected: xx-xxxx-N (e.g., us-east-1, ap-northeast-1)") + os.Exit(1) + } } applyStartupConfig(opts, fileCfg, cfg) @@ -79,7 +84,7 @@ func main() { if err := log.EnableFile(opts.logFile); err != nil { fmt.Fprintf(os.Stderr, "Warning: could not open log file %s: %v\n", opts.logFile, err) } else { - log.Info("claws started", "profile", opts.profile, "region", opts.region, "readOnly", opts.readOnly) + log.Info("claws started", "profiles", opts.profiles, "regions", opts.regions, "readOnly", opts.readOnly) } } @@ -99,8 +104,8 @@ func main() { } type cliOptions struct { - profile string - region string + profiles []string + regions []string readOnly bool envCreds bool autosave *bool @@ -112,22 +117,34 @@ type cliOptions struct { // parseFlags parses command line flags and returns options func parseFlags() cliOptions { + return parseFlagsFromArgs(os.Args[1:]) +} + +// parseFlagsFromArgs parses the given args and returns options (testable) +func parseFlagsFromArgs(args []string) cliOptions { opts := cliOptions{} showHelp := false showVersion := false - args := os.Args[1:] for i := 0; i < len(args); i++ { switch args[i] { case "-p", "--profile": if i+1 < len(args) { i++ - opts.profile = args[i] + for _, p := range strings.Split(args[i], ",") { + if p = strings.TrimSpace(p); p != "" && !slices.Contains(opts.profiles, p) { + opts.profiles = append(opts.profiles, p) + } + } } case "-r", "--region": if i+1 < len(args) { i++ - opts.region = args[i] + for _, r := range strings.Split(args[i], ",") { + if r = strings.TrimSpace(r); r != "" && !slices.Contains(opts.regions, r) { + opts.regions = append(opts.regions, r) + } + } } case "-ro", "--read-only": opts.readOnly = true @@ -185,10 +202,10 @@ func printUsage() { fmt.Println("Usage: claws [options]") fmt.Println() fmt.Println("Options:") - fmt.Println(" -p, --profile ") - fmt.Println(" AWS profile to use") - fmt.Println(" -r, --region ") - fmt.Println(" AWS region to use") + fmt.Println(" -p, --profile [,name2,...]") + fmt.Println(" AWS profile(s) to use (comma-separated or repeated)") + fmt.Println(" -r, --region [,region2,...]") + fmt.Println(" AWS region(s) to use (comma-separated or repeated)") fmt.Println(" -s, --service [/]") fmt.Println(" Start directly on a service/resource (e.g., ec2, rds/snapshots, cfn)") fmt.Println(" Supports aliases: cfn, sg, logs, ddb, etc.") @@ -213,10 +230,12 @@ func printUsage() { fmt.Println(" Show this help message") fmt.Println() fmt.Println("Examples:") - fmt.Println(" claws -s ec2 Open EC2 instances browser") - fmt.Println(" claws -s rds/snapshots Open RDS snapshots browser") - fmt.Println(" claws -s cfn Open CloudFormation stacks (alias)") - fmt.Println(" claws -s ec2 -i i-12345 Open detail view for instance i-12345") + fmt.Println(" claws -s ec2 Open EC2 instances browser") + fmt.Println(" claws -s rds/snapshots Open RDS snapshots browser") + fmt.Println(" claws -s cfn Open CloudFormation stacks (alias)") + fmt.Println(" claws -s ec2 -i i-12345 Open detail view for instance i-12345") + fmt.Println(" claws -p dev,prod Query multiple profiles") + fmt.Println(" claws -r us-east-1,ap-northeast-1 Query multiple regions") fmt.Println() fmt.Println("Environment Variables:") fmt.Println(" CLAWS_READ_ONLY=1|true Enable read-only mode") @@ -228,8 +247,12 @@ func applyStartupConfig(opts cliOptions, fileCfg *config.FileConfig, cfg *config if opts.envCreds { cfg.UseEnvOnly() - } else if opts.profile != "" { - cfg.UseProfile(opts.profile) + } else if len(opts.profiles) > 0 { + sels := make([]config.ProfileSelection, len(opts.profiles)) + for i, p := range opts.profiles { + sels[i] = config.ProfileSelectionFromID(p) + } + cfg.SetSelections(sels) } else if len(startupProfiles) > 0 { sels := make([]config.ProfileSelection, len(startupProfiles)) for i, id := range startupProfiles { @@ -238,8 +261,8 @@ func applyStartupConfig(opts cliOptions, fileCfg *config.FileConfig, cfg *config cfg.SetSelections(sels) } - if opts.region != "" { - cfg.SetRegion(opts.region) + if len(opts.regions) > 0 { + cfg.SetRegions(opts.regions) } else if len(startupRegions) > 0 { cfg.SetRegions(startupRegions) } diff --git a/cmd/claws/main_test.go b/cmd/claws/main_test.go new file mode 100644 index 0000000..bb130a8 --- /dev/null +++ b/cmd/claws/main_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "slices" + "testing" +) + +func TestParseFlags_Profiles(t *testing.T) { + tests := []struct { + name string + args []string + expected []string + }{ + { + name: "comma separated", + args: []string{"-p", "dev,prod"}, + expected: []string{"dev", "prod"}, + }, + { + name: "repeated flags", + args: []string{"-p", "dev", "-p", "prod"}, + expected: []string{"dev", "prod"}, + }, + { + name: "mixed comma and repeated", + args: []string{"-p", "dev,staging", "-p", "prod"}, + expected: []string{"dev", "staging", "prod"}, + }, + { + name: "empty values filtered", + args: []string{"-p", "dev, , prod"}, + expected: []string{"dev", "prod"}, + }, + { + name: "duplicates removed", + args: []string{"-p", "dev,dev", "-p", "dev"}, + expected: []string{"dev"}, + }, + { + name: "whitespace trimmed", + args: []string{"-p", " dev , prod "}, + expected: []string{"dev", "prod"}, + }, + { + name: "long form flag", + args: []string{"--profile", "dev,prod"}, + expected: []string{"dev", "prod"}, + }, + { + name: "no profiles", + args: []string{}, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := parseFlagsFromArgs(tt.args) + + if !slices.Equal(opts.profiles, tt.expected) { + t.Errorf("profiles = %v, want %v", opts.profiles, tt.expected) + } + }) + } +} + +func TestParseFlags_Regions(t *testing.T) { + tests := []struct { + name string + args []string + expected []string + }{ + { + name: "comma separated", + args: []string{"-r", "us-east-1,ap-northeast-1"}, + expected: []string{"us-east-1", "ap-northeast-1"}, + }, + { + name: "repeated flags", + args: []string{"-r", "us-east-1", "-r", "ap-northeast-1"}, + expected: []string{"us-east-1", "ap-northeast-1"}, + }, + { + name: "duplicates removed", + args: []string{"-r", "us-east-1,us-east-1", "-r", "us-east-1"}, + expected: []string{"us-east-1"}, + }, + { + name: "long form flag", + args: []string{"--region", "us-east-1,eu-west-1"}, + expected: []string{"us-east-1", "eu-west-1"}, + }, + { + name: "no regions", + args: []string{}, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := parseFlagsFromArgs(tt.args) + + if !slices.Equal(opts.regions, tt.expected) { + t.Errorf("regions = %v, want %v", opts.regions, tt.expected) + } + }) + } +} + +func TestParseFlags_Combined(t *testing.T) { + opts := parseFlagsFromArgs([]string{"-p", "dev,prod", "-r", "us-east-1,ap-northeast-1", "-ro"}) + + expectedProfiles := []string{"dev", "prod"} + expectedRegions := []string{"us-east-1", "ap-northeast-1"} + + if !slices.Equal(opts.profiles, expectedProfiles) { + t.Errorf("profiles = %v, want %v", opts.profiles, expectedProfiles) + } + if !slices.Equal(opts.regions, expectedRegions) { + t.Errorf("regions = %v, want %v", opts.regions, expectedRegions) + } + if !opts.readOnly { + t.Error("readOnly should be true") + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 0951f91..27125db 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,6 +48,7 @@ theme: nord # Preset: dark, light, nord, dracula, gruvbox, catppuc The config file is **not created automatically**. Create it manually if needed. CLI flags (`-p`, `-r`, `-t`, `--autosave`, `--no-autosave`) override config file settings. +Multiple values supported: `-p dev,prod` or `-p dev -p prod`. ## Themes diff --git a/docs/images/demo.gif b/docs/images/demo.gif index 37987f5..8d63278 100644 Binary files a/docs/images/demo.gif and b/docs/images/demo.gif differ diff --git a/docs/tapes/demo.tape b/docs/tapes/demo.tape index c8cf5eb..da52050 100644 --- a/docs/tapes/demo.tape +++ b/docs/tapes/demo.tape @@ -9,7 +9,7 @@ Set Height 1080 Set TypingSpeed 0.08 Sleep 0.5s -Type "./claws" +Type "./claws -p prod,dev -r us-east-1,us-west-2" Sleep 1.5s Enter @@ -25,14 +25,14 @@ Sleep 1.5s Enter Sleep 2s -Type "/public" +Type "/web" Sleep 1s Enter Sleep 2s Type "m" Sleep 1s -Type "j" +Type "G" Sleep 1s Type "d" Sleep 3s diff --git a/scripts/localstack-demo-setup.sh b/scripts/localstack-demo-setup.sh index dab295a..81c5594 100755 --- a/scripts/localstack-demo-setup.sh +++ b/scripts/localstack-demo-setup.sh @@ -165,10 +165,10 @@ create_vpc_b() { aws_cmd ec2 attach-internet-gateway --vpc-id "$VPC_B" --internet-gateway-id "$IGW_B" SUBNET_B1=$(aws_cmd ec2 create-subnet --vpc-id "$VPC_B" --cidr-block 10.1.1.0/24 --availability-zone us-west-2a --query 'Subnet.SubnetId' --output text) - aws_cmd ec2 create-tags --resources "$SUBNET_B1" --tags Key=Name,Value=dev-subnet-2a Key=Env,Value=dev ${DEMO_TAG} ${DEMO_TAG2} + aws_cmd ec2 create-tags --resources "$SUBNET_B1" --tags Key=Name,Value=dev-web-west-2a Key=Env,Value=dev ${DEMO_TAG} ${DEMO_TAG2} SUBNET_B2=$(aws_cmd ec2 create-subnet --vpc-id "$VPC_B" --cidr-block 10.1.2.0/24 --availability-zone us-west-2b --query 'Subnet.SubnetId' --output text) - aws_cmd ec2 create-tags --resources "$SUBNET_B2" --tags Key=Name,Value=dev-subnet-2b Key=Env,Value=dev ${DEMO_TAG} ${DEMO_TAG2} + aws_cmd ec2 create-tags --resources "$SUBNET_B2" --tags Key=Name,Value=dev-db-west-2b Key=Env,Value=dev ${DEMO_TAG} ${DEMO_TAG2} RTB_B=$(aws_cmd ec2 create-route-table --vpc-id "$VPC_B" --query 'RouteTable.RouteTableId' --output text) aws_cmd ec2 create-tags --resources "$RTB_B" --tags Key=Name,Value=dev-rt-west Key=Env,Value=dev ${DEMO_TAG} ${DEMO_TAG2} @@ -206,7 +206,7 @@ create_vpc_c() { aws_cmd ec2 attach-internet-gateway --vpc-id "$VPC_C" --internet-gateway-id "$IGW_C" SUBNET_C1=$(aws_cmd ec2 create-subnet --vpc-id "$VPC_C" --cidr-block 10.2.1.0/24 --availability-zone ap-northeast-1a --query 'Subnet.SubnetId' --output text) - aws_cmd ec2 create-tags --resources "$SUBNET_C1" --tags Key=Name,Value=prod-tokyo-1a Key=Env,Value=prod ${DEMO_TAG} ${DEMO_TAG2} + aws_cmd ec2 create-tags --resources "$SUBNET_C1" --tags Key=Name,Value=prod-web-tokyo-1a Key=Env,Value=prod ${DEMO_TAG} ${DEMO_TAG2} SG_TOKYO=$(aws_cmd ec2 create-security-group --group-name prod-sg-tokyo --description "Tokyo prod" --vpc-id "$VPC_C" --query 'GroupId' --output text) aws_cmd ec2 create-tags --resources "$SG_TOKYO" --tags Key=Name,Value=prod-sg-tokyo Key=Env,Value=prod ${DEMO_TAG} ${DEMO_TAG2}