- Add PGlite (embedded Postgres) for local tier — gateway runs without external PG server. Same schema, same Drizzle API, zero module refactor. - Install wizard now offers tier selection (local vs team). Local skips DATABASE_URL/VALKEY_URL, writes mosaic.config.json. - Comment out npmjs publish step in CI (preserved for future use). - Revert gateway publishConfig to Gitea registry, include in Gitea publish. - Add repository field to all 23 publishable package.json files for Gitea package-to-repo linking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
260 lines
7.5 KiB
TypeScript
260 lines
7.5 KiB
TypeScript
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<typeof createInterface>, question: string): Promise<string> {
|
|
return new Promise((resolve) => rl.question(question, resolve));
|
|
}
|
|
|
|
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> {
|
|
// 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 !== 4000
|
|
? 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: 'sqlite', path: join(GATEWAY_HOME, 'data.db') },
|
|
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<typeof createInterface>,
|
|
host: string,
|
|
port: number,
|
|
meta: Omit<GatewayMeta, 'adminToken'> & { adminToken?: string },
|
|
): 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) {
|
|
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)}`);
|
|
}
|
|
}
|