chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user