Files
stack/packages/mosaic/src/wizard.ts
Jarvis f3d5ef8d7d
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
feat: unified first-run flow — merge wizard + gateway install (IUH-M03)
Collapse `mosaic wizard` and `mosaic gateway install` into a single cohesive
first-run experience. Gateway config and admin bootstrap now run as terminal
stages of `runWizard`, sharing `WizardState` with the framework stages and
eliminating the fragile 10-minute `$XDG_RUNTIME_DIR/mosaic-install-state.json`
session-file bridge.

- Extract `gatewayConfigStage` and `gatewayBootstrapStage` as first-class
  wizard stages with full spec coverage (headless + interactive paths).
- `mosaic gateway install` becomes a thin wrapper that invokes the same
  two stages — the CLI entry point is preserved for operators who only
  need to (re)configure the daemon.
- Honor explicit `--port` override even on resume: when the override
  differs from the saved GATEWAY_PORT, force a config regeneration so
  `.env` and `meta.json` cannot drift.
- Honor `state.hooks.accepted === false` in the finalize stage and in
  `mosaic-link-runtime-assets`: declined hooks are now actually opted-out,
  with a stable `mosaic-managed: true` marker in the template so cleanup
  survives template updates without touching user-owned configs.
- Headless rerun of an already-bootstrapped gateway with no local token
  cache is a successful no-op (no more false-positive install failures).
- `tools/install.sh` calls `mosaic wizard` only — the follow-up
  `mosaic gateway install` auto-launch is removed.

Closes mosaicstack/mosaic-stack#427.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 14:08:28 -05:00

164 lines
5.9 KiB
TypeScript

import type { WizardPrompter } from './prompter/interface.js';
import type { ConfigService } from './config/config-service.js';
import type { WizardState } from './types.js';
import { welcomeStage } from './stages/welcome.js';
import { detectInstallStage } from './stages/detect-install.js';
import { modeSelectStage } from './stages/mode-select.js';
import { soulSetupStage } from './stages/soul-setup.js';
import { userSetupStage } from './stages/user-setup.js';
import { toolsSetupStage } from './stages/tools-setup.js';
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';
import { gatewayConfigStage } from './stages/gateway-config.js';
import { gatewayBootstrapStage } from './stages/gateway-bootstrap.js';
export interface WizardOptions {
mosaicHome: string;
sourceDir: string;
prompter: WizardPrompter;
configService: ConfigService;
cliOverrides?: Partial<WizardState>;
/**
* Skip the terminal gateway stages. Used by callers that only want to
* configure the framework (SOUL.md/USER.md/skills/hooks) without touching
* the gateway daemon. Defaults to `false` — the unified first-run flow
* runs everything end-to-end.
*/
skipGateway?: boolean;
/** Host passed through to the gateway config stage. Defaults to localhost. */
gatewayHost?: string;
/** Default gateway port (14242) — overridable by CLI flag. */
gatewayPort?: number;
/**
* Explicit port override from the caller. Honored even when resuming
* from an existing `.env` (useful when the saved port conflicts with
* another service).
*/
gatewayPortOverride?: number;
/** Skip `npm install -g @mosaicstack/gateway` during the config stage. */
skipGatewayNpmInstall?: boolean;
}
export async function runWizard(options: WizardOptions): Promise<void> {
const { prompter, configService, mosaicHome, sourceDir } = options;
const state: WizardState = {
mosaicHome,
sourceDir,
mode: 'quick',
installAction: 'fresh',
soul: {},
user: {},
tools: {},
runtimes: { detected: [], mcpConfigured: false },
selectedSkills: [],
};
// Apply CLI overrides (strip undefined values)
if (options.cliOverrides) {
if (options.cliOverrides.soul) {
for (const [k, v] of Object.entries(options.cliOverrides.soul)) {
if (v !== undefined) {
(state.soul as Record<string, unknown>)[k] = v;
}
}
}
if (options.cliOverrides.user) {
for (const [k, v] of Object.entries(options.cliOverrides.user)) {
if (v !== undefined) {
(state.user as Record<string, unknown>)[k] = v;
}
}
}
if (options.cliOverrides.tools) {
for (const [k, v] of Object.entries(options.cliOverrides.tools)) {
if (v !== undefined) {
(state.tools as Record<string, unknown>)[k] = v;
}
}
}
if (options.cliOverrides.mode) {
state.mode = options.cliOverrides.mode;
}
}
// Stage 1: Welcome
await welcomeStage(prompter, state);
// Stage 2: Existing Install Detection
await detectInstallStage(prompter, state, configService);
// Stage 3: Quick Start vs Advanced (skip if keeping existing)
if (state.installAction === 'fresh' || state.installAction === 'reset') {
await modeSelectStage(prompter, state);
} else if (state.installAction === 'reconfigure') {
state.mode = 'advanced';
}
// Stage 4: SOUL.md
await soulSetupStage(prompter, state);
// Stage 5: USER.md
await userSetupStage(prompter, state);
// Stage 6: TOOLS.md
await toolsSetupStage(prompter, state);
// Stage 7: Runtime Detection & Installation
await runtimeSetupStage(prompter, state);
// Stage 8: Hooks preview (Claude only — skipped if Claude not detected)
await hooksPreviewStage(prompter, state);
// Stage 9: Skills Selection
await skillsSelectStage(prompter, state);
// Stage 10: Finalize (writes configs, links runtime assets, runs doctor)
await finalizeStage(prompter, state, configService);
// 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;
}
}
}