setup: fix install re-run after Ctrl+C + clearer Step 6 handoff#231
Conversation
Two related fixes to the first-run experience:
install.sh: a new setup_completed helper detects the state where the
binary is on disk but no service unit was installed (operator Ctrl+Cs
out of the wizard). On re-run the installer falls through to the
fresh-install path instead of hitting the upgrade branch and dying
on systemctl start aimx ("Unit aimx.service not found"). Probe paths
are env-var overridable for test isolation.
aimx setup: the closing handoff now reads accurately and the
operator-facing call-to-action is visually distinct.
- The "AIMX setup — complete" banner moves to between Step 5 and
Step 6 (no second checklist underneath — the operator already
saw it when Step 5 marked complete).
- The aimx agents setup callout is now a three-line box with a
preceding sentence telling the operator to run it as a regular
user. Box geometry is locked in by an equal-width unit test;
the body row hard-codes the literal arrow so NO_COLOR / non-TTY
rendering can't desync the borders.
- The closing capability line no longer overclaims that the agents
have access to anything (the daemon is up but no agent is wired
yet). New wording: "AIMX is now running at @<domain> and ready
to send and receive email." + "Once you've wired your MCP to
your LLM, try asking your agent, e.g.".
- The final success line is prefixed with the checklist glyph so
it reads as the final box checked.
Closes #229.
uzyn
left a comment
There was a problem hiding this comment.
Changes Overview
This PR makes two related fixes to the aimx setup first-run experience:
install.shgains asetup_completedhelper (probes/etc/systemd/system/aimx.serviceand/etc/init.d/aimx, both overridable via env vars). The upgrade-vs-fresh decision inmain()is gated on it so a Ctrl+C-interrupted setup re-enters cleanly instead of dying onsystemctl start aimx.src/setup.rsreworks the closing handoff: the🐦⬛ AIMX setup — completebanner moves between Step 5 and Step 6, theaimx agents setupcallout becomes a three-line box with hard-coded→and an equal-width invariant, the closing capability line is rewritten to stop overclaiming agent wiring, and the success line is prefixed with a glyph intended to mirror the per-step checklist's ☑.
Scope Alignment
The PR description, the linked issue body, and the diff line up on most points — but there is one inconsistency around the final success-line glyph that the implementation does not honor. See Potential Bugs below.
Banned planning-doc tokens check (\bSprint\b|\bS\d+[-.]\d+\b|\bFR-\d+[a-z]?\b|User Story|Acceptance criteria) against the PR's diff additions: zero hits. Pre-existing tokens in the repo are not touched.
Potential Bugs
Blocker 1 — Closing success line renders ✓, not ☑, despite explicit stated intent
The linked issue body (#229) explicitly promises:
Prefixes "AIMX has been set up successfully." with the same green ☑ glyph the checklist uses, so it reads as the final box checked.
…and its rendered example shows ☑ AIMX has been set up successfully.
The PR description's R11 claims the same: "line is prefixed with term::success_mark() (☑ on TTY, [OK] on non-TTY)".
The new doc comment on print_closing_message (src/setup.rs:2708-2710) reads:
The success line is prefixed with the same ☑ glyph the per-step checklist uses (via
term::success_mark)
But term::success_mark() in src/term.rs:114-120 returns "✓".green() on TTY and "[OK]".normal() on non-TTY. The per-step checklist's ☑ glyph comes from term::step_glyph(StepState::Done) (src/term.rs:179), which is a different function returning a different glyph.
So the operator will see ✓ AIMX has been set up successfully. on TTY (not ☑) and [OK] AIMX has been set up successfully. on non-TTY (not [x]). The intended visual parity with the checklist row that just rendered ☑ Install AIMX is broken.
Two viable fixes:
- Swap
term::success_mark()→term::step_glyph(term::StepState::Done)inprint_closing_message. This delivers ☑/[x]to match the checklist exactly. - Introduce a
term::done_mark()helper and use it (ifsuccess_markrendering elsewhere depends on✓).
The test closing_message_carries_required_phrasing does not catch this because it only greps the source for the literal string term::success_mark(). It asserts the function is called, not that the resulting glyph matches the stated intent. After fixing, the test should also assert against the rendered glyph (e.g. compare against term::step_glyph(StepState::Done).to_string() so a future swap of success_mark to a non-☑ rendering does not silently revert this).
Classified as Blocker because it violates the explicitly stated scope/intent (issue #229 body + PR description R11 + inline doc comment).
Test Coverage
mcp_section_callout_box_is_equal_width(new): solid. Checks top/body/bottom char-count equality (correctly usingchars().count()for multi-byte box glyphs), asserts the body contains the literal→, AND source-greps insidemcp_section_linesfor a regression ofterm::prompt_markreintroduction. This catches the exact desync class the PR description calls out.setup_completed gate(a-e, new intests/install_sh.sh): covers the systemd-only branch, openrc-only branch, neither-present branch, the end-to-end "binary at target tag + no unit → fall-through" path, and a regression assertion that genuine version-diff upgrades still take the upgrade branch. Good coverage.- The new
closing_message_carries_required_phrasingtest is too weak in one place — it assertssource.contains("term::success_mark()")rather than asserting the rendered output mirrorsterm::step_glyph(StepState::Done). This is what let Blocker 1 slip through. Tightening this test is part of the recommended fix. - The new
closing_message_does_not_overclaim_agent_capabilitytest correctly locks the old overclaim out by greping inside theprint_closing_messagefunction body only, so it cannot false-pass on a literal that survives elsewhere in the file. Good. - The renamed
final_banner_signals_completiontest correctly assertsprint_step_listis NOT called inside the banner body (function-body-bounded grep). Solid.
Code Quality (Non-blocker)
print_final_banner(_checklist: &Checklist)now ignores its only parameter. Underscore prefix silences clippy, but the API is misleading — every caller now passes a value that is read once (the&borrow check) and then dropped. Consider either dropping the parameter or wiring it back into a single state-of-the-wizard summary (e.g. a one-liner "✓ all 5 install steps complete; 1 handoff remaining"). Keeping the param to "preserve API surface" is weak given this is apubhelper in a binary crate with no external consumers.- The
INNER_WIDTH = 29constant incallout_boxhas a clear inline justification, and the test will fail loudly if the command is renamed. Acceptable, though pulling the constant onto the call site (callout_box(" → aimx agents setup", 29)) or computing it from the body length plus padding would make the coupling between body content and pad width more obvious. Non-blocker, design-taste call. - The new
setup_completedhelper ininstall.shmirrors the existing helper style (env-var overridable probe paths). Good fit with the rest of the file.
Pre-existing macOS test noise (verified unrelated)
The implementer flagged uds_authz (lookup_unknown_uid_returns_none, peer_username_does_not_panic_on_orphan_uid, peer_username_orphan_uid_returns_specific_error) and integration::concurrent_ingest_burst_and_mark_same_mailbox_no_torn_writes as failing on Darwin hosts but passing on Linux CI. I confirmed:
- The three
uds_authztests exerciselookup_username/peer_usernamefor orphan uids (e.g.4_294_967_294). macOS NSS resolves that uid to"nobody"while glibc returns None. None of the diff touchessrc/uds_authz.rsor the lookup path; the failures are host-level NSS behaviour, not regressions. - The
tests/install_sh.shbash 3.2 + set -equirk on Darwin is an artifact of/bin/shbeing POSIX-mode bash 3.2 on macOS. Linux CI uses dash/bash. Verified by.github/workflows/ci.yml→ all fourruns-on:entries areubuntu-latest. The harness's new section 9b runs on the samesh "${INSTALL_SH}"invocation pattern as the surrounding sections — no new Darwin-specific brittleness introduced.
These failures are not caused by this diff and CI will not see them.
Install.sh re-entry — verified clean
Trace through the Ctrl+C scenario (binary on disk at the same TAG, no service unit):
[ -x "${_install_path}" ] && ! setup_completed→ true,_is_upgrade=0, emits "continuing setup".- Non-upgrade path: tarball downloaded,
install -m 0755overwrites the binary at the same path (effective no-op for the same tag). backup_existing_configmoves any partial/etc/aimx/config.tomlaside.exec ${SUDO} aimx setup.run_setupcallsis_already_configured(src/setup.rs:2286), which requiresservice_running && cert_exists && dkim_exists. Post-Ctrl+C, the service unit was never written →is_service_runningreturns false →already_configuredis false → wizard re-enters from preflight. Clean.
One minor cosmetic in this flow: ui_success "AIMX ${TAG} installed to ${_install_path}" will print even when the binary on disk is already at TAG. Acceptable noise — it's a true statement about disk state and the operator just saw the "continuing setup" line.
Box geometry — verified
callout_box(" → aimx agents setup") produces three 33-char-wide lines (2-char indent + corner/pipe + 29-char inner + corner/pipe). Body content is 21 chars, so 8 chars of right padding. The equal-width test correctly uses chars().count() (not len()) so the multi-byte box glyphs and → arrow don't break the assertion. The test also defends against term::prompt_mark() being reintroduced — without this guard, a future refactor could silently desync the corners on non-TTY because [>] is wider than →.
Summary and Recommended Actions
- Overall verdict: Needs minor fixes
- Blockers:
- Closing success line uses
term::success_mark()(renders✓/[OK]) instead of the☑/[x]glyph promised by both the linked issue body and PR description R11. Fix by routing throughterm::step_glyph(term::StepState::Done)and tighteningclosing_message_carries_required_phrasingto assert the rendered glyph, not just the function name.
- Closing success line uses
- Non-blockers:
print_final_bannerparameter is now unused; either drop or rewire.- Consider hoisting
INNER_WIDTHto the call site for clearer coupling with the body literal.
- Nice-to-haves: none.
Address review feedback on PR #231: Blocker — the closing success line was prefixed with term::success_mark() (renders the check mark on TTY, [OK] on non-TTY) instead of the checklist glyph the PR description, the linked issue, and the in-function doc comment all promise (the ballot box check on TTY, [x] on non-TTY). Route the prefix through term::step_glyph(term::StepState::Done) so the closing tick visually matches the per-step checklist row "Install AIMX". Tighten closing_message_carries_required_phrasing to (a) bound the grep to the print_closing_message body, (b) assert the new helper is called, (c) assert term::success_mark is NOT used, and (d) assert the rendered glyph contains the ballot box or [x] so a future swap of step_glyph(Done) trips the test immediately. Non-blocker — drop the now-unused &Checklist parameter from print_final_banner. The underscore-silenced parameter was a footgun: the banner is title-only by design and does not depend on checklist state, so the API was misleading. No external callers (it is a pub helper in a binary crate). Non-blocker — hoist the callout box inner_width from a private const inside callout_box() to a parameter at the call site, so the coupling between body content (21 chars wide) and pad width (29, leaving 8 chars of right breathing room) is visible where the body literal lives.
Cycle 2 — review feedback addressedPushed FixedBlocker — closing-line glyph parity with the checklist. I also tightened
Non-blocker — Non-blocker — hoist Verification
Intentionally left as-isNothing intentionally deferred — all three review items addressed. Pre-existing Darwin-host noise (unchanged from cycle 1)The same three
|
uzyn
left a comment
There was a problem hiding this comment.
Re-review — cycle 2 (commit 2d5672d)
All three items from the cycle-1 review are verified fixed. Approving.
Resolved issues
Blocker — closing success line now uses the checklist glyph. Verified at src/setup.rs:2718 — print_closing_message now calls term::step_glyph(term::StepState::Done) instead of term::success_mark(). Rendering: ☑ on TTY, [x] on non-TTY, matching the per-step checklist row exactly. The in-function doc comment (lines 2710-2713) was updated to reference term::step_glyph and explicitly call out that success_mark would NOT match.
Test strengthening — closing_message_carries_required_phrasing is now genuinely glyph-aware (src/setup.rs:6144-6183). The cycle-1 source-grep-only failure mode is closed off by:
- Bounding the grep to the
print_closing_messagebody (extractsfn print_closing_message(→ first\n}\n), not the whole file. Verified that the function body contains no nested braces that could cause premature termination. - Positive assertion: body contains
term::step_glyph(term::StepState::Done). - Negative assertion: body does NOT contain
term::success_mark— this is the exact regression class. - Runtime rendering assertion: actually calls
term::step_glyph(term::StepState::Done).to_string()and asserts the rendered output contains'☑'or"[x]". This defends against the helper internals being swapped away from the checklist glyphs — not just against the source string changing.
Confirmed against src/term.rs:174-194: step_glyph(Done) returns ☑.green() on TTY or [x].normal() on non-TTY, so the assertion covers both real-world outputs.
Non-blocker — print_final_banner parameter dropped. Verified at src/setup.rs:2415 (signature is now pub fn print_final_banner()) and src/setup.rs:2684 (single call site updated to print_final_banner();). Repo-wide grep confirms no other callers — print_final_banner is referenced only inside src/setup.rs.
Non-blocker — inner_width hoisted to call site. Verified at src/setup.rs:1437 (call site is callout_box(" → aimx agents setup", 29) with a six-line comment above it explaining the 21-char body + 8-char right padding coupling) and src/setup.rs:1448-1458 (signature is fn callout_box(body: &str, inner_width: usize); the private INNER_WIDTH = 29 const is gone). The coupling between body content and pad width is now visible together at the call site.
Verification
cargo test --bin aimx setup::tests::→ 313 passed, 0 failed.cargo test --bin aimx closing_message_carries_required_phrasing→ 1 passed.cargo clippy --bin aimx -- -D warnings→ clean.cargo fmt -- --check→ clean.- Banned planning-doc tokens (
\bSprint\s*\d+,\bS\d+[-.]\d+\b,\bFR-\d+[a-z]?\b,User Story,Acceptance criteria) against the cycle-2 diff: zero hits. - Files touched in cycle 2:
src/setup.rsonly.install.shandtests/install_sh.shuntouched — thesetup_completedgate side of the PR is unchanged and the pre-existing macOS-host test noise (uds_authz, integration concurrency, install.sh bash-3.2 quirk) is by definition not regressed.
No new issues
No regressions, no scope creep, no new bugs introduced. Cycle-2 is a tight, surgical fix.
Summary and recommended actions
- Overall verdict: Ready to merge.
- Blockers: none.
- Non-blockers: none.
- Nice-to-haves: none.
Recommended merge commit message
setup: fix install re-run after Ctrl+C + clearer Step 6 handoff (#231)
install.sh: add `setup_completed` helper (probing
/etc/systemd/system/aimx.service or /etc/init.d/aimx, with env-var
overrides for tests) and gate the upgrade-vs-fresh decision on it. A
Ctrl+C-interrupted `aimx setup` leaves the binary on disk but no
service unit; re-running the installer used to die on
`systemctl start aimx`. The gate forces the fresh-install path in
that state so the wizard re-enters cleanly. Genuine version-diff
upgrades are unchanged.
src/setup.rs: rework the closing handoff:
- Move the `🐦⬛ AIMX setup — complete` banner between Step 5 and
Step 6 (no second checklist redraw underneath); drop the now-
unused `&Checklist` parameter from `print_final_banner`.
- Reshape Step 6's `aimx agents setup` callout into a three-line
box preceded by a "run as a regular user" sentence; pad width
hoisted to the call site so body content and pad width sit next
to each other.
- Rewrite the closing capability line to stop overclaiming agent
wiring; the success line is now prefixed with
`term::step_glyph(StepState::Done)` (☑ / [x]) to mirror the
per-step checklist glyph.
- Lock the new behaviour with `mcp_section_callout_box_is_equal_width`,
`closing_message_carries_required_phrasing` (now glyph-aware at
runtime), `closing_message_does_not_overclaim_agent_capability`,
and `final_banner_signals_completion`.
Closes #229.
CodeQL `rust/cleartext-logging` flagged the local `run_as_user_idx` in `mcp_section_lines_are_in_documented_order` because the variable name contains "user". The variable is a `usize` position index in a Vec<String> — there is no sensitive data — but the heuristic trips on the name. Rename to `handoff_sentence_idx`, which is also more descriptive of what the index actually points at (the "To wire AIMX into the harness..." sentence between the bullets and the boxed callout). No behaviour change; same 5 assertions run, same coverage.
Summary
Two related fixes to the
aimx setupfirst-run experience:install.sh— when a user Ctrl+Cs out ofaimx setupbefore the service unit is installed, the binary is on disk but noaimx.serviceexists. Re-running the installer hits the upgrade branch and dies onsystemctl start aimx("Unit aimx.service not found"). A newsetup_completedhelper detects this state so re-runs fall through to the fresh-install path and re-enter the wizard cleanly.aimx setup— improve the closing handoff so it reads accurately and the operator-facing call-to-action is visually distinct:🐦⬛ AIMX setup — completebanner now sits between Step 5 and Step 6 (no second checklist underneath — the operator already saw it when Step 5 marked complete).→ aimx agents setupcallout is now a three-line box with a preceding sentence telling the operator to run it as a regular user (not root). Box geometry locked in by an equal-width unit test; the body row hard-codes→soNO_COLOR/ non-TTY rendering can't desync the borders.Requirements
setup_completedhelper returns 0 when either/etc/systemd/system/aimx.serviceor/etc/init.d/aimxexists; 1 otherwise.AIMX_SETUP_COMPLETED_SYSTEMD/AIMX_SETUP_COMPLETED_OPENRCfor test harness isolation.setup_completed: binary on disk but no unit file → fall through to the fresh-install path.parse_installed_tag/compare_tagsflow.binary present but aimx setup did not complete (no service unit); continuing setupso the operator understands why.tests/install_sh.shassertions continue to pass (the equal-version dry-run test was updated to set the new env var so it stays on the equal-version branch instead of falling into the new Ctrl+C fall-through).🐦⬛ AIMX setup — completebanner is emitted between Step 5 completion and the Step 6 section header; the second checklist render that used to print underprint_final_banneris dropped.aimx agents setupcallout is a three-line box preceded by the "To wire AIMX into the harness..." sentence.mcp_section_callout_box_is_equal_width(equal char width on top/body/bottom). Body row hard-codes the literal→arrow; a source-grep guard inside the test fails if a future refactor reintroducesterm::prompt_mark()in the callout.term::success_mark()(☑ on TTY,[OK]on non-TTY /NO_COLOR).mcp_section_lines_are_in_documented_order,mcp_section_recommends_running_aimx_agents_setup,mcp_section_lists_every_registered_agent,final_banner_signals_completion,closing_message_carries_required_phrasing,closing_message_does_not_overclaim_agent_capability,run_setup_step_six_uses_handoff_state,run_setup_emits_section_headers_in_spec_order, plus the newmcp_section_callout_box_is_equal_width.Out of scope
install.shandsrc/setup.rs's closing handoff path.setup_completedgate tests cover the regression in code).Technical approach
install.shsetup_completed()helper sits in its own section betweencompare_tagsandstop_service. Same[ -f ... ]shape as the rest of the helpers; env-var override matches the existingAIMX_INSTALL_CONFIG_PATHtestability pattern._install_pathexists AND! setup_completed, force_is_upgrade=0, emit the "continuing setup" line, and skip theparse_installed_tag/compare_tagsmachinery entirely. The downstream fresh-install path re-lays the binary at the target tag andexec sudo aimx setupruns the wizard.src/setup.rsprint_final_banneris reduced to just the title — the call moves from after Step 6 to betweenprint_step_complete(5, ...)andsection_header(6, ...).mcp_section_lines()now emits the preceding sentence, then a three-linecallout_box(...)helper that pads the body to a fixed inner width (29 chars) and wraps it in┌─┐ │…│ └─┘characters. The body row contains the literal→(notterm::prompt_mark()); the test enforces that and equal char width across the three lines.print_closing_messageis rewritten:term::success_mark()prefix on the success line, then the new "running at" / "wire your MCP" wording.Test plan
sh -n install.sh— pass.shellcheck install.sh— clean.shellcheck -s sh tests/install_sh.sh— clean.tests/install_sh.sh— the new# setup_completed gatesection adds 4 assertions covering both helper-level branches and an end-to-end dry-run that proves the upgrade decision is bypassed when no unit file exists, plus a regression assertion that a genuine version-diff upgrade still takes the upgrade branch. (The harness stops at the dry-run smoke section on macOS hosts because bash 3.2 tripsset -eon captured commands that hit the Darwin gate — this is pre-existing host noise; CI runs on Linux where it works fine. Verified with an isolated harness that thesetup_completedhelper itself returns the right value in all four cases.)cargo test— 313 setup tests pass, including the 5 net-new ones. The 3uds_authzfailures (lookup_unknown_uid_returns_none,peer_username_does_not_panic_on_orphan_uid,peer_username_orphan_uid_returns_specific_error) and 1integrationfailure (concurrent_ingest_burst_and_mark_same_mailbox_no_torn_writes) are pre-existing macOS host issues unrelated to this change — macOS's NSS resolves uid 4294967294 to "nobody" instead of returning None, and the UDS bind on macOS sometimes can't reach the socket path insidetempfile::TempDir. Both pass on Linux CI.cargo clippy -- -D warnings— clean.cargo fmt -- --check— clean.Notes
Closes #229.
The box callout uses a fixed 29-char inner width — that was the smallest stable width that fits
→ aimx agents setupwith a bit of right-side breathing room. If we ever rename the subcommand, the constant incallout_box()needs to be widened (andmcp_section_callout_box_is_equal_widthwill fail loudly if you forget).