import { randomBytes } from 'node:crypto'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { createInterface } from 'node:readline'; import type { GatewayMeta } from './daemon.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'); interface InstallOpts { host: string; port: number; skipInstall?: boolean; } function prompt(rl: ReturnType, question: string): Promise { return new Promise((resolve) => rl.question(question, resolve)); } 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 { 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`); } 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'); } 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'; const tier = tierAnswer === '2' ? 'team' : 'local'; const port = opts.port !== 14242 ? opts.port : parseInt( (await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(), 10, ); let databaseUrl: string | undefined; let valkeyUrl: string | undefined; 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'; } const anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): '); const 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'); const name = (await prompt(rl, 'Admin name: ')).trim(); if (!name) { console.error('Name is required.'); return; } const email = (await prompt(rl, 'Admin email: ')).trim(); if (!email) { console.error('Email is required.'); return; } const password = (await prompt(rl, 'Admin password (min 8 chars): ')).trim(); if (password.length < 8) { console.error('Password must be at least 8 characters.'); return; } 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)}`); } }