UNPKG

spaps

Version:

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

1,399 lines • 50.7 kB
const chalk = require('chalk');
const fs = require('fs');
const { DEFAULT_PORT } = require('./config');
const { handleError } = require('./error-handler');
const { hasInteractiveTerminal } = require('./auth/env');
const { showQuickHelp } = require('./help-quick');
const { showQuickReference } = require('./docs-quick');

function lazy(factory) {
  let value;
  return () => {
    if (value === undefined) {
      value = factory();
    }
    return value;
  };
}

const loadHelpSystem = lazy(() => require('./help-system'));
const loadDocsSystem = lazy(() => require('./docs-system'));
const loadAiHelper = lazy(() => require('./ai-helper'));
const loadAiToolSpec = lazy(() => require('./ai-tool-spec'));
const loadDoctor = lazy(() => require('./doctor'));
const loadHomeView = lazy(() => require('./home-view'));
const loadFixtureKernel = lazy(() => require('./fixture-kernel'));
const loadProjectScaffolder = lazy(() => require('./project-scaffolder'));
const loadAuthCommandHandlers = lazy(() => require('./auth/handlers'));
const loadDomainCli = lazy(() => require('./domain-cli'));

function openExternalUrl(url, { spawnImpl = null, platform = process.platform } = {}) {
  const spawn = spawnImpl || require('child_process').spawn;
  let command;
  let args;

  if (platform === 'darwin') {
    command = 'open';
    args = [url];
  } else if (platform === 'win32') {
    command = 'rundll32';
    args = ['url.dll,FileProtocolHandler', url];
  } else {
    command = 'xdg-open';
    args = [url];
  }

  const child = spawn(command, args, { stdio: 'ignore', detached: true });
  child.on?.('error', () => {});
  child.unref?.();
  return { command, args };
}

