Compare commits
6 Commits
d5d7a9ab43
...
mosaic-v0.
| Author | SHA1 | Date | |
|---|---|---|---|
| a4c94d9a90 | |||
| cee838d22e | |||
| 732f8a49cf | |||
| be917e2496 | |||
| cd8b1f666d | |||
| 8fa5995bde |
@@ -1,72 +1,57 @@
|
||||
# Mission Manifest — CLI Unification & E2E First-Run
|
||||
# Mission Manifest — Install UX Hardening
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** cli-unification-20260404
|
||||
**Statement:** Transform the Mosaic CLI from a partially-duplicated, manually-assembled experience into a single cohesive entry point that installs, configures, and controls the entire Mosaic system. Every Mosaic package gets first-class CLI surface. The first-run experience works end-to-end with no manual stitching. Gateway token recovery is possible without the web UI. Opt-in telemetry uses the published telemetry clients.
|
||||
**ID:** install-ux-hardening-20260405
|
||||
**Statement:** Close the remaining gaps in the Mosaic Stack first-run and teardown experience uncovered by the post-`cli-unification` audit. A user MUST be able to cleanly uninstall the stack; the wizard MUST make security-sensitive surfaces visible (hooks, password entry); and CI/headless installs MUST NOT hang on interactive prompts. The longer-term goal is a single cohesive first-run flow that collapses `mosaic wizard` and `mosaic gateway install` into one state-bridged experience.
|
||||
**Phase:** Complete
|
||||
**Current Milestone:** —
|
||||
**Progress:** 8 / 8 milestones
|
||||
**Status:** completed
|
||||
**Last Updated:** 2026-04-05
|
||||
**Release:** [`mosaic-v0.0.24`](https://git.mosaicstack.dev/mosaicstack/mosaic-stack/releases/tag/mosaic-v0.0.24) (`@mosaicstack/mosaic@0.0.24`, alpha — stays in 0.0.x until GA)
|
||||
**Progress:** 3 / 3 milestones
|
||||
**Status:** complete
|
||||
**Last Updated:** 2026-04-05 (mission complete)
|
||||
**Parent Mission:** [cli-unification-20260404](./archive/missions/cli-unification-20260404/MISSION-MANIFEST.md) (complete)
|
||||
|
||||
## Context
|
||||
|
||||
Post-merge audit of `cli-unification-20260404` (AC-1, AC-6) validated that the first-run wizard covers first user, password, admin tokens, gateway instance config, skills, and SOUL.md/USER.md init. The audit surfaced six gaps, grouped into three tracks of independent value.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] AC-1: Fresh machine `bash <(curl …install.sh)` → single command lands on a working authenticated gateway with a usable admin token; no secondary manual wizards required
|
||||
- [x] AC-2: `mosaic --help` lists every sub-package as a top-level command and is alphabetized for readability
|
||||
- [x] AC-3: `mosaic auth`, `mosaic brain`, `mosaic forge`, `mosaic log`, `mosaic macp`, `mosaic memory`, `mosaic queue`, `mosaic storage`, `mosaic telemetry` each expose at least one working subcommand that exercises the underlying package
|
||||
- [x] AC-4: Gateway admin token can be rotated or recovered from the CLI alone — operator is never stranded because the web UI is inaccessible
|
||||
- [x] AC-5: `mosaic telemetry` uses the published `@mosaicstack/telemetry-client-js` (from the Gitea npm registry); local OTEL stays for wide-event logging / post-mortems; remote upload is opt-in and disabled by default
|
||||
- [x] AC-6: Install → wizard → gateway install → TUI verification flow is a single cohesive path with clear state transitions and no dead ends
|
||||
- [x] AC-7: `@mosaicstack/mosaic` is the sole `mosaic` binary owner; `@mosaicstack/cli` is gone from the repo and all docs
|
||||
- [x] AC-8: All milestones ship as merged PRs with green CI, closed issues, and updated release notes
|
||||
- [x] AC-1: `mosaic uninstall` (top-level) cleanly reverses every mutation made by `tools/install.sh` — framework data, npm CLI, nested stack deps, runtime asset injections in `~/.claude/`, npmrc scope mapping, PATH edits. Dry-run supported. `--keep-data` preserves memory + user files + gateway DB. (PR #429)
|
||||
- [x] AC-2: `curl … | bash -s -- --uninstall` works without requiring a functioning CLI. (PR #429)
|
||||
- [x] AC-3: Password entry in `bootstrapFirstUser` is masked (no plaintext echo); confirm prompt added. (PR #431)
|
||||
- [x] AC-4: Wizard has an explicit hooks stage that previews which hooks will be installed, asks for confirmation, and records the user's choice. `mosaic config hooks list|enable|disable` surface exists. (PR #431 — consent; PR #433 — finalize-stage gating now honors `state.hooks.accepted === false` end-to-end)
|
||||
- [x] AC-5: `runConfigWizard` and `bootstrapFirstUser` accept a headless path (env vars + `--yes`) so `tools/install.sh --yes` + `MOSAIC_ASSUME_YES=1` completes end-to-end in CI without TTY. (PR #431)
|
||||
- [x] AC-6: `mosaic wizard` and `mosaic gateway install` are collapsed into a single cohesive entry point with shared state; gateway install is now terminal stages 11 & 12 of `runWizard`, session-file bridge removed, `mosaic gateway install` preserved as a thin standalone wrapper. (PR #433)
|
||||
- [x] AC-7: All milestones shipped as merged PRs with green CI and closed issues. (PRs #429, #431, #433)
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------ | ------------------------------------------------------------------------ | ------ | ----------------------------------- | --------------------------------- | ---------- | ---------- |
|
||||
| 1 | cu-m01 | Kill legacy @mosaicstack/cli package | done | chore/remove-cli-package-duplicate | #398 | 2026-04-04 | 2026-04-04 |
|
||||
| 2 | cu-m02 | Archive stale mission state + scaffold new mission | done | docs/mission-cli-unification | #399 | 2026-04-04 | 2026-04-04 |
|
||||
| 3 | cu-m03 | Fix gateway bootstrap token recovery (server + CLI paths) | done | feat/gateway-token-recovery | #411, #414 | 2026-04-05 | 2026-04-05 |
|
||||
| 4 | cu-m04 | Alphabetize + group `mosaic --help` output | done | feat/help-sort + feat/mosaic-config | #402, #408 | 2026-04-05 | 2026-04-05 |
|
||||
| 5 | cu-m05 | Sub-package CLI surface (auth/brain/forge/log/macp/memory/queue/storage) | done | feat/mosaic-\*-cli (x9) | #403–#407, #410, #412, #413, #415 | 2026-04-05 | 2026-04-05 |
|
||||
| 6 | cu-m06 | `mosaic telemetry` — local OTEL + opt-in remote upload | done | feat/mosaic-telemetry | #417 | 2026-04-05 | 2026-04-05 |
|
||||
| 7 | cu-m07 | Unified first-run UX (install.sh → wizard → gateway → TUI) | done | feat/mosaic-first-run-ux | #418 | 2026-04-05 | 2026-04-05 |
|
||||
| 8 | cu-m08 | Docs refresh + release tag | done | docs/cli-unification-release-v0.1.0 | #419 | 2026-04-05 | 2026-04-05 |
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------- | --------------------------------------------------------- | ------ | ----------------------- | ----- | ---------- | ---------- |
|
||||
| 1 | IUH-M01 | `mosaic uninstall` — top-level teardown + shell wrapper | done | feat/mosaic-uninstall | #425 | 2026-04-05 | 2026-04-05 |
|
||||
| 2 | IUH-M02 | Wizard remediation — hooks visibility, pwd mask, headless | done | feat/wizard-remediation | #426 | 2026-04-05 | 2026-04-05 |
|
||||
| 3 | IUH-M03 | Unified first-run wizard (collapse wizard + gateway) | done | feat/unified-first-run | #427 | 2026-04-05 | 2026-04-05 |
|
||||
|
||||
## Deployment
|
||||
## Subagent Delegation Plan
|
||||
|
||||
| Target | URL | Method |
|
||||
| -------------------- | --------- | ----------------------------------------------- |
|
||||
| Local tier (default) | localhost | `mosaic gateway install` — pglite + local queue |
|
||||
| Team tier | any host | `mosaic gateway install` — PG + Valkey |
|
||||
| Docker Compose (dev) | localhost | `docker compose up` for PG/Valkey/OTEL/Jaeger |
|
||||
| Milestone | Recommended Tier | Rationale |
|
||||
| --------- | ---------------- | ---------------------------------------------------------------------- |
|
||||
| IUH-M01 | sonnet | Standard feature work — new command surface mirroring existing install |
|
||||
| IUH-M02 | sonnet | Small surgical fixes across 3-4 files |
|
||||
| IUH-M03 | opus | Architectural refactor; state machine design decisions |
|
||||
|
||||
## Coordination
|
||||
## Risks
|
||||
|
||||
- **Primary Agent:** claude-opus-4-6[1m]
|
||||
- **Sibling Agents:** sonnet (standard implementation), haiku (status/explore/verify), codex (coding-heavy tasks)
|
||||
- **Shared Contracts:** `docs/PRD.md` (existing v0.1.0 PRD — still the long-term target), this manifest, `docs/TASKS.md`, `docs/scratchpads/cli-unification-20260404.md`
|
||||
- **Reversal completeness** — runtime asset linking creates `.mosaic-bak-*` backups; uninstall must honor them vs. when to delete. Ambiguity without an install manifest.
|
||||
- **npm global nested deps** — `npm uninstall -g @mosaicstack/mosaic` removes nested `@mosaicstack/*`, but ownership conflicts with explicitly installed peer packages (`@mosaicstack/gateway`, `@mosaicstack/memory`) need test coverage.
|
||||
- **Headless bootstrap** — admin password via env var is a credential on disk; needs clear documentation that `MOSAIC_ADMIN_PASSWORD` is intended for CI-only and should be rotated post-install.
|
||||
|
||||
## Token Budget
|
||||
## Out of Scope
|
||||
|
||||
| Metric | Value |
|
||||
| ------ | ------ |
|
||||
| Budget | TBD |
|
||||
| Used | ~80K |
|
||||
| Mode | normal |
|
||||
|
||||
## Session History
|
||||
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | --------------- | ---------- | -------- | ---------------- | ------------------------------------------------------------ |
|
||||
| 1 | claude-opus-4-6 | 2026-04-04 | ~4h | context-budget | cu-m01 + cu-m02 merged (#398, #399); open questions resolved |
|
||||
| 2 | claude-opus-4-6 | 2026-04-05 | ~6h | mission-complete | cu-m03..cu-m08 all merged; mosaic-v0.1.0 released |
|
||||
|
||||
## Scratchpad
|
||||
|
||||
Path: `docs/scratchpads/cli-unification-20260404.md`
|
||||
- `mosaicstack.dev/install.sh` vanity URL (blocked on marketing site work)
|
||||
- Uninstall for the `@mosaicstack/gateway` database contents — delegated to `mosaic gateway uninstall` semantics already in place
|
||||
- Signature/checksum verification of install scripts
|
||||
|
||||
109
docs/TASKS.md
109
docs/TASKS.md
@@ -1,90 +1,41 @@
|
||||
# Tasks — CLI Unification & E2E First-Run
|
||||
# Tasks — Install UX Hardening
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
>
|
||||
> **Mission:** cli-unification-20260404
|
||||
> **Mission:** install-ux-hardening-20260405
|
||||
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `glm-5` | `—` (auto)
|
||||
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `—` (auto)
|
||||
|
||||
## Milestone 1 — Kill legacy @mosaicstack/cli (done)
|
||||
## Milestone 1 — `mosaic uninstall` (IUH-M01)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ----------------------------------------------------------------- | ----- | ----- | ---------------------------------- | ---------- | -------- | --------------------------- |
|
||||
| CU-01-01 | done | Delete packages/cli directory; update workspace + docs references | #398 | opus | chore/remove-cli-package-duplicate | — | 5K | Merged c39433c3. 6685 LOC−. |
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------- | ----- | ------ | --------------------- | ---------- | -------- | ------------------------------------------------------ |
|
||||
| IUH-01-01 | done | Design install manifest schema (`~/.config/mosaic/.install-manifest.json`) — what install writes on first success | #425 | sonnet | feat/mosaic-uninstall | — | 8K | v1 schema in `install-manifest.ts` |
|
||||
| IUH-01-02 | done | `mosaic uninstall` TS command: `--framework`, `--cli`, `--gateway`, `--all`, `--keep-data`, `--yes`, `--dry-run` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-01 | 25K | `uninstall.ts` |
|
||||
| IUH-01-03 | done | Reverse runtime asset linking in `~/.claude/` — restore `.mosaic-bak-*` if present, remove managed copies otherwise | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 12K | file list hardcoded from mosaic-link-runtime-assets |
|
||||
| IUH-01-04 | done | Reverse npmrc scope mapping and PATH edits made by `tools/install.sh` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 8K | npmrc reversed; no PATH edits found in v0.0.24 install |
|
||||
| IUH-01-05 | done | Shell fallback: `tools/install.sh --uninstall` path for users without a working CLI | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 10K | |
|
||||
| IUH-01-06 | done | Vitest coverage: dry-run output, `--all`, `--keep-data`, partial state, missing manifest | #425 | sonnet | feat/mosaic-uninstall | IUH-01-05 | 15K | 14 new tests, 170 total |
|
||||
| IUH-01-07 | done | Code review (independent) + remediation | #425 | sonnet | feat/mosaic-uninstall | IUH-01-06 | 5K | |
|
||||
| IUH-01-08 | done | PR open, CI green, review, merge to `main`, close issue | #425 | sonnet | feat/mosaic-uninstall | IUH-01-07 | 3K | PR #429, merge 25cada77 |
|
||||
|
||||
## Milestone 2 — Archive stale mission + scaffold new mission (done)
|
||||
## Milestone 2 — Wizard Remediation (IUH-M02)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ------------------------------------------------------------------ | ----- | ----- | ---------------------------- | ---------- | -------- | --------------------------------- |
|
||||
| CU-02-01 | done | Move stale MISSION-MANIFEST / TASKS / PRD-Harness to docs/archive/ | #399 | opus | docs/mission-cli-unification | CU-01-01 | 3K | Harness + storage missions done. |
|
||||
| CU-02-02 | done | Scaffold new MISSION-MANIFEST.md, TASKS.md, scratchpad | #399 | opus | docs/mission-cli-unification | CU-02-01 | 5K | This file + manifest + scratchpad |
|
||||
| CU-02-03 | done | PR review, merge, branch cleanup | #399 | opus | docs/mission-cli-unification | CU-02-02 | 2K | Merged as 6f15a84c |
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | -------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------------- | ---------- | -------- | ----------------------------------------------- |
|
||||
| IUH-02-01 | done | Password masking: replace plaintext `rl.question` in `bootstrapFirstUser` with masked TTY read + confirmation | #426 | sonnet | feat/wizard-remediation | IUH-01-08 | 8K | `prompter/masked-prompt.ts` |
|
||||
| IUH-02-02 | done | Hooks preview stage in wizard: show `framework/runtime/claude/hooks-config.json` entries + confirm prompt | #426 | sonnet | feat/wizard-remediation | IUH-02-01 | 12K | `stages/hooks-preview.ts`; finalize gating TODO |
|
||||
| IUH-02-03 | done | `mosaic config hooks list\|enable\|disable` subcommands | #426 | sonnet | feat/wizard-remediation | IUH-02-02 | 15K | `commands/config.ts` |
|
||||
| IUH-02-04 | done | Headless path: env-var driven `runConfigWizard` + `bootstrapFirstUser` (`MOSAIC_ASSUME_YES`, `MOSAIC_ADMIN_*`) | #426 | sonnet | feat/wizard-remediation | IUH-02-03 | 12K | |
|
||||
| IUH-02-05 | done | Tests + code review + PR merge | #426 | sonnet | feat/wizard-remediation | IUH-02-04 | 10K | PR #431, merge cd8b1f66 |
|
||||
|
||||
## Milestone 3 — Gateway bootstrap token recovery
|
||||
## Milestone 3 — Unified First-Run Wizard (IUH-M03)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ---------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----------------------------- |
|
||||
| CU-03-01 | done | Implementation plan for BetterAuth-cookie recovery flow (decision locked 2026-04-04) | — | opus | — | CU-02-03 | 4K | Design locked; plan-only task |
|
||||
| CU-03-02 | done | Server: add recovery/rotate endpoint on apps/gateway/src/admin (gated by design from CU-03-01) | — | sonnet | — | CU-03-01 | 12K | |
|
||||
| CU-03-03 | done | CLI: `mosaic gateway login` — interactive BetterAuth sign-in, persist session | — | sonnet | — | CU-03-02 | 10K | |
|
||||
| CU-03-04 | done | CLI: `mosaic gateway config rotate-token` — mint new admin token via authenticated API | — | sonnet | — | CU-03-03 | 8K | |
|
||||
| CU-03-05 | done | CLI: `mosaic gateway config recover-token` — execute the recovery flow from CU-03-01 | — | sonnet | — | CU-03-03 | 10K | |
|
||||
| CU-03-06 | done | Install UX: fix the "user exists, no token" dead-end in runInstall bootstrapFirstUser path | — | sonnet | — | CU-03-05 | 8K | |
|
||||
| CU-03-07 | done | Tests: integration tests for each recovery path (happy + error) | — | sonnet | — | CU-03-06 | 10K | |
|
||||
| CU-03-08 | done | Code review + remediation | — | haiku | — | CU-03-07 | 4K | |
|
||||
|
||||
## Milestone 4 — `mosaic --help` alphabetize + grouping
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ------------------------------- |
|
||||
| CU-04-01 | done | Enable `configureHelp({ sortSubcommands: true })` on root program and each subgroup | — | sonnet | — | CU-02-03 | 3K | |
|
||||
| CU-04-02 | done | Group commands into sections (Runtime, Gateway, Framework, Platform) in help output | — | sonnet | — | CU-04-01 | 5K | |
|
||||
| CU-04-03 | done | Verify help snapshots render readably; update any docs with stale output | — | haiku | — | CU-04-02 | 3K | |
|
||||
| CU-04-04 | done | Top-level `mosaic config` command — `show`, `get <key>`, `set <key> <val>`, `edit`, `path` — wraps packages/mosaic/src/config/config-service.ts (framework/agent config; distinct from `mosaic gateway config`) | — | sonnet | — | CU-02-03 | 10K | New scope (decision 2026-04-04) |
|
||||
| CU-04-05 | done | Tests + code review for CU-04-04 | — | haiku | — | CU-04-04 | 4K | |
|
||||
|
||||
## Milestone 5 — Sub-package CLI surface
|
||||
|
||||
> Pattern: each sub-package exports `register<Name>Command(program: Command)` co-located with the library code (proven by `@mosaicstack/quality-rails`). Wire into `packages/mosaic/src/cli.ts`.
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | --------------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ------------------- |
|
||||
| CU-05-01 | done | `mosaic forge` — subcommands: `run`, `status`, `resume`, `personas list` | — | sonnet | — | CU-02-03 | 18K | User priority |
|
||||
| CU-05-02 | done | `mosaic storage` — subcommands: `status`, `tier show`, `tier switch`, `export`, `import`, `migrate` | — | sonnet | — | CU-02-03 | 15K | |
|
||||
| CU-05-03 | done | `mosaic queue` — subcommands: `list`, `stats`, `pause/resume`, `jobs tail`, `drain` | — | sonnet | — | CU-02-03 | 12K | |
|
||||
| CU-05-04 | done | `mosaic memory` — subcommands: `search`, `stats`, `insights list`, `preferences list` | — | sonnet | — | CU-02-03 | 12K | |
|
||||
| CU-05-05 | done | `mosaic brain` — subcommands: `projects list/create`, `missions list`, `tasks list`, `conversations list` | — | sonnet | — | CU-02-03 | 15K | |
|
||||
| CU-05-06 | done | `mosaic auth` — subcommands: `users list/create/delete`, `sso list`, `sso test`, `sessions list` | — | sonnet | — | CU-03-03 | 15K | needs gateway login |
|
||||
| CU-05-07 | done | `mosaic log` — subcommands: `tail`, `search`, `export`, `level <level>` | — | sonnet | — | CU-02-03 | 10K | |
|
||||
| CU-05-08 | done | `mosaic macp` — subcommands: `tasks list`, `submit`, `gate`, `events tail` | — | sonnet | — | CU-02-03 | 12K | |
|
||||
| CU-05-09 | done | Wire all eight `register<Name>Command` calls into packages/mosaic/src/cli.ts | — | haiku | — | CU-05-01…8 | 3K | |
|
||||
| CU-05-10 | done | Integration test: `mosaic <cmd> --help` exits 0 for every new command | — | haiku | — | CU-05-09 | 5K | |
|
||||
|
||||
## Milestone 6 — `mosaic telemetry`
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ---------------------------------------------- |
|
||||
| CU-06-01 | done | Add `@mosaicstack/telemetry-client-js` as dependency of `@mosaicstack/mosaic` from Gitea registry | — | sonnet | — | CU-02-03 | 3K | |
|
||||
| CU-06-02 | done | `mosaic telemetry local` — status, tail, Jaeger link (wraps existing apps/gateway/src/tracing.ts) | — | sonnet | — | CU-06-01 | 8K | |
|
||||
| CU-06-03 | done | `mosaic telemetry` — status, opt-in, opt-out, test, upload (uses telemetry-client-js) | — | sonnet | — | CU-06-01 | 12K | Dry-run mode when server endpoint not yet live |
|
||||
| CU-06-04 | done | Persistent consent state in mosaic config; disabled by default | — | sonnet | — | CU-06-03 | 5K | |
|
||||
| CU-06-05 | done | Tests + code review | — | haiku | — | CU-06-04 | 5K | |
|
||||
|
||||
## Milestone 7 — Unified first-run UX
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ---------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----- |
|
||||
| CU-07-01 | done | tools/install.sh: after npm install, hand off to `mosaic wizard` then `mosaic gateway install` | — | sonnet | — | CU-03-06 | 10K | |
|
||||
| CU-07-02 | done | `mosaic wizard` and `mosaic gateway install` coordination: shared state, no duplicate prompts | — | sonnet | — | CU-07-01 | 12K | |
|
||||
| CU-07-03 | done | Post-install verification step: "gateway healthy, tui connects, admin token on file" | — | sonnet | — | CU-07-02 | 8K | |
|
||||
| CU-07-04 | done | End-to-end test on a clean container from scratch | — | haiku | — | CU-07-03 | 8K | |
|
||||
|
||||
## Milestone 8 — Docs + release
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ---------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----- |
|
||||
| CU-08-01 | done | Update README.md with new command tree, install flow, and feature list | — | sonnet | — | CU-07-04 | 8K | |
|
||||
| CU-08-02 | done | Update docs/guides/user-guide.md with all new sub-package commands | — | sonnet | — | CU-08-01 | 10K | |
|
||||
| CU-08-03 | done | Version bump `@mosaicstack/mosaic`, publish to Gitea registry | — | opus | — | CU-08-02 | 3K | |
|
||||
| CU-08-04 | done | Release notes, tag `v0.1.0-rc.N`, publish release on Gitea | — | opus | — | CU-08-03 | 3K | |
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ----------------------------------------------------------------------------------------------------------- | ----- | ----- | ---------------------- | ---------- | -------- | ---------------------------------- |
|
||||
| IUH-03-01 | done | Design doc: unified state machine; decide whether `mosaic gateway install` becomes an internal wizard stage | #427 | opus | feat/unified-first-run | IUH-02-05 | 10K | scratchpad Session 5 |
|
||||
| IUH-03-02 | done | Refactor `runWizard` to invoke gateway install as a stage; drop the 10-minute session-file bridge | #427 | opus | feat/unified-first-run | IUH-03-01 | 25K | stages 11 & 12; bridge removed |
|
||||
| IUH-03-03 | done | Preserve backward-compat: `mosaic gateway install` still works as a standalone entry point | #427 | opus | feat/unified-first-run | IUH-03-02 | 10K | thin wrapper over stages |
|
||||
| IUH-03-04 | done | Tests + code review + PR merge | #427 | opus | feat/unified-first-run | IUH-03-03 | 12K | PR #433, merge 732f8a49; +15 tests |
|
||||
| IUH-03-05 | done | Bonus: honor `state.hooks.accepted` in finalize stage (closes M02 follow-up) | #427 | opus | feat/unified-first-run | IUH-03-04 | 5K | MOSAIC_SKIP_CLAUDE_HOOKS env flag |
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Mission Manifest — CLI Unification & E2E First-Run
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** cli-unification-20260404
|
||||
**Statement:** Transform the Mosaic CLI from a partially-duplicated, manually-assembled experience into a single cohesive entry point that installs, configures, and controls the entire Mosaic system. Every Mosaic package gets first-class CLI surface. The first-run experience works end-to-end with no manual stitching. Gateway token recovery is possible without the web UI. Opt-in telemetry uses the published telemetry clients.
|
||||
**Phase:** Complete
|
||||
**Current Milestone:** —
|
||||
**Progress:** 8 / 8 milestones
|
||||
**Status:** completed
|
||||
**Last Updated:** 2026-04-05
|
||||
**Release:** [`mosaic-v0.0.24`](https://git.mosaicstack.dev/mosaicstack/mosaic-stack/releases/tag/mosaic-v0.0.24) (`@mosaicstack/mosaic@0.0.24`, alpha — stays in 0.0.x until GA)
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] AC-1: Fresh machine `bash <(curl …install.sh)` → single command lands on a working authenticated gateway with a usable admin token; no secondary manual wizards required
|
||||
- [x] AC-2: `mosaic --help` lists every sub-package as a top-level command and is alphabetized for readability
|
||||
- [x] AC-3: `mosaic auth`, `mosaic brain`, `mosaic forge`, `mosaic log`, `mosaic macp`, `mosaic memory`, `mosaic queue`, `mosaic storage`, `mosaic telemetry` each expose at least one working subcommand that exercises the underlying package
|
||||
- [x] AC-4: Gateway admin token can be rotated or recovered from the CLI alone — operator is never stranded because the web UI is inaccessible
|
||||
- [x] AC-5: `mosaic telemetry` uses the published `@mosaicstack/telemetry-client-js` (from the Gitea npm registry); local OTEL stays for wide-event logging / post-mortems; remote upload is opt-in and disabled by default
|
||||
- [x] AC-6: Install → wizard → gateway install → TUI verification flow is a single cohesive path with clear state transitions and no dead ends
|
||||
- [x] AC-7: `@mosaicstack/mosaic` is the sole `mosaic` binary owner; `@mosaicstack/cli` is gone from the repo and all docs
|
||||
- [x] AC-8: All milestones ship as merged PRs with green CI, closed issues, and updated release notes
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------ | ------------------------------------------------------------------------ | ------ | ----------------------------------- | --------------------------------- | ---------- | ---------- |
|
||||
| 1 | cu-m01 | Kill legacy @mosaicstack/cli package | done | chore/remove-cli-package-duplicate | #398 | 2026-04-04 | 2026-04-04 |
|
||||
| 2 | cu-m02 | Archive stale mission state + scaffold new mission | done | docs/mission-cli-unification | #399 | 2026-04-04 | 2026-04-04 |
|
||||
| 3 | cu-m03 | Fix gateway bootstrap token recovery (server + CLI paths) | done | feat/gateway-token-recovery | #411, #414 | 2026-04-05 | 2026-04-05 |
|
||||
| 4 | cu-m04 | Alphabetize + group `mosaic --help` output | done | feat/help-sort + feat/mosaic-config | #402, #408 | 2026-04-05 | 2026-04-05 |
|
||||
| 5 | cu-m05 | Sub-package CLI surface (auth/brain/forge/log/macp/memory/queue/storage) | done | feat/mosaic-\*-cli (x9) | #403–#407, #410, #412, #413, #415 | 2026-04-05 | 2026-04-05 |
|
||||
| 6 | cu-m06 | `mosaic telemetry` — local OTEL + opt-in remote upload | done | feat/mosaic-telemetry | #417 | 2026-04-05 | 2026-04-05 |
|
||||
| 7 | cu-m07 | Unified first-run UX (install.sh → wizard → gateway → TUI) | done | feat/mosaic-first-run-ux | #418 | 2026-04-05 | 2026-04-05 |
|
||||
| 8 | cu-m08 | Docs refresh + release tag | done | docs/cli-unification-release-v0.1.0 | #419 | 2026-04-05 | 2026-04-05 |
|
||||
|
||||
## Deployment
|
||||
|
||||
| Target | URL | Method |
|
||||
| -------------------- | --------- | ----------------------------------------------- |
|
||||
| Local tier (default) | localhost | `mosaic gateway install` — pglite + local queue |
|
||||
| Team tier | any host | `mosaic gateway install` — PG + Valkey |
|
||||
| Docker Compose (dev) | localhost | `docker compose up` for PG/Valkey/OTEL/Jaeger |
|
||||
|
||||
## Coordination
|
||||
|
||||
- **Primary Agent:** claude-opus-4-6[1m]
|
||||
- **Sibling Agents:** sonnet (standard implementation), haiku (status/explore/verify), codex (coding-heavy tasks)
|
||||
- **Shared Contracts:** `docs/PRD.md` (existing v0.1.0 PRD — still the long-term target), this manifest, `docs/TASKS.md`, `docs/scratchpads/cli-unification-20260404.md`
|
||||
|
||||
## Token Budget
|
||||
|
||||
| Metric | Value |
|
||||
| ------ | ------ |
|
||||
| Budget | TBD |
|
||||
| Used | ~80K |
|
||||
| Mode | normal |
|
||||
|
||||
## Session History
|
||||
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | --------------- | ---------- | -------- | ---------------- | ------------------------------------------------------------ |
|
||||
| 1 | claude-opus-4-6 | 2026-04-04 | ~4h | context-budget | cu-m01 + cu-m02 merged (#398, #399); open questions resolved |
|
||||
| 2 | claude-opus-4-6 | 2026-04-05 | ~6h | mission-complete | cu-m03..cu-m08 all merged; mosaic-v0.1.0 released |
|
||||
|
||||
## Scratchpad
|
||||
|
||||
Path: `docs/scratchpads/cli-unification-20260404.md`
|
||||
90
docs/archive/missions/cli-unification-20260404/TASKS.md
Normal file
90
docs/archive/missions/cli-unification-20260404/TASKS.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Tasks — CLI Unification & E2E First-Run
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
>
|
||||
> **Mission:** cli-unification-20260404
|
||||
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `glm-5` | `—` (auto)
|
||||
|
||||
## Milestone 1 — Kill legacy @mosaicstack/cli (done)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ----------------------------------------------------------------- | ----- | ----- | ---------------------------------- | ---------- | -------- | --------------------------- |
|
||||
| CU-01-01 | done | Delete packages/cli directory; update workspace + docs references | #398 | opus | chore/remove-cli-package-duplicate | — | 5K | Merged c39433c3. 6685 LOC−. |
|
||||
|
||||
## Milestone 2 — Archive stale mission + scaffold new mission (done)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ------------------------------------------------------------------ | ----- | ----- | ---------------------------- | ---------- | -------- | --------------------------------- |
|
||||
| CU-02-01 | done | Move stale MISSION-MANIFEST / TASKS / PRD-Harness to docs/archive/ | #399 | opus | docs/mission-cli-unification | CU-01-01 | 3K | Harness + storage missions done. |
|
||||
| CU-02-02 | done | Scaffold new MISSION-MANIFEST.md, TASKS.md, scratchpad | #399 | opus | docs/mission-cli-unification | CU-02-01 | 5K | This file + manifest + scratchpad |
|
||||
| CU-02-03 | done | PR review, merge, branch cleanup | #399 | opus | docs/mission-cli-unification | CU-02-02 | 2K | Merged as 6f15a84c |
|
||||
|
||||
## Milestone 3 — Gateway bootstrap token recovery
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ---------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----------------------------- |
|
||||
| CU-03-01 | done | Implementation plan for BetterAuth-cookie recovery flow (decision locked 2026-04-04) | — | opus | — | CU-02-03 | 4K | Design locked; plan-only task |
|
||||
| CU-03-02 | done | Server: add recovery/rotate endpoint on apps/gateway/src/admin (gated by design from CU-03-01) | — | sonnet | — | CU-03-01 | 12K | |
|
||||
| CU-03-03 | done | CLI: `mosaic gateway login` — interactive BetterAuth sign-in, persist session | — | sonnet | — | CU-03-02 | 10K | |
|
||||
| CU-03-04 | done | CLI: `mosaic gateway config rotate-token` — mint new admin token via authenticated API | — | sonnet | — | CU-03-03 | 8K | |
|
||||
| CU-03-05 | done | CLI: `mosaic gateway config recover-token` — execute the recovery flow from CU-03-01 | — | sonnet | — | CU-03-03 | 10K | |
|
||||
| CU-03-06 | done | Install UX: fix the "user exists, no token" dead-end in runInstall bootstrapFirstUser path | — | sonnet | — | CU-03-05 | 8K | |
|
||||
| CU-03-07 | done | Tests: integration tests for each recovery path (happy + error) | — | sonnet | — | CU-03-06 | 10K | |
|
||||
| CU-03-08 | done | Code review + remediation | — | haiku | — | CU-03-07 | 4K | |
|
||||
|
||||
## Milestone 4 — `mosaic --help` alphabetize + grouping
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ------------------------------- |
|
||||
| CU-04-01 | done | Enable `configureHelp({ sortSubcommands: true })` on root program and each subgroup | — | sonnet | — | CU-02-03 | 3K | |
|
||||
| CU-04-02 | done | Group commands into sections (Runtime, Gateway, Framework, Platform) in help output | — | sonnet | — | CU-04-01 | 5K | |
|
||||
| CU-04-03 | done | Verify help snapshots render readably; update any docs with stale output | — | haiku | — | CU-04-02 | 3K | |
|
||||
| CU-04-04 | done | Top-level `mosaic config` command — `show`, `get <key>`, `set <key> <val>`, `edit`, `path` — wraps packages/mosaic/src/config/config-service.ts (framework/agent config; distinct from `mosaic gateway config`) | — | sonnet | — | CU-02-03 | 10K | New scope (decision 2026-04-04) |
|
||||
| CU-04-05 | done | Tests + code review for CU-04-04 | — | haiku | — | CU-04-04 | 4K | |
|
||||
|
||||
## Milestone 5 — Sub-package CLI surface
|
||||
|
||||
> Pattern: each sub-package exports `register<Name>Command(program: Command)` co-located with the library code (proven by `@mosaicstack/quality-rails`). Wire into `packages/mosaic/src/cli.ts`.
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | --------------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ------------------- |
|
||||
| CU-05-01 | done | `mosaic forge` — subcommands: `run`, `status`, `resume`, `personas list` | — | sonnet | — | CU-02-03 | 18K | User priority |
|
||||
| CU-05-02 | done | `mosaic storage` — subcommands: `status`, `tier show`, `tier switch`, `export`, `import`, `migrate` | — | sonnet | — | CU-02-03 | 15K | |
|
||||
| CU-05-03 | done | `mosaic queue` — subcommands: `list`, `stats`, `pause/resume`, `jobs tail`, `drain` | — | sonnet | — | CU-02-03 | 12K | |
|
||||
| CU-05-04 | done | `mosaic memory` — subcommands: `search`, `stats`, `insights list`, `preferences list` | — | sonnet | — | CU-02-03 | 12K | |
|
||||
| CU-05-05 | done | `mosaic brain` — subcommands: `projects list/create`, `missions list`, `tasks list`, `conversations list` | — | sonnet | — | CU-02-03 | 15K | |
|
||||
| CU-05-06 | done | `mosaic auth` — subcommands: `users list/create/delete`, `sso list`, `sso test`, `sessions list` | — | sonnet | — | CU-03-03 | 15K | needs gateway login |
|
||||
| CU-05-07 | done | `mosaic log` — subcommands: `tail`, `search`, `export`, `level <level>` | — | sonnet | — | CU-02-03 | 10K | |
|
||||
| CU-05-08 | done | `mosaic macp` — subcommands: `tasks list`, `submit`, `gate`, `events tail` | — | sonnet | — | CU-02-03 | 12K | |
|
||||
| CU-05-09 | done | Wire all eight `register<Name>Command` calls into packages/mosaic/src/cli.ts | — | haiku | — | CU-05-01…8 | 3K | |
|
||||
| CU-05-10 | done | Integration test: `mosaic <cmd> --help` exits 0 for every new command | — | haiku | — | CU-05-09 | 5K | |
|
||||
|
||||
## Milestone 6 — `mosaic telemetry`
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ---------------------------------------------- |
|
||||
| CU-06-01 | done | Add `@mosaicstack/telemetry-client-js` as dependency of `@mosaicstack/mosaic` from Gitea registry | — | sonnet | — | CU-02-03 | 3K | |
|
||||
| CU-06-02 | done | `mosaic telemetry local` — status, tail, Jaeger link (wraps existing apps/gateway/src/tracing.ts) | — | sonnet | — | CU-06-01 | 8K | |
|
||||
| CU-06-03 | done | `mosaic telemetry` — status, opt-in, opt-out, test, upload (uses telemetry-client-js) | — | sonnet | — | CU-06-01 | 12K | Dry-run mode when server endpoint not yet live |
|
||||
| CU-06-04 | done | Persistent consent state in mosaic config; disabled by default | — | sonnet | — | CU-06-03 | 5K | |
|
||||
| CU-06-05 | done | Tests + code review | — | haiku | — | CU-06-04 | 5K | |
|
||||
|
||||
## Milestone 7 — Unified first-run UX
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ---------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----- |
|
||||
| CU-07-01 | done | tools/install.sh: after npm install, hand off to `mosaic wizard` then `mosaic gateway install` | — | sonnet | — | CU-03-06 | 10K | |
|
||||
| CU-07-02 | done | `mosaic wizard` and `mosaic gateway install` coordination: shared state, no duplicate prompts | — | sonnet | — | CU-07-01 | 12K | |
|
||||
| CU-07-03 | done | Post-install verification step: "gateway healthy, tui connects, admin token on file" | — | sonnet | — | CU-07-02 | 8K | |
|
||||
| CU-07-04 | done | End-to-end test on a clean container from scratch | — | haiku | — | CU-07-03 | 8K | |
|
||||
|
||||
## Milestone 8 — Docs + release
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ---------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----- |
|
||||
| CU-08-01 | done | Update README.md with new command tree, install flow, and feature list | — | sonnet | — | CU-07-04 | 8K | |
|
||||
| CU-08-02 | done | Update docs/guides/user-guide.md with all new sub-package commands | — | sonnet | — | CU-08-01 | 10K | |
|
||||
| CU-08-03 | done | Version bump `@mosaicstack/mosaic`, publish to Gitea registry | — | opus | — | CU-08-02 | 3K | |
|
||||
| CU-08-04 | done | Release notes, tag `v0.1.0-rc.N`, publish release on Gitea | — | opus | — | CU-08-03 | 3K | |
|
||||
@@ -68,6 +68,40 @@ ASSUMPTION: No PATH modifications in current install.sh (v0.0.24). Framework v0/
|
||||
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
|
||||
@@ -122,3 +156,175 @@ ASSUMPTION: The `hooks` subcommands under `config` operate on `~/.claude/hooks-c
|
||||
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 `runConfigWizard` → `stages/gateway-config.ts`
|
||||
- Extract `bootstrapFirstUser` → `stages/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.
|
||||
|
||||
---
|
||||
|
||||
## Session 6 — 2026-04-05 (orchestrator close-out) — MISSION COMPLETE
|
||||
|
||||
### IUH-M03 completion summary (reported by opus delivery agent)
|
||||
|
||||
- **PR:** #433 merged as `732f8a49`
|
||||
- **CI:** Woodpecker green on final rebased commit `f3d5ef8d`
|
||||
- **Issue:** #427 closed with summary comment
|
||||
- **Tests:** 219 passing (+15 net new), 24 files
|
||||
- **Codex review:** 4 rounds applied and remediated; round 5 blocked by upstream quota — no known outstanding findings
|
||||
|
||||
### What shipped in M03
|
||||
|
||||
- NEW stages: `stages/gateway-config.ts`, `stages/gateway-bootstrap.ts` (extracted from the old monolithic `gateway/install.ts`)
|
||||
- NEW integration test: `__tests__/integration/unified-wizard.test.ts`
|
||||
- `runWizard` now has 12 stages — gateway config + bootstrap are terminal stages 11 & 12
|
||||
- 10-minute `$XDG_RUNTIME_DIR/mosaic-install-state.json` session-file bridge **deleted**
|
||||
- `mosaic gateway install` rewritten as a thin standalone wrapper invoking the same two stages — backward-compat preserved
|
||||
- `WizardState.gateway?` slice carries host/port/tier/admin/adminTokenIssued across stages
|
||||
- `tools/install.sh` single unified `mosaic wizard` call — no more two-phase launch
|
||||
- **Bonus scoped in:** finalize stage honors `state.hooks.accepted === false` via `MOSAIC_SKIP_CLAUDE_HOOKS=1`; `mosaic-link-runtime-assets` honors the flag; Mosaic-managed detection now uses a stable `"mosaic-managed": true` marker in `hooks-config.json` with byte-equality fallback for legacy installs. **Closes the M02 follow-up.**
|
||||
|
||||
### Mission status — ALL DONE
|
||||
|
||||
| AC | Status | PR |
|
||||
| ---- | ------ | ---------------------------------------------------- |
|
||||
| AC-1 | ✓ | #429 |
|
||||
| AC-2 | ✓ | #429 |
|
||||
| AC-3 | ✓ | #431 |
|
||||
| AC-4 | ✓ | #431 + #433 (gating) |
|
||||
| AC-5 | ✓ | #431 |
|
||||
| AC-6 | ✓ | #433 |
|
||||
| AC-7 | ✓ | #429, #431, #433 all merged, CI green, issues closed |
|
||||
|
||||
### Follow-ups for future work (not blocking mission close)
|
||||
|
||||
1. **`pr-ci-wait.sh` vs Woodpecker**: wrapper reports `state=unknown` because Woodpecker doesn't publish to Gitea's combined-status endpoint. Worker used `tea pr` CI glyphs as authoritative. Pre-existing tooling gap — worth a separate tooling-team issue.
|
||||
2. **`issue-create.sh` / `pr-create.sh` wrapper `eval` bug with multiline bodies** — hit by M01, M02, M03 workers. All fell back to Gitea REST API. Needs wrapper fix.
|
||||
3. **Codex review round 5** — attempted but blocked by upstream quota. Rerun after quota resets to confirm nothing else surfaces.
|
||||
4. **Pi settings.json reversal** — deferred from M01; install manifest schema should be extended to track Pi settings mutations for reversal.
|
||||
5. **`cli-smoke.spec.ts` pre-existing failure** — `@mosaicstack/brain` resolution in Vitest. Unrelated. Worth a separate issue.
|
||||
|
||||
### Next steps (orchestrator)
|
||||
|
||||
1. This scratchpad + MISSION-MANIFEST.md + TASKS.md updates → final docs PR
|
||||
2. After merge: create release tag per framework rule (milestone/mission completion = release tag + repository release)
|
||||
3. Archive mission docs under `docs/archive/missions/install-ux-hardening-20260405/` once the tag is published
|
||||
|
||||
@@ -49,6 +49,7 @@ describe('Full Wizard (headless)', () => {
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGateway: true,
|
||||
});
|
||||
|
||||
const soulPath = join(tmpDir, 'SOUL.md');
|
||||
@@ -75,6 +76,7 @@ describe('Full Wizard (headless)', () => {
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGateway: true,
|
||||
});
|
||||
|
||||
const userPath = join(tmpDir, 'USER.md');
|
||||
@@ -97,6 +99,7 @@ describe('Full Wizard (headless)', () => {
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGateway: true,
|
||||
cliOverrides: {
|
||||
soul: {
|
||||
agentName: 'FromCLI',
|
||||
|
||||
146
packages/mosaic/__tests__/integration/unified-wizard.test.ts
Normal file
146
packages/mosaic/__tests__/integration/unified-wizard.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Unified wizard integration test — exercises the `skipGateway: false` code
|
||||
* path so that wiring between `runWizard` and the two gateway stages is
|
||||
* covered. The gateway stages themselves are mocked (they require a real
|
||||
* daemon + network) but the dynamic imports and option plumbing are real.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, cpSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { createConfigService } from '../../src/config/config-service.js';
|
||||
|
||||
const gatewayConfigMock = vi.fn();
|
||||
const gatewayBootstrapMock = vi.fn();
|
||||
|
||||
vi.mock('../../src/stages/gateway-config.js', () => ({
|
||||
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
|
||||
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
|
||||
}));
|
||||
|
||||
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
|
||||
import { runWizard } from '../../src/wizard.js';
|
||||
|
||||
describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
let tmpDir: string;
|
||||
const repoRoot = join(import.meta.dirname, '..', '..');
|
||||
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
const originalAssumeYes = process.env['MOSAIC_ASSUME_YES'];
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-unified-wizard-'));
|
||||
const candidates = [join(repoRoot, 'framework', 'templates'), join(repoRoot, 'templates')];
|
||||
for (const templatesDir of candidates) {
|
||||
if (existsSync(templatesDir)) {
|
||||
cpSync(templatesDir, join(tmpDir, 'templates'), { recursive: true });
|
||||
break;
|
||||
}
|
||||
}
|
||||
gatewayConfigMock.mockReset();
|
||||
gatewayBootstrapMock.mockReset();
|
||||
// Pretend we're on an interactive TTY so the wizard's headless-abort
|
||||
// branch does not call `process.exit(1)` during these tests.
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
if (originalAssumeYes === undefined) {
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
} else {
|
||||
process.env['MOSAIC_ASSUME_YES'] = originalAssumeYes;
|
||||
}
|
||||
});
|
||||
|
||||
it('invokes the gateway config + bootstrap stages by default', async () => {
|
||||
gatewayConfigMock.mockResolvedValue({ ready: true, host: 'localhost', port: 14242 });
|
||||
gatewayBootstrapMock.mockResolvedValue({ completed: true });
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
'Your timezone': 'UTC',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
gatewayHost: 'localhost',
|
||||
gatewayPort: 14242,
|
||||
skipGatewayNpmInstall: true,
|
||||
});
|
||||
|
||||
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||
expect(gatewayBootstrapMock).toHaveBeenCalledTimes(1);
|
||||
const configCall = gatewayConfigMock.mock.calls[0];
|
||||
expect(configCall[2]).toMatchObject({
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
});
|
||||
const bootstrapCall = gatewayBootstrapMock.mock.calls[0];
|
||||
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
||||
});
|
||||
|
||||
it('does not invoke bootstrap when config stage reports not ready', async () => {
|
||||
gatewayConfigMock.mockResolvedValue({ ready: false });
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
'Your timezone': 'UTC',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGatewayNpmInstall: true,
|
||||
});
|
||||
|
||||
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('respects skipGateway: true', async () => {
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
'Your timezone': 'UTC',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGateway: true,
|
||||
});
|
||||
|
||||
expect(gatewayConfigMock).not.toHaveBeenCalled();
|
||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "Universal Atomic Code Implementer Hooks",
|
||||
"description": "Comprehensive hooks configuration for quality enforcement and automatic remediation",
|
||||
"mosaic-managed": true,
|
||||
"version": "1.0.0",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
|
||||
@@ -70,11 +70,45 @@ for p in "${legacy_paths[@]}"; do
|
||||
done
|
||||
|
||||
# Claude-specific runtime files (settings, hooks — NOT CLAUDE.md which is now a thin pointer)
|
||||
# When MOSAIC_SKIP_CLAUDE_HOOKS=1 is set (user declined hooks in the wizard
|
||||
# preview stage), skip hooks-config.json but still copy the other runtime
|
||||
# files so Claude still gets CLAUDE.md/settings.json/context7 guidance.
|
||||
for runtime_file in \
|
||||
CLAUDE.md \
|
||||
settings.json \
|
||||
hooks-config.json \
|
||||
context7-integration.md; do
|
||||
if [[ "$runtime_file" == "hooks-config.json" ]] && [[ "${MOSAIC_SKIP_CLAUDE_HOOKS:-0}" == "1" ]]; then
|
||||
echo "[mosaic-link] Skipping hooks-config.json (user declined in wizard)"
|
||||
# An existing ~/.claude/hooks-config.json that we previously installed
|
||||
# is identified by one of:
|
||||
# 1. It's a symlink (legacy symlink-mode install)
|
||||
# 2. It contains the `mosaic-managed` marker string we embed in the
|
||||
# template (survives template updates unlike byte-equality)
|
||||
# 3. It is byte-identical to the current Mosaic template (fallback
|
||||
# for templates that pre-date the marker)
|
||||
# Anything else is user-owned and we must leave it alone.
|
||||
existing_hooks="$HOME/.claude/hooks-config.json"
|
||||
mosaic_hooks_src="$MOSAIC_HOME/runtime/claude/hooks-config.json"
|
||||
if [[ -L "$existing_hooks" ]]; then
|
||||
rm -f "$existing_hooks"
|
||||
echo "[mosaic-link] Removed previously-linked Mosaic hooks-config.json (was symlink)"
|
||||
elif [[ -f "$existing_hooks" ]]; then
|
||||
is_mosaic_managed=0
|
||||
if grep -q 'mosaic-managed' "$existing_hooks" 2>/dev/null; then
|
||||
is_mosaic_managed=1
|
||||
elif [[ -f "$mosaic_hooks_src" ]] && cmp -s "$existing_hooks" "$mosaic_hooks_src"; then
|
||||
is_mosaic_managed=1
|
||||
fi
|
||||
if [[ "$is_mosaic_managed" == "1" ]]; then
|
||||
mv "$existing_hooks" "${existing_hooks}.mosaic-bak-${backup_stamp}"
|
||||
echo "[mosaic-link] Removed previously-linked Mosaic hooks-config.json (backup at ${existing_hooks}.mosaic-bak-${backup_stamp})"
|
||||
else
|
||||
echo "[mosaic-link] Leaving existing non-Mosaic hooks-config.json in place"
|
||||
fi
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
src="$MOSAIC_HOME/runtime/claude/$runtime_file"
|
||||
[[ -f "$src" ]] || continue
|
||||
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaicstack/mosaic",
|
||||
"version": "0.0.24",
|
||||
"version": "0.0.25",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
|
||||
@@ -1,60 +1,21 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
/**
|
||||
* Thin wrapper over the unified first-run stages.
|
||||
*
|
||||
* `mosaic gateway install` is kept as a standalone entry point for users who
|
||||
* already went through `mosaic wizard` and only need to (re)configure the
|
||||
* gateway daemon. It builds a minimal `WizardState`, invokes
|
||||
* `gatewayConfigStage` and `gatewayBootstrapStage` directly, and returns.
|
||||
*
|
||||
* The heavy lifting — prompts, env writes, daemon lifecycle, bootstrap POST —
|
||||
* lives in `packages/mosaic/src/stages/gateway-config.ts` and
|
||||
* `packages/mosaic/src/stages/gateway-bootstrap.ts` so that the same code
|
||||
* path runs under both the unified wizard and this standalone command.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { createInterface } from 'node:readline';
|
||||
import type { GatewayMeta } from './daemon.js';
|
||||
import { promptMaskedConfirmed } from '../../prompter/masked-prompt.js';
|
||||
import {
|
||||
ENV_FILE,
|
||||
GATEWAY_HOME,
|
||||
LOG_FILE,
|
||||
ensureDirs,
|
||||
getDaemonPid,
|
||||
installGatewayPackage,
|
||||
readMeta,
|
||||
resolveGatewayEntry,
|
||||
startDaemon,
|
||||
stopDaemon,
|
||||
waitForHealth,
|
||||
writeMeta,
|
||||
getInstalledGatewayVersion,
|
||||
} from './daemon.js';
|
||||
|
||||
const MOSAIC_CONFIG_FILE = join(GATEWAY_HOME, 'mosaic.config.json');
|
||||
|
||||
// ─── Wizard session state (transient, CU-07-02) ──────────────────────────────
|
||||
|
||||
const INSTALL_STATE_FILE = join(
|
||||
process.env['XDG_RUNTIME_DIR'] ?? process.env['TMPDIR'] ?? tmpdir(),
|
||||
'mosaic-install-state.json',
|
||||
);
|
||||
|
||||
interface InstallSessionState {
|
||||
wizardCompletedAt: string;
|
||||
mosaicHome: string;
|
||||
}
|
||||
|
||||
function readInstallState(): InstallSessionState | null {
|
||||
if (!existsSync(INSTALL_STATE_FILE)) return null;
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(INSTALL_STATE_FILE, 'utf-8')) as InstallSessionState;
|
||||
// Only trust state that is < 10 minutes old
|
||||
const age = Date.now() - new Date(raw.wizardCompletedAt).getTime();
|
||||
if (age > 10 * 60 * 1000) return null;
|
||||
return raw;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearInstallState(): void {
|
||||
try {
|
||||
unlinkSync(INSTALL_STATE_FILE);
|
||||
} catch {
|
||||
// Ignore — file may already be gone
|
||||
}
|
||||
}
|
||||
import { ClackPrompter } from '../../prompter/clack-prompter.js';
|
||||
import type { WizardState } from '../../types.js';
|
||||
|
||||
interface InstallOpts {
|
||||
host: string;
|
||||
@@ -62,563 +23,85 @@ interface InstallOpts {
|
||||
skipInstall?: boolean;
|
||||
}
|
||||
|
||||
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the process should skip interactive prompts.
|
||||
* Headless mode is activated by `MOSAIC_ASSUME_YES=1` or when stdin is not a
|
||||
* TTY (piped/redirected — typical in CI and Docker).
|
||||
*/
|
||||
function isHeadless(): boolean {
|
||||
function isHeadlessRun(): boolean {
|
||||
return process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
}
|
||||
|
||||
export async function runInstall(opts: InstallOpts): Promise<void> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
await doInstall(rl, opts);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
const mosaicHome = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
|
||||
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
|
||||
// CU-07-02: Check for a fresh wizard session state and apply it.
|
||||
const sessionState = readInstallState();
|
||||
if (sessionState) {
|
||||
const defaultHome = join(homedir(), '.config', 'mosaic');
|
||||
const customHome = sessionState.mosaicHome !== defaultHome ? sessionState.mosaicHome : null;
|
||||
const prompter = new ClackPrompter();
|
||||
|
||||
if (customHome && !process.env['MOSAIC_GATEWAY_HOME']) {
|
||||
// The wizard ran with a custom MOSAIC_HOME that differs from the default.
|
||||
// GATEWAY_HOME is derived from MOSAIC_GATEWAY_HOME (or defaults to
|
||||
// ~/.config/mosaic/gateway). Set the env var so the rest of this install
|
||||
// inherits the correct location. This must be set before GATEWAY_HOME is
|
||||
// evaluated by any imported helper — helpers that re-evaluate the path at
|
||||
// call time will pick it up automatically.
|
||||
process.env['MOSAIC_GATEWAY_HOME'] = join(customHome, 'gateway');
|
||||
console.log(
|
||||
`Resuming from wizard session — gateway home set to ${process.env['MOSAIC_GATEWAY_HOME']}\n`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`Resuming from wizard session — using ${sessionState.mosaicHome} from earlier.\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = readMeta();
|
||||
const envExists = existsSync(ENV_FILE);
|
||||
const mosaicConfigExists = existsSync(MOSAIC_CONFIG_FILE);
|
||||
let hasConfig = envExists && mosaicConfigExists;
|
||||
let daemonRunning = getDaemonPid() !== null;
|
||||
const hasAdminToken = Boolean(existing?.adminToken);
|
||||
// `opts.host` already incorporates meta fallback via the parent command
|
||||
// in gateway.ts (resolveOpts). Using it directly also lets a user pass
|
||||
// `--host X` to recover from a previous install that stored a broken
|
||||
// host. We intentionally do not prefer `existing.host` over `opts.host`.
|
||||
const host = opts.host;
|
||||
|
||||
// Corrupt partial state: exactly one of the two config files survived.
|
||||
// This happens when an earlier install was interrupted between writing
|
||||
// .env and mosaic.config.json. Rewriting the missing one would silently
|
||||
// rotate BETTER_AUTH_SECRET or clobber saved DB/Valkey URLs. Refuse to
|
||||
// guess — tell the user how to recover. Check file presence only; do
|
||||
// NOT gate on `existing`, because the installer writes config before
|
||||
// meta, so an interrupted first install has no meta yet.
|
||||
if ((envExists || mosaicConfigExists) && !hasConfig) {
|
||||
console.error('Gateway install is in a corrupt partial state:');
|
||||
console.error(` .env file: ${envExists ? 'present' : 'MISSING'} (${ENV_FILE})`);
|
||||
console.error(
|
||||
` mosaic.config.json: ${mosaicConfigExists ? 'present' : 'MISSING'} (${MOSAIC_CONFIG_FILE})`,
|
||||
);
|
||||
console.error('\nRun `mosaic gateway uninstall` to clean up, then re-run install.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fully set up already — offer to re-run the config wizard and restart.
|
||||
// The wizard allows changing storage tier / DB URLs, so this can move
|
||||
// the install onto a different data store. We do NOT wipe persisted
|
||||
// local data here — for a true scratch wipe run `mosaic gateway
|
||||
// uninstall` first.
|
||||
let explicitReinstall = false;
|
||||
if (existing && hasConfig && daemonRunning && hasAdminToken) {
|
||||
console.log(`Gateway is already installed and running (v${existing.version}).`);
|
||||
console.log(` Endpoint: http://${existing.host}:${existing.port.toString()}`);
|
||||
console.log(` Status: mosaic gateway status`);
|
||||
console.log();
|
||||
console.log('Re-running the config wizard will:');
|
||||
console.log(' - regenerate .env and mosaic.config.json');
|
||||
console.log(' - restart the daemon');
|
||||
console.log(' - preserve BETTER_AUTH_SECRET (sessions stay valid)');
|
||||
console.log(' - clear the stored admin token (you will re-bootstrap an admin user)');
|
||||
console.log(' - allow changing storage tier / DB URLs (may point at a different data store)');
|
||||
console.log('To wipe persisted data, run `mosaic gateway uninstall` first.');
|
||||
const answer = await prompt(rl, 'Re-run config wizard? [y/N] ');
|
||||
if (answer.trim().toLowerCase() !== 'y') {
|
||||
console.log('Nothing to do.');
|
||||
return;
|
||||
}
|
||||
// Fall through. The daemon stop below triggers because hasConfig=false
|
||||
// forces the wizard to re-run.
|
||||
hasConfig = false;
|
||||
explicitReinstall = true;
|
||||
} else if (existing && (hasConfig || daemonRunning)) {
|
||||
// Partial install detected — resume instead of re-prompting the user.
|
||||
console.log('Detected a partial gateway installation — resuming setup.\n');
|
||||
}
|
||||
|
||||
// If we are going to (re)write config, the running daemon would end up
|
||||
// serving the old config while health checks and meta point at the new
|
||||
// one. Always stop the daemon before writing config.
|
||||
if (!hasConfig && daemonRunning) {
|
||||
console.log('Stopping gateway daemon before writing new config...');
|
||||
try {
|
||||
await stopDaemon();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/not running/i.test(msg)) {
|
||||
// Raced with daemon exit — fine, proceed.
|
||||
} else {
|
||||
console.error(`Failed to stop running daemon: ${msg}`);
|
||||
console.error('Refusing to rewrite config while an unknown-state daemon is running.');
|
||||
console.error('Stop it manually (mosaic gateway stop) and re-run install.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Re-check — stop may have succeeded but we want to be sure before
|
||||
// writing new config files and starting a fresh process.
|
||||
if (getDaemonPid() !== null) {
|
||||
console.error('Gateway daemon is still running after stop attempt. Aborting.');
|
||||
return;
|
||||
}
|
||||
daemonRunning = false;
|
||||
}
|
||||
|
||||
// Step 1: Install npm package. Always run on first install and on any
|
||||
// resume where the daemon is NOT already running — a prior failure may
|
||||
// have been caused by a broken package version, and the retry should
|
||||
// pick up the latest release. Skip only when resuming while the daemon
|
||||
// is already alive (package must be working to have started).
|
||||
if (!opts.skipInstall && !daemonRunning) {
|
||||
installGatewayPackage();
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
|
||||
// Step 2: Collect configuration (skip if both files already exist).
|
||||
// On resume, treat the .env file as authoritative for port — but let a
|
||||
// user-supplied non-default `--port` override it so they can recover
|
||||
// from a conflicting saved port the same way `--host` lets them
|
||||
// recover from a bad saved host. `opts.port === 14242` is commander's
|
||||
// default (not explicit user input), so we prefer .env in that case.
|
||||
let port: number;
|
||||
const regeneratedConfig = !hasConfig;
|
||||
if (hasConfig) {
|
||||
const envPort = readPortFromEnv();
|
||||
port = opts.port !== 14242 ? opts.port : (envPort ?? existing?.port ?? opts.port);
|
||||
console.log(`Using existing config at ${ENV_FILE} (port ${port.toString()})`);
|
||||
} else {
|
||||
port = await runConfigWizard(rl, opts);
|
||||
}
|
||||
|
||||
// Step 3: Write meta.json. Prefer host from existing meta when resuming.
|
||||
let entryPoint: string;
|
||||
try {
|
||||
entryPoint = resolveGatewayEntry();
|
||||
} catch {
|
||||
console.error('Error: Gateway package not found after install.');
|
||||
console.error('Check that @mosaicstack/gateway installed correctly.');
|
||||
return;
|
||||
}
|
||||
|
||||
const version = getInstalledGatewayVersion() ?? 'unknown';
|
||||
// Preserve the admin token only on a pure resume (no config regeneration).
|
||||
// Any time we regenerated config, the wizard may have pointed at a
|
||||
// different storage tier / DB URL, so the old token is unverifiable —
|
||||
// drop it and require re-bootstrap.
|
||||
const preserveToken = !regeneratedConfig && Boolean(existing?.adminToken);
|
||||
const meta: GatewayMeta = {
|
||||
version,
|
||||
installedAt: explicitReinstall
|
||||
? new Date().toISOString()
|
||||
: (existing?.installedAt ?? new Date().toISOString()),
|
||||
entryPoint,
|
||||
host,
|
||||
port,
|
||||
...(preserveToken && existing?.adminToken ? { adminToken: existing.adminToken } : {}),
|
||||
const state: WizardState = {
|
||||
mosaicHome,
|
||||
sourceDir: mosaicHome,
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
writeMeta(meta);
|
||||
|
||||
// Step 4: Start the daemon (idempotent — skip if already running).
|
||||
if (!daemonRunning) {
|
||||
console.log('\nStarting gateway daemon...');
|
||||
try {
|
||||
const pid = startDaemon();
|
||||
console.log(`Gateway started (PID ${pid.toString()})`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
||||
printLogTail();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log('\nGateway daemon is already running.');
|
||||
}
|
||||
const { gatewayConfigStage } = await import('../../stages/gateway-config.js');
|
||||
const { gatewayBootstrapStage } = await import('../../stages/gateway-bootstrap.js');
|
||||
|
||||
// Step 5: Wait for health
|
||||
console.log('Waiting for gateway to become healthy...');
|
||||
const healthy = await waitForHealth(host, port, 30_000);
|
||||
if (!healthy) {
|
||||
console.error('\nGateway did not become healthy within 30 seconds.');
|
||||
printLogTail();
|
||||
console.error('\nFix the underlying error above, then re-run `mosaic gateway install`.');
|
||||
return;
|
||||
}
|
||||
console.log('Gateway is healthy.\n');
|
||||
// Preserve the legacy "explicit --port wins over saved config" semantic:
|
||||
// commander defaults the port to 14242, so any other value is treated as
|
||||
// an explicit user override that the config stage should honor even on
|
||||
// resume.
|
||||
const portOverride = opts.port !== 14242 ? opts.port : undefined;
|
||||
|
||||
// Step 6: Bootstrap — first admin user.
|
||||
await bootstrapFirstUser(rl, host, port, meta);
|
||||
|
||||
console.log('\n─── Installation Complete ───');
|
||||
console.log(` Endpoint: http://${host}:${port.toString()}`);
|
||||
console.log(` Config: ${GATEWAY_HOME}`);
|
||||
console.log(` Logs: mosaic gateway logs`);
|
||||
console.log(` Status: mosaic gateway status`);
|
||||
|
||||
// Step 7: Post-install verification (CU-07-03)
|
||||
const { runPostInstallVerification } = await import('./verify.js');
|
||||
await runPostInstallVerification(host, port);
|
||||
|
||||
// CU-07-02: Clear transient wizard session state on successful install.
|
||||
clearInstallState();
|
||||
}
|
||||
|
||||
async function runConfigWizard(
|
||||
rl: ReturnType<typeof createInterface>,
|
||||
opts: InstallOpts,
|
||||
): Promise<number> {
|
||||
console.log('\n─── Gateway Configuration ───\n');
|
||||
|
||||
// If a previous .env exists on disk, reuse its BETTER_AUTH_SECRET so
|
||||
// regenerating config does not silently log out existing users.
|
||||
const preservedAuthSecret = readEnvVarFromFile('BETTER_AUTH_SECRET');
|
||||
if (preservedAuthSecret) {
|
||||
console.log('(Preserving existing BETTER_AUTH_SECRET — auth sessions will remain valid.)\n');
|
||||
}
|
||||
|
||||
let tier: 'local' | 'team';
|
||||
let port: number;
|
||||
let databaseUrl: string | undefined;
|
||||
let valkeyUrl: string | undefined;
|
||||
let anthropicKey: string;
|
||||
let corsOrigin: string;
|
||||
|
||||
if (isHeadless()) {
|
||||
// ── Headless / non-interactive path ────────────────────────────────────
|
||||
console.log('Headless mode detected — reading configuration from environment variables.\n');
|
||||
|
||||
const storageTierEnv = process.env['MOSAIC_STORAGE_TIER'] ?? 'local';
|
||||
tier = storageTierEnv === 'team' ? 'team' : 'local';
|
||||
|
||||
const portEnv = process.env['MOSAIC_GATEWAY_PORT'];
|
||||
port = portEnv ? parseInt(portEnv, 10) : opts.port;
|
||||
|
||||
databaseUrl = process.env['MOSAIC_DATABASE_URL'];
|
||||
valkeyUrl = process.env['MOSAIC_VALKEY_URL'];
|
||||
anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
|
||||
corsOrigin = process.env['MOSAIC_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||
|
||||
// Validate required vars for team tier
|
||||
if (tier === 'team') {
|
||||
const missing: string[] = [];
|
||||
if (!databaseUrl) missing.push('MOSAIC_DATABASE_URL');
|
||||
if (!valkeyUrl) missing.push('MOSAIC_VALKEY_URL');
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`Error: headless install with tier=team requires the following env vars:\n` +
|
||||
missing.map((v) => ` ${v}`).join('\n'),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Storage tier: ${tier}`);
|
||||
console.log(` Gateway port: ${port.toString()}`);
|
||||
if (tier === 'team') {
|
||||
console.log(` DATABASE_URL: ${databaseUrl ?? ''}`);
|
||||
console.log(` VALKEY_URL: ${valkeyUrl ?? ''}`);
|
||||
}
|
||||
console.log(` CORS origin: ${corsOrigin}`);
|
||||
console.log();
|
||||
} else {
|
||||
// ── Interactive path ────────────────────────────────────────────────────
|
||||
console.log('Storage tier:');
|
||||
console.log(' 1. Local (embedded database, no dependencies)');
|
||||
console.log(' 2. Team (PostgreSQL + Valkey required)');
|
||||
const tierAnswer = (await prompt(rl, 'Select [1]: ')).trim() || '1';
|
||||
tier = tierAnswer === '2' ? 'team' : 'local';
|
||||
|
||||
port =
|
||||
opts.port !== 14242
|
||||
? opts.port
|
||||
: parseInt(
|
||||
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
|
||||
10,
|
||||
);
|
||||
|
||||
if (tier === 'team') {
|
||||
databaseUrl =
|
||||
(await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) ||
|
||||
'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
|
||||
valkeyUrl =
|
||||
(await prompt(rl, 'VALKEY_URL [redis://localhost:6380]: ')) || 'redis://localhost:6380';
|
||||
}
|
||||
|
||||
anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): ');
|
||||
|
||||
corsOrigin =
|
||||
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
|
||||
}
|
||||
|
||||
const authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex');
|
||||
|
||||
const envLines = [
|
||||
`GATEWAY_PORT=${port.toString()}`,
|
||||
`BETTER_AUTH_SECRET=${authSecret}`,
|
||||
`BETTER_AUTH_URL=http://${opts.host}:${port.toString()}`,
|
||||
`GATEWAY_CORS_ORIGIN=${corsOrigin}`,
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318`,
|
||||
`OTEL_SERVICE_NAME=mosaic-gateway`,
|
||||
];
|
||||
|
||||
if (tier === 'team' && databaseUrl && valkeyUrl) {
|
||||
envLines.push(`DATABASE_URL=${databaseUrl}`);
|
||||
envLines.push(`VALKEY_URL=${valkeyUrl}`);
|
||||
}
|
||||
|
||||
if (anthropicKey) {
|
||||
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
|
||||
}
|
||||
|
||||
writeFileSync(ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 });
|
||||
console.log(`\nConfig written to ${ENV_FILE}`);
|
||||
|
||||
const mosaicConfig =
|
||||
tier === 'local'
|
||||
? {
|
||||
tier: 'local',
|
||||
storage: { type: 'pglite', dataDir: join(GATEWAY_HOME, 'storage-pglite') },
|
||||
queue: { type: 'local', dataDir: join(GATEWAY_HOME, 'queue') },
|
||||
memory: { type: 'keyword' },
|
||||
}
|
||||
: {
|
||||
tier: 'team',
|
||||
storage: { type: 'postgres', url: databaseUrl },
|
||||
queue: { type: 'bullmq', url: valkeyUrl },
|
||||
memory: { type: 'pgvector' },
|
||||
};
|
||||
|
||||
writeFileSync(MOSAIC_CONFIG_FILE, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 });
|
||||
console.log(`Config written to ${MOSAIC_CONFIG_FILE}`);
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
function readEnvVarFromFile(key: string): string | null {
|
||||
if (!existsSync(ENV_FILE)) return null;
|
||||
try {
|
||||
for (const line of readFileSync(ENV_FILE, 'utf-8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx <= 0) continue;
|
||||
if (trimmed.slice(0, eqIdx) !== key) continue;
|
||||
return trimmed.slice(eqIdx + 1);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPortFromEnv(): number | null {
|
||||
const raw = readEnvVarFromFile('GATEWAY_PORT');
|
||||
if (raw === null) return null;
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function printLogTail(maxLines = 30): void {
|
||||
if (!existsSync(LOG_FILE)) {
|
||||
console.error(`(no log file at ${LOG_FILE})`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const lines = readFileSync(LOG_FILE, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim().length > 0);
|
||||
const tail = lines.slice(-maxLines);
|
||||
if (tail.length === 0) {
|
||||
console.error('(log file is empty)');
|
||||
return;
|
||||
}
|
||||
console.error(`\n─── Last ${tail.length.toString()} log lines (${LOG_FILE}) ───`);
|
||||
for (const line of tail) console.error(line);
|
||||
console.error('─────────────────────────────────────────────');
|
||||
} catch (err) {
|
||||
console.error(`Could not read log file: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printAdminTokenBanner(token: string): void {
|
||||
const border = '═'.repeat(68);
|
||||
console.log();
|
||||
console.log(border);
|
||||
console.log(' Admin API Token');
|
||||
console.log(border);
|
||||
console.log();
|
||||
console.log(` ${token}`);
|
||||
console.log();
|
||||
console.log(' Save this token now — it will not be shown again in full.');
|
||||
console.log(' It is stored (read-only) at:');
|
||||
console.log(` ${join(GATEWAY_HOME, 'meta.json')}`);
|
||||
console.log();
|
||||
console.log(' Use it with admin endpoints, e.g.:');
|
||||
console.log(` mosaic gateway --token <token> status`);
|
||||
console.log(border);
|
||||
}
|
||||
|
||||
async function bootstrapFirstUser(
|
||||
rl: ReturnType<typeof createInterface>,
|
||||
host: string,
|
||||
port: number,
|
||||
meta: GatewayMeta,
|
||||
): Promise<void> {
|
||||
const baseUrl = `http://${host}:${port.toString()}`;
|
||||
const headless = isHeadlessRun();
|
||||
|
||||
try {
|
||||
const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`);
|
||||
if (!statusRes.ok) return;
|
||||
|
||||
const status = (await statusRes.json()) as { needsSetup: boolean };
|
||||
if (!status.needsSetup) {
|
||||
if (meta.adminToken) {
|
||||
console.log('Admin user already exists (token on file).');
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin user exists but no token — offer inline recovery when interactive.
|
||||
console.log('Admin user already exists but no admin token is on file.');
|
||||
|
||||
if (process.stdin.isTTY) {
|
||||
const answer = (await prompt(rl, 'Run token recovery now? [Y/n] ')).trim().toLowerCase();
|
||||
if (answer === '' || answer === 'y' || answer === 'yes') {
|
||||
console.log();
|
||||
try {
|
||||
const { ensureSession, mintAdminToken, persistToken } = await import('./token-ops.js');
|
||||
const cookie = await ensureSession(baseUrl);
|
||||
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
|
||||
const minted = await mintAdminToken(baseUrl, cookie, label);
|
||||
persistToken(baseUrl, minted);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Token recovery failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('No admin token on file. Run: mosaic gateway config recover-token');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.warn('Could not check bootstrap status — skipping first user setup.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('─── Admin User Setup ───\n');
|
||||
|
||||
let name: string;
|
||||
let email: string;
|
||||
let password: string;
|
||||
|
||||
if (isHeadless()) {
|
||||
// ── Headless path ──────────────────────────────────────────────────────
|
||||
const nameEnv = process.env['MOSAIC_ADMIN_NAME']?.trim() ?? '';
|
||||
const emailEnv = process.env['MOSAIC_ADMIN_EMAIL']?.trim() ?? '';
|
||||
const passwordEnv = process.env['MOSAIC_ADMIN_PASSWORD'] ?? '';
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!nameEnv) missing.push('MOSAIC_ADMIN_NAME');
|
||||
if (!emailEnv) missing.push('MOSAIC_ADMIN_EMAIL');
|
||||
if (!passwordEnv) missing.push('MOSAIC_ADMIN_PASSWORD');
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`Error: headless admin bootstrap requires the following env vars:\n` +
|
||||
missing.map((v) => ` ${v}`).join('\n'),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (passwordEnv.length < 8) {
|
||||
console.error('Error: MOSAIC_ADMIN_PASSWORD must be at least 8 characters.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
name = nameEnv;
|
||||
email = emailEnv;
|
||||
password = passwordEnv;
|
||||
} else {
|
||||
// ── Interactive path ────────────────────────────────────────────────────
|
||||
name = (await prompt(rl, 'Admin name: ')).trim();
|
||||
if (!name) {
|
||||
console.error('Name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
email = (await prompt(rl, 'Admin email: ')).trim();
|
||||
if (!email) {
|
||||
console.error('Email is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
password = await promptMaskedConfirmed(
|
||||
'Admin password (min 8 chars): ',
|
||||
'Confirm password: ',
|
||||
(v) => (v.length < 8 ? 'Password must be at least 8 characters' : undefined),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/bootstrap/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
const configResult = await gatewayConfigStage(prompter, state, {
|
||||
host: opts.host,
|
||||
defaultPort: opts.port,
|
||||
portOverride,
|
||||
skipInstall: opts.skipInstall,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
console.error(`Bootstrap failed (${res.status.toString()}): ${body}`);
|
||||
if (!configResult.ready || !configResult.host || configResult.port === undefined) {
|
||||
// In headless/scripted installs, a non-ready config stage is a fatal
|
||||
// error — we must not report "complete" when the gateway was never
|
||||
// configured. Exit non-zero so CI notices.
|
||||
if (headless) {
|
||||
prompter.warn('Gateway configuration failed in headless mode — aborting.');
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = (await res.json()) as {
|
||||
user: { id: string; email: string };
|
||||
token: { plaintext: string };
|
||||
};
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
|
||||
// Persist the token so future CLI calls can authenticate automatically.
|
||||
meta.adminToken = result.token.plaintext;
|
||||
writeMeta(meta);
|
||||
if (!bootstrapResult.completed && headless) {
|
||||
prompter.warn('Admin bootstrap failed in headless mode — aborting.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nAdmin user created: ${result.user.email}`);
|
||||
printAdminTokenBanner(result.token.plaintext);
|
||||
prompter.log('─── Installation Complete ───');
|
||||
prompter.log(` Endpoint: http://${configResult.host}:${configResult.port.toString()}`);
|
||||
prompter.log(` Logs: mosaic gateway logs`);
|
||||
prompter.log(` Status: mosaic gateway status`);
|
||||
|
||||
// Post-install verification (CU-07-03) — non-fatal.
|
||||
try {
|
||||
const { runPostInstallVerification } = await import('./verify.js');
|
||||
await runPostInstallVerification(configResult.host, configResult.port);
|
||||
} catch {
|
||||
// Non-fatal — verification is a courtesy
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
// Stages normally return structured results for expected failures.
|
||||
// Anything that reaches here is an unexpected runtime error — render a
|
||||
// concise warning AND re-throw so the command exits non-zero. Silent
|
||||
// swallowing would let scripted installs report success on failure.
|
||||
prompter.warn(`Gateway install failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,18 @@ import type { ConfigService } from '../config/config-service.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { getShellProfilePath } from '../platform/detect.js';
|
||||
|
||||
function linkRuntimeAssets(mosaicHome: string): void {
|
||||
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
||||
if (existsSync(script)) {
|
||||
try {
|
||||
spawnSync('bash', [script], { timeout: 30000, stdio: 'pipe' });
|
||||
spawnSync('bash', [script], {
|
||||
timeout: 30000,
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
...(skipClaudeHooks ? { MOSAIC_SKIP_CLAUDE_HOOKS: '1' } : {}),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal: wizard continues
|
||||
}
|
||||
@@ -110,8 +117,12 @@ export async function finalizeStage(
|
||||
}
|
||||
|
||||
// 3. Link runtime assets
|
||||
// Honor the hooks-preview decision: when the user declined hooks, pass
|
||||
// MOSAIC_SKIP_CLAUDE_HOOKS=1 to the linker so hooks-config.json is not
|
||||
// copied into ~/.claude/ while still linking the other runtime files.
|
||||
spin.update('Linking runtime assets...');
|
||||
linkRuntimeAssets(state.mosaicHome);
|
||||
const skipClaudeHooks = state.hooks?.accepted === false;
|
||||
linkRuntimeAssets(state.mosaicHome, skipClaudeHooks);
|
||||
|
||||
// 4. Sync skills
|
||||
if (state.selectedSkills.length > 0) {
|
||||
|
||||
225
packages/mosaic/src/stages/gateway-bootstrap.spec.ts
Normal file
225
packages/mosaic/src/stages/gateway-bootstrap.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { WizardState } from '../types.js';
|
||||
|
||||
// ── Mock daemon module ────────────────────────────────────────────────────
|
||||
|
||||
const daemonState = {
|
||||
meta: null as null | {
|
||||
version: string;
|
||||
installedAt: string;
|
||||
entryPoint: string;
|
||||
host: string;
|
||||
port: number;
|
||||
adminToken?: string;
|
||||
},
|
||||
writeMetaCalls: [] as unknown[],
|
||||
};
|
||||
|
||||
vi.mock('../commands/gateway/daemon.js', () => ({
|
||||
GATEWAY_HOME: '/tmp/fake-gw',
|
||||
readMeta: () => daemonState.meta,
|
||||
writeMeta: (m: unknown) => {
|
||||
daemonState.writeMetaCalls.push(m);
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Mock masked-prompt so we never touch real stdin raw mode ──────────────
|
||||
|
||||
vi.mock('../prompter/masked-prompt.js', () => ({
|
||||
promptMaskedConfirmed: vi.fn().mockResolvedValue('supersecret'),
|
||||
}));
|
||||
|
||||
import { gatewayBootstrapStage } from './gateway-bootstrap.js';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function buildPrompter(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
intro: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
text: vi.fn().mockImplementation(async (opts: { message: string }) => {
|
||||
if (/name/i.test(opts.message)) return 'Tester';
|
||||
if (/email/i.test(opts.message)) return 'test@example.com';
|
||||
return '';
|
||||
}),
|
||||
confirm: vi.fn().mockResolvedValue(true),
|
||||
select: vi.fn(),
|
||||
multiselect: vi.fn(),
|
||||
groupMultiselect: vi.fn(),
|
||||
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
|
||||
separator: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(): WizardState {
|
||||
return {
|
||||
mosaicHome: '/tmp/fake-mosaic',
|
||||
sourceDir: '/tmp/fake-mosaic',
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('gatewayBootstrapStage', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
daemonState.meta = {
|
||||
version: '0.0.99',
|
||||
installedAt: new Date().toISOString(),
|
||||
entryPoint: '/fake/entry.js',
|
||||
host: 'localhost',
|
||||
port: 14242,
|
||||
};
|
||||
daemonState.writeMetaCalls = [];
|
||||
// Keep headless so we exercise the env-var path
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
process.env['MOSAIC_ADMIN_NAME'] = 'Tester';
|
||||
process.env['MOSAIC_ADMIN_EMAIL'] = 'test@example.com';
|
||||
process.env['MOSAIC_ADMIN_PASSWORD'] = 'supersecret';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates the first admin user and persists the token', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: true }),
|
||||
}))
|
||||
.mockImplementationOnce(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user: { id: 'u1', email: 'test@example.com' },
|
||||
token: { plaintext: 'plain-token-xyz' },
|
||||
}),
|
||||
}));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState();
|
||||
|
||||
const result = await gatewayBootstrapStage(p, state, { host: 'localhost', port: 14242 });
|
||||
|
||||
expect(result.completed).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(daemonState.writeMetaCalls).toHaveLength(1);
|
||||
const persistedMeta = daemonState.writeMetaCalls[0] as { adminToken?: string };
|
||||
expect(persistedMeta.adminToken).toBe('plain-token-xyz');
|
||||
expect(state.gateway?.adminTokenIssued).toBe(true);
|
||||
});
|
||||
|
||||
it('short-circuits when admin already exists and token is on file', async () => {
|
||||
daemonState.meta!.adminToken = 'already-have-token';
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: false }),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState();
|
||||
|
||||
const result = await gatewayBootstrapStage(p, state, { host: 'localhost', port: 14242 });
|
||||
|
||||
expect(result.completed).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(daemonState.writeMetaCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('treats headless rerun of already-bootstrapped gateway as a successful no-op', async () => {
|
||||
// Admin already exists server-side, but local meta has no token cache.
|
||||
// Headless mode should NOT fail the install — leave admin in place.
|
||||
daemonState.meta!.adminToken = undefined;
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: false }),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState();
|
||||
|
||||
const result = await gatewayBootstrapStage(p, state, { host: 'localhost', port: 14242 });
|
||||
|
||||
expect(result.completed).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(daemonState.writeMetaCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns non-completed in headless mode when required env vars are missing', async () => {
|
||||
delete process.env['MOSAIC_ADMIN_NAME'];
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: true }),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState();
|
||||
|
||||
const result = await gatewayBootstrapStage(p, state, { host: 'localhost', port: 14242 });
|
||||
|
||||
expect(result.completed).toBe(false);
|
||||
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('MOSAIC_ADMIN_NAME'));
|
||||
});
|
||||
|
||||
it('returns non-completed when bootstrap status call fails', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValueOnce(new Error('network down'));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState();
|
||||
|
||||
const result = await gatewayBootstrapStage(p, state, { host: 'localhost', port: 14242 });
|
||||
|
||||
expect(result.completed).toBe(false);
|
||||
expect(p.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns non-completed when bootstrap/setup responds with error', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => 'bad password',
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState();
|
||||
|
||||
const result = await gatewayBootstrapStage(p, state, { host: 'localhost', port: 14242 });
|
||||
|
||||
expect(result.completed).toBe(false);
|
||||
expect(daemonState.writeMetaCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
215
packages/mosaic/src/stages/gateway-bootstrap.ts
Normal file
215
packages/mosaic/src/stages/gateway-bootstrap.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Gateway bootstrap stage — creates the first admin user and persists the
|
||||
* admin API token.
|
||||
*
|
||||
* Runs as the terminal stage of the unified first-run wizard and is also
|
||||
* invoked by the `mosaic gateway install` standalone entry point after the
|
||||
* config stage. Idempotent: if an admin already exists, this stage offers
|
||||
* inline token recovery instead of re-prompting for credentials.
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { promptMaskedConfirmed } from '../prompter/masked-prompt.js';
|
||||
|
||||
// ── Headless detection ────────────────────────────────────────────────────────
|
||||
|
||||
function isHeadless(): boolean {
|
||||
return process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
}
|
||||
|
||||
// ── Options ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GatewayBootstrapStageOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface GatewayBootstrapStageResult {
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
// ── Stage ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function gatewayBootstrapStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
opts: GatewayBootstrapStageOptions,
|
||||
): Promise<GatewayBootstrapStageResult> {
|
||||
const { host, port } = opts;
|
||||
const baseUrl = `http://${host}:${port.toString()}`;
|
||||
|
||||
const { readMeta, writeMeta, GATEWAY_HOME } = await import('../commands/gateway/daemon.js');
|
||||
const existingMeta = readMeta();
|
||||
if (!existingMeta) {
|
||||
p.warn('Gateway meta.json missing — cannot bootstrap admin user.');
|
||||
return { completed: false };
|
||||
}
|
||||
|
||||
// Check whether an admin already exists.
|
||||
let needsSetup: boolean;
|
||||
try {
|
||||
const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`);
|
||||
if (!statusRes.ok) {
|
||||
p.warn('Could not check bootstrap status — skipping first user setup.');
|
||||
return { completed: false };
|
||||
}
|
||||
const status = (await statusRes.json()) as { needsSetup: boolean };
|
||||
needsSetup = status.needsSetup;
|
||||
} catch {
|
||||
p.warn('Could not reach gateway bootstrap endpoint — skipping first user setup.');
|
||||
return { completed: false };
|
||||
}
|
||||
|
||||
if (!needsSetup) {
|
||||
if (existingMeta.adminToken) {
|
||||
p.log('Admin user already exists (token on file).');
|
||||
return { completed: true };
|
||||
}
|
||||
|
||||
// Admin exists but no token on file — offer inline recovery if interactive.
|
||||
p.warn('Admin user already exists but no admin token is on file.');
|
||||
|
||||
// Headless re-install: treat this as a successful no-op. The gateway has
|
||||
// already been bootstrapped; a scripted re-run should not fail simply
|
||||
// because the local admin-token cache has been cleared. Operators can
|
||||
// run `mosaic gateway config recover-token` interactively later.
|
||||
if (isHeadless()) {
|
||||
p.log(
|
||||
'Headless mode — leaving existing admin in place. Run `mosaic gateway config recover-token` to restore local token access.',
|
||||
);
|
||||
return { completed: true };
|
||||
}
|
||||
|
||||
const runRecovery = await p.confirm({
|
||||
message: 'Run token recovery now?',
|
||||
initialValue: true,
|
||||
});
|
||||
if (runRecovery) {
|
||||
try {
|
||||
const { ensureSession, mintAdminToken, persistToken } =
|
||||
await import('../commands/gateway/token-ops.js');
|
||||
const cookie = await ensureSession(baseUrl);
|
||||
const label = `CLI recovery token (${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace('T', ' ')})`;
|
||||
const minted = await mintAdminToken(baseUrl, cookie, label);
|
||||
persistToken(baseUrl, minted);
|
||||
return { completed: true };
|
||||
} catch (err) {
|
||||
p.warn(`Token recovery failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return { completed: false };
|
||||
}
|
||||
}
|
||||
|
||||
p.log('No admin token on file. Run: mosaic gateway config recover-token');
|
||||
return { completed: false };
|
||||
}
|
||||
|
||||
// Fresh bootstrap — collect admin credentials.
|
||||
p.note('Admin User Setup', 'Create your first admin user');
|
||||
|
||||
let name: string;
|
||||
let email: string;
|
||||
let password: string;
|
||||
|
||||
if (isHeadless()) {
|
||||
const nameEnv = process.env['MOSAIC_ADMIN_NAME']?.trim() ?? '';
|
||||
const emailEnv = process.env['MOSAIC_ADMIN_EMAIL']?.trim() ?? '';
|
||||
const passwordEnv = process.env['MOSAIC_ADMIN_PASSWORD'] ?? '';
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!nameEnv) missing.push('MOSAIC_ADMIN_NAME');
|
||||
if (!emailEnv) missing.push('MOSAIC_ADMIN_EMAIL');
|
||||
if (!passwordEnv) missing.push('MOSAIC_ADMIN_PASSWORD');
|
||||
|
||||
if (missing.length > 0) {
|
||||
p.warn('Headless admin bootstrap requires env vars: ' + missing.join(', '));
|
||||
return { completed: false };
|
||||
}
|
||||
if (passwordEnv.length < 8) {
|
||||
p.warn('MOSAIC_ADMIN_PASSWORD must be at least 8 characters.');
|
||||
return { completed: false };
|
||||
}
|
||||
|
||||
name = nameEnv;
|
||||
email = emailEnv;
|
||||
password = passwordEnv;
|
||||
} else {
|
||||
name = await p.text({
|
||||
message: 'Admin name',
|
||||
validate: (v) => (v.trim().length === 0 ? 'Name is required' : undefined),
|
||||
});
|
||||
email = await p.text({
|
||||
message: 'Admin email',
|
||||
validate: (v) => (v.trim().length === 0 ? 'Email is required' : undefined),
|
||||
});
|
||||
password = await promptMaskedConfirmed(
|
||||
'Admin password (min 8 chars): ',
|
||||
'Confirm password: ',
|
||||
(v) => (v.length < 8 ? 'Password must be at least 8 characters' : undefined),
|
||||
);
|
||||
}
|
||||
|
||||
state.gateway = {
|
||||
...(state.gateway ?? {
|
||||
host,
|
||||
port,
|
||||
tier: 'local',
|
||||
corsOrigin: 'http://localhost:3000',
|
||||
}),
|
||||
admin: { name, email, password },
|
||||
};
|
||||
|
||||
// Call bootstrap setup.
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/bootstrap/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
p.warn(`Bootstrap failed (${res.status.toString()}): ${body}`);
|
||||
return { completed: false };
|
||||
}
|
||||
|
||||
const result = (await res.json()) as {
|
||||
user: { id: string; email: string };
|
||||
token: { plaintext: string };
|
||||
};
|
||||
|
||||
// Persist the token so future CLI calls can authenticate automatically.
|
||||
const meta = { ...existingMeta, adminToken: result.token.plaintext };
|
||||
writeMeta(meta);
|
||||
|
||||
if (state.gateway) {
|
||||
state.gateway.adminTokenIssued = true;
|
||||
}
|
||||
|
||||
p.log(`Admin user created: ${result.user.email}`);
|
||||
printAdminTokenBanner(p, result.token.plaintext, join(GATEWAY_HOME, 'meta.json'));
|
||||
return { completed: true };
|
||||
} catch (err) {
|
||||
p.warn(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return { completed: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Banner ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function printAdminTokenBanner(p: WizardPrompter, token: string, metaPath: string): void {
|
||||
const body = [
|
||||
' Save this token now — it will not be shown again in full.',
|
||||
` ${token}`,
|
||||
'',
|
||||
` Stored (read-only) at: ${metaPath}`,
|
||||
'',
|
||||
' Use it with admin endpoints, e.g.:',
|
||||
' mosaic gateway --token <token> status',
|
||||
].join('\n');
|
||||
p.note(body, 'Admin API Token');
|
||||
}
|
||||
314
packages/mosaic/src/stages/gateway-config.spec.ts
Normal file
314
packages/mosaic/src/stages/gateway-config.spec.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { WizardState } from '../types.js';
|
||||
|
||||
// ── Mock the gateway daemon module (dynamic-imported inside the stage) ──
|
||||
//
|
||||
// The stage dynamic-imports `../commands/gateway/daemon.js`, so vi.mock
|
||||
// before importing the stage itself. We pin GATEWAY_HOME/ENV_FILE to a
|
||||
// per-test temp directory via a mutable holder so each test can swap the
|
||||
// values without reloading the module.
|
||||
|
||||
const daemonState = {
|
||||
gatewayHome: '',
|
||||
envFile: '',
|
||||
metaFile: '',
|
||||
mosaicConfigFile: '',
|
||||
logFile: '',
|
||||
daemonPid: null as number | null,
|
||||
meta: null as null | {
|
||||
version: string;
|
||||
installedAt: string;
|
||||
entryPoint: string;
|
||||
host: string;
|
||||
port: number;
|
||||
adminToken?: string;
|
||||
},
|
||||
startCalled: 0,
|
||||
stopCalled: 0,
|
||||
waitHealthOk: true,
|
||||
ensureDirsCalled: 0,
|
||||
installPkgCalled: 0,
|
||||
writeMetaCalls: [] as unknown[],
|
||||
};
|
||||
|
||||
vi.mock('../commands/gateway/daemon.js', () => ({
|
||||
get GATEWAY_HOME() {
|
||||
return daemonState.gatewayHome;
|
||||
},
|
||||
get ENV_FILE() {
|
||||
return daemonState.envFile;
|
||||
},
|
||||
get META_FILE() {
|
||||
return daemonState.metaFile;
|
||||
},
|
||||
get LOG_FILE() {
|
||||
return daemonState.logFile;
|
||||
},
|
||||
ensureDirs: () => {
|
||||
daemonState.ensureDirsCalled += 1;
|
||||
},
|
||||
getDaemonPid: () => daemonState.daemonPid,
|
||||
installGatewayPackage: () => {
|
||||
daemonState.installPkgCalled += 1;
|
||||
},
|
||||
readMeta: () => daemonState.meta,
|
||||
resolveGatewayEntry: () => '/fake/entry.js',
|
||||
startDaemon: () => {
|
||||
daemonState.startCalled += 1;
|
||||
daemonState.daemonPid = 42424;
|
||||
return 42424;
|
||||
},
|
||||
stopDaemon: async () => {
|
||||
daemonState.stopCalled += 1;
|
||||
daemonState.daemonPid = null;
|
||||
},
|
||||
waitForHealth: async () => daemonState.waitHealthOk,
|
||||
writeMeta: (m: unknown) => {
|
||||
daemonState.writeMetaCalls.push(m);
|
||||
},
|
||||
getInstalledGatewayVersion: () => '0.0.99',
|
||||
}));
|
||||
|
||||
import { gatewayConfigStage } from './gateway-config.js';
|
||||
|
||||
// ── Prompter stub ─────────────────────────────────────────────────────────
|
||||
|
||||
function buildPrompter(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
intro: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
text: vi.fn().mockResolvedValue('14242'),
|
||||
confirm: vi.fn().mockResolvedValue(false),
|
||||
select: vi.fn().mockResolvedValue('local'),
|
||||
multiselect: vi.fn(),
|
||||
groupMultiselect: vi.fn(),
|
||||
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
|
||||
separator: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(mosaicHome: string): WizardState {
|
||||
return {
|
||||
mosaicHome,
|
||||
sourceDir: mosaicHome,
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('gatewayConfigStage', () => {
|
||||
let tmp: string;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'mosaic-gw-config-'));
|
||||
daemonState.gatewayHome = join(tmp, 'gateway');
|
||||
daemonState.envFile = join(daemonState.gatewayHome, '.env');
|
||||
daemonState.metaFile = join(daemonState.gatewayHome, 'meta.json');
|
||||
daemonState.mosaicConfigFile = join(daemonState.gatewayHome, 'mosaic.config.json');
|
||||
daemonState.logFile = join(daemonState.gatewayHome, 'logs', 'gateway.log');
|
||||
daemonState.daemonPid = null;
|
||||
daemonState.meta = null;
|
||||
daemonState.startCalled = 0;
|
||||
daemonState.stopCalled = 0;
|
||||
daemonState.waitHealthOk = true;
|
||||
daemonState.ensureDirsCalled = 0;
|
||||
daemonState.installPkgCalled = 0;
|
||||
daemonState.writeMetaCalls = [];
|
||||
// Ensure the dir exists for config writes
|
||||
require('node:fs').mkdirSync(daemonState.gatewayHome, { recursive: true });
|
||||
// Force headless path via env for predictable tests
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||
delete process.env['MOSAIC_DATABASE_URL'];
|
||||
delete process.env['MOSAIC_VALKEY_URL'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('writes .env + mosaic.config.json and starts the daemon on a fresh install', async () => {
|
||||
const p = buildPrompter();
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.host).toBe('localhost');
|
||||
expect(result.port).toBe(14242);
|
||||
expect(existsSync(daemonState.envFile)).toBe(true);
|
||||
expect(existsSync(daemonState.mosaicConfigFile)).toBe(true);
|
||||
const envContents = readFileSync(daemonState.envFile, 'utf-8');
|
||||
expect(envContents).toContain('GATEWAY_PORT=14242');
|
||||
expect(envContents).toContain('BETTER_AUTH_SECRET=');
|
||||
expect(daemonState.startCalled).toBe(1);
|
||||
expect(daemonState.writeMetaCalls).toHaveLength(1);
|
||||
expect(state.gateway?.tier).toBe('local');
|
||||
expect(state.gateway?.regeneratedConfig).toBe(true);
|
||||
});
|
||||
|
||||
it('short-circuits when gateway is already fully installed and user declines rerun', async () => {
|
||||
// Pre-populate both files + running daemon + meta with token
|
||||
const fs = require('node:fs');
|
||||
fs.writeFileSync(daemonState.envFile, 'GATEWAY_PORT=14242\n');
|
||||
fs.writeFileSync(daemonState.mosaicConfigFile, '{}');
|
||||
daemonState.daemonPid = 1234;
|
||||
daemonState.meta = {
|
||||
version: '0.0.99',
|
||||
installedAt: new Date().toISOString(),
|
||||
entryPoint: '/fake/entry.js',
|
||||
host: 'localhost',
|
||||
port: 14242,
|
||||
adminToken: 'existing-token',
|
||||
};
|
||||
|
||||
const p = buildPrompter({ confirm: vi.fn().mockResolvedValue(false) });
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.port).toBe(14242);
|
||||
expect(daemonState.startCalled).toBe(0);
|
||||
expect(daemonState.writeMetaCalls).toHaveLength(0);
|
||||
expect(state.gateway?.regeneratedConfig).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses corrupt partial state (one config file present)', async () => {
|
||||
const fs = require('node:fs');
|
||||
fs.writeFileSync(daemonState.envFile, 'GATEWAY_PORT=14242\n');
|
||||
// mosaicConfigFile intentionally missing
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(daemonState.startCalled).toBe(0);
|
||||
});
|
||||
|
||||
it('honors MOSAIC_STORAGE_TIER=team in headless path', async () => {
|
||||
process.env['MOSAIC_STORAGE_TIER'] = 'team';
|
||||
process.env['MOSAIC_DATABASE_URL'] = 'postgresql://test/db';
|
||||
process.env['MOSAIC_VALKEY_URL'] = 'redis://test:6379';
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(state.gateway?.tier).toBe('team');
|
||||
const envContents = readFileSync(daemonState.envFile, 'utf-8');
|
||||
expect(envContents).toContain('DATABASE_URL=postgresql://test/db');
|
||||
expect(envContents).toContain('VALKEY_URL=redis://test:6379');
|
||||
const mosaicConfig = JSON.parse(readFileSync(daemonState.mosaicConfigFile, 'utf-8'));
|
||||
expect(mosaicConfig.tier).toBe('team');
|
||||
});
|
||||
|
||||
it('regenerates config when portOverride differs from saved GATEWAY_PORT', async () => {
|
||||
// Both config files present with a saved port of 14242. Caller passes
|
||||
// a portOverride of 15000, which should force regeneration (not trip
|
||||
// the corrupt-partial-state guard) and write the new port to .env.
|
||||
const fs = require('node:fs');
|
||||
fs.writeFileSync(daemonState.envFile, 'GATEWAY_PORT=14242\nBETTER_AUTH_SECRET=seeded\n');
|
||||
fs.writeFileSync(daemonState.mosaicConfigFile, '{}');
|
||||
daemonState.daemonPid = null;
|
||||
daemonState.meta = {
|
||||
version: '0.0.99',
|
||||
installedAt: new Date().toISOString(),
|
||||
entryPoint: '/fake/entry.js',
|
||||
host: 'localhost',
|
||||
port: 14242,
|
||||
};
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
portOverride: 15000,
|
||||
skipInstall: true,
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.port).toBe(15000);
|
||||
expect(state.gateway?.regeneratedConfig).toBe(true);
|
||||
const envContents = readFileSync(daemonState.envFile, 'utf-8');
|
||||
expect(envContents).toContain('GATEWAY_PORT=15000');
|
||||
expect(envContents).not.toContain('GATEWAY_PORT=14242');
|
||||
// Secret should still be preserved across the regeneration.
|
||||
expect(envContents).toContain('BETTER_AUTH_SECRET=seeded');
|
||||
// writeMeta should have been called with the new port.
|
||||
const lastMeta = daemonState.writeMetaCalls.at(-1) as { port: number } | undefined;
|
||||
expect(lastMeta?.port).toBe(15000);
|
||||
});
|
||||
|
||||
it('preserves BETTER_AUTH_SECRET from existing .env on reconfigure', async () => {
|
||||
// Seed an .env with a known secret, leave mosaic.config.json missing so
|
||||
// hasConfig=false (triggers config regeneration without needing the
|
||||
// "already installed" branch).
|
||||
const fs = require('node:fs');
|
||||
const preservedSecret = 'b'.repeat(64);
|
||||
fs.writeFileSync(
|
||||
daemonState.envFile,
|
||||
`GATEWAY_PORT=14242\nBETTER_AUTH_SECRET=${preservedSecret}\n`,
|
||||
);
|
||||
// Corrupt partial state normally refuses — remove envFile after capturing
|
||||
// its contents... actually use a different approach: pre-create both files
|
||||
// but clear the meta/daemon state so the "fully installed" branch is skipped.
|
||||
fs.writeFileSync(daemonState.mosaicConfigFile, '{}');
|
||||
daemonState.daemonPid = null;
|
||||
daemonState.meta = null; // no meta → partial install "resume" path
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
});
|
||||
|
||||
// hasConfig=true (both files present) so we enter the "use existing
|
||||
// config" branch and DON'T regenerate — secret is implicitly preserved.
|
||||
expect(result.ready).toBe(true);
|
||||
expect(state.gateway?.regeneratedConfig).toBe(false);
|
||||
const envContents = readFileSync(daemonState.envFile, 'utf-8');
|
||||
expect(envContents).toContain(`BETTER_AUTH_SECRET=${preservedSecret}`);
|
||||
});
|
||||
});
|
||||
520
packages/mosaic/src/stages/gateway-config.ts
Normal file
520
packages/mosaic/src/stages/gateway-config.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* Gateway configuration stage — writes .env + mosaic.config.json, starts the
|
||||
* daemon, and waits for it to become healthy.
|
||||
*
|
||||
* Runs as the penultimate stage of the unified first-run wizard, and is also
|
||||
* invoked directly by the `mosaic gateway install` standalone entry point
|
||||
* (see `commands/gateway/install.ts`).
|
||||
*
|
||||
* Idempotency contract:
|
||||
* - If both .env and mosaic.config.json already exist AND the daemon is
|
||||
* running AND meta has an adminToken, we short-circuit with a confirmation
|
||||
* prompt asking whether to re-run the config wizard.
|
||||
* - Partial state (one file present, the other missing) is refused and the
|
||||
* user is told to run `mosaic gateway uninstall` first.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { GatewayState, GatewayStorageTier, WizardState } from '../types.js';
|
||||
|
||||
// ── Headless detection ────────────────────────────────────────────────────────
|
||||
|
||||
function isHeadless(): boolean {
|
||||
return process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
}
|
||||
|
||||
// ── .env helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function readEnvVarFromFile(envFile: string, key: string): string | null {
|
||||
if (!existsSync(envFile)) return null;
|
||||
try {
|
||||
for (const line of readFileSync(envFile, 'utf-8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx <= 0) continue;
|
||||
if (trimmed.slice(0, eqIdx) !== key) continue;
|
||||
return trimmed.slice(eqIdx + 1);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPortFromEnv(envFile: string): number | null {
|
||||
const raw = readEnvVarFromFile(envFile, 'GATEWAY_PORT');
|
||||
if (raw === null) return null;
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
// ── Prompt helpers (unified prompter) ────────────────────────────────────────
|
||||
|
||||
async function promptTier(p: WizardPrompter): Promise<GatewayStorageTier> {
|
||||
const tier = await p.select<GatewayStorageTier>({
|
||||
message: 'Storage tier',
|
||||
initialValue: 'local',
|
||||
options: [
|
||||
{
|
||||
value: 'local',
|
||||
label: 'Local',
|
||||
hint: 'embedded database, no dependencies',
|
||||
},
|
||||
{
|
||||
value: 'team',
|
||||
label: 'Team',
|
||||
hint: 'PostgreSQL + Valkey required',
|
||||
},
|
||||
],
|
||||
});
|
||||
return tier;
|
||||
}
|
||||
|
||||
async function promptPort(p: WizardPrompter, defaultPort: number): Promise<number> {
|
||||
const raw = await p.text({
|
||||
message: 'Gateway port',
|
||||
defaultValue: defaultPort.toString(),
|
||||
validate: (v) => {
|
||||
const n = parseInt(v, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > 65535) return 'Port must be a number between 1 and 65535';
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
return parseInt(raw, 10);
|
||||
}
|
||||
|
||||
// ── Options ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GatewayConfigStageOptions {
|
||||
/** Gateway host (from CLI flag or meta fallback). Defaults to localhost. */
|
||||
host: string;
|
||||
/** Default port when nothing else is set. */
|
||||
defaultPort?: number;
|
||||
/**
|
||||
* Explicit port override from the caller (e.g. `mosaic gateway install
|
||||
* --port 9999`). When set, this value wins over the port stored in an
|
||||
* existing `.env` / meta.json so users can recover from a conflicting
|
||||
* saved port without deleting config files first.
|
||||
*/
|
||||
portOverride?: number;
|
||||
/** Skip the `npm install -g @mosaicstack/gateway` step (local build / tests). */
|
||||
skipInstall?: boolean;
|
||||
}
|
||||
|
||||
export interface GatewayConfigStageResult {
|
||||
/** `true` when the daemon is running, healthy, and `meta.json` is current. */
|
||||
ready: boolean;
|
||||
/** Populated when ready — caller uses this for the bootstrap stage. */
|
||||
host?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
// ── Stage ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function gatewayConfigStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
opts: GatewayConfigStageOptions,
|
||||
): Promise<GatewayConfigStageResult> {
|
||||
// Ensure gateway modules resolve against the correct MOSAIC_GATEWAY_HOME
|
||||
// before any dynamic import — the daemon module captures paths at import
|
||||
// time from process.env.
|
||||
const defaultMosaicHome = join(process.env['HOME'] ?? '', '.config', 'mosaic');
|
||||
if (state.mosaicHome !== defaultMosaicHome && !process.env['MOSAIC_GATEWAY_HOME']) {
|
||||
process.env['MOSAIC_GATEWAY_HOME'] = join(state.mosaicHome, 'gateway');
|
||||
}
|
||||
|
||||
const {
|
||||
ENV_FILE,
|
||||
GATEWAY_HOME,
|
||||
LOG_FILE,
|
||||
ensureDirs,
|
||||
getDaemonPid,
|
||||
installGatewayPackage,
|
||||
readMeta,
|
||||
resolveGatewayEntry,
|
||||
startDaemon,
|
||||
stopDaemon,
|
||||
waitForHealth,
|
||||
writeMeta,
|
||||
getInstalledGatewayVersion,
|
||||
} = await import('../commands/gateway/daemon.js');
|
||||
|
||||
const MOSAIC_CONFIG_FILE = join(GATEWAY_HOME, 'mosaic.config.json');
|
||||
|
||||
p.separator();
|
||||
|
||||
const existing = readMeta();
|
||||
const envExists = existsSync(ENV_FILE);
|
||||
const mosaicConfigExists = existsSync(MOSAIC_CONFIG_FILE);
|
||||
let hasConfig = envExists && mosaicConfigExists;
|
||||
let daemonRunning = getDaemonPid() !== null;
|
||||
const hasAdminToken = Boolean(existing?.adminToken);
|
||||
|
||||
const defaultPort = opts.defaultPort ?? 14242;
|
||||
const host = opts.host;
|
||||
|
||||
// If the caller explicitly asked for a port that differs from the saved
|
||||
// .env port, force config regeneration. Otherwise meta.json and .env would
|
||||
// drift: the daemon still binds to the saved GATEWAY_PORT while meta +
|
||||
// health checks believe the daemon is on the override port.
|
||||
//
|
||||
// We track this as a separate `forcePortRegen` flag so the corrupt-
|
||||
// partial-state guard below does not mistake an intentional override
|
||||
// regeneration for half-written config from a crashed install.
|
||||
let forcePortRegen = false;
|
||||
if (hasConfig && opts.portOverride !== undefined) {
|
||||
const savedPort = readPortFromEnv(ENV_FILE);
|
||||
if (savedPort !== null && savedPort !== opts.portOverride) {
|
||||
p.log(
|
||||
`Port override (${opts.portOverride.toString()}) differs from saved GATEWAY_PORT=${savedPort.toString()} — regenerating config.`,
|
||||
);
|
||||
hasConfig = false;
|
||||
forcePortRegen = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Corrupt partial state — refuse. (Skip when we intentionally forced
|
||||
// regeneration due to a port-override mismatch; in that case both files
|
||||
// are present and `hasConfig` was deliberately cleared.)
|
||||
if ((envExists || mosaicConfigExists) && !hasConfig && !forcePortRegen) {
|
||||
p.warn('Gateway install is in a corrupt partial state:');
|
||||
p.log(` .env file: ${envExists ? 'present' : 'MISSING'} (${ENV_FILE})`);
|
||||
p.log(
|
||||
` mosaic.config.json: ${mosaicConfigExists ? 'present' : 'MISSING'} (${MOSAIC_CONFIG_FILE})`,
|
||||
);
|
||||
p.log('\nRun `mosaic gateway uninstall` to clean up, then re-run install.');
|
||||
return { ready: false };
|
||||
}
|
||||
|
||||
// Already fully installed path — ask whether to re-run config.
|
||||
let explicitReinstall = false;
|
||||
if (existing && hasConfig && daemonRunning && hasAdminToken) {
|
||||
p.note(
|
||||
[
|
||||
`Gateway is already installed and running (v${existing.version}).`,
|
||||
` Endpoint: http://${existing.host}:${existing.port.toString()}`,
|
||||
` Status: mosaic gateway status`,
|
||||
'',
|
||||
'Re-running the config wizard will:',
|
||||
' - regenerate .env and mosaic.config.json',
|
||||
' - restart the daemon',
|
||||
' - preserve BETTER_AUTH_SECRET (sessions stay valid)',
|
||||
' - clear the stored admin token (you will re-bootstrap an admin user)',
|
||||
' - allow changing storage tier / DB URLs (may point at a different data store)',
|
||||
'To wipe persisted data, run `mosaic gateway uninstall` first.',
|
||||
].join('\n'),
|
||||
'Gateway already installed',
|
||||
);
|
||||
|
||||
const rerun = await p.confirm({
|
||||
message: 'Re-run config wizard?',
|
||||
initialValue: false,
|
||||
});
|
||||
if (!rerun) {
|
||||
// Not rewriting config — the daemon is still listening on
|
||||
// `existing.port`, so downstream callers must use that even if the
|
||||
// user passed a --port override. An override only applies when the
|
||||
// user agrees to a rerun (handled in the regeneration branch below).
|
||||
state.gateway = {
|
||||
host: existing.host,
|
||||
port: existing.port,
|
||||
tier: 'local',
|
||||
corsOrigin: 'http://localhost:3000',
|
||||
regeneratedConfig: false,
|
||||
};
|
||||
return { ready: true, host: existing.host, port: existing.port };
|
||||
}
|
||||
hasConfig = false;
|
||||
explicitReinstall = true;
|
||||
} else if (existing && (hasConfig || daemonRunning)) {
|
||||
p.log('Detected a partial gateway installation — resuming setup.\n');
|
||||
}
|
||||
|
||||
// Stop daemon before rewriting config.
|
||||
if (!hasConfig && daemonRunning) {
|
||||
p.log('Stopping gateway daemon before writing new config...');
|
||||
try {
|
||||
await stopDaemon();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!/not running/i.test(msg)) {
|
||||
p.warn(`Failed to stop running daemon: ${msg}`);
|
||||
p.warn('Refusing to rewrite config while an unknown-state daemon is running.');
|
||||
return { ready: false };
|
||||
}
|
||||
}
|
||||
if (getDaemonPid() !== null) {
|
||||
p.warn('Gateway daemon is still running after stop attempt. Aborting.');
|
||||
return { ready: false };
|
||||
}
|
||||
daemonRunning = false;
|
||||
}
|
||||
|
||||
// Install the gateway npm package on first install or after failure.
|
||||
if (!opts.skipInstall && !daemonRunning) {
|
||||
installGatewayPackage();
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
|
||||
// Collect configuration.
|
||||
const regeneratedConfig = !hasConfig;
|
||||
let port: number;
|
||||
let gatewayState: GatewayState;
|
||||
|
||||
if (hasConfig) {
|
||||
const envPort = readPortFromEnv(ENV_FILE);
|
||||
// Explicit --port override wins even on resume so users can recover from
|
||||
// a conflicting saved port without wiping config first.
|
||||
port = opts.portOverride ?? envPort ?? existing?.port ?? defaultPort;
|
||||
p.log(`Using existing config at ${ENV_FILE} (port ${port.toString()})`);
|
||||
gatewayState = {
|
||||
host,
|
||||
port,
|
||||
tier: 'local',
|
||||
corsOrigin: 'http://localhost:3000',
|
||||
regeneratedConfig: false,
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
gatewayState = await collectAndWriteConfig(p, {
|
||||
host,
|
||||
defaultPort: opts.portOverride ?? defaultPort,
|
||||
envFile: ENV_FILE,
|
||||
mosaicConfigFile: MOSAIC_CONFIG_FILE,
|
||||
gatewayHome: GATEWAY_HOME,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof GatewayConfigValidationError) {
|
||||
p.warn(err.message);
|
||||
return { ready: false };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
port = gatewayState.port;
|
||||
}
|
||||
|
||||
state.gateway = gatewayState;
|
||||
|
||||
// Write meta.json.
|
||||
let entryPoint: string;
|
||||
try {
|
||||
entryPoint = resolveGatewayEntry();
|
||||
} catch {
|
||||
p.warn(
|
||||
'Gateway package not found after install. Check that @mosaicstack/gateway installed correctly.',
|
||||
);
|
||||
return { ready: false };
|
||||
}
|
||||
|
||||
const version = getInstalledGatewayVersion() ?? 'unknown';
|
||||
const preserveToken = !regeneratedConfig && Boolean(existing?.adminToken);
|
||||
const meta = {
|
||||
version,
|
||||
installedAt: explicitReinstall
|
||||
? new Date().toISOString()
|
||||
: (existing?.installedAt ?? new Date().toISOString()),
|
||||
entryPoint,
|
||||
host,
|
||||
port,
|
||||
...(preserveToken && existing?.adminToken ? { adminToken: existing.adminToken } : {}),
|
||||
};
|
||||
writeMeta(meta);
|
||||
|
||||
// Start the daemon.
|
||||
if (!daemonRunning) {
|
||||
p.log('Starting gateway daemon...');
|
||||
try {
|
||||
const pid = startDaemon();
|
||||
p.log(`Gateway started (PID ${pid.toString()})`);
|
||||
} catch (err) {
|
||||
p.warn(`Failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
||||
printLogTailViaPrompter(p, LOG_FILE);
|
||||
return { ready: false };
|
||||
}
|
||||
} else {
|
||||
p.log('Gateway daemon is already running.');
|
||||
}
|
||||
|
||||
// Wait for health.
|
||||
p.log('Waiting for gateway to become healthy...');
|
||||
const healthy = await waitForHealth(host, port, 30_000);
|
||||
if (!healthy) {
|
||||
p.warn('Gateway did not become healthy within 30 seconds.');
|
||||
printLogTailViaPrompter(p, LOG_FILE);
|
||||
p.warn('Fix the underlying error above, then re-run `mosaic gateway install`.');
|
||||
return { ready: false };
|
||||
}
|
||||
p.log('Gateway is healthy.');
|
||||
|
||||
return { ready: true, host, port };
|
||||
}
|
||||
|
||||
// ── Config collection ─────────────────────────────────────────────────────────
|
||||
|
||||
interface CollectOptions {
|
||||
host: string;
|
||||
defaultPort: number;
|
||||
envFile: string;
|
||||
mosaicConfigFile: string;
|
||||
gatewayHome: string;
|
||||
}
|
||||
|
||||
/** Raised by the config stage when headless env validation fails. */
|
||||
export class GatewayConfigValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'GatewayConfigValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
async function collectAndWriteConfig(
|
||||
p: WizardPrompter,
|
||||
opts: CollectOptions,
|
||||
): Promise<GatewayState> {
|
||||
p.note('Collecting gateway configuration', 'Gateway Configuration');
|
||||
|
||||
// Preserve existing BETTER_AUTH_SECRET if an .env survives on disk.
|
||||
const preservedAuthSecret = readEnvVarFromFile(opts.envFile, 'BETTER_AUTH_SECRET');
|
||||
if (preservedAuthSecret) {
|
||||
p.log('(Preserving existing BETTER_AUTH_SECRET — auth sessions will remain valid.)');
|
||||
}
|
||||
|
||||
let tier: GatewayStorageTier;
|
||||
let port: number;
|
||||
let databaseUrl: string | undefined;
|
||||
let valkeyUrl: string | undefined;
|
||||
let anthropicKey: string;
|
||||
let corsOrigin: string;
|
||||
|
||||
if (isHeadless()) {
|
||||
p.log('Headless mode detected — reading configuration from environment variables.');
|
||||
|
||||
const storageTierEnv = process.env['MOSAIC_STORAGE_TIER'] ?? 'local';
|
||||
tier = storageTierEnv === 'team' ? 'team' : 'local';
|
||||
|
||||
const portEnv = process.env['MOSAIC_GATEWAY_PORT'];
|
||||
port = portEnv ? parseInt(portEnv, 10) : opts.defaultPort;
|
||||
|
||||
databaseUrl = process.env['MOSAIC_DATABASE_URL'];
|
||||
valkeyUrl = process.env['MOSAIC_VALKEY_URL'];
|
||||
anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
|
||||
corsOrigin = process.env['MOSAIC_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||
|
||||
if (tier === 'team') {
|
||||
const missing: string[] = [];
|
||||
if (!databaseUrl) missing.push('MOSAIC_DATABASE_URL');
|
||||
if (!valkeyUrl) missing.push('MOSAIC_VALKEY_URL');
|
||||
if (missing.length > 0) {
|
||||
throw new GatewayConfigValidationError(
|
||||
'Headless install with tier=team requires env vars: ' + missing.join(', '),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tier = await promptTier(p);
|
||||
port = await promptPort(p, opts.defaultPort);
|
||||
|
||||
if (tier === 'team') {
|
||||
databaseUrl = await p.text({
|
||||
message: 'DATABASE_URL',
|
||||
defaultValue: 'postgresql://mosaic:mosaic@localhost:5433/mosaic',
|
||||
});
|
||||
valkeyUrl = await p.text({
|
||||
message: 'VALKEY_URL',
|
||||
defaultValue: 'redis://localhost:6380',
|
||||
});
|
||||
}
|
||||
|
||||
anthropicKey = await p.text({
|
||||
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
corsOrigin = await p.text({
|
||||
message: 'CORS origin',
|
||||
defaultValue: 'http://localhost:3000',
|
||||
});
|
||||
}
|
||||
|
||||
const authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex');
|
||||
|
||||
const envLines = [
|
||||
`GATEWAY_PORT=${port.toString()}`,
|
||||
`BETTER_AUTH_SECRET=${authSecret}`,
|
||||
`BETTER_AUTH_URL=http://${opts.host}:${port.toString()}`,
|
||||
`GATEWAY_CORS_ORIGIN=${corsOrigin}`,
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318`,
|
||||
`OTEL_SERVICE_NAME=mosaic-gateway`,
|
||||
];
|
||||
|
||||
if (tier === 'team' && databaseUrl && valkeyUrl) {
|
||||
envLines.push(`DATABASE_URL=${databaseUrl}`);
|
||||
envLines.push(`VALKEY_URL=${valkeyUrl}`);
|
||||
}
|
||||
|
||||
if (anthropicKey) {
|
||||
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
|
||||
}
|
||||
|
||||
writeFileSync(opts.envFile, envLines.join('\n') + '\n', { mode: 0o600 });
|
||||
p.log(`Config written to ${opts.envFile}`);
|
||||
|
||||
const mosaicConfig =
|
||||
tier === 'local'
|
||||
? {
|
||||
tier: 'local',
|
||||
storage: { type: 'pglite', dataDir: join(opts.gatewayHome, 'storage-pglite') },
|
||||
queue: { type: 'local', dataDir: join(opts.gatewayHome, 'queue') },
|
||||
memory: { type: 'keyword' },
|
||||
}
|
||||
: {
|
||||
tier: 'team',
|
||||
storage: { type: 'postgres', url: databaseUrl },
|
||||
queue: { type: 'bullmq', url: valkeyUrl },
|
||||
memory: { type: 'pgvector' },
|
||||
};
|
||||
|
||||
writeFileSync(opts.mosaicConfigFile, JSON.stringify(mosaicConfig, null, 2) + '\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
p.log(`Config written to ${opts.mosaicConfigFile}`);
|
||||
|
||||
return {
|
||||
host: opts.host,
|
||||
port,
|
||||
tier,
|
||||
databaseUrl,
|
||||
valkeyUrl,
|
||||
anthropicKey: anthropicKey || undefined,
|
||||
corsOrigin,
|
||||
regeneratedConfig: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Log tail ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function printLogTailViaPrompter(p: WizardPrompter, logFile: string, maxLines = 30): void {
|
||||
if (!existsSync(logFile)) {
|
||||
p.warn(`(no log file at ${logFile})`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const lines = readFileSync(logFile, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim().length > 0);
|
||||
const tail = lines.slice(-maxLines);
|
||||
if (tail.length === 0) {
|
||||
p.warn('(log file is empty)');
|
||||
return;
|
||||
}
|
||||
p.note(tail.join('\n'), `Last ${tail.length.toString()} log lines`);
|
||||
} catch (err) {
|
||||
p.warn(`Could not read log file: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,30 @@ export interface HooksState {
|
||||
acceptedAt?: string;
|
||||
}
|
||||
|
||||
export type GatewayStorageTier = 'local' | 'team';
|
||||
|
||||
export interface GatewayAdminState {
|
||||
name: string;
|
||||
email: string;
|
||||
/** Plaintext password held in memory only for the duration of the wizard run. */
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface GatewayState {
|
||||
host: string;
|
||||
port: number;
|
||||
tier: GatewayStorageTier;
|
||||
databaseUrl?: string;
|
||||
valkeyUrl?: string;
|
||||
anthropicKey?: string;
|
||||
corsOrigin: string;
|
||||
/** True when .env + mosaic.config.json were (re)generated in this run. */
|
||||
regeneratedConfig?: boolean;
|
||||
admin?: GatewayAdminState;
|
||||
/** Populated after bootstrap/setup succeeds. */
|
||||
adminTokenIssued?: boolean;
|
||||
}
|
||||
|
||||
export interface WizardState {
|
||||
mosaicHome: string;
|
||||
sourceDir: string;
|
||||
@@ -56,4 +80,5 @@ export interface WizardState {
|
||||
runtimes: RuntimeState;
|
||||
selectedSkills: string[];
|
||||
hooks?: HooksState;
|
||||
gateway?: GatewayState;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { WizardPrompter } from './prompter/interface.js';
|
||||
import type { ConfigService } from './config/config-service.js';
|
||||
import type { WizardState } from './types.js';
|
||||
@@ -14,25 +11,8 @@ import { runtimeSetupStage } from './stages/runtime-setup.js';
|
||||
import { hooksPreviewStage } from './stages/hooks-preview.js';
|
||||
import { skillsSelectStage } from './stages/skills-select.js';
|
||||
import { finalizeStage } from './stages/finalize.js';
|
||||
|
||||
// ─── Transient install session state (CU-07-02) ───────────────────────────────
|
||||
|
||||
const INSTALL_STATE_FILE = join(
|
||||
process.env['XDG_RUNTIME_DIR'] ?? process.env['TMPDIR'] ?? tmpdir(),
|
||||
'mosaic-install-state.json',
|
||||
);
|
||||
|
||||
function writeInstallState(mosaicHome: string): void {
|
||||
try {
|
||||
const state = {
|
||||
wizardCompletedAt: new Date().toISOString(),
|
||||
mosaicHome,
|
||||
};
|
||||
writeFileSync(INSTALL_STATE_FILE, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
||||
} catch {
|
||||
// Non-fatal — gateway install will just ask for home again
|
||||
}
|
||||
}
|
||||
import { gatewayConfigStage } from './stages/gateway-config.js';
|
||||
import { gatewayBootstrapStage } from './stages/gateway-bootstrap.js';
|
||||
|
||||
export interface WizardOptions {
|
||||
mosaicHome: string;
|
||||
@@ -40,6 +20,25 @@ export interface WizardOptions {
|
||||
prompter: WizardPrompter;
|
||||
configService: ConfigService;
|
||||
cliOverrides?: Partial<WizardState>;
|
||||
/**
|
||||
* Skip the terminal gateway stages. Used by callers that only want to
|
||||
* configure the framework (SOUL.md/USER.md/skills/hooks) without touching
|
||||
* the gateway daemon. Defaults to `false` — the unified first-run flow
|
||||
* runs everything end-to-end.
|
||||
*/
|
||||
skipGateway?: boolean;
|
||||
/** Host passed through to the gateway config stage. Defaults to localhost. */
|
||||
gatewayHost?: string;
|
||||
/** Default gateway port (14242) — overridable by CLI flag. */
|
||||
gatewayPort?: number;
|
||||
/**
|
||||
* Explicit port override from the caller. Honored even when resuming
|
||||
* from an existing `.env` (useful when the saved port conflicts with
|
||||
* another service).
|
||||
*/
|
||||
gatewayPortOverride?: number;
|
||||
/** Skip `npm install -g @mosaicstack/gateway` during the config stage. */
|
||||
skipGatewayNpmInstall?: boolean;
|
||||
}
|
||||
|
||||
export async function runWizard(options: WizardOptions): Promise<void> {
|
||||
@@ -116,10 +115,49 @@ export async function runWizard(options: WizardOptions): Promise<void> {
|
||||
// Stage 9: Skills Selection
|
||||
await skillsSelectStage(prompter, state);
|
||||
|
||||
// Stage 10: Finalize
|
||||
// Stage 10: Finalize (writes configs, links runtime assets, runs doctor)
|
||||
await finalizeStage(prompter, state, configService);
|
||||
|
||||
// CU-07-02: Write transient session state so `mosaic gateway install` can
|
||||
// pick up mosaicHome without re-prompting.
|
||||
writeInstallState(state.mosaicHome);
|
||||
// Stages 11 & 12: Gateway config + admin bootstrap.
|
||||
// The unified first-run flow runs these as terminal stages so the user
|
||||
// goes from "welcome" through "admin user created" in a single cohesive
|
||||
// experience. Callers that only want the framework portion pass
|
||||
// `skipGateway: true`.
|
||||
if (!options.skipGateway) {
|
||||
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
|
||||
try {
|
||||
const configResult = await gatewayConfigStage(prompter, state, {
|
||||
host: options.gatewayHost ?? 'localhost',
|
||||
defaultPort: options.gatewayPort ?? 14242,
|
||||
portOverride: options.gatewayPortOverride,
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
});
|
||||
|
||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||
if (headlessRun) {
|
||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed && headlessRun) {
|
||||
prompter.warn('Admin bootstrap failed in headless mode — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Stages normally return structured `ready: false` results for
|
||||
// expected failures. Anything that reaches here is an unexpected
|
||||
// runtime error — render a concise warning for UX AND re-throw so
|
||||
// the CLI (and `tools/install.sh` auto-launch) sees a non-zero exit.
|
||||
// Swallowing here would let headless installs report success even
|
||||
// when the gateway stage crashed.
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,15 +423,18 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
||||
if [[ ! -f "$MOSAIC_HOME/SOUL.md" ]]; then
|
||||
echo ""
|
||||
if [[ "$FLAG_NO_AUTO_LAUNCH" == "false" ]] && [[ -t 0 ]] && [[ -t 1 ]]; then
|
||||
# Interactive TTY and auto-launch not suppressed: run wizard + gateway install
|
||||
info "First install detected — launching setup wizard…"
|
||||
# Interactive TTY and auto-launch not suppressed: run the unified wizard.
|
||||
# `mosaic wizard` now runs the full first-run flow end-to-end: identity
|
||||
# setup → runtimes → hooks preview → skills → finalize → gateway
|
||||
# config → admin bootstrap. No second call needed.
|
||||
info "First install detected — launching unified setup wizard…"
|
||||
echo ""
|
||||
|
||||
MOSAIC_BIN="$PREFIX/bin/mosaic"
|
||||
|
||||
if ! command -v "$MOSAIC_BIN" &>/dev/null && ! command -v mosaic &>/dev/null; then
|
||||
warn "mosaic binary not found on PATH — skipping auto-launch."
|
||||
warn "Add $PREFIX/bin to PATH and run: mosaic wizard && mosaic gateway install"
|
||||
warn "Add $PREFIX/bin to PATH and run: mosaic wizard"
|
||||
else
|
||||
# Prefer the absolute path from the prefix we just installed to
|
||||
MOSAIC_CMD="mosaic"
|
||||
@@ -439,28 +442,19 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
||||
MOSAIC_CMD="$MOSAIC_BIN"
|
||||
fi
|
||||
|
||||
# Run wizard; if it fails we still try gateway install (best effort)
|
||||
if "$MOSAIC_CMD" wizard; then
|
||||
ok "Wizard complete."
|
||||
else
|
||||
warn "Wizard exited non-zero — continuing to gateway install."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "Launching gateway install…"
|
||||
if "$MOSAIC_CMD" gateway install; then
|
||||
ok "Gateway install complete."
|
||||
else
|
||||
warn "Gateway install exited non-zero."
|
||||
echo " You can retry with: ${C}mosaic gateway install${RESET}"
|
||||
warn "Wizard exited non-zero."
|
||||
echo " You can retry with: ${C}mosaic wizard${RESET}"
|
||||
echo " Or run gateway install alone: ${C}mosaic gateway install${RESET}"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# Non-interactive or --no-auto-launch: print guidance only
|
||||
info "First install detected. Set up your agent identity:"
|
||||
echo " ${C}mosaic init${RESET} (interactive SOUL.md / USER.md setup)"
|
||||
echo " ${C}mosaic wizard${RESET} (full guided wizard via Node.js)"
|
||||
echo " ${C}mosaic gateway install${RESET} (install and start the gateway)"
|
||||
echo " ${C}mosaic wizard${RESET} (unified first-run wizard — identity + gateway + admin)"
|
||||
echo " ${C}mosaic gateway install${RESET} (standalone gateway (re)configure)"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user