625 lines
23 KiB
TypeScript
625 lines
23 KiB
TypeScript
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<typeof createInterface>, question: string): Promise<string> {
|
|
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<void> {
|
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
try {
|
|
await doInstall(rl, opts);
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|
|
|
|
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);
|
|
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<typeof createInterface>,
|
|
opts: InstallOpts,
|
|
): Promise<number> {
|
|
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 <token> status`);
|
|
console.log(border);
|
|
}
|
|
|
|
async function bootstrapFirstUser(
|
|
rl: ReturnType<typeof createInterface>,
|
|
host: string,
|
|
port: number,
|
|
meta: GatewayMeta,
|
|
): Promise<void> {
|
|
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)}`);
|
|
}
|
|
}
|