From 641e4604d51837abddb3a59af8533f5fb8838fe3 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 5 Apr 2026 06:08:50 +0000 Subject: [PATCH] feat(forge): mosaic forge CLI surface (#412) --- packages/forge/package.json | 3 +- packages/forge/src/cli.spec.ts | 57 ++++ packages/forge/src/cli.ts | 280 ++++++++++++++++++ packages/forge/src/index.ts | 3 + packages/mosaic/src/cli.ts | 10 + packages/mosaic/src/commands/auth.spec.ts | 114 ++++++++ packages/mosaic/src/commands/auth.ts | 331 ++++++++++++++++++++++ pnpm-lock.yaml | 54 +++- 8 files changed, 849 insertions(+), 3 deletions(-) create mode 100644 packages/forge/src/cli.spec.ts create mode 100644 packages/forge/src/cli.ts create mode 100644 packages/mosaic/src/commands/auth.spec.ts create mode 100644 packages/mosaic/src/commands/auth.ts diff --git a/packages/forge/package.json b/packages/forge/package.json index 1ab372d..ee64970 100644 --- a/packages/forge/package.json +++ b/packages/forge/package.json @@ -26,7 +26,8 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { - "@mosaicstack/macp": "workspace:*" + "@mosaicstack/macp": "workspace:*", + "commander": "^13.0.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/packages/forge/src/cli.spec.ts b/packages/forge/src/cli.spec.ts new file mode 100644 index 0000000..d2fe881 --- /dev/null +++ b/packages/forge/src/cli.spec.ts @@ -0,0 +1,57 @@ +import { Command } from 'commander'; +import { describe, expect, it } from 'vitest'; + +import { registerForgeCommand } from './cli.js'; + +describe('registerForgeCommand', () => { + it('registers a "forge" command on the parent program', () => { + const program = new Command(); + registerForgeCommand(program); + + const forgeCmd = program.commands.find((c) => c.name() === 'forge'); + expect(forgeCmd).toBeDefined(); + }); + + it('registers the four required subcommands under forge', () => { + const program = new Command(); + registerForgeCommand(program); + + const forgeCmd = program.commands.find((c) => c.name() === 'forge'); + expect(forgeCmd).toBeDefined(); + + const subNames = forgeCmd!.commands.map((c) => c.name()); + + expect(subNames).toContain('run'); + expect(subNames).toContain('status'); + expect(subNames).toContain('resume'); + expect(subNames).toContain('personas'); + }); + + it('registers "personas list" as a subcommand of "forge personas"', () => { + const program = new Command(); + registerForgeCommand(program); + + const forgeCmd = program.commands.find((c) => c.name() === 'forge'); + const personasCmd = forgeCmd!.commands.find((c) => c.name() === 'personas'); + expect(personasCmd).toBeDefined(); + + const personasSubNames = personasCmd!.commands.map((c) => c.name()); + expect(personasSubNames).toContain('list'); + }); + + it('does not modify the parent program name or description', () => { + const program = new Command('mosaic'); + program.description('Mosaic Stack CLI'); + registerForgeCommand(program); + + expect(program.name()).toBe('mosaic'); + expect(program.description()).toBe('Mosaic Stack CLI'); + }); + + it('can be called multiple times without throwing', () => { + const program = new Command(); + expect(() => { + registerForgeCommand(program); + }).not.toThrow(); + }); +}); diff --git a/packages/forge/src/cli.ts b/packages/forge/src/cli.ts new file mode 100644 index 0000000..618150a --- /dev/null +++ b/packages/forge/src/cli.ts @@ -0,0 +1,280 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { Command } from 'commander'; + +import { classifyBrief } from './brief-classifier.js'; +import { STAGE_LABELS, STAGE_SEQUENCE } from './constants.js'; +import { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js'; +import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js'; +import type { PipelineOptions, RunManifest, TaskExecutor } from './types.js'; + +// --------------------------------------------------------------------------- +// Stub executor — used when no real executor is wired at CLI invocation time. +// --------------------------------------------------------------------------- + +const stubExecutor: TaskExecutor = { + async submitTask(task) { + console.log(` [forge] stage submitted: ${task.id} (${task.title})`); + }, + async waitForCompletion(taskId, _timeoutMs) { + console.log(` [forge] stage complete: ${taskId}`); + return { + task_id: taskId, + status: 'completed' as const, + completed_at: new Date().toISOString(), + exit_code: 0, + gate_results: [], + }; + }, + async getTaskStatus(_taskId) { + return 'completed' as const; + }, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatDuration(startedAt?: string, completedAt?: string): string { + if (!startedAt || !completedAt) return '-'; + const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime(); + const secs = Math.round(ms / 1000); + return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m${secs % 60}s`; +} + +function printManifestTable(manifest: RunManifest): void { + console.log(`\nRun ID : ${manifest.runId}`); + console.log(`Status : ${manifest.status}`); + console.log(`Brief : ${manifest.brief}`); + console.log(`Class : ${manifest.briefClass} (${manifest.classSource})`); + console.log(`Updated: ${manifest.updatedAt}`); + console.log(''); + console.log('Stage'.padEnd(22) + 'Status'.padEnd(14) + 'Duration'); + console.log('-'.repeat(50)); + for (const stage of STAGE_SEQUENCE) { + const s = manifest.stages[stage]; + if (!s) continue; + const label = (STAGE_LABELS[stage] ?? stage).padEnd(22); + const status = s.status.padEnd(14); + const dur = formatDuration(s.startedAt, s.completedAt); + console.log(`${label}${status}${dur}`); + } + console.log(''); +} + +function resolveRunDir(runId: string, projectRoot?: string): string { + const root = projectRoot ?? process.cwd(); + return path.join(root, '.forge', 'runs', runId); +} + +function listRecentRuns(projectRoot?: string): void { + const root = projectRoot ?? process.cwd(); + const runsDir = path.join(root, '.forge', 'runs'); + + if (!fs.existsSync(runsDir)) { + console.log('No runs found. Run `mosaic forge run` to start a pipeline.'); + return; + } + + const entries = fs + .readdirSync(runsDir) + .filter((name) => fs.statSync(path.join(runsDir, name)).isDirectory()) + .sort() + .reverse() + .slice(0, 10); + + if (entries.length === 0) { + console.log('No runs found.'); + return; + } + + console.log('\nRecent runs:'); + console.log('Run ID'.padEnd(22) + 'Status'.padEnd(14) + 'Brief'); + console.log('-'.repeat(70)); + + for (const runId of entries) { + const runDir = path.join(runsDir, runId); + try { + const manifest = loadManifest(runDir); + const status = manifest.status.padEnd(14); + const brief = path.basename(manifest.brief); + console.log(`${runId.padEnd(22)}${status}${brief}`); + } catch { + console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(14)}`); + } + } + console.log(''); +} + +// --------------------------------------------------------------------------- +// Register function +// --------------------------------------------------------------------------- + +/** + * Register forge subcommands on an existing Commander program. + * Mirrors the pattern used by registerQualityRails in @mosaicstack/quality-rails. + */ +export function registerForgeCommand(parent: Command): void { + const forge = parent.command('forge').description('Run and manage Forge pipelines'); + + // ── forge run ──────────────────────────────────────────────────────────── + + forge + .command('run') + .description('Run a Forge pipeline from a brief markdown file') + .requiredOption('--brief ', 'Path to the brief markdown file') + .option('--run-id ', 'Override the auto-generated run ID') + .option('--resume', 'Resume an existing run instead of starting a new one', false) + .option('--config ', 'Path to forge config file (.forge/config.yaml)') + .option('--codebase ', 'Codebase root to pass to the pipeline', process.cwd()) + .option('--dry-run', 'Print planned stages without executing', false) + .action( + async (opts: { + brief: string; + runId?: string; + resume: boolean; + config?: string; + codebase: string; + dryRun: boolean; + }) => { + const briefPath = path.resolve(opts.brief); + + if (!fs.existsSync(briefPath)) { + console.error(`[forge] brief not found: ${briefPath}`); + process.exitCode = 1; + return; + } + + const briefContent = fs.readFileSync(briefPath, 'utf-8'); + const briefClass = classifyBrief(briefContent); + const projectRoot = opts.codebase; + + if (opts.resume) { + const runId = opts.runId ?? generateRunId(); + const runDir = resolveRunDir(runId, projectRoot); + console.log(`[forge] resuming run: ${runId}`); + const { resumePipeline } = await import('./pipeline-runner.js'); + const result = await resumePipeline(runDir, stubExecutor); + console.log(`[forge] pipeline complete: ${result.runId}`); + return; + } + + const pipelineOptions: PipelineOptions = { + briefClass, + codebase: projectRoot, + dryRun: opts.dryRun, + executor: stubExecutor, + }; + + if (opts.dryRun) { + const { stagesForClass } = await import('./brief-classifier.js'); + const stages = stagesForClass(briefClass); + console.log(`[forge] dry-run — brief class: ${briefClass}`); + console.log('[forge] planned stages:'); + for (const stage of stages) { + console.log(` - ${stage} (${STAGE_LABELS[stage] ?? stage})`); + } + return; + } + + console.log(`[forge] starting pipeline for brief: ${briefPath}`); + console.log(`[forge] classified as: ${briefClass}`); + + try { + const result = await runPipeline(briefPath, projectRoot, pipelineOptions); + console.log(`[forge] pipeline complete: ${result.runId}`); + console.log(`[forge] run directory: ${result.runDir}`); + } catch (err) { + console.error( + `[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exitCode = 1; + } + }, + ); + + // ── forge status ───────────────────────────────────────────────────────── + + forge + .command('status [runId]') + .description('Show the status of a pipeline run (omit runId to list recent runs)') + .option('--project ', 'Project root (defaults to cwd)', process.cwd()) + .action(async (runId: string | undefined, opts: { project: string }) => { + if (!runId) { + listRecentRuns(opts.project); + return; + } + + const runDir = resolveRunDir(runId, opts.project); + try { + const manifest = getPipelineStatus(runDir); + printManifestTable(manifest); + } catch (err) { + console.error( + `[forge] could not load run "${runId}": ${err instanceof Error ? err.message : String(err)}`, + ); + process.exitCode = 1; + } + }); + + // ── forge resume ───────────────────────────────────────────────────────── + + forge + .command('resume ') + .description('Resume a stopped or failed pipeline run') + .option('--project ', 'Project root (defaults to cwd)', process.cwd()) + .action(async (runId: string, opts: { project: string }) => { + const runDir = resolveRunDir(runId, opts.project); + + if (!fs.existsSync(runDir)) { + console.error(`[forge] run not found: ${runDir}`); + process.exitCode = 1; + return; + } + + console.log(`[forge] resuming run: ${runId}`); + + try { + const { resumePipeline } = await import('./pipeline-runner.js'); + const result = await resumePipeline(runDir, stubExecutor); + console.log(`[forge] pipeline complete: ${result.runId}`); + console.log(`[forge] run directory: ${result.runDir}`); + } catch (err) { + console.error(`[forge] resume failed: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } + }); + + // ── forge personas ──────────────────────────────────────────────────────── + + const personas = forge.command('personas').description('Manage Forge board personas'); + + personas + .command('list') + .description('List configured board personas') + .option( + '--project ', + 'Project root for persona overrides (defaults to cwd)', + process.cwd(), + ) + .option('--board-dir ', 'Override the board agents directory') + .action((opts: { project: string; boardDir?: string }) => { + const effectivePersonas = opts.boardDir + ? loadBoardPersonas(opts.boardDir) + : getEffectivePersonas(opts.project); + + if (effectivePersonas.length === 0) { + console.log('[forge] no board personas configured.'); + return; + } + + console.log(`\nBoard personas (${effectivePersonas.length}):\n`); + console.log('Slug'.padEnd(24) + 'Name'); + console.log('-'.repeat(50)); + for (const p of effectivePersonas) { + console.log(`${p.slug.padEnd(24)}${p.name}`); + } + console.log(''); + }); +} diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts index 0f939d2..62c765a 100644 --- a/packages/forge/src/index.ts +++ b/packages/forge/src/index.ts @@ -80,3 +80,6 @@ export { resumePipeline, getPipelineStatus, } from './pipeline-runner.js'; + +// CLI +export { registerForgeCommand } from './cli.js'; diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 378a93d..5c1a07a 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -3,6 +3,7 @@ import { createRequire } from 'module'; import { Command } from 'commander'; import { registerBrainCommand } from '@mosaicstack/brain'; +import { registerForgeCommand } from '@mosaicstack/forge'; import { registerLogCommand } from '@mosaicstack/log'; import { registerMemoryCommand } from '@mosaicstack/memory'; import { registerQualityRails } from '@mosaicstack/quality-rails'; @@ -13,6 +14,7 @@ import { registerConfigCommand } from './commands/config.js'; import { registerMissionCommand } from './commands/mission.js'; // prdy is registered via launch.ts import { registerLaunchCommands } from './commands/launch.js'; +import { registerAuthCommand } from './commands/auth.js'; import { registerGatewayCommand } from './commands/gateway.js'; import { backgroundUpdateCheck, @@ -327,6 +329,10 @@ sessionsCmd } }); +// ─── auth ──────────────────────────────────────────────────────────────── + +registerAuthCommand(program); + // ─── gateway ────────────────────────────────────────────────────────── registerGatewayCommand(program); @@ -347,6 +353,10 @@ registerMissionCommand(program); registerBrainCommand(program); +// ─── forge ─────────────────────────────────────────────────────────────── + +registerForgeCommand(program); + // ─── quality-rails ────────────────────────────────────────────────────── registerQualityRails(program); diff --git a/packages/mosaic/src/commands/auth.spec.ts b/packages/mosaic/src/commands/auth.spec.ts new file mode 100644 index 0000000..8c5e425 --- /dev/null +++ b/packages/mosaic/src/commands/auth.spec.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; + +// ─── Mocks ────────────────────────────────────────────────────────────────── +// These mocks prevent any real disk/network access during tests. + +vi.mock('./gateway/login.js', () => ({ + getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'), +})); + +vi.mock('./gateway/token-ops.js', () => ({ + requireSession: vi.fn().mockResolvedValue('better-auth.session_token=test'), +})); + +// Global fetch is never called in smoke tests (no actions invoked). + +import { registerAuthCommand } from './auth.js'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function buildTestProgram(): Command { + const program = new Command('mosaic').exitOverride(); + registerAuthCommand(program); + return program; +} + +function findCommand(program: Command, ...path: string[]): Command | undefined { + let current: Command = program; + for (const name of path) { + const found = current.commands.find((c) => c.name() === name); + if (!found) return undefined; + current = found; + } + return current; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('registerAuthCommand', () => { + let program: Command; + + beforeEach(() => { + vi.clearAllMocks(); + program = buildTestProgram(); + }); + + it('registers the top-level auth command', () => { + const authCmd = findCommand(program, 'auth'); + expect(authCmd).toBeDefined(); + expect(authCmd?.name()).toBe('auth'); + }); + + describe('auth users', () => { + it('registers the users subcommand', () => { + const usersCmd = findCommand(program, 'auth', 'users'); + expect(usersCmd).toBeDefined(); + }); + + it('registers users list with --limit flag', () => { + const listCmd = findCommand(program, 'auth', 'users', 'list'); + expect(listCmd).toBeDefined(); + const limitOpt = listCmd?.options.find((o) => o.long === '--limit'); + expect(limitOpt).toBeDefined(); + }); + + it('registers users create', () => { + const createCmd = findCommand(program, 'auth', 'users', 'create'); + expect(createCmd).toBeDefined(); + }); + + it('registers users delete with --yes flag', () => { + const deleteCmd = findCommand(program, 'auth', 'users', 'delete'); + expect(deleteCmd).toBeDefined(); + const yesOpt = deleteCmd?.options.find((o) => o.long === '--yes'); + expect(yesOpt).toBeDefined(); + }); + }); + + describe('auth sso', () => { + it('registers the sso subcommand', () => { + const ssoCmd = findCommand(program, 'auth', 'sso'); + expect(ssoCmd).toBeDefined(); + }); + + it('registers sso list', () => { + const listCmd = findCommand(program, 'auth', 'sso', 'list'); + expect(listCmd).toBeDefined(); + }); + + it('registers sso test', () => { + const testCmd = findCommand(program, 'auth', 'sso', 'test'); + expect(testCmd).toBeDefined(); + }); + }); + + describe('auth sessions', () => { + it('registers the sessions subcommand', () => { + const sessCmd = findCommand(program, 'auth', 'sessions'); + expect(sessCmd).toBeDefined(); + }); + + it('registers sessions list', () => { + const listCmd = findCommand(program, 'auth', 'sessions', 'list'); + expect(listCmd).toBeDefined(); + }); + }); + + it('all top-level auth subcommand names are correct', () => { + const authCmd = findCommand(program, 'auth'); + expect(authCmd).toBeDefined(); + const names = authCmd!.commands.map((c) => c.name()).sort(); + expect(names).toEqual(['sessions', 'sso', 'users']); + }); +}); diff --git a/packages/mosaic/src/commands/auth.ts b/packages/mosaic/src/commands/auth.ts new file mode 100644 index 0000000..6d190b7 --- /dev/null +++ b/packages/mosaic/src/commands/auth.ts @@ -0,0 +1,331 @@ +import type { Command } from 'commander'; +import { getGatewayUrl } from './gateway/login.js'; +import { requireSession } from './gateway/token-ops.js'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface UserDto { + id: string; + name: string; + email: string; + role: string; + banned: boolean; + banReason: string | null; + createdAt: string; + updatedAt: string; +} + +interface UserListDto { + users: UserDto[]; + total: number; +} + +// ─── HTTP helpers ──────────────────────────────────────────────────────────── + +async function adminGet(gatewayUrl: string, cookie: string, path: string): Promise { + let res: Response; + try { + res = await fetch(`${gatewayUrl}${path}`, { + headers: { Cookie: cookie, Origin: gatewayUrl }, + }); + } 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()}).`); + console.error('Run: mosaic gateway login'); + process.exit(2); + } + + if (!res.ok) { + const body = await res.text().catch(() => ''); + console.error(`Gateway returned error (${res.status.toString()}): ${body.slice(0, 200)}`); + process.exit(3); + } + + return res.json() as Promise; +} + +async function adminPost( + gatewayUrl: string, + cookie: string, + path: string, + body: unknown, +): Promise { + let res: Response; + try { + res = await fetch(`${gatewayUrl}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: cookie, + Origin: gatewayUrl, + }, + body: JSON.stringify(body), + }); + } 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()}).`); + console.error('Run: mosaic gateway login'); + process.exit(2); + } + + if (!res.ok) { + const body = await res.text().catch(() => ''); + console.error(`Gateway returned error (${res.status.toString()}): ${body.slice(0, 200)}`); + process.exit(3); + } + + return res.json() as Promise; +} + +async function adminDelete(gatewayUrl: string, cookie: string, path: string): Promise { + let res: Response; + try { + res = await fetch(`${gatewayUrl}${path}`, { + method: 'DELETE', + headers: { Cookie: cookie, Origin: gatewayUrl }, + }); + } 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()}).`); + console.error('Run: mosaic gateway login'); + process.exit(2); + } + + if (!res.ok && res.status !== 204) { + const body = await res.text().catch(() => ''); + console.error(`Gateway returned error (${res.status.toString()}): ${body.slice(0, 200)}`); + process.exit(3); + } +} + +// ─── Formatters ────────────────────────────────────────────────────────────── + +function printUser(u: UserDto): void { + console.log(` ID: ${u.id}`); + console.log(` Name: ${u.name}`); + console.log(` Email: ${u.email}`); + console.log(` Role: ${u.role}`); + console.log(` Banned: ${u.banned ? `yes (${u.banReason ?? 'no reason'})` : 'no'}`); + console.log(` Created: ${new Date(u.createdAt).toLocaleString()}`); + console.log(''); +} + +// ─── Register function ─────────────────────────────────────────────────────── + +/** + * Register `mosaic auth` subcommands on an existing Commander program. + * + * Location rationale: placed in packages/mosaic rather than packages/auth because + * the CLI needs session helpers (loadSession, validateSession, requireSession) + * and gateway URL resolution (getGatewayUrl) that live in packages/mosaic. + * Keeping packages/auth as a pure server-side library avoids adding commander + * and CLI tooling as dependencies there. + */ +export function registerAuthCommand(parent: Command): void { + const auth = parent + .command('auth') + .description('Manage gateway authentication, users, SSO providers, and sessions') + .configureHelp({ sortSubcommands: true }) + .action(() => { + auth.outputHelp(); + }); + + // ─── users ────────────────────────────────────────────────────────────── + + const users = auth + .command('users') + .description('Manage gateway users') + .configureHelp({ sortSubcommands: true }) + .action(() => { + users.outputHelp(); + }); + + users + .command('list') + .description('List all users on the gateway') + .option('-g, --gateway ', 'Gateway URL') + .option('-l, --limit ', 'Maximum number of users to display', '100') + .action(async (opts: { gateway?: string; limit: string }) => { + const url = getGatewayUrl(opts.gateway); + const cookie = await requireSession(url); + const limit = parseInt(opts.limit, 10); + + const result = await adminGet(url, cookie, '/api/admin/users'); + + const subset = result.users.slice(0, limit); + if (subset.length === 0) { + console.log('No users found.'); + return; + } + + console.log(`Users (${subset.length.toString()} of ${result.total.toString()}):\n`); + for (const u of subset) { + printUser(u); + } + }); + + users + .command('create') + .description('Create a new gateway user (interactive prompts)') + .option('-g, --gateway ', 'Gateway URL') + .action(async (opts: { gateway?: string }) => { + const url = getGatewayUrl(opts.gateway); + const cookie = await requireSession(url); + + const { + text, + password: clackPassword, + select, + intro, + outro, + isCancel, + } = await import('@clack/prompts'); + + intro('Create a new Mosaic gateway user'); + + const name = await text({ message: 'Full name:', placeholder: 'Jane Doe' }); + if (isCancel(name)) { + outro('Cancelled.'); + process.exit(0); + } + + const email = await text({ message: 'Email:', placeholder: 'jane@example.com' }); + if (isCancel(email)) { + outro('Cancelled.'); + process.exit(0); + } + + const pw = await clackPassword({ message: 'Password:' }); + if (isCancel(pw)) { + outro('Cancelled.'); + process.exit(0); + } + + const role = await select({ + message: 'Role:', + options: [ + { value: 'member', label: 'member' }, + { value: 'admin', label: 'admin' }, + ], + }); + if (isCancel(role)) { + outro('Cancelled.'); + process.exit(0); + } + + const created = await adminPost(url, cookie, '/api/admin/users', { + name: name as string, + email: email as string, + password: pw as string, + role: role as string, + }); + + outro(`User created: ${created.email} (${created.id})`); + }); + + users + .command('delete ') + .description('Delete a gateway user by ID') + .option('-g, --gateway ', 'Gateway URL') + .option('-y, --yes', 'Skip confirmation prompt') + .action(async (id: string, opts: { gateway?: string; yes?: boolean }) => { + const url = getGatewayUrl(opts.gateway); + const cookie = await requireSession(url); + + if (!opts.yes) { + const { confirm, isCancel } = await import('@clack/prompts'); + const confirmed = await confirm({ + message: `Delete user ${id}? This cannot be undone.`, + }); + if (isCancel(confirmed) || !confirmed) { + console.log('Aborted.'); + process.exit(0); + } + } + + await adminDelete(url, cookie, `/api/admin/users/${id}`); + console.log(`User ${id} deleted.`); + }); + + // ─── sso ──────────────────────────────────────────────────────────────── + + const sso = auth + .command('sso') + .description('Manage SSO provider configuration') + .configureHelp({ sortSubcommands: true }) + .action(() => { + sso.outputHelp(); + }); + + sso + .command('list') + .description('List configured SSO providers (reads gateway discovery endpoint if available)') + .option('-g, --gateway ', 'Gateway URL') + .action(async (opts: { gateway?: string }) => { + // The admin SSO discovery endpoint is not yet wired server-side. + // The buildSsoDiscovery helper in @mosaicstack/auth reads env-vars on the + // server; there is no GET /api/admin/sso endpoint in apps/gateway/src/admin/. + // Stub until a gateway admin route is wired. + console.log( + 'not yet wired — admin endpoint missing (GET /api/admin/sso not implemented server-side)', + ); + console.log( + 'Hint: SSO providers are configured via environment variables (AUTHENTIK_*, WORKOS_*, KEYCLOAK_*).', + ); + // Suppress unused variable warning + void opts; + }); + + sso + .command('test ') + .description('Smoke-test a configured SSO provider') + .option('-g, --gateway ', 'Gateway URL') + .action(async (provider: string, opts: { gateway?: string }) => { + // No server-side SSO smoke-test endpoint exists yet. + console.log( + `not yet wired — admin endpoint missing (POST /api/admin/sso/${provider}/test not implemented server-side)`, + ); + void opts; + }); + + // ─── sessions ──────────────────────────────────────────────────────────── + + const authSessions = auth + .command('sessions') + .description('Manage BetterAuth user sessions stored on the gateway') + .configureHelp({ sortSubcommands: true }) + .action(() => { + authSessions.outputHelp(); + }); + + authSessions + .command('list') + .description('List active user sessions') + .option('-g, --gateway ', 'Gateway URL') + .action(async (opts: { gateway?: string }) => { + // No GET /api/admin/auth-sessions endpoint exists in apps/gateway/src/admin/. + // Stub until a gateway admin route is wired. + console.log( + 'not yet wired — admin endpoint missing (GET /api/admin/auth-sessions not implemented server-side)', + ); + void opts; + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 007b954..053413d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -385,6 +385,9 @@ importers: '@mosaicstack/macp': specifier: workspace:* version: link:../macp + commander: + specifier: ^13.0.0 + version: 13.1.0 devDependencies: '@types/node': specifier: ^22.0.0 @@ -664,10 +667,10 @@ importers: dependencies: '@mariozechner/pi-agent-core': specifier: ^0.63.1 - version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) '@mariozechner/pi-ai': specifier: ^0.63.1 - version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) '@sinclair/typebox': specifier: ^0.34.41 version: 0.34.48 @@ -7042,6 +7045,12 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@anthropic-ai/sdk@0.73.0(zod@3.25.76)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 3.25.76 + '@anthropic-ai/sdk@0.73.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -8383,6 +8392,18 @@ snapshots: - ws - zod + '@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8431,6 +8452,30 @@ snapshots: - ws - zod + '@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@anthropic-ai/sdk': 0.73.0(zod@3.25.76) + '@aws-sdk/client-bedrock-runtime': 3.1008.0 + '@google/genai': 1.45.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) + '@mistralai/mistralai': 1.14.1 + '@sinclair/typebox': 0.34.48 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + chalk: 5.6.2 + openai: 6.26.0(ws@8.20.0)(zod@3.25.76) + partial-json: 0.1.7 + proxy-agent: 6.5.0 + undici: 7.24.3 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) @@ -12806,6 +12851,11 @@ snapshots: dependencies: mimic-function: 5.0.1 + openai@6.26.0(ws@8.20.0)(zod@3.25.76): + optionalDependencies: + ws: 8.20.0 + zod: 3.25.76 + openai@6.26.0(ws@8.20.0)(zod@4.3.6): optionalDependencies: ws: 8.20.0