feat: mosaic gateway CLI daemon management + admin token auth (#369)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed

This commit was merged in pull request #369.
This commit is contained in:
2026-04-04 18:03:12 +00:00
parent 202e375f41
commit 39ccba95d0
16 changed files with 1325 additions and 188 deletions

View File

@@ -0,0 +1,143 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { ENV_FILE, getDaemonPid, readMeta, META_FILE, ensureDirs } from './daemon.js';
// Keys that should be masked in output
const SECRET_KEYS = new Set([
'BETTER_AUTH_SECRET',
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'ZAI_API_KEY',
'OPENROUTER_API_KEY',
'DISCORD_BOT_TOKEN',
'TELEGRAM_BOT_TOKEN',
]);
function maskValue(key: string, value: string): string {
if (SECRET_KEYS.has(key) && value.length > 8) {
return value.slice(0, 4) + '…' + value.slice(-4);
}
return value;
}
function parseEnvFile(): Map<string, string> {
const map = new Map<string, string>();
if (!existsSync(ENV_FILE)) return map;
const lines = readFileSync(ENV_FILE, 'utf-8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
map.set(trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1));
}
return map;
}
function writeEnvFile(entries: Map<string, string>): void {
ensureDirs();
const lines: string[] = [];
for (const [key, value] of entries) {
lines.push(`${key}=${value}`);
}
writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 });
}
interface ConfigOpts {
set?: string;
unset?: string;
edit?: boolean;
}
export async function runConfig(opts: ConfigOpts): Promise<void> {
// Set a value
if (opts.set) {
const eqIdx = opts.set.indexOf('=');
if (eqIdx === -1) {
console.error('Usage: mosaic gateway config --set KEY=VALUE');
process.exit(1);
}
const key = opts.set.slice(0, eqIdx);
const value = opts.set.slice(eqIdx + 1);
const entries = parseEnvFile();
entries.set(key, value);
writeEnvFile(entries);
console.log(`Set ${key}=${maskValue(key, value)}`);
promptRestart();
return;
}
// Unset a value
if (opts.unset) {
const entries = parseEnvFile();
if (!entries.has(opts.unset)) {
console.error(`Key not found: ${opts.unset}`);
process.exit(1);
}
entries.delete(opts.unset);
writeEnvFile(entries);
console.log(`Removed ${opts.unset}`);
promptRestart();
return;
}
// Open in editor
if (opts.edit) {
if (!existsSync(ENV_FILE)) {
console.error(`No config file found at ${ENV_FILE}`);
console.error('Run `mosaic gateway install` first.');
process.exit(1);
}
const editor = process.env['EDITOR'] ?? process.env['VISUAL'] ?? 'vi';
try {
execSync(`${editor} "${ENV_FILE}"`, { stdio: 'inherit' });
promptRestart();
} catch {
console.error('Editor exited with error.');
}
return;
}
// Default: show current config
showConfig();
}
function showConfig(): void {
if (!existsSync(ENV_FILE)) {
console.log('No gateway configuration found.');
console.log('Run `mosaic gateway install` to set up.');
return;
}
const entries = parseEnvFile();
const meta = readMeta();
console.log('Mosaic Gateway Configuration');
console.log('────────────────────────────');
console.log(` Config file: ${ENV_FILE}`);
console.log(` Meta file: ${META_FILE}`);
console.log();
if (entries.size === 0) {
console.log(' (empty)');
return;
}
const maxKeyLen = Math.max(...[...entries.keys()].map((k) => k.length));
for (const [key, value] of entries) {
const padding = ' '.repeat(maxKeyLen - key.length);
console.log(` ${key}${padding} ${maskValue(key, value)}`);
}
if (meta?.adminToken) {
console.log();
console.log(` Admin token: ${maskValue('token', meta.adminToken)}`);
}
}
function promptRestart(): void {
if (getDaemonPid() !== null) {
console.log('\nGateway is running — restart to apply changes: mosaic gateway restart');
}
}

View File

