From 1b8e58dc0914ef21a921b8346bc081821e80b154 Mon Sep 17 00:00:00 2001 From: YoungJin Date: Thu, 13 Aug 2026 17:24:53 +0900 Subject: [PATCH 1/2] feat: make ECR login TUI-first and document the TUI-first product rule Add an ECR Login Helper feature to the TUI: it resolves the private registry URI for the active context and shows copyable Docker and Podman login commands (c/p to copy, r to refresh). The existing `unic ecr login` CLI command stays as a secondary scripting helper and is documented as such. README gains a Product Principles section and the CONTRIBUTING PR checklist gains a TUI-first review item so future service features default to a TUI entry point. Closes #178 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BFhLrnpVxivu62cC3k9NZB --- CONTRIBUTING.md | 1 + README.md | 11 +++- internal/app/app.go | 3 + internal/app/help.go | 9 +++ internal/app/messages.go | 6 ++ internal/app/screen_ecr.go | 91 +++++++++++++++++++++++++++ internal/app/screen_ecr_login_test.go | 59 +++++++++++++++++ internal/domain/catalog.go | 4 ++ internal/domain/catalog_test.go | 16 +++++ internal/domain/model.go | 1 + 10 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 internal/app/screen_ecr_login_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 977229f..18e9eb2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,7 @@ Thanks for your interest in contributing. ## Pull Request Checklist - [ ] The change is scoped and documented. +- [ ] New user-facing AWS service capabilities are TUI-first: they ship with a TUI entry point, and any CLI command is a secondary surface for scripting or automation. - [ ] Branch name follows `docs/branch-naming-harness.md`. - [ ] `README.md` and relevant `docs/` pages were reviewed and updated if needed. - [ ] Existing behavior is not broken. diff --git a/README.md b/README.md index bc1e469..d47bcbd 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,8 @@ eval "$(unic env prod-admin)" # Interactively choose/setup a context and copy exports to clipboard unic context setup -# Print an ECR login command for the current context +# Print an ECR login command for the current context (scripting helper; +# the TUI's ECR Login Helper is the primary workflow) unic ecr login # Copy a Podman ECR login command to the clipboard @@ -372,7 +373,7 @@ checks: | Bedrock API Keys | `c` create, choose current IAM user or another user, `r` rotate secret, `d` delete, type the IAM user/key ID to confirm, `c` copy one-time key without printing it, `e` copy `AWS_BEARER_TOKEN_BEDROCK` export | | CloudWatch Metrics | preset-driven metric list/detail flow, `/` filter, `space` select related series, `g` preset cycle, `t/p/s` range-period-stat controls, `r` refresh, in-terminal single-series and comparison charts | | CloudWatch Logs | log groups/streams load 10 at a time, `n` load more, `1`-`6` time presets, `t` live tail, `f` filter pattern, `w` wrap toggle, `h/l` horizontal scroll | -| ECR Login | CLI helper: `unic ecr login [--runtime docker|podman] [--copy]` | +| ECR Login Helper | `c` copy Docker login command, `p` copy Podman login command, `r` refresh; CLI helper for scripting: `unic ecr login [--runtime docker|podman] [--copy]` | | ECS Exec | `r` refresh, `Enter` drill down / exec | | ECS Rollout / Exec | cluster/service lists support refresh and drill-down, service detail shows deployments/task definition images/events, `Enter` continues into tasks and exec | | EKS Browser | cluster/node group/add-on lists support `/` filter and `r` refresh, cluster view shows version/status/endpoint visibility/ARN summary, `a` opens managed add-ons, `U` opens current-version upgrade readiness, `u` opens kubeconfig access helper, node group detail shows desired/min/max scaling plus health issues | @@ -389,7 +390,7 @@ The service list defaults to favorites first, then alphabetical order. Press `f` The EKS Browser includes a managed add-on status view for each cluster. Add-on rows show the installed version, status, and health summary, with degraded or unhealthy add-ons highlighted so core components such as CoreDNS, kube-proxy, VPC CNI, and CSI drivers are easy to spot. -The ECR Repository Browser opens image/tag lists from each repository. Image rows include tags, digest, pushed time, and size, and mark untagged images or images older than 90 days as cleanup candidates. Image detail exposes digest and tag values for clipboard copy. +The ECR Login Helper resolves the private registry URI for the active context and shows copyable Docker and Podman login commands without leaving the TUI; `unic ecr login` remains as a secondary CLI helper for scripting. The ECR Repository Browser opens image/tag lists from each repository. Image rows include tags, digest, pushed time, and size, and mark untagged images or images older than 90 days as cleanup candidates. Image detail exposes digest and tag values for clipboard copy. The FIS Experiment Template Browser lists experiment templates in the active region and opens a detail screen with role ARN, targets, actions, target mappings, parameters, filters, and stop condition summaries without leaving the TUI. Template detail includes a Safe Run Preview that summarizes blast radius, target selection modes, action count, active stop conditions, IAM role, and warnings for missing stop conditions, missing role ARN, broad selection, or unbounded selectors. The preview also states the template ID that any future execution path must type to confirm before a run can start. Press `h` on a selected template or template detail to inspect recent runs for that template, or `H` from the template list to inspect recent experiment history across the active account/region. History rows include run status, timing, and stop/failure summaries, with failed, stopped, stopping, and cancelled runs visually highlighted; `Enter` opens run detail with start/end times, duration, action states, targets, stop conditions, and failure metadata. @@ -403,6 +404,10 @@ Bedrock API key management uses the active unic AWS context and IAM service-spec Reachability Analyzer starts with a region selection step, defaults to the current context region, and now surfaces the AWS-documented source and destination resource types that unic supports: EC2 instances, Internet gateways, Network interfaces, Transit gateways, Transit gateway attachments, Virtual private gateways, VPC endpoint services, VPC endpoints, VPC peering connections, plus IP addresses as destinations. The source and destination pickers support type tabs, keyword filtering, IPv4 destination validation, and automatic cleanup of temporary Network Insights resources after each analysis. During analysis, the loading screen shows a vertical source-to-destination flow and intent summary, and the result view renders path hops and findings in a more readable layout. +## Product Principles + +- **TUI-first.** New user-facing AWS service capabilities ship with a TUI entry point first. CLI commands are secondary surfaces for scripting, automation, or copy/paste handoff, and should be documented as such. + ## Development ```bash diff --git a/internal/app/app.go b/internal/app/app.go index 389ed57..541790d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -78,6 +78,7 @@ const ( screenECRRepositoryDetail screenECRImageList screenECRImageDetail + screenECRLoginHelper screenFISTemplateList screenFISTemplateDetail screenFISExperimentList @@ -670,6 +671,8 @@ func (m Model) updateFeatureList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.ecs.Start(&m) case domain.FeatureECRRepositoryBrowser: return m.ecr.Start(&m) + case domain.FeatureECRLoginHelper: + return m.ecr.StartLogin(&m) case domain.FeatureEKSBrowser: return m.eks.Start(&m) case domain.FeatureFISTemplateBrowser: diff --git a/internal/app/help.go b/internal/app/help.go index 124d69f..682cfd4 100644 --- a/internal/app/help.go +++ b/internal/app/help.go @@ -492,6 +492,13 @@ func (m Model) currentScreenShortcuts() []helpShortcut { {"q", "Go back to the feature list"}, {"esc", "Go back to the image list"}, } + case screenECRLoginHelper: + return []helpShortcut{ + {"c", "Copy the Docker login command"}, + {"p", "Copy the Podman login command"}, + {"r", "Re-resolve the registry and commands"}, + {"q / esc", "Go back to the feature list"}, + } case screenFISTemplateList: shortcuts := listScreenShortcuts("open the selected experiment template", "go back to the feature list", true, false) return append(shortcuts[:3], append([]helpShortcut{ @@ -914,6 +921,8 @@ func (m Model) helpScreenTitle() string { return "ECR Images" case screenECRImageDetail: return "ECR Image Detail" + case screenECRLoginHelper: + return "ECR Login Helper" case screenFISTemplateList: return "FIS Experiment Templates" case screenFISTemplateDetail: diff --git a/internal/app/messages.go b/internal/app/messages.go index 7865d1b..ebc8de2 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -278,6 +278,12 @@ type ecrRepositoriesLoadedMsg struct { repositories []awsservice.ECRRepository } +type ecrLoginResolvedMsg struct { + registryURI string + dockerCommand string + podmanCommand string +} + type ecrImagesLoadedMsg struct { repository string images []awsservice.ECRImage diff --git a/internal/app/screen_ecr.go b/internal/app/screen_ecr.go index 1ddc98f..e4aed10 100644 --- a/internal/app/screen_ecr.go +++ b/internal/app/screen_ecr.go @@ -22,6 +22,9 @@ type ecrModel struct { imageIdx int selectedImage *awsservice.ECRImage copyMsg string + loginRegistryURI string + loginDockerCommand string + loginPodmanCommand string } func newECRModel() ecrModel { @@ -32,6 +35,10 @@ func (em *ecrModel) Start(m *Model) (tea.Model, tea.Cmd) { return m.startLoading(em.loadRepositories(*m)) } +func (em *ecrModel) StartLogin(m *Model) (tea.Model, tea.Cmd) { + return m.startLoading(em.loadLoginCommands(*m)) +} + func (em *ecrModel) HandleMessage(m *Model, msg tea.Msg) (tea.Model, tea.Cmd, bool) { switch msg := msg.(type) { case ecrRepositoriesLoadedMsg: @@ -57,6 +64,13 @@ func (em *ecrModel) HandleMessage(m *Model, msg tea.Msg) (tea.Model, tea.Cmd, bo m.resetFilter(filterECRImages) m.screen = screenECRImageList return *m, nil, true + case ecrLoginResolvedMsg: + em.loginRegistryURI = msg.registryURI + em.loginDockerCommand = msg.dockerCommand + em.loginPodmanCommand = msg.podmanCommand + em.copyMsg = "" + m.screen = screenECRLoginHelper + return *m, nil, true } return *m, nil, false } @@ -75,6 +89,9 @@ func (em *ecrModel) HandleKey(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd, boo case screenECRImageDetail: newM, cmd := em.updateImageDetail(m, msg) return newM, cmd, true + case screenECRLoginHelper: + newM, cmd := em.updateLoginHelper(m, msg) + return newM, cmd, true default: return *m, nil, false } @@ -90,6 +107,8 @@ func (em ecrModel) View(m Model) (string, bool) { return em.viewImageList(m), true case screenECRImageDetail: return em.viewImageDetail(m), true + case screenECRLoginHelper: + return em.viewLoginHelper(m), true default: return "", false } @@ -227,6 +246,78 @@ func (em *ecrModel) updateImageDetail(m *Model, msg tea.KeyMsg) (tea.Model, tea. return *m, nil } +func (em *ecrModel) updateLoginHelper(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc": + em.copyMsg = "" + m.screen = screenFeatureList + case "r": + return m.startLoading(em.loadLoginCommands(*m)) + case "c": + em.copyLoginCommand("Docker", em.loginDockerCommand) + case "p": + em.copyLoginCommand("Podman", em.loginPodmanCommand) + } + return *m, nil +} + +func (em *ecrModel) copyLoginCommand(runtime, command string) { + if command == "" { + em.copyMsg = fmt.Sprintf("No %s login command available", runtime) + return + } + if err := clipboard.Copy(command); err != nil { + em.copyMsg = fmt.Sprintf("Clipboard error: %s", err) + return + } + em.copyMsg = fmt.Sprintf("Copied %s login command to clipboard", runtime) +} + +func (em *ecrModel) loadLoginCommands(m Model) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + registryURI, _, err := awsservice.ResolvePrivateECRRegistryURI(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + docker, err := awsservice.BuildECRLoginCommand(registryURI, m.cfg.Region, awsservice.ECRRuntimeDocker) + if err != nil { + return errMsg{err: err} + } + podman, err := awsservice.BuildECRLoginCommand(registryURI, m.cfg.Region, awsservice.ECRRuntimePodman) + if err != nil { + return errMsg{err: err} + } + return ecrLoginResolvedMsg{registryURI: registryURI, dockerCommand: docker, podmanCommand: podman} + } +} + +func (em ecrModel) viewLoginHelper(m Model) string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("ECR Login Helper")) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" Registry ")) + b.WriteString(normalStyle.Render(em.loginRegistryURI)) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" Docker")) + b.WriteString("\n") + b.WriteString(normalStyle.Render(" " + em.loginDockerCommand)) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" Podman")) + b.WriteString("\n") + b.WriteString(normalStyle.Render(" " + em.loginPodmanCommand)) + b.WriteString("\n") + if em.copyMsg != "" { + b.WriteString("\n") + b.WriteString(selectedStyle.Render(" " + em.copyMsg)) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(m.renderHelpBar("c: copy docker • p: copy podman • r: refresh • esc: back • H: home")) + return b.String() +} + func (em *ecrModel) loadRepositories(m Model) tea.Cmd { return func() tea.Msg { ctx := context.Background() diff --git a/internal/app/screen_ecr_login_test.go b/internal/app/screen_ecr_login_test.go new file mode 100644 index 0000000..c84d0bb --- /dev/null +++ b/internal/app/screen_ecr_login_test.go @@ -0,0 +1,59 @@ +package app + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "unic/internal/config" +) + +func TestECRLoginResolvedMsgOpensHelperScreen(t *testing.T) { + m := Model{ + cfg: &config.Config{Region: "ap-northeast-2"}, + screen: screenLoading, + } + + msg := ecrLoginResolvedMsg{ + registryURI: "123456789012.dkr.ecr.ap-northeast-2.amazonaws.com", + dockerCommand: "aws ecr get-login-password --region ap-northeast-2 | docker login ...", + podmanCommand: "aws ecr get-login-password --region ap-northeast-2 | podman login ...", + } + _, _, handled := m.ecr.HandleMessage(&m, msg) + if !handled { + t.Fatal("expected login-resolved message to be handled") + } + if m.screen != screenECRLoginHelper { + t.Fatalf("expected login helper screen, got %v", m.screen) + } + + view, ok := m.ecr.View(m) + if !ok { + t.Fatal("expected login helper view to render") + } + for _, want := range []string{"ECR Login Helper", msg.registryURI, "docker login", "podman login"} { + if !strings.Contains(view, want) { + t.Fatalf("expected view to contain %q, got:\n%s", want, view) + } + } +} + +func TestECRLoginHelperEscReturnsToFeatureList(t *testing.T) { + m := Model{ + cfg: &config.Config{Region: "ap-northeast-2"}, + screen: screenECRLoginHelper, + } + m.ecr.copyMsg = "stale" + + _, _, handled := m.ecr.HandleKey(&m, tea.KeyMsg{Type: tea.KeyEsc}) + if !handled { + t.Fatal("expected key to be handled on the login helper screen") + } + if m.screen != screenFeatureList { + t.Fatalf("expected feature list screen, got %v", m.screen) + } + if m.ecr.copyMsg != "" { + t.Fatalf("expected copy message to be cleared, got %q", m.ecr.copyMsg) + } +} diff --git a/internal/domain/catalog.go b/internal/domain/catalog.go index 9e9f851..3de790c 100644 --- a/internal/domain/catalog.go +++ b/internal/domain/catalog.go @@ -94,6 +94,10 @@ func Catalog() []Service { Kind: FeatureECRRepositoryBrowser, Description: "Browse ECR repositories and review image policy settings", }, + { + Kind: FeatureECRLoginHelper, + Description: "Generate and copy a registry login command for the active context", + }, }, }, { diff --git a/internal/domain/catalog_test.go b/internal/domain/catalog_test.go index 76280c6..033658d 100644 --- a/internal/domain/catalog_test.go +++ b/internal/domain/catalog_test.go @@ -244,3 +244,19 @@ func TestCatalogDoesNotContainInspectorPseudoService(t *testing.T) { } } } + +func TestCatalogContainsECRLoginHelperFeature(t *testing.T) { + for _, svc := range Catalog() { + if svc.Name != ServiceECR { + continue + } + for _, feat := range svc.Features { + if feat.Kind == FeatureECRLoginHelper { + return + } + } + t.Error("ECR service should have ECR Login Helper feature") + return + } + t.Error("ECR service not found in catalog") +} diff --git a/internal/domain/model.go b/internal/domain/model.go index 9e78c28..04f490f 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -42,6 +42,7 @@ const ( FeatureCloudWatchLogsBrowser FeatureKind = "CloudWatch Logs Browser" FeatureECSExec FeatureKind = "ECS Browser & Exec" FeatureECRRepositoryBrowser FeatureKind = "ECR Repository Browser" + FeatureECRLoginHelper FeatureKind = "ECR Login Helper" FeatureEKSBrowser FeatureKind = "EKS Cluster Browser" FeatureFISTemplateBrowser FeatureKind = "FIS Experiment Template Browser" FeatureS3Browser FeatureKind = "S3 Browser" From 34b2f150081abd29ce11bb5361db12ab6708eb2d Mon Sep 17 00:00:00 2001 From: YoungJin Date: Fri, 14 Aug 2026 09:26:15 +0900 Subject: [PATCH 2/2] fix: make copied ECR login commands run under the active unic context Address review: the registry URI was resolved with the unic context's credentials, but the copied command ran with the shell's ambient AWS credentials, so SSO/assume-role/okta contexts could log in to a different account or fail. Copied commands are now prefixed with eval "$(unic env )" so the login uses the same context the screen resolved the registry with. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BFhLrnpVxivu62cC3k9NZB --- README.md | 2 +- internal/app/screen_ecr.go | 17 ++++++++++++++++- internal/app/screen_ecr_login_test.go | 9 +++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dd601f7..86574ba 100644 --- a/README.md +++ b/README.md @@ -399,7 +399,7 @@ The service list defaults to favorites first, then alphabetical order. Press `f` The EKS Browser includes a managed add-on status view for each cluster. Add-on rows show the installed version, status, and health summary, with degraded or unhealthy add-ons highlighted so core components such as CoreDNS, kube-proxy, VPC CNI, and CSI drivers are easy to spot. -The ECR Login Helper resolves the private registry URI for the active context and shows copyable Docker and Podman login commands without leaving the TUI; `unic ecr login` remains as a secondary CLI helper for scripting. The ECR Repository Browser opens image/tag lists from each repository. Image rows include tags, digest, pushed time, and size, and mark untagged images or images older than 90 days as cleanup candidates. Image detail exposes digest and tag values for clipboard copy. +The ECR Login Helper resolves the private registry URI for the active context and shows copyable Docker and Podman login commands without leaving the TUI. The copied commands are prefixed with `eval "$(unic env )"` so they authenticate with the active unic context rather than whatever ambient AWS credentials the shell happens to have; `unic ecr login` remains as a secondary CLI helper for scripting. The ECR Repository Browser opens image/tag lists from each repository. Image rows include tags, digest, pushed time, and size, and mark untagged images or images older than 90 days as cleanup candidates. Image detail exposes digest and tag values for clipboard copy. The FIS Experiment Template Browser lists experiment templates in the active region and opens a detail screen with role ARN, targets, actions, target mappings, parameters, filters, and stop condition summaries without leaving the TUI. Template detail includes a Safe Run Preview that summarizes blast radius, target selection modes, action count, active stop conditions, IAM role, and warnings for missing stop conditions, missing role ARN, broad selection, or unbounded selectors. The preview also states the template ID that any future execution path must type to confirm before a run can start. Press `h` on a selected template or template detail to inspect recent runs for that template, or `H` from the template list to inspect recent experiment history across the active account/region. History rows include run status, timing, and stop/failure summaries, with failed, stopped, stopping, and cancelled runs visually highlighted; `Enter` opens run detail with start/end times, duration, action states, targets, stop conditions, and failure metadata. diff --git a/internal/app/screen_ecr.go b/internal/app/screen_ecr.go index e4aed10..738ae1c 100644 --- a/internal/app/screen_ecr.go +++ b/internal/app/screen_ecr.go @@ -288,8 +288,23 @@ func (em *ecrModel) loadLoginCommands(m Model) tea.Cmd { if err != nil { return errMsg{err: err} } - return ecrLoginResolvedMsg{registryURI: registryURI, dockerCommand: docker, podmanCommand: podman} + return ecrLoginResolvedMsg{ + registryURI: registryURI, + dockerCommand: withContextEnv(m.cfg.ContextName, docker), + podmanCommand: withContextEnv(m.cfg.ContextName, podman), + } + } +} + +// withContextEnv prefixes a copied command with the active context's shell +// exports. The registry URI is resolved with the unic context's credentials, +// but `aws ecr get-login-password` runs with the shell's ambient credentials — +// without this prefix the copied command could log in to a different account. +func withContextEnv(contextName, command string) string { + if contextName == "" { + return command } + return fmt.Sprintf("eval \"$(unic env %s)\" && %s", contextName, command) } func (em ecrModel) viewLoginHelper(m Model) string { diff --git a/internal/app/screen_ecr_login_test.go b/internal/app/screen_ecr_login_test.go index c84d0bb..db9dbe5 100644 --- a/internal/app/screen_ecr_login_test.go +++ b/internal/app/screen_ecr_login_test.go @@ -57,3 +57,12 @@ func TestECRLoginHelperEscReturnsToFeatureList(t *testing.T) { t.Fatalf("expected copy message to be cleared, got %q", m.ecr.copyMsg) } } + +func TestECRLoginCommandsApplyContextEnv(t *testing.T) { + if got := withContextEnv("prod-admin", "aws ecr get-login-password | docker login ..."); !strings.HasPrefix(got, `eval "$(unic env prod-admin)" && aws ecr`) { + t.Fatalf("expected context env prefix, got %q", got) + } + if got := withContextEnv("", "aws ecr get-login-password | docker login ..."); strings.Contains(got, "unic env") { + t.Fatalf("expected no prefix without a context name, got %q", got) + } +}