function createHandlers(version, logo) {
  function invalidArgument(message) {
    const error = new Error(message);
    error.code = 'EINVAL';
    return error;
  }

  function emitInteractiveFallback(message) {
    console.log(
      chalk.yellow(`\n⚠️  ${message}. Showing the quick reference instead.\n`)
    );
  }

  const verifyHandler = async ({ options }) => {
    const { runQuickTest } = loadAiHelper();
    const result = await runQuickTest({
      port: options.port,
      serverUrl: options.serverUrl,
      cwd: process.cwd(),
    });

    if (options.json) {
      console.log(JSON.stringify(result, null, 2));
      return;
    }

    console.log(chalk.yellow('\n🍠 SPAPS Verify\n'));
    console.log(result.success ? chalk.green(result.summary) : chalk.yellow(result.summary));
    console.log();
    (result.results || []).forEach((entry) => {
      const mark = entry.success ? chalk.green('✓') : chalk.red('✗');
      const message = entry.message ? `: ${entry.message}` : '';
      console.log(`  ${mark} ${entry.test}${message}`);
      if (!entry.success && entry.fix) {
        console.log(chalk.gray(`    fix: ${entry.fix}`));
      }
    });
    if (Array.isArray(result.next_steps) && result.next_steps.length > 0) {
      console.log();
      console.log(chalk.bold('Next'));
      result.next_steps.forEach((step) => {
        console.log(chalk.cyan(`  ${step}`));
      });
    }
    console.log();
  };

  return {
    home: async ({ options }) => {
      const { buildHomeView, renderHomeView } = loadHomeView();
      const view = await buildHomeView({
        port: options.port,
        serverUrl: options.serverUrl,
        cwd: process.cwd(),
      });

      if (options.json) {
        console.log(JSON.stringify(view, null, 2));
        return;
      }

      renderHomeView(view, { logo });
    },
    local: async ({ options }) => {
      const isJson = options.json;
      if (!isJson) console.log(logo);

      // Handle stop subcommand
      if (options.stop) {
        try {
          const LocalServer = require('./local-server.js');
          const server = new LocalServer({
            port: options.port,
            runtimeDir: options.runtimeDir,
            runtimeSource: options.runtimeSource,
            dataSource: options.dataSource,
            json: isJson,
          });
          server.stop();
          return;
        } catch (error) {
          handleError(error, { command: 'local stop' }, { json: isJson });
        }
      }

      // Handle start
      try {
        const LocalServer = require('./local-server.js');
        const server = new LocalServer({
          port: options.port,
          runtimeDir: options.runtimeDir,
          runtimeSource: options.runtimeSource,
          dataSource: options.dataSource,
          json: isJson,
          detach: options.detach,
          fresh: options.fresh,
          fromBackup: options.fromBackup
        });

        await server.start();

        if (options.open && !isJson) {
          const url = `http://localhost:${options.port}/docs`;
          openExternalUrl(url);
        }

        // Set up shutdown handler
        const shutdown = async () => {
          await server.shutdown();
          process.exit(0);
        };
        process.on('SIGINT', shutdown);
        process.on('SIGTERM', shutdown);
      } catch (error) {
        handleError(error, { port: options.port, command: 'local' }, { json: isJson });
      }
    },
    quickstart: async ({ options }) => {
      const { getQuickStartInstructions } = loadAiHelper();
      const instructions = await getQuickStartInstructions(options.port);
      if (options.json) {
        console.log(JSON.stringify(instructions, null, 2));
      } else {
        console.log(chalk.yellow('\n🍠 SPAPS Quick Start Instructions\n'));
        const modeSummary = instructions.server.running
          ? instructions.auth.local_mode
            ? 'Mode: local mode active'
            : 'Mode: provisioned application required'
          : 'Mode: server unreachable';
        console.log(modeSummary);
        instructions.summary.forEach((line, index) => {
          console.log(`${index + 1}. ${line}`);
        });
        console.log('\nFor JSON output: npx spaps quickstart --json');
      }
    },
    status: async ({ options }) => {
      const { getServerStatus } = loadAiHelper();
      const status = await getServerStatus(options.port);
      if (options.json) {
        console.log(JSON.stringify(status));
        if (!status.running) process.exitCode = 1;
      } else {
        if (!status.running) {
          console.log(chalk.red('\n❌ SPAPS server is not running'));
          console.log('Start it with:');
          console.log(chalk.cyan(`  npx spaps local --port ${options.port}`));
          console.log();
          process.exitCode = 1;
        } else {
          console.log(chalk.green('\n✅ SPAPS server is running!\n'));
          console.log('  URL:', chalk.cyan(status.url));
          console.log('  Docs:', chalk.cyan(status.docs));
          if (status.local_mode?.known) {
            console.log(
              '  Mode:',
              chalk.cyan(status.local_mode.active ? 'local mode active' : 'application key required')
            );
          }
          console.log();
        }
      }
    },
    verify: verifyHandler,
    init: async ({ options }) => {
      const isJson = options.json;
      const envContent = `# SPAPS Local Development\nSPAPS_API_URL=http://localhost:${DEFAULT_PORT}\n# SPAPS_API_KEY=your-api-key-here\n`;
      const result = { success: true, command: 'init', files_created: [], files_skipped: [], next_steps: ['npx spaps local', 'npm install @spaps/sdk', 'Start coding!'] };
      if (!fs.existsSync('.env.local')) {
        fs.writeFileSync('.env.local', envContent);
        result.files_created.push('.env.local');
        if (!isJson) console.log(chalk.green('✅ Created .env.local'));
      } else {
        result.files_skipped.push('.env.local');
        result.message = '.env.local already exists';
        if (!isJson) console.log(chalk.yellow('⚠️  .env.local already exists'));
      }
      if (isJson) {
        console.log(JSON.stringify(result));
      } else {
        console.log();
        console.log(chalk.green('✨ SPAPS initialized!'));
        console.log();
        console.log('Next steps:');
        console.log(chalk.cyan('  1. Run: npx spaps local'));
        console.log(chalk.cyan('  2. Install SDK: npm install @spaps/sdk'));
        console.log(chalk.cyan('  3. Start coding!'));
      }
    },
    create: async ({ options }) => {
      const isJson = options.json;
      const { createProjectStarter } = loadProjectScaffolder();

      try {
        const result = await createProjectStarter({
          name: options.name,
          template: options.template,
          dir: options.dir,
          port: options.port,
          force: options.force,
          version,
        });

        if (isJson) {
          console.log(JSON.stringify(result, null, 2));
          return;
        }

        console.log(chalk.green(`\n✨ Created ${result.project_name} (${result.template})`));
        console.log(chalk.cyan(`   ${result.target_dir}`));
        console.log(chalk.gray(`   provisioning: ${result.provisioning.status}`));

        if (result.files_created.length > 0) {
          console.log(chalk.green('\nFiles created:'));
          result.files_created.forEach((file) => {
            console.log(chalk.gray(`  • ${file}`));
          });
        }

        if (result.files_overwritten.length > 0) {
          console.log(chalk.yellow('\nFiles overwritten:'));
          result.files_overwritten.forEach((file) => {
            console.log(chalk.gray(`  • ${file}`));
          });
        }

        if (result.warnings.length > 0) {
          console.log(chalk.yellow('\nWarnings:'));
          result.warnings.forEach((warning) => {
            console.log(chalk.gray(`  • ${warning}`));
          });
        }

        console.log(chalk.green('\nNext steps:'));
        result.next_steps.forEach((step, index) => {
          console.log(chalk.cyan(`  ${index + 1}. ${step}`));
        });
        console.log();
      } catch (error) {
        handleError(
          error,
          {
            command: 'create',
            name: options.name,
            template: options.template,
            dir: options.dir,
          },
          { json: isJson }
        );
      }
    },
    types: () => {
      console.log(chalk.yellow('🍠 SPAPS'));
      console.log(chalk.yellow(`🚧 'spaps types' coming in v0.4.0!`));
    },
    help: async ({ options }) => {
      if (options.interactive) {
        if (!hasInteractiveTerminal()) {
          emitInteractiveFallback('Interactive help requires a TTY');
          showQuickHelp();
          return;
        }
        await loadHelpSystem().showInteractiveHelp();
      } else if (options.quick) {
        showQuickHelp();
      } else {
        showQuickHelp();
      }
    },
    docs: async ({ options }) => {
      if (options.search) {
        const results = loadDocsSystem().searchDocs(options.search);
        if (options.json) {
          console.log(JSON.stringify({ results }, null, 2));
        } else {
          console.log(chalk.yellow(`\n🔍 Search results for "${options.search}":\n`));
          if (results.length === 0) {
            console.log(chalk.gray('  No results found'));
          } else {
            results.forEach((result, i) => {
              console.log(chalk.green(`  ${i + 1}. ${result.title}`));
              console.log(chalk.gray(`     ${result.preview}`));
              console.log();
            });
          }
          console.log(chalk.blue('  Run: npx spaps docs --interactive'));
          console.log(chalk.blue('  to browse full documentation\n'));
        }
      } else if (options.interactive) {
        if (!hasInteractiveTerminal()) {
          emitInteractiveFallback('Interactive docs require a TTY');
          showQuickReference();
          return;
        }
        await loadDocsSystem().showInteractiveDocs();
      } else {
        showQuickReference();
      }
    },
    tools: async ({ options }) => {
      const { buildToolSpec } = loadAiToolSpec();
      const spec = await buildToolSpec({
        format: options.format || 'openai',
        port: options.port,
        version,
      });
      if (options.json) {
        console.log(JSON.stringify(spec, null, 2));
      } else {
        console.log(chalk.yellow('\n🍠 SPAPS AI Tool Spec (OpenAI-style)\n'));
        console.log('Base URL:', spec.base_url);
        if (spec.auth && typeof spec.auth.local_mode === 'boolean') {
          console.log(
            'Auth:',
            spec.auth.local_mode ? 'local mode active' : 'application key required'
          );
        }
        console.log('Tools:');
        spec.tools.forEach((t, i) => {
          console.log(chalk.green(`  ${i + 1}. ${t.name}`), '-', t.description);
          console.log(chalk.gray(`     ${t.method} ${t.path}`));
        });
        console.log('\nTip: npx spaps tools --json > spaps-tools.json');
      }
    },
    fixtures: async ({ options }) => {
      const isJson = options.json;
      const {
        applyFixtures,
        exportStorageState,
        initFixtureKernel,
        resetFixtures,
      } = loadFixtureKernel();

      try {
        if (options.format && options.format !== 'playwright') {
          throw invalidArgument(`Unsupported fixture format "${options.format}". Only "playwright" is currently supported.`);
        }

        let result;
        switch (options.subcommand) {
          case 'init':
            result = await initFixtureKernel({
              dir: options.dir,
              port: options.port,
              baseUrl: options.baseUrl,
              version,
              force: options.force,
            });
            break;
          case 'apply':
            result = await applyFixtures({
              dir: options.dir,
              port: options.port,
              baseUrl: options.baseUrl,
              version,
              persona: options.persona,
              seed: options.seed,
              syncServer: options.syncServer,
            });
            break;
          case 'reset':
            result = await resetFixtures({
              dir: options.dir,
              port: options.port,
              baseUrl: options.baseUrl,
              version,
              seed: options.seed,
              syncServer: options.syncServer,
            });
            break;
          case 'storage-state':
            if (!options.persona) {
              throw invalidArgument('The fixtures storage-state command requires --persona.');
            }
            result = await exportStorageState({
              dir: options.dir,
              port: options.port,
              baseUrl: options.baseUrl,
              version,
              persona: options.persona,
              seed: options.seed,
              syncServer: options.syncServer,
            });
            break;
          default:
            throw invalidArgument(
              `Unsupported fixtures subcommand "${options.subcommand}". Use init, apply, reset, or storage-state.`
            );
        }

        if (isJson) {
          console.log(JSON.stringify(result, null, 2));
          return;
        }

        console.log(chalk.green(`\n✨ .spaps fixtures ${result.subcommand} complete`));
        console.log(chalk.cyan(`   ${result.fixture_dir}`));

        if (Array.isArray(result.files_created) && result.files_created.length > 0) {
          console.log(chalk.green('\nFiles created:'));
          result.files_created.forEach((file) => {
            console.log(chalk.gray(`  • ${file}`));
          });
        }

        if (Array.isArray(result.files_overwritten) && result.files_overwritten.length > 0) {
          console.log(chalk.yellow('\nFiles overwritten:'));
          result.files_overwritten.forEach((file) => {
            console.log(chalk.gray(`  • ${file}`));
          });
        }

        if (Array.isArray(result.removed) && result.removed.length > 0) {
          console.log(chalk.yellow('\nRemoved stale artifacts:'));
          result.removed.forEach((file) => {
            console.log(chalk.gray(`  • ${file}`));
          });
        }

        if (Array.isArray(result.seeding?.personas) && result.seeding.personas.length > 0) {
          console.log(chalk.green('\nSeeded personas:'));
          result.seeding.personas.forEach((entry) => {
            console.log(chalk.gray(`  • ${entry.persona} (${entry.request_count} requests)`));
          });
        }

        if (Array.isArray(result.server_sync?.personas) && result.server_sync.personas.length > 0) {
          console.log(chalk.green('\nSynced server fixtures:'));
          console.log(chalk.gray(`  app: ${result.server_sync.application.slug}`));
          result.server_sync.personas.forEach((entry) => {
            console.log(chalk.gray(`  • ${entry.code} (${entry.user.id})`));
          });
        }

        if (result.generated?.personas?.length) {
          console.log(chalk.green('\nGenerated personas:'));
          result.generated.personas.forEach((entry) => {
            console.log(chalk.gray(`  • ${entry.persona}`));
            console.log(chalk.gray(`    storageState: ${entry.storage_state_path}`));
            console.log(chalk.gray(`    headers:      ${entry.headers_path}`));
          });
        }

        if (result.generated?.bridge?.script_path) {
          console.log(chalk.green('\nFrontend bridge:'));
          console.log(chalk.gray(`  script: ${result.generated.bridge.script_path}`));
          console.log(chalk.gray(`  include: <script src="${result.generated.bridge.public_url}"></script>`));
        }

        if (result.storage_state_path) {
          console.log(chalk.green('\nStorage state:'));
          console.log(chalk.gray(`  ${result.storage_state_path}`));
          console.log(chalk.gray(`  headers: ${result.headers_path}`));
        }

        if (result.bridge?.script_path) {
          console.log(chalk.green('\nFrontend bridge:'));
          console.log(chalk.gray(`  script: ${result.bridge.script_path}`));
          console.log(chalk.gray(`  include: <script src="${result.bridge.public_url}"></script>`));
        }

        console.log(chalk.green('\nNext steps:'));
        (result.next_steps || []).forEach((step, index) => {
          console.log(chalk.cyan(`  ${index + 1}. ${step}`));
        });
        console.log();
      } catch (error) {
        handleError(
          error,
          {
            command: 'fixtures',
            subcommand: options.subcommand,
            dir: options.dir,
            persona: options.persona,
          },
          { json: isJson }
        );
      }
    },
    doctor: async ({ options }) => {
      const { runDoctor } = loadDoctor();
      await runDoctor({
        port: options.port || DEFAULT_PORT,
        serverUrl: options.serverUrl || null,
        origin: options.origin || null,
        stripe: options.stripe || null,
        json: options.json,
      });
    },
    test: verifyHandler,
    auth: async (...args) => loadAuthCommandHandlers().authHandler(...args),
    login: async (...args) => loadAuthCommandHandlers().loginHandler(...args),
    logout: async (...args) => loadAuthCommandHandlers().logoutHandler(...args),
    whoami: async (...args) => loadAuthCommandHandlers().whoamiHandler(...args),
    token: async (...args) => loadAuthCommandHandlers().tokenHandler(...args),
    billing: billingHandler,
    dayrate: dayrateHandler,
    email: emailHandler,
    policy: policyHandler,
    webhook: webhookHandler,
    'issue-reports': issueReportsHandler,
    access: capabilityHandler,
    journey: capabilityHandler,
    graph: capabilityHandler,
    explain: capabilityHandler,
    contract: capabilityHandler,
  };
}