@@ -0,0 +1,253 @@
import { spawn, execSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
openSync,
constants,
} from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { createRequire } from 'node:module';
// ─── Paths ──────────────────────────────────────────────────────────────────
export const GATEWAY_HOME = resolve(
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
);
export const PID_FILE = join(GATEWAY_HOME, 'daemon.pid');
export const LOG_DIR = join(GATEWAY_HOME, 'logs');
export const LOG_FILE = join(LOG_DIR, 'gateway.log');
export const ENV_FILE = join(GATEWAY_HOME, '.env');
export const META_FILE = join(GATEWAY_HOME, 'meta.json');
// ─── Meta ───────────────────────────────────────────────────────────────────
export interface GatewayMeta {
version: string;
installedAt: string;
entryPoint: string;
adminToken?: string;
host: string;
port: number;
}
export function readMeta(): GatewayMeta | null {
if (!existsSync(META_FILE)) return null;
try {
return JSON.parse(readFileSync(META_FILE, 'utf-8')) as GatewayMeta;
} catch {
return null;
}
}
export function writeMeta(meta: GatewayMeta): void {
ensureDirs();
writeFileSync(META_FILE, JSON.stringify(meta, null, 2), { mode: 0o600 });
}
// ─── Directories ────────────────────────────────────────────────────────────
export function ensureDirs(): void {
mkdirSync(GATEWAY_HOME, { recursive: true, mode: 0o700 });
mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
}
// ─── PID management ─────────────────────────────────────────────────────────
export function readPid(): number | null {
if (!existsSync(PID_FILE)) return null;
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
return Number.isNaN(pid) ? null : pid;
} catch {
return null;
}
}
export function isRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export function getDaemonPid(): number | null {
const pid = readPid();
if (pid === null) return null;
return isRunning(pid) ? pid : null;
}
// ─── Entry point resolution ─────────────────────────────────────────────────
export function resolveGatewayEntry(): string {
// Check meta.json for custom entry point
const meta = readMeta();
if (meta?.entryPoint && existsSync(meta.entryPoint)) {
return meta.entryPoint;
}
// Try to resolve from globally installed @mosaicstack/gateway
try {
const req = createRequire(import.meta.url);
const pkgPath = req.resolve('@mosaicstack/gateway/package.json');
const mainEntry = join(resolve(pkgPath, '..'), 'dist', 'main.js');
if (existsSync(mainEntry)) return mainEntry;
} catch {
// Not installed globally via @mosaicstack
}
// Try @mosaic/gateway (workspace / dev)
try {
const req = createRequire(import.meta.url);
const pkgPath = req.resolve('@mosaic/gateway/package.json');
const mainEntry = join(resolve(pkgPath, '..'), 'dist', 'main.js');
if (existsSync(mainEntry)) return mainEntry;
} catch {
// Not available
}
throw new Error('Cannot find gateway entry point. Run `mosaic gateway install` first.');
}
// ─── Start / Stop / Health ──────────────────────────────────────────────────
export function startDaemon(): number {
const running = getDaemonPid();
if (running !== null) {
throw new Error(`Gateway is already running (PID ${running.toString()})`);
}
ensureDirs();
const entryPoint = resolveGatewayEntry();
// Load env vars from gateway .env
const env: Record<string, string> = { ...process.env } as Record<string, string>;
if (existsSync(ENV_FILE)) {
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) env[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
}
}
const logFd = openSync(LOG_FILE, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND);
const child = spawn('node', [entryPoint], {
detached: true,
stdio: ['ignore', logFd, logFd],
env,
cwd: GATEWAY_HOME,
});
if (!child.pid) {
throw new Error('Failed to spawn gateway process');
}
writeFileSync(PID_FILE, child.pid.toString(), { mode: 0o600 });
child.unref();
return child.pid;
}
export async function stopDaemon(timeoutMs = 10_000): Promise<void> {
const pid = getDaemonPid();
if (pid === null) {
throw new Error('Gateway is not running');
}
process.kill(pid, 'SIGTERM');
// Poll for exit
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (!isRunning(pid)) {
cleanPidFile();
return;
}
await sleep(250);
}
// Force kill
try {
process.kill(pid, 'SIGKILL');
} catch {
// Already dead
}
cleanPidFile();
}
function cleanPidFile(): void {
try {
unlinkSync(PID_FILE);
} catch {
// Ignore
}
}
export async function waitForHealth(
host: string,
port: number,
timeoutMs = 30_000,
): Promise<boolean> {
const start = Date.now();
let delay = 500;
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(`http://${host}:${port.toString()}/health`);
if (res.ok) return true;
} catch {
// Not ready yet
}
await sleep(delay);
delay = Math.min(delay * 1.5, 3000);
}
return false;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ─── npm install helper ─────────────────────────────────────────────────────
export function installGatewayPackage(): void {
console.log('Installing @mosaicstack/gateway...');
execSync('npm install -g @mosaicstack/gateway@latest', {
stdio: 'inherit',
timeout: 120_000,
});
}
export function uninstallGatewayPackage(): void {
try {
execSync('npm uninstall -g @mosaicstack/gateway', {
stdio: 'inherit',
timeout: 60_000,
});
} catch {
console.warn('Warning: npm uninstall may not have completed cleanly.');
}
}
export function getInstalledGatewayVersion(): string | null {
try {
const output = execSync('npm ls -g @mosaicstack/gateway --json --depth=0', {
encoding: 'utf-8',
timeout: 15_000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const data = JSON.parse(output) as {
dependencies?: { '@mosaicstack/gateway'?: { version?: string } };
};
return data.dependencies?.['@mosaicstack/gateway']?.version ?? null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,223 @@
import { randomBytes } from 'node:crypto';
import { writeFileSync } from 'node:fs';
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');
const port =
opts.port !== 4000
? opts.port
: parseInt(
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
10,
);
const databaseUrl =
(await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) ||
'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const 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()}`,
`DATABASE_URL=${databaseUrl}`,
`VALKEY_URL=${valkeyUrl}`,
`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 (anthropicKey) {
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
}
writeFileSync(ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 });
console.log(`\nConfig written to ${ENV_FILE}`);
// 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)}`);
}
}

View File

@@ -0,0 +1,37 @@
import { existsSync, readFileSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { LOG_FILE } from './daemon.js';
interface LogsOpts {
follow?: boolean;
lines?: number;
}
export function runLogs(opts: LogsOpts): void {
if (!existsSync(LOG_FILE)) {
console.log('No log file found. Is the gateway installed?');
return;
}
if (opts.follow) {
const lines = opts.lines ?? 50;
const tail = spawn('tail', ['-n', lines.toString(), '-f', LOG_FILE], {
stdio: 'inherit',
});
tail.on('error', () => {
// Fallback for systems without tail
console.log(readLastLines(opts.lines ?? 50));
console.log('\n(--follow requires `tail` command)');
});
return;
}
// Just print last N lines
console.log(readLastLines(opts.lines ?? 50));
}
function readLastLines(n: number): string {
const content = readFileSync(LOG_FILE, 'utf-8');
const lines = content.split('\n');
return lines.slice(-n).join('\n');
}

View File

@@ -0,0 +1,115 @@
import { getDaemonPid, readMeta, LOG_FILE, GATEWAY_HOME } from './daemon.js';
interface GatewayOpts {
host: string;
port: number;
token?: string;
}
interface ServiceStatus {
name: string;
status: string;
latency?: string;
}
interface AdminHealth {
status: string;
services: {
database: { status: string; latencyMs: number };
cache: { status: string; latencyMs: number };
};
agentPool?: { active: number };
providers?: Array<{ name: string; available: boolean; models: number }>;
}
export async function runStatus(opts: GatewayOpts): Promise<void> {
const meta = readMeta();
const pid = getDaemonPid();
console.log('Mosaic Gateway Status');
console.log('─────────────────────');
// Daemon status
if (pid !== null) {
console.log(` Status: running (PID ${pid.toString()})`);
} else {
console.log(' Status: stopped');
}
// Version
console.log(` Version: ${meta?.version ?? 'unknown'}`);
// Endpoint
const host = opts.host;
const port = opts.port;
console.log(` Endpoint: http://${host}:${port.toString()}`);
console.log(` Config: ${GATEWAY_HOME}`);
console.log(` Logs: ${LOG_FILE}`);
if (pid === null) return;
// Health check
try {
const healthRes = await fetch(`http://${host}:${port.toString()}/health`);
if (!healthRes.ok) {
console.log('\n Health: unreachable');
return;
}
} catch {
console.log('\n Health: unreachable');
return;
}
// Admin health (requires token)
const token = opts.token ?? meta?.adminToken;
if (!token) {
console.log(
'\n (No admin token — run `mosaic gateway config` to set one for detailed status)',
);
return;
}
try {
const res = await fetch(`http://${host}:${port.toString()}/api/admin/health`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
console.log('\n Admin health: unauthorized or unavailable');
return;
}
const health = (await res.json()) as AdminHealth;
console.log('\n Services:');
const services: ServiceStatus[] = [
{
name: 'Database',
status: health.services.database.status,
latency: `${health.services.database.latencyMs.toString()}ms`,
},
{
name: 'Cache',
status: health.services.cache.status,
latency: `${health.services.cache.latencyMs.toString()}ms`,
},
];
for (const svc of services) {
const latStr = svc.latency ? ` (${svc.latency})` : '';
console.log(` ${svc.name}:${' '.repeat(10 - svc.name.length)}${svc.status}${latStr}`);
}
if (health.providers && health.providers.length > 0) {
const available = health.providers.filter((p) => p.available);
const names = available.map((p) => p.name).join(', ');
console.log(`\n Providers: ${available.length.toString()} active (${names})`);
}
if (health.agentPool) {
console.log(` Sessions: ${health.agentPool.active.toString()} active`);
}
} catch {
console.log('\n Admin health: connection error');
}
}

View File

@@ -0,0 +1,62 @@
import { existsSync, rmSync } from 'node:fs';
import { createInterface } from 'node:readline';
import {
GATEWAY_HOME,
getDaemonPid,
readMeta,
stopDaemon,
uninstallGatewayPackage,
} from './daemon.js';
export async function runUninstall(): Promise<void> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
await doUninstall(rl);
} finally {
rl.close();
}
}
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
}
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
const meta = readMeta();
if (!meta) {
console.log('Gateway is not installed.');
return;
}
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
return;
}
// Stop if running
if (getDaemonPid() !== null) {
console.log('Stopping gateway daemon...');
try {
await stopDaemon();
console.log('Stopped.');
} catch (err) {
console.warn(`Warning: ${err instanceof Error ? err.message : String(err)}`);
}
}
// Remove config/data
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
if (removeData.toLowerCase() === 'y') {
if (existsSync(GATEWAY_HOME)) {
rmSync(GATEWAY_HOME, { recursive: true, force: true });
console.log('Gateway data removed.');
}
}
// Uninstall npm package
console.log('Uninstalling npm package...');
uninstallGatewayPackage();
console.log('\nGateway uninstalled.');
}