Caltech Library logo
skip to main content

AWS Tools — awsops — Implementation Plan (Go)

Source Design

See DESIGN.md and DECISIONS.md for the complete design and decision rationale, including why this plan retargets from Bash to Go (DECISIONS.md, 2026-07-01 “Retarget implementation from Bash to Go”).

Status (2026-07-01)

Starting from scratch. ec2_ami_manager.bash remains in the repo as the working reference for expected behavior and stays in place, untouched, until this plan’s Phase 14 (main menu) is complete and TEST_PLAN_REAL_AWS.txt passes against the Go binary. Nothing below has been implemented yet.

Unlike the Bash plan, the AMI-from-instance capabilities that were folded in later as “Phase 5b” (volume-size time estimate, fstrim/SSM step, unbounded polling, Invenio RDM crash-consistency guidance) are included in Phase 5 from the start — we already know they’re required.

Phases

TDD throughout: write the *_test.go file (with fakes for the AWS SDK interfaces) before the implementation it covers.


Phase 0 — Project Setup

Effort: ~2 hours Priority: High

Tasks


Phase 1 — AWS Client Layer

Effort: ~3 hours Priority: High Files: internal/awsclient/

Work Items

Tests: credential-check failure path; region client construction; retry/backoff behavior on throttling errors (table-driven, using a fake that returns a throttling error N times then succeeds)

Dependency: Phase 0


Phase 2 — Resource Listing

Effort: ~4 hours Priority: High Files: internal/inventory/

Work Items

Tests: aggregation across regions; empty-region handling; terminated- instance filtering; Name/Project/Environment-tag extraction (present/ absent); group/filter by Project and by Environment

Dependency: Phase 1


Phase 3 — Terminal UI (done)

Effort: ~4 hours Priority: High Files: internal/ui/

Built on github.com/rsdoiel/termlib rather than a stdlib-only implementation — see DECISIONS.md, “Use github.com/rsdoiel/termlib for the Terminal UI”.

Work Items

Tests: pick list with valid/invalid/cancel input, and prompt with default/validator, driven through termlib.LineEditor via an os.Pipe() (not a TTY) rather than a real terminal – LineEditor.Prompt detects the non-TTY input and falls back to plain line reading, which is termlib’s own documented/intended way to drive it in tests. This is the Go equivalent of the Bash version’s skipped “interactive testing is hard” tests, and is not skipped here.

Dependency: Phase 2


Phase 4 — Create EC2 Instance from AMI (done)

Effort: ~6 hours Priority: High Files: internal/workflow/{launch_instance,launch_execute,cloud_init_check,confirm,ssm,userdata}.go

Implemented as several smaller files rather than one, each independently tested: confirm.go (the reusable yes/no gate — see below), ssm.go (WaitForSSMOnline/RunShellCommand, generic building blocks reused by Phases 10/12/13), userdata.go (@file vs. inline text), cloud_init_check.go (the launch-time completion check), launch_instance.go (params struct + interactive collection), launch_execute.go (Launch/WaitUntilRunning), and create_instance_from_ami.go (the orchestrator PLAN.md originally called out as this phase’s one file).

Confirm(t, le, question) (bool, error) is the first instance of the “reusable confirmation/dry-run gate” DECISIONS.md’s “Structure workflows for future record/replay” calls for — a simple yes/no tier for reversible actions. Phase 8/11/13’s heavier dry-run + type-to-confirm tier is a separate function, added when those phases need it.

Work Items

Tests: parameter collection with a fake reader; user-data-from-file loading (success and file-not-found); launch success/failure; poll-until-running with a fake that transitions state after N calls; poll timeout path; cloud-init completion check (done/error/SSM-unavailable)

Dependency: Phase 1 (for the SSM client), Phase 3


Phase 5 — Create EC2 Instance from Cloud-Init YAML (done)

Effort: ~2 hours Priority: Medium Files: internal/workflow/launch_from_cloud_init.go, create_instance_from_cloud_init.go; extracted the shared execution logic from Phase 4’s create_instance_from_ami.go into a new runLaunch function in launch_execute.go, confirmed behavior-preserving against Phase 4’s existing tests before adding this phase’s own.

Work Items

Tests: prompt-ordering test confirming cloud-init is collected before the AMI pick list; otherwise covered by Phase 4’s execution-path tests (no new execution logic to test independently)

Dependency: Phase 4 (shares its execution path directly)


Phase 6 — Start EC2 Instance (done)

Effort: ~2 hours Priority: Medium Files: internal/workflow/power_state.go

Reuses Phase 4’s WaitUntilRunning (poll-until-running) and the displayConnectionInfo helper extracted from launch_execute.go when Phase 5 shared its execution path — no new poll/display logic needed here, so the timeout path is covered by Phase 4’s existing tests rather than re-tested at this orchestrator level (same reuse pattern as Phase 5’s own Tests note).

Work Items

Tests: start success/failure; poll-until-running with a fake state transition; poll timeout path; confirmation decline (no API call made)

Dependency: Phase 3


Phase 7 — Stop EC2 Instance (done)

Effort: ~2 hours Priority: Medium Files: internal/workflow/power_state.go

WaitUntilRunning (Phase 4) was refactored into a shared waitUntilState helper in launch_execute.go (confirmed behavior-preserving against Phase 4/6’s existing tests first), so WaitUntilStopped here is a one-line wrapper rather than duplicated polling logic.

Work Items

Tests: stop success/failure; poll-until-stopped with a fake state transition; poll timeout path; confirmation decline (no API call made)

Dependency: Phase 3


Phase 8 — Terminate EC2 Instance (done)

Effort: ~6 hours Priority: High Files: internal/workflow/terminate_instance.go; added ConfirmDestructive to confirm.go – the heavier type-to-confirm gate DECISIONS.md’s “Structure workflows for future record/replay” anticipated. Single-shot (no retry loop on mismatch), matching ec2_ami_manager.bash’s type_to_confirm; accepts either the instance ID or Name (DESIGN.md says “ID or name,” though the Bash version’s actual remove_ami call site only ever passed the ID – Go version honors the doc’s stated intent).

Work Items

Tests: dry-run display, including the DeleteOnTermination warning present/absent; production-tag warning shown/not-shown; type-to-confirm match/mismatch; termination success/failure

Dependency: Phase 3


Phase 9 — Manage Tags (done)

Effort: ~4 hours Priority: Medium Files: internal/workflow/manage_tags.go

Current tags are fetched fresh via ec2:DescribeInstances/DescribeImages (both already return the full Tags list) rather than a separate ec2:DescribeTags call. TagChangeParams stays minimal (resource ID, action, key, value), matching Phase 8’s TerminateInstanceParams precedent for the params-struct/confirm-gate seam.

Work Items

Tests: add/update/remove success paths, for both an instance and an AMI target; confirmation decline (no API call made); CreateTags/ DeleteTags failure handling

Dependency: Phase 3


Phase 10 — Create AMI from EC2 Instance (done)

Effort: ~8 hours Priority: High Files: internal/workflow/{volume_info,fstrim,create_ami_execute,create_ami_from_instance}.go

ec2:DescribeVolumes was missing from both the EC2API interface and DESIGN.md’s Assumptions list – added to both once this phase’s volume- info gathering needed it. isSSMOnline is a single-shot check (not Phase 4’s poll-based WaitForSSMOnline), matching ec2_ami_manager.bash’s check_ssm_availability for an instance that’s presumably been running a while already. WaitForAMIAvailable has no internal timeout at all (unlike every other poll in this codebase) – only the caller’s ctx can end it – since the Bash version’s fixed 600-second timeout for this exact operation was itself a correctness bug (DECISIONS.md, 2026-06-30).

Work Items

Tests: volume-info gathering; SSM-unavailable path; fstrim success/decline; AMI-name default generation and validation; create success/failure; unbounded-poll transitions (available/failed) with a fake clock or call-count-based fake

Dependency: Phase 3


Phase 11 — Remove AMI (done)

Effort: ~6 hours Priority: High Files: internal/workflow/remove_ami.go

The dependency check (instancesUsingAMI) filters the already-fetched inventory listing by ImageID rather than making a fresh AWS call – Phase 2’s ListInstances already carries each instance’s ImageID. Reuses ConfirmDestructive from Phase 8 unchanged.

Work Items

Tests: dry-run display; dependency detection (AMI in use / not in use); production-tag warning shown/not-shown; type-to-confirm match/mismatch; removal success/failure

Dependency: Phase 3


Phase 12 — Show/Export Cloud-Init (done)

Effort: ~8 hours Priority: High Files: internal/workflow/{cloud_init_instance,cloud_init_ami,cloud_init_export,show_cloud_init}.go

The always-terminate guarantee is a defer against a cleanup-scoped context.WithTimeout(context.Background(), 30s), deliberately decoupled from the caller’s ctx so cleanup isn’t skipped by an early return or by ctx itself being cancelled. Verified with dedicated tests asserting TerminateInstances is called exactly once across every failure path (SSM never online, command fails, instance never reaches running) and exactly zero times when launch itself fails (nothing to clean up).

Work Items

Tests: instance-path success; instance-path no-user-data-set; AMI-path full happy path with a fake SSM client; AMI-path cleanup-on-SSM-timeout (assert TerminateInstances is still called); AMI-path cleanup-on-command-failure; export-to-file success and path-error handling

Dependency: Phase 1, Phase 2, Phase 3


Phase 13 — Backup Archive & Trim (done, unit tests only)

Effort: ~10 hours Priority: High Files: internal/workflow/{backup_list,backup_upload,backup_verify,backup_delete,backup_archive}.go

Age filtering happens in Go (FilterByAge), not in the remote find command – the SSM command only lists every file’s size/mtime; all threshold logic is local and independently testable, avoiding fragile shell-arithmetic date math. Building the remote upload/delete scripts reintroduces the shell-quoting risk category this whole rewrite exists to eliminate for local command construction – SSM’s API only accepts a command string, so it’s unavoidable here. Caught a real quoting bug in review: the upload script’s S3 destination URI and echoed key were unquoted (only the source path was), which would have broken on any filename containing a single quote – fixed by routing every dynamic value through shellQuote and passing it as a separate printf argument rather than interpolating into a double-quoted string.

