Files
stack/docs/scratchpads/install-ux-hardening-20260405.md
jason.woltje 732f8a49cf
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
feat: unified first-run flow — merge wizard + gateway install (IUH-M03) (#433)
2026-04-05 19:13:02 +00:00

18 KiB

Install UX Hardening — IUH-M01 Session Notes

Session: 2026-04-05 (agent-ad6b6696)

Plan

Manifest schema decision:

  • Version 1 JSON at ~/.config/mosaic/.install-manifest.json (mode 0600)
  • Written by tools/install.sh after successful install
  • Fields: version, installedAt, cliVersion, frameworkVersion, mutations{directories, npmGlobalPackages, npmrcLines, shellProfileEdits, runtimeAssetCopies}
  • Uninstall reads it; if missing → heuristic mode (warn user)

File list:

  • NEW: packages/mosaic/src/runtime/install-manifest.ts — read/write helpers + types
  • NEW: packages/mosaic/src/runtime/install-manifest.spec.ts — unit tests
  • NEW: packages/mosaic/src/commands/uninstall.ts — command implementation
  • NEW: packages/mosaic/src/commands/uninstall.spec.ts — unit tests
  • MOD: packages/mosaic/src/cli.ts — register uninstall command
  • MOD: tools/install.sh — write manifest on success + add --uninstall path

Runtime asset list (from mosaic-link-runtime-assets / framework/install.sh):

  • ~/.claude/CLAUDE.md (source: $MOSAIC_HOME/runtime/claude/CLAUDE.md)
  • ~/.claude/settings.json (source: $MOSAIC_HOME/runtime/claude/settings.json)
  • ~/.claude/hooks-config.json (source: $MOSAIC_HOME/runtime/claude/hooks-config.json)
  • ~/.claude/context7-integration.md (source: $MOSAIC_HOME/runtime/claude/context7-integration.md)
  • ~/.config/opencode/AGENTS.md (source: $MOSAIC_HOME/runtime/opencode/AGENTS.md)
  • ~/.codex/instructions.md (source: $MOSAIC_HOME/runtime/codex/instructions.md)

Reversal logic:

  1. If .mosaic-bak-<stamp> exists for a file → restore it
  2. Else if managed copy exists → remove it
  3. Never touch files not in the known list

npmrc reversal:

  • Only remove line @mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
  • If manifest has the line, use that as authoritative; else check heuristically

PATH reversal:

  • Check install.sh: it does NOT add PATH entries to shell profiles (framework/install.sh migration removes old $MOSAIC_HOME/bin PATH entries in v0/v1→v2 migration, but new install does NOT add PATH)
  • ASSUMPTION: No PATH edits in current install (v0.0.24+). Shell profiles not modified by current install.
  • The $PREFIX/bin is mentioned in a warning but NOT added to shell profiles by install.sh.
  • shellProfileEdits array will be empty for new installs; heuristic mode also skips it.

Test strategy:

  • Unit test manifest read/write with temp dir mocking
  • Unit test command registration
  • Unit test dry-run flag (no actual fs mutations)
  • Unit test --keep-data skips protected paths
  • Unit test heuristic mode warning

Implementation order:

  1. install-manifest.ts helpers
  2. install-manifest.spec.ts tests
  3. uninstall.ts command
  4. uninstall.spec.ts tests
  5. cli.ts registration
  6. tools/install.sh manifest writing + --uninstall path

ASSUMPTION: No PATH modifications in current install.sh (v0.0.24). Framework v0/v1→v2 migration cleaned old PATH entries but current install does not add new ones. ASSUMPTION: --uninstall in install.sh handles framework + cli + npmrc only; gateway teardown deferred to mosaic gateway uninstall. ASSUMPTION: Pi settings.json edits (skills paths) added by framework/install.sh are NOT reversed in this iteration — too risky to touch user Pi config without manifest evidence. Noted as follow-up.


Session 2 — 2026-04-05 (orchestrator resume)

IUH-M01 completion summary

  • PR: #429 merged as 25cada77
  • CI: green (Woodpecker)
  • Issue: #425 closed
  • Files: +1205 lines across 4 new + 2 modified + 1 docs
  • Tests: 14 new, 170 total passing

Follow-ups captured from worker report

  1. Pi settings.json reversal deferred — worker flagged as too risky without manifest evidence. Future IUH task should add manifest entries for Pi settings mutations. Not blocking M02/M03.
  2. Pre-existing cli-smoke.spec.ts failure@mosaicstack/brain package entry resolution fails in Vitest. Unrelated to IUH-M01. Worth a separate issue later.
  3. pr-create.sh wrapper bug with multiline bodies — wrapper evals body args as shell when they contain newlines/paths. Worker fell back to Gitea REST API. Same class of bug I hit earlier with issue-create.sh. Worth a tooling-team issue to fix both wrappers.

Mission doc sync

cli-unification docs that were archived before the M01 subagent ran did not travel into the M01 PR (they were local, stashed before pull). Re-applying now:

  • docs/archive/missions/cli-unification-20260404/ (the old manifest + tasks)
  • docs/MISSION-MANIFEST.md (new install-ux-hardening content)
  • docs/TASKS.md (new install-ux-hardening content)

Committing as docs: scaffold install-ux-hardening mission + archive cli-unification.

Next action

Delegate IUH-M02 to a sonnet subagent in an isolated worktree.


Session 3: 2026-04-05 (agent-a6ff34a5) — IUH-M02 Wizard Remediation

Plan

AC-3: Password masking + confirmation

  • New packages/mosaic/src/prompter/masked-prompt.ts — raw-mode stdin reader that suppresses echo, handles backspace/Ctrl+C/Enter.
  • bootstrapFirstUser in packages/mosaic/src/commands/gateway/install.ts: replace rl.question('Admin password...') with promptMaskedPassword(), require confirm pass, keep min-8 validation.
  • Headless path: when MOSAIC_ASSUME_YES=1 or !process.stdin.isTTY, read MOSAIC_ADMIN_PASSWORD env var directly.

AC-4a: Hooks preview stage

  • New packages/mosaic/src/stages/hooks-preview.ts — reads hooks-config.json from state.sourceDir or state.mosaicHome, displays each top-level hook category with name/trigger/command preview, prompts "Install these hooks? [Y/n]", stores result in state.hooks.
  • packages/mosaic/src/types.ts — add hooks?: { accepted: boolean; acceptedAt?: string } to WizardState.
  • packages/mosaic/src/wizard.ts — insert hooksPreviewStage between runtimeSetupStage and skillsSelectStage; skip if no claude runtime detected.

AC-4b: mosaic config hooks subcommands

  • Add hooks subcommand group to packages/mosaic/src/commands/config.ts:
    • list: reads ~/.claude/hooks-config.json, shows hook names and enabled/disabled status
    • disable <name>: prefixes matching hook key with _disabled_ in the JSON
    • enable <name>: removes _disabled_ prefix if present

AC-5: Headless install path

  • runConfigWizard: detect headless mode (MOSAIC_ASSUME_YES=1 or !process.stdin.isTTY), read env vars with defaults, validate required vars, skip prompts entirely.
  • bootstrapFirstUser: detect headless mode, read MOSAIC_ADMIN_NAME/EMAIL/PASSWORD, validate, proceed without prompts.
  • Document env vars in packages/mosaic/README.md (create if absent).

File list

NEW:

  • packages/mosaic/src/prompter/masked-prompt.ts
  • packages/mosaic/src/prompter/masked-prompt.spec.ts
  • packages/mosaic/src/stages/hooks-preview.ts
  • packages/mosaic/src/stages/hooks-preview.spec.ts

MODIFIED:

  • packages/mosaic/src/types.ts — extend WizardState
  • packages/mosaic/src/wizard.ts — wire hooksPreviewStage
  • packages/mosaic/src/commands/gateway/install.ts — masked password + headless path
  • packages/mosaic/src/commands/config.ts — add hooks subcommands
  • packages/mosaic/src/commands/config.spec.ts — extend tests
  • packages/mosaic/README.md — document env vars

Assumptions

ASSUMPTION: hooks-config.json location is <sourceDir>/framework/runtime/claude/hooks-config.json during wizard (sourceDir is package root). Fall back to <mosaicHome>/runtime/claude/hooks-config.json for installed config. ASSUMPTION: The hooks subcommands under config operate on ~/.claude/hooks-config.json (the installed copy), not the package source. ASSUMPTION: For the hooks preview stage, the "name" field displayed per hook entry is the top-level event key (e.g. "PostToolUse") plus the matcher from nested hooks array. This is the most user-readable representation given the hooks-config.json structure. ASSUMPTION: config hooks list/enable/disable use CLAUDE_HOME env or ~/.claude as the target directory for hooks files. ASSUMPTION: The headless TTY detection (!process.stdin.isTTY) is sufficient; MOSAIC_ASSUME_YES=1 is an explicit override for cases where stdin is a TTY but the user still wants non-interactive (e.g., scripted installs with piped terminal).


Session 4 — 2026-04-05 (orchestrator resume) — IUH-M02 closed, delegating IUH-M03

IUH-M02 completion summary

  • PR: #431 merged as cd8b1f66
  • CI: green (Woodpecker)
  • Issue: #426 closed
  • Acceptance criteria: AC-3 (password mask), AC-4 (hooks visibility — consent recorded), AC-5 (headless path) all satisfied
  • New files: prompter/masked-prompt.ts, stages/hooks-preview.ts (+ specs)
  • Modified: wizard.ts, types.ts (state.hooks), commands/gateway/install.ts, commands/config.ts

Follow-up captured from M02 agent

Hooks consent is recorded but not enforced. The hooks-preview stage sets state.hooks.accepted when the user confirms, but the finalize stage still unconditionally runs mosaic-link-runtime-assets, which copies hooks-config.json into ~/.claude/ regardless of consent. This is a soft gap — the user sees the prompt and can decline, but declining currently has no effect downstream.

Options for addressing:

  • Fold into IUH-M03 (since M03 touches the finalize/install convergence path anyway)
  • Spin a separate small follow-up issue after M03 lands

Leaning toward folding into M03 — the unified first-run flow naturally reworks the finalize→gateway handoff where this gating belongs.

IUH-M03 delegation

Now delegating to an opus subagent in an isolated worktree. Scope from /tmp/iuh-m03-body.md:

  • Extract runConfigWizardstages/gateway-config.ts
  • Extract bootstrapFirstUserstages/gateway-bootstrap.ts
  • runWizard invokes gateway stages as final stages
  • Drop the 10-minute $XDG_RUNTIME_DIR/mosaic-install-state.json session bridge
  • mosaic gateway install becomes a thin standalone wrapper for backward-compat
  • tools/install.sh single auto-launch entry point
  • Bonus if scoped: honor state.hooks.accepted in finalize stage so declining hooks actually skips hook install

Known tooling caveats to pass to worker:

  • issue-create.sh / pr-create.sh wrappers eval multiline bodies as shell — use Gitea REST API fallback with load_credentials gitea-mosaicstack
  • Protected main: PR-only, squash merge
  • Must run ci-queue-wait.sh --purpose push|merge before push/merge

Session 5: 2026-04-05 (agent-a7875fbd) — IUH-M03 Unified First-Run

Problem recap

mosaic wizard and mosaic gateway install currently run as two separate phases bridged by a fragile 10-minute session file at $XDG_RUNTIME_DIR/mosaic-install-state.json. tools/install.sh auto-launches both sequentially so the user perceives two wizards stitched together; state is not shared, prompts are duplicated, and if the user walks away the bridge expires.

Design decision — Option A: gateway install becomes terminal stages of runWizard

Two options on the table:

  • (A) Extract runConfigWizard and bootstrapFirstUser into stages/gateway-config.ts and stages/gateway-bootstrap.ts, append them to runWizard as final stages, and make mosaic gateway install a thin wrapper that runs the same stages with an ephemeral state seeded from existing config.
  • (B) Introduce a new top-level orchestrator that composes the wizard and gateway install as siblings.

Chosen: Option A. Rationale:

  1. The wizard already owns a WizardState that threads state across stages — gateway config/bootstrap fit naturally as additional stages without a new orchestration layer.
  2. mosaic gateway install as standalone entry point stays idempotent by seeding a minimal WizardState and running only the gateway stages, reusing the same functions.
  3. Avoids a parallel state object and keeps the call graph linear; easier to test and to reason about the "one cohesive flow" UX goal.
  4. Option B would leave runWizard and the gateway install as siblings that still need to share a state object — equivalent complexity without the narrative simplification.

Scope

  1. Extend WizardState with optional gateway slice: { tier, port, databaseUrl?, valkeyUrl?, anthropicKey?, corsOrigin, admin?: { name, email, password } }. The admin password is held in memory only — never persisted to disk as part of the state object.
  2. New packages/mosaic/src/stages/gateway-config.ts — pure stage that:
    • Reads existing .env/mosaic.config.json if present (resume path) and sets state.
    • Otherwise prompts via WizardPrompter (interactive) or reads env vars (headless).
    • Writes .env and mosaic.config.json, starts the daemon, waits for health.
  3. New packages/mosaic/src/stages/gateway-bootstrap.ts — pure stage that:
    • Checks /api/bootstrap/status.
    • If needsSetup, prompts for admin name/email/password (uses promptMaskedConfirmed) or reads env vars (headless); calls /api/bootstrap/setup; persists token in meta.
    • If already setup, handles inline token recovery exactly as today.
  4. packages/mosaic/src/wizard.ts — append gateway-config and gateway-bootstrap as stages 11 and 12. Remove writeInstallState and the INSTALL_STATE_FILE constant entirely.
  5. packages/mosaic/src/commands/gateway/install.ts — becomes a thin wrapper that builds a minimal WizardState with a ClackPrompter, then calls runGatewayConfigStage(...) and runGatewayBootstrapStage(...) directly. Remove the session-file readers/writers. Headless detection is delegated to the stage itself. The wrapper still exposes the runInstall({host, port, skipInstall}) API so gateway.ts command registration is unchanged.
  6. tools/install.sh — drop the second mosaic gateway install call; mosaic wizard now covers end-to-end. Leave gateway install guidance for non-auto-launch path so users still know the standalone entry point exists.
  7. Hooks gating (bonus — folded in): finalize.ts already runs mosaic-link-runtime-assets. When state.hooks?.accepted === false, set MOSAIC_SKIP_CLAUDE_HOOKS=1 in the env for the subprocess; teach the script to skip copying hooks-config.json when that env var is set. Other runtime assets (CLAUDE.md, settings.json, context7) still get linked.

Files

NEW:

  • packages/mosaic/src/stages/gateway-config.ts (+ .spec.ts)
  • packages/mosaic/src/stages/gateway-bootstrap.ts (+ .spec.ts)

MODIFIED:

  • packages/mosaic/src/types.ts — extend WizardState with gateway?: slice
  • packages/mosaic/src/wizard.ts — append gateway stages, remove session-file bridge
  • packages/mosaic/src/commands/gateway/install.ts — thin wrapper over stages, remove 10-min bridge
  • packages/mosaic/src/stages/finalize.ts — honor state.hooks.accepted === false by setting MOSAIC_SKIP_CLAUDE_HOOKS=1
  • packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets — honor MOSAIC_SKIP_CLAUDE_HOOKS=1
  • tools/install.sh — single unified auto-launch

Assumptions

ASSUMPTION: Gateway stages must run after finalizeStage because finalize writes identity files and links runtime assets that the gateway admin UX may later display — reversed ordering would leave Claude runtime linkage incomplete when the admin token banner prints. ASSUMPTION: Standalone mosaic gateway install uses a ClackPrompter (interactive) by default; the headless path is still triggered by MOSAIC_ASSUME_YES=1 or non-TTY stdin, and the stage functions detect this internally. ASSUMPTION: When runWizard reaches the gateway stages, state.mosaicHome is authoritative for GATEWAY_HOME resolution if it differs from the default — we set process.env.MOSAIC_GATEWAY_HOME before importing gateway modules so the constants resolve correctly. ASSUMPTION: Keeping backwards compatibility for runInstall({host, port, skipInstall}) is enough — no other internal caller exists. ASSUMPTION: Removing the session file is safe because the old bridge is at most a 10-minute window; there is no on-disk migration to do.

Test plan

  • gateway-config.spec.ts: fresh install writes .env + mosaic.config.json (mock fs + prompter); resume path reuses existing BETTER_AUTH_SECRET; headless path respects MOSAIC_STORAGE_TIER/MOSAIC_GATEWAY_PORT/etc.
  • gateway-bootstrap.spec.ts: calls /api/bootstrap/setup with collected creds (mock fetch); handles "already setup" branch; honors headless env vars; persists token via writeMeta.
  • Extend existing passing tests — no regressions in login.spec, recover-token.spec, rotate-token.spec.
  • Unified flow integration is covered at the stage-level; no new e2e test infra required.

Delivery cycle

plan (this entry) → code → typecheck/lint/format → test → codex review (~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted) → remediate → commit → ci-queue-wait push → push → PR → CI green → merge → close #427.

Remediation log (codex review rounds)

  • Round 1 — hooks opt-out did not remove an existing managed file; port override ignored on resume; headless errors swallowed. Fixed: hooks cleanup, portOverride honored, errors re-thrown.
  • Round 2 — headless stage failures exited 0; port override on decline-rerun mismatched; no default-path integration test. Fixed: process.exit(1) in headless, revert portOverride on decline, add unified-wizard.test.ts.
  • Round 3 — hooks removal too broad (would touch user-owned files); port override written to meta but not .env (drift); wizard swallowed errors. Fixed: cmp -s managed-file check, force regeneration when portOverride differs from saved port, re-throw unexpected errors.
  • Round 4 — port-override regeneration tripped the corrupt-partial-state guard (blocker); headless already-bootstrapped-with-no-local-token path reported failure instead of no-op; hooks byte-equality fragile across template updates. Fixed: introduce forcePortRegen flag bypassing the guard (with a dedicated spec test), headless rerun of already-bootstrapped gateway now returns { completed: true } (with spec coverage), hooks cleanup now checks for a stable "mosaic-managed": true marker embedded in the template (byte-equality remains as a fallback for legacy installs).
  • Round 5 codex review attempted but blocked by upstream usage limit (quota). Rerun after quota refresh if further findings appear; all round-4 findings are code-covered.