function emitText({ intent, result, isJson, successMessage = null }) {
  const { emit } = loadDomainCli();
  if (isJson) {
    emit({ intent, result, isJson, successMessage });
    return;
  }
  if (!result.ok) {
    emit({ intent, result, isJson, successMessage });
    return;
  }
  if (successMessage) {
    console.log(successMessage);
  }
  const output = typeof result.data === 'string' ? result.data : JSON.stringify(result.data, null, 2);
  process.stdout.write(output);
  if (!output.endsWith('\n')) process.stdout.write('\n');
}

function parseJsonFlag(raw, label) {
  if (raw === null || raw === undefined) {
    return { ok: true, value: null };
  }
  try {
    return { ok: true, value: JSON.parse(raw) };
  } catch {
    console.error(`${label}: must be valid JSON`);
    process.exitCode = 2;
    return { ok: false, value: null };
  }
}

function parseBooleanFlag(raw, label) {
  if (raw === null || raw === undefined) {
    return { ok: true, value: null };
  }

  if (typeof raw === 'boolean') {
    return { ok: true, value: raw };
  }

  const normalized = String(raw).trim().toLowerCase();
  if (['true', '1', 'yes', 'y'].includes(normalized)) {
    return { ok: true, value: true };
  }
  if (['false', '0', 'no', 'n'].includes(normalized)) {
    return { ok: true, value: false };
  }

  console.error(`${label}: must be one of true|false|1|0|yes|no`);
  process.exitCode = 2;
  return { ok: false, value: null };
}

