Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
9da096fe9b feat(mosaic): add top-level mosaic config command (CU-04-04, CU-04-05)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Adds `mosaic config` command tree with five subcommands (show, get, set,
edit, path) backed by ConfigService; adds minimal get/set/path/readAll
primitives to ConfigService + FileConfigAdapter. Includes Vitest tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:22:30 -05:00
30 changed files with 5 additions and 2099 deletions

View File

@@ -23,7 +23,6 @@
}, },
"dependencies": { "dependencies": {
"@mosaicstack/db": "workspace:*", "@mosaicstack/db": "workspace:*",
"commander": "^13.0.0",
"drizzle-orm": "^0.45.1" "drizzle-orm": "^0.45.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,68 +0,0 @@
import { Command } from 'commander';
import { describe, it, expect } from 'vitest';
import { registerLogCommand } from './cli.js';
function buildTestProgram(): Command {
const program = new Command('mosaic');
program.exitOverride(); // prevent process.exit in tests
registerLogCommand(program);
return program;
}
describe('registerLogCommand', () => {
it('registers a "log" subcommand on the parent', () => {
const program = buildTestProgram();
const names = program.commands.map((c) => c.name());
expect(names).toContain('log');
});
it('log command has tail, search, export, and level subcommands', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log');
expect(logCmd).toBeDefined();
const subNames = logCmd!.commands.map((c) => c.name());
expect(subNames).toContain('tail');
expect(subNames).toContain('search');
expect(subNames).toContain('export');
expect(subNames).toContain('level');
});
it('tail subcommand has expected options', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log')!;
const tailCmd = logCmd.commands.find((c) => c.name() === 'tail')!;
const optionNames = tailCmd.options.map((o) => o.long);
expect(optionNames).toContain('--agent');
expect(optionNames).toContain('--level');
expect(optionNames).toContain('--category');
expect(optionNames).toContain('--tier');
expect(optionNames).toContain('--limit');
expect(optionNames).toContain('--db');
});
it('search subcommand accepts a positional query argument', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log')!;
const searchCmd = logCmd.commands.find((c) => c.name() === 'search')!;
// Commander stores positional args in _args
const argNames = searchCmd.registeredArguments.map((a) => a.name());
expect(argNames).toContain('query');
});
it('export subcommand accepts a positional path argument', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log')!;
const exportCmd = logCmd.commands.find((c) => c.name() === 'export')!;
const argNames = exportCmd.registeredArguments.map((a) => a.name());
expect(argNames).toContain('path');
});
it('level subcommand accepts a positional level argument', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log')!;
const levelCmd = logCmd.commands.find((c) => c.name() === 'level')!;
const argNames = levelCmd.registeredArguments.map((a) => a.name());
expect(argNames).toContain('level');
});
});

View File

@@ -1,177 +0,0 @@
import { writeFileSync } from 'node:fs';
import type { Command } from 'commander';
import type { LogCategory, LogLevel, LogTier } from './agent-logs.js';
interface FilterOptions {
agent?: string;
level?: string;
category?: string;
tier?: string;
limit?: string;
db?: string;
}
function parseLimit(raw: string | undefined, defaultVal = 50): number {
if (!raw) return defaultVal;
const n = parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : defaultVal;
}
function buildQuery(opts: FilterOptions) {
return {
...(opts.agent ? { sessionId: opts.agent } : {}),
...(opts.level ? { level: opts.level as LogLevel } : {}),
...(opts.category ? { category: opts.category as LogCategory } : {}),
...(opts.tier ? { tier: opts.tier as LogTier } : {}),
limit: parseLimit(opts.limit),
};
}
async function openDb(connectionString: string) {
const { createDb } = await import('@mosaicstack/db');
return createDb(connectionString);
}
function resolveConnectionString(opts: FilterOptions): string | undefined {
return opts.db ?? process.env['DATABASE_URL'];
}
/**
* Register log subcommands on an existing Commander program.
* This avoids cross-package Commander version mismatches by using the
* caller's Command instance directly.
*/
export function registerLogCommand(parent: Command): void {
const log = parent.command('log').description('Query and manage agent logs');
// ─── tail ───────────────────────────────────────────────────────────────
log
.command('tail')
.description('Tail recent agent logs')
.option('--agent <id>', 'Filter by agent/session ID')
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
.option('--limit <n>', 'Number of logs to return (default 50)', '50')
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
.action(async (opts: FilterOptions) => {
const connStr = resolveConnectionString(opts);
if (!connStr) {
console.error('Database connection required: use --db or set DATABASE_URL');
process.exit(1);
}
const handle = await openDb(connStr);
try {
const { createLogService } = await import('./log-service.js');
const svc = createLogService(handle.db);
const query = buildQuery(opts);
const logs = await svc.logs.query(query);
if (logs.length === 0) {
console.log('No logs found.');
return;
}
for (const entry of logs) {
const ts = new Date(entry.createdAt).toISOString();
console.log(`[${ts}] [${entry.level}] [${entry.category}] ${entry.content}`);
}
} finally {
await handle.close();
}
});
// ─── search ─────────────────────────────────────────────────────────────
log
.command('search <query>')
.description('Full-text search over agent logs')
.option('--agent <id>', 'Filter by agent/session ID')
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
.option('--limit <n>', 'Number of logs to return (default 50)', '50')
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
.action(async (query: string, opts: FilterOptions) => {
const connStr = resolveConnectionString(opts);
if (!connStr) {
console.error('Database connection required: use --db or set DATABASE_URL');
process.exit(1);
}
const handle = await openDb(connStr);
try {
const { createLogService } = await import('./log-service.js');
const svc = createLogService(handle.db);
const baseQuery = buildQuery(opts);
const logs = await svc.logs.query(baseQuery);
const lowerQ = query.toLowerCase();
const matched = logs.filter(
(e) =>
e.content.toLowerCase().includes(lowerQ) ||
(e.metadata != null && JSON.stringify(e.metadata).toLowerCase().includes(lowerQ)),
);
if (matched.length === 0) {
console.log('No matching logs found.');
return;
}
for (const entry of matched) {
const ts = new Date(entry.createdAt).toISOString();
console.log(`[${ts}] [${entry.level}] [${entry.category}] ${entry.content}`);
}
} finally {
await handle.close();
}
});
// ─── export ─────────────────────────────────────────────────────────────
log
.command('export <path>')
.description('Export matching logs to an NDJSON file')
.option('--agent <id>', 'Filter by agent/session ID')
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
.option('--limit <n>', 'Number of logs to export (default 50)', '50')
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
.action(async (outputPath: string, opts: FilterOptions) => {
const connStr = resolveConnectionString(opts);
if (!connStr) {
console.error('Database connection required: use --db or set DATABASE_URL');
process.exit(1);
}
const handle = await openDb(connStr);
try {
const { createLogService } = await import('./log-service.js');
const svc = createLogService(handle.db);
const query = buildQuery(opts);
const logs = await svc.logs.query(query);
const ndjson = logs.map((e) => JSON.stringify(e)).join('\n');
writeFileSync(outputPath, ndjson, 'utf8');
console.log(`Exported ${logs.length} log(s) to ${outputPath}`);
} finally {
await handle.close();
}
});
// ─── level ──────────────────────────────────────────────────────────────
log
.command('level <level>')
.description('Set runtime log level for the connected log service')
.action((level: string) => {
void level;
console.log(
'Runtime log level adjustment is not supported in current mode (DB-backed log service).',
);
process.exitCode = 0;
});
}

