diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index f51eda3..a138b8b 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -34,6 +34,7 @@ "@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", diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 3f79224..cce64d2 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -5,6 +5,7 @@ import { Command } from 'commander'; import { registerBrainCommand } from '@mosaicstack/brain'; 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 @@ -347,6 +348,10 @@ registerQualityRails(program); registerQueueCommand(program); +// ─── storage ───────────────────────────────────────────────────────────── + +registerStorageCommand(program); + // ─── update ───────────────────────────────────────────────────────────── program diff --git a/packages/storage/package.json b/packages/storage/package.json index a1d251b..e4b40e4 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -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", diff --git a/packages/storage/src/cli.spec.ts b/packages/storage/src/cli.spec.ts new file mode 100644 index 0000000..0f6807a --- /dev/null +++ b/packages/storage/src/cli.spec.ts @@ -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'); + }); +}); diff --git a/packages/storage/src/cli.ts b/packages/storage/src/cli.ts new file mode 100644 index 0000000..e6fc1cf --- /dev/null +++ b/packages/storage/src/cli.ts @@ -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 ') + .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 ') + .description('Dump the active storage contents to a file') + .action((outputPath: string) => { + const currentTier = activeTier(); + + if (currentTier === 'postgres') { + const redacted = redactedConnectionString() ?? ''; + 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 pg_dump -U > ${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 ') + .description('Restore storage contents from a previously exported file') + .action((inputPath: string) => { + const currentTier = activeTier(); + + if (currentTier === 'postgres') { + const redacted = redactedConnectionString() ?? ''; + 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.'); + } + }); +} diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 1a5498f..4d44cea 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -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'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 606620d..8d7e10f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -478,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 @@ -599,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