UNPKG

spaps

Version:

Sweet Potato Authentication & Payment Service CLI - Docker Compose orchestrator for local Python/FastAPI SPAPS server with built-in admin middleware

1,119 lines (1,047 loc) 43.6 kB
// CLI dispatcher: builds a Commander program with pluggable handlers // Enables dry-run parsing for unit tests without executing side effects. const { Command } = require('commander'); const { DEFAULT_PORT } = require('./config'); function defineProgram({ handlers = {}, dryRun = false, version = '0.0.0', logo = null } = {}) { const intents = []; const program = new Command(); if (dryRun) { program.allowUnknownOption(true); // Tolerate stray operands during unit tests (Commander normally errors) if (typeof program.allowExcessArguments === 'function') { program.allowExcessArguments(true); } } program .name('spaps') .description('CLI for Sweet Potato Authentication & Payment Service') .version(version) .option('--json', 'Output in JSON format for machine parsing'); function makeAction(name, shape) { return async function actionWrapper(...args) { // Commander 11 passes (options, command) for subcommands without args // For commands with args, it passes (arg1, arg2, ..., options, command) const cmd = args[args.length - 1]; const options = args[args.length - 2] || {}; const positionals = args.slice(0, -2); const parentJson = program.opts().json; const isJson = Boolean(options.json || parentJson); const intent = { name, options: { ...shape(options, cmd, isJson, positionals) } }; intents.push(intent); if (dryRun) return intent; const handler = handlers[name]; if (typeof handler === 'function') { return await handler(intent, { program, cmd }); } return intent; }; } function allowDryRun(command) { if (!dryRun) return command; command.allowUnknownOption(true); if (typeof command.allowExcessArguments === 'function') { command.allowExcessArguments(true); } return command; } function addRemoteCommandOptions(command) { return command .option('-p, --port <port>', 'Port (default: 3301)', String(DEFAULT_PORT)) .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)') .option('--json', 'Output in JSON format'); } function addAccessDecisionOptions(command) { return command .option('--actor-type <type>', 'Actor type', 'user') .option('--actor-ref <ref>', 'Stable actor reference') .option('--user-id <id>', 'User UUID') .option('--email <email>', 'Actor email address') .option('--agent-id <id>', 'Agent UUID') .option('--authenticated <bool>', 'Actor authenticated state') .requiredOption('--action <action>', 'Action to check') .requiredOption('--resource-type <type>', 'Resource type') .requiredOption('--resource-ref <ref>', 'Stable resource reference') .option('--resource-id <id>', 'Resource UUID') .option('--resource-key <key>', 'Resource key') .option('--entitlement-key <key>', 'Required entitlement key') .option('--entitlement-resource-type <type>', 'Entitlement resource type') .option('--entitlement-resource-id <id>', 'Entitlement resource UUID') .option('--policy-name <name>', 'Policy name to evaluate') .option('--policy-context <json>', 'Policy context JSON') .option('--usage-feature-key <key>', 'Usage-control feature key') .option('--usage-dimensions <json>', 'Usage dimensions JSON') .option('--x402-resource-key <key>', 'x402 resource key') .option('--approval-id <id>', 'Approval UUID') .option('--approval-required', 'Require an approval before action execution', false) .option('--authority-scope <scope>', 'Authority scope', 'application') .option('--context <json>', 'Decision context JSON') .option('--idempotency-key <key>', 'Decision idempotency key') .option('--correlation-id <id>', 'Correlation id'); } // spaps home const cmdHome = program .command('home') .description('Show operator, app, and runtime state') .option('-p, --port <port>', 'Port (default: 3301)', String(DEFAULT_PORT)) .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)') .option('--json', 'Output in JSON format') .action( makeAction('home', (opts, _cmd, isJson) => ({ port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, json: isJson, })) ); allowDryRun(cmdHome); // spaps local const cmdLocal = program .command('local [subcommand]') .description('Start local SPAPS server via Docker Compose (subcommand: stop)') .option('-p, --port <port>', 'Port to check (default: 3301)', String(DEFAULT_PORT)) .option('--runtime-dir <path>', 'Portable runtime directory (defaults to ~/.cache/spaps/local-<port>)') .option('--runtime-source <source>', 'Runtime source: auto|repo|bundle', 'auto') .option( '--data-source <source>', 'Base data source: empty|prod-cache|prod-fresh (use --from-backup for an explicit dump file)', 'empty' ) .option('-d, --detach', 'Run in background (don\'t tail logs)', false) .option('--fresh', 'Fresh start: tear down and rebuild from scratch', false) .option('--from-backup <path>', 'Load from database dump file', null) .option('-o, --open', 'Open browser automatically', false) .option('--json', 'Output in JSON format') .action( makeAction('local', (opts, _cmd, isJson, positionals) => { const subcommand = typeof positionals[0] === 'string' ? positionals[0] : null; const out = { port: Number(opts.port) || 3301, runtimeDir: opts.runtimeDir || null, runtimeSource: String(opts.runtimeSource || 'auto'), dataSource: String(opts.dataSource || 'empty'), open: Boolean(opts.open), detach: Boolean(opts.detach), fresh: Boolean(opts.fresh), fromBackup: opts.fromBackup || null, stop: subcommand === 'stop', json: isJson, }; return out; }) ); allowDryRun(cmdLocal); // spaps quickstart const cmdQuick = program .command('quickstart') .description('Get quick start instructions (for AI agents)') .option('-p, --port <port>', 'Port to check', String(DEFAULT_PORT)) .option('--json', 'Output in JSON format') .action(makeAction('quickstart', (opts, _cmd, isJson) => ({ port: Number(opts.port), json: isJson }))); allowDryRun(cmdQuick); // spaps status const cmdStatus = program .command('status') .description('Check if SPAPS server is running') .option('-p, --port <port>', 'Port to check', String(DEFAULT_PORT)) .option('--json', 'Output in JSON format') .action(makeAction('status', (opts, _cmd, isJson) => ({ port: Number(opts.port), json: isJson }))); allowDryRun(cmdStatus); // spaps verify const cmdVerify = program .command('verify') .alias('test') .description('Run a quick SPAPS verification') .option('-p, --port <port>', 'Port (default: 3301)', String(DEFAULT_PORT)) .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)') .option('--json', 'Output in JSON format') .action( makeAction('verify', (opts, _cmd, isJson) => ({ port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, json: isJson, })) ); allowDryRun(cmdVerify); // spaps init const cmdInit = program .command('init') .description('Initialize SPAPS in current project') .option('--json', 'Output in JSON format') .action(makeAction('init', (_opts, _cmd, isJson) => ({ json: isJson }))); allowDryRun(cmdInit); // spaps create <name> const cmdCreate = program .command('create <name>') .description('Create a starter project directory wired for SPAPS') .option('-t, --template <template>', 'Starter template: nextjs|react|node|vanilla') .option('--dir <dir>', 'Target directory (defaults to ./<name>)') .option('-p, --port <port>', 'Local SPAPS port to provision against', String(DEFAULT_PORT)) .option('-f, --force', 'Allow writing into a non-empty directory', false) .option('--json', 'Output in JSON format') .action( makeAction('create', (opts, cmd, isJson) => ({ name: cmd.args[0], template: opts.template || null, dir: opts.dir || null, port: Number(opts.port) || DEFAULT_PORT, force: Boolean(opts.force), json: isJson, })) ); allowDryRun(cmdCreate); // spaps types const cmdTypes = program .command('types') .description('Generate TypeScript types (coming soon)') .action(makeAction('types', () => ({}))); allowDryRun(cmdTypes); // spaps help const cmdHelp = program .command('help') .description('Show help and guides') .option('-i, --interactive', 'Interactive help mode') .option('-q, --quick', 'Quick reference') .action( makeAction('help', (opts) => ({ interactive: Boolean(opts.interactive), quick: Boolean(opts.quick) })) ); allowDryRun(cmdHelp); // spaps docs const cmdDocs = program .command('docs') .description('Browse SDK documentation') .option('-i, --interactive', 'Interactive documentation browser') .option('-s, --search <query>', 'Search documentation') .option('--json', 'Output in JSON format') .action( makeAction('docs', (opts, _cmd, isJson) => ({ interactive: Boolean(opts.interactive), search: opts.search || null, json: isJson })) ); allowDryRun(cmdDocs); // spaps tools const cmdTools = program .command('tools') .description('Output AI tool spec (OpenAI-style)') .option('-p, --port <port>', 'Port to use for base_url', String(DEFAULT_PORT)) .option('-f, --format <format>', 'Spec format (openai)', 'openai') .option('--json', 'Output in JSON format') .action( makeAction('tools', (opts, _cmd, isJson) => ({ port: Number(opts.port), format: String(opts.format || 'openai'), json: isJson })) ); allowDryRun(cmdTools); // spaps fixtures const cmdFixtures = program .command('fixtures <subcommand>') .description('Manage repo-local .spaps auth fixtures (init|apply|reset|storage-state)') .option('--dir <dir>', 'Target repo directory (defaults to current working directory)') .option('-p, --port <port>', 'Port to inspect for SPAPS runtime hints', String(DEFAULT_PORT)) .option('--base-url <url>', 'Browser app base URL for generated storage-state files') .option('--persona <persona>', 'Persona code to target for apply or storage-state export') .option('--seed', 'Run persona-declared seed requests against the local SPAPS server', false) .option('--sync-server', 'Reconcile fixture users, memberships, and entitlements into a local SPAPS server', false) .option('-f, --format <format>', 'Artifact format (playwright)', 'playwright') .option('--force', 'Overwrite fixture files during init', false) .option('--json', 'Output in JSON format') .action( makeAction('fixtures', (opts, cmd, isJson) => { return { subcommand: cmd.args[0], dir: opts.dir || null, port: Number(opts.port) || DEFAULT_PORT, baseUrl: opts.baseUrl || null, persona: opts.persona || null, seed: Boolean(opts.seed), syncServer: Boolean(opts.syncServer), format: String(opts.format || 'playwright'), force: Boolean(opts.force), json: isJson, }; }) ); allowDryRun(cmdFixtures); // spaps doctor const cmdDoctor = program .command('doctor') .description('Diagnose local environment and config') .option('-p, --port <port>', 'Port to check', String(DEFAULT_PORT)) .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)') .option('--origin <origin>', 'Browser origin to send with publishable-key auth diagnostics') .option('-s, --stripe <mode>', 'Stripe mode: mock|real') .option('--json', 'Output in JSON format') .action( makeAction('doctor', (opts, _cmd, isJson) => ({ port: Number(opts.port), serverUrl: opts.serverUrl || null, origin: opts.origin || null, stripe: opts.stripe || null, json: isJson, })) ); allowDryRun(cmdDoctor); // spaps login const cmdLogin = program .command('login') .alias('connect') .description('Authenticate with a SPAPS server (RFC 8628 device flow)') .option('-p, --port <port>', 'Port (default: 3301)', String(DEFAULT_PORT)) .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)') .option('--client-id <id>', 'Application slug to authorize as') .option('--json', 'Output in JSON format') .action( makeAction('login', (opts, _cmd, isJson) => ({ port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, clientId: opts.clientId || null, json: isJson, })) ); allowDryRun(cmdLogin); // spaps logout const cmdLogout = program .command('logout') .description('Revoke and clear stored SPAPS credentials') .option('-p, --port <port>', 'Port (default: 3301)', String(DEFAULT_PORT)) .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)') .option('--json', 'Output in JSON format') .action( makeAction('logout', (opts, _cmd, isJson) => ({ port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, json: isJson, })) ); allowDryRun(cmdLogout); // spaps whoami const cmdWhoami = program .command('whoami') .description('Show the currently authenticated user') .option('-p, --port <port>', 'Port (default: 3301)', String(DEFAULT_PORT)) .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)') .option('--json', 'Output in JSON format') .action( makeAction('whoami', (opts, _cmd, isJson) => ({ port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, json: isJson, })) ); allowDryRun(cmdWhoami); // spaps token (print access token for piping to curl or env vars) const cmdToken = program .command('token') .description('Print the current access token (for piping to tools like curl)') .option('-p, --port <port>', 'Port (default: 3301)', String(DEFAULT_PORT)) .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)') .option('--json', 'Output JSON with metadata instead of bare token') .option( '--refresh', 'Force a token refresh via the public-client refresh grant even if the stored token is still valid.' ) .action( makeAction('token', (opts, _cmd, isJson) => ({ port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, refresh: Boolean(opts.refresh), json: isJson, })) ); allowDryRun(cmdToken); // spaps auth <subcommand> const cmdAuth = program .command('auth') .description('Auth discovery and local diagnostic commands') .showHelpAfterError() .showSuggestionAfterError(); if (!dryRun) { cmdAuth.action(() => { cmdAuth.outputHelp(); }); } allowDryRun(cmdAuth); function addAuthSubcommand(name, description, addOptions, shape) { const command = cmdAuth.command(name).description(description); addRemoteCommandOptions(command) .option('--origin <origin>', 'Browser Origin header for publishable-key checks'); if (typeof addOptions === 'function') addOptions(command); command.action( makeAction('auth', (opts, _cmd, isJson) => ({ subcommand: name, port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, origin: opts.origin || null, ...shape(opts), json: isJson, })) ); allowDryRun(command); return command; } addAuthSubcommand( 'methods', 'Print the auth method matrix from GET /api/auth/methods', null, () => ({}) ); const cmdPasskeys = cmdAuth .command('passkeys') .description('Configure and inspect application passkey relying-party profiles'); allowDryRun(cmdPasskeys); function collectValue(value, previous) { return [...(previous || []), value]; } function addPasskeyCommand(action, description, mutating = false) { const command = cmdPasskeys.command(action).description(description); addRemoteCommandOptions(command) .option('--application-id <id>', 'Application UUID (or SPAPS_APPLICATION_ID)') .option('--rp-id <hostname>', 'Exact WebAuthn relying-party hostname') .option('--display-name <name>', 'Relying-party display name') .option('--allowed-origin <origin>', 'Exact allowed browser origin (repeatable)', collectValue, []) .option('--environment <environment>', 'local, preview, or production', 'production') .option('--policy-version <version>', 'Passkey policy version', '1'); if (mutating) { command.option('--confirm-disruptive', 'Confirm changes affecting existing credentials', false); } command.action( makeAction('auth', (opts, _cmd, isJson) => ({ subcommand: `passkeys.${action}`, port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, applicationId: opts.applicationId || null, rpId: opts.rpId || null, displayName: opts.displayName || null, allowedOrigins: opts.allowedOrigin || [], environment: opts.environment || 'production', policyVersion: Number(opts.policyVersion) || 1, confirmDisruptive: Boolean(opts.confirmDisruptive), json: isJson, })) ); allowDryRun(command); } addPasskeyCommand('configure', 'Create or update the canonical passkey profile', true); addPasskeyCommand('show', 'Show the active passkey profile'); addPasskeyCommand('validate', 'Validate a proposed passkey profile without saving it'); addPasskeyCommand('disable', 'Disable a passkey profile', true); const cmdPasskeyDoctor = cmdPasskeys .command('doctor') .description('Cross-check passkey discovery, RP/origin, browser scope, SDK, CORS, and scaffold readiness'); addRemoteCommandOptions(cmdPasskeyDoctor) .option('--application-id <id>', 'Application UUID (or SPAPS_APPLICATION_ID)') .option('--rp-id <hostname>', 'Expected exact WebAuthn relying-party hostname') .option('--origin <origin>', 'Expected exact browser origin') .option('--environment <environment>', 'Expected local, preview, or production profile environment', 'production') .option('--publishable-scope <scope>', 'Observed publishable-key scope (repeatable)', collectValue, []) .option('--cors-origin <origin>', 'Observed allowed CORS origin (repeatable)', collectValue, []) .option('--sdk-version <version>', 'Observed installed spaps-sdk version'); cmdPasskeyDoctor.action( makeAction('auth', (opts, _cmd, isJson) => ({ subcommand: 'passkeys.doctor', port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, applicationId: opts.applicationId || null, rpId: opts.rpId || null, origin: opts.origin || null, environment: opts.environment || 'production', publishableScopes: opts.publishableScope || [], corsOrigins: opts.corsOrigin || [], sdkVersion: opts.sdkVersion || null, json: isJson, })) ); allowDryRun(cmdPasskeyDoctor); const cmdPasskeyTest = cmdPasskeys .command('test') .description('Emit or launch the safe local Chromium virtual-authenticator passkey harness'); addRemoteCommandOptions(cmdPasskeyTest) .option('--rp-id <hostname>', 'Expected exact WebAuthn relying-party hostname', 'localhost') .option('--origin <origin>', 'Expected exact browser origin', 'http://localhost:3000') .option('--launch', 'Launch the isolated local SDK Chromium harness', false) .option('--allow-remote', 'Record explicit operator intent for a remote ceremony target', false); cmdPasskeyTest.action( makeAction('auth', (opts, _cmd, isJson) => ({ subcommand: 'passkeys.test', port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, rpId: opts.rpId || 'localhost', origin: opts.origin || 'http://localhost:3000', launch: Boolean(opts.launch), allowRemote: Boolean(opts.allowRemote), json: isJson, })) ); allowDryRun(cmdPasskeyTest); addAuthSubcommand( 'mfa-test', 'Exercise local TOTP MFA enrollment, activation, login challenge, verification, and cleanup', (command) => command .option('--email <email>', 'Local test user email (or SPAPS_TEST_EMAIL)') .option('--password <password>', 'Local test user password (or SPAPS_TEST_PASSWORD)') .option('--allow-remote', 'Allow running against a non-local server URL', false), (opts) => ({ email: opts.email || null, password: opts.password || null, allowRemote: Boolean(opts.allowRemote), }) ); addAuthSubcommand( 'sms-test', 'Request or verify an SMS OTP against local console SMS', (command) => command .option('--phone-number <phone>', 'E.164 phone number (or SPAPS_TEST_PHONE_NUMBER)') .option('--challenge-id <id>', 'Existing SMS challenge id to verify') .option('--code <code>', 'SMS code from local server logs') .option('--allow-remote', 'Allow running against a non-local server URL', false), (opts) => ({ phoneNumber: opts.phoneNumber || null, challengeId: opts.challengeId || null, code: opts.code || null, allowRemote: Boolean(opts.allowRemote), }) ); // spaps dayrate <subcommand> const cmdDayrate = program .command('dayrate <subcommand>') .description('Dayrate domain commands (subcommand: config)') .option('-p, --port <port>', 'Port (default: 3301)', String(DEFAULT_PORT)) .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)') .option('--json', 'Output in JSON format') .action( makeAction('dayrate', (opts, cmd, isJson) => ({ subcommand: cmd.args[0], port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, json: isJson, })) ); allowDryRun(cmdDayrate); // spaps billing <subcommand> const cmdBilling = program .command('billing <subcommand>') .description('Billing-account operator commands (subcommand: status|attach|verify)') .option('-p, --port <port>', 'Port (default: 3301)', String(DEFAULT_PORT)) .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)') .option('--billing-account-id <id>', 'Billing account id (attach)') .option('--json', 'Output in JSON format') .action( makeAction('billing', (opts, cmd, isJson) => ({ subcommand: cmd.args[0], port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, billingAccountId: opts.billingAccountId || null, json: isJson, })) ); allowDryRun(cmdBilling); // spaps email const cmdEmail = program .command('email') .description('Email domain commands') .showHelpAfterError() .showSuggestionAfterError() .addHelpText('after', '\nUse `spaps email <verb> --help` for verb-specific flags.\n'); if (!dryRun) { cmdEmail.action(() => { cmdEmail.outputHelp(); }); } allowDryRun(cmdEmail); function addEmailSubcommand(name, description, addOptions, shape) { const command = cmdEmail.command(name).description(description); if (typeof addOptions === 'function') addOptions(command); addRemoteCommandOptions(command); command.action( makeAction('email', (opts, _cmd, isJson) => ({ subcommand: name, port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, ...shape(opts), json: isJson, })) ); allowDryRun(command); return command; } addEmailSubcommand( 'send', 'Send a transactional email by template key', (command) => command .requiredOption('--template-key <key>', 'Template key') .requiredOption('--to <email>', 'Recipient email address') .option('--context <json>', 'Template context as JSON') .option('--user-id <id>', 'User id for log attribution') .option('--owner-id <id>', 'Owner id for log attribution') .option('--subject-override <text>', 'Subject override') .option('--body-override <text>', 'Body override') .option('--idempotency-key <key>', 'Idempotency key for retry-safe sends'), (opts) => ({ templateKey: opts.templateKey || null, to: opts.to || null, context: opts.context || null, userId: opts.userId || null, ownerId: opts.ownerId || null, subjectOverride: opts.subjectOverride || null, bodyOverride: opts.bodyOverride || null, idempotencyKey: opts.idempotencyKey || null, }) ); addEmailSubcommand( 'get-template', 'Fetch one email template by key', (command) => command.requiredOption('--template-key <key>', 'Template key'), (opts) => ({ templateKey: opts.templateKey || null, }) ); addEmailSubcommand( 'preview', 'Render a template preview using sample or custom context', (command) => command .requiredOption('--template-key <key>', 'Template key') .option('--context <json>', 'Template context as JSON'), (opts) => ({ templateKey: opts.templateKey || null, context: opts.context || null, }) ); addEmailSubcommand( 'logs', 'List email logs filtered by owner or user', (command) => command .option('--owner-id <id>', 'Owner id for log attribution') .option('--user-id <id>', 'User id for log attribution') .option('--limit <n>', 'Pagination limit') .option('--offset <n>', 'Pagination offset'), (opts) => ({ ownerId: opts.ownerId || null, userId: opts.userId || null, limit: opts.limit ? Number(opts.limit) : null, offset: opts.offset ? Number(opts.offset) : null, }) ); addEmailSubcommand( 'list-templates', 'List all templates for the active application', null, () => ({}) ); addEmailSubcommand( 'create-template', 'Create a new template', (command) => command .requiredOption('--template-key <key>', 'Template key') .requiredOption('--name <name>', 'Template display name') .requiredOption('--subject <text>', 'Template subject') .requiredOption('--html-body <html>', 'Template HTML body') .option('--text-body <text>', 'Template text body') .option('--description <text>', 'Template description') .option('--from-email <email>', 'From email address') .option('--from-name <name>', 'From display name') .option('--reply-to <email>', 'Reply-to address') .option('--variables <json>', 'Template variables JSON') .option('--sample-context <json>', 'Sample context JSON') .option('--is-active <bool>', 'Template active state') .option('--category <category>', 'Template category'), (opts) => ({ templateKey: opts.templateKey || null, name: opts.name || null, subject: opts.subject || null, htmlBody: opts.htmlBody || null, textBody: opts.textBody || null, description: opts.description || null, fromEmail: opts.fromEmail || null, fromName: opts.fromName || null, replyTo: opts.replyTo || null, variables: opts.variables || null, sampleContext: opts.sampleContext || null, isActive: opts.isActive === undefined ? null : opts.isActive, category: opts.category || null, }) ); addEmailSubcommand( 'update-template', 'Update an existing template', (command) => command .requiredOption('--template-key <key>', 'Template key') .option('--name <name>', 'Template display name') .option('--subject <text>', 'Template subject') .option('--html-body <html>', 'Template HTML body') .option('--text-body <text>', 'Template text body') .option('--description <text>', 'Template description') .option('--from-email <email>', 'From email address') .option('--from-name <name>', 'From display name') .option('--reply-to <email>', 'Reply-to address') .option('--variables <json>', 'Template variables JSON') .option('--sample-context <json>', 'Sample context JSON') .option('--is-active <bool>', 'Template active state') .option('--category <category>', 'Template category'), (opts) => ({ templateKey: opts.templateKey || null, name: opts.name || null, subject: opts.subject || null, htmlBody: opts.htmlBody || null, textBody: opts.textBody || null, description: opts.description || null, fromEmail: opts.fromEmail || null, fromName: opts.fromName || null, replyTo: opts.replyTo || null, variables: opts.variables || null, sampleContext: opts.sampleContext || null, isActive: opts.isActive === undefined ? null : opts.isActive, category: opts.category || null, }) ); addEmailSubcommand( 'get-override', 'Fetch the current override for a template', (command) => command.requiredOption('--template-key <key>', 'Template key'), (opts) => ({ templateKey: opts.templateKey || null, }) ); addEmailSubcommand( 'set-override', 'Create or update an override for a template', (command) => command .requiredOption('--template-key <key>', 'Template key') .option('--subject-override <text>', 'Subject override') .option('--body-override <text>', 'Body override'), (opts) => ({ templateKey: opts.templateKey || null, subjectOverride: opts.subjectOverride || null, bodyOverride: opts.bodyOverride || null, }) ); addEmailSubcommand( 'clear-override', 'Delete an override for a template', (command) => command.requiredOption('--template-key <key>', 'Template key'), (opts) => ({ templateKey: opts.templateKey || null, }) ); // spaps policy <subcommand> const cmdPolicy = program .command('policy <subcommand>') .description('Policies domain commands (subcommand: list|create|delete)') .option('-p, --port <port>', 'Port (default: 3301)', String(DEFAULT_PORT)) .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)') .option('--name <name>', 'Policy name (create)') .option('--effect <effect>', 'Policy effect: allow|deny (create)') .option('--conditions <json>', 'Policy conditions as JSON (create)') .option('--description <text>', 'Policy description (create)') .option('--priority <n>', 'Policy priority (create)') .option('--id <id>', 'Policy id (delete)') .option('--is-active <bool>', 'Filter by is_active (list)') .option('--limit <n>', 'Limit (list)') .option('--json', 'Output in JSON format') .action( makeAction('policy', (opts, cmd, isJson) => ({ subcommand: cmd.args[0], port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, name: opts.name || null, effect: opts.effect || null, conditions: opts.conditions || null, description: opts.description || null, priority: opts.priority ? Number(opts.priority) : 0, id: opts.id || null, isActive: opts.isActive === undefined ? null : opts.isActive, limit: opts.limit ? Number(opts.limit) : null, json: isJson, })) ); allowDryRun(cmdPolicy); // spaps webhook <subcommand> const cmdWebhook = program .command('webhook <subcommand>') .description('Webhooks domain commands (subcommand: list|register)') .option('-p, --port <port>', 'Port (default: 3301)', String(DEFAULT_PORT)) .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)') .option('--url <url>', 'Destination URL (register)') .option('--events <csv>', 'Comma-separated event keys (register)') .option('--json', 'Output in JSON format') .action( makeAction('webhook', (opts, cmd, isJson) => ({ subcommand: cmd.args[0], port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, url: opts.url || null, events: opts.events || null, json: isJson, })) ); allowDryRun(cmdWebhook); // spaps issue-reports <subcommand> const cmdIssueReports = program .command('issue-reports <subcommand>') .description('Issue reporting commands (subcommand: list-mine)') .option('-p, --port <port>', 'Port (default: 3301)', String(DEFAULT_PORT)) .option('--server-url <url>', 'Full server URL (overrides --port and SPAPS_API_URL)') .option('--status <status>', 'Filter by status') .option('--limit <n>', 'Pagination limit') .option('--offset <n>', 'Pagination offset') .option('--json', 'Output in JSON format') .action( makeAction('issue-reports', (opts, cmd, isJson) => ({ subcommand: cmd.args[0], port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, status: opts.status || null, limit: opts.limit ? Number(opts.limit) : null, offset: opts.offset ? Number(opts.offset) : null, json: isJson, })) ); allowDryRun(cmdIssueReports); // spaps access check const cmdAccess = program .command('access') .description('Access decision commands') .showHelpAfterError() .showSuggestionAfterError(); if (!dryRun) { cmdAccess.action(() => { cmdAccess.outputHelp(); }); } allowDryRun(cmdAccess); const cmdAccessCheck = addAccessDecisionOptions(cmdAccess.command('check').description('Check whether an actor can perform an action')); addRemoteCommandOptions(cmdAccessCheck).action( makeAction('access', (opts, _cmd, isJson) => ({ subcommand: 'check', port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, actorType: opts.actorType || 'user', actorRef: opts.actorRef || null, userId: opts.userId || null, email: opts.email || null, agentId: opts.agentId || null, authenticated: opts.authenticated === undefined ? null : opts.authenticated, action: opts.action || null, resourceType: opts.resourceType || null, resourceRef: opts.resourceRef || null, resourceId: opts.resourceId || null, resourceKey: opts.resourceKey || null, entitlementKey: opts.entitlementKey || null, entitlementResourceType: opts.entitlementResourceType || null, entitlementResourceId: opts.entitlementResourceId || null, policyName: opts.policyName || null, policyContext: opts.policyContext || null, usageFeatureKey: opts.usageFeatureKey || null, usageDimensions: opts.usageDimensions || null, x402ResourceKey: opts.x402ResourceKey || null, approvalId: opts.approvalId || null, approvalRequired: Boolean(opts.approvalRequired), authorityScope: opts.authorityScope || 'application', context: opts.context || null, idempotencyKey: opts.idempotencyKey || null, correlationId: opts.correlationId || null, json: isJson, })) ); allowDryRun(cmdAccessCheck); // spaps journey run const cmdJourney = program .command('journey') .description('Prepare agent-safe next actions for an application journey') .showHelpAfterError() .showSuggestionAfterError(); if (!dryRun) { cmdJourney.action(() => { cmdJourney.outputHelp(); }); } allowDryRun(cmdJourney); const cmdJourneyRun = addAccessDecisionOptions(cmdJourney.command('run').description('Run the access-to-next-action preparation flow')); addRemoteCommandOptions( cmdJourneyRun .option('--include-command-templates', 'Include command templates for operator-gated actions', false) .option('--operator-gated', 'Request server-authorized operator-gated templates', false) .option('--operator-labels <csv>', 'Comma-separated operator labels') .option('--environment <environment>', 'Execution environment', 'production') ).action( makeAction('journey', (opts, _cmd, isJson) => ({ subcommand: 'run', port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, actorType: opts.actorType || 'user', actorRef: opts.actorRef || null, userId: opts.userId || null, email: opts.email || null, agentId: opts.agentId || null, authenticated: opts.authenticated === undefined ? null : opts.authenticated, action: opts.action || null, resourceType: opts.resourceType || null, resourceRef: opts.resourceRef || null, resourceId: opts.resourceId || null, resourceKey: opts.resourceKey || null, entitlementKey: opts.entitlementKey || null, entitlementResourceType: opts.entitlementResourceType || null, entitlementResourceId: opts.entitlementResourceId || null, policyName: opts.policyName || null, policyContext: opts.policyContext || null, usageFeatureKey: opts.usageFeatureKey || null, usageDimensions: opts.usageDimensions || null, x402ResourceKey: opts.x402ResourceKey || null, approvalId: opts.approvalId || null, approvalRequired: Boolean(opts.approvalRequired), authorityScope: opts.authorityScope || 'application', context: opts.context || null, idempotencyKey: opts.idempotencyKey || null, correlationId: opts.correlationId || null, includeCommandTemplates: Boolean(opts.includeCommandTemplates), operatorGated: Boolean(opts.operatorGated), operatorLabels: opts.operatorLabels || null, environment: opts.environment || 'production', json: isJson, })) ); allowDryRun(cmdJourneyRun); // spaps graph <nodes|paths|impact> const cmdGraph = program .command('graph') .description('Inspect the materialized SPAPS capability graph') .showHelpAfterError() .showSuggestionAfterError(); if (!dryRun) { cmdGraph.action(() => { cmdGraph.outputHelp(); }); } allowDryRun(cmdGraph); addRemoteCommandOptions( cmdGraph.command('nodes') .description('List capability graph nodes') .option('--application-id <id>', 'Explicit application id for super-admin reads') .option('--node-type <type>', 'Filter by node type') .option('--status <status>', 'Filter by row status', 'active') .option('-q, --query <query>', 'Case-insensitive label search') .option('--cursor <cursor>', 'Pagination cursor') .option('--limit <n>', 'Pagination limit') ).action( makeAction('graph', (opts, _cmd, isJson) => ({ subcommand: 'nodes', port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, applicationId: opts.applicationId || null, nodeType: opts.nodeType || null, status: opts.status || 'active', query: opts.query || null, cursor: opts.cursor || null, limit: opts.limit ? Number(opts.limit) : null, json: isJson, })) ); addRemoteCommandOptions( cmdGraph.command('paths') .description('Find bounded paths between two graph nodes') .requiredOption('--from <node_key>', 'Starting node key') .requiredOption('--to <node_key>', 'Target node key') .option('--application-id <id>', 'Explicit application id for super-admin reads') .option('--max-depth <n>', 'Maximum path depth') .option('--limit <n>', 'Path limit') .option('--include-stale', 'Include stale graph rows', false) ).action( makeAction('graph', (opts, _cmd, isJson) => ({ subcommand: 'paths', port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, applicationId: opts.applicationId || null, fromNodeKey: opts.from || null, toNodeKey: opts.to || null, maxDepth: opts.maxDepth ? Number(opts.maxDepth) : null, limit: opts.limit ? Number(opts.limit) : null, includeStale: Boolean(opts.includeStale), json: isJson, })) ); addRemoteCommandOptions( cmdGraph.command('impact') .description('Traverse outward from one graph node') .requiredOption('--node-key <node_key>', 'Starting node key') .option('--application-id <id>', 'Explicit application id for super-admin reads') .option('--max-depth <n>', 'Maximum traversal depth') .option('--limit <n>', 'Result limit') .option('--include-stale', 'Include stale graph rows', false) ).action( makeAction('graph', (opts, _cmd, isJson) => ({ subcommand: 'impact', port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, applicationId: opts.applicationId || null, nodeKey: opts.nodeKey || null, maxDepth: opts.maxDepth ? Number(opts.maxDepth) : null, limit: opts.limit ? Number(opts.limit) : null, includeStale: Boolean(opts.includeStale), json: isJson, })) ); addRemoteCommandOptions( cmdGraph.command('refresh') .description('Refresh the capability graph projection') .option('--application-id <id>', 'Explicit application id for super-admin refresh') .option('--correlation-id <id>', 'Correlation id for audit and projection diagnostics') ).action( makeAction('graph', (opts, _cmd, isJson) => ({ subcommand: 'refresh', port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, applicationId: opts.applicationId || null, correlationId: opts.correlationId || null, json: isJson, })) ); // spaps explain <decision-id> const cmdExplain = addRemoteCommandOptions( program .command('explain <decision-id>') .description('Explain one persisted access decision trace') ).action( makeAction('explain', (opts, cmd, isJson) => ({ decisionId: cmd.args[0], port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, json: isJson, })) ); allowDryRun(cmdExplain); // spaps contract const cmdContract = addRemoteCommandOptions( program .command('contract') .description('Fetch the capability graph client contract') ).action( makeAction('contract', (opts, _cmd, isJson) => ({ port: Number(opts.port) || DEFAULT_PORT, serverUrl: opts.serverUrl || null, json: isJson, })) ); allowDryRun(cmdContract); return { program, getIntents: () => intents }; } function buildProgram(config = {}) { return defineProgram(config).program; } function parseArgv(argv, config = {}) { const { program, getIntents } = defineProgram({ ...config, dryRun: true }); program.exitOverride(() => { /* swallow exit in dry-run */ }); const normalizedArgv = Array.isArray(argv) && argv.length >= 2 && /(^|[\\/])node(\.exe)?$/.test(String(argv[0])) && /spaps(?:\.js)?$/.test(String(argv[1])) ? argv.slice(2) : argv; try { program.parse(normalizedArgv, { from: 'user' }); } catch (err) { // Commander throws for help/version; we ignore in parse mode } return getIntents(); } module.exports = { buildProgram, parseArgv };