View File

@@ -9,4 +9,3 @@ export {
type LogTier, type LogTier,
type LogQuery, type LogQuery,
} from './agent-logs.js'; } from './agent-logs.js';
export { registerLogCommand } from './cli.js';

View File

@@ -21,9 +21,6 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests" "test": "vitest run --passWithNoTests"
}, },
"dependencies": {
"commander": "^13.0.0"
},
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@vitest/coverage-v8": "^2.0.0", "@vitest/coverage-v8": "^2.0.0",

View File

@@ -1,77 +0,0 @@
import { describe, it, expect } from 'vitest';
import { Command } from 'commander';
import { registerMacpCommand } from './cli.js';
describe('registerMacpCommand', () => {
function buildProgram(): Command {
const program = new Command();
program.exitOverride(); // prevent process.exit in tests
registerMacpCommand(program);
return program;
}
it('registers a "macp" command on the parent', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp');
expect(macpCmd).toBeDefined();
});
it('registers "macp tasks" subcommand group', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const tasksCmd = macpCmd.commands.find((c) => c.name() === 'tasks');
expect(tasksCmd).toBeDefined();
});
it('registers "macp tasks list" subcommand with --status and --type flags', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const tasksCmd = macpCmd.commands.find((c) => c.name() === 'tasks')!;
const listCmd = tasksCmd.commands.find((c) => c.name() === 'list');
expect(listCmd).toBeDefined();
const optionNames = listCmd!.options.map((o) => o.long);
expect(optionNames).toContain('--status');
expect(optionNames).toContain('--type');
});
it('registers "macp submit" subcommand', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const submitCmd = macpCmd.commands.find((c) => c.name() === 'submit');
expect(submitCmd).toBeDefined();
});
it('registers "macp gate" subcommand with --fail-on flag', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const gateCmd = macpCmd.commands.find((c) => c.name() === 'gate');
expect(gateCmd).toBeDefined();
const optionNames = gateCmd!.options.map((o) => o.long);
expect(optionNames).toContain('--fail-on');
});
it('registers "macp events" subcommand group', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const eventsCmd = macpCmd.commands.find((c) => c.name() === 'events');
expect(eventsCmd).toBeDefined();
});
it('registers "macp events tail" subcommand', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const eventsCmd = macpCmd.commands.find((c) => c.name() === 'events')!;
const tailCmd = eventsCmd.commands.find((c) => c.name() === 'tail');
expect(tailCmd).toBeDefined();
});
it('has all required top-level subcommands', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const topLevel = macpCmd.commands.map((c) => c.name());
expect(topLevel).toContain('tasks');
expect(topLevel).toContain('submit');
expect(topLevel).toContain('gate');
expect(topLevel).toContain('events');
});
});

View File

@@ -1,92 +0,0 @@
import type { Command } from 'commander';
/**
* Register macp subcommands on an existing Commander program.
* This avoids cross-package Commander version mismatches by using the
* caller's Command instance directly.
*/
export function registerMacpCommand(parent: Command): void {
const macp = parent.command('macp').description('MACP task and gate management');
// ─── tasks ───────────────────────────────────────────────────────────────
const tasks = macp.command('tasks').description('Manage MACP tasks');
tasks
.command('list')
.description('List MACP tasks')
.option(
'--status <status>',
'Filter by task status (pending|running|gated|completed|failed|escalated)',
)
.option(
'--type <type>',
'Filter by task type (coding|deploy|research|review|documentation|infrastructure)',
)
.action((opts: { status?: string; type?: string }) => {
// not yet wired — task persistence layer is not present in @mosaicstack/macp
console.log('[macp] tasks list: not yet wired — use macp package programmatically');
if (opts.status) {
console.log(` status filter: ${opts.status}`);
}
if (opts.type) {
console.log(` type filter: ${opts.type}`);
}
process.exitCode = 0;
});
// ─── submit ──────────────────────────────────────────────────────────────
macp
.command('submit <path>')
.description('Submit a task from a JSON/YAML spec file')
.action((specPath: string) => {
// not yet wired — task submission requires a running MACP server
console.log('[macp] submit: not yet wired — use macp package programmatically');
console.log(` spec path: ${specPath}`);
console.log(' task id: (unavailable — no MACP server connected)');
console.log(' status: (unavailable — no MACP server connected)');
process.exitCode = 0;
});
// ─── gate ────────────────────────────────────────────────────────────────
macp
.command('gate <spec>')
.description('Run a gate from a spec string or file path (wraps runGate/runGates)')
.option('--fail-on <mode>', 'Gate fail-on mode: ai|fail|both|none', 'fail')
.option('--cwd <path>', 'Working directory for gate execution', process.cwd())
.option('--log <path>', 'Path to write gate log output', '/tmp/macp-gate.log')
.option('--timeout <seconds>', 'Gate timeout in seconds', '60')
.action((spec: string, opts: { failOn: string; cwd: string; log: string; timeout: string }) => {
// not yet wired — gate execution requires a task context and event sink
console.log('[macp] gate: not yet wired — use macp package programmatically');
console.log(` spec: ${spec}`);
console.log(` fail-on: ${opts.failOn}`);
console.log(` cwd: ${opts.cwd}`);
console.log(` log: ${opts.log}`);
console.log(` timeout: ${opts.timeout}s`);
process.exitCode = 0;
});
// ─── events ──────────────────────────────────────────────────────────────
const events = macp.command('events').description('Stream MACP events');
events
.command('tail')
.description('Tail MACP events from the event log (wraps event emitter)')
.option('--file <path>', 'Path to the MACP events NDJSON file')
.option('--follow', 'Follow the file for new events (like tail -f)')
.action((opts: { file?: string; follow?: boolean }) => {
// not yet wired — event streaming requires a live event source
console.log('[macp] events tail: not yet wired — use macp package programmatically');
if (opts.file) {
console.log(` file: ${opts.file}`);
}
if (opts.follow) {
console.log(' mode: follow');
}
process.exitCode = 0;
});
}

View File

@@ -41,6 +41,3 @@ export type { NormalizedGate } from './gate-runner.js';
// Event emitter // Event emitter
export { nowISO, appendEvent, emitEvent } from './event-emitter.js'; export { nowISO, appendEvent, emitEvent } from './event-emitter.js';
// CLI
export { registerMacpCommand } from './cli.js';

View File

