import { randomBytes } from 'node:crypto'; import { 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, ensureDirs, installGatewayPackage, readMeta, resolveGatewayEntry, startDaemon, waitForHealth, writeMeta, getInstalledGatewayVersion, } from './daemon.js'; 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 { // Check existing installation const existing = readMeta(); if (existing) { const answer = await prompt( rl, `Gateway already installed (v${existing.version}). Reinstall? [y/N] `, ); if (answer.toLowerCase() !== 'y') { console.log('Aborted.'); return; } } // Step 1: Install npm package if (!opts.skipInstall) { installGatewayPackage(); } ensureDirs(); // Step 2: Collect configuration console.log('\n─── Gateway Configuration ───\n'); // Tier selection 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'; // Generate auth secret const authSecret = randomBytes(32).toString('hex'); // Step 3: Write .env 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}`); // Step 3b: Write mosaic.config.json 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' }, }; const configFile = join(GATEWAY_HOME, 'mosaic.config.json'); writeFileSync(configFile, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 }); console.log(`Config written to ${configFile}`); // Step 4: Write meta.json 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'; const meta = { version, installedAt: new Date().toISOString(), entryPoint, host: opts.host, port, }; writeMeta(meta); // Step 5: Start the daemon 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)}`); return; } // Step 6: Wait for health console.log('Waiting for gateway to become healthy...'); const healthy = await waitForHealth(opts.host, port, 30_000); if (!healthy) { console.error('Gateway did not become healthy within 30 seconds.'); console.error(`Check logs: mosaic gateway logs`); return; } console.log('Gateway is healthy.\n'); // Step 7: Bootstrap — first user setup await bootstrapFirstUser(rl, opts.host, port, meta); console.log('\n─── Installation Complete ───'); console.log(` Endpoint: http://${opts.host}:${port.toString()}`); console.log(` Config: ${GATEWAY_HOME}`); console.log(` Logs: mosaic gateway logs`); console.log(` Status: mosaic gateway status`); } async function bootstrapFirstUser( rl: ReturnType, host: string, port: number, meta: Omit & { adminToken?: string }, ): 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) { console.log('Admin user already exists — skipping setup.'); 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 }; }; // Save admin token to meta meta.adminToken = result.token.plaintext; writeMeta(meta as GatewayMeta); console.log(`\nAdmin user created: ${result.user.email}`); console.log('Admin API token saved to gateway config.'); } catch (err) { console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`); } }