chore: consolidate new foundation and archive v1 (#1495)

This commit is contained in:
2026-09-07 12:32:57 -05:00
3511 changed files with 727899 additions and 10 deletions
@@ -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');
}
}
@@ -0,0 +1,245 @@
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
}
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 ─────────────────────────────────────────────────────
const GITEA_REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
export function installGatewayPackage(): void {
console.log('Installing @mosaicstack/gateway from Gitea registry...');
execSync(`npm install -g @mosaicstack/gateway@latest --@mosaic:registry=${GITEA_REGISTRY}`, {
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;
}
}
@@ -0,0 +1,130 @@
/**
* Thin wrapper over the unified first-run stages.
*
* `mosaic gateway install` is kept as a standalone entry point for users who
* already went through `mosaic wizard` and only need to (re)configure the
* gateway daemon. It builds a minimal `WizardState`, invokes
* `gatewayConfigStage` and `gatewayBootstrapStage` directly, and returns.
*
* The heavy lifting — prompts, env writes, daemon lifecycle, bootstrap POST —
* lives in `packages/mosaic/src/stages/gateway-config.ts` and
* `packages/mosaic/src/stages/gateway-bootstrap.ts` so that the same code
* path runs under both the unified wizard and this standalone command.
*/
import { homedir } from 'node:os';
import { join } from 'node:path';
import { ClackPrompter } from '../../prompter/clack-prompter.js';
import type { VerifyResult } from './verify.js';
import type { WizardState } from '../../types.js';
interface InstallOpts {
host: string;
port: number;
skipInstall?: boolean;
}
function isHeadlessRun(): boolean {
return process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
}
export async function runInstall(opts: InstallOpts): Promise<void> {
const mosaicHome = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
const prompter = new ClackPrompter();
const state: WizardState = {
mosaicHome,
sourceDir: mosaicHome,
mode: 'quick',
installAction: 'fresh',
soul: {},
user: {},
tools: {},
runtimes: { detected: [], mcpConfigured: false },
selectedSkills: [],
};
const { gatewayConfigStage } = await import('../../stages/gateway-config.js');
const { gatewayBootstrapStage } = await import('../../stages/gateway-bootstrap.js');
// Preserve the legacy "explicit --port wins over saved config" semantic:
// commander defaults the port to 14242, so any other value is treated as
// an explicit user override that the config stage should honor even on
// resume.
const portOverride = opts.port !== 14242 ? opts.port : undefined;
const headless = isHeadlessRun();
try {
const configResult = await gatewayConfigStage(prompter, state, {
host: opts.host,
defaultPort: opts.port,
portOverride,
skipInstall: opts.skipInstall,
});
if (!configResult.ready || !configResult.host || configResult.port === undefined) {
// In headless/scripted installs, a non-ready config stage is a fatal
// error — we must not report "complete" when the gateway was never
// configured. Exit non-zero so CI notices.
if (headless) {
prompter.warn('Gateway configuration failed in headless mode — aborting.');
process.exit(1);
}
return;
}
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host,
port: configResult.port,
});
if (!bootstrapResult.completed && headless) {
prompter.warn('Admin bootstrap failed in headless mode — aborting.');
process.exit(1);
}
prompter.log('─── Installation Complete ───');
prompter.log(` Endpoint: http://${configResult.host}:${configResult.port.toString()}`);
prompter.log(` Logs: mosaic gateway logs`);
prompter.log(` Status: mosaic gateway status`);
// Post-install verification (CU-07-03). Health/token/bootstrap failures
// stay non-fatal (courtesy checks), but a FAILED database schema check is
// fatal (#1392): an install that reports success over an empty/partial
// database is the exact T63 failure this command must never reproduce.
let verifyResult: VerifyResult | undefined;
let verificationThrew = false;
try {
const { runPostInstallVerification } = await import('./verify.js');
verifyResult = await runPostInstallVerification(configResult.host, configResult.port);
} catch (err) {
// Health/token/bootstrap courtesy failures are non-fatal, but a THROWN
// schema verification must not let install report success either (N2,
// rev-code-02 review 285): mark it and treat as fatal below.
verificationThrew = true;
const msg = err instanceof Error ? err.message : String(err);
prompter.warn(`Post-install verification errored: ${msg}`);
}
if (verifyResult && verifyResult.schemaMigrated === false) {
prompter.warn(
'Gateway install ABORTED: database schema verification failed (remediation above).',
);
process.exit(1);
}
if (verificationThrew) {
prompter.warn(
'Gateway install ABORTED: post-install verification errored (see above); refusing to report success on an unverified database.',
);
process.exit(1);
}
} catch (err) {
// Stages normally return structured results for expected failures.
// Anything that reaches here is an unexpected runtime error — render a
// concise warning AND re-throw so the command exits non-zero. Silent
// swallowing would let scripted installs report success on failure.
prompter.warn(`Gateway install failed: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
}
@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock auth module
vi.mock('../../auth.js', () => ({
signIn: vi.fn(),
saveSession: vi.fn(),
}));
// Mock daemon to avoid file-system reads
vi.mock('./daemon.js', () => ({
readMeta: vi.fn().mockReturnValue({
host: 'localhost',
port: 14242,
version: '1.0.0',
installedAt: '',
entryPoint: '',
}),
}));
import { runLogin, getGatewayUrl } from './login.js';
import { signIn, saveSession } from '../../auth.js';
import { readMeta } from './daemon.js';
const mockSignIn = vi.mocked(signIn);
const mockSaveSession = vi.mocked(saveSession);
const mockReadMeta = vi.mocked(readMeta);
describe('getGatewayUrl', () => {
it('returns override URL when provided', () => {
expect(getGatewayUrl('http://my-gateway:9999')).toBe('http://my-gateway:9999');
});
it('builds URL from meta.json when no override given', () => {
mockReadMeta.mockReturnValueOnce({
host: 'myhost',
port: 8080,
version: '1.0.0',
installedAt: '',
entryPoint: '',
});
expect(getGatewayUrl()).toBe('http://myhost:8080');
});
it('falls back to default when meta is null', () => {
mockReadMeta.mockReturnValueOnce(null);
expect(getGatewayUrl()).toBe('http://localhost:14242');
});
});
describe('runLogin', () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
beforeEach(() => {
vi.clearAllMocks();
});
it('calls signIn and saveSession on success', async () => {
const fakeAuth = {
cookie: 'better-auth.session_token=abc',
userId: 'u1',
email: '[email protected]',
};
mockSignIn.mockResolvedValueOnce(fakeAuth);
await runLogin({
gatewayUrl: 'http://localhost:14242',
email: '[email protected]',
password: 'password123',
});
expect(mockSignIn).toHaveBeenCalledWith(
'http://localhost:14242',
'[email protected]',
'password123',
);
expect(mockSaveSession).toHaveBeenCalledWith('http://localhost:14242', fakeAuth);
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[email protected]'));
});
it('propagates signIn errors', async () => {
mockSignIn.mockRejectedValueOnce(new Error('Sign-in failed (401): invalid credentials'));
await expect(
runLogin({ gatewayUrl: 'http://localhost:14242', email: '[email protected]', password: 'wrong' }),
).rejects.toThrow('Sign-in failed (401)');
});
});
@@ -0,0 +1,87 @@
import { createInterface } from 'node:readline';
import { signIn, saveSession } from '../../auth.js';
import { readMeta } from './daemon.js';
/**
* Prompt for a single line of input (with echo).
*/
export function promptLine(question: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
/**
* Prompt for a secret value without echoing the typed characters to the terminal.
* Uses TTY raw mode when available so that passwords do not appear in terminal
* recordings, scrollback, or shared screen sessions.
*/
export function promptSecret(question: string): Promise<string> {
return new Promise((resolve) => {
process.stdout.write(question);
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding('utf-8');
let secret = '';
const onData = (char: string): void => {
if (char === '\n' || char === '\r' || char === '\u0004') {
process.stdout.write('\n');
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdin.pause();
process.stdin.removeListener('data', onData);
resolve(secret);
} else if (char === '\u0003') {
// ^C
process.stdout.write('\n');
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdin.pause();
process.stdin.removeListener('data', onData);
process.exit(130);
} else if (char === '\u007f' || char === '\b') {
secret = secret.slice(0, -1);
} else {
secret += char;
}
};
process.stdin.on('data', onData);
});
}
/**
* Shared login helper used by both `mosaic login` and `mosaic gateway login`.
* Prompts for email/password if not supplied, signs in, and persists the session.
*/
export async function runLogin(opts: {
gatewayUrl: string;
email?: string;
password?: string;
}): Promise<void> {
const email = opts.email ?? (await promptLine('Email: '));
// Do not trim password — it may intentionally contain leading/trailing whitespace
const password = opts.password ?? (await promptSecret('Password: '));
const auth = await signIn(opts.gatewayUrl, email, password);
saveSession(opts.gatewayUrl, auth);
console.log(`Signed in as ${auth.email} (${opts.gatewayUrl})`);
}
/**
* Derive the gateway base URL from meta.json with a fallback.
*/
export function getGatewayUrl(overrideUrl?: string): string {
if (overrideUrl) return overrideUrl;
const meta = readMeta();
if (meta) return `http://${meta.host}:${meta.port.toString()}`;
return 'http://localhost:14242';
}
@@ -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');
}
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { Readable } from 'node:stream';
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
describe('readCredentialsFromPipedStdin — #1394 stdin dual path (real streams)', () => {
it('reads exactly two lines; email trimmed, password as-is', async () => {
const r = await readCredentialsFromPipedStdin(
Readable.from([' [email protected] \n', 'pw with spaces \n']),
);
expect(r.email).toBe('[email protected]');
expect(r.password).toBe('pw with spaces ');
});
it('empty stdin → nulls (the headless-no-credentials shape)', async () => {
const r = await readCredentialsFromPipedStdin(Readable.from(['']));
expect(r).toEqual({ email: null, password: null });
});
it('single line only → email set, password null', async () => {
const r = await readCredentialsFromPipedStdin(Readable.from(['only-email\n']));
expect(r.email).toBe('only-email');
expect(r.password).toBeNull();
});
it('stops after two lines even if more follow', async () => {
const r = await readCredentialsFromPipedStdin(
Readable.from(['[email protected]\n', 'pw\n', 'extra\n', 'more\n']),
);
expect(r).toEqual({ email: '[email protected]', password: 'pw' });
});
});
@@ -0,0 +1,26 @@
import { createInterface } from 'node:readline';
/**
* Read email + password as two lines from non-TTY stdin (the headless dual
* path for callers that cannot pass argv: printf 'email\npassword\n' | …).
* Caller gates on !isTTY; the password line is kept as-is (no trim —
* whitespace may be intentional).
*
* Separate module (not login.ts) so tests can exercise the REAL reader
* against real streams while token-ops specs mock this seam cleanly.
*/
export function readCredentialsFromPipedStdin(
input: NodeJS.ReadableStream = process.stdin,
): Promise<{ email: string | null; password: string | null }> {
return new Promise((resolve) => {
const lines: string[] = [];
const rl = createInterface({ input });
rl.on('line', (l) => {
lines.push(l);
if (lines.length >= 2) rl.close();
});
rl.on('close', () => {
resolve({ email: (lines[0] ?? '').trim() || null, password: lines[1] ?? null });
});
});
}
@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ─── Mocks ──────────────────────────────────────────────────────────────────
vi.mock('../../auth.js', () => ({
loadSession: vi.fn(),
validateSession: vi.fn(),
signIn: vi.fn(),
saveSession: vi.fn(),
}));
vi.mock('./daemon.js', () => ({
readMeta: vi.fn(),
writeMeta: vi.fn(),
}));
vi.mock('./login.js', () => ({
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
// promptLine/promptSecret are used by ensureSession on the TTY path; return fixed
// values so tests never block on stdin.
promptLine: vi.fn().mockResolvedValue('[email protected]'),
promptSecret: vi.fn().mockResolvedValue('test-password'),
}));
// #1394: non-TTY runs resolve credentials from piped stdin instead of prompts.
vi.mock('./piped-credentials.js', () => ({
readCredentialsFromPipedStdin: vi.fn().mockResolvedValue({
email: '[email protected]',
password: 'test-password',
}),
}));
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
import { runRecoverToken, ensureSession } from './token-ops.js';
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
import { readMeta, writeMeta } from './daemon.js';
const mockLoadSession = vi.mocked(loadSession);
const mockValidateSession = vi.mocked(validateSession);
const mockSignIn = vi.mocked(signIn);
const mockSaveSession = vi.mocked(saveSession);
const mockReadMeta = vi.mocked(readMeta);
const mockWriteMeta = vi.mocked(writeMeta);
const baseUrl = 'http://localhost:14242';
const fakeCookie = 'better-auth.session_token=sess123';
const fakeToken = {
id: 'tok-1',
label: 'CLI recovery token (2026-04-04 12:00)',
plaintext: 'abcdef1234567890',
};
const fakeMeta = {
version: '1.0.0',
installedAt: '',
entryPoint: '',
host: 'localhost',
port: 14242,
};
describe('ensureSession', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'log').mockImplementation(() => {});
});
it('returns cookie from stored session when valid', async () => {
mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: '[email protected]' });
mockValidateSession.mockResolvedValueOnce(true);
const cookie = await ensureSession(baseUrl);
expect(cookie).toBe(fakeCookie);
expect(mockSignIn).not.toHaveBeenCalled();
});
it('resolves piped-stdin credentials and signs in when stored session is invalid', async () => {
mockLoadSession.mockReturnValueOnce({ cookie: 'old-cookie', userId: 'u1', email: '[email protected]' });
mockValidateSession.mockResolvedValueOnce(false);
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
mockSignIn.mockResolvedValueOnce(newAuth);
const cookie = await ensureSession(baseUrl);
expect(cookie).toBe(fakeCookie);
expect(mockSaveSession).toHaveBeenCalledWith(baseUrl, newAuth);
});
it('resolves piped-stdin credentials when no session exists', async () => {
mockLoadSession.mockReturnValueOnce(null);
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
mockSignIn.mockResolvedValueOnce(newAuth);
const cookie = await ensureSession(baseUrl);
expect(cookie).toBe(fakeCookie);
expect(mockSignIn).toHaveBeenCalled();
// The non-TTY path resolves credentials from the piped-stdin seam, not prompts.
expect(
vi.mocked(await import('./piped-credentials.js')).readCredentialsFromPipedStdin,
).toHaveBeenCalled();
});
it('exits non-zero when signIn fails', async () => {
mockLoadSession.mockReturnValueOnce(null);
mockSignIn.mockRejectedValueOnce(new Error('Sign-in failed (401): bad creds'));
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((_code?: number | string | null | undefined) => {
throw new Error(`process.exit(${String(_code)})`);
});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(ensureSession(baseUrl)).rejects.toThrow('process.exit(2)');
expect(processExitSpy).toHaveBeenCalledWith(2);
processExitSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
});
describe('runRecoverToken', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('signs in via piped stdin, mints a token, and persists it when no session exists', async () => {
mockLoadSession.mockReturnValueOnce(null);
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
mockSignIn.mockResolvedValueOnce(newAuth);
mockReadMeta.mockReturnValue(fakeMeta);
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => fakeToken,
});
await runRecoverToken();
expect(mockSignIn).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(
`${baseUrl}/api/admin/tokens`,
expect.objectContaining({ method: 'POST' }),
);
expect(mockWriteMeta).toHaveBeenCalledWith(
expect.objectContaining({ adminToken: fakeToken.plaintext }),
);
});
it('skips login when a valid session exists and mints a recovery token', async () => {
mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: '[email protected]' });
mockValidateSession.mockResolvedValueOnce(true);
mockReadMeta.mockReturnValue(fakeMeta);
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => fakeToken,
});
await runRecoverToken();
expect(mockSignIn).not.toHaveBeenCalled();
expect(mockWriteMeta).toHaveBeenCalledWith(
expect.objectContaining({ adminToken: fakeToken.plaintext }),
);
});
it('uses label containing "recovery token"', async () => {
mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: '[email protected]' });
mockValidateSession.mockResolvedValueOnce(true);
mockReadMeta.mockReturnValue(fakeMeta);
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => fakeToken,
});
await runRecoverToken();
const call = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(call[1].body as string) as { label: string };
expect(body.label).toMatch(/CLI recovery token/);
});
});
@@ -0,0 +1,205 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ─── Mocks ──────────────────────────────────────────────────────────────────
vi.mock('../../auth.js', () => ({
loadSession: vi.fn(),
validateSession: vi.fn(),
signIn: vi.fn(),
saveSession: vi.fn(),
}));
vi.mock('./daemon.js', () => ({
readMeta: vi.fn(),
writeMeta: vi.fn(),
}));
vi.mock('./login.js', () => ({
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
}));
// Mock global fetch
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
import { runRotateToken, mintAdminToken, persistToken } from './token-ops.js';
import { loadSession, validateSession } from '../../auth.js';
import { readMeta, writeMeta } from './daemon.js';
const mockLoadSession = vi.mocked(loadSession);
const mockValidateSession = vi.mocked(validateSession);
const mockReadMeta = vi.mocked(readMeta);
const mockWriteMeta = vi.mocked(writeMeta);
const baseUrl = 'http://localhost:14242';
const fakeCookie = 'better-auth.session_token=sess123';
const fakeToken = {
id: 'tok-1',
label: 'CLI rotated token (2026-04-04)',
plaintext: 'abcdef1234567890',
};
const fakeMeta = {
version: '1.0.0',
installedAt: '',
entryPoint: '',
host: 'localhost',
port: 14242,
};
describe('mintAdminToken', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('calls the admin tokens endpoint with the session cookie and returns the token', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => fakeToken,
});
const result = await mintAdminToken(baseUrl, fakeCookie, fakeToken.label);
expect(mockFetch).toHaveBeenCalledWith(
`${baseUrl}/api/admin/tokens`,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Cookie: fakeCookie }),
}),
);
expect(result).toEqual(fakeToken);
});
it('exits 2 on 401 from the server', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 401, text: async () => 'Unauthorized' });
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((_code?: number | string | null | undefined) => {
throw new Error(`process.exit(${String(_code)})`);
});
await expect(mintAdminToken(baseUrl, fakeCookie, 'label')).rejects.toThrow('process.exit(2)');
expect(processExitSpy).toHaveBeenCalledWith(2);
processExitSpy.mockRestore();
});
it('exits 2 on 403 from the server', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 403, text: async () => 'Forbidden' });
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((_code?: number | string | null | undefined) => {
throw new Error(`process.exit(${String(_code)})`);
});
await expect(mintAdminToken(baseUrl, fakeCookie, 'label')).rejects.toThrow('process.exit(2)');
expect(processExitSpy).toHaveBeenCalledWith(2);
processExitSpy.mockRestore();
});
it('exits 3 on other non-ok status', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'Internal Error' });
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((_code?: number | string | null | undefined) => {
throw new Error(`process.exit(${String(_code)})`);
});
await expect(mintAdminToken(baseUrl, fakeCookie, 'label')).rejects.toThrow('process.exit(3)');
expect(processExitSpy).toHaveBeenCalledWith(3);
processExitSpy.mockRestore();
});
it('exits 1 on network error', async () => {
mockFetch.mockRejectedValueOnce(new Error('connection refused'));
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((_code?: number | string | null | undefined) => {
throw new Error(`process.exit(${String(_code)})`);
});
await expect(mintAdminToken(baseUrl, fakeCookie, 'label')).rejects.toThrow('process.exit(1)');
expect(processExitSpy).toHaveBeenCalledWith(1);
processExitSpy.mockRestore();
});
});
describe('persistToken', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('writes the new token to meta.json', () => {
mockReadMeta.mockReturnValueOnce(fakeMeta);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
persistToken(baseUrl, fakeToken);
expect(mockWriteMeta).toHaveBeenCalledWith(
expect.objectContaining({ adminToken: fakeToken.plaintext }),
);
consoleSpy.mockRestore();
});
it('prints a masked preview of the token', () => {
mockReadMeta.mockReturnValueOnce(fakeMeta);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
persistToken(baseUrl, fakeToken);
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(allOutput).toContain('abcdef12...');
consoleSpy.mockRestore();
});
});
describe('runRotateToken', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});
});
it('exits 2 when there is no stored session', async () => {
mockLoadSession.mockReturnValueOnce(null);
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((_code?: number | string | null | undefined) => {
throw new Error(`process.exit(${String(_code)})`);
});
await expect(runRotateToken()).rejects.toThrow('process.exit(2)');
expect(processExitSpy).toHaveBeenCalledWith(2);
processExitSpy.mockRestore();
});
it('exits 2 when session is invalid', async () => {
mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: '[email protected]' });
mockValidateSession.mockResolvedValueOnce(false);
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((_code?: number | string | null | undefined) => {
throw new Error(`process.exit(${String(_code)})`);
});
await expect(runRotateToken()).rejects.toThrow('process.exit(2)');
expect(processExitSpy).toHaveBeenCalledWith(2);
processExitSpy.mockRestore();
});
it('mints and persists a new token when session is valid', async () => {
mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: '[email protected]' });
mockValidateSession.mockResolvedValueOnce(true);
mockReadMeta.mockReturnValue(fakeMeta);
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => fakeToken,
});
await runRotateToken();
expect(mockWriteMeta).toHaveBeenCalledWith(
expect.objectContaining({ adminToken: fakeToken.plaintext }),
);
});
});
@@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
checkDatabaseSchema,
SCHEMA_FAIL_REMEDIATION,
type SchemaCheckDeps,
} from './schema-check.js';
/* ------------------------------------------------------------------ */
/* Fixture config */
/* ------------------------------------------------------------------ */
const tmpDirs: string[] = [];
function writeConfig(cfg: Record<string, unknown>): string {
const dir = mkdtempSync(join(tmpdir(), 'schema-check-'));
tmpDirs.push(dir);
const path = join(dir, 'mosaic.config.json');
writeFileSync(path, JSON.stringify(cfg));
return path;
}
const STANDALONE_CFG = {
tier: 'standalone',
storage: { type: 'postgres', url: 'postgresql://u:p@localhost:5434/x' },
queue: { type: 'bullmq', url: 'redis://localhost:6380' },
memory: { type: 'keyword' },
};
const LOCAL_CFG = {
tier: 'local',
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
queue: { type: 'local', dataDir: '.mosaic/queue' },
memory: { type: 'keyword' },
};
function deps(overrides: Partial<SchemaCheckDeps> = {}): SchemaCheckDeps {
return {
runMigrations: vi.fn().mockResolvedValue(undefined),
getMigrationStatus: vi.fn().mockResolvedValue({
appliedCount: 17,
expectedCount: 17,
expectedLastTag: '0016_x',
complete: true,
}),
...overrides,
};
}
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
describe('checkDatabaseSchema', () => {
beforeEach(() => {
process.env['MOSAIC_CONFIG'] = '';
vi.stubEnv('DATABASE_URL', 'postgresql://env:env@localhost:9999/env');
});
afterEach(() => {
vi.unstubAllEnvs();
for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
it('passes when the ledger matches the shipped journal', async () => {
const cfg = writeConfig(STANDALONE_CFG);
const d = deps();
const result = await checkDatabaseSchema(d, cfg);
expect(result.status).toBe('ok');
expect(result.detail).toContain('17/17');
expect(d.runMigrations).toHaveBeenCalledWith(STANDALONE_CFG.storage.url);
expect(d.getMigrationStatus).toHaveBeenCalledWith(STANDALONE_CFG.storage.url);
});
it('FAILS when the ledger is incomplete — the #1389 empty-database signature', async () => {
const cfg = writeConfig(STANDALONE_CFG);
const d = deps({
getMigrationStatus: vi.fn().mockResolvedValue({
appliedCount: 0,
expectedCount: 17,
expectedLastTag: '0016_x',
complete: false,
}),
});
const result = await checkDatabaseSchema(d, cfg);
expect(result.status).toBe('fail');
if (result.status !== 'fail') throw new Error('expected fail');
expect(result.detail).toContain('0/17');
expect(result.remediation).toBe(SCHEMA_FAIL_REMEDIATION);
expect(result.remediation).toContain('#1389');
});
it('FAILS when the ledger is only PARTIALLY migrated (#1402 upgrade case)', async () => {
const cfg = writeConfig(STANDALONE_CFG);
const d = deps({
getMigrationStatus: vi.fn().mockResolvedValue({
appliedCount: 9,
expectedCount: 17,
expectedLastTag: '0016_x',
complete: false,
}),
});
const result = await checkDatabaseSchema(d, cfg);
expect(result.status).toBe('fail');
expect(result.detail).toContain('9/17');
});
it('FAILS (never crashes) when the migration run itself throws', async () => {
const cfg = writeConfig(STANDALONE_CFG);
const d = deps({
runMigrations: vi.fn().mockRejectedValue(new Error('connection refused')),
});
const result = await checkDatabaseSchema(d, cfg);
expect(result.status).toBe('fail');
expect(result.detail).toContain('connection refused');
});
it('skips the local tier (gateway migrates its own PGlite at startup)', async () => {
const cfg = writeConfig(LOCAL_CFG);
const d = deps();
const result = await checkDatabaseSchema(d, cfg);
expect(result.status).toBe('skipped');
expect(d.runMigrations).not.toHaveBeenCalled();
});
});
/* ------------------------------------------------------------------ */
/* Config resolution priority */
/* ------------------------------------------------------------------ */
describe('resolveSchemaCheckConfigPath', () => {
it('prefers the daemon-written gateway config over cwd copies', async () => {
const { resolveSchemaCheckConfigPath } = await import('./schema-check.js');
const cwdCfg = writeConfig(STANDALONE_CFG); // in a temp dir
const daemonDir = mkdtempSync(join(tmpdir(), 'schema-check-daemon-'));
tmpDirs.push(daemonDir);
const daemonHome = join(daemonDir, '.config', 'mosaic', 'gateway');
mkdirSync(daemonHome, { recursive: true });
writeFileSync(join(daemonHome, 'mosaic.config.json'), JSON.stringify(LOCAL_CFG));
const prevHome = process.env['HOME'];
vi.stubEnv('HOME', daemonDir);
vi.stubEnv('MOSAIC_CONFIG', '');
try {
const resolved = resolveSchemaCheckConfigPath();
// Must NOT pick the cwd copy (cwd is the vitest project dir, not our
// temp dir — so the only resolvable candidates are daemon + $HOME/.mosaic).
expect(resolved).toBe(join(daemonHome, 'mosaic.config.json'));
void cwdCfg;
} finally {
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
}
});
it('gives MOSAIC_CONFIG NO authority (N1, review 285): env never overrides file resolution', async () => {
const { resolveSchemaCheckConfigPath } = await import('./schema-check.js');
const daemonDir = mkdtempSync(join(tmpdir(), 'schema-check-env-'));
tmpDirs.push(daemonDir);
const daemonHome = join(daemonDir, '.config', 'mosaic', 'gateway');
mkdirSync(daemonHome, { recursive: true });
writeFileSync(join(daemonHome, 'mosaic.config.json'), JSON.stringify(LOCAL_CFG));
// A stale env var pointing at a DIFFERENT file must be ignored entirely:
const decoyDir = mkdtempSync(join(tmpdir(), 'schema-check-decoy-'));
tmpDirs.push(decoyDir);
const decoyPath = join(decoyDir, 'mosaic.config.json');
writeFileSync(decoyPath, JSON.stringify(STANDALONE_CFG));
const prevHome = process.env['HOME'];
vi.stubEnv('HOME', daemonDir);
vi.stubEnv('MOSAIC_CONFIG', decoyPath);
try {
const resolved = resolveSchemaCheckConfigPath();
expect(resolved).toBe(join(daemonHome, 'mosaic.config.json'));
expect(resolved).not.toBe(decoyPath);
} finally {
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
}
});
});
@@ -0,0 +1,110 @@
/**
* Install-time database schema verification (#1392).
*
* Fresh standalone (postgres) installs once ended "healthy" with a completely
* empty database: the resolved dependency set shipped no migrations at all
* (#1389), the gateway started fine, and nothing failed until the first real
* query. The issue's own conclusion: the installer must "instruct and verify".
*
* This check runs AFTER migrations have been (re-)applied, so the only way it
* fails is a genuinely broken migration set or database — which is exactly
* when install must not report success. Failure is fatal by design (fast-fail
* STANDARDS); callers print the remediation text and exit non-zero.
*/
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { loadConfig } from '@mosaicstack/config';
export interface SchemaStatusCounts {
appliedCount: number;
expectedCount: number;
expectedLastTag: string;
complete: boolean;
}
/** Injectable migration surface — keeps this unit-testable without a DB. */
export interface SchemaCheckDeps {
runMigrations(url: string): Promise<void>;
getMigrationStatus(url: string): Promise<SchemaStatusCounts>;
}
export type SchemaCheckResult =
| { status: 'ok'; detail: string }
| { status: 'skipped'; detail: string }
| { status: 'fail'; detail: string; remediation: string };
export const SCHEMA_FAIL_REMEDIATION = [
'The gateway database does not carry the full schema.',
'Causes seen in the wild: dependency set resolved without migrations (#1389), or a partially-migrated database (#1402).',
'Remediation:',
' 1. Re-run: mosaic gateway install (applies migrations and verifies again)',
' 2. Check the resolved @mosaicstack/db version is the same pipeline as the gateway (npm ls -g @mosaicstack/db)',
' 3. Manual apply: run runMigrations() from @mosaicstack/db against the storage URL, then re-verify',
].join('\n');
/**
* Resolve the config the INSTALLED gateway would use — same priority the
* daemon applies (apps/gateway/src/env.ts resolveGatewayConfigPath), minus
* the source-tree anchors that do not exist on an installed host. Verifying
* against any other config could green-light a database the daemon never
* reads (#1392: verify what runs, not what happens to lie in cwd).
*/
export function resolveSchemaCheckConfigPath(explicit?: string): string | undefined {
if (explicit) return resolve(explicit);
// NOTE: no env-var candidate, deliberately. apps/gateway/src/env.ts gives env
// NO config authority (a stale MOSAIC_CONFIG could verify a database the
// daemon never reads — rev-code-02 review 285, note N1). Resolution order
// mirrors the daemon's file priorities only.
const candidates = [
join(homedir(), '.config', 'mosaic', 'gateway', 'mosaic.config.json'), // daemon-written
resolve(process.cwd(), 'mosaic.config.json'),
join(homedir(), '.mosaic', 'mosaic.config.json'),
];
for (const c of candidates) {
if (c && existsSync(c)) return c;
}
return undefined; // loadConfig falls back to env-var detection
}
export async function checkDatabaseSchema(
deps: SchemaCheckDeps,
configPath?: string,
): Promise<SchemaCheckResult> {
const config = loadConfig(resolveSchemaCheckConfigPath(configPath));
// Local tier: the gateway itself runs PGlite migrations at startup (see
// DatabaseModule.onModuleInit), and a broken local tier fails the health
// check instead. Nothing for the installer to verify here.
if (config.storage.type !== 'postgres') {
return {
status: 'skipped',
detail: 'database schema (local tier — migrated by gateway at startup)',
};
}
const url = config.storage.url;
try {
await deps.runMigrations(url);
const status = await deps.getMigrationStatus(url);
if (status.complete) {
return {
status: 'ok',
detail: `database schema (${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations)`,
};
}
return {
status: 'fail',
detail: `database schema incomplete (${status.appliedCount.toString()}/${status.expectedCount.toString()} applied, last expected: ${status.expectedLastTag})`,
remediation: SCHEMA_FAIL_REMEDIATION,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
status: 'fail',
detail: `database schema check errored: ${msg}`,
remediation: SCHEMA_FAIL_REMEDIATION,
};
}
}
@@ -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');
}
}
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../auth.js', () => ({
loadSession: vi.fn(),
validateSession: vi.fn(),
signIn: vi.fn(),
saveSession: vi.fn(),
}));
vi.mock('./login.js', () => ({
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
promptLine: vi.fn(),
promptSecret: vi.fn(),
}));
vi.mock('./piped-credentials.js', () => ({
readCredentialsFromPipedStdin: vi.fn(),
}));
vi.mock('./daemon.js', () => ({
readMeta: vi.fn(),
writeMeta: vi.fn(),
}));
import { ensureSession } from './token-ops.js';
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
import { promptLine, promptSecret } from './login.js';
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
const URL = 'http://localhost:14242';
function asNonTTY(): void {
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
}
describe('ensureSession — #1394 credential precedence (flag > piped stdin > prompt)', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(loadSession).mockReturnValue(null);
asNonTTY();
});
it('stored valid session wins; no credentials touched', async () => {
vi.mocked(loadSession).mockReturnValue({ cookie: 'SESS', email: '[email protected]' } as never);
vi.mocked(validateSession).mockResolvedValue(true);
await expect(ensureSession(URL)).resolves.toBe('SESS');
expect(signIn).not.toHaveBeenCalled();
});
it('flag email + stdin password: FLAG wins for email, stdin supplies the password', async () => {
vi.mocked(signIn).mockResolvedValue({ cookie: 'NEW', email: '[email protected]' } as never);
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
email: '[email protected]',
password: 'stdin-pw',
});
await ensureSession(URL, { email: '[email protected]' });
expect(signIn).toHaveBeenCalledWith(URL, '[email protected]', 'stdin-pw');
expect(promptLine).not.toHaveBeenCalled();
expect(promptSecret).not.toHaveBeenCalled();
});
it('stdin-only path (no flag): both credentials from piped lines', async () => {
vi.mocked(signIn).mockResolvedValue({ cookie: 'NEW2', email: '[email protected]' } as never);
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
email: '[email protected]',
password: 'spw',
});
await ensureSession(URL);
expect(signIn).toHaveBeenCalledWith(URL, '[email protected]', 'spw');
});
it('no credentials headless → exit(2) with --email guidance; signIn untouched', async () => {
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({ email: null, password: null });
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('EXIT');
}) as never);
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(ensureSession(URL)).rejects.toThrow('EXIT');
expect(exit).toHaveBeenCalledWith(2);
expect(err).toHaveBeenCalledWith(expect.stringContaining('--email'));
expect(signIn).not.toHaveBeenCalled();
exit.mockRestore();
err.mockRestore();
});
it('successful sign-in persists the session', async () => {
vi.mocked(signIn).mockResolvedValue({ cookie: 'C', email: '[email protected]' } as never);
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
email: '[email protected]',
password: 'pw',
});
await ensureSession(URL);
expect(saveSession).toHaveBeenCalledWith(URL, expect.anything());
});
});
@@ -0,0 +1,190 @@
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
import { readMeta, writeMeta } from './daemon.js';
import { getGatewayUrl, promptLine, promptSecret } from './login.js';
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
interface MintedToken {
id: string;
label: string;
plaintext: string;
}
/**
* Call POST /api/admin/tokens with the session cookie and return the minted token.
* Exits the process on network or auth errors.
*/
export async function mintAdminToken(
gatewayUrl: string,
cookie: string,
label: string,
): Promise<MintedToken> {
let res: Response;
try {
res = await fetch(`${gatewayUrl}/api/admin/tokens`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: cookie,
Origin: gatewayUrl,
},
body: JSON.stringify({ label, scope: 'admin' }),
});
} catch (err) {
console.error(
`Could not reach gateway at ${gatewayUrl}: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
if (res.status === 401 || res.status === 403) {
console.error(
`Session rejected by the gateway (${res.status.toString()}) — your session may be expired.`,
);
console.error('Run: mosaic gateway login');
process.exit(2);
}
if (!res.ok) {
const body = await res.text().catch(() => '');
console.error(
`Gateway rejected token creation (${res.status.toString()}): ${body.slice(0, 200)}`,
);
process.exit(3);
}
const data = (await res.json()) as { id: string; label: string; plaintext: string };
return { id: data.id, label: data.label, plaintext: data.plaintext };
}
/**
* Persist the new token into meta.json and print the confirmation banner.
*
* Emits a warning when the target gateway differs from the locally installed one,
* so operators are aware that meta.json may not reflect the intended gateway.
*/
export function persistToken(gatewayUrl: string, minted: MintedToken): void {
const meta = readMeta() ?? {
version: 'unknown',
installedAt: new Date().toISOString(),
entryPoint: '',
host: new URL(gatewayUrl).hostname,
port: parseInt(new URL(gatewayUrl).port || '14242', 10),
};
// Warn when the target gateway does not match the locally installed one
const targetHost = new URL(gatewayUrl).hostname;
if (targetHost !== meta.host) {
console.warn(
`Warning: token was minted against ${gatewayUrl} but is being saved to the local` +
` meta.json (host: ${meta.host}). Copy the token manually if targeting a remote gateway.`,
);
}
writeMeta({ ...meta, adminToken: minted.plaintext });
const preview = `${minted.plaintext.slice(0, 8)}...`;
console.log();
console.log(`Token minted: ${minted.label}`);
console.log(`Preview: ${preview}`);
console.log('Token saved to meta.json. Use it with admin endpoints.');
}
/**
* Require a valid session for the given gateway URL.
* Returns the session cookie or exits if not authenticated.
*/
export async function requireSession(gatewayUrl: string): Promise<string> {
const session = loadSession(gatewayUrl);
if (session) {
const valid = await validateSession(gatewayUrl, session.cookie);
if (valid) return session.cookie;
}
console.error('Not signed in or session expired.');
console.error('Run: mosaic gateway login');
process.exit(2);
}
/**
* Ensure a valid session for the gateway, prompting for credentials if needed.
* On sign-in failure, prints the error and exits non-zero.
* Returns the session cookie.
*
* Credential precedence when sign-in is needed (#1394):
* 1. explicit opts (--email flag; highest)
* 2. non-TTY stdin — first line email, second line password (headless dual
* path for callers without argv access: printf 'email\npassword\n' | …)
* 3. interactive prompt (TTY only)
*/
export interface SessionCredentialOptions {
/** Email from an explicit flag (argv). Highest precedence. */
email?: string;
/** Password from an explicit source. Rare; passwords normally come via stdin/prompt. */
password?: string;
}
export async function ensureSession(
gatewayUrl: string,
opts: SessionCredentialOptions = {},
): Promise<string> {
// Try the stored session first
const session = loadSession(gatewayUrl);
if (session) {
const valid = await validateSession(gatewayUrl, session.cookie);
if (valid) return session.cookie;
console.log('Stored session is invalid or expired. Please sign in again.');
} else {
console.log(`No session found for ${gatewayUrl}. Please sign in.`);
}
let email = opts.email;
let password = opts.password;
if ((!email || !password) && !process.stdin.isTTY) {
const piped = await readCredentialsFromPipedStdin();
email = email ?? piped.email ?? undefined;
password = password ?? piped.password ?? undefined;
}
if (!email || !password) {
if (!process.stdin.isTTY) {
console.error(
'No valid session and no credentials available headlessly. Provide --email plus ' +
"a password line on stdin (printf 'email\\npassword\\n' | …), or run interactively.",
);
process.exit(2);
}
email = await promptLine('Email: ');
// Do not trim password — it may contain intentional leading/trailing whitespace
password = await promptSecret('Password: ');
}
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(2);
});
saveSession(gatewayUrl, auth);
console.log(`Signed in as ${auth.email}`);
return auth.cookie;
}
/**
* `mosaic gateway config rotate-token` — requires an existing valid session.
*/
export async function runRotateToken(gatewayUrl?: string): Promise<void> {
const url = getGatewayUrl(gatewayUrl);
const cookie = await requireSession(url);
const label = `CLI rotated token (${new Date().toISOString().slice(0, 10)})`;
const minted = await mintAdminToken(url, cookie, label);
persistToken(url, minted);
}
/**
* `mosaic gateway config recover-token` — signs in if no session exists.
* Passes the --email flag through to ensureSession (#1394 dual path).
*/
export async function runRecoverToken(gatewayUrl?: string, email?: string): Promise<void> {
const url = getGatewayUrl(gatewayUrl);
const cookie = await ensureSession(url, { email });
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
const minted = await mintAdminToken(url, cookie, label);
persistToken(url, minted);
}
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mkdirSync } from 'node:fs';
vi.mock('./daemon.js', () => ({
GATEWAY_HOME: '/tmp/u-test-gateway-home',
getDaemonPid: vi.fn().mockReturnValue(null),
readMeta: vi.fn(),
stopDaemon: vi.fn(),
uninstallGatewayPackage: vi.fn(),
}));
import { runUninstall } from './uninstall.js';
import { readMeta, uninstallGatewayPackage } from './daemon.js';
describe('gateway uninstall — #1390 headless semantics', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('non-TTY without --yes FAILS LOUD (exit 1, nothing touched)', async () => {
vi.mocked(readMeta).mockReturnValue({
version: '0.0.7',
installedAt: '',
entryPoint: '',
host: 'localhost',
port: 14242,
});
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('EXIT');
}) as never);
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(runUninstall()).rejects.toThrow('EXIT');
expect(exit).toHaveBeenCalledWith(1);
expect(err).toHaveBeenCalledWith(expect.stringContaining('stdin is not a TTY'));
expect(uninstallGatewayPackage).not.toHaveBeenCalled();
exit.mockRestore();
err.mockRestore();
});
it('--yes proceeds headlessly WITHOUT removing data (never implied)', async () => {
const meta = {
version: '0.0.7',
installedAt: '',
entryPoint: '',
host: 'localhost',
port: 14242,
};
vi.mocked(readMeta).mockReturnValue(meta);
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
await runUninstall({ yes: true });
expect(uninstallGatewayPackage).toHaveBeenCalledTimes(1);
expect(log).toHaveBeenCalledWith(expect.stringContaining('Gateway data kept'));
log.mockRestore();
});
it('--yes --remove-data removes data headlessly', async () => {
vi.mocked(readMeta).mockReturnValue({
version: '0.0.7',
installedAt: '',
entryPoint: '',
host: 'localhost',
port: 14242,
});
mkdirSync('/tmp/u-test-gateway-home', { recursive: true }); // existsSync gate
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
await runUninstall({ yes: true, removeData: true });
expect(uninstallGatewayPackage).toHaveBeenCalledTimes(1);
expect(log).toHaveBeenCalledWith(expect.stringContaining('Gateway data removed'));
log.mockRestore();
});
it('no meta → clean no-op even with --yes', async () => {
vi.mocked(readMeta).mockReturnValue(null);
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
await runUninstall({ yes: true });
expect(log).toHaveBeenCalledWith('Gateway is not installed.');
expect(uninstallGatewayPackage).not.toHaveBeenCalled();
log.mockRestore();
});
});
@@ -0,0 +1,104 @@
import { existsSync, rmSync } from 'node:fs';
import { createInterface } from 'node:readline';
import {
GATEWAY_HOME,
getDaemonPid,
readMeta,
stopDaemon,
uninstallGatewayPackage,
} from './daemon.js';
export interface UninstallOptions {
/** Skip the confirmation prompt (headless/scripted uninstall). */
yes?: boolean;
/** Also remove all gateway data at GATEWAY_HOME (never implied by --yes). */
removeData?: boolean;
}
export async function runUninstall(opts: UninstallOptions = {}): Promise<void> {
const nonInteractive = Boolean(opts.yes) || process.env['MOSAIC_ASSUME_YES'] === '1';
// Non-TTY without explicit consent must FAIL LOUD, not quietly do nothing:
// the pre-fix behavior (prompt on a closed stdin → default No → exit 0,
// gateway untouched) reported success-by-silence to every scripted caller
// (#1390). An explicit refusal beats a silent no-op.
if (!nonInteractive && !process.stdin.isTTY) {
console.error(
'gateway uninstall: stdin is not a TTY and no --yes was given — refusing to ' +
'run an interactive uninstall headlessly (nothing was changed). ' +
'Use --yes (and --remove-data to also delete gateway data), or run from a terminal.',
);
process.exit(1);
}
const rl = nonInteractive
? null
: createInterface({ input: process.stdin, output: process.stdout });
try {
await doUninstall(rl as NonNullable<typeof rl>, opts, nonInteractive);
} finally {
rl?.close();
}
}
function prompt(
rl: NonNullable<ReturnType<typeof createInterface>>,
question: string,
): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
}
async function doUninstall(
rl: ReturnType<typeof createInterface>,
opts: UninstallOptions,
nonInteractive: boolean,
): Promise<void> {
const meta = readMeta();
if (!meta) {
console.log('Gateway is not installed.');
return;
}
if (nonInteractive) {
console.log(`Uninstalling Mosaic Gateway (--yes${opts.removeData ? ' --remove-data' : ''})...`);
} else {
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. Interactive: ask. Headless: only with the explicit
// flag — destructive recursion is never implied by --yes alone (#1390).
let removeData = Boolean(opts.removeData);
if (!nonInteractive) {
const answer = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
removeData = answer.toLowerCase() === 'y';
}
if (removeData) {
if (existsSync(GATEWAY_HOME)) {
rmSync(GATEWAY_HOME, { recursive: true, force: true });
console.log('Gateway data removed.');
}
} else {
console.log(`Gateway data kept at ${GATEWAY_HOME}.`);
}
// Uninstall npm package
console.log('Uninstalling npm package...');
uninstallGatewayPackage();
console.log('\nGateway uninstalled.');
}
@@ -0,0 +1,148 @@
import { readMeta } from './daemon.js';
// ANSI colour helpers (gracefully degrade when not a TTY)
const isTTY = Boolean(process.stdout.isTTY);
const G = isTTY ? '\x1b[0;32m' : '';
const R = isTTY ? '\x1b[0;31m' : '';
const BOLD = isTTY ? '\x1b[1m' : '';
const RESET = isTTY ? '\x1b[0m' : '';
function ok(label: string): void {
process.stdout.write(` ${G}${RESET} ${label.padEnd(36)}${G}[ok]${RESET}\n`);
}
function fail(label: string, hint: string): void {
process.stdout.write(` ${R}${RESET} ${label.padEnd(36)}${R}[FAIL]${RESET}\n`);
process.stdout.write(` ${R}${hint}${RESET}\n`);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWithRetry(
url: string,
opts: RequestInit = {},
retries = 3,
delayMs = 1000,
): Promise<Response | null> {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const res = await fetch(url, opts);
// Retry on non-OK responses too — the gateway may still be starting up
// (e.g. 503 before the app bootstrap completes).
if (res.ok) return res;
} catch {
// Network-level error — not ready yet, will retry
}
if (attempt < retries - 1) await sleep(delayMs);
}
return null;
}
export interface VerifyResult {
gatewayHealthy: boolean;
adminTokenOnFile: boolean;
bootstrapReachable: boolean;
/** False only on a FAILED postgres schema check; true when ok or skipped. */
schemaMigrated: boolean;
allPassed: boolean;
}
/**
* Run post-install verification checks.
*
* @param host - Gateway hostname (e.g. "localhost")
* @param port - Gateway port (e.g. 14242)
* @returns VerifyResult — callers can inspect individual flags
*/
export async function runPostInstallVerification(
host: string,
port: number,
): Promise<VerifyResult> {
const baseUrl = `http://${host}:${port.toString()}`;
console.log(`\n${BOLD}Mosaic installation verified:${RESET}`);
// ─── Check 1: Gateway /health ─────────────────────────────────────────────
const healthRes = await fetchWithRetry(`${baseUrl}/health`);
const gatewayHealthy = healthRes !== null && healthRes.ok;
if (gatewayHealthy) {
ok('gateway healthy');
} else {
fail('gateway healthy', 'Run: mosaic gateway status / mosaic gateway logs');
}
// ─── Check 2: Admin token on file ─────────────────────────────────────────
const meta = readMeta();
const adminTokenOnFile = Boolean(meta?.adminToken && meta.adminToken.length > 0);
if (adminTokenOnFile) {
ok('admin token on file');
} else {
fail('admin token on file', 'Run: mosaic gateway config recover-token');
}
// ─── Check 3: Bootstrap endpoint reachable ────────────────────────────────
const bootstrapRes = await fetchWithRetry(`${baseUrl}/api/bootstrap/status`);
const bootstrapReachable = bootstrapRes !== null && bootstrapRes.ok;
if (bootstrapReachable) {
ok('bootstrap endpoint reach');
} else {
fail('bootstrap endpoint reach', 'Run: mosaic gateway status / mosaic gateway logs');
}
// ─── Check 4: Database schema migrated (#1392) ────────────────────────────
// Fatal-on-failure for install: the #1389 failure mode was an install that
// reported success over an empty database. Local tiers skip (the gateway
// migrates its own PGlite at startup and would fail health if it couldn't).
let schemaMigrated = true;
try {
const { checkDatabaseSchema } = await import('./schema-check.js');
const { runMigrations, getMigrationStatus } = await import('@mosaicstack/db');
const result = await checkDatabaseSchema(
{ runMigrations, getMigrationStatus },
undefined, // resolver mirrors daemon file priorities; env has no config authority (N1)
);
if (result.status === 'ok') {
ok(result.detail);
} else if (result.status === 'skipped') {
ok(result.detail);
} else {
fail(result.detail, result.remediation);
schemaMigrated = false;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
fail(
`database schema check errored: ${msg}`,
'See #1392/#1389; re-run: mosaic gateway install',
);
schemaMigrated = false;
}
const allPassed = gatewayHealthy && adminTokenOnFile && bootstrapReachable && schemaMigrated;
if (!allPassed) {
console.log(
`\n${R}One or more checks failed.${RESET} Recovery commands listed above.\n` +
`Use ${BOLD}mosaic gateway status${RESET} and ${BOLD}mosaic gateway config recover-token${RESET} to investigate.\n`,
);
}
return { gatewayHealthy, adminTokenOnFile, bootstrapReachable, schemaMigrated, allPassed };
}
/**
* Standalone entry point for `mosaic gateway verify`.
* Reads host/port from meta.json if not provided.
*/
export async function runVerify(opts: { host?: string; port?: number }): Promise<void> {
const meta = readMeta();
const host = opts.host ?? meta?.host ?? 'localhost';
const port = opts.port ?? meta?.port ?? 14242;
const result = await runPostInstallVerification(host, port);
if (!result.allPassed) {
process.exit(1);
}
}