@@ -25,7 +25,6 @@
"@mosaicstack/db": "workspace:*", "@mosaicstack/db": "workspace:*",
"@mosaicstack/storage": "workspace:*", "@mosaicstack/storage": "workspace:*",
"@mosaicstack/types": "workspace:*", "@mosaicstack/types": "workspace:*",
"commander": "^13.0.0",
"drizzle-orm": "^0.45.1" "drizzle-orm": "^0.45.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,63 +0,0 @@
import { describe, it, expect } from 'vitest';
import { Command } from 'commander';
import { registerMemoryCommand } from './cli.js';
/**
* Smoke test — only verifies command wiring.
* Does NOT open a database connection.
*/
describe('registerMemoryCommand', () => {
function buildProgram(): Command {
const program = new Command('mosaic');
program.exitOverride(); // prevent process.exit during tests
registerMemoryCommand(program);
return program;
}
it('registers a "memory" subcommand', () => {
const program = buildProgram();
const memory = program.commands.find((c) => c.name() === 'memory');
expect(memory).toBeDefined();
});
it('registers "memory search"', () => {
const program = buildProgram();
const memory = program.commands.find((c) => c.name() === 'memory')!;
const search = memory.commands.find((c) => c.name() === 'search');
expect(search).toBeDefined();
});
it('registers "memory stats"', () => {
const program = buildProgram();
const memory = program.commands.find((c) => c.name() === 'memory')!;
const stats = memory.commands.find((c) => c.name() === 'stats');
expect(stats).toBeDefined();
});
it('registers "memory insights list"', () => {
const program = buildProgram();
const memory = program.commands.find((c) => c.name() === 'memory')!;
const insights = memory.commands.find((c) => c.name() === 'insights');
expect(insights).toBeDefined();
const list = insights!.commands.find((c) => c.name() === 'list');
expect(list).toBeDefined();
});
it('registers "memory preferences list"', () => {
const program = buildProgram();
const memory = program.commands.find((c) => c.name() === 'memory')!;
const preferences = memory.commands.find((c) => c.name() === 'preferences');
expect(preferences).toBeDefined();
const list = preferences!.commands.find((c) => c.name() === 'list');
expect(list).toBeDefined();
});
it('"memory search" has --limit and --agent options', () => {
const program = buildProgram();
const memory = program.commands.find((c) => c.name() === 'memory')!;
const search = memory.commands.find((c) => c.name() === 'search')!;
const optNames = search.options.map((o) => o.long);
expect(optNames).toContain('--limit');
expect(optNames).toContain('--agent');
});
});

View File

