diff --git a/README.md b/README.md index bc1e469..e6ea0b5 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,11 @@ unic context order # Clear current context and copy cleanup commands to clipboard unic context unset + +# Generate contexts from the accounts/roles visible to an SSO base context +unic context sync +unic context sync dev-sso --dry-run +unic context sync dev-sso --prune ``` `unic context setup` writes its prompts to `stderr` and copies the generated shell commands to the clipboard. @@ -119,6 +124,7 @@ Both flows now include a `UNIC_CONTEXT` marker in the generated exports so the T Contexts can be prioritized in the setup picker with an `order` field in config. In the CLI `unic context setup` flow, the picker filters contexts, SSO accounts, SSO roles, and configured resource regions as you type, with arrow-key navigation and Enter to confirm. Multi-region contexts prompt for the shell session region after account/role selection; single-region contexts skip that step. The selection changes `AWS_REGION` and `AWS_DEFAULT_REGION` in the generated exports without modifying the context's persisted default region. Use `unic context order` to open reorder mode, choose a context with `↑/↓` or `j/k`, press `Enter` to start moving it, then press `Enter` again to save. `unic context order ` still works for direct updates. +`unic context sync [base-context]` lists the AWS accounts and roles visible to an SSO base context and adds a sync-managed concrete context for each new account/role pair, inheriting the base context's regions. When only one SSO base context exists the argument can be omitted. Existing contexts are never rewritten: pairs that already have a context (manual or synced) are kept as-is. Synced contexts carry a `sync_source: ` marker in `config.yaml`; when their account/role disappears from SSO they are reported as orphans and removed only with `--prune`. Use `--dry-run` to preview the plan without writing config. ## Configuration @@ -223,6 +229,7 @@ Optional context fields: | Field | Meaning | |---|---| | `order` | Lower values appear first in the context setup picker. Contexts without `order` fall back after ordered entries in their existing file order. | +| `sync_source` | Name of the SSO base context that generated this context via `unic context sync`. Marks the context as sync-managed: re-syncs may prune it (with `--prune`) when its account/role disappears from SSO. Contexts without this field are never touched by sync. | | `sso_region` | (SSO only) Region of the IAM Identity Center portal, used for SSO login and role-credential retrieval. Defaults to `region` when unset. Use it when the SSO portal and your resources live in different regions. | | `resources.regions` / `regions` | Additional resource regions available through the global `R` picker. The default resource region is always included automatically. | diff --git a/docs/architecture.en.md b/docs/architecture.en.md index 1ed1bad..dc5431c 100644 --- a/docs/architecture.en.md +++ b/docs/architecture.en.md @@ -42,6 +42,11 @@ Owns non-TUI commands: - live incremental filtering for large context/account/role lists - interactive context ordering via `unic context order` - can trigger `aws login` for `console_login` contexts +- `unic context sync [base-context]` + - generates a sync-managed context for every account/role pair visible to an SSO base context + - generated contexts carry a `sync_source` marker to stay distinguishable from manual ones + - sync-managed contexts whose account/role disappeared are reported as orphans and removed only with `--prune` + - `--dry-run` prints the plan without writing config - `unic context unset` ### `internal/config/` diff --git a/docs/architecture.ko.md b/docs/architecture.ko.md index 9478f45..b054dbc 100644 --- a/docs/architecture.ko.md +++ b/docs/architecture.ko.md @@ -42,6 +42,11 @@ cmd/unic/main.go - 많은 context/account/role 목록에서 live incremental filtering 지원 - `unic context order`를 통한 interactive context ordering 지원 - `console_login` context에서 `aws login` 실행 가능 +- `unic context sync [base-context]` + - SSO base context에 보이는 account/role 조합마다 sync-managed context를 생성 + - 생성된 context는 `sync_source` marker로 수동 context와 구분 + - 사라진 account/role의 sync-managed context는 orphan으로 보고하고 `--prune`일 때만 삭제 + - `--dry-run`으로 config를 쓰지 않고 plan만 출력 - `unic context unset` ### `internal/config/` diff --git a/internal/auth/sync.go b/internal/auth/sync.go new file mode 100644 index 0000000..73b3d83 --- /dev/null +++ b/internal/auth/sync.go @@ -0,0 +1,119 @@ +package auth + +import ( + "context" + "fmt" + "sort" + + "unic/internal/config" +) + +// ContextSyncPlan describes what a sync run would change for one SSO base context. +type ContextSyncPlan struct { + Base string + // Add holds new sync-managed contexts for SSO account/role pairs that + // have no configured context yet. + Add []config.ContextEntry + // Keep lists configured contexts (manual or synced) that still match an + // SSO-visible account/role pair. Sync never rewrites them. + Keep []string + // Orphans lists sync-managed contexts for this base whose account/role + // pair is no longer visible in SSO. + Orphans []string +} + +// BuildContextSyncPlan compares the accounts and roles visible to an SSO base +// context with the configured contexts and returns the additions, unchanged +// matches, and sync-managed orphans. It never plans changes to manually +// managed contexts. +func BuildContextSyncPlan(ctx context.Context, configPath string, base config.ContextInfo) (ContextSyncPlan, error) { + plan := ContextSyncPlan{Base: base.Name} + + accounts, err := ListSSOContextAccounts(ctx, configPath, base) + if err != nil { + return plan, err + } + + existing, err := config.Contexts(configPath) + if err != nil { + return plan, err + } + + type roleKey struct{ account, role string } + configured := make(map[roleKey]config.ContextInfo) + for _, info := range existing { + if config.AuthType(info.AuthType) != config.AuthTypeSSO || + info.SSOStartURL != base.SSOStartURL || + info.SSOAccountID == "" || info.SSORoleName == "" { + continue + } + configured[roleKey{info.SSOAccountID, info.SSORoleName}] = info + } + + region := base.Region + if region == "" { + region = config.DefaultRegion + } + resourceRegions := contextRegions(region, base.Regions) + + desired := make(map[roleKey]bool) + names := append([]config.ContextInfo(nil), existing...) + for _, account := range accounts { + roles, err := ListSSOContextRoles(ctx, configPath, base, account.ID) + if err != nil { + return plan, err + } + for _, role := range roles { + key := roleKey{account.ID, role.Name} + desired[key] = true + if match, ok := configured[key]; ok { + plan.Keep = append(plan.Keep, match.Name) + continue + } + name := uniqueContextName(names, fmt.Sprintf("%s-%s-%s", base.Name, account.ID, sanitizeName(role.Name))) + names = append(names, config.ContextInfo{Name: name}) + plan.Add = append(plan.Add, config.ContextEntry{ + Name: name, + SyncSource: base.Name, + Auth: &config.ContextAuth{ + Type: string(config.AuthTypeSSO), + Profile: base.Profile, + SSOStartURL: base.SSOStartURL, + SSORegion: base.SSORegion, + SSOAccountID: account.ID, + SSORoleName: role.Name, + }, + Resources: &config.ContextResources{ + DefaultRegion: region, + Regions: resourceRegions[1:], + }, + }) + } + } + + for _, info := range existing { + if info.SyncSource != base.Name { + continue + } + if !desired[roleKey{info.SSOAccountID, info.SSORoleName}] { + plan.Orphans = append(plan.Orphans, info.Name) + } + } + sort.Strings(plan.Keep) + sort.Strings(plan.Orphans) + return plan, nil +} + +// ApplyContextSyncPlan persists a sync plan: additions are upserted as +// sync-managed contexts, and orphans are removed only when prune is set. +func ApplyContextSyncPlan(configPath string, plan ContextSyncPlan, prune bool) error { + for _, entry := range plan.Add { + if err := config.UpsertContext(configPath, entry); err != nil { + return err + } + } + if prune { + return config.RemoveContexts(configPath, plan.Orphans) + } + return nil +} diff --git a/internal/auth/sync_test.go b/internal/auth/sync_test.go new file mode 100644 index 0000000..05dfdc2 --- /dev/null +++ b/internal/auth/sync_test.go @@ -0,0 +1,174 @@ +package auth + +import ( + "context" + "errors" + "testing" + + "unic/internal/config" + awsservice "unic/internal/services/aws" +) + +const syncBaseConfig = `current: dev-sso +contexts: + - name: dev-sso + profile: sso-profile + region: ap-northeast-2 + auth_type: sso + sso_start_url: https://example.awsapps.com/start + - name: manual-admin + profile: sso-profile + region: ap-northeast-2 + auth_type: sso + sso_start_url: https://example.awsapps.com/start + sso_account_id: "111111111111" + sso_role_name: Admin + - name: dev-sso-222222222222-stale + auth_type: sso + sso_start_url: https://example.awsapps.com/start + sso_account_id: "222222222222" + sso_role_name: Stale + region: ap-northeast-2 + sync_source: dev-sso +` + +func stubSSOListing(t *testing.T, accounts []awsservice.SSOAccount, roles map[string][]awsservice.SSORole) { + t.Helper() + origAccounts := listSSOAccountsFn + origRoles := listSSOAccountRolesFn + t.Cleanup(func() { + listSSOAccountsFn = origAccounts + listSSOAccountRolesFn = origRoles + }) + listSSOAccountsFn = func(_ context.Context, _ *config.Config) ([]awsservice.SSOAccount, error) { + return accounts, nil + } + listSSOAccountRolesFn = func(_ context.Context, _ *config.Config, accountID string) ([]awsservice.SSORole, error) { + out, ok := roles[accountID] + if !ok { + return nil, errors.New("unexpected account " + accountID) + } + return out, nil + } +} + +func syncBase(t *testing.T, configPath string) config.ContextInfo { + t.Helper() + contexts, err := config.Contexts(configPath) + if err != nil { + t.Fatalf("failed to list contexts: %v", err) + } + for _, ctx := range contexts { + if ctx.Name == "dev-sso" { + return ctx + } + } + t.Fatal("dev-sso base context not found") + return config.ContextInfo{} +} + +func TestBuildContextSyncPlan(t *testing.T) { + configPath := writeConfig(t, t.TempDir(), syncBaseConfig) + stubSSOListing(t, + []awsservice.SSOAccount{ + {ID: "111111111111", Name: "prod"}, + {ID: "333333333333", Name: "dev"}, + }, + map[string][]awsservice.SSORole{ + "111111111111": {{Name: "Admin"}}, + "333333333333": {{Name: "DeveloperRole"}}, + }, + ) + + plan, err := BuildContextSyncPlan(context.Background(), configPath, syncBase(t, configPath)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(plan.Add) != 1 || plan.Add[0].Name != "dev-sso-333333333333-developerrole" { + t.Fatalf("expected one added context for the new account/role, got %+v", plan.Add) + } + added := plan.Add[0] + if added.SyncSource != "dev-sso" { + t.Fatalf("expected added context to be marked sync-managed, got %+v", added) + } + if added.Auth == nil || added.Auth.SSOAccountID != "333333333333" || added.Auth.SSORoleName != "DeveloperRole" { + t.Fatalf("expected added context auth fields, got %+v", added.Auth) + } + if added.Resources == nil || added.Resources.DefaultRegion != "ap-northeast-2" { + t.Fatalf("expected added context to inherit the base region, got %+v", added.Resources) + } + + if len(plan.Keep) != 1 || plan.Keep[0] != "manual-admin" { + t.Fatalf("expected the matching manual context to be kept, got %+v", plan.Keep) + } + if len(plan.Orphans) != 1 || plan.Orphans[0] != "dev-sso-222222222222-stale" { + t.Fatalf("expected the stale sync-managed context to be orphaned, got %+v", plan.Orphans) + } +} + +func TestApplyContextSyncPlanWithoutPruneKeepsOrphans(t *testing.T) { + configPath := writeConfig(t, t.TempDir(), syncBaseConfig) + plan := ContextSyncPlan{ + Base: "dev-sso", + Add: []config.ContextEntry{{ + Name: "dev-sso-333333333333-developerrole", + SyncSource: "dev-sso", + Auth: &config.ContextAuth{ + Type: "sso", + SSOStartURL: "https://example.awsapps.com/start", + SSOAccountID: "333333333333", + SSORoleName: "DeveloperRole", + }, + Resources: &config.ContextResources{DefaultRegion: "ap-northeast-2"}, + }}, + Orphans: []string{"dev-sso-222222222222-stale"}, + } + + if err := ApplyContextSyncPlan(configPath, plan, false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + contexts, err := config.Contexts(configPath) + if err != nil { + t.Fatalf("failed to list contexts: %v", err) + } + byName := map[string]config.ContextInfo{} + for _, ctx := range contexts { + byName[ctx.Name] = ctx + } + added, ok := byName["dev-sso-333333333333-developerrole"] + if !ok || added.SyncSource != "dev-sso" { + t.Fatalf("expected sync-managed context to be persisted, got %+v", byName) + } + if _, ok := byName["dev-sso-222222222222-stale"]; !ok { + t.Fatal("expected orphan to survive without --prune") + } +} + +func TestApplyContextSyncPlanPruneRemovesOnlyOrphans(t *testing.T) { + configPath := writeConfig(t, t.TempDir(), syncBaseConfig) + plan := ContextSyncPlan{ + Base: "dev-sso", + Orphans: []string{"dev-sso-222222222222-stale"}, + } + + if err := ApplyContextSyncPlan(configPath, plan, true); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + contexts, err := config.Contexts(configPath) + if err != nil { + t.Fatalf("failed to list contexts: %v", err) + } + names := map[string]bool{} + for _, ctx := range contexts { + names[ctx.Name] = true + } + if names["dev-sso-222222222222-stale"] { + t.Fatal("expected orphan to be pruned") + } + if !names["dev-sso"] || !names["manual-admin"] { + t.Fatalf("expected base and manual contexts to survive prune, got %+v", names) + } +} diff --git a/internal/cli/context.go b/internal/cli/context.go index 2fe36be..bf83d1e 100644 --- a/internal/cli/context.go +++ b/internal/cli/context.go @@ -26,6 +26,9 @@ var ( setContextOrdersFn = config.SetContextOrders reorderContextsFn = reorderContexts copyClipboardFn = clipboard.Copy + listContextsFn = config.Contexts + buildSyncPlanFn = auth.BuildContextSyncPlan + applySyncPlanFn = auth.ApplyContextSyncPlan ) func newContextCmd() *cobra.Command { @@ -37,9 +40,106 @@ func newContextCmd() *cobra.Command { cmd.AddCommand(newContextSetupCmd()) cmd.AddCommand(newContextOrderCmd()) cmd.AddCommand(newContextUnsetCmd()) + cmd.AddCommand(newContextSyncCmd()) return cmd } +func newContextSyncCmd() *cobra.Command { + var prune, dryRun bool + cmd := &cobra.Command{ + Use: "sync [base-context]", + Short: "Generate contexts from the accounts and roles visible to an SSO base context", + Long: "List the AWS accounts and roles visible to an SSO base context and add a sync-managed context for each pair. " + + "Existing contexts are never rewritten; sync-managed contexts whose account/role disappeared are reported and removed only with --prune.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + configPath, err := defaultPathFn() + if err != nil { + return err + } + if err := ensureConfigExistsFn(configPath); err != nil { + return err + } + base, err := resolveSyncBase(configPath, args) + if err != nil { + return err + } + plan, err := buildSyncPlanFn(context.Background(), configPath, base) + if err != nil { + return err + } + printSyncPlan(cmd.OutOrStdout(), plan, prune, dryRun) + if dryRun { + return nil + } + return applySyncPlanFn(configPath, plan, prune) + }, + } + cmd.Flags().BoolVar(&prune, "prune", false, "remove sync-managed contexts whose SSO account/role is no longer visible") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show the sync plan without writing config") + return cmd +} + +func resolveSyncBase(configPath string, args []string) (config.ContextInfo, error) { + contexts, err := listContextsFn(configPath) + if err != nil { + return config.ContextInfo{}, err + } + + if len(args) == 1 { + for _, ctx := range contexts { + if ctx.Name != args[0] { + continue + } + if !auth.IsBaseSSOContext(ctx) { + return config.ContextInfo{}, fmt.Errorf("context %q is not an SSO base context", ctx.Name) + } + return ctx, nil + } + return config.ContextInfo{}, fmt.Errorf("context %q not found", args[0]) + } + + var bases []config.ContextInfo + for _, ctx := range contexts { + if auth.IsBaseSSOContext(ctx) { + bases = append(bases, ctx) + } + } + switch len(bases) { + case 0: + return config.ContextInfo{}, fmt.Errorf("no SSO base context found; add one with sso_start_url and no sso_account_id/sso_role_name") + case 1: + return bases[0], nil + default: + names := make([]string, 0, len(bases)) + for _, base := range bases { + names = append(names, base.Name) + } + return config.ContextInfo{}, fmt.Errorf("multiple SSO base contexts found (%s); pass one as an argument", strings.Join(names, ", ")) + } +} + +func printSyncPlan(out io.Writer, plan auth.ContextSyncPlan, prune, dryRun bool) { + for _, entry := range plan.Add { + fmt.Fprintf(out, "add: %s\n", entry.Name) + } + for _, name := range plan.Orphans { + action := "orphan" + if prune { + action = "remove" + } + fmt.Fprintf(out, "%s: %s\n", action, name) + } + suffix := "" + if dryRun { + suffix = " (dry run, nothing written)" + } + fmt.Fprintf(out, "sync %s: %d added, %d kept, %d orphaned%s\n", plan.Base, len(plan.Add), len(plan.Keep), len(plan.Orphans), suffix) + if !prune && len(plan.Orphans) > 0 { + fmt.Fprintln(out, "use --prune to remove orphaned sync-managed contexts") + } +} + func newContextOrderCmd() *cobra.Command { return &cobra.Command{ Use: "order [context-name] [number]", diff --git a/internal/cli/context_sync_test.go b/internal/cli/context_sync_test.go new file mode 100644 index 0000000..dd62bcf --- /dev/null +++ b/internal/cli/context_sync_test.go @@ -0,0 +1,143 @@ +package cli + +import ( + "bytes" + "context" + "strings" + "testing" + + "unic/internal/auth" + "unic/internal/config" +) + +func stubSyncSeams(t *testing.T, contexts []config.ContextInfo, plan auth.ContextSyncPlan) (applied *bool, pruned *bool) { + t.Helper() + origPath := defaultPathFn + origEnsure := ensureConfigExistsFn + origList := listContextsFn + origBuild := buildSyncPlanFn + origApply := applySyncPlanFn + t.Cleanup(func() { + defaultPathFn = origPath + ensureConfigExistsFn = origEnsure + listContextsFn = origList + buildSyncPlanFn = origBuild + applySyncPlanFn = origApply + }) + + defaultPathFn = func() (string, error) { return "/tmp/unused-config.yaml", nil } + ensureConfigExistsFn = func(string) error { return nil } + listContextsFn = func(string) ([]config.ContextInfo, error) { return contexts, nil } + buildSyncPlanFn = func(context.Context, string, config.ContextInfo) (auth.ContextSyncPlan, error) { + return plan, nil + } + applied = new(bool) + pruned = new(bool) + applySyncPlanFn = func(_ string, _ auth.ContextSyncPlan, prune bool) error { + *applied = true + *pruned = prune + return nil + } + return applied, pruned +} + +func baseSSOContexts() []config.ContextInfo { + return []config.ContextInfo{ + {Name: "dev-sso", AuthType: "sso", SSOStartURL: "https://example.awsapps.com/start"}, + {Name: "manual-admin", AuthType: "sso", SSOStartURL: "https://example.awsapps.com/start", SSOAccountID: "111111111111", SSORoleName: "Admin"}, + } +} + +func runContextSync(t *testing.T, args ...string) (string, error) { + t.Helper() + cmd := NewRootCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(append([]string{"context", "sync"}, args...)) + err := cmd.Execute() + return out.String(), err +} + +func TestContextSyncAppliesPlanAndPrintsSummary(t *testing.T) { + plan := auth.ContextSyncPlan{ + Base: "dev-sso", + Add: []config.ContextEntry{{Name: "dev-sso-333333333333-developerrole"}}, + Keep: []string{"manual-admin"}, + Orphans: []string{"dev-sso-222222222222-stale"}, + } + applied, pruned := stubSyncSeams(t, baseSSOContexts(), plan) + + out, err := runContextSync(t) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !*applied || *pruned { + t.Fatalf("expected plan applied without prune, applied=%v pruned=%v", *applied, *pruned) + } + for _, want := range []string{ + "add: dev-sso-333333333333-developerrole", + "orphan: dev-sso-222222222222-stale", + "sync dev-sso: 1 added, 1 kept, 1 orphaned", + "use --prune", + } { + if !strings.Contains(out, want) { + t.Fatalf("expected output to contain %q, got:\n%s", want, out) + } + } +} + +func TestContextSyncDryRunDoesNotApply(t *testing.T) { + applied, _ := stubSyncSeams(t, baseSSOContexts(), auth.ContextSyncPlan{Base: "dev-sso"}) + + out, err := runContextSync(t, "--dry-run") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if *applied { + t.Fatal("expected dry run to skip applying the plan") + } + if !strings.Contains(out, "dry run, nothing written") { + t.Fatalf("expected dry-run marker in output, got:\n%s", out) + } +} + +func TestContextSyncPruneFlagPassesThrough(t *testing.T) { + applied, pruned := stubSyncSeams(t, baseSSOContexts(), auth.ContextSyncPlan{ + Base: "dev-sso", + Orphans: []string{"dev-sso-222222222222-stale"}, + }) + + out, err := runContextSync(t, "--prune") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !*applied || !*pruned { + t.Fatalf("expected plan applied with prune, applied=%v pruned=%v", *applied, *pruned) + } + if !strings.Contains(out, "remove: dev-sso-222222222222-stale") { + t.Fatalf("expected remove line in output, got:\n%s", out) + } +} + +func TestContextSyncRejectsNonBaseContextArgument(t *testing.T) { + stubSyncSeams(t, baseSSOContexts(), auth.ContextSyncPlan{}) + + if _, err := runContextSync(t, "manual-admin"); err == nil || !strings.Contains(err.Error(), "not an SSO base context") { + t.Fatalf("expected non-base context rejection, got %v", err) + } + if _, err := runContextSync(t, "missing"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected missing context rejection, got %v", err) + } +} + +func TestContextSyncRequiresArgumentWhenMultipleBases(t *testing.T) { + contexts := append(baseSSOContexts(), config.ContextInfo{ + Name: "other-sso", AuthType: "sso", SSOStartURL: "https://other.awsapps.com/start", + }) + stubSyncSeams(t, contexts, auth.ContextSyncPlan{}) + + if _, err := runContextSync(t); err == nil || !strings.Contains(err.Error(), "multiple SSO base contexts") { + t.Fatalf("expected multiple-base error, got %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index b8ccb2e..1fc4482 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,6 +73,7 @@ type ContextEntry struct { SSOAccountID string `yaml:"sso_account_id,omitempty"` SSORoleName string `yaml:"sso_role_name,omitempty"` Regions []string `yaml:"regions,omitempty"` + SyncSource string `yaml:"sync_source,omitempty"` Auth *ContextAuth `yaml:"auth,omitempty"` Resources *ContextResources `yaml:"resources,omitempty"` } @@ -158,6 +159,7 @@ type ContextInfo struct { SSOAccountID string SSORoleName string Regions []string + SyncSource string Current bool Favorite bool } @@ -437,6 +439,7 @@ func Contexts(configPath string) ([]ContextInfo, error) { SSOAccountID: resolved.SSOAccountID, SSORoleName: resolved.SSORoleName, Regions: resolved.Regions, + SyncSource: ctx.SyncSource, Current: ctx.Name == fc.Current, Favorite: favorite, }) @@ -681,6 +684,49 @@ func UpsertContext(configPath string, entry ContextEntry) error { return nil } +// RemoveContexts deletes the named contexts from config. The current context +// selection is cleared when it points at a removed context. +func RemoveContexts(configPath string, names []string) error { + if len(names) == 0 { + return nil + } + data, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config: %w", err) + } + + var fc fileConfig + if err := yaml.Unmarshal(data, &fc); err != nil { + return fmt.Errorf("failed to parse %s: %w", configPath, err) + } + + remove := make(map[string]struct{}, len(names)) + for _, name := range names { + remove[name] = struct{}{} + } + + kept := fc.Contexts[:0] + for _, ctx := range fc.Contexts { + if _, ok := remove[ctx.Name]; ok { + if fc.Current == ctx.Name { + fc.Current = "" + } + continue + } + kept = append(kept, ctx) + } + fc.Contexts = kept + + out, err := yaml.Marshal(&fc) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + if err := os.WriteFile(configPath, out, 0644); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + return nil +} + // SetContextOrder updates the display order for a named context. func SetContextOrder(configPath, name string, order int) error { data, err := os.ReadFile(configPath)