feat(mosaic): unified first-run UX wizard -> gateway install -> verify (#418)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #418.
This commit is contained in:
2026-04-05 07:29:17 +00:00
parent a531029c5b
commit 872c124581
6 changed files with 471 additions and 20 deletions

View File

@@ -188,6 +188,16 @@ export function registerGatewayCommand(program: Command): void {
runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) });
});
// ─── verify ─────────────────────────────────────────────────────────────
gw.command('verify')
.description('Verify the gateway installation (health, token, bootstrap endpoint)')
.action(async () => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
const { runVerify } = await import('./gateway/verify.js');
await runVerify(opts);
});
// ─── uninstall ──────────────────────────────────────────────────────────
gw.command('uninstall')

View File

@@ -1,6 +1,7 @@
import { randomBytes } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import { createInterface } from 'node:readline';
import type { GatewayMeta } from './daemon.js';
import {
@@ -21,6 +22,39 @@ import {
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
}
}
interface InstallOpts {
host: string;
port: number;
@@ -41,6 +75,30 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
}
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
// CU-07-02: Check for a fresh wizard session state and apply it.
const sessionState = readInstallState();
if (sessionState) {
const defaultHome = join(homedir(), '.config', 'mosaic');
const customHome = sessionState.mosaicHome !== defaultHome ? sessionState.mosaicHome : null;
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);
@@ -218,6 +276,13 @@ async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOp
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(

View File

@@ -0,0 +1,117 @@
import { readMeta } from './daemon.js';
// ANSI colour helpers (gracefully degrade when not a TTY)
const isTTY = Boolean(process.stdout.isTTY);
const G = isTTY ? '\x1b[0;32m' : '';
const R = isTTY ? '\x1b[0;31m' : '';
const BOLD = isTTY ? '\x1b[1m' : '';
const RESET = isTTY ? '\x1b[0m' : '';
function ok(label: string): void {
process.stdout.write(` ${G}${RESET} ${label.padEnd(36)}${G}[ok]${RESET}\n`);
}
function fail(label: string, hint: string): void {
process.stdout.write(` ${R}${RESET} ${label.padEnd(36)}${R}[FAIL]${RESET}\n`);
process.stdout.write(` ${R}${hint}${RESET}\n`);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWithRetry(
url: string,
opts: RequestInit = {},
retries = 3,
delayMs = 1000,
): Promise<Response | null> {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const res = await fetch(url, opts);
// Retry on non-OK responses too — the gateway may still be starting up
// (e.g. 503 before the app bootstrap completes).
if (res.ok) return res;
} catch {
// Network-level error — not ready yet, will retry
}
if (attempt < retries - 1) await sleep(delayMs);
}
return null;
}
export interface VerifyResult {
gatewayHealthy: boolean;
adminTokenOnFile: boolean;
bootstrapReachable: boolean;
allPassed: boolean;
}
/**
* Run post-install verification checks.
*
* @param host - Gateway hostname (e.g. "localhost")
* @param port - Gateway port (e.g. 14242)
* @returns VerifyResult — callers can inspect individual flags
*/
export async function runPostInstallVerification(
host: string,
port: number,
): Promise<VerifyResult> {
const baseUrl = `http://${host}:${port.toString()}`;
console.log(`\n${BOLD}Mosaic installation verified:${RESET}`);
// ─── Check 1: Gateway /health ─────────────────────────────────────────────
const healthRes = await fetchWithRetry(`${baseUrl}/health`);
const gatewayHealthy = healthRes !== null && healthRes.ok;
if (gatewayHealthy) {
ok('gateway healthy');
} else {
fail('gateway healthy', 'Run: mosaic gateway status / mosaic gateway logs');
}
// ─── Check 2: Admin token on file ─────────────────────────────────────────
const meta = readMeta();
const adminTokenOnFile = Boolean(meta?.adminToken && meta.adminToken.length > 0);
if (adminTokenOnFile) {
ok('admin token on file');
} else {
fail('admin token on file', 'Run: mosaic gateway config recover-token');
}
// ─── Check 3: Bootstrap endpoint reachable ────────────────────────────────
const bootstrapRes = await fetchWithRetry(`${baseUrl}/api/bootstrap/status`);
const bootstrapReachable = bootstrapRes !== null && bootstrapRes.ok;
if (bootstrapReachable) {
ok('bootstrap endpoint reach');
} else {
fail('bootstrap endpoint reach', 'Run: mosaic gateway status / mosaic gateway logs');
}
const allPassed = gatewayHealthy && adminTokenOnFile && bootstrapReachable;
if (!allPassed) {
console.log(
`\n${R}One or more checks failed.${RESET} Recovery commands listed above.\n` +
`Use ${BOLD}mosaic gateway status${RESET} and ${BOLD}mosaic gateway config recover-token${RESET} to investigate.\n`,
);
}
return { gatewayHealthy, adminTokenOnFile, bootstrapReachable, allPassed };
}
/**
* Standalone entry point for `mosaic gateway verify`.
* Reads host/port from meta.json if not provided.
*/
export async function runVerify(opts: { host?: string; port?: number }): Promise<void> {
const meta = readMeta();
const host = opts.host ?? meta?.host ?? 'localhost';
const port = opts.port ?? meta?.port ?? 14242;
const result = await runPostInstallVerification(host, port);
if (!result.allPassed) {
process.exit(1);
}
}

View File

@@ -1,3 +1,6 @@
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';
@@ -11,6 +14,25 @@ import { runtimeSetupStage } from './stages/runtime-setup.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
}
}
export interface WizardOptions {
mosaicHome: string;
sourceDir: string;
@@ -92,4 +114,8 @@ export async function runWizard(options: WizardOptions): Promise<void> {
// Stage 9: Finalize
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);
}