function requireOption(value, message) {
  if (value !== null && value !== undefined && value !== '') {
    return true;
  }
  console.error(message);
  process.exitCode = 2;
  return false;
}

function assignIfDefined(target, key, value) {
  if (value !== null && value !== undefined) {
    target[key] = value;
  }
}

function splitCsv(raw) {
  if (!raw) return [];
  return String(raw)
    .split(',')
    .map((value) => value.trim())
    .filter(Boolean);
}

function extractErrorObject(result) {
  const candidates = [result?.raw, result?.data];
  for (const candidate of candidates) {
    if (!candidate || typeof candidate !== 'object') continue;
    if (candidate.error && typeof candidate.error === 'object') return candidate.error;
    if (typeof candidate.code === 'string' || typeof candidate.message === 'string') {
      return candidate;
    }
  }
  return null;
}

function extractErrorMessage(result) {
  const error = extractErrorObject(result);
  return (
    error?.message ||
    result?.data?.detail ||
    (result?.status ? `HTTP ${result.status}` : 'Request failed')
  );
}

function extractErrorCode(result) {
  const error = extractErrorObject(result);
  return error?.code || (result?.status ? `http_${result.status}` : 'request_failed');
}

function extractRequestId(result) {
  const raw = result?.raw;
  if (!raw || typeof raw !== 'object') return null;
  return raw.request_id || raw.metadata?.request_id || null;
}

function extractSources(data) {
  if (Array.isArray(data?.sources)) return data.sources;
  if (Array.isArray(data?.decision?.sources)) return data.decision.sources;
  return [];
}

function normalizeDiagnostic(item, fallbackStatus = null) {
  const diagnostic = {
    code: item?.code || (fallbackStatus ? `http_${fallbackStatus}` : 'request_failed'),
    message: item?.message || 'Request failed',
    status: item?.status ?? fallbackStatus,
  };
  assignIfDefined(diagnostic, 'details', item?.details);
  return diagnostic;
}

function extractPayloadDiagnostics(result) {
  const raw = result?.raw;
  if (Array.isArray(raw?.diagnostics)) {
    return raw.diagnostics.map((item) => normalizeDiagnostic(item, result?.status || null));
  }
  return [];
}

function extractRemediations(data) {
  const actions = Array.isArray(data?.next_actions) ? data.next_actions : [];
  return actions.map((action) => {
    const remediation = {
      kind: action.kind,
      label: action.label || null,
      method: action.method || null,
      path: action.path || null,
      cli: action.cli || null,
      requires: Array.isArray(action.requires) ? action.requires : [],
    };
    assignIfDefined(remediation, 'command_template', action.command_template);
    assignIfDefined(remediation, 'safe_to_execute', action.safe_to_execute);
    assignIfDefined(remediation, 'operator_gate_required', action.operator_gate_required);
    return remediation;
  });
}

function normalizeRemediation(item) {
  const remediation = {
    kind: item.kind,
    label: item.label || null,
    method: item.method || null,
    path: item.path || null,
    cli: item.cli || null,
    requires: Array.isArray(item.requires) ? item.requires : [],
  };
  assignIfDefined(remediation, 'details', item.details);
  assignIfDefined(remediation, 'command_template', item.command_template);
  assignIfDefined(remediation, 'safe_to_execute', item.safe_to_execute);
  assignIfDefined(remediation, 'operator_gate_required', item.operator_gate_required);
  return remediation;
}

function extractPayloadRemediations(result) {
  const raw = result?.raw;
  if (!Array.isArray(raw?.remediations)) return [];
  return raw.remediations
    .filter((item) => item && typeof item === 'object' && item.kind)
    .map((item) => normalizeRemediation(item));
}

