diff --git a/README.ja.md b/README.ja.md index 4e5b40fc..5be9e5bc 100644 --- a/README.ja.md +++ b/README.ja.md @@ -17,6 +17,7 @@ AWSリソース管理のためのターミナルUI - **インタラクティブTUI** - vimスタイルのキーバインドでAWSリソースを操作できます - **70サービス、175リソース** - EC2、S3、Lambda、RDS、ECS、EKSなど多数に対応しています - **マルチプロファイル&マルチリージョン** - 複数のアカウント/リージョンを並列でクエリできます +- **プロファイルログイン補助** - プロファイル選択画面からAWS SSOログインやコンソールログインを実行できます - **リソースアクション** - インスタンスの起動/停止、リソースの削除、ログのテールが可能です - **クロスリソースナビゲーション** - VPCからサブネット、LambdaからCloudWatchへジャンプできます - **フィルタリング&ソート** - あいまい検索、タグフィルタリング、カラムソートに対応しています @@ -109,6 +110,8 @@ claws --read-only | `A` | AIチャット(リスト/詳細/差分ビュー) | | `R` | リージョンを選択します | | `P` | プロファイルを選択します | +| `l` | 選択中のプロファイルでSSOログインします(プロファイル選択画面) | +| `L` | 選択中のプロファイルでコンソールログインします(プロファイル選択画面) | | `?` | ヘルプを表示します | | `q` | 終了します | diff --git a/README.ko.md b/README.ko.md index 77e8c515..2072a7aa 100644 --- a/README.ko.md +++ b/README.ko.md @@ -17,6 +17,7 @@ AWS 리소스 관리를 위한 터미널 UI - **인터랙티브 TUI** - vim 스타일 키 바인딩으로 AWS 리소스를 탐색할 수 있습니다 - **70개 서비스, 175개 리소스** - EC2, S3, Lambda, RDS, ECS, EKS 등 다양한 서비스를 지원합니다 - **멀티 프로필 및 멀티 리전** - 여러 계정/리전을 병렬로 조회할 수 있습니다 +- **프로필 로그인 도우미** - 프로필 선택기에서 AWS SSO 로그인 또는 콘솔 로그인을 실행할 수 있습니다 - **리소스 액션** - 인스턴스 시작/중지, 리소스 삭제, 로그 테일링이 가능합니다 - **크로스 리소스 탐색** - VPC에서 서브넷으로, Lambda에서 CloudWatch로 이동할 수 있습니다 - **필터링 및 정렬** - 퍼지 검색, 태그 필터링, 컬럼 정렬을 지원합니다 @@ -109,6 +110,8 @@ claws --read-only | `A` | AI 채팅 (리스트/상세/비교 뷰) | | `R` | 리전을 선택합니다 | | `P` | 프로필을 선택합니다 | +| `l` | 선택된 프로필로 SSO 로그인합니다 (프로필 선택기) | +| `L` | 선택된 프로필로 콘솔 로그인합니다 (프로필 선택기) | | `?` | 도움말을 표시합니다 | | `q` | 종료합니다 | diff --git a/README.md b/README.md index 68ff6a8d..e244a74f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A terminal UI for AWS resource management - **Interactive TUI** - Navigate AWS resources with vim-style keybindings - **70 services, 175 resources** - EC2, S3, Lambda, RDS, ECS, EKS, and more - **Multi-profile & Multi-region** - Query multiple accounts/regions in parallel +- **Profile login helpers** - Run AWS SSO login or open console login from the profile selector - **Resource actions** - Start/stop instances, delete resources, tail logs - **Cross-resource navigation** - Jump from VPC to subnets, Lambda to CloudWatch - **Filtering & sorting** - Fuzzy search, tag filtering, column sorting @@ -109,6 +110,8 @@ claws --read-only | `A` | AI Chat (in list/detail/diff views) | | `R` | Select region(s) | | `P` | Select profile(s) | +| `l` | SSO login for selected profile (in profile selector) | +| `L` | Console login for selected profile (in profile selector) | | `?` | Show help | | `q` | Quit | diff --git a/README.zh-CN.md b/README.zh-CN.md index 85b7ac99..36e01478 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,6 +17,7 @@ AWS 资源管理终端 UI - **交互式 TUI** - 使用 vim 风格的快捷键浏览 AWS 资源 - **70 个服务、175 个资源** - 支持 EC2、S3、Lambda、RDS、ECS、EKS 等众多服务 - **多配置文件与多区域** - 并行查询多个账户和区域 +- **配置文件登录辅助** - 可从配置文件选择器执行 AWS SSO 登录或控制台登录 - **资源操作** - 启动/停止实例、删除资源、追踪日志 - **跨资源导航** - 从 VPC 跳转到子网,从 Lambda 跳转到 CloudWatch - **筛选与排序** - 模糊搜索、标签筛选、列排序 @@ -109,6 +110,8 @@ claws --read-only | `A` | AI 聊天(列表/详情/差异视图) | | `R` | 选择区域 | | `P` | 选择配置文件 | +| `l` | 对选中的配置文件进行 SSO 登录(配置文件选择器) | +| `L` | 对选中的配置文件进行控制台登录(配置文件选择器) | | `?` | 显示帮助 | | `q` | 退出 | diff --git a/cmd/claws/main_test.go b/cmd/claws/main_test.go index cf3b8602..7da7ad69 100644 --- a/cmd/claws/main_test.go +++ b/cmd/claws/main_test.go @@ -3,6 +3,8 @@ package main import ( "slices" "testing" + + "github.com/clawscli/claws/internal/config" ) func TestParseFlags_Profiles(t *testing.T) { @@ -146,3 +148,93 @@ func TestParseFlags_ConfigFile(t *testing.T) { }) } } + +func TestParseFlags_EnvCreds(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "short flag", args: []string{"-e"}}, + {name: "long flag", args: []string{"--env"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := parseFlagsFromArgs(tt.args) + if !opts.envCreds { + t.Error("envCreds should be true") + } + }) + } +} + +func TestApplyStartupConfig_ProfilePrecedence(t *testing.T) { + tests := []struct { + name string + opts cliOptions + startup []string + wantProfile []string + }{ + { + name: "saved startup profiles used when no CLI override", + opts: cliOptions{}, + startup: []string{"saved"}, + wantProfile: []string{"saved"}, + }, + { + name: "profile flag overrides saved startup profiles", + opts: cliOptions{profiles: []string{"cli"}}, + startup: []string{"saved"}, + wantProfile: []string{"cli"}, + }, + { + name: "env flag overrides profile flag and saved startup profiles", + opts: cliOptions{envCreds: true, profiles: []string{"cli"}}, + startup: []string{"saved"}, + wantProfile: []string{config.ProfileIDEnvOnly}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fileCfg := &config.FileConfig{Startup: config.StartupConfig{Profiles: tt.startup}} + cfg := &config.Config{} + + applyStartupConfig(tt.opts, fileCfg, cfg) + + if got := selectionIDs(cfg.Selections()); !slices.Equal(got, tt.wantProfile) { + t.Errorf("selections = %v, want %v", got, tt.wantProfile) + } + }) + } +} + +func TestApplyStartupConfig_EnvOverrideDoesNotMutateSavedProfiles(t *testing.T) { + fileCfg := &config.FileConfig{Startup: config.StartupConfig{Profiles: []string{"personal"}}} + cfg := &config.Config{} + + applyStartupConfig(cliOptions{envCreds: true}, fileCfg, cfg) + + if got := cfg.Selection().ID(); got != config.ProfileIDEnvOnly { + t.Fatalf("selection = %q, want env-only", got) + } + _, savedProfiles := fileCfg.GetStartup() + if !slices.Equal(savedProfiles, []string{"personal"}) { + t.Fatalf("saved profiles = %v, want [personal]", savedProfiles) + } + + nextCfg := &config.Config{} + applyStartupConfig(cliOptions{}, fileCfg, nextCfg) + + if got := nextCfg.Selection().ID(); got != "personal" { + t.Errorf("next launch selection = %q, want personal", got) + } +} + +func selectionIDs(selections []config.ProfileSelection) []string { + ids := make([]string, len(selections)) + for i, sel := range selections { + ids[i] = sel.ID() + } + return ids +} diff --git a/internal/view/profile_selector.go b/internal/view/profile_selector.go index 4f09d28f..b19d1bbf 100644 --- a/internal/view/profile_selector.go +++ b/internal/view/profile_selector.go @@ -118,6 +118,22 @@ func (p *ProfileSelector) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case loginResultMsg: p.loginResult = &msg if msg.success { + if msg.isConsoleLogin { + selected := p.selector.Selected() + for id := range selected { + delete(selected, id) + } + selected[msg.profileID] = true + + sel := config.NamedProfile(msg.profileID) + selections := []config.ProfileSelection{sel} + config.Global().SetSelections(selections) + p.selector.ClearResult() + p.updateExtraHeight() + return p, func() tea.Msg { + return navmsg.ProfilesChangedMsg{Selections: selections} + } + } p.selector.Selected()[msg.profileID] = true p.selector.ClearResult() } @@ -277,8 +293,6 @@ func (p *ProfileSelector) consoleLoginCurrentProfile() (tea.Model, tea.Cmd) { if err != nil { return loginResultMsg{profileID: profileID, success: false, err: err, isConsoleLogin: true} } - sel := config.NamedProfile(profileID) - config.Global().SetSelection(sel) return loginResultMsg{profileID: profileID, success: true, isConsoleLogin: true} }) } diff --git a/internal/view/profile_selector_test.go b/internal/view/profile_selector_test.go index df046eb0..2bb98859 100644 --- a/internal/view/profile_selector_test.go +++ b/internal/view/profile_selector_test.go @@ -4,6 +4,9 @@ import ( "testing" tea "charm.land/bubbletea/v2" + + "github.com/clawscli/claws/internal/config" + navmsg "github.com/clawscli/claws/internal/msg" ) func testProfiles() []profileItem { @@ -173,3 +176,39 @@ func TestProfileSelectorToggle(t *testing.T) { t.Error("Expected both profiles to be selected") } } + +func TestProfileSelectorConsoleLoginSuccessSwitchesAndEmitsProfileChange(t *testing.T) { + config.Global().UseEnvOnly() + t.Cleanup(func() { config.Global().UseSDKDefault() }) + + selector := NewProfileSelector() + selector.SetSize(100, 50) + selector.Update(profilesLoadedMsg{profiles: []profileItem{ + {id: config.ProfileIDEnvOnly, display: "Env/IMDS Only", isSSO: false}, + {id: "dev", display: "dev", isSSO: false}, + }}) + + _, cmd := selector.Update(loginResultMsg{profileID: "dev", success: true, isConsoleLogin: true}) + if cmd == nil { + t.Fatal("Expected console login success to emit profile change command") + } + + msg := cmd() + profileMsg, ok := msg.(navmsg.ProfilesChangedMsg) + if !ok { + t.Fatalf("command message = %T, want ProfilesChangedMsg", msg) + } + if len(profileMsg.Selections) != 1 || profileMsg.Selections[0].ID() != "dev" { + t.Fatalf("ProfilesChangedMsg selections = %v, want [dev]", profileMsg.Selections) + } + + if got := config.Global().Selection().ID(); got != "dev" { + t.Errorf("global selection = %q, want dev", got) + } + if selector.selector.Selected()[config.ProfileIDEnvOnly] { + t.Error("env-only selection should be cleared after console login switches profile") + } + if !selector.selector.Selected()["dev"] { + t.Error("dev profile should be selected after console login") + } +}