import { randomBytes } from 'node:crypto'; 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 { 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 } } interface InstallOpts { host: string; port: number; 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 { 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(); } } 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; 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 } : {}), }; 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.'); } // 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'); // 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()}`; 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 }), }); if (!res.ok) { const body = await res.text().catch(() => ''); console.error(`Bootstrap failed (${res.status.toString()}): ${body}`); return; } const result = (await res.json()) as { user: { id: string; email: string }; token: { plaintext: string }; }; // Persist the token so future CLI calls can authenticate automatically. meta.adminToken = result.token.plaintext; writeMeta(meta); console.log(`\nAdmin user created: ${result.user.email}`); printAdminTokenBanner(result.token.plaintext); } catch (err) { console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`); } }