function capabilityEnvelope({ command, result, data = null, success = null, diagnostics = [] }) {
  const resolvedData = data === null && result ? result.data : data;
  const ok = success === null ? Boolean(result?.ok) : Boolean(success);
  const envelopeDiagnostics = Array.isArray(diagnostics) ? diagnostics.slice() : [];
  envelopeDiagnostics.push(...extractPayloadDiagnostics(result));
  if (result && !result.ok) {
    const hasServerDiagnostic = envelopeDiagnostics.length > 0;
    if (!hasServerDiagnostic) {
      const error = extractErrorObject(result);
      const diagnostic = {
        code: extractErrorCode(result),
        message: extractErrorMessage(result),
        status: result.status || null,
      };
      assignIfDefined(diagnostic, 'details', error?.details);
      envelopeDiagnostics.push(diagnostic);
    }
  }
  const payloadRemediations = extractPayloadRemediations(result);
  const envelope = {
    schema_version: 'spaps.cli.capability.v1',
    command,
    success: ok,
    status: result?.status || null,
    data: resolvedData,
    diagnostics: envelopeDiagnostics,
    remediations: payloadRemediations.length ? payloadRemediations : extractRemediations(resolvedData),
    sources: extractSources(resolvedData),
  };
  assignIfDefined(envelope, 'request_id', extractRequestId(result));
  assignIfDefined(envelope, 'timestamp', result?.raw?.timestamp);
  return envelope;
}

function emitCapability({ command, result, isJson, exitCodeOnFailure = 10 }) {
  if (isJson) {
    console.log(JSON.stringify(capabilityEnvelope({ command, result }), null, 2));
  } else if (!result.ok) {
    console.error(`\u2717 ${command}: ${extractErrorMessage(result)}`);
  } else {
    const data = result.data || {};
    if (typeof data.allowed === 'boolean') {
      console.log(`${command}: ${data.allowed ? 'allowed' : data.outcome || 'not allowed'}`);
      (data.reasons || []).forEach((reason) => {
        console.log(`  - ${reason.message || reason.code}`);
      });
    } else if (typeof data.status === 'string' && typeof data.allowed === 'boolean') {
      console.log(`${command}: ${data.status}`);
    } else {
      console.log(JSON.stringify(data, null, 2));
    }
  }
  if (!result.ok) {
    process.exitCode = exitCodeOnFailure;
  }
}

function emitCapabilityError({ command, err, isJson, exitCode = 10 }) {
  const message = err && err.message ? err.message : String(err);
  const code = err && err.code ? err.code : 'ERROR';
  if (isJson) {
    console.log(JSON.stringify({
      schema_version: 'spaps.cli.capability.v1',
      command,
      success: false,
      status: err?.status || null,
      data: null,
      diagnostics: [{ code, message, status: err?.status || null }],
      remediations: code === 'NOT_AUTHENTICATED' || code === 'SESSION_EXPIRED'
        ? [{ kind: 'authenticate', label: 'Run spaps login', cli: 'spaps login', requires: ['operator'] }]
        : [],
      sources: [],
    }, null, 2));
  } else {
    const hint = code === 'NOT_AUTHENTICATED' || code === 'SESSION_EXPIRED'
      ? ' (run `spaps login` first)'
      : '';
    console.error(`\u2717 ${command}: ${message}${hint}`);
  }
  process.exitCode = exitCode;
}

function buildAccessDecisionPayload(options, prefix) {
  if (!requireOption(options.action, `${prefix}: --action is required`)) return null;
  if (!requireOption(options.resourceType, `${prefix}: --resource-type is required`)) return null;
  if (!requireOption(options.resourceRef, `${prefix}: --resource-ref is required`)) return null;

  const context = parseJsonFlag(options.context, `${prefix}: --context`);
  if (!context.ok) return null;
  const policyContext = parseJsonFlag(options.policyContext, `${prefix}: --policy-context`);
  if (!policyContext.ok) return null;
  const usageDimensions = parseJsonFlag(options.usageDimensions, `${prefix}: --usage-dimensions`);
  if (!usageDimensions.ok) return null;
  const authenticated = parseBooleanFlag(options.authenticated, `${prefix}: --authenticated`);
  if (!authenticated.ok) return null;

  const actor = {
    actor_type: options.actorType || 'user',
  };
  assignIfDefined(actor, 'actor_ref', options.actorRef);
  assignIfDefined(actor, 'user_id', options.userId);
  assignIfDefined(actor, 'email', options.email);
  assignIfDefined(actor, 'agent_id', options.agentId);
  assignIfDefined(actor, 'authenticated', authenticated.value);

  const resource = {
    resource_type: options.resourceType,
    resource_ref: options.resourceRef,
  };
  assignIfDefined(resource, 'resource_id', options.resourceId);
  assignIfDefined(resource, 'resource_key', options.resourceKey);

  const controls = {
    approval_required: Boolean(options.approvalRequired),
    authority_scope: options.authorityScope || 'application',
  };
  assignIfDefined(controls, 'entitlement_key', options.entitlementKey);
  assignIfDefined(controls, 'entitlement_resource_type', options.entitlementResourceType);
  assignIfDefined(controls, 'entitlement_resource_id', options.entitlementResourceId);
  assignIfDefined(controls, 'policy_name', options.policyName);
  assignIfDefined(controls, 'policy_context', policyContext.value);
  assignIfDefined(controls, 'usage_feature_key', options.usageFeatureKey);
  assignIfDefined(controls, 'usage_dimensions', usageDimensions.value);
  assignIfDefined(controls, 'x402_resource_key', options.x402ResourceKey);
  assignIfDefined(controls, 'approval_id', options.approvalId);

  const payload = {
    actor,
    action: options.action,
    resource,
    controls,
    context: context.value || {},
  };
  assignIfDefined(payload, 'idempotency_key', options.idempotencyKey);
  assignIfDefined(payload, 'correlation_id', options.correlationId);
  return payload;
}