@@ -1,179 +0,0 @@
import type { Command } from 'commander';
import type { MemoryAdapter } from './types.js';
/**
* Build and return a connected MemoryAdapter from a connection string or
* the MEMORY_DB_URL / DATABASE_URL environment variable.
*
* For pgvector (postgres://...) the connection string is injected into
* DATABASE_URL so that PgVectorAdapter's internal createDb() picks it up.
*
* Throws with a human-readable message if no connection info is available.
*/
async function resolveAdapter(dbOption: string | undefined): Promise<MemoryAdapter> {
const connStr = dbOption ?? process.env['MEMORY_DB_URL'] ?? process.env['DATABASE_URL'];
if (!connStr) {
throw new Error(
'No database connection string provided. ' +
'Pass --db <connection-string> or set MEMORY_DB_URL / DATABASE_URL.',
);
}
// Lazy imports so the module loads cleanly without a live DB during smoke tests.
const { createMemoryAdapter, registerMemoryAdapter } = await import('./factory.js');
if (connStr.startsWith('postgres') || connStr.startsWith('pg')) {
// PgVectorAdapter reads DATABASE_URL via createDb() — inject it here.
process.env['DATABASE_URL'] = connStr;
const { PgVectorAdapter } = await import('./adapters/pgvector.js');
registerMemoryAdapter('pgvector', (cfg) => new PgVectorAdapter(cfg as never));
return createMemoryAdapter({ type: 'pgvector' });
}
// Keyword adapter backed by pglite storage; treat connStr as a data directory.
const { KeywordAdapter } = await import('./adapters/keyword.js');
const { createStorageAdapter, registerStorageAdapter } = await import('@mosaicstack/storage');
const { PgliteAdapter } = await import('@mosaicstack/storage');
registerStorageAdapter('pglite', (cfg) => new PgliteAdapter(cfg as never));
const storage = createStorageAdapter({ type: 'pglite', dataDir: connStr });
registerMemoryAdapter('keyword', (cfg) => new KeywordAdapter(cfg as never));
return createMemoryAdapter({ type: 'keyword', storage });
}
/**
* Register `memory` subcommands on an existing Commander program.
* Follows the registerQualityRails pattern from @mosaicstack/quality-rails.
*/
export function registerMemoryCommand(parent: Command): void {
const memory = parent.command('memory').description('Inspect and query the Mosaic memory layer');
// ── memory search <query> ──────────────────────────────────────────────
memory
.command('search <query>')
.description('Semantic search over insights')
.option('--db <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
.option('--limit <n>', 'Maximum number of results', '10')
.option('--agent <id>', 'Filter by agent / user ID')
.action(async (query: string, opts: { db?: string; limit: string; agent?: string }) => {
let adapter: MemoryAdapter | undefined;
try {
adapter = await resolveAdapter(opts.db);
const limit = parseInt(opts.limit, 10);
const userId = opts.agent ?? 'system';
const results = await adapter.searchInsights(userId, query, { limit });
if (results.length === 0) {
console.log('No insights found.');
} else {
for (const r of results) {
console.log(`[${r.id}] (score=${r.score.toFixed(3)}) ${r.content}`);
}
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
} finally {
await adapter?.close();
}
});
// ── memory stats ──────────────────────────────────────────────────────
memory
.command('stats')
.description('Print memory tier info: adapter type, insight count, preference count')
.option('--db <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
.option('--agent <id>', 'User / agent ID scope for counts', 'system')
.action(async (opts: { db?: string; agent: string }) => {
let adapter: MemoryAdapter | undefined;
try {
adapter = await resolveAdapter(opts.db);
const adapterType = adapter.name;
const insightCount = await adapter
.searchInsights(opts.agent, '', { limit: 100000 })
.then((r) => r.length)
.catch(() => -1);
const prefCount = await adapter
.listPreferences(opts.agent)
.then((r) => r.length)
.catch(() => -1);
console.log(`adapter: ${adapterType}`);
console.log(`insights: ${insightCount === -1 ? 'unavailable' : String(insightCount)}`);
console.log(`preferences: ${prefCount === -1 ? 'unavailable' : String(prefCount)}`);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
} finally {
await adapter?.close();
}
});
// ── memory insights ───────────────────────────────────────────────────
const insightsCmd = memory.command('insights').description('Manage insights');
insightsCmd
.command('list')
.description('List recent insights')
.option('--db <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
.option('--limit <n>', 'Maximum number of results', '20')
.option('--agent <id>', 'User / agent ID scope', 'system')
.action(async (opts: { db?: string; limit: string; agent: string }) => {
let adapter: MemoryAdapter | undefined;
try {
adapter = await resolveAdapter(opts.db);
const limit = parseInt(opts.limit, 10);
const results = await adapter.searchInsights(opts.agent, '', { limit });
if (results.length === 0) {
console.log('No insights found.');
} else {
for (const r of results) {
console.log(`[${r.id}] ${r.content}`);
}
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
} finally {
await adapter?.close();
}
});
// ── memory preferences ────────────────────────────────────────────────
const prefsCmd = memory.command('preferences').description('Manage stored preferences');
prefsCmd
.command('list')
.description('List stored preferences')
.option('--db <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
.option('--agent <id>', 'User / agent ID scope', 'system')
.option('--category <cat>', 'Filter by category')
.action(async (opts: { db?: string; agent: string; category?: string }) => {
let adapter: MemoryAdapter | undefined;
try {
adapter = await resolveAdapter(opts.db);
const prefs = await adapter.listPreferences(opts.agent, opts.category);
if (prefs.length === 0) {
console.log('No preferences found.');
} else {
for (const p of prefs) {
console.log(`[${p.category}] ${p.key} = ${JSON.stringify(p.value)}`);
}
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
} finally {
await adapter?.close();
}
});
}

View File

@@ -1,5 +1,4 @@
export { createMemory, type Memory } from './memory.js'; export { createMemory, type Memory } from './memory.js';
export { registerMemoryCommand } from './cli.js';
export { export {
createPreferencesRepo, createPreferencesRepo,
type PreferencesRepo, type PreferencesRepo,

View File

@@ -30,13 +30,9 @@
"@mosaicstack/brain": "workspace:*", "@mosaicstack/brain": "workspace:*",
"@mosaicstack/config": "workspace:*", "@mosaicstack/config": "workspace:*",
"@mosaicstack/forge": "workspace:*", "@mosaicstack/forge": "workspace:*",
"@mosaicstack/log": "workspace:*",
"@mosaicstack/macp": "workspace:*", "@mosaicstack/macp": "workspace:*",
"@mosaicstack/memory": "workspace:*",
"@mosaicstack/prdy": "workspace:*", "@mosaicstack/prdy": "workspace:*",
"@mosaicstack/quality-rails": "workspace:*", "@mosaicstack/quality-rails": "workspace:*",
"@mosaicstack/queue": "workspace:*",
"@mosaicstack/storage": "workspace:*",
"@mosaicstack/types": "workspace:*", "@mosaicstack/types": "workspace:*",
"@clack/prompts": "^0.9.1", "@clack/prompts": "^0.9.1",
"commander": "^13.0.0", "commander": "^13.0.0",

View File

@@ -3,12 +3,7 @@
import { createRequire } from 'module'; import { createRequire } from 'module';
import { Command } from 'commander'; import { Command } from 'commander';
import { registerBrainCommand } from '@mosaicstack/brain'; import { registerBrainCommand } from '@mosaicstack/brain';
import { registerLogCommand } from '@mosaicstack/log';
import { registerMacpCommand } from '@mosaicstack/macp';
import { registerMemoryCommand } from '@mosaicstack/memory';
import { registerQualityRails } from '@mosaicstack/quality-rails'; import { registerQualityRails } from '@mosaicstack/quality-rails';
import { registerQueueCommand } from '@mosaicstack/queue';
import { registerStorageCommand } from '@mosaicstack/storage';
import { registerAgentCommand } from './commands/agent.js'; import { registerAgentCommand } from './commands/agent.js';
import { registerConfigCommand } from './commands/config.js'; import { registerConfigCommand } from './commands/config.js';
import { registerMissionCommand } from './commands/mission.js'; import { registerMissionCommand } from './commands/mission.js';
@@ -348,30 +343,10 @@ registerMissionCommand(program);
registerBrainCommand(program); registerBrainCommand(program);
// ─── macp ────────────────────────────────────────────────────────────────
registerMacpCommand(program);
// ─── quality-rails ────────────────────────────────────────────────────── // ─── quality-rails ──────────────────────────────────────────────────────
registerQualityRails(program); registerQualityRails(program);
// ─── log ─────────────────────────────────────────────────────────────────
registerLogCommand(program);
// ─── memory ──────────────────────────────────────────────────────────────
registerMemoryCommand(program);
// ─── queue ───────────────────────────────────────────────────────────────
registerQueueCommand(program);
// ─── storage ─────────────────────────────────────────────────────────────
registerStorageCommand(program);
// ─── update ───────────────────────────────────────────────────────────── // ─── update ─────────────────────────────────────────────────────────────
program program

View File

@@ -6,7 +6,6 @@ import {
stopDaemon, stopDaemon,
waitForHealth, waitForHealth,
} from './gateway/daemon.js'; } from './gateway/daemon.js';
import { getGatewayUrl } from './gateway/login.js';
interface GatewayParentOpts { interface GatewayParentOpts {
host: string; host: string;
@@ -120,28 +119,9 @@ export function registerGatewayCommand(program: Command): void {
await runStatus(opts); await runStatus(opts);
}); });
// ─── login ──────────────────────────────────────────────────────────────
gw.command('login')
.description('Sign in to the gateway (defaults to URL from meta.json)')
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
.option('-e, --email <email>', 'Email address')
.option('-p, --password <password>', 'Password')
.action(async (cmdOpts: { gateway?: string; email?: string; password?: string }) => {
const { runLogin } = await import('./gateway/login.js');
const url = getGatewayUrl(cmdOpts.gateway);
try {
await runLogin({ gatewayUrl: url, email: cmdOpts.email, password: cmdOpts.password });
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── config ───────────────────────────────────────────────────────────── // ─── config ─────────────────────────────────────────────────────────────
const configCmd = gw gw.command('config')
.command('config')
.description('View or modify gateway configuration') .description('View or modify gateway configuration')
.option('--set <KEY=VALUE>', 'Set a configuration value') .option('--set <KEY=VALUE>', 'Set a configuration value')
.option('--unset <KEY>', 'Remove a configuration key') .option('--unset <KEY>', 'Remove a configuration key')
@@ -151,24 +131,6 @@ export function registerGatewayCommand(program: Command): void {
await runConfig(cmdOpts); await runConfig(cmdOpts);
}); });
configCmd
.command('rotate-token')
.description('Mint a new admin token using the stored BetterAuth session')
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
.action(async (cmdOpts: { gateway?: string }) => {
const { runRotateToken } = await import('./gateway/token-ops.js');
await runRotateToken(cmdOpts.gateway);
});
configCmd
.command('recover-token')
.description('Recover an admin token — prompts for login if no valid session exists')
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
.action(async (cmdOpts: { gateway?: string }) => {
const { runRecoverToken } = await import('./gateway/token-ops.js');
await runRecoverToken(cmdOpts.gateway);
});
// ─── logs ─────────────────────────────────────────────────────────────── // ─── logs ───────────────────────────────────────────────────────────────
gw.command('logs') gw.command('logs')

View File

@@ -388,32 +388,10 @@ async function bootstrapFirstUser(
if (!status.needsSetup) { if (!status.needsSetup) {
if (meta.adminToken) { if (meta.adminToken) {
console.log('Admin user already exists (token on file).'); console.log('Admin user already exists (token on file).');
return; } else {
console.log('Admin user already exists — skipping setup.');
console.log('(No admin token on file — sign in via the web UI to manage tokens.)');
} }
// Admin user exists but no token — offer inline recovery when interactive.
console.log('Admin user already exists but no admin token is on file.');
if (process.stdin.isTTY) {
const answer = (await prompt(rl, 'Run token recovery now? [Y/n] ')).trim().toLowerCase();
if (answer === '' || answer === 'y' || answer === 'yes') {
console.log();
try {
const { ensureSession, mintAdminToken, persistToken } = await import('./token-ops.js');
const cookie = await ensureSession(baseUrl);
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
const minted = await mintAdminToken(baseUrl, cookie, label);
persistToken(baseUrl, minted);
} catch (err) {
console.error(
`Token recovery failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
return;
}
}
console.log('No admin token on file. Run: mosaic gateway config recover-token');
return; return;
} }
} catch { } catch {

View File

@@ -1,87 +0,0 @@
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: 'admin@test.com',
};
mockSignIn.mockResolvedValueOnce(fakeAuth);
await runLogin({
gatewayUrl: 'http://localhost:14242',
email: 'admin@test.com',
password: 'password123',
});
expect(mockSignIn).toHaveBeenCalledWith(
'http://localhost:14242',
'admin@test.com',
'password123',
);
expect(mockSaveSession).toHaveBeenCalledWith('http://localhost:14242', fakeAuth);
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('admin@test.com'));
});
it('propagates signIn errors', async () => {
mockSignIn.mockRejectedValueOnce(new Error('Sign-in failed (401): invalid credentials'));
await expect(
runLogin({ gatewayUrl: 'http://localhost:14242', email: 'bad@test.com', password: 'wrong' }),
).rejects.toThrow('Sign-in failed (401)');
});
});

View File

@@ -1,39 +0,0 @@
import { createInterface } from 'node:readline';
import { signIn, saveSession } from '../../auth.js';
import { readMeta } from './daemon.js';
/**
* 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> {
let email = opts.email;
let password = opts.password;
if (!email || !password) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
if (!email) email = await ask('Email: ');
if (!password) password = await ask('Password: ');
rl.close();
}
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';
}

View File

@@ -1,176 +0,0 @@
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 readline so tests don't block on stdin
vi.mock('node:readline', () => ({
createInterface: vi.fn().mockReturnValue({
question: vi.fn((_q: string, cb: (a: string) => void) => cb('test-input')),
close: vi.fn(),
}),
}));
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: 'a@b.com' });
mockValidateSession.mockResolvedValueOnce(true);
const cookie = await ensureSession(baseUrl);
expect(cookie).toBe(fakeCookie);
expect(mockSignIn).not.toHaveBeenCalled();
});
it('prompts for credentials and signs in when stored session is invalid', async () => {
mockLoadSession.mockReturnValueOnce({ cookie: 'old-cookie', userId: 'u1', email: 'a@b.com' });
mockValidateSession.mockResolvedValueOnce(false);
const newAuth = { cookie: fakeCookie, userId: 'u2', email: 'a@b.com' };
mockSignIn.mockResolvedValueOnce(newAuth);
const cookie = await ensureSession(baseUrl);
expect(cookie).toBe(fakeCookie);
expect(mockSaveSession).toHaveBeenCalledWith(baseUrl, newAuth);
});
it('prompts for credentials when no session exists', async () => {
mockLoadSession.mockReturnValueOnce(null);
const newAuth = { cookie: fakeCookie, userId: 'u2', email: 'a@b.com' };
mockSignIn.mockResolvedValueOnce(newAuth);
const cookie = await ensureSession(baseUrl);
expect(cookie).toBe(fakeCookie);
expect(mockSignIn).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('prompts for login, mints a token, and persists it when no session exists', async () => {
mockLoadSession.mockReturnValueOnce(null);
const newAuth = { cookie: fakeCookie, userId: 'u2', email: 'admin@test.com' };
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: 'a@b.com' });
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: 'a@b.com' });
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/);
});
});

View File

@@ -1,205 +0,0 @@
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: 'a@b.com' });
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: 'a@b.com' });
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 }),
);
});
});

View File

@@ -1,149 +0,0 @@
import { createInterface } from 'node:readline';
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
import { readMeta, writeMeta } from './daemon.js';
import { getGatewayUrl } from './login.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.
*/
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),
};
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.
*/
export async function ensureSession(gatewayUrl: string): 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.`);
}
// Prompt for credentials
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
const email = (await ask('Email: ')).trim();
const password = (await ask('Password: ')).trim();
rl.close();
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` — prompts for login if no session exists.
*/
export async function runRecoverToken(gatewayUrl?: string): Promise<void> {
const url = getGatewayUrl(gatewayUrl);
const cookie = await ensureSession(url);
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
const minted = await mintAdminToken(url, cookie, label);
persistToken(url, minted);
}

View File

@@ -22,7 +22,6 @@
}, },
"dependencies": { "dependencies": {
"@mosaicstack/types": "workspace:*", "@mosaicstack/types": "workspace:*",
"commander": "^13.0.0",
"ioredis": "^5.10.0" "ioredis": "^5.10.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,62 +0,0 @@
import { describe, it, expect } from 'vitest';
import { Command } from 'commander';
import { registerQueueCommand } from './cli.js';
describe('registerQueueCommand', () => {
function buildProgram(): Command {
const program = new Command('mosaic');
registerQueueCommand(program);
return program;
}
it('registers a "queue" subcommand', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
expect(queueCmd).toBeDefined();
});
it('queue has list, stats, pause, resume, jobs, drain subcommands', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
expect(queueCmd).toBeDefined();
const names = queueCmd!.commands.map((c) => c.name());
expect(names).toContain('list');
expect(names).toContain('stats');
expect(names).toContain('pause');
expect(names).toContain('resume');
expect(names).toContain('jobs');
expect(names).toContain('drain');
});
it('jobs subcommand has a "tail" subcommand', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
const jobsCmd = queueCmd!.commands.find((c) => c.name() === 'jobs');
expect(jobsCmd).toBeDefined();
const tailCmd = jobsCmd!.commands.find((c) => c.name() === 'tail');
expect(tailCmd).toBeDefined();
});
it('drain has a --yes option', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
const drainCmd = queueCmd!.commands.find((c) => c.name() === 'drain');
expect(drainCmd).toBeDefined();
const optionNames = drainCmd!.options.map((o) => o.long);
expect(optionNames).toContain('--yes');
});
it('stats accepts an optional [name] argument', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
const statsCmd = queueCmd!.commands.find((c) => c.name() === 'stats');
expect(statsCmd).toBeDefined();
// Should not throw when called without argument
const args = statsCmd!.registeredArguments;
expect(args.length).toBe(1);
expect(args[0]!.required).toBe(false);
});
});

View File

@@ -1,248 +0,0 @@
import type { Command } from 'commander';
import { createLocalAdapter } from './adapters/local.js';
import type { QueueConfig } from './types.js';
/** Resolve adapter type from env; defaults to 'local'. */
function resolveAdapterType(): 'bullmq' | 'local' {
const t = process.env['QUEUE_ADAPTER'] ?? 'local';
return t === 'bullmq' ? 'bullmq' : 'local';
}
function resolveConfig(): QueueConfig {
const type = resolveAdapterType();
if (type === 'bullmq') {
return { type: 'bullmq', url: process.env['VALKEY_URL'] };
}
return { type: 'local', dataDir: process.env['QUEUE_DATA_DIR'] };
}
const BULLMQ_ONLY_MSG =
'not supported by local adapter — use the bullmq tier for this (set QUEUE_ADAPTER=bullmq)';
/**
* Register queue subcommands on an existing Commander program.
* Follows the same pattern as registerQualityRails in @mosaicstack/quality-rails.
*/
export function registerQueueCommand(parent: Command): void {
buildQueueCommand(parent.command('queue').description('Manage Mosaic job queues'));
}
function buildQueueCommand(queue: Command): void {
// ─── list ──────────────────────────────────────────────────────────────
queue
.command('list')
.description('List all queues known to the configured adapter')
.action(async () => {
const config = resolveConfig();
if (config.type === 'local') {
const adapter = createLocalAdapter(config);
// Local adapter tracks queues in its internal Map; we expose them by
// listing JSON files in the data dir.
const { readdirSync } = await import('node:fs');
const { existsSync } = await import('node:fs');
const dataDir = config.dataDir ?? '.mosaic/queue';
if (!existsSync(dataDir)) {
console.log('No queues found (data dir does not exist yet).');
await adapter.close();
return;
}
const files = readdirSync(dataDir).filter((f: string) => f.endsWith('.json'));
if (files.length === 0) {
console.log('No queues found.');
} else {
console.log('Queues (local adapter):');
for (const f of files) {
console.log(` - ${f.slice(0, -5)}`);
}
}
await adapter.close();
return;
}
// bullmq — not enough info to enumerate queues without a BullMQ Board
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── stats ─────────────────────────────────────────────────────────────
queue
.command('stats [name]')
.description('Show stats for a queue (or all queues)')
.action(async (name?: string) => {
const config = resolveConfig();
if (config.type === 'local') {
const adapter = createLocalAdapter(config);
const { readdirSync } = await import('node:fs');
const { existsSync } = await import('node:fs');
const dataDir = config.dataDir ?? '.mosaic/queue';
let names: string[] = [];
if (name) {
names = [name];
} else {
if (existsSync(dataDir)) {
names = readdirSync(dataDir)
.filter((f: string) => f.endsWith('.json'))
.map((f: string) => f.slice(0, -5));
}
}
if (names.length === 0) {
console.log('No queues found.');
await adapter.close();
return;
}
for (const queueName of names) {
const len = await adapter.length(queueName);
console.log(`Queue: ${queueName}`);
console.log(` waiting: ${len}`);
console.log(` active: 0 (local adapter — no active tracking)`);
console.log(` completed: 0 (local adapter — no completed tracking)`);
console.log(` failed: 0 (local adapter — no failed tracking)`);
console.log(` delayed: 0 (local adapter — no delayed tracking)`);
}
await adapter.close();
return;
}
// bullmq
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── pause ─────────────────────────────────────────────────────────────
queue
.command('pause <name>')
.description('Pause job processing for a queue')
.action(async (_name: string) => {
const config = resolveConfig();
if (config.type === 'local') {
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
return;
}
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── resume ────────────────────────────────────────────────────────────
queue
.command('resume <name>')
.description('Resume job processing for a queue')
.action(async (_name: string) => {
const config = resolveConfig();
if (config.type === 'local') {
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
return;
}
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── jobs tail ─────────────────────────────────────────────────────────
const jobs = queue.command('jobs').description('Job-level operations');
jobs
.command('tail [name]')
.description('Stream new jobs as they arrive (poll-based)')
.option('--interval <ms>', 'Poll interval in ms', '2000')
.action(async (name: string | undefined, opts: { interval: string }) => {
const config = resolveConfig();
const pollMs = parseInt(opts.interval, 10);
if (config.type === 'local') {
const adapter = createLocalAdapter(config);
const { existsSync, readdirSync } = await import('node:fs');
const dataDir = config.dataDir ?? '.mosaic/queue';
let names: string[] = [];
if (name) {
names = [name];
} else {
if (existsSync(dataDir)) {
names = readdirSync(dataDir)
.filter((f: string) => f.endsWith('.json'))
.map((f: string) => f.slice(0, -5));
}
}
if (names.length === 0) {
console.log('No queues to tail.');
await adapter.close();
return;
}
console.log(`Tailing queues: ${names.join(', ')} (Ctrl-C to stop)`);
const lastLen = new Map<string, number>();
for (const qn of names) {
lastLen.set(qn, await adapter.length(qn));
}
const timer = setInterval(async () => {
for (const qn of names) {
const len = await adapter.length(qn);
const prev = lastLen.get(qn) ?? 0;
if (len > prev) {
console.log(
`[${new Date().toISOString()}] ${qn}: ${len - prev} new job(s) (total: ${len})`,
);
}
lastLen.set(qn, len);
}
}, pollMs);
process.on('SIGINT', async () => {
clearInterval(timer);
await adapter.close();
process.exit(0);
});
return;
}
// bullmq — use subscribe on the channel
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── drain ─────────────────────────────────────────────────────────────
queue
.command('drain <name>')
.description('Drain all pending jobs from a queue')
.option('--yes', 'Skip confirmation prompt')
.action(async (name: string, opts: { yes?: boolean }) => {
if (!opts.yes) {
console.error(
`WARNING: This will remove all pending jobs from queue "${name}". Re-run with --yes to confirm.`,
);
process.exit(1);
return;
}
const config = resolveConfig();
if (config.type === 'local') {
const adapter = createLocalAdapter(config);
let removed = 0;
while ((await adapter.length(name)) > 0) {
await adapter.dequeue(name);
removed++;
}
console.log(`Drained ${removed} job(s) from queue "${name}".`);
await adapter.close();
return;
}
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
}

View File

@@ -11,7 +11,6 @@ export { type QueueAdapter, type QueueConfig as QueueAdapterConfig } from './typ
export { createQueueAdapter, registerQueueAdapter } from './factory.js'; export { createQueueAdapter, registerQueueAdapter } from './factory.js';
export { createBullMQAdapter } from './adapters/bullmq.js'; export { createBullMQAdapter } from './adapters/bullmq.js';
export { createLocalAdapter } from './adapters/local.js'; export { createLocalAdapter } from './adapters/local.js';
export { registerQueueCommand } from './cli.js';
import { registerQueueAdapter } from './factory.js'; import { registerQueueAdapter } from './factory.js';
import { createBullMQAdapter } from './adapters/bullmq.js'; import { createBullMQAdapter } from './adapters/bullmq.js';

View File

@@ -23,8 +23,7 @@
"dependencies": { "dependencies": {
"@electric-sql/pglite": "^0.2.17", "@electric-sql/pglite": "^0.2.17",
"@mosaicstack/db": "workspace:^", "@mosaicstack/db": "workspace:^",
"@mosaicstack/types": "workspace:*", "@mosaicstack/types": "workspace:*"
"commander": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.8.0", "typescript": "^5.8.0",

View File

@@ -1,85 +0,0 @@
import { describe, it, expect } from 'vitest';
import { Command } from 'commander';
import { registerStorageCommand } from './cli.js';
describe('registerStorageCommand', () => {
function buildProgram(): Command {
const program = new Command();
program.exitOverride(); // prevent process.exit in tests
registerStorageCommand(program);
return program;
}
it('registers a "storage" command on the parent', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage');
expect(storageCmd).toBeDefined();
});
it('registers "storage status" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const statusCmd = storageCmd.commands.find((c) => c.name() === 'status');
expect(statusCmd).toBeDefined();
});
it('registers "storage tier" subcommand group', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier');
expect(tierCmd).toBeDefined();
});
it('registers "storage tier show" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier')!;
const showCmd = tierCmd.commands.find((c) => c.name() === 'show');
expect(showCmd).toBeDefined();
});
it('registers "storage tier switch" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier')!;
const switchCmd = tierCmd.commands.find((c) => c.name() === 'switch');
expect(switchCmd).toBeDefined();
});
it('registers "storage export" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const exportCmd = storageCmd.commands.find((c) => c.name() === 'export');
expect(exportCmd).toBeDefined();
});
it('registers "storage import" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const importCmd = storageCmd.commands.find((c) => c.name() === 'import');
expect(importCmd).toBeDefined();
});
it('registers "storage migrate" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const migrateCmd = storageCmd.commands.find((c) => c.name() === 'migrate');
expect(migrateCmd).toBeDefined();
});
it('has all required subcommands in a single assertion', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const topLevel = storageCmd.commands.map((c) => c.name());
expect(topLevel).toContain('status');
expect(topLevel).toContain('tier');
expect(topLevel).toContain('export');
expect(topLevel).toContain('import');
expect(topLevel).toContain('migrate');
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier')!;
const tierSubcmds = tierCmd.commands.map((c) => c.name());
expect(tierSubcmds).toContain('show');
expect(tierSubcmds).toContain('switch');
});
});

View File

@@ -1,256 +0,0 @@
import type { Command } from 'commander';
/**
* Reads the DATABASE_URL environment variable and redacts the password portion.
*/
function redactedConnectionString(): string | null {
const url = process.env['DATABASE_URL'];
if (!url) return null;
try {
const parsed = new URL(url);
if (parsed.password) {
parsed.password = '***';
}
return parsed.toString();
} catch {
// Not a valid URL — redact anything that looks like :password@
return url.replace(/:([^@/]+)@/, ':***@');
}
}
/**
* Determine the active storage tier from the environment.
* Looks at DATABASE_URL; if absent or set to a pglite path, treats tier as pglite.
*/
function activeTier(): 'postgres' | 'pglite' {
const url = process.env['DATABASE_URL'];
if (url && url.startsWith('postgres')) return 'postgres';
return 'pglite';
}
/**
* Return a human-readable config source description.
*/
function configSource(): string {
if (process.env['DATABASE_URL']) return 'env:DATABASE_URL';
const pgliteDir = process.env['PGLITE_DATA_DIR'];
if (pgliteDir) return `env:PGLITE_DATA_DIR (${pgliteDir})`;
return 'default (no DATABASE_URL set)';
}
/**
* Register storage subcommands on an existing Commander program.
* Follows the registerQualityRails pattern — uses the caller's Command
* instance to avoid cross-package Commander version mismatches.
*/
export function registerStorageCommand(parent: Command): void {
const storage = parent
.command('storage')
.description('Inspect and manage Mosaic storage configuration');
// ── storage status ───────────────────────────────────────────────────────
storage
.command('status')
.description('Show the configured storage tier and whether the adapter is reachable')
.action(async () => {
const tier = activeTier();
const source = configSource();
const connStr = tier === 'postgres' ? redactedConnectionString() : null;
console.log(`[storage] tier: ${tier}`);
console.log(`[storage] config source: ${source}`);
if (tier === 'postgres' && connStr) {
console.log(`[storage] connection: ${connStr}`);
try {
const { createDb, sql } = await import('@mosaicstack/db');
const url = process.env['DATABASE_URL'] ?? '';
const handle = createDb(url);
await handle.db.execute(sql`SELECT 1`);
await handle.close();
console.log('[storage] reachable: yes');
} catch (err) {
console.log(
`[storage] reachable: no (${err instanceof Error ? err.message : String(err)})`,
);
}
} else {
const dataDir = process.env['PGLITE_DATA_DIR'] ?? ':memory:';
console.log(`[storage] data dir: ${dataDir}`);
console.log('[storage] reachable: pglite is always local — no network check needed');
}
});
// ── storage tier ─────────────────────────────────────────────────────────
const tier = storage.command('tier').description('Inspect or switch the storage tier');
tier
.command('show')
.description('Print the active storage tier and its config source')
.action(() => {
const activeTierValue = activeTier();
const source = configSource();
console.log(`[storage] active tier: ${activeTierValue}`);
console.log(`[storage] config source: ${source}`);
});
tier
.command('switch <tier>')
.description('Switch storage tier between pglite and postgres')
.action((newTier: string) => {
const validTiers = ['pglite', 'postgres'];
if (!validTiers.includes(newTier)) {
console.error(
`[storage] unknown tier: ${newTier}. Valid options: ${validTiers.join(', ')}`,
);
process.exitCode = 1;
return;
}
console.log(`[storage] tier switch requested: ${newTier}`);
console.log('');
console.log('Mosaic storage tier is controlled by environment variables.');
console.log('Automatic config-file mutation is not supported — set the variable manually.');
console.log('');
if (newTier === 'postgres') {
console.log('To switch to postgres:');
console.log(' 1. Set DATABASE_URL in your environment or .env file:');
console.log(' export DATABASE_URL="postgresql://user:pass@localhost:5432/mosaic"');
console.log(' 2. Run migrations:');
console.log(' pnpm --filter @mosaicstack/db db:migrate');
console.log(' 3. Restart the gateway.');
} else {
console.log('To switch to pglite:');
console.log(' 1. Unset DATABASE_URL (or set it to a pglite path):');
console.log(' unset DATABASE_URL');
console.log(' # optionally: export PGLITE_DATA_DIR=/path/to/pglite/data');
console.log(' 2. Restart the gateway.');
console.log(' Note: pglite uses an in-process database — no migrations needed.');
}
});
// ── storage export ───────────────────────────────────────────────────────
storage
.command('export <path>')
.description('Dump the active storage contents to a file')
.action((outputPath: string) => {
const currentTier = activeTier();
if (currentTier === 'postgres') {
const redacted = redactedConnectionString() ?? '<DATABASE_URL>';
console.log('[storage] export for postgres tier');
console.log('');
console.log('postgres export is not yet wired in the CLI — use pg_dump directly:');
console.log('');
console.log(` pg_dump "${redacted}" > ${outputPath}`);
console.log('');
console.log('Or with Docker:');
console.log(
` docker exec <postgres-container> pg_dump -U <user> <dbname> > ${outputPath}`,
);
process.exitCode = 0;
} else {
const dataDir = process.env['PGLITE_DATA_DIR'];
console.log('[storage] export for pglite tier');
console.log('');
console.log(
'pglite export is not yet wired in the CLI — copy the data directory directly:',
);
console.log('');
if (dataDir) {
console.log(` cp -r ${dataDir} ${outputPath}`);
} else {
console.log(
' PGLITE_DATA_DIR is not set; the database is in-memory and cannot be exported.',
);
console.log(' Set PGLITE_DATA_DIR to a persistent path before running export.');
}
process.exitCode = 0;
}
});
// ── storage import ───────────────────────────────────────────────────────
storage
.command('import <path>')
.description('Restore storage contents from a previously exported file')
.action((inputPath: string) => {
const currentTier = activeTier();
if (currentTier === 'postgres') {
const redacted = redactedConnectionString() ?? '<DATABASE_URL>';
console.log('[storage] import for postgres tier');
console.log('');
console.log('postgres import is not yet wired in the CLI — use psql directly:');
console.log('');
console.log(` psql "${redacted}" < ${inputPath}`);
process.exitCode = 0;
} else {
const dataDir = process.env['PGLITE_DATA_DIR'];
console.log('[storage] import for pglite tier');
console.log('');
console.log(
'pglite import is not yet wired in the CLI — restore the data directory directly:',
);
console.log('');
if (dataDir) {
console.log(` rm -rf ${dataDir} && cp -r ${inputPath} ${dataDir}`);
console.log(' Then restart the gateway.');
} else {
console.log(
' PGLITE_DATA_DIR is not set; set it to a persistent path before running import.',
);
}
process.exitCode = 0;
}
});
// ── storage migrate ──────────────────────────────────────────────────────
storage
.command('migrate')
.description(
'Run database migrations (thin wrapper — delegates to pnpm db:migrate or prints the command)',
)
.option('--run', 'Actually execute the migration command via shell')
.action(async (opts: { run?: boolean }) => {
const currentTier = activeTier();
if (currentTier === 'pglite') {
console.log('[storage] pglite tier detected');
console.log(
'pglite runs schema setup automatically on first connection via adapter.migrate().',
);
console.log('No separate migration step is required.');
return;
}
const migrateCmd = 'pnpm --filter @mosaicstack/db db:migrate';
console.log('[storage] postgres tier detected');
console.log(`Migration command: ${migrateCmd}`);
console.log('');
if (opts.run) {
console.log('Running migrations...');
const { execSync } = await import('node:child_process');
try {
execSync(migrateCmd, { stdio: 'inherit' });
console.log('[storage] migrations complete.');
} catch (err) {
console.error(
`[storage] migration failed: ${err instanceof Error ? err.message : String(err)}`,
);
process.exitCode = 1;
}
} else {
console.log('To run migrations, execute:');
console.log(` ${migrateCmd}`);
console.log('');
console.log('Or pass --run to have this command execute it for you.');
}
});
}

View File

@@ -2,7 +2,6 @@ export type { StorageAdapter, StorageConfig } from './types.js';
export { createStorageAdapter, registerStorageAdapter } from './factory.js'; export { createStorageAdapter, registerStorageAdapter } from './factory.js';
export { PostgresAdapter } from './adapters/postgres.js'; export { PostgresAdapter } from './adapters/postgres.js';
export { PgliteAdapter } from './adapters/pglite.js'; export { PgliteAdapter } from './adapters/pglite.js';
export { registerStorageCommand } from './cli.js';
import { registerStorageAdapter } from './factory.js'; import { registerStorageAdapter } from './factory.js';
import { PostgresAdapter } from './adapters/postgres.js'; import { PostgresAdapter } from './adapters/postgres.js';

28
pnpm-lock.yaml generated
View File

@@ -404,9 +404,6 @@ importers:
'@mosaicstack/db': '@mosaicstack/db':
specifier: workspace:* specifier: workspace:*
version: link:../db version: link:../db
commander:
specifier: ^13.0.0
version: 13.1.0
drizzle-orm: drizzle-orm:
specifier: ^0.45.1 specifier: ^0.45.1
version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8) version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)
@@ -419,10 +416,6 @@ importers:
version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1) version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
packages/macp: packages/macp:
dependencies:
commander:
specifier: ^13.0.0
version: 13.1.0
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^22.0.0 specifier: ^22.0.0
@@ -448,9 +441,6 @@ importers:
'@mosaicstack/types': '@mosaicstack/types':
specifier: workspace:* specifier: workspace:*
version: link:../types version: link:../types
commander:
specifier: ^13.0.0
version: 13.1.0
drizzle-orm: drizzle-orm:
specifier: ^0.45.1 specifier: ^0.45.1
version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8) version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)
@@ -476,27 +466,15 @@ importers:
'@mosaicstack/forge': '@mosaicstack/forge':
specifier: workspace:* specifier: workspace:*
version: link:../forge version: link:../forge
'@mosaicstack/log':
specifier: workspace:*
version: link:../log
'@mosaicstack/macp': '@mosaicstack/macp':
specifier: workspace:* specifier: workspace:*
version: link:../macp version: link:../macp
'@mosaicstack/memory':
specifier: workspace:*
version: link:../memory
'@mosaicstack/prdy': '@mosaicstack/prdy':
specifier: workspace:* specifier: workspace:*
version: link:../prdy version: link:../prdy
'@mosaicstack/quality-rails': '@mosaicstack/quality-rails':
specifier: workspace:* specifier: workspace:*
version: link:../quality-rails version: link:../quality-rails
'@mosaicstack/queue':
specifier: workspace:*
version: link:../queue
'@mosaicstack/storage':
specifier: workspace:*
version: link:../storage
'@mosaicstack/types': '@mosaicstack/types':
specifier: workspace:* specifier: workspace:*
version: link:../types version: link:../types
@@ -593,9 +571,6 @@ importers:
'@mosaicstack/types': '@mosaicstack/types':
specifier: workspace:* specifier: workspace:*
version: link:../types version: link:../types
commander:
specifier: ^13.0.0
version: 13.1.0
ioredis: ioredis:
specifier: ^5.10.0 specifier: ^5.10.0
version: 5.10.0 version: 5.10.0
@@ -618,9 +593,6 @@ importers:
'@mosaicstack/types': '@mosaicstack/types':
specifier: workspace:* specifier: workspace:*
version: link:../types version: link:../types
commander:
specifier: ^13.0.0
version: 13.1.0
devDependencies: devDependencies:
typescript: typescript:
specifier: ^5.8.0 specifier: ^5.8.0