Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
206cc799f3 feat(storage): add registerStorageCommand for mosaic storage CLI surface
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Implements CU-05-02: adds `mosaic storage` command group with subcommands
status, tier show, tier switch, export, import, and migrate. Pragmatic
stubs print actionable instructions for unimplemented export/import tiers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:34:29 -05:00
11 changed files with 356 additions and 257 deletions

View File

@@ -25,7 +25,6 @@
"@mosaicstack/db": "workspace:*",
"@mosaicstack/storage": "workspace:*",
"@mosaicstack/types": "workspace:*",
"commander": "^13.0.0",
"drizzle-orm": "^0.45.1"
},
"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 { registerMemoryCommand } from './cli.js';
export {
createPreferencesRepo,
type PreferencesRepo,

View File

@@ -31,10 +31,10 @@
"@mosaicstack/config": "workspace:*",
"@mosaicstack/forge": "workspace:*",
"@mosaicstack/macp": "workspace:*",
"@mosaicstack/memory": "workspace:*",
"@mosaicstack/prdy": "workspace:*",
"@mosaicstack/quality-rails": "workspace:*",
"@mosaicstack/queue": "workspace:*",
"@mosaicstack/storage": "workspace:*",
"@mosaicstack/types": "workspace:*",
"@clack/prompts": "^0.9.1",
"commander": "^13.0.0",

View File

@@ -3,9 +3,9 @@
import { createRequire } from 'module';
import { Command } from 'commander';
import { registerBrainCommand } from '@mosaicstack/brain';
import { registerMemoryCommand } from '@mosaicstack/memory';
import { registerQualityRails } from '@mosaicstack/quality-rails';
import { registerQueueCommand } from '@mosaicstack/queue';
import { registerStorageCommand } from '@mosaicstack/storage';
import { registerAgentCommand } from './commands/agent.js';
import { registerMissionCommand } from './commands/mission.js';
// prdy is registered via launch.ts
@@ -344,14 +344,14 @@ registerBrainCommand(program);
registerQualityRails(program);
// ─── memory ──────────────────────────────────────────────────────────────
registerMemoryCommand(program);
// ─── queue ───────────────────────────────────────────────────────────────
registerQueueCommand(program);
// ─── storage ─────────────────────────────────────────────────────────────
registerStorageCommand(program);
// ─── update ─────────────────────────────────────────────────────────────
program

View File

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

View File

@@ -0,0 +1,85 @@
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');
});
});

256
packages/storage/src/cli.ts Normal file
View File

@@ -0,0 +1,256 @@
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,6 +2,7 @@ export type { StorageAdapter, StorageConfig } from './types.js';
export { createStorageAdapter, registerStorageAdapter } from './factory.js';
export { PostgresAdapter } from './adapters/postgres.js';
export { PgliteAdapter } from './adapters/pglite.js';
export { registerStorageCommand } from './cli.js';
import { registerStorageAdapter } from './factory.js';
import { PostgresAdapter } from './adapters/postgres.js';

12
pnpm-lock.yaml generated
View File

@@ -441,9 +441,6 @@ importers:
'@mosaicstack/types':
specifier: workspace:*
version: link:../types
commander:
specifier: ^13.0.0
version: 13.1.0
drizzle-orm:
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)
@@ -472,9 +469,6 @@ importers:
'@mosaicstack/macp':
specifier: workspace:*
version: link:../macp
'@mosaicstack/memory':
specifier: workspace:*
version: link:../memory
'@mosaicstack/prdy':
specifier: workspace:*
version: link:../prdy
@@ -484,6 +478,9 @@ importers:
'@mosaicstack/queue':
specifier: workspace:*
version: link:../queue
'@mosaicstack/storage':
specifier: workspace:*
version: link:../storage
'@mosaicstack/types':
specifier: workspace:*
version: link:../types
@@ -605,6 +602,9 @@ importers:
'@mosaicstack/types':
specifier: workspace:*
version: link:../types
commander:
specifier: ^13.0.0
version: 13.1.0
devDependencies:
typescript:
specifier: ^5.8.0