diff --git a/docs/scratchpads/install-ux-hardening-20260405.md b/docs/scratchpads/install-ux-hardening-20260405.md index 298297d..5167ec9 100644 --- a/docs/scratchpads/install-ux-hardening-20260405.md +++ b/docs/scratchpads/install-ux-hardening-20260405.md @@ -156,3 +156,84 @@ 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 (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. diff --git a/packages/mosaic/__tests__/integration/full-wizard.test.ts b/packages/mosaic/__tests__/integration/full-wizard.test.ts index b045f7f..da1d6da 100644 --- a/packages/mosaic/__tests__/integration/full-wizard.test.ts +++ b/packages/mosaic/__tests__/integration/full-wizard.test.ts @@ -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', diff --git a/packages/mosaic/__tests__/integration/unified-wizard.test.ts b/packages/mosaic/__tests__/integration/unified-wizard.test.ts new file mode 100644 index 0000000..169f945 --- /dev/null +++ b/packages/mosaic/__tests__/integration/unified-wizard.test.ts @@ -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(); + }); +}); diff --git a/packages/mosaic/framework/runtime/claude/hooks-config.json b/packages/mosaic/framework/runtime/claude/hooks-config.json index 8c13bd8..21d57b9 100644 --- a/packages/mosaic/framework/runtime/claude/hooks-config.json +++ b/packages/mosaic/framework/runtime/claude/hooks-config.json @@ -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": [ diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets b/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets index 9bf2a9e..a6b71c2 100755 --- a/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets +++ b/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets @@ -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" diff --git a/packages/mosaic/src/commands/gateway/install.ts b/packages/mosaic/src/commands/gateway/install.ts index 7c2c1fc..84b07c0 100644 --- a/packages/mosaic/src/commands/gateway/install.ts +++ b/packages/mosaic/src/commands/gateway/install.ts @@ -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, question: string): Promise { - 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 { - 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, opts: InstallOpts): Promise { - // 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, - opts: InstallOpts, -): Promise { - 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 status`); - console.log(border); -} - -async function bootstrapFirstUser( - rl: ReturnType, - host: string, - port: number, - meta: GatewayMeta, -): Promise { - 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; } } diff --git a/packages/mosaic/src/stages/finalize.ts b/packages/mosaic/src/stages/finalize.ts index e13a654..aa975df 100644 --- a/packages/mosaic/src/stages/finalize.ts +++ b/packages/mosaic/src/stages/finalize.ts @@ -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) { diff --git a/packages/mosaic/src/stages/gateway-bootstrap.spec.ts b/packages/mosaic/src/stages/gateway-bootstrap.spec.ts new file mode 100644 index 0000000..fd15fbb --- /dev/null +++ b/packages/mosaic/src/stages/gateway-bootstrap.spec.ts @@ -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> = {}) { + 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); + }); +}); diff --git a/packages/mosaic/src/stages/gateway-bootstrap.ts b/packages/mosaic/src/stages/gateway-bootstrap.ts new file mode 100644 index 0000000..fd7bc1e --- /dev/null +++ b/packages/mosaic/src/stages/gateway-bootstrap.ts @@ -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 { + 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 status', + ].join('\n'); + p.note(body, 'Admin API Token'); +} diff --git a/packages/mosaic/src/stages/gateway-config.spec.ts b/packages/mosaic/src/stages/gateway-config.spec.ts new file mode 100644 index 0000000..b4173b3 --- /dev/null +++ b/packages/mosaic/src/stages/gateway-config.spec.ts @@ -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> = {}) { + 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}`); + }); +}); diff --git a/packages/mosaic/src/stages/gateway-config.ts b/packages/mosaic/src/stages/gateway-config.ts new file mode 100644 index 0000000..88b1fd0 --- /dev/null +++ b/packages/mosaic/src/stages/gateway-config.ts @@ -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 { + const tier = await p.select({ + 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 { + 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 { + // 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 { + 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)}`); + } +} diff --git a/packages/mosaic/src/types.ts b/packages/mosaic/src/types.ts index d0944d5..a5ea880 100644 --- a/packages/mosaic/src/types.ts +++ b/packages/mosaic/src/types.ts @@ -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; } diff --git a/packages/mosaic/src/wizard.ts b/packages/mosaic/src/wizard.ts index f1f8898..2f5e640 100644 --- a/packages/mosaic/src/wizard.ts +++ b/packages/mosaic/src/wizard.ts @@ -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; + /** + * 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 { @@ -116,10 +115,49 @@ export async function runWizard(options: WizardOptions): Promise { // 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; + } + } } diff --git a/tools/install.sh b/tools/install.sh index 14db8fe..518411f 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -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