Real-AWS verification remains blocked on the S3 bucket and target instance IAM policy the user still needs to set up outside this repo (per DESIGN.md Assumptions #3-4) – flagged rather than guessed at.

Work Items

Tests: dry-run empty result; dry-run with matches; type-to-confirm mismatch; full happy path (all files verified, deleted, fstrim runs) with fake EC2API/SSMAPI/S3API; partial-verification-failure path (only verified files deleted, failures reported, fstrim still runs on whatever was freed); SSM-unavailable path

Dependency: Phase 1 (including the S3API), Phase 2, Phase 3. Real-AWS verification (Phase 16) additionally depends on Phase 4, since the live test target is a throwaway instance launched from an existing AMI that already has these backups baked in — never tested directly against a production instance.


Phase 14 — Main Menu and Integration (done)

Effort: ~4 hours Priority: High Files: cmd/awsops/main.go, internal/workflow/menu.go

Closed an architectural gap Phases 4-13 deliberately deferred here: each of the ten orchestrators originally took a single-region EC2API/ SSMAPI, but Phase 2 aggregates instances/AMIs across all four regions, so the right client isn’t known until after a resource is picked inside the orchestrator. Fixed by changing each orchestrator to take per-region client maps and resolve (internal/workflow/region_clients.go) by the picked resource’s Region field immediately after the pick – touched all ten orchestrator files and their tests, verified build/vet/race clean after each one before moving to the next.

menu.go’s MenuActions struct of func(ctx) error closures (bound by main.go to live clients and a mutable instance/AMI snapshot) lets menu dispatch itself be tested with fakes, without driving any workflow’s full interactive prompt sequence. Ctrl+C between prompts cancels ctx via signal.NotifyContext (every poll loop already selects on ctx.Done()); Ctrl+C/Ctrl+D during an active prompt surfaces as termlib.ErrInterrupted/ io.EOF instead, handled the same way in the menu loop.

Work Items

Tests: menu navigation and dispatch (fake workflows); refresh-after- operation; clean exit; signal handling

Dependency: Phase 4, 5, 6, 7, 8, 9, 10, 11, 12, 13


Phase 15 — Polish and Error Handling (done)

Effort: ~4 hours Priority: Medium

Work Items

Dependency: Phase 14


Phase 15.1 — Debug Logging (-debug) (done)

Effort: ~2 hours Priority: Medium

Work Items

Dependency: Phase 1 (AWS Client Layer)


Phase 15.2 — Create Key Pair inline (done)

Effort: ~1.5 hours Priority: Medium

Work Items

Dependency: Phase 4 (Create EC2 Instance from AMI)


Phase 15.3 — IAM Instance Profile pick-or-create (done)

Effort: ~2 hours Priority: Medium

Real-AWS testing hit InvalidParameterValue: ... Invalid IAM Instance Profile name at launch – see DECISIONS.md, “Support picking or creating an IAM instance profile from within awsops”.

Work Items

Dependency: Phase 4 (Create EC2 Instance from AMI)


Phase 15.4 — Derive key pair name from a private key filename/path (done)

Effort: ~1 hour Priority: Medium

Real-AWS testing hit InvalidKeyPair.NotFound from typing a private key file path at “Key pair name” – see DECISIONS.md, “Derive the AWS key pair name from a private key filename/path”.

Work Items

Dependency: Phase 15.2 (Create Key Pair inline)


Phase 15.5 — Pre-flight check: instance type vs. subnet Availability Zone (done)

Effort: ~2 hours Priority: Medium

Real-AWS testing (via the -debug log) hit Unsupported: ... instance type ... is not supported in ... Availability Zone – see DECISIONS.md, “Pre-flight check: instance type vs. subnet Availability Zone”.

Work Items

Dependency: Phase 4 (Create EC2 Instance from AMI)


Phase 15.6 — Instance type: curated pick list (done)

Effort: ~1 hour Priority: Medium

See DECISIONS.md, “Instance type pick list: curated shortlist, not the full AWS catalog”.

Work Items

Dependency: Phase 4 (Create EC2 Instance from AMI)


Phase 15.7 — Pre-flight check: instance type vs. AMI ENA support (done)

Effort: ~2 hours Priority: Medium

Real-AWS testing hit InvalidParameterCombination: Enhanced networking ... is required for the '<type>' instance type – see DECISIONS.md, “Pre-flight check: instance type vs. AMI ENA support”. Closes the ENA item queued in TODO.md since an earlier session.

Work Items

Dependency: Phase 4 (Create EC2 Instance from AMI), Phase 15.6 (reuses promptInstanceType)


Phase 15.8 — Tolerate post-launch/post-create eventual-consistency windows (done)

Effort: ~1 hour Priority: High

A real, confirmed launch failed immediately after RunInstances succeeded – see DECISIONS.md, “Tolerate DescribeInstances’ post- RunInstances eventual-consistency window”. Not an edge case: a near-certain race on every launch that happened to hit AWS’s brief propagation window.

Work Items

Dependency: Phase 4 (Create EC2 Instance from AMI), Phase 5 (Create AMI from EC2 Instance)


Phase 15.9 — Filter the subnet picker by instance-type Availability Zone support (done)

Effort: ~1.5 hours Priority: Medium

Real-AWS testing surfaced repeated instance-type/subnet back-and-forth – see DECISIONS.md, “Filter the subnet picker by instance-type Availability Zone support”.

Work Items

Dependency: Phase 15.5 (Pre-flight check: instance type vs. subnet Availability Zone)


Phase 15.10 — Move “Show resource lists” to the top of the Compute menu (done)

Effort: ~30 minutes Priority: Low

See DECISIONS.md, “Move ‘Show resource lists’ to the top of the Compute menu; rename from ‘Refresh’”.

Work Items

Dependency: Phase 14 (Main Menu and Integration)


Phase 15.11 — Auto-detect a bare existing-file path in User data / Cloud-init YAML input (done)

Effort: ~1 hour Priority: Medium

Real-world use: typing newt-machine.yaml (no @ prefix) at the Cloud-init YAML prompt silently became the instance’s literal user-data instead of loading the file – see DECISIONS.md, “Auto-detect a bare existing-file path in User data / Cloud-init YAML input”.

Work Items

Dependency: Phase 4 (Create EC2 Instance from AMI), Phase 5 (Create EC2 Instance from Cloud-Init YAML)


Phase 15.12 — Create EC2 Instance from Cloud-Init YAML always reads from a file (done)

Effort: ~1.5 hours Priority: Medium

Follow-up to Phase 15.11: for this specific prompt, “inline text or @file path” was itself the wrong shape – see DECISIONS.md, “Create EC2 Instance from Cloud-Init YAML always reads from a file”.

Work Items

Dependency: Phase 15.11 (Auto-detect a bare existing-file path in User data / Cloud-init YAML input)


Phase 15.13 — Offer official Ubuntu LTS AMIs alongside owned AMIs (done)

Effort: ~2 hours Priority: Medium

See DECISIONS.md, “Offer official Ubuntu LTS AMIs alongside owned AMIs when picking a base AMI”.

Work Items

Dependency: Phase 4 (Create EC2 Instance from AMI), Phase 15.6 (curated instance-type list), Phase 15.7 (ENA pre-flight check)


Phase 15.14 — Narrow configured regions to us-west-1/us-west-2 (done)

Effort: ~1 hour Priority: High

See DECISIONS.md, “Narrow configured regions to us-west-1/us-west-2” – follow-up to the official-Ubuntu-AMI feature surfacing a region (us-west-1) with no provisioned key pairs.

Work Items

Dependency: Phase 1 (region configuration)


Phase 15.15 — Validate key pair name against the AMI’s region (done)

Effort: ~3 hours Priority: High

A real launch failed with InvalidKeyPair.NotFound after every prompt was already answered and confirmed – see DECISIONS.md, “Validate key pair name against the AMI’s region”.

Work Items

Dependency: Phase 4 (Create EC2 Instance from AMI), Phase 15.2 (Create Key Pair inline), Phase 15.4 (key filename/path derivation)


Phase 15.16 — Tolerate GetCommandInvocation’s post-SendCommand eventual-consistency window (done)

Effort: ~1 hour Priority: High

Third instance of the same eventual-consistency bug pattern found this session – see DECISIONS.md, “Tolerate GetCommandInvocation’s post-SendCommand eventual-consistency window”.

Work Items

Dependency: Phase 4 (Create EC2 Instance from AMI – introduced RunShellCommand/the cloud-init completion check)


Phase 15.17 — ~/.awsops YAML config file (done)

Effort: ~2.5 hours Priority: Medium

See DECISIONS.md, “Add a ~/.awsops YAML config file for awsops’ own operational settings”.

Work Items

Dependency: Phase 15.14 (Narrow configured regions to us-west-1/us-west-2)


Phase 15.18 — Highlight PickList’s prompt header (done)

Effort: ~1 hour Priority: Low

See DECISIONS.md, “Highlight PickList’s prompt header when color is enabled”.

Work Items

Dependency: Phase 15 (Color output for state – ui.ColorEnabled(), NO_COLOR/non-TTY fallback)


Phase 15.19 — Configure per-instance backup directories by Name pattern (done)

Effort: ~1.5 hours Priority: Medium

See DECISIONS.md, “Configure per-instance backup directories by Name pattern”.

Work Items

Dependency: Phase 15.17 (~/.awsops YAML config file)


Phase 15.20 — Per-file upload progress for Backup Archive & Trim (done)

Effort: ~1.5 hours Priority: Medium

See DECISIONS.md, “Per-file upload progress for Backup Archive & Trim”.

Work Items

Dependency: Phase 11 (Backup Archive & Trim)


Phase 15.21 — Preflight check: S3 bucket access before Backup Archive & Trim’s dry-run list (done)

Effort: ~1 hour Priority: Medium

See DECISIONS.md, “Preflight check: S3 bucket access before Backup Archive & Trim’s dry-run list”.

Work Items

Dependency: Phase 11 (Backup Archive & Trim)


Phase 15.22 — Resolve a bucket’s actual region before Backup Archive & Trim’s access check (done)

Effort: ~1.5 hours Priority: High

Real-AWS regression found immediately after Phase 15.21 shipped – see DECISIONS.md, “Resolve a bucket’s actual region before Backup Archive & Trim’s access check”.

Work Items

Dependency: Phase 15.21 (Preflight check: S3 bucket access)


Phase 15.23 — Namespace backup uploads by instance (done)

Effort: ~1 hour Priority: Medium

See DECISIONS.md, “Namespace backup uploads by instance”.

Work Items

Dependency: Phase 11 (Backup Archive & Trim)


Phase 15.24 — Show instance IP addresses in the main listing (done)

Effort: ~1 hour Priority: Medium

See DECISIONS.md, “Show instance IP addresses in the main listing”.

Work Items

Dependency: Phase 1 (Unified Resource Listing)


Phase 15.25 — Suppress aws s3 cp’s progress output to avoid truncating the OK/FAIL signal (done)

Effort: ~1 hour Priority: High

Real-AWS regression found immediately after the IAM permission fix above – see DECISIONS.md, “Suppress aws s3 cp’s progress output to avoid truncating the OK/FAIL signal”.

Work Items

Dependency: Phase 15.20 (Per-file upload progress)


Phase 15.26 — Preflight check: AWS CLI availability before Backup Archive & Trim (done)

Effort: ~1 hour Priority: High

Hit twice in a row in real-AWS testing (newauthors, then data-new) – see DECISIONS.md, “Preflight check: AWS CLI availability before Backup Archive & Trim”.

Work Items

Dependency: Phase 11 (Backup Archive & Trim)


Phase 16 — Testing

Effort: ~6 hours Priority: High

Work Items

Dependency: Phase 14


Phase 17 — Documentation and Bash Retirement (done)

Effort: ~2 hours Priority: Medium

Work Items

Dependency: Phase 16


Phase 18 — Domain Picker Refactor

Effort: ~4 hours Priority: High Files: internal/ui/domainmenu.go, cmd/awsops/main.go, internal/workflow/menu.go

Per DECISIONS.md, 2026-07-02 “Redesign navigation as a domain picker; add Key Management, S3, and CloudFront domains”. Pure navigation refactor — no new AWS calls, and Compute’s existing workflows and tests are untouched. Runs alongside Phase 16/17, not blocking or blocked by them.

Work Items

Tests: domain picker dispatches to the right domain loop; Compute’s existing workflow tests continue to pass unmodified; “Back”/“Exit” behavior from within a domain menu

Dependency: Phase 14 (Main Menu and Integration)


Phase 19 — Key Management Domain (done)

Effort: ~6 hours Priority: Medium Files: internal/inventory/keypairs.go, internal/workflow/{keypair_create,keypair_import,keypair_delete,keymgmt_common,keymgmt_menu}.go

Implements DESIGN.md Features 13-16.

Work Items

Tests: internal/inventory/keypairs_test.go, internal/workflow/{keypair_create,keypair_import,keypair_delete,keymgmt_menu}_test.go – fakes for DescribeKeyPairs/CreateKeyPair/ImportKeyPair/DeleteKeyPair covering success, name-collision re-prompt (Create and Import), malformed public key file, dependent-instance detection, and the menu loop’s dispatch/refresh/error/exit-signal behavior. go build ./..., go vet ./..., go test ./... -race, gofmt -l . all clean.

Tests: fakes for DescribeKeyPairs/CreateKeyPair/ImportKeyPair/ DeleteKeyPair covering success, name-collision re-prompt, malformed public key file, dependent-instance detection

Dependency: Phase 18; reuses Phase 15.2’s CreateKeyPair wrapper


Phase 20 — S3 Domain (Buckets & Static Websites) (done)

Effort: ~16 hours (raised from ~10h after adding Feature 21.1, 2026-07-08 — see DECISIONS.md) Priority: Medium Files: internal/awsclient/s3.go (broadened), internal/inventory/buckets.go, internal/workflow/{bucket_create,bucket_website,bucket_sync,bucket_browse,bucket_lifecycle,s3_menu}.go

Implements DESIGN.md Features 17-21, 21.1, and the 2026-07-02 “CloudFront + OAC by default for static websites” decision, with scope decisions made 2026-07-08 before implementation started (see DECISIONS.md): public-read bucket policy opt-out deferred, key-prefix filter added to Browse/Manage Objects, and Feature 21.1 (Manage Bucket Lifecycle Policies) added as new scope with a Purpose-tag-driven guided/generic split.

Work Items

Tests: internal/inventory/buckets_test.go, internal/workflow/{bucket_create,bucket_website,bucket_sync,bucket_browse,bucket_lifecycle,s3_menu}_test.go – fakes for each new S3 call covering success/error paths (bucket-name-taken, website-not-configured treated as non-error, sync diff correctness, upload/delete confirmations never bundled, prefix filter narrows the object listing, lifecycle guided-vs-generic branch selection by Purpose tag, rule add/edit/remove round-trips through the fetch-modify-PutBack cycle correctly) — TDD throughout. go build ./..., go vet ./..., go test ./... -race, gofmt -l . all clean.

Real-AWS verification (2026-07-08): created one throwaway bucket per purpose, configured website hosting, ran Sync twice (upload pass, then a locally-deleted-file pass exercising the separate delete confirm), browsed with and without a prefix filter (view metadata, delete an object), set and round-tripped a guided backup lifecycle policy, and added/edited/removed a named rule via the generic editor – all against account 074441911104. Found and fixed one real bug along the way (empty- Rules PutBucketLifecycleConfiguration on last-rule removal; see DECISIONS.md) and left one open, documented gap (no local validation that transition-days < expiration-days; see TODO.md). All throwaway buckets and objects cleaned up afterward – no production bucket was touched.

Dependency: Phase 18


Phase 20.1 — S3 Object Management: Interactive File Manager (huh + bubbletea)

Status: implemented and unit-tested 2026-07-09 (go build ./..., go vet ./..., go test ./... -race all clean); not yet verified against real AWS — see Phase 22. This is the “next release focuses on improving UX and the UI” work flagged when 0.0.1 shipped (see DECISIONS.md, “0.0.1 scope: ship on termlib as-is; postpone CloudFront and the UI/UX overhaul”). Numbered 20.1 (inserted after Phase 20 without renumbering CloudFront’s Phases 21-22 — same decimal-insertion convention already used for Phase 15.1-15.26 and DESIGN.md’s Feature 21.1) because it revises Phase 20’s S3 domain rather than adding a new one.

Effort: ~24 hours estimated; actual scope grew somewhat during implementation (extracting internal/s3diff and adding a dedicated Sync action — see below and DECISIONS.md). Priority: High Files (as actually built, package boundary resolved during implementation): internal/awsclient/s3.go/logging_s3.go (added GetObject + wrapper), internal/workflow/s3_menu.go (revised menu), internal/workflow/object_browser.go (huh pre-flight, new), internal/filemanager/*.go (the bubbletea Model — its own package, not folded into internal/workflow, since internal/workflow now depends on it via object_browser.go and a same-package dependency the other way would cycle), internal/s3diff/*.go (new — diffSync/walkLocalTree/ listAllBucketObjects/contentTypeFor extracted here, out of the now- deleted bucket_sync.go, so both internal/workflow and internal/filemanager can depend on the same diff logic without a workflow<->filemanager import cycle), internal/workflow/bucket_browse.go (stripped to just listBucketObjectsWithPrefix, still needed by Delete Bucket’s empty-bucket check) and internal/workflow/bucket_delete_objects.go (deleted) and internal/workflow/bucket_sync.go (deleted, superseded by internal/s3diff + the file manager’s Sync action).

Implements DESIGN.md Features 21.2-21.8.

Work Items

Tests: resolved — github.com/charmbracelet/x/exp/teatest is real and usable (confirmed against actual source, per this project’s evaluation discipline): teatest.NewTestModel runs the Model as a real bubbletea.Program against an in-memory terminal, .Send injects key messages, and teatest.WaitFor polls rendered output. One practical caveat learned while writing these tests: bubbletea’s renderer only retransmits screen lines that changed since the last frame, so asserting on unchanged-but-still-visible text across two separate WaitFor calls can race (the earlier call already drained the frame that contained it) — check multiple substrings in one WaitFor condition, or assert on the status line’s derived text instead of raw row content, when that matters. go test -race caught one genuine bug this surfaced: a running action’s background goroutine (runDelete) was mutating pane state (clearTags) directly instead of only sending progress text over its channel, racing with the render loop’s concurrent read — fixed by moving that mutation to the overlay-dismiss handler, which runs on Update’s single goroutine. Diff/glob/listing helpers are tested as plain Go functions independent of the Model (internal/s3diff, internal/filemanager/entry_test.go, listing_test.go, pane_test.go).

Dependency: Phase 20 (done)


Phase 20.2 — S3 Menu: Convert RunS3Menu to huh.Select (done)

Status: implemented and unit-tested 2026-07-10 (go build ./..., go vet ./..., go test ./... -race all clean). Continues continue_next_time.txt’s next-up item from the Phase 20.1 session: “replace the S3 management menu and display of buckets with the huh module” — this phase covers the menu half; bucket-selection call sites are Phase 20.4 (below).

Files: internal/workflow/s3_menu.go, s3_menu_test.go, huh_accessible_test.go (new — reusable pipe-testable-input helper).

Work Items

Tests: all existing dispatch/refresh/error-handling coverage carried over unchanged (same input strings — huh’s accessible-mode 1-indexed numbering happens to match s3MenuItems’ order); new TestMapS3MenuPickerErr for the abort mapping.

Dependency: Phase 20.1 (done, established the huh call-site pattern); the pipe-testability resolution above (done, same session).


Phase 20.3 — S3 Domain: Paged, Accessible Resource List Display (superseded)

Superseded 2026-07-10, same day, by Phase 20.6 below. Screen-reader/accessible-mode compatibility – this phase’s central constraint – turned out not to be an actual requirement once discussed directly (DECISIONS.md, “Deprecate termlib; standardize on huh/ bubbletea before 0.0.2”). internal/ui.PagedTable/DisplayBuckets, implemented below, are retired in favor of a bubbletea-based List-tier component built on a new shared internal/tui chrome package (Phase 20.5), less than a day after landing. Left below as the accurate record of what was implemented and why it changed, not deleted.

Status (as originally completed): implemented and unit-tested 2026-07-10 (go build ./..., go vet ./..., go test ./... -race all clean). Exposed by testing Phase 20.2: every successful S3 menu action called actions.Refresh(ctx), which both re-fetched bucket data and printed the entire bucket table (ui.DisplayBuckets) unconditionally — cluttering the menu’s redisplay after every action, with no pagination for a large bucket count. Full design, mockup (approved before implementation started), and rejected alternatives: DESIGN.md, “S3 Resource List Display — Paged, Accessible- Compatible”; decision record: DECISIONS.md, “Decouple the S3 menu from resource-list display; add a generic paged table to internal/ui”.

Priority: requested directly by the user, ahead of Phase 20.4. Files: internal/ui/paged_table.go (new, generic — not bucket-specific), paged_table_test.go (new), internal/ui/display.go (DisplayBuckets now takes le and returns error, delegates to PagedTable), display_test.go, internal/workflow/s3_menu.go (S3Actions.ShowResourceLists, new field; “Show resource lists” entry now dispatches to it instead of Refresh), s3_menu_test.go, cmd/clasm/main.go (refreshS3 now silent-refetch-only; showS3ResourceLists, new closure, wired to ShowResourceLists).

Work Items

Tests: internal/ui/paged_table_test.go (11 cases: single/multi page, next/previous navigation and their at-boundary no-ops, page-back, invalid-command reprompt, case-insensitive commands, empty rows, and read-error propagation — each via Title’s recorded call args, not string-scraping banners); internal/ui/display_test.go (DisplayBuckets empty/populated/paginates-large-lists); internal/workflow/s3_menu_test.go (new TestRunS3Menu_ShowResourceListsDispatchesToItsOwnAction, since no prior test exercised choosing menu item 1 at all). No new testability question — PagedTable is plain termlib/LineEditor.Prompt sequential printing (no huh), pipe-testable the same way PickList’s existing tests already are, reusing this package’s own newPipeEditor helper.

Dependency: Phase 20.2 (done — this phase was found while testing it, not a prerequisite of its design).


Phase 20.4 — S3 Bucket Selection: Convert to tui.Picker (done)

Status: implemented and unit-tested 2026-07-10 (go build ./..., go vet ./..., go test ./... -race, gofmt -l all clean). Originally scoped to convert to huh.Select (continue_next_time.txt‘s remaining next-up item from Phase 20.1’s session); retargeted before any code was written once the user pointed out huh.Select’s rendering doesn’t match the List/Manager tiers’ chrome (“this UI should feel the same whether I select a bucket, an AMI or an EC2 instance”) — see DECISIONS.md, “Add a Picker tier: resource selection gets its own internal/tui component, not huh.Select,” and DESIGN.md’s “Picker tier” section (with the full map of every current resource-selection call site across the app).

Converted the bucket-selection step inside ConfigureBucketWebsite (bucket_website.go), ManageBucketLifecyclePolicies (bucket_lifecycle.go), and DeleteBucket (bucket_delete.go) — previously ui.PickList(t, le, buckets, bucketLabel, "Select a bucket") — to a shared pickBucket helper (bucket_website.go, next to bucketLabel) built on tui.RunPicker. CreateBucket stayed out of scope (it creates a new bucket, not select an existing one); the rest of each workflow stays on termlib. object_browser.go’s existing huh.Select-based bucket pre-flight was NOT touched by this phase — whether it should also move to PickerModel is a separate question, not decided here.

Testable-core split, since tui.RunPicker runs a real bubbletea Program that can’t be driven by a test’s pipe input (mirrors the RunS3Menu/runS3Menu split from Phase 20.2): each of the three exported functions now does the picker call, then delegates to an unexported core taking the already-resolved bucket directly (configureBucketWebsite, manageBucketLifecyclePolicies, deleteBucket). Every existing test for “pick a bucket, then do X” was rewritten to call the unexported core with a bucket value instead of driving a ui.PickList-shaped "1\n" pipe input; each function’s own “cancel while picking a bucket” test (TestConfigureBucketWebsite_ CancellationAbortsCleanly, TestManageBucketLifecyclePolicies_ CancellationAtBucketPick, and DeleteBucket’s equivalent) was retired — that tested ui.PickList’s “0=Cancel” numbered-option convention, which no longer exists once selection is tui.RunPicker-based. The picker-selection step itself is covered only by manual/interactive verification going forward, the same accepted limitation object_browser.go’s huh-based bucket pre-flight already has. cancelledIsNil (manage_tags.go) now also recognizes tui.ErrCancelled alongside ui.ErrCancelled, so cancelling either kind of picker behaves identically from the operator’s point of view.

Dependency: Phase 20.8 (internal/tui.PickerModel itself, done).


Phase 20.5 — internal/tui: Shared Chrome Package (extracted from the file manager) (done)

Status: implemented and unit-tested 2026-07-10 (go build ./..., go vet ./..., go test ./... -race all clean). First piece of DESIGN.md’s “Terminal UI Architecture: Menus, Actions, Lists, and Managers”; decision record: DECISIONS.md, “Terminal UI architecture: menu → action/list/manager taxonomy; shared internal/tui chrome package.”

Files: new internal/tui package (box.go, scroll.go, style.go + their _test.go files, written test-first); internal/filemanager/view.go (box-drawing/scroll/style helpers removed, replaced with calls into internal/tui); internal/filemanager/box_test.go/scroll_test.go (moved tests removed, remaining Model-level tests updated to call tui.RuneLen instead of the now-moved stripANSI).

Work Items

Tests: internal/tui/box_test.go/scroll_test.go/style_test.go (20 cases total, several new beyond what internal/filemanager already had indirectly — direct coverage for TopBorder/BottomBorder/ Divider/SplitDivider/MergeDivider/BoxLine, which previously only had indirect coverage via filemanager.Model.View()’s tests), written before box.go/scroll.go/style.go existed. internal/filemanager’s existing test suite (unchanged assertions, box_test.go/scroll_test.go trimmed to their Model-level cases) is the regression check that the extraction was behavior-preserving.

Dependency: none (pure refactor, no new external dependency).


Phase 20.6 — S3 Domain: List Viewer bubbletea Component (replaces PagedTable) (done)

Status: implemented and unit-tested 2026-07-10 (go build ./..., go vet ./..., go test ./... -race all clean). Replaces Phase 20.3 (superseded, above). Full design: DESIGN.md, “Terminal UI Architecture…,” “List tier” section; decision record: DECISIONS.md, “Terminal UI architecture…”.

Files: internal/tui/listview.go (new: ListViewConfig, ListViewModel, NewListViewModel, RunListView) + listview_test.go (new, written test-first); internal/ui/display.go (DisplayBuckets rewritten around tui.RunListView; bucketListViewConfig extracted as its testable core); internal/ui/paged_table.go/paged_table_test.go (removed); cmd/clasm/main.go (showS3ResourceLists now calls ui.DisplayBuckets(ctx, s3State.buckets) — no term/le needed, huh/ termlib aren’t involved at all). The “List S3 Buckets” rename and dropping “Back to domain picker” are Phase 20.7, not this phase — this phase only replaces the rendering mechanism behind “Show resource lists,” not its label or the surrounding menu.

Work Items

Tests: internal/tui/listview_test.go (9 cases). A real rendering lesson surfaced while writing them, worth keeping in mind for any future internal/tui component: when rendered content height exactly matches the declared terminal height (this component’s own “fill the screen” design, by construction — windowHeight = height - chrome), driving it through a real teatest.NewTestModel Program can lose its own top line to the emulated terminal’s scrolling, a known class of issue with inline (non-tea.WithAltScreen) bubbletea rendering, not a bug in this component specifically. internal/filemanager’s own test suite already sidesteps this the same way: exact-height/scroll-window assertions (TestModel_LargeListing_*) drive Model directly (set width/height, call Update/View() synchronously) rather than through teatest, reserving teatest for key-driven behavior with content comfortably smaller than the terminal. This phase’s tests follow the same split.

Dependency: Phase 20.5 (done — the shared chrome it’s built on).

Dependency: Phase 20.5 (the shared chrome it’s built on).


Phase 20.7 — S3 Menu: universal ‘q’ quit key; remove “Back to domain picker”; rename “Show resource lists” (done)

Status: implemented and unit-tested 2026-07-10 (go build ./..., go vet ./..., go test ./... -race all clean). Applies DECISIONS.md’s “TUI keybinding conventions” to the one menu converted so far.

Files: internal/workflow/s3_menu.go, s3_menu_test.go.

Work Items

Tests: s3_menu_test.go rewritten around a context.WithCancel + cancel-from-within-the-test-action-closure pattern for every test that previously chose “Back to domain picker” (item 7) to end the loop after observing one dispatch — that menu item no longer exists, and accessible mode has no way to simulate the q/ctrl+c abort that replaces it (same limitation mapS3MenuPickerErr already documented). New TestS3MenuItems_NoBackToDomainPickerEntry (exactly 6 items, no nil action) and TestS3MenuItems_FirstItemIsListS3Buckets guard the removal/ rename directly. The q-triggers-Quit behavior itself can only be confirmed by real interactive use — not yet done, same class of gap as this session’s other huh/bubbletea work.

Dependency: Phase 20.2 (the menu this applies to).


Phase 20.8 — internal/tui: PickerModel (selectable, filterable List-tier component) (done)

Status: implemented and unit-tested 2026-07-10 (go build ./..., go vet ./..., go test ./... -race, gofmt -l all clean). Full design: DESIGN.md, “Terminal UI Architecture…,” “Picker tier” section; decision record: DECISIONS.md, “Add a Picker tier: resource selection gets its own internal/tui component, not huh.Select.”

Files: internal/tui/picker.go (PickerConfig, PickerModel, NewPickerModel, RunPicker, ErrCancelled) + picker_test.go (12 cases, written test-first).

Work Items

A real rendering finding, beyond what Phase 20.6 already documented: the content area’s rendered height must be pinned to the unfiltered row count (bounded by the window height), not however many rows the current filter happens to match — otherwise the box’s height shrinks and grows as the operator types a filter, which reproduced the same class of inline-bubbletea-rendering hiccup Phase 20.6 found with exact/changing frame heights (confirmed by a failing test before this fix, not just reasoned about). Fixed by padding the content area with blank rows up to a stable height determined by the total dataset size — incidentally also better UX (a fixed-height results viewport while typing a filter, fzf-style, rather than the box visibly resizing).

Tests: written before the implementation, following listview_test.go’s established split (teatest for key-driven behavior with content comfortably smaller than the terminal, direct Model-driving for exact scroll-window/height assertions). Two teatest-based filter tests initially failed for a second, distinct reason: bubbletea only retransmits screen lines that changed since the immediately preceding frame, so checking for the same text across two separate WaitFor calls (one already having drained it from the stream) can race if a later frame doesn’t happen to change that particular line again — fixed by combining assertions into single WaitFor calls, exactly the workaround internal/filemanager’s own tests already document for this same class of issue.

Dependency: Phase 20.5 (the shared chrome it’s built on).


Phase 20.9 — Lifecycle Rules Action Menu: Convert to huh.Select (done)

Status: implemented and unit-tested 2026-07-10 (go build ./..., go vet ./..., go test ./... -race, gofmt -l all clean). Requested directly by the user after manually trying the S3 domain (Phase 20.7’s q key took a moment to render the first time, prompting a live check): convert ManageBucketLifecyclePolicies’s “Choose an action” menu (Add rule/Edit rule/Remove rule/View rule details) from ui.PickList to huh.Select, matching RunS3Menu’s Phase 20.2/20.7 pattern exactly — this is a guide-menu-shaped choice (a small, fixed action set), not a Picker-tier candidate (DESIGN.md’s “Picker tier” map already excluded it for this reason).

Files: internal/workflow/bucket_lifecycle.go (pickLifecycleAction, new; lifecycleActionLabel removed, no longer needed), bucket_lifecycle_test.go (all 15 tests updated).

Work Items

Tests: action-menu selections now feed through a separate newHuhAccessibleInput reader, not le (which still feeds every other prompt in this function — rule/storage-class PickLists, confirms, day-count/ID input, unaffected by this phase). Several tests that used to select “Back” (position 5) to end the loop cleanly were restructured around real terminating actions instead, since that position no longer exists and the q/ctrl+c abort that replaces it can’t be simulated in accessible mode (same limitation mapS3MenuPickerErr already documents) — e.g. choosing “Edit rule”/“View rule details” with zero rules present returns immediately by construction, which several tests already relied on incidentally and now rely on deliberately. TestManageBucketLifecyclePolicies_BackActionSkipsPut (tested the “Back” choice specifically) was retired, matching the precedent set by TestRunS3Menu_BackToDomainPickerDoesNotRefresh in Phase 20.2.

Dependency: Phase 20.2 (established the pattern this reuses).


Phase 20.10 — Menu Tier: Top-Level Navigation Menus (done)

Status: implemented and unit-tested 2026-07-10 (go build ./..., go vet ./..., go test ./... -race, gofmt -l all clean). First batch of DESIGN.md’s Menu-tier punch list, working through it in the order the user requested (menu tier, then picker, then list). Converts the three top-level navigation menus from ui.PickList to huh.Select, each an exact copy of RunS3Menu’s Phase 20.2/20.7 pattern: select by index (each item’s action is a func, not comparable), q bound alongside ctrl+c on Quit, a printed hint above the menu, the redundant “Back”/“Exit” menu item dropped.

Files: domain_menu.go/domain_menu_test.go, menu.go/ menu_test.go, keymgmt_menu.go/keymgmt_menu_test.go.

Work Items

Tests: every test that used to select “Back”/“Exit” (by position) to end a menu loop was rewritten around the context.WithCancel + cancel-from-within-the-test-action-closure pattern established in Phase 20.7 (cancelingAction, shared from s3_menu_test.go rather than redefined per file); tests for the removed-item’s own specific behavior (TestRunMainMenu_BackToDomainPickerDoesNotRefresh, TestRunKeyMgmtMenu_BackToDomainPickerDoesNotRefresh, TestRunMainMenu_CleanExitOnCancelledPickList, TestRunKeyMgmtMenu_CleanExitOnCancelledPickList, TestRunDomainPicker_ExitEndsTheProgram, TestRunDomainPicker_CleanExitOnCancelledPickList) were retired, same precedent as Phase 20.2/20.7/20.9. New TestDomainItems_NoExitEntry/ TestMainMenuItems_NoBackToDomainPickerEntry/ TestKeyMgmtMenuItems_NoBackToDomainPickerEntry guard each menu’s item count and non-nil actions directly.

Dependency: Phase 20.2 (the pattern this reuses).


Phase 20.11 — Menu Tier: Remaining Punch-List Items (done)

Status: implemented and unit-tested 2026-07-10 (go build ./..., go vet ./..., go test ./... -race, gofmt -l all clean). Completes DESIGN.md’s Menu-tier punch list – every remaining ui.PickList call site classified as Menu tier is now huh.Select. Two shared helpers added to domain_menu.go next to runMenuField/ menuQuitKeyMap: pickString (fixed []string options) and its generic backer pickComparable[T comparable] (fixed []T options with a caller-supplied label func) – covers every remaining site without repeating the index-selection workaround pickS3MenuItem/ pickMainMenuItem/etc. needed only because their option types embed a func field.

Work Items

Tests: every affected test call site now feeds its huh.Select(s) via a separate newHuhAccessibleInput reader instead of the numbered le-pipe selection ui.PickList used to read; le still feeds every other prompt unaffected by these conversions. Cancellation tests for pickers that used to support a PickList “0=Cancel”/le-driven abort (TestShowCloudInit_CancelledKindPickList, TestCreateBucket_RegionCancellationAbortsCleanly, TestCreateKeyPairStandalone_CancelledRegionPick, TestImportKeyPairStandalone_CancelledRegionPick) were retired – q/ ctrl+c abort has no accessible-mode keyboard to simulate it with, same precedent as every prior phase’s menu conversions. cancelledIsNil (manage_tags.go) now also matches huh.ErrUserAborted, unifying it with ui.ErrCancelled/tui.ErrCancelled as the one cancellation-mapping policy for one-off Menu-tier pickers (as opposed to mapMenuPickerErr, which is specific to domain-loop menus backing out to ErrBackToDomainPicker).

Files: domain_menu.go (new pickString/pickComparable helpers), show_cloud_init.go/_test.go, manage_tags.go/_test.go, bucket_create.go/_test.go, keymgmt_common.go, keypair_create.go/ _test.go, keypair_import.go/_test.go, launch_prompts.go/ _test.go, instance_type_az_check.go/_test.go, instance_type_ena_check.go/_test.go, launch_instance.go/_test.go, launch_from_cloud_init.go/_test.go, create_instance_from_ami.go/ _test.go, create_instance_from_cloud_init.go/_test.go, bucket_lifecycle.go/_test.go.

Dependency: Phase 20.10 (the runMenuField helper this builds on).

DESIGN.md’s Menu-tier punch list is now fully converted – next up is the Picker tier (8 remaining entries), per the user’s requested order (menu, then picker, then list).


Phase 20.12 — Picker Tier: Every Remaining Resource Selector (done)

Status: implemented and unit-tested 2026-07-10 (go build ./..., go vet ./..., go test ./... -race, gofmt -l all clean). Completes DESIGN.md’s Picker-tier punch list – every remaining ui.PickList call site classified as Picker tier (fetched/variable-length resource collections) is now tui.RunPicker. Six new one-line-per-type picker helpers added next to each resource’s own label function, all following pickBucket’s exact shape (Phase 20.4): build rows []string from the resource’s label func, call tui.RunPicker, index back into the original slice – pickInstance/pickImage (power_state.go/ launch_instance.go), pickSubnet (launch_prompts.go), pickInstanceProfileChoice/pickRole (create_instance_profile.go), pickKeyPairChoice (create_key_pair.go), pickKeyPairForDeletion (keypair_delete.go), pickLifecycleRule (bucket_lifecycle.go).

Work Items

Tests: every affected call site’s happy-path/error-path tests were rewritten to call the new testable core with an already-resolved resource instead of driving ui.PickList’s numbered selection through le; a handful of fakes needed a forced-error field (fakeIAMClientNoProfiles(), errNoKeyPairsConfigured on fakeEC2Client) so unrelated launch-params tests don’t themselves reach the now-bubbletea IAM-profile/key-pair pickers. “0=Cancel”/ list-selection tests for every converted picker were retired – a real bubbletea Program can’t be pipe-tested, no keyboard to simulate an abort or a specific-item selection with – the same precedent pickBucket (Phase 20.4) already established; each retirement is commented in place noting what still covers the underlying logic directly (the resource’s own list/filter tests, or a newly-split testable core).

Files: power_state.go/_test.go, launch_instance.go/_test.go, launch_from_cloud_init.go/_test.go, create_instance_from_ami.go/ _test.go, create_instance_from_cloud_init.go/_test.go, terminate_instance.go/_test.go, backup_archive.go/_test.go, create_ami_from_instance.go/_test.go, remove_ami.go/_test.go, show_cloud_init.go/_test.go, manage_tags.go/_test.go, launch_prompts.go/_test.go, create_instance_profile.go/_test.go, create_key_pair.go/_test.go, keypair_delete.go/_test.go, bucket_lifecycle.go/_test.go, userdata_test.go (gained promptCloudInitYAMLFile’s own direct tests, migrated out of launch_from_cloud_init_test.go).

Dependency: Phase 20.4 (pickBucket, the pattern every helper here copies) and Phase 20.11 (the Menu-tier sweep that had to finish first).

DESIGN.md’s Picker-tier punch list is now fully converted – next up is the List tier (3 remaining entries: EC2 instances, AMIs, Key pairs), per the user’s requested order (menu, then picker, then list).


Phase 20.13 — List Tier: EC2 Instances, AMIs, Key Pairs (done)

Status: implemented and unit-tested 2026-07-10 (go build ./..., go vet ./..., go test ./... -race, gofmt -l all clean). Completes DESIGN.md’s List-tier punch list – the last remaining tier from the full termlib-to-huh/bubbletea conversion sweep. DisplayInstances, DisplayImages, and DisplayKeyPairs (internal/ui/display.go) converted to tui.RunListView, mirroring DisplayBuckets/ bucketListViewConfig (Phase 20.6) exactly: each gained an instanceListViewConfig/imageListViewConfig/keyPairListViewConfig builder (pure, unit-testable column formatting, reusing the existing orUnknown/orNone/stateColor helpers unchanged) and became a thin func(ctx, ...) error wrapper.

Work Items

Tests: display_test.go’s TestDisplayInstances_*/ TestDisplayImages_*/TestDisplayKeyPairs_* (direct calls against a bytes.Buffer-backed termlib.Terminal) replaced with TestInstanceListViewConfig_*/TestImageListViewConfig_*/ TestKeyPairListViewConfig_* (direct calls against the new pure builders, asserting on cfg.Header/cfg.Rows/cfg.Title), matching TestBucketListViewConfig_*’s existing style exactly; the two color-specific tests became TestInstanceRow_ColorEnabled_ AppliesStateColor/ColorDisabled_NoANSICodes, calling instanceRow directly with an explicit bool instead of relying on the ambient terminal state DisplayInstances used to take as a parameter. New TestStyleRow_CursorRowReassertsReverseVideoAfterEmbeddedReset (internal/tui/style_test.go) covers the reverseVideo fix directly. menu_test.go/keymgmt_menu_test.go gained TestRunMainMenu_ShowResourceListsDispatchesToItsOwnAction/ TestRunKeyMgmtMenu_ShowResourceListsDispatchesToItsOwnAction, mirroring s3_menu_test.go’s existing TestRunS3Menu_ShowResourceListsDispatches ToItsOwnAction – neither menu’s “Show resource lists” dispatch had ever actually been exercised by a test before (both testMenuActions/ testKeyMgmtActions helpers only wired Refresh), a real coverage gap this phase closed along the way.

Phase 20.14 — Chrome Consistency: Full-Height Rendering Fix + List-Tier Filtering (done)

Status: implemented and unit-tested 2026-07-10 (go build ./..., go vet ./..., go test ./... -race, gofmt -l all clean). Follow-up to Phase 20.13, from user-reported feedback after using the newly converted List tier: (1) the List/Picker/file-manager boxes weren’t using the full terminal height, and (2) List-tier filtering – listed in DESIGN.md’s keybinding table (/ = Filter, “Menus, pickers, lists, managers”) since Phase 20.8 but never built for lists – was still missing.

Work Items

Tests: internal/tui/listview_test.go gained TestListView_SlashEntersFilterModeAndNarrowsRows, _FilterIsCaseInsensitive, _EscClearsFilter, _LettersDuringFilterModeAreTextNotCommands – direct mirrors of picker_test.go’s existing filter tests, minus selection (List has nothing to choose). TestListView_LegendShowsScrollAndQuit renamed to _LegendShowsScrollFilterAndQuit and now also asserts “filter” appears. All of picker_test.go’s existing filter tests continued to pass unchanged against the refactored filterState-backed PickerModel, confirming the extraction didn’t change Picker’s own behavior.

Files: internal/ui/display.go/display_test.go, internal/tui/style.go/style_test.go, internal/workflow/menu.go/ menu_test.go, internal/workflow/keymgmt_menu.go/ keymgmt_menu_test.go, cmd/clasm/main.go.

Dependency: Phase 20.6 (bucketListViewConfig/DisplayBuckets, the pattern every builder here copies) and Phase 20.12 (the Picker-tier sweep that had to finish first).

DESIGN.md’s full termlib-to-huh/bubbletea conversion punch list (Menu, Picker, and List tiers) is now completely converted. Remaining termlib call sites outside this punch list (e.g. ui.PickList/ui.Prompt/ Confirm calls not classified into any of the three tiers) stay as-is per DESIGN.md’s own note that internal/ui shrinks over the course of termlib removal rather than being replaced in one step.

Phase 20.15 — Termlib Removal, Part 1: Foundational Helpers (done)

Status: implemented and unit-tested 2026-07-13 (go build ./..., go vet ./..., go test ./... -race, gofmt -l all clean except the pre-existing, unrelated version.go). Implements DESIGN.md, “Removing termlib: Action Wizards and Output,” and DECISIONS.md, “Remove termlib entirely: input via huh, output via io.Writer.”

Landed differently from the original design in one real way, found while implementing: ui.Prompt/Confirm/ConfirmDestructive don’t just need a testable core (the RunXxx/runXxx split used everywhere else) – they need their accessible-mode I/O exposed as a functional option (ui.WithIO(input, output), WithConfirmIO(input, output)), because these three are called from dozens of call sites spread across both internal/ui and internal/workflow, not from one function with one obvious “core” to split. ConfirmDestructive also changed shape slightly to make room for the options param: mustMatch ...string became mustMatch []string, opts ...ConfirmOption (Go allows only one variadic per signature) – the ~6 call sites now wrap their arguments in a slice literal instead of passing them bare. This option-based shape is what let Phase 20.16 thread the same input/output pair through every intermediate “leaf” prompt function (not just the handful anticipated below) without a second, parallel mechanism.

Work Items

Tests: prompt_test.go/confirm_test.go rewritten to call the public Prompt/Confirm/ConfirmDestructive directly with WithIO/WithConfirmIO, matching the accessible-mode pipe pattern already established for the Menu tier. picklist_test.go deleted. display_test.go/color_test.go updated for the local constants, plus new TestTruncate/TestPadRight parity tests (ported from termlib’s own test cases) that weren’t there before. progress_ticker_test.go updated to construct a bytes.Buffer directly instead of termlib.New(&buf), plus a new TestFormatDuration (also ported from termlib’s own test cases).

Files: internal/ui/{prompt,picklist,color,display}.go (+ tests), internal/workflow/{confirm,progress_ticker,domain_menu}.go (+ tests).

Phase 20.16 — Termlib Removal, Part 2: Propagate Across Action Wizards (done)

Status: implemented and unit-tested 2026-07-13 (same clean sweep as Phase 20.15 – the two landed together in one sitting, since Go’s whole-module compilation meant Phase 20.15’s signature changes and this phase’s propagation had to be internally consistent at every commit boundary; see DESIGN.md’s “Sequencing” note). Propagated le *termlib.LineEditor removal and t *termlib.Terminalio.Writer across every remaining caller, then removed termlib from go.mod.

The real scope turned out larger than the original work-item list below: ui.Prompt/Confirm/ConfirmDestructive’s new WithIO/ WithConfirmIO options (Phase 20.15) meant every intermediate function between a workflow’s testable core and its leaf prompt call also needed input io.Reader, output io.Writer parameters threaded through – promptKeyPairNameOrCreate, promptSubnetID, promptSecurityGroupIDs, promptIAMInstanceProfileOrCreate, createInstanceProfileForRole, createInstanceProfileInteractive, promptCloudInitYAMLFile, offerFstrimIfAvailable, confirmLifecycleChange, promptOptionalDays, promptPositiveDays, removeLifecycleRule(ForRule), startEC2Instance/stopEC2Instance, terminateEC2Instance/ confirmTerminate, removeAMI/confirmRemoveAMI, deleteKeyPair/ confirmDeleteKeyPair, runLaunch, createAMIFromInstance, and createBucket/configureBucketWebsite (which previously had no menu-tier huh.Select at all, so no menuInput/menuOutput pair to reuse) – none of these were anticipated in the original per-file list, found only by tracing every Confirm(/ConfirmDestructive(/ ui.Prompt( call site after the mechanical sweep below and checking whether its enclosing function had any way to reach it from a test.

A second real gap, found only by actually running the test suite (not by static analysis): huh’s accessible-mode input (accessibility. PromptString, used by Input.RunAccessible) has no way to surface EOF as an error – it silently falls back to the field’s default (or blank) forever, unlike termlib.LineEditor.Prompt, which returned io.EOF once piped input ran out. TestCreateInstanceProfileForRole_ NameCollisionRetries relied on that old behavior (a fake that always errors, expecting the retry loop to eventually surface an error once input was exhausted) and hung indefinitely under the new behavior instead. Fixed by giving fakeIAMClient the same createInstanceProfile ErrOnce shape fakeEC2Client already used for the analogous key-pair test, and rewriting the test to expect a successful retry rather than an eventual error – the more accurate reflection of real interactive behavior anyway (an operator retrying a genuinely duplicate name would keep being asked forever, not have the tool give up on their behalf).

TestImportKeyPairStandalone_PromptLabelStaysShort (a guard against a real, now-moot termlib bug – LineEditor.Prompt’s raw-mode redraw math assumed a prompt fit on one terminal row) was retired outright, same treatment as ui.PickList’s own tests.

Work Items

Tests: every _test.go file that constructed termlib.New(&buf) to capture output rewritten to use a bytes.Buffer directly. newPipeEditor (previously wrapping a real os.Pipe + termlib. LineEditor) rewritten to return a (io.Writer, io.Reader, *bytes.Buffer) trio backed by newHuhAccessibleInput’s line-at-a-time reader, with the writer and buffer deliberately the same value. Every test driving a menu-tier huh.Select and one or more free-text prompts/confirms in the same call had its two previously-independent input streams (le for free text, a separate newHuhAccessibleInput reader for the menu) merged into one combined stream, in the exact order the production code actually reads them – verified by running the suite, not just by tracing the code, since a wrong merge order fails loudly (wrong field gets the wrong text) rather than silently.

Files: all files listed above, plus each one’s corresponding _test.go, plus go.mod/go.sum.

Phase 20.17 — Chrome Standardization: Shared lipgloss Palette (done)

Status: implemented and verified 2026-07-13. Implements DESIGN.md, “Chrome Standardization: A Shared lipgloss Palette,” and DECISIONS.md, “Chrome standardization: one shared indigo accent via lipgloss.”

Work Items

Tests: internal/tui/box_test.go — the four tests asserting exact/ literal border strings (TestBottomBorder_MatchesInnerWidth, TestDivider_MatchesInnerWidth, TestTopBorder_TitleFitsWithinWidth, TestSplitAndMergeDividers_JoinAtTheMiddleColumn) and TestBoxLine_PadsToInnerWidthAndAddsBorders updated to compare against StripANSI(got). New internal/tui/theme_test.go confirms Theme() is non-nil, Focused.Title’s foreground matches the shared accentColor, and Blurred.Base’s border style is lipgloss.HiddenBorder(). Full go build ./..., go vet ./..., go test ./... -race sweep green; gofmt -l . clean except a pre-existing, unrelated version.go (generated file).

Files: internal/tui/{theme.go (new),theme_test.go (new),box.go, box_test.go}, internal/ui/prompt.go, internal/workflow/{confirm, domain_menu,object_browser}.go, go.mod/go.sum (lipgloss promoted from indirect to direct via go mod tidy).

Dependency: None — additive styling, no signature changes.

Phase 20.18 — Progress Ticker: Real bubbletea Spinner (done)

Status: implemented and verified 2026-07-13. Depended on Phase 20.17 for the shared accent color (tui.SpinnerStyle()).

Work Items

Tests: progress_ticker_test.go’s two async tests (TestStartProgressTicker_PrintsPeriodically, TestStartProgressTicker_StopsCleanly) updated to drop the removed interval argument and sleep in multiples of DefaultSpinnerInterval instead of a caller-supplied fast interval; TestFormatDuration unchanged. Full go build ./..., go vet ./..., go test ./... -race sweep green.

Files: internal/workflow/progress_ticker.go (+ test), internal/workflow/{create_ami_from_instance,show_cloud_init, backup_archive}.go, internal/tui/theme.go.

Dependency: Phase 20.17 (soft).

Phase 20.19 — object_browser.go: Bucket Pre-flight onto pickBucket (done)

Status: implemented and verified 2026-07-13.

Work Items

Tests: No object_browser_test.go existed before this change (bare huh.Select fields aren’t unit-tested via the accessible-mode pipe path in this codebase the way Menu-tier huh.Selects are – there was nothing to retire or update). Full go build ./..., go vet ./..., go test ./... -race sweep green.

Files: internal/workflow/object_browser.go, cmd/clasm/main.go.

Dependency: None.


Phase 20.20 — Backup Archive & Trim: Reorder Prompts (done)

Status: implemented and verified 2026-07-13. Implements DECISIONS.md, “Reorder Backup Archive & Trim’s prompts.”

Work Items

Tests: every existing backup_archive_test.go test’s input string (four \n-joined answers) reordered to match; assertions unchanged.

Files: internal/workflow/{backup_archive.go,backup_archive_test.go}.

Dependency: None.

Phase 20.21 — Backup Archive & Trim: Recall Instance/Directory (done)

Status: implemented and verified 2026-07-13. Implements DECISIONS.md, “Recall Backup Archive & Trim’s instance/directory choices per-instance.”

Work Items

Tests: new internal/state/state_test.go (missing-file, malformed- YAML, save-then-load round-trip, overwrite, DefaultPath); new internal/tui/picker_test.go cases (InitialCursor positions the cursor; out-of-range falls back to 0; Enter immediately selects the pre-positioned row); new internal/workflow/backup_archive_test.go cases (recalled directory takes priority over the Name-pattern rule; Save is called with the right instance/directory; a Save error is a warning, not fatal).

Files: internal/state/{state.go,state_test.go} (new), internal/tui/{picker.go,picker_test.go}, internal/workflow/ {power_state.go,backup_archive.go,backup_archive_test.go}, cmd/clasm/main.go.

Dependency: None.

Phase 20.22 — Contextual Description Text on Menu/Picker-tier Screens (done)

Status: implemented and verified 2026-07-13. Implements DECISIONS.md, “Contextual description text on Menu/Picker-tier screens.”

Work Items

Tests: new internal/tui/filter_test.go (description costs two rows like a header; height never drops below the floor); new TestPicker_DescriptionRendersBelowTopBorder in picker_test.go. No new workflow-level tests – the description strings are static text threaded through already-tested call paths, not new branching logic.

Files: internal/tui/{picker.go,filter.go,filter_test.go, picker_test.go,listview.go}, and every internal/workflow/*.go file containing a Menu-tier huh.Select or one of the 9 Picker-tier tui.PickerConfig constructor call sites (15 + 9 call sites across ~20 files).

Dependency: None.

Phase 20.23 — huh Fields: Full Box Border to Match tui’s Chrome (done)

Status: implemented and verified 2026-07-13. Implements DECISIONS.md, “huh fields get a full box border to match tui’s chrome.”

Work Items

Tests: verified via a throwaway test rendering Theme().Focused. Base.Render(...) directly with a forced true-color profile and inspecting the raw ANSI output – confirms a full ┌─┐│ │└─┘ box in the shared accent, not huh’s default left-bar. Not committed as a permanent test (a pure styling value, not branching logic); full go build ./..., go vet ./..., go test ./... -race sweep green throughout.

Files: internal/tui/theme.go.

Dependency: None.


Phase 20.24 — Clear the Screen at Startup (done)

Status: implemented and verified 2026-07-13. Implements DECISIONS.md, “Clear the screen at startup.” Partially addresses a combined request (“clear the screen first and take up the full height of the terminal window”); the “full height” half was deliberately not implemented here – see the note below.

Work Items

Not done, deliberately deferred: making the domain picker (or the Menu tier generally) visually fill the terminal’s full height. The domain picker is a huh.Select (Menu tier), which – unlike the Picker/List/Manager tier’s bubbletea components – has no built-in concept of “occupy the full window height”; every other Menu-tier screen in the app (S3/EC2/Key Management menus) is the same compact, content-sized form, so making only the root domain picker full-height would be visually inconsistent with every menu one level deeper, the opposite of the consistency this session’s other chrome work has been building toward. Revisit if this turns out to be what “full height” specifically meant, once clarified.

Tests: internal/ui/clear_test.go asserts the exact bytes written match ansi.EraseEntireScreen + ansi.CursorHomePosition.

Files: internal/ui/{clear.go,clear_test.go} (new), cmd/clasm/main.go, go.mod/go.sum.

Dependency: None.

Phase 20.25 — Bucket Picker for Backup Archive & Trim (done)

Status: implemented and verified 2026-07-13. Implements DECISIONS.md, “Bucket picker for Backup Archive & Trim.”

Work Items

Tests: three new cases in backup_archive_test.go (TestBackupArchiveAndTrim_BucketPickerOffersKnownBuckets, ..._BucketPickerOtherFallsBackToFreeText, ..._BucketPickerFallsBackToFreeTextOnListError), each verifying the resulting bucket name via the upload command’s s3://<bucket>/... destination. Every pre-existing test in this file continues to pass unchanged – none populate fakeS3Client.buckets, so ListBuckets returns an empty list and they all naturally exercise the free-text fallback branch, exactly as before this change.

Files: internal/workflow/{backup_archive.go,backup_archive_test.go}.

Dependency: None.

Phase 20.26 — Full-height Menu Tier (done)

Status: implemented and unit-tested 2026-07-20 (go build ./..., go vet ./..., go test ./... -race all clean; gofmt -l clean except the pre-existing, unrelated version.go); not yet verified against real AWS/a real terminal – see Phase 22 and the general manual-verification gap every phase this session has carried. Resolves Phase 20.24’s deferred “full height” request. Implements DESIGN.md, “Full-height Menu Tier,” and DECISIONS.md, “Full-height Menu tier via live WindowSizeMsg tracking, applied at every depth.”

Work Items

Tests: internal/workflow/domain_menu_height_test.go (new) – TestQuitKeyGuard_WindowSizeMsgProducesFullTerminalHeight asserts the combined on-screen output (hint line + form view) equals the terminal height exactly, across several sizes; TestQuitKeyGuard_ ShortContentStillFillsTheWindow confirms a 2-option menu still renders padded close to full height at a 30-row terminal, not shrunk to content; TestQuitKeyGuard_TinyTerminalDoesNotPanic covers the non-positive-height no-op path. Accessible-mode (pipe-tested) call sites are unaffected – they never construct a tea.Program, so this whole path doesn’t apply to them.

Files: internal/workflow/domain_menu.go, internal/workflow/domain_menu_height_test.go (new).

Dependency: None.

Phase 20.27 — Launch Templates (done)

Status: implemented and unit-tested 2026-07-20 (go build ./..., go vet ./..., go test ./... -race all clean; gofmt -l clean except the pre-existing, unrelated version.go); not yet verified against real AWS – see Phase 22. v0.0.2’s headline feature, confirmed directly 2026-07-20 (v0.0.1 is already piloting in production, unreleased – no git tag yet). Folds in the IMDSv2 bug fix (TODO.md, Bugs) as one design/ implementation pass, since both touch the same MetadataOptions concept. Implements DESIGN.md, “Launch Templates,” and DECISIONS.md, “Launch templates: build directly from cloud-init YAML, diff-then-new- version sync, fold in IMDSv2.” Deliberately excludes TODO.md’s tags-screen fix, backup-bucket-default, and top-level cross-resource tag management – those get their own design/decision/plan pass after this one lands.

Effort: ~24 hours estimated (comparable scope to Phase 20.1: a new client surface, seven new interactive workflows, a diff mechanism, plus three IMDSv2 call sites) Priority: High Files: internal/awsclient/ec2.go/logging_ec2.go (7 new EC2API methods + logging wrapper), internal/inventory/launch_templates.go (new – LaunchTemplate list-tier type, per-version detail type, ListLaunchTemplates + version-detail fetch, aggregated across regions like Image/ Instance), internal/workflow/show_launch_template.go (new – List/Show), internal/workflow/launch_template_create.go (new – Create from Cloud-Init YAML), internal/workflow/launch_from_template.go (new – Create EC2 Instance from Launch Template, naming to match the existing launch_from_cloud_init.go convention), internal/workflow/launch_template_sync.go (new – Sync/diff/no-op detection), internal/workflow/launch_template_manage.go (new – Promote to Default, Delete Version(s), Delete Template), internal/workflow/launch_execute.go (IMDSv2 on plain RunInstances), internal/workflow/menu.go (7 new mainMenuItems entries + matching MenuActions fields), cmd/clasm/main.go (wiring), go.mod/go.sum (github.com/aymanbagabas/go-udiff promoted from indirect to direct – already present transitively via charmbracelet/x/exp/teatest, used by internal/filemanager’s own tests, so no new dependency is actually being introduced).

Work Items

Tests: each new workflow gets an accessible-mode pipe-tested core, matching every existing Menu-tier workflow’s convention. launch_execute_test.go’s existing fakeEC2Client embeds awsclient.EC2API, so widening the interface doesn’t break any existing test; the new launch-template methods get added to that same shared fake rather than a second one. Specific cases to cover: Sync’s identical-content-skips-a-version branch and different-content-shows- a-diff-then-creates-a-version branch; the plain-RunInstances and new-template MetadataOptions both come back required in the captured request; Delete Version(s)/Delete Template’s Environment=production extra-warning gate.

Dependency: None (builds on the existing EC2 client/inventory/ Menu-tier conventions; does not depend on Phase 20.26).

Phase 20.28 — Launch Templates: Real-Usage Fixes and Version History

Status: implemented and unit-tested 2026-07-20 (go build ./..., go vet ./..., go test ./... -race all clean; gofmt -l clean except the pre-existing, unrelated version.go); not yet re-verified against real AWS. Direct fallout from the operator’s first real-AWS pass over Phase 20.27 (creating a template, launching from it, syncing, promoting, listing, deleting) – one genuine bug found from the debug log, three UX gaps from live use. Implements DECISIONS.md, “Accept v-prefixed launch template versions” and “Launch Template version history, scrollable diffs, and split Show resource lists.”

Work Items

Tests: TestShowLaunchTemplate_AcceptsVPrefixedVersion and TestNormalizeVersionSelector reproduce the exact failure from the debug log before fixing it; TestDisplayDiff_AccessibleModeFallsBackToPlainDump covers the test-mode branch; TestShowLaunchTemplate_ListAllVersions/ _DiffTwoVersions/_DiffTwoVersions_IdenticalReportsNoDifference/ TestLaunchTemplateVersionRows cover the new sub-choices; TestListLaunchTemplateVersions_* cover the new inventory call; menu_test.go’s hardcoded menu-item indices and count were updated for the reordering (matching the same maintenance every prior menu-ordering change in this project has needed).

Files: internal/workflow/show_launch_template.go, internal/workflow/launch_template_manage.go, internal/workflow/launch_template_sync.go, internal/inventory/launch_templates.go, internal/workflow/menu.go, cmd/clasm/main.go, plus each file’s _test.go counterpart.

Dependency: Phase 20.27.

Phase 20.29 — Manage Tags: Loop Until ‘q’, Show Tags Choice

Status: implemented and unit-tested 2026-07-20 (go build ./..., go vet ./..., go test ./... -race all clean; gofmt -l clean except the pre-existing, unrelated version.go); not yet re-verified against real AWS. Closes the TODO.md bug: “missing a ‘show tags’ menu option and the tags shown at the top of the screen don’t update on change.” Implements DECISIONS.md, “Manage Tags: loop until ‘q’, always show current tags, add a Show tags choice.”

Work Items

Tests: TestManageTags_AddOnInstance/_UpdateOnAMI/_RemoveOnInstance/ _EnvironmentNoteShown/_DeclinedConfirmationDoesNotApply/ _NoExistingTagsToUpdate/_RejectsBlankTagKeyOnAdd now call applyOneTagChange directly (simpler, and sidesteps the EOF gotcha entirely since there’s no loop involved). TestManageTags_ShowTagsRedisplaysAndContinues and TestManageTags_LoopRefreshesTagsAfterChange exercise the actual loop via manageTagsForResource, using cancelAfterNFetches to end it deterministically – the latter is the actual bug-fix proof: it Adds a tag, then immediately Updates it, which only succeeds if the loop refreshed tags from AWS in between (via a new statefulTagsFakeEC2Client that, unlike the shared fakeEC2Client, actually tracks tag state across CreateTags/DescribeInstances calls).

Files: internal/workflow/manage_tags.go, internal/workflow/manage_tags_test.go.

Dependency: None.

Real-usage fix (2026-07-20)

Found during Phase 20.30’s real-terminal testing: Add/Update/Remove all confirmed working (instances, AMIs, launch templates, key pairs), but “Show tags” appeared to do nothing – the screen looked unchanged. Root cause: the full-height “Choose an action” Select (Phase 20.26) scrolls the separately-printed displayTags output out of view the instant it renders, on every iteration – not specific to “Show tags” itself, but most noticeable there since nothing else changes on screen. Fixed by embedding the current tags directly in the Select’s own Description (actionMenuDescription, tested), so they’re guaranteed part of the same full-height chrome instead of relying on scrollback. See DECISIONS.md, “Manage Tags: embed current tags in the action Select’s own Description…”. go build/vet/test -race/ gofmt all clean; confirmed against a real terminal 2026-07-20 for all four kinds (instance, AMI, launch template, key pair).


Phase 20.30 — Tag Management Domain

Status: all five resource types (Instance/AMI/Launch Template/Key Pair/S3 Bucket) implemented, unit-tested, and confirmed against real AWS 2026-07-20 (go build ./..., go vet ./..., go test ./... -race all clean; gofmt -l clean except the pre-existing, unrelated version.go; Manage Tags’ Add/Update/Remove/Show tags all confirmed working at a real terminal for every kind, including S3 Bucket’s read-modify-write and the real-usage fix below). Phase 20.30 is complete. Implements DESIGN.md, “Tag Management Domain (Design Addendum, 2026-07-20)” and DECISIONS.md, “Tag Management: a fourth domain, generalizing the Manage Tags loop across five resource types” plus “Generalize applyOneTagChange for S3’s read-modify-write tag semantics.” Closes the TODO.md requested feature: “A top level menu item for managing tags across resources (EC2, AMI, S3, etc).” Depended on Phase 20.29’s loop/manageTagsForResource shape, reused as-is for the EC2-backed types and unchanged in shape for S3 (only the apply closure it’s given differs).

Effort: Large (comparable to Phase 20.27, Launch Templates).

Priority: Requested feature, not a bug fix; no committed deadline.

Design note: applyOneTagChange generalized only when S3 actually needed it

The original plan (below) assumed applyOneTagChange would need a pluggable apply closure from the start. In practice, this phase shipped in two slices: the four EC2-backed kinds first, then S3. ApplyTagChange (EC2’s CreateTags/DeleteTags) already worked unmodified for all four EC2-backed kinds – Launch Template and Key Pair needed new fetch functions (fetchLaunchTemplateTags/fetchKeyPairTags) and new wiring, but not a new apply path – so the pluggable-apply-closure generalization was deferred until S3 actually needed it, rather than built speculatively up front. Rather than touch Compute’s existing narrow ManageTags/manageTags (Instance/AMI only) at all, a new, separate ManageResourceTags/manageResourceTags (in the new internal/workflow/tag_management.go) was added: it picks a kind, then a resource, then hands off to manageTagsForResource. Once the S3 slice began, applyOneTagChange/manageTagsForResource were generalized to take a tagApplyFunc closure instead of a hardcoded awsclient.EC2API client (DECISIONS.md, “Generalize applyOneTagChange for S3’s read-modify-write tag semantics”) – EC2-backed callers now build func(ctx, params) error { return ApplyTagChange(ctx, client, params) } at their one remaining call site each, and S3 builds applyBucketTagChange (bucket_tags.go) instead.

Work Items (EC2-backed types)

Tests (EC2-backed types)

Files (EC2-backed types): internal/inventory/{instances,images,launch_templates,keypairs}.go and their _test.go files, internal/workflow/manage_tags.go, internal/workflow/tag_management.go (new), internal/workflow/tag_management_test.go (new), internal/workflow/tagmgmt_menu.go (new), internal/workflow/tagmgmt_menu_test.go (new), internal/workflow/domain_menu.go + domain_menu_test.go, internal/workflow/launch_execute_test.go (shared fake gained DescribeLaunchTemplates), internal/ui/display.go + display_test.go, cmd/clasm/main.go.

Work Items (S3 Bucket)

Tests (S3 Bucket)

Files (S3 Bucket): internal/awsclient/s3.go + logging_s3.go (DeleteBucketTagging), internal/workflow/manage_tags.go (tagApplyFunc generalization), internal/workflow/manage_tags_test.go (ec2Apply helper), internal/workflow/bucket_tags.go (new), internal/workflow/bucket_tags_test.go (new), internal/workflow/tag_management.go + tag_management_test.go, internal/workflow/bucket_fakes_test.go + backup_verify_test.go (shared fakeS3Client gained DeleteBucketTagging), internal/workflow/tagmgmt_menu.go, cmd/clasm/main.go.

Dependency: Phase 20.29 (Manage Tags loop/manageTagsForResource).


Phase 21 — CloudFront Domain

Status: someday/maybe – not on the active roadmap, no committed timeline (revised 2026-07-09 from “postponed to a later version,” see DECISIONS.md). No code written; the CloudFront domain-picker entry was removed rather than left wired to NotYetImplemented, so the 0.0.1 UI doesn’t expose a menu item that goes nowhere. The design below stays valid reference for if this is ever picked back up, but it is not queued as “next” behind anything currently planned (Phase 20.1 is the active next-release work).

Effort: ~8 hours (implementation) + real-AWS verification, now folded into this phase’s own scope rather than Phase 22’s – see below Priority: Deferred (someday/maybe) Files: internal/awsclient/cloudfront.go, internal/inventory/distributions.go, internal/workflow/{distribution_create,distribution_invalidate}.go

Implements DESIGN.md Features 22-25.

Work Items

Tests: fakes for each new CloudFront call; OAC-then-bucket-policy sequencing; poll-until-Deployed/Completed with bounded test timeouts

Dependency: Phase 18, Phase 20 (Create Distribution hands off to Create Bucket)


Phase 22 — Real-AWS Testing: Key Management, S3

Effort: ~6 hours Priority: High Files: TEST_PLAN_REAL_AWS.txt (extended with new sections)

Mirrors Phase 16’s manual-verification approach, extended to Key Management and S3. Independent of Phase 16/17 (Compute’s own verification and Bash retirement) — see DECISIONS.md, 2026-07-02. No longer covers CloudFront (see DECISIONS.md, “Demote CloudFront to someday/maybe…”) — that verification now lives in Phase 21 itself, whenever it’s picked up, so this phase isn’t blocked on a someday/maybe item.

Work Items

Dependency: Phase 19, 20


Phase 20.31 — Configurable EBS Root Volume Size

Status: implemented and unit-tested 2026-07-21 (go build ./..., go vet ./..., go test ./... -timeout 60s all clean; gofmt -l clean). Implements DESIGN.md, “Configurable EBS Root Volume Size” and DECISIONS.md, “Configurable EBS root volume size: scope, flow coverage, and resize automation depth.” Closes TODO.md’s “Bug (confirmed in production use, 2026-07-22)” entry. Built test-first throughout: every new behavior (the missing BlockDeviceMappings, each growRootFilesystem fallback branch, the menu dispatch) has a test that failed before the corresponding code existed. Not yet verified against real AWS.

Work Items

Part 1 — set root volume size at creation:

Part 2 — resize an already-running instance’s root volume:

Tests: test-first throughout – TestLaunch_SetsRootVolumeSize/ TestBuildRequestLaunchTemplateData_SetsRootVolumeSize reproduced the “no BlockDeviceMappings sent” gap (confirmed failing before the fix, matching this project’s established convention) before the fix; TestCollectLaunchInstanceParams_SetsRootVolumeSize/ TestCollectLaunchInstanceParamsFromCloudInit_SetsRootVolumeSize cover the prompt end-to-end; TestDescribeLaunchTemplateVersion_DecodesRootVolumeSize/ TestShowLaunchTemplate_DisplaysRootVolumeSize cover the display side; TestRootVolumeInfo_*/TestPromptNewVolumeSizeGB_*/ TestModifyVolumeSize_*/TestWaitUntilVolumeModificationUsable_*/ TestResizeInstanceRootVolume_* cover Part 2’s own workflow; TestSplitDiskAndPartition_*/TestParseFindmntOutput_*/ TestRootFilesystemGrowCommand_*/TestGrowRootFilesystem_* cover the SSM automation, including every fallback branch. One real bug caught during this phase: growRootFilesystem/resizeInstanceRootVolume initially hardcoded production SSM timeouts (2 min/10 min), making their own “SSM not online” tests actually wait out those real timeouts – fixed by threading onlineTimeout/commandTimeout/ pollInterval through explicitly (growRootFilesystem) and by configuring the fake SSM client to resolve immediately (resizeInstanceRootVolume’s own test), matching checkCloudInitCompletion’s existing pattern. menu_test.go’s hardcoded menu-item count (20 -> 21) was updated for the new entry, same maintenance cost every prior menu-ordering change in this project has needed.

Files: internal/workflow/launch_instance.go, internal/workflow/launch_from_cloud_init.go, internal/workflow/launch_execute.go, internal/workflow/launch_template_create.go, internal/inventory/launch_templates.go, internal/workflow/show_launch_template.go, internal/awsclient/ec2.go, internal/awsclient/logging_ec2.go, internal/workflow/menu.go, cmd/clasm/main.go, new internal/workflow/ebs_size.go, internal/workflow/resize_volume.go, internal/workflow/ssm_grow.go, plus each file’s _test.go counterpart.

Dependency: Phase 20.27 (Launch Templates).


Phase 20.32 — Pause for Acknowledgment Before Every Menu-Loop Redraw

Status: revised and re-implemented 2026-07-22, superseding this same phase’s first pass from earlier the same day. First pass (error/ refresh-error prints in all four menu loops, plus a one-off pause inside resizeInstanceRootVolume) was implemented, unit-tested, and live-verified working for its own two original incidents – then live testing found a third incident the first pass didn’t cover (runLaunch’s cloud-init-error report, which returns nil, not an error, so the error-path-only pause never fired). See DECISIONS.md, “Widen ‘pause for acknowledgment’ to every action, not just errors.” Re-implemented per that revision: go build ./..., go vet ./..., go test ./... -race all clean; gofmt -l clean. Not yet re-verified live against real AWS a second time, or released.

Work Items

Tests: test-first, per this project’s standing convention – every per-domain test that dispatches more than one action in sequence (*_DispatchesToTheChosenAction, *_ShowXDispatchesToItsOwnAction, *_RefreshesAfterASuccessfulAction, *_ActionErrorDoesNotCrashLoop) needed a blank input line inserted after every dispatch now, not just the error ones, since the pause is no longer conditional on err; the four *_PausesForAcknowledgmentAfterARefreshError tests added in the first pass still hold (a refresh error is itself dispatched via the same unconditional call site); removed the pause assertion from TestResizeInstanceRootVolume_HappyPath since that function no longer pauses itself. All widened tests confirmed failing against the first-pass code before the loop-placement change.

Files: internal/workflow/menu.go, internal/workflow/s3_menu.go, internal/workflow/keymgmt_menu.go, internal/workflow/tagmgmt_menu.go, internal/workflow/resize_volume.go, plus each file’s _test.go counterpart.

Dependency: none (independent bugfix; targeted for v0.0.4).


Phase 20.33 — SSM-Capable Instance Profile Enforcement + Retrofit

Status: designed 2026-07-22 (DESIGN.md, “SSM-Capable Instance Profile Enforcement + Retrofit”; DECISIONS.md, same title). Targeted for v0.0.5, alongside the not-yet-started arm64/Graviton + Ubuntu 26.04 LTS work. Not yet implemented.

Work Items

Part 1 — SSM-capability verification helper: done, 2026-07-22.

Part 2 — Enforcement at launch: done, 2026-07-22, revised same day after live testing.

Tests (Part 2): test-first for the two builder functions – buildInstanceProfileChoices/buildRoleChoices are independent of the Picker-tier UI (same “testable core” split as every other Picker-tier conversion in this project) and got direct unit tests covering the no-profiles/no-none-entry case, filtering out non-capable entries (confirmed failing against the pre-revision annotate-only code first), and IAM-error propagation. A new TestCreateInstanceProfileInteractive_NoSSMCapableRolesReturnsWithoutError covers the empty-after-filter case. Two more tests (TestPromptIAMInstanceProfileOrCreate_PropagatesSSMCheckError, TestCreateInstanceProfileInteractive_PropagatesSSMCheckError) confirm the SSM-check runs before the (untestable) picker call, so an IAM error there is still reachable and testable without touching the UI. All existing tests (including the free-text-fallback and no-roles-found paths) still pass unchanged. go build/vet/test -race/gofmt all clean.

Part 3 — Retrofit (general-purpose associate/replace): done, 2026-07-22.

Status: Phase 20.33 (all three parts) fully implemented, unit-tested, and real-AWS-verified 2026-07-22. go build/go vet/go test ./... -race/gofmt -l all clean. Verified live: created ec2-granian-test-role (EC2 trust policy + AmazonSSMManagedInstanceCore attached) via AWS CLI since the account had no other general-purpose SSM-capable role; Part 2’s filtered picker correctly showed only SSM-capable profiles/roles (confirmed both before and after the new role existed); launched test-clasm-granian successfully using it, alongside Phase 20.34’s gzip fix (below) in the same launch-template-from-cloud-init flow. Not yet released – targeted for v0.0.5 alongside the not-yet-started arm64/Graviton + Ubuntu 26.04 LTS work.

Tests: test-first throughout, per this project’s standing convention – every new test confirmed failing (compile error, the Go equivalent of a failing test) before its corresponding implementation existed. Fixture-driven unit tests for roleHasSSMPermissions/ instanceProfileIsSSMCapable against a fake IAM client (Part 1, no real AWS round-trip); buildInstanceProfileChoices/buildRoleChoices tests covering the no-profiles-still-offers-create-new case, SSM-capability annotation, and IAM-error propagation, plus two integration tests confirming the SSM-check runs before the (untestable) Picker-tier call so an IAM error there is still reachable without touching the UI (Part 2); associate_instance_profile.go tests covering both the associate-path and replace-path branches, resolve-error propagation, associate-error propagation, and the missing-region-client case (fake EC2 client, DescribeIamInstanceProfileAssociations returning empty vs. non-empty) (Part 3).

Files: internal/awsclient/iam.go, internal/awsclient/logging_iam.go, internal/awsclient/ec2.go, internal/awsclient/logging_ec2.go, internal/workflow/create_instance_profile.go, new internal/workflow/ssm_iam_check.go, new internal/workflow/associate_instance_profile.go, internal/workflow/menu.go, cmd/clasm/main.go, plus each file’s _test.go counterpart.

Dependency: none (independent of the not-yet-started arm64/Graviton phase, though both target v0.0.5).


Phase 20.34 — Gzip-Compress User-Data Before Base64-Encoding

Status: designed, implemented, and real-AWS-verified 2026-07-22, live-testing-driven (see DECISIONS.md, “Always gzip-compress user-data before base64-encoding it”). Closes InvalidUserData.Malformed: User data is limited to 16384 bytes, hit creating a launch template from invenio-rdm-13-granian-init.yaml (16976 raw bytes, already over the limit). Verified live: test-clasm-granian launch template created successfully and an instance launched from it. Targeted for v0.0.5.

Work Items

Tests: test-first – encodeUserData/decodeUserData round-trip tests (encode then decode returns the original text), a decode-plain-base64-without-gzip-magic test (backward compatibility with every resource created before this change), and decode-error tests for malformed base64/corrupt gzip. All new tests confirmed failing (undefined function, the Go equivalent of a failing test) before the helpers existed. Existing tests at every read/write call site still pass unchanged, since encode-then-decode round-trips to the same plain text. go build/vet/test -race/gofmt all clean. Manually confirmed the real 16976-byte granian file gzips to 5628 bytes via plain gzip -c | wc -c, not assumed.

Files: new internal/workflow/userdata_gzip.go, internal/workflow/launch_execute.go, internal/workflow/launch_template_create.go, internal/workflow/launch_template_sync.go, internal/workflow/cloud_init_instance.go, internal/workflow/show_launch_template.go, plus each file’s _test.go counterpart.

Dependency: none.


Phase 20.35 — ARM64 (Graviton) Support + Ubuntu 26.04 LTS

Status: designed, implemented, and real-AWS-verified 2026-07-22 (DESIGN.md, “ARM64 (Graviton) Support + Ubuntu 26.04 LTS”; DECISIONS.md, “ARM64/Ubuntu 26.04: filter the instance-type list by AMI architecture, no new pre-flight check”). Targeted for v0.0.5 alongside Phase 20.33/20.34 (also real-AWS-verified). go build/go vet/go test ./... -race/gofmt -l all clean. All three Phase 20.33/20.34/20.35 work targeted for v0.0.5 is now implemented, unit-tested, and real-AWS-verified. Not yet released.

Work Items

Tests: test-first, per this project’s standing convention, every new test confirmed failing (compile error – undefined field/wrong arg count, the Go equivalent of a failing test) before its implementation existed. imageFromSDK/listOfficialUbuntuAMIsInRegion architecture-population tests; a curatedUbuntuReleases test asserting every expected arm64/26.04 name pattern is present; promptInstanceType filtering tests (arm64-only list when filtered to arm64, x86_64-only when filtered to x86_64, “Other” always present even when filtered, unfiltered when arch == "", including the two existing “Other” free-text tests whose numeric index shifted from 12 to 21 once the Graviton entries were appended); two integration tests (TestCollectLaunchInstanceParams_FiltersInstanceTypeByImageArchitecture, the cloud-init path’s own mirror of it) confirming both collection functions actually thread the picked AMI’s Architecture through to the filter, not just that the filter works in isolation. All prior existing tests (including the five original promptInstanceType tests using positions 1/4/10, unaffected by appending rather than interleaving) still pass unchanged.

Files: internal/inventory/images.go, internal/workflow/official_ubuntu_amis.go, internal/workflow/launch_prompts.go, internal/workflow/launch_instance.go, internal/workflow/launch_from_cloud_init.go, plus each file’s _test.go counterpart.

Dependency: none.


Phase 20.36 — IAM Domain: Discovery, Categorization, and the Read-Only Guard

Status: designed 2026-07-23, implemented 2026-07-23, real-bug found and fixed via live testing 2026-07-23 (DESIGN.md, “IAM Profile & Role Management Domain”; DECISIONS.md, “IAM Profile & Role Management: Origin tag revision…” and “Real bug: ListRoles/ListInstanceProfiles/ ListPolicies don’t return tags inline”). Targeted for v0.0.5, bundled alongside Phases 20.33-20.35 (already implemented) rather than deferred to v0.0.6. go build/go vet/go test ./... -race/gofmt -l all clean. The user tagged a real role (air-sampling, Origin=DLD in their own casing) and found Show Roles still reported it (unset) – root-caused to a wrong assumption in the original design (below), not the config layer (verified separately and independently correct).

Work Items

Dependency: none. (The previously-planned “Tag as DLD-owned” menu action is dropped — see DECISIONS.md, Decision 3 of the revision entry — so this phase no longer depends on Phase 20.37 for a dedicated action; it still shares Phase 20.37’s tag-write path for editing Origin like any other tag.)

Files: internal/config/config.go, internal/awsclient/iam.go, internal/awsclient/logging_iam.go, internal/workflow/create_instance_profile_test.go (shared fakeIAMClient gained ListPolicies/ListRoleTags/ ListInstanceProfileTags/ListPolicyTags stubs), new internal/inventory/iam.go, new internal/ui/iam_display.go, new internal/workflow/iam_domain.go, new internal/workflow/iam_menu.go, internal/workflow/domain_menu.go, cmd/clasm/main.go, plus each new file’s _test.go counterpart.

Status update, same day: live-testing bug (tags-not-inline, above) found and fixed after the initial implementation; go build/go vet/ go test ./... -race/gofmt -l re-confirmed clean after the fix. Real-AWS-verified 2026-07-23 – user confirmed air-sampling now shows dld in the Origin column after rebuilding, and ran all six manual test items suggested after Phase 20.36’s initial implementation (config end-to-end with a non-default key/value, Show Policies scoped to customer-managed only, SSM-capable column accuracy, sort order, filter/back-navigation/fresh-refetch, regression check on the other four domains) – all passed.

Second real bug found via live testing, same day, while testing Phase 20.37: the IAM Role picker took several seconds to open in an account with dozens of roles – ListIAMRoleSummaries/ListIAMInstanceProfileSummaries/ ListIAMPolicySummaries fetched each resource’s tags one at a time, sequentially. Fixed by parallelizing with a bounded worker pool (new fetchTagsConcurrently, iamTagFetchConcurrency = 10), mirroring inventory.ListImages’ own concurrent per-region fan-out. See DECISIONS.md, “Parallelize per-resource IAM tag fetches.” All existing correctness tests kept green; go test -race confirms no data race. Real-AWS-verified 2026-07-23 – user rebuilt and confirmed the IAM Role picker opens noticeably faster. Not yet released.

Third real bug found via live testing, same day, while testing Phase 20.40’s Delete Role: three real test roles were silently missing from the Delete Role picker even though they were correctly tagged origin=dld (confirmed via aws iam list-role-tags). Root-caused via aws iam list-roles --no-paginate (the AWS CLI auto-paginates by default, which had been masking this): the account has 121 roles, IAM truncates ListRoles at 100 per page, and ListIAMRoleSummaries made a single, un-paginated call with no IsTruncated/Marker handling at all – any account over 100 roles was silently missing some, with no error. ListIAMInstanceProfileSummaries/ListIAMPolicySummaries had the same gap, not yet triggered only because this account’s instance profiles (4) and local policies (59) both stay under IAM’s page size. Fixed by adding listAllRoles/listAllInstanceProfiles/ listAllPolicies (internal/inventory/iam.go), each paging via Marker until IsTruncated is false, and switching all three ListIAM*Summaries functions to use them. See DECISIONS.md, “Real bug: ListRoles/ListInstanceProfiles/ListPolicies silently truncate past 100 items.” Test-first: a TestListIAM*Summaries_PaginatesAcrossMultiplePages test per kind, each confirmed failing (only the first page’s item returned) before the fix. go build/go vet/go test ./... -race/ gofmt -l all clean after the fix.

Real-AWS-verified 2026-07-23, immediately surfacing a follow-on real bug: fetching all 121 (correctly-paginated) roles’ tags at concurrency 10 reliably throttled (Throttling: Rate exceeded), reproduced twice in a row. Fixed by lowering iamTagFetchConcurrency from 10 to 4 and raising awsclient.NewIAMClient’s retry ceiling to 8 attempts (config.WithRetryMaxAttempts(8), IAM-client-only, since its N+1-calls-per-list shape is what makes it uniquely prone to this). Re-verified against real AWS after both changes: all 121 roles fetched, all 5 DLD-owned roles correctly recognized (including the three test roles that prompted this investigation), no throttling.


Phase 20.37 — Tag Management Domain Extension for IAM Resources

Status: designed 2026-07-23, implemented and unit-tested 2026-07-23 (DESIGN.md, “IAM Profile & Role Management Domain”). Targeted for v0.0.5. go build/go vet/go test ./... -race/gofmt -l all clean. Not yet real-AWS-verified or released.

Work Items

Tests: test-first throughout – fetch/apply closures tested against the shared fakeIAMClient (extended with configurable per-name/ARN tag maps and last-input capture for Tag/Untag calls); iamXxxTaggedResources conversions and three new “No IAM X found” early-return paths tested directly, matching the existing five kinds’ test shape.

Files: internal/awsclient/iam.go, internal/awsclient/logging_iam.go, internal/workflow/create_instance_profile_test.go (shared fakeIAMClient gained configurable Tag/Untag fields), new internal/workflow/iam_tags.go, internal/inventory/iam.go (Tags field), internal/workflow/tag_management.go, cmd/clasm/main.go, plus each changed file’s _test.go counterpart.

Dependency: Phase 20.36 (shares the IAM domain’s resource-listing plumbing and the origin_tag config, though the tagging mechanism itself is never gated by Phase 20.36’s read-only check).


Phase 20.38 — IAM Detail View

Status: designed and implemented 2026-07-23 (DESIGN.md, “IAM Profile & Role Management Domain”). Targeted for v0.0.5. go build/go vet/ go test ./... -race/gofmt -l all clean. Two scope questions DESIGN.md left open were resolved by the user before implementation: policy documents render as raw, pretty-printed JSON (not a summarized rendering), and the “which running instance is associated” half of the cross-reference is deferred – “which instance profiles reference this role” (cheap, already-fetched data) ships now.

Real-AWS-verified 2026-07-23 – user confirmed View Role Detail on air-sampling (trust policy, tags, all three attached policies, the inline policy), drilling into both an attached and the inline policy’s actual document, View Instance Profile Detail on ec2-granian-test-role (correctly showing its one contained role as SSM-capable, no tags), and clean q exit from the policy-document drill-down back to the IAM menu. Not yet released.

Work Items

Tests: test-first throughout. Real bug caught in the test suite itself, not production code: runPolicyDocLoop’s tests initially relied on exhausted accessible-mode input to end the loop after a single pick, which hung indefinitely — huh’s accessible-mode Select has no way to signal “input exhausted” as an error (the same documented gotcha as manageTagsForResource’s own tests, Phase 20.29), so it silently re-picks the default option forever rather than erroring. Fixed by having the fake client’s relevant method (GetPolicy/ GetPolicyVersion/GetRolePolicy, via three small wrapper types) cancel the context as a side effect, matching domain_menu_test.go’s cancelingAction pattern exactly. Caught before ever reaching the user by running tests with an explicit -timeout 30s after the first hang.

Files: internal/awsclient/iam.go, internal/awsclient/logging_iam.go, internal/workflow/create_instance_profile_test.go (shared fakeIAMClient gained GetRole/GetInstanceProfile/ListRolePolicies/ GetRolePolicy/GetPolicy/GetPolicyVersion, plus a PolicyName derived from ARN in ListAttachedRolePolicies – a test-fixture gap, not a production bug, since nothing needed the name before), new internal/workflow/iam_detail.go, internal/inventory/iam.go (RoleNames field), internal/workflow/iam_menu.go, cmd/clasm/main.go, plus each changed file’s _test.go counterpart.

Dependency: Phase 20.36 (the detail view is reached from the discovery list).


Phase 20.39 — Curated Per-Use-Case Role/Policy Creation Templates

Status: designed and implemented 2026-07-23 (DESIGN.md, “IAM Profile & Role Management Domain”; DECISIONS.md, “IAM Profile & Role Management: seven scoping decisions, bundled into v0.0.5” and “…Origin tag revision…”). Targeted for v0.0.5. go build/go vet/go test ./... -race/gofmt -l all clean. Not yet real-AWS-verified or released. Reverses the 2026-07-02 “never creates a role, only attaches an existing one” scope (DECISIONS.md, “Support picking or creating an IAM instance profile from within awsops”) — deliberately, and only through curated templates.

Work Items

Tests: test-first throughout, including the full guided flow (template pick → role name → params → confirm → create/attach) driven end to end via accessible-mode pipe input – auto-tagging when origin_tag.dld_value is set (and its absence when unset), the SSM managed policy attaching alongside the custom policy for RDM Repository, declined confirmation skipping creation entirely, and CreateRole’s error propagating before any AttachRolePolicy call is attempted.

Files: internal/awsclient/iam.go, internal/awsclient/logging_iam.go, internal/workflow/create_instance_profile_test.go (shared fakeIAMClient gained CreateRole/CreatePolicy/AttachRolePolicy), new internal/workflow/iam_templates.go, internal/workflow/iam_menu.go, cmd/clasm/main.go, plus each changed file’s _test.go counterpart.

Real-AWS testing in progress, 2026-07-23. RDM Repository Instance confirmed fully – role created, tagged origin=dld automatically, AmazonSSMManagedInstanceCore + a correctly-scoped custom policy both attached (s3:ListBucket on the bucket, s3:GetObject/s3:PutObject on <bucket>/*, nothing broader). Static Website confirmed in read-only mode (bucket-only, no distribution).

Real bug (usability) found via that same live testing, fixed same day: templates originally required hand-typed ARNs. Typing a full S3 ARN forces an unnecessary mental reformatting step, and CloudFront ARNs specifically can’t be found without leaving the tool (Console or a separate CLI call) – breaking the interactive workflow. Fixed by having each template param collect a plain name/ID (bucket name, distribution ID, log group name, secret name) and having clasm construct the ARN itself via a new BuildARN field, using the account ID already resolved at startup (sts:GetCallerIdentity) and the configured region. See DECISIONS.md, “Phase 20.39 templates collect resource names/IDs, not ARNs.” BuildPolicy functions themselves needed no changes – only the collection step and prompt wording. Publish-mode Static Website (with a distribution ID) and the thin templates (Bridge Service, Patron-Facing, Data Processing) not yet real-AWS-verified.

Dependency: Phase 20.36 (origin_tag config), Phase 20.37 (tag-write path used to auto-tag a newly-created role, when configured).


Phase 20.40 — Delete Role, Attach/Detach Policy (CRUD Completion for DLD-Owned Roles)

Status: designed, implemented, and real-AWS-verified 2026-07-23 (DESIGN.md, “CRUD completion for DLD-owned roles”; DECISIONS.md, “IAM Profile & Role Management: support CRUD for DLD-owned roles”). Targeted for v0.0.5. go build/go vet/go test ./... -race/gofmt -l all clean. Motivated directly by live use: testing Phase 20.39’s templates left behind test roles with no way to remove them from within clasm.

Work Items

Tests: test-first for the core delete logic (deleteIAMRole, deleteIAMPolicyIfUnused) — every AWS-call failure point covered via subtests, plus never-deletes-an-AWS-managed-policy and leaves-the-dedicated-policy-if-used-elsewhere. Menu-tier flows (*Confirmed cores) driven end to end via accessible-mode pipe input: confirmed/declined/mismatched confirmation, RequireDLDOwned blocking a non-DLD-owned role, each AWS-call error propagating, and the no-DLD-owned-roles/no-attached-policies early-return messages. The Picker-tier entry points (pickIAMRole/pickIAMPolicy) remain untested directly, same accepted limitation as every other Picker-tier flow in this domain (Phase 20.36/20.38/20.39’s own testable-core split).

Files: internal/awsclient/iam.go, internal/awsclient/logging_iam.go, internal/workflow/create_instance_profile_test.go (shared fakeIAMClient gained DeleteRole/DeleteRolePolicy/ DetachRolePolicy/DeletePolicy/DeletePolicyVersion/ ListPolicyVersions/ListEntitiesForPolicy), new internal/workflow/iam_lifecycle.go, internal/workflow/iam_menu.go, cmd/clasm/main.go, plus each changed file’s _test.go counterpart.

Dependency: Phase 20.36 (RequireDLDOwned, filterDLDOwnedRoles’ DLD-ownership data), Phase 20.38 (fetchIAMRoleDetail, displayIAMRoleDetail, IAMRoleDetail/IAMPolicyRef), Phase 20.39 (the <role>-policy naming convention Delete Role’s cascade relies on).

Real-AWS-verified 2026-07-23 – blocked initially by the pagination/ throttling bugs above (both found and fixed the same day, during this very verification attempt), then confirmed clean once fixed: the user ran Delete Role against all three real test roles left over from Phase 20.39’s template testing (test-rdm-repo-role, test-static-site-role, test-static-site-role-2). Independently confirmed via aws iam get-role/get-policy afterward: all three roles and their dedicated <role>-policy policies are gone. This doubled as Delete Role’s real-AWS verification and closed out the last open item from Phases 20.36-20.40. All five IAM phases (20.36-20.40) are now implemented, unit-tested, and real-AWS-verified.


Phase 20.41 — Instance/AMI Detail Views

Status: designed, implemented, and unit-tested 2026-07-24, targeted for v0.0.5 (DESIGN.md, “Instance/AMI Detail Views”; DECISIONS.md, “Instance/AMI Detail Views: on-demand describe calls, appended menu placement”). Closes the outstanding TODO.md item left over since Launch Templates gained its own detail view. go build/go vet/go test ./... -race/gofmt -l all clean. Not yet real-AWS-verified.

Work Items

Tests

Test-first throughout, confirmed failing (undefined symbols) before each implementation existed, per this project’s convention ([[feedback-test-before-fix]] applies to new-feature work the same way it applies to bug fixes). DescribeInstanceDetail/DescribeImageDetail covered via a fake EC2API client: found case (full field mapping, including empty-tags/no-public-IP/no-IAM-profile edge cases) and not-found/client-error cases. showInstanceDetail/showAMIDetail testable cores covered directly against the shared internal/workflow fakeEC2Client (extended with a handful of new fields – instanceDetail*/describeImages{Name,CreationDate,Architecture, EnaSupport} – to synthesize the fuller field set this view needed; no existing test’s behavior changed) for: full curated-field display, no-volumes/no-block-device-mappings, unknown-region error, and each AWS call’s error propagating. ShowInstanceDetail/ShowAMIDetail’s no-resources-found early return covered directly (no Picker-tier driving needed, matching showLaunchTemplate’s own accepted limitation).

Files

internal/inventory/instances.go (or new instance_detail.go), internal/inventory/images.go (or new image_detail.go), new internal/workflow/show_instance_detail.go, internal/workflow/show_ami_detail.go, internal/workflow/menu.go, cmd/clasm/main.go, plus each new/changed file’s _test.go counterpart.

Dependency

Existing pickInstance/pickImage (Picker-tier), GatherVolumeInfo (internal/workflow/volume_info.go), displayOrNone.


Phase 20.42 — Configure clasm Domain

Status: designed, implemented, and unit-tested 2026-07-24, targeted for v0.0.5 (DESIGN.md, “Configure clasm Domain”; DECISIONS.md, “Configure clasm domain: explicit Save, region changes deferred to next launch”). go build/go vet/go test ./... -race/gofmt -l all clean. Not yet real-AWS-verified (this domain makes no AWS calls at all, so “verified” here just means a live manual walkthrough).

Work Items

Tests

Test-first throughout, confirmed failing (undefined symbols) before each implementation existed. config.Save covered directly: round-trips a Config through Save then Load, confirms 0o644 permissions, confirms an unwritable path errors cleanly. editRegions/ editBackupDirectoryRules/editOriginTag/displayConfig/ warnIfDirtyOnQuit each covered directly and independently (add, remove, blank-entry-skipped, no-entries-to-remove, unchanged-values-not-marked- dirty, blank-key-falls-back-to-default). runConfigureMenu’s dispatch loop covered the same way runTagMgmtMenu already is: dispatch-to- chosen-action, Save dispatches to its own action, Refresh runs once after a successful action, a single action’s error doesn’t crash the loop, pause-for-acknowledgment after a successful action – every case terminates via ctx cancellation from within a test-supplied action closure, never by letting scripted input run out.

Real bug found via review before real-AWS/live testing even started, fixed same day: editRegions/editBackupDirectoryRules originally called displayRegionsList/displayBackupDirectoryRulesList (a plain print) immediately before their own action-menu pickString – the same bug class as Phase 20.29’s “Show tags appeared to do nothing” (a full-height Menu-tier Select fills the whole terminal on every render and scrolls whatever was printed just before it out of view, so the current region/rule list was invisible in real terminal use even though it was always being (re)computed correctly). Fixed the same way Phase 20.29 was: embed the current list in the Select’s own Description (regionsEditDescription/backupDirectoryRulesEditDescription) in addition to (not instead of) the existing plain print, since accessible mode never renders a field’s Description, only Title and options – this means the fix itself is invisible to the existing pipe-driven tests, same accepted limitation as manage_tags.go’s own actionMenuDescription fix. General lesson reinforced a third time in this project: any call site that prints plain text immediately before a full-height Menu-tier Select is at risk of this same silent-scroll bug – worth checking for it by default in any new Menu-tier loop, not just after a live report.

Files

internal/config/config.go, new internal/workflow/configure_menu.go, internal/workflow/domain_menu.go, cmd/clasm/main.go, plus each new/changed file’s _test.go counterpart.

Dependency

Existing config.Load/config.Config/config.BackupDirectoryFor, ui.Prompt/ui.WithDefault, this project’s Menu-tier pickString/ pickComparable primitives, tagmgmt_menu.go‘s loop-until-’q’ shape as the direct structural model.


Phase 20.43 — Menu Reordering and Terminology Cleanup

Status: designed, implemented, and unit-tested 2026-07-24 (see MENU_REVIEW.md for the full audit; DECISIONS.md, “Regroup the Compute menu, and a terminology cleanup across every domain menu”). go build/ go vet/go test ./... -race/gofmt -l all clean. No AWS-facing behavior changed – this is entirely menu-item order and label text.

Work Items

Tests

No new test files – every change here is a reorder or relabel of an existing, already-tested menu, so the work was updating the existing accessible-mode pipe-input index literals (and, for S3, one literal label assertion) to match, then confirming the full suite still passes. A background search across every _test.go file confirmed only that one S3 label assertion existed anywhere in the suite before starting, so the blast radius of the rename pass was known upfront rather than discovered by trial and error.

Files

internal/workflow/menu.go, menu_test.go, tagmgmt_menu.go, tagmgmt_menu_test.go, bucket_lifecycle.go, bucket_lifecycle_test.go, iam_menu.go, iam_menu_test.go, s3_menu.go, s3_menu_test.go, keymgmt_menu.go.

Not done, flagged as pre-existing gaps

user_manual.md and TUI_REFERENCE.md’s screen-map are both already stale independent of this change (missing three whole domains, wrong item counts) – out of scope for this pass, noted in DECISIONS.md as a separate, larger follow-up if wanted.


Phase 20.44 — User-Data Pre-Flight Size Check

Status: designed, implemented, unit-tested, and real-AWS-verified 2026-07-28, live-testing-driven (see DESIGN.md, “User-Data Pre-Flight Size Check”; DECISIONS.md, “User-data pre-flight size check: hard error, no remediation loop-back; switch to gzip.BestCompression”). Closes a confirmed gap: Phase 20.34’s gzip fix is not sufficient for large cloud-init files (the incident’s granian-rdm-v14 file, 57849 raw bytes at the time, still exceeded the 16384-byte limit even gzip’d), and encodeUserData had no pre-flight size validation anywhere. go build/go vet/go test ./... -race/gofmt -l all clean. Targeted for v0.0.6.

Work Items

Tests

Test-first, per [[feedback-test-before-fix]]/this project’s standing practice: every new/changed userdata_gzip_test.go, launch_execute_test.go, launch_template_create_test.go, and launch_template_sync_test.go case confirmed failing (compile error against the old single-return signatures) before the implementation existed.

Files

internal/workflow/userdata_gzip.go, userdata_gzip_test.go, launch_execute.go, launch_execute_test.go, launch_template_create.go, launch_template_create_test.go, launch_template_sync.go, launch_template_sync_test.go.

Dependency

None. Independent of Phase 20.34 (already shipped in v0.0.5) other than building directly on its userdata_gzip.go.


Phase 20.45 — Cloud-Init Picker: Discoverable Cancel + SyncLaunchTemplate’s Missing cancelledIsNil Wrap

Status: designed, implemented, unit-tested, and verified in a real interactive terminal 2026-07-28, live-usage-driven (see DESIGN.md, “Cloud-Init Picker: Discoverable Cancel + a Second, Related Bug”; DECISIONS.md, “Cloud-init picker: a ‘q’ cancel sentinel, and a second bug found while tracing it”). Two bugs, same reported symptom: (1) promptCloudInitYAMLFile had no discoverable single-step cancel – ‘q’ was typed as literal filename text; (2) SyncLaunchTemplate never wrapped its testable core’s return with cancelledIsNil, so any cancellation inside it exited clasm entirely instead of returning to the Compute menu. go build/go vet/go test ./... -race/gofmt -l all clean. Targeted for v0.0.6.

Work Items

Tests

Test-first, per [[feedback-test-before-fix]]: confirmed failing before either fix landed – the “q” tests hung (accessible-mode PromptString re-prompting forever on exhausted input rather than erroring, the same gotcha already documented in this project’s own memory for looping huh-based workflows) rather than failing cleanly, which is itself confirmation the old code had no cancel path at all. SyncLaunchTemplate itself isn’t pipe-testable (its first step, pickLaunchTemplate, is a Picker-tier bubbletea program – same limitation as every other Picker-tier conversion in this package), so coverage is at the testable layers directly, matching how this package already tests around that limitation elsewhere:

Files

internal/workflow/userdata.go, userdata_test.go (new), launch_template_sync.go, launch_template_sync_test.go.

Dependency

None.


Phase 20.46 — Modify Launch Template Size

Status: designed, implemented, unit-tested, and real-AWS-verified 2026-07-28 (see DESIGN.md, “Modify Launch Template Size”; DECISIONS.md, “Modify Launch Template Size: one combined action, unfiltered instance-type picker, shrink to the AMI’s snapshot floor”). New TODO.md-requested feature: change a launch template’s instance type and/or EBS root volume size without touching UserData, today only possible via Sync (which only ever changes UserData) or by hand-editing in the AWS Console. Expanded mid-design after the user’s own concrete example (switching granian-rdm-v14-test from Graviton/arm64 to an x86_64 instance type) surfaced that this requires an AMI swap too, since architecture is tied to the AMI and CreateLaunchTemplateVersion doesn’t validate the mismatch itself. go build/go vet/go test ./... -race/gofmt -l all clean. Targeted for v0.0.6.

Work Items

Tests

Test-first, per [[feedback-test-before-fix]]: every new/changed function’s tests confirmed failing (undefined symbol or assignment- mismatch compile errors) before the corresponding implementation existed.

Files

internal/workflow/ebs_size.go, ebs_size_test.go, launch_from_cloud_init.go, launch_instance.go, new instance_type_arch_check.go/instance_type_arch_check_test.go, new modify_launch_template_size.go/modify_launch_template_size_test.go, menu.go, menu_test.go, cmd/clasm/main.go, launch_execute_test.go (fake DescribeInstanceTypes extended with instanceTypeArchitectures).

Dependency

None.


Phase 20.47 — SSH Connection Info: Key Path + Username Guess

Status: designed, implemented, unit-tested, and real-AWS-verified 2026-07-28 (see DESIGN.md, “SSH Connection Info: Key Path + Username Guess”; DECISIONS.md, “SSH connection info: guess the key path (only if it exists on disk) and the login username”). TODO.md-requested feature: displayConnectionInfo already printed ssh ec2-user@<ip>, missing an -i <key path> entirely and hardcoding “ec2-user” even for this tool’s own Ubuntu-only curated AMI list. go build/go vet/go test ./... -race/gofmt -l all clean. Targeted for v0.0.6.

Work Items

Tests

Test-first, per [[feedback-test-before-fix]]: confirmed failing (undefined-symbol/assignment-mismatch compile errors) before the implementation existed.

Files

internal/workflow/launch_execute.go, launch_execute_test.go, launch_from_template.go, power_state.go, and their _test.go counterparts.

Dependency

None.


Deferred to a Later Version (Phase 23+, not scheduled)

Not part of v1/v2 — see DECISIONS.md, “V1 scope: ship the four primitives first, defer composite workflows”, “Add Show/Export Cloud-Init as a v1 primitive”, “Add Backup Archive & Trim as a v1 primitive”, “Add Rename Instance as a v1 primitive; AMI Name is immutable”, “Add Create EC2 Instance from Cloud-Init YAML as a v1 primitive”, “Add Start/Stop/ Terminate EC2 Instance as v1 primitives”, “Structure workflows for future record/replay”, “Redesign navigation as a domain picker…”, “CloudFront + OAC by default for static websites”, and DESIGN.md, “Deferred to a Later Version”. Recorded here so they’re scheduled deliberately once Phase 16/22 pass, not lost:


Priority Order for Implementation

Phase Priority Effort Dependencies
Phase 0 High 2h None
Phase 1 High 3h Phase 0
Phase 2 High 4h Phase 1
Phase 3 High 4h Phase 2
Phase 4 High 6h Phase 1, 3
Phase 5 Medium 2h Phase 4
Phase 6 Medium 2h Phase 3
Phase 7 Medium 2h Phase 3
Phase 8 High 6h Phase 3
Phase 9 Medium 4h Phase 3
Phase 10 High 8h Phase 3
Phase 11 High 6h Phase 3
Phase 12 High 8h Phase 1, 2, 3
Phase 13 High 10h Phase 1, 2, 3
Phase 14 High 4h Phase 4, 5, 6, 7, 8, 9, 10, 11, 12, 13
Phase 15 Medium 4h Phase 14
Phase 16 High 6h Phase 14
Phase 17 Medium 2h Phase 16
Phase 18 High 4h Phase 14 (runs alongside 16/17)
Phase 19 Medium 6h Phase 18
Phase 20 Medium 16h Phase 18
Phase 20.1 High 24h Phase 20
Phase 21 Deferred (someday/maybe) 8h Phase 18, Phase 20
Phase 22 High 6h Phase 19, 20
Phase 23+ Deferred Phase 16, 22 (see above)