async function dayrateHandler({ options }) {
  const { callEndpoint, emit, emitAuthError } = loadDomainCli();
  const isJson = Boolean(options.json);
  const sub = options.subcommand;
  if (sub !== 'config') {
    console.error('dayrate: unknown subcommand. Supported: config');
    process.exitCode = 2;
    return;
  }
  try {
    const result = await callEndpoint({ options, method: 'GET', path: '/api/dayrate/admin/config' });
    emit({ intent: 'dayrate.config', result, isJson });
    if (!result.ok) process.exitCode = 1;
  } catch (err) {
    emitAuthError('dayrate.config', err, isJson);
    process.exitCode = 1;
  }
}

async function billingHandler({ options }) {
  const { callEndpoint, emit, emitAuthError } = loadDomainCli();
  const isJson = Boolean(options.json);
  const sub = options.subcommand;
  try {
    if (sub === 'status') {
      const result = await callEndpoint({
        options,
        method: 'GET',
        path: '/api/admin/billing/status',
      });
      emit({ intent: 'billing.status', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }

    if (sub === 'verify') {
      const result = await callEndpoint({
        options,
        method: 'GET',
        path: '/api/admin/billing/verify',
      });
      emit({ intent: 'billing.verify', result, isJson });
      if (!result.ok || result.data?.valid === false) process.exitCode = 1;
      return;
    }

    if (sub === 'attach') {
      if (!requireOption(options.billingAccountId, 'billing attach: --billing-account-id is required')) return;
      const result = await callEndpoint({
        options,
        method: 'POST',
        path: '/api/admin/billing/attach',
        body: { billing_account_id: options.billingAccountId },
      });
      emit({ intent: 'billing.attach', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }

    console.error('billing: unknown subcommand. Supported: status, attach, verify');
    process.exitCode = 2;
  } catch (err) {
    emitAuthError(`billing.${sub || 'unknown'}`, err, isJson);
    process.exitCode = 1;
  }
}

async function emailHandler({ options }) {
  const { callEndpoint, emit, emitAuthError } = loadDomainCli();
  const isJson = Boolean(options.json);
  const sub = options.subcommand;

  try {
    if (sub === 'send') {
      if (!requireOption(options.templateKey, 'email send: --template-key is required')) return;
      if (!requireOption(options.to, 'email send: --to is required')) return;

      const context = parseJsonFlag(options.context, 'email send: --context');
      if (!context.ok) return;

      const body = {
        template_key: options.templateKey,
        to: options.to,
      };
      assignIfDefined(body, 'context', context.value);
      assignIfDefined(body, 'user_id', options.userId);
      assignIfDefined(body, 'owner_id', options.ownerId);
      assignIfDefined(body, 'subject_override', options.subjectOverride);
      assignIfDefined(body, 'body_override', options.bodyOverride);
      assignIfDefined(body, 'idempotency_key', options.idempotencyKey);

      const result = await callEndpoint({ options, method: 'POST', path: '/api/email/send', body });
      emit({ intent: 'email.send', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }

    if (sub === 'get-template') {
      if (!requireOption(options.templateKey, 'email get-template: --template-key is required')) return;
      const result = await callEndpoint({
        options,
        method: 'GET',
        path: `/api/email/templates/${encodeURIComponent(options.templateKey)}`,
      });
      emit({ intent: 'email.get-template', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }

    if (sub === 'preview') {
      if (!requireOption(options.templateKey, 'email preview: --template-key is required')) return;

      const context = parseJsonFlag(options.context, 'email preview: --context');
      if (!context.ok) return;

      const path = `/api/email/templates/${encodeURIComponent(options.templateKey)}/preview`;
      const result = context.value === null
        ? await callEndpoint({ options, method: 'GET', path })
        : await callEndpoint({
            options,
            method: 'POST',
            path,
            body: { context: context.value },
          });

      emitText({ intent: 'email.preview', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }

    if (sub === 'logs') {
      const query = {};
      if (options.ownerId) query.owner_id = options.ownerId;
      if (options.userId) query.user_id = options.userId;
      if (options.limit) query.limit = options.limit;
      if (options.offset) query.offset = options.offset;
      const result = await callEndpoint({ options, method: 'GET', path: '/api/email/logs', query });
      emit({ intent: 'email.logs', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }

    if (sub === 'list-templates') {
      const result = await callEndpoint({ options, method: 'GET', path: '/api/email/templates' });
      emit({ intent: 'email.list-templates', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }

    if (sub === 'create-template') {
      if (!requireOption(options.templateKey, 'email create-template: --template-key is required')) return;
      if (!requireOption(options.name, 'email create-template: --name is required')) return;
      if (!requireOption(options.subject, 'email create-template: --subject is required')) return;
      if (!requireOption(options.htmlBody, 'email create-template: --html-body is required')) return;

      const variables = parseJsonFlag(options.variables, 'email create-template: --variables');
      if (!variables.ok) return;
      const sampleContext = parseJsonFlag(options.sampleContext, 'email create-template: --sample-context');
      if (!sampleContext.ok) return;
      const isActive = parseBooleanFlag(options.isActive, 'email create-template: --is-active');
      if (!isActive.ok) return;

      const body = {
        template_key: options.templateKey,
        name: options.name,
        subject: options.subject,
        html_body: options.htmlBody,
      };
      assignIfDefined(body, 'description', options.description);
      assignIfDefined(body, 'text_body', options.textBody);
      assignIfDefined(body, 'from_email', options.fromEmail);
      assignIfDefined(body, 'from_name', options.fromName);
      assignIfDefined(body, 'reply_to', options.replyTo);
      assignIfDefined(body, 'variables', variables.value);
      assignIfDefined(body, 'sample_context', sampleContext.value);
      assignIfDefined(body, 'is_active', isActive.value);
      assignIfDefined(body, 'category', options.category);

      const result = await callEndpoint({ options, method: 'POST', path: '/api/email/templates', body });
      emit({ intent: 'email.create-template', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }

    if (sub === 'update-template') {
      if (!requireOption(options.templateKey, 'email update-template: --template-key is required')) return;

      const variables = parseJsonFlag(options.variables, 'email update-template: --variables');
      if (!variables.ok) return;
      const sampleContext = parseJsonFlag(options.sampleContext, 'email update-template: --sample-context');
      if (!sampleContext.ok) return;
      const isActive = parseBooleanFlag(options.isActive, 'email update-template: --is-active');
      if (!isActive.ok) return;

      const body = {};
      assignIfDefined(body, 'name', options.name);
      assignIfDefined(body, 'description', options.description);
      assignIfDefined(body, 'subject', options.subject);
      assignIfDefined(body, 'html_body', options.htmlBody);
      assignIfDefined(body, 'text_body', options.textBody);
      assignIfDefined(body, 'from_email', options.fromEmail);
      assignIfDefined(body, 'from_name', options.fromName);
      assignIfDefined(body, 'reply_to', options.replyTo);
      assignIfDefined(body, 'variables', variables.value);
      assignIfDefined(body, 'sample_context', sampleContext.value);
      assignIfDefined(body, 'is_active', isActive.value);
      assignIfDefined(body, 'category', options.category);

      if (Object.keys(body).length === 0) {
        console.error('email update-template: provide at least one field to update');
        process.exitCode = 2;
        return;
      }

      const result = await callEndpoint({
        options,
        method: 'PUT',
        path: `/api/email/templates/${encodeURIComponent(options.templateKey)}`,
        body,
      });
      emit({ intent: 'email.update-template', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }

    if (sub === 'get-override') {
      if (!requireOption(options.templateKey, 'email get-override: --template-key is required')) return;
      const result = await callEndpoint({
        options,
        method: 'GET',
        path: `/api/email/templates/${encodeURIComponent(options.templateKey)}/override`,
      });
      emit({ intent: 'email.get-override', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }

    if (sub === 'set-override') {
      if (!requireOption(options.templateKey, 'email set-override: --template-key is required')) return;
      const body = {};
      assignIfDefined(body, 'subject_override', options.subjectOverride);
      assignIfDefined(body, 'body_override', options.bodyOverride);
      if (Object.keys(body).length === 0) {
        console.error('email set-override: provide --subject-override and/or --body-override');
        process.exitCode = 2;
        return;
      }
      const result = await callEndpoint({
        options,
        method: 'PUT',
        path: `/api/email/templates/${encodeURIComponent(options.templateKey)}/override`,
        body,
      });
      emit({ intent: 'email.set-override', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }

    if (sub === 'clear-override') {
      if (!requireOption(options.templateKey, 'email clear-override: --template-key is required')) return;
      const result = await callEndpoint({
        options,
        method: 'DELETE',
        path: `/api/email/templates/${encodeURIComponent(options.templateKey)}/override`,
      });
      emit({ intent: 'email.clear-override', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }

    console.error(
      'email: unknown subcommand. Supported: send, get-template, preview, logs, list-templates, create-template, update-template, get-override, set-override, clear-override'
    );
    process.exitCode = 2;
  } catch (err) {
    emitAuthError(`email.${sub || 'unknown'}`, err, isJson);
    process.exitCode = 1;
  }
}

async function policyHandler({ options }) {
  const { callEndpoint, emit, emitAuthError } = loadDomainCli();
  const isJson = Boolean(options.json);
  const sub = options.subcommand;
  try {
    if (sub === 'list') {
      const query = {};
      if (options.isActive !== null && options.isActive !== undefined) query.is_active = options.isActive;
      if (options.limit) query.limit = options.limit;
      const result = await callEndpoint({ options, method: 'GET', path: '/api/policies', query });
      emit({ intent: 'policy.list', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }
    if (sub === 'create') {
      if (!options.name || !options.effect) {
        console.error('policy create: --name and --effect are required');
        process.exitCode = 2;
        return;
      }
      let conditions = {};
      if (options.conditions) {
        try { conditions = JSON.parse(options.conditions); }
        catch { console.error('policy create: --conditions must be valid JSON'); process.exitCode = 2; return; }
      }
      const body = {
        name: options.name,
        effect: options.effect,
        conditions,
        description: options.description || null,
        priority: options.priority || 0,
      };
      const result = await callEndpoint({ options, method: 'POST', path: '/api/policies', body });
      emit({ intent: 'policy.create', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }
    if (sub === 'delete') {
      if (!options.id) {
        console.error('policy delete: --id is required');
        process.exitCode = 2;
        return;
      }
      const result = await callEndpoint({ options, method: 'DELETE', path: `/api/policies/${encodeURIComponent(options.id)}` });
      emit({ intent: 'policy.delete', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }
    console.error('policy: unknown subcommand. Supported: list, create, delete');
    process.exitCode = 2;
  } catch (err) {
    emitAuthError(`policy.${sub || 'unknown'}`, err, isJson);
    process.exitCode = 1;
  }
}

async function webhookHandler({ options }) {
  const { callEndpoint, emit, emitAuthError } = loadDomainCli();
  const isJson = Boolean(options.json);
  const sub = options.subcommand;
  try {
    if (sub === 'list') {
      const result = await callEndpoint({ options, method: 'GET', path: '/api/webhooks' });
      emit({ intent: 'webhook.list', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }
    if (sub === 'register') {
      if (!options.url || !options.events) {
        console.error('webhook register: --url and --events (comma-separated) are required');
        process.exitCode = 2;
        return;
      }
      const events = String(options.events).split(',').map((e) => e.trim()).filter(Boolean);
      const body = { url: options.url, events };
      const result = await callEndpoint({ options, method: 'POST', path: '/api/webhooks', body });
      emit({ intent: 'webhook.register', result, isJson });
      if (!result.ok) process.exitCode = 1;
      return;
    }
    console.error('webhook: unknown subcommand. Supported: list, register');
    process.exitCode = 2;
  } catch (err) {
    emitAuthError(`webhook.${sub || 'unknown'}`, err, isJson);
    process.exitCode = 1;
  }
}

async function issueReportsHandler({ options }) {
  const { callEndpoint, emit, emitAuthError } = loadDomainCli();
  const isJson = Boolean(options.json);
  const sub = options.subcommand;
  if (sub !== 'list-mine') {
    console.error('issue-reports: unknown subcommand. Supported: list-mine');
    process.exitCode = 2;
    return;
  }
  try {
    const query = {};
    if (options.status) query.status = options.status;
    if (options.limit) query.limit = options.limit;
    if (options.offset) query.offset = options.offset;
    const result = await callEndpoint({ options, method: 'GET', path: '/api/v1/issue-reports', query });
    emit({ intent: 'issue-reports.list-mine', result, isJson });
    if (!result.ok) process.exitCode = 1;
  } catch (err) {
    emitAuthError('issue-reports.list-mine', err, isJson);
    process.exitCode = 1;
  }
}

async function capabilityHandler({ name, options }) {
  const { callEndpoint } = loadDomainCli();
  const isJson = Boolean(options.json);
  const command = name === 'access'
    ? `access.${options.subcommand || 'unknown'}`
    : name === 'journey'
      ? `journey.${options.subcommand || 'unknown'}`
      : name === 'graph'
        ? `graph.${options.subcommand || 'unknown'}`
        : name;

  try {
    if (name === 'access') {
      if (options.subcommand !== 'check') {
        console.error('access: unknown subcommand. Supported: check');
        process.exitCode = 2;
        return;
      }
      const body = buildAccessDecisionPayload(options, 'access check');
      if (!body) return;
      const result = await callEndpoint({
        options,
        method: 'POST',
        path: '/api/access/decide',
        body,
      });
      emitCapability({ command, result, isJson, exitCodeOnFailure: 10 });
      return;
    }

    if (name === 'journey') {
      if (options.subcommand !== 'run') {
        console.error('journey: unknown subcommand. Supported: run');
        process.exitCode = 2;
        return;
      }
      const access = buildAccessDecisionPayload(options, 'journey run');
      if (!access) return;
      const operatorLabels = splitCsv(options.operatorLabels)
        .filter((label) => label.toLowerCase() !== 'operator-gated');
      const body = {
        access,
        include_command_templates: Boolean(options.includeCommandTemplates),
        operator_labels: operatorLabels,
        environment: options.environment || 'production',
      };
      const result = await callEndpoint({
        options,
        method: 'POST',
        path: '/api/actions/prepare',
        body,
      });
      emitCapability({ command, result, isJson, exitCodeOnFailure: 30 });
      return;
    }

    if (name === 'graph') {
      let result;
      if (options.subcommand === 'nodes') {
        const query = {};
        assignIfDefined(query, 'node_type', options.nodeType);
        assignIfDefined(query, 'status', options.status);
        assignIfDefined(query, 'q', options.query);
        assignIfDefined(query, 'cursor', options.cursor);
        assignIfDefined(query, 'limit', options.limit);
        assignIfDefined(query, 'application_id', options.applicationId);
        result = await callEndpoint({ options, method: 'GET', path: '/api/graph/nodes', query });
      } else if (options.subcommand === 'paths') {
        const query = {
          from_node_key: options.fromNodeKey,
          to_node_key: options.toNodeKey,
        };
        assignIfDefined(query, 'max_depth', options.maxDepth);
        assignIfDefined(query, 'limit', options.limit);
        assignIfDefined(query, 'include_stale', options.includeStale);
        assignIfDefined(query, 'application_id', options.applicationId);
        result = await callEndpoint({ options, method: 'GET', path: '/api/graph/paths', query });
      } else if (options.subcommand === 'impact') {
        const query = {
          node_key: options.nodeKey,
        };
        assignIfDefined(query, 'max_depth', options.maxDepth);
        assignIfDefined(query, 'limit', options.limit);
        assignIfDefined(query, 'include_stale', options.includeStale);
        assignIfDefined(query, 'application_id', options.applicationId);
        result = await callEndpoint({ options, method: 'GET', path: '/api/graph/impact', query });
      } else if (options.subcommand === 'refresh') {
        const query = {};
        assignIfDefined(query, 'application_id', options.applicationId);
        assignIfDefined(query, 'correlation_id', options.correlationId);
        result = await callEndpoint({ options, method: 'POST', path: '/api/graph/refresh', query });
      } else {
        console.error('graph: unknown subcommand. Supported: nodes, paths, impact, refresh');
        process.exitCode = 2;
        return;
      }
      emitCapability({ command, result, isJson, exitCodeOnFailure: 10 });
      return;
    }

    if (name === 'explain') {
      if (!requireOption(options.decisionId, 'explain: <decision-id> is required')) return;
      const result = await callEndpoint({
        options,
        method: 'GET',
        path: `/api/graph/explain/${encodeURIComponent(options.decisionId)}`,
      });
      emitCapability({ command, result, isJson, exitCodeOnFailure: 10 });
      return;
    }

    if (name === 'contract') {
      const result = await callEndpoint({
        options,
        method: 'GET',
        path: '/api/contract',
      });
      emitCapability({ command, result, isJson, exitCodeOnFailure: 20 });
      return;
    }

    console.error('capability: unsupported command');
    process.exitCode = 2;
  } catch (err) {
    emitCapabilityError({
      command,
      err,
      isJson,
      exitCode: name === 'contract' ? 20 : name === 'journey' ? 30 : 10,
    });
  }
}

module.exports = { createHandlers, openExternalUrl };