[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/JavaScriptSolidServer/JavaScriptSolidServer/gh-pages/bin/jss.js [Back]  [Original]

#!/usr/bin/env node

/**
 * JavaScript Solid Server CLI
 *
 * Usage:
 *   jss start [options]    Start the server
 *   jss init               Initialize configuration
 *   jss passwd   Change user password
 */

import { Command } from 'commander';
import { createServer } from '../src/server.js';
import { loadConfig, saveConfig, printConfig, defaults, parsePluginFlag } from '../src/config.js';
import { createInvite, listInvites, revokeInvite } from '../src/idp/invites.js';
import { findByUsername, updatePassword, deleteAccount } from '../src/idp/accounts.js';
import { setQuotaLimit, getQuotaInfo, reconcileQuota, formatBytes } from '../src/storage/quota.js';
import { parseSize } from '../src/config.js';
import { findFreePort, formatUrl } from '../src/utils/port.js';
import crypto from 'crypto';
import fs from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
import readline from 'readline';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(await fs.readFile(path.join(__dirname, '../package.json'), 'utf8'));

const program = new Command();

program
  .name('jss')
  .description('JavaScript Solid Server - A minimal, fast, JSON-LD native Solid server')
  .version(pkg.version);

/**
 * Convert a camelCase option name back to its kebab-case CLI form for
 * error messages (`singleUserName`  `single-user-name`).
 */
function camelToKebab (name) {
  return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
}

/**
 * Reject any option value that looks like another flag (#103).
 *
 * Commander happily consumes the next argv as a value, so
 *   `jss start --single-user-name --idp`
 * silently sets `singleUserName="--idp"` and the IdP flag is lost.
 * This validator runs as a `preAction` hook for every subcommand, so
 * any option with a missing value gets a clear error instead of a
 * confusing downstream failure ("issuer has no registration endpoint",
 * "Single-user: --idp" in the banner, etc.).
 */
program.hook('preAction', (_thisCommand, actionCommand) => {
  const opts = actionCommand.opts();
  for (const [key, value] of Object.entries(opts)) {
    const flag = camelToKebab(key);
    if (typeof value === 'string' && value.startsWith('--')) {
      console.error(
        `Error: --${flag} value "${value}" looks like a flag, not a value.\n` +
        `Hint: did you forget to provide a value? e.g. --${flag} someValue`
      );
      process.exit(1);
    }
    // Numeric options (parseInt-coerced like --port) silently produce
    // NaN when given a flag like `--idp`. Catch that too  same root
    // cause, different surface.
    if (typeof value === 'number' && Number.isNaN(value)) {
      console.error(
        `Error: --${flag} got a non-numeric value (parsed as NaN).\n` +
        `Hint: did you forget to provide a number? e.g. --${flag} 8080`
      );
      process.exit(1);
    }
  }
});

/**
 * Start command
 */
program
  .command('start')
  .description('Start the Solid server')
  .option('-p, --port ', 'Port to listen on', parseInt)
  .option('-h, --host ', 'Host to bind to')
  .option('-r, --root ', 'Data directory')
  .option('-c, --config ', 'Config file path')
  .option('--ssl-key ', 'Path to SSL private key (PEM)')
  .option('--ssl-cert ', 'Path to SSL certificate (PEM)')
  .option('--multi-user', 'Enable multi-user mode')
  .option('--no-multi-user', 'Disable multi-user mode')
  .option('--conneg', 'Enable content negotiation (Turtle support)')
  .option('--no-conneg', 'Disable content negotiation')
  .option('--notifications', 'Enable WebSocket notifications')
  .option('--no-notifications', 'Disable WebSocket notifications')
  .option('--idp', 'Enable built-in Identity Provider')
  .option('--no-idp', 'Disable built-in Identity Provider')
  .option('--provision-keys', 'Generate a Schnorr secp256k1 owner key on pod creation, written to /private/privkey.jsonld in W3C CID v1.0 Multikey format (off by default)')
  .option('--no-provision-keys', 'Do not auto-generate an owner key on pod creation')
  .option('--idp-issuer ', 'IdP issuer URL (defaults to server URL)')
  .option('--subdomains', 'Enable subdomain-based pods (XSS protection)')
  .option('--no-subdomains', 'Disable subdomain-based pods')
  .option('--base-domain ', 'Base domain for subdomain pods (e.g., "example.com")')
  .option('--mashlib-cdn', 'Enable Mashlib data browser (CDN mode)')
  .option('--mashlib-module ', 'Enable ES module data browser from a URL')
  .option('--no-mashlib', 'Disable Mashlib data browser')
  .option('--mashlib-version ', 'Mashlib version for CDN mode (default: 2.0.0)')
  .option('--git', 'Enable Git HTTP backend (clone/push support)')
  .option('--no-git', 'Disable Git HTTP backend')
  .option('--cors-proxy', 'Enable CORS proxy at /proxy?url=... for browser apps (WAC-gated)')
  .option('--no-cors-proxy', 'Disable CORS proxy')
  .option('--cors-proxy-max-bytes ', 'CORS proxy upstream response size cap (default 50MB)', parseInt)
  .option('--cors-proxy-timeout-ms ', 'CORS proxy upstream request timeout (default 30s)', parseInt)
  .option('--cors-proxy-max-redirects ', 'CORS proxy max redirect hops, each re-validated (default 5)', parseInt)
  .option('--body-limit ', 'Maximum request body size, e.g. 100MB or 1GB (default 20MB). Raise to accept larger `git push`; lower for tighter memory-DoS protection.')
  .option('--nostr', 'Enable Nostr relay')
  .option('--no-nostr', 'Disable Nostr relay')
  .option('--nostr-path ', 'Nostr relay WebSocket path (default: /relay)')
  .option('--nostr-max-events ', 'Max events in relay memory (default: 1000)', parseInt)
  .option('--webrtc', 'Enable WebRTC signaling server')
  .option('--no-webrtc', 'Disable WebRTC signaling server')
  .option('--webrtc-path ', 'WebRTC signaling WebSocket path (default: /.webrtc)')
  .option('--terminal', 'Enable WebSocket terminal (shell access)')
  .option('--no-terminal', 'Disable WebSocket terminal')
  .option('--tunnel', 'Enable tunnel proxy (decentralized ngrok)')
  .option('--no-tunnel', 'Disable tunnel proxy')
  .option('--tunnel-path ', 'Tunnel WebSocket path (default: /.tunnel)')
  .option('--activitypub', 'Enable ActivityPub federation')
  .option('--no-activitypub', 'Disable ActivityPub federation')
  .option('--ap-username ', 'ActivityPub username (default: me)')
  .option('--ap-display-name ', 'ActivityPub display name')
  .option('--ap-summary ', 'ActivityPub bio/summary')
  .option('--ap-nostr-pubkey ', 'Nostr pubkey for identity linking')
  .option('--invite-only', 'Require invite code for registration')
  .option('--no-invite-only', 'Allow open registration')
  .option('--single-user', 'Single-user mode (creates pod on startup, disables registration)')
  .option('--single-user-name ', 'Mount the pod at // instead of at the server root (default: root pod at /)')
  .option('--single-user-password ', 'Initial IDP password to seed when creating the single-user pod (or set JSS_SINGLE_USER_PASSWORD)')
  .option('--webid-tls', 'Enable WebID-TLS client certificate authentication')
  .option('--no-webid-tls', 'Disable WebID-TLS authentication')
  .option('--public', 'Allow unauthenticated access (skip WAC, open read/write)')
  .option('--read-only', 'Disable PUT/DELETE/PATCH methods (read-only mode)')
  .option('--live-reload', 'Inject live reload script into HTML (auto-refresh on changes)')
  .option('--pay', 'Enable HTTP 402 paid access for /pay/* routes')
  .option('--no-pay', 'Disable HTTP 402 paid access')
  .option('--pay-cost ', 'Cost per request in satoshis (default: 1)', parseInt)
  .option('--pay-mempool-url ', 'Mempool API URL for deposit verification')
  .option('--pay-address ', 'Address for receiving deposits')
  .option('--pay-token ', 'Token to sell (enables primary market)')
  .option('--pay-rate ', 'Sats per token for primary market (default: 1)', parseInt)
  .option('--pay-chains ', 'Comma-separated chain IDs for multi-chain deposits/AMM (e.g. "tbtc3,tbtc4")')
  .option('--mongo', 'Enable MongoDB-backed /db/ route')
  .option('--no-mongo', 'Disable MongoDB-backed /db/ route')
  .option('--mongo-url ', 'MongoDB connection URL (default: mongodb://localhost:27017)')
  .option('--mongo-database ', 'MongoDB database name (default: solid)')
  .option('--mcp', 'Enable MCP (Model Context Protocol) server at /mcp  pod as a tool surface for agents (#490)')
  .option('--no-mcp', 'Disable MCP server')
  .option('-q, --quiet', 'Suppress log output')
  .option('--plugin ', 'Mount an app plugin (repeatable; prefix must start with /). Appends to config-file plugins (#594)', (value, previous) => previous.concat([value]), [])
  .option('--log-level ', 'Log level: error, warn, info, debug (default: info)')
  .option('--print-config', 'Print configuration and exit')
  .action(async (options) => {
    try {
      // Normalize --multi-user (Commander camelCase: multiUser) to internal key
      if (options.multiUser !== undefined) {
        options.multiuser = options.multiUser;
        delete options.multiUser;
      }

      const config = await loadConfig(options, options.config);

      // --plugin entries APPEND to the config file's plugins rather than
      // following the CLI-replaces-file rule  replacing would make -c plus
      // one --plugin silently drop the file's declared apps (#594).
      if (options.plugin?.length) {
        config.plugins = [
          ...(Array.isArray(config.plugins) ? config.plugins : []),
          ...options.plugin.map(parsePluginFlag),
        ];
      }

      // Set DATA_ROOT env var so all modules use the same data directory
      process.env.DATA_ROOT = path.resolve(config.root);

      if (options.printConfig) {
        printConfig(config);
        process.exit(0);
      }

      // If the requested port is busy, shift up to the next free one
      // (Vite-style), rather than dying on a raw EADDRINUSE  a common
      // first-run papercut when a stale instance is still running (#557).
      // Must run BEFORE the issuer/baseUrl are derived so they reflect
      // the port we actually bind. The notice goes to stderr so it
      // surfaces even under --quiet (a port change the operator didn't
      // ask for is operationally significant).
      const requestedPort = config.port;
      const boundPort = await findFreePort(requestedPort, config.host);
      if (boundPort === null) {
        console.error(
          `Error: no free port found in ${requestedPort}${requestedPort + 9} on ${config.host}.`
        );
        process.exit(1);
      }
      if (boundPort !== requestedPort) {
        console.error(`  Port ${requestedPort} is in use  using ${boundPort} instead.`);
        config.port = boundPort;
      }

      // Determine IdP issuer URL
      const protocol = config.ssl ? 'https' : 'http';
      const baseUrl = formatUrl(config.host, config.port, protocol);
      // Ensure issuer has trailing slash for CTH compatibility
      let idpIssuer = config.idpIssuer || baseUrl;
      if (idpIssuer && !idpIssuer.endsWith('/')) {
        idpIssuer = idpIssuer + '/';
      }

      // Create and start server
      const server = createServer({
        port: config.port,
        host: config.host,
        // Wire the parsed --body-limit / JSS_BODY_LIMIT value through 
        // omitting it here silently pinned every CLI-started server to
        // the 10MB default and made the #474 knob dead wiring (#561).
        bodyLimit: config.bodyLimit,
        logger: config.logger,
        conneg: config.conneg,
        notifications: config.notifications,
        idp: config.idp,
        idpIssuer: idpIssuer,
        ssl: config.ssl ? {
          key: await fs.readFile(config.sslKey),
          cert: await fs.readFile(config.sslCert),
        } : null,
        root: config.root,
        subdomains: config.subdomains,
        baseDomain: config.baseDomain,
        mashlib: config.mashlib || config.mashlibCdn,
        mashlibCdn: config.mashlibCdn,
        mashlibVersion: config.mashlibVersion,
        mashlibModule: config.mashlibModule,
        git: config.git,
        corsProxy: config.corsProxy,
        corsProxyMaxBytes: config.corsProxyMaxBytes,
        corsProxyTimeoutMs: config.corsProxyTimeoutMs,
        corsProxyMaxRedirects: config.corsProxyMaxRedirects,
        nostr: config.nostr,
        nostrPath: config.nostrPath,
        nostrMaxEvents: config.nostrMaxEvents,
        webrtc: config.webrtc,
        webrtcPath: config.webrtcPath,
        terminal: config.terminal,
        tunnel: config.tunnel,
        tunnelPath: config.tunnelPath,
        activitypub: config.activitypub,
        apUsername: config.apUsername,
        apDisplayName: config.apDisplayName,
        apSummary: config.apSummary,
        apNostrPubkey: config.apNostrPubkey,
        inviteOnly: config.inviteOnly,
        webidTls: config.webidTls,
        singleUser: config.singleUser,
        singleUserName: config.singleUserName,
        singleUserPassword: config.singleUserPassword,
        provisionKeys: config.provisionKeys,
        public: config.public,
        readOnly: config.readOnly,
        liveReload: config.liveReload,
        pay: config.pay,
        payCost: config.payCost,
        payMempoolUrl: config.payMempoolUrl,
        payAddress: config.payAddress,
        payToken: config.payToken,
        payRate: config.payRate,
        payChains: config.payChains,
        mongo: config.mongo,
        mongoUrl: config.mongoUrl,
        mongoDatabase: config.mongoDatabase,
        mcp: config.mcp,
        // Config-file-only keys (no CLI flags yet): omitting them here made
        // the documented `-c config.json` route silently boot without the
        // declared apps (#592).
        appPaths: config.appPaths,
        plugins: config.plugins,
      });

      await server.listen({ port: config.port, host: config.host });

      if (!config.quiet) {
        console.log(`\n  JavaScript Solid Server v${pkg.version}`);
        console.log(`  ${baseUrl}/`);
        console.log(`\n  Data: ${path.resolve(config.root)}`);
        if (config.ssl) console.log('  SSL:  enabled');
        if (config.conneg) console.log('  Conneg: enabled');
        if (config.notifications) console.log('  WebSocket: enabled');
        if (config.idp) console.log(`  IdP: ${idpIssuer}`);
        if (config.subdomains) console.log(`  Subdomains: ${config.baseDomain} (XSS protection enabled)`);
        if (config.mashlibCdn) {
          console.log(`  Mashlib: v${config.mashlibVersion} (CDN mode)`);
        } else if (config.mashlib) {
          console.log(`  Mashlib: local (data browser enabled)`);
        }
        if (config.mashlibModule) console.log(`  Mashlib module: ${config.mashlibModule}`);
        if (config.git) console.log('  Git: enabled (clone/push support)');
        if (config.corsProxy) console.log('  CORS proxy: enabled (/proxy?url=..., WAC-gated)');
        if (config.nostr) console.log(`  Nostr: enabled (${config.nostrPath})`);
        if (config.webrtc) console.log(`  WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`);
        if (config.terminal) console.log('  Terminal: enabled (/.terminal)');
        if (config.tunnel) console.log(`  Tunnel: enabled (${config.tunnelPath || '/.tunnel'})`);
        if (config.activitypub) console.log(`  ActivityPub: enabled (@${config.apUsername || 'me'})`);
        if (config.singleUser) console.log(`  Single-user: ${config.singleUserName || 'me'} (registration disabled)`);
        else if (config.inviteOnly) console.log('  Registration: invite-only');
        if (config.webidTls) console.log('  WebID-TLS: enabled (client certificate auth)');
        if (config.public) {
          console.log('');
          console.log('    WARNING: PUBLIC MODE ENABLED');
          console.log('     All files are accessible without authentication.');
          if (!config.readOnly) {
            console.log('     Anyone can read, write, and delete files.');
          }
          console.log('     Do not expose to the internet!');
        }
        if (config.pay) {
          console.log(`  Pay: ${config.payCost} sat/req (402 enabled)`);
          if (config.payToken) console.log(`  Token: ${config.payToken} @ ${config.payRate} sat/token`);
        }
        if (config.mongo) console.log(`  MongoDB: ${config.mongoUrl} (${config.mongoDatabase})`);
        if (config.readOnly) console.log('  Read-only: enabled (PUT/DELETE/PATCH disabled)');
        console.log('\n  Press Ctrl+C to stop\n');
      }

      // Handle shutdown
      const shutdown = async () => {
        if (!config.quiet) console.log('\n  Shutting down...');
        await server.close();
        process.exit(0);
      };

      process.on('SIGINT', shutdown);
      process.on('SIGTERM', shutdown);

      // Gracefully handle ECONNRESET  normal network noise from clients
      // closing connections early (browser navigation, health checks, etc.)
      process.on('uncaughtException', (err) => {
        if (err.code === 'ECONNRESET' || err.code === 'EPIPE' || err.code === 'ECONNABORTED') {
          return;
        }
        console.error('Uncaught exception:', err);
        process.exit(1);
      });

    } catch (err) {
      console.error(`Error: ${err.message}`);
      process.exit(1);
    }
  });

/**
 * Install command  install a Solid app from the default registry
 * (`github.com/solid-apps/`) into a running pod.
 *
 * Phase 1 of #464 / scoped in #478. Hardcodes the default registry;
 * later phases add /, full URLs, refs, renames, did:nostr
 * resolution, NIP-98 auth, curated default sets, and --bundle.
 */
program
  .command('install [names...]')
  .description('Install a Solid app (or a bundle of apps) into a running pod')
  .option('--pod ', 'Target pod URL', 'http://localhost:4443')
  .option('--user ', 'Username for IDP auth', 'me')
  .option('--password ', 'Password (default: $JSS_SINGLE_USER_PASSWORD or "me")')
  .option('--nostr-privkey ', 'Sign install pushes with NIP-98 using this 64-char hex Nostr privkey instead of fetching a bearer token (default: $NOSTR_PRIVKEY)')
  .option('--bundle ', 'Install everything in a bundle (JSON-LD doc). Source: bare name  solid-apps/bundles, /, https://..., or a local path')
  .action(async (names, options) => {
    try {
      const { runInstall } = await import('../src/cli/install.js');
      await runInstall(names, options);
    } catch (err) {
      // runInstall prints its own per-app error lines; we just exit non-zero.
      process.exit(1);
    }
  });

/**
 * Init command - interactive configuration
 */
program
  .command('init')
  .description('Initialize server configuration')
  .option('-c, --config ', 'Config file path', './config.json')
  .option('-y, --yes', 'Accept defaults without prompting')
  .action(async (options) => {
    const configFile = path.resolve(options.config);

    // Check if config already exists
    if (await fs.pathExists(configFile)) {
      console.log(`Config file already exists: ${configFile}`);
      const overwrite = options.yes ? true : await confirm('Overwrite?');
      if (!overwrite) {
        console.log('Aborted.');
        process.exit(0);
      }
    }

    let config;

    if (options.yes) {
      // Use defaults
      config = { ...defaults };
    } else {
      // Interactive prompts
      console.log('\n  JavaScript Solid Server Setup\n');

      config = {
        port: await prompt('Port', defaults.port),
        root: await prompt('Data directory', defaults.root),
        conneg: await confirm('Enable content negotiation (Turtle support)?', defaults.conneg),
        notifications: await confirm('Enable WebSocket notifications?', defaults.notifications),
      };

      // Ask about SSL
      const useSSL = await confirm('Configure SSL?', false);
      if (useSSL) {
        config.sslKey = await prompt('SSL key path', './ssl/key.pem');
        config.sslCert = await prompt('SSL certificate path', './ssl/cert.pem');
      }

      // Ask about IdP
      config.idp = await confirm('Enable built-in Identity Provider?', false);
      if (config.idp) {
        const customIssuer = await confirm('Use custom issuer URL?', false);
        if (customIssuer) {
          config.idpIssuer = await prompt('IdP issuer URL', 'https://example.com');
        }
      }

      console.log('');
    }

    // Save config
    await saveConfig(config, configFile);
    console.log(`Configuration saved to: ${configFile}`);

    // Create data directory
    const dataDir = path.resolve(config.root);
    await fs.ensureDir(dataDir);
    console.log(`Data directory created: ${dataDir}`);

    console.log('\nRun `jss start` to start the server.\n');
  });

/**
 * Invite command - manage invite codes
 */
const inviteCmd = program
  .command('invite')
  .description('Manage invite codes for registration');

inviteCmd
  .command('create')
  .description('Create a new invite code')
  .option('-u, --uses ', 'Maximum uses (default: 1)', (v) => parseInt(v, 10), 1)
  .option('-n, --note ', 'Optional note/description')
  .option('-r, --root ', 'Data directory')
  .action(async (options) => {
    try {
      // Set DATA_ROOT if provided
      if (options.root) {
        process.env.DATA_ROOT = path.resolve(options.root);
      }

      const { code, invite } = await createInvite({
        maxUses: options.uses,
        note: options.note || ''
      });

      console.log(`\nCreated invite code: ${code}`);
      if (invite.maxUses > 1) {
        console.log(`Uses: 0/${invite.maxUses}`);
      }
      if (invite.note) {
        console.log(`Note: ${invite.note}`);
      }
      console.log('');
    } catch (err) {
      console.error(`Error: ${err.message}`);
      process.exit(1);
    }
  });

inviteCmd
  .command('list')
  .description('List all invite codes')
  .option('-r, --root ', 'Data directory')
  .action(async (options) => {
    try {
      // Set DATA_ROOT if provided
      if (options.root) {
        process.env.DATA_ROOT = path.resolve(options.root);
      }

      const invites = await listInvites();

      if (invites.length === 0) {
        console.log('\nNo invite codes found.\n');
        return;
      }

      console.log('\n  CODE        USES     CREATED      NOTE');
      console.log('  ' + '-'.repeat(55));

      for (const invite of invites) {
        const uses = `${invite.uses}/${invite.maxUses}`.padEnd(8);
        const created = invite.created.split('T')[0];
        const note = invite.note || '';
        console.log(`  ${invite.code}    ${uses} ${created}   ${note}`);
      }
      console.log('');
    } catch (err) {
      console.error(`Error: ${err.message}`);
      process.exit(1);
    }
  });

inviteCmd
  .command('revoke ')
  .description('Revoke an invite code')
  .option('-r, --root ', 'Data directory')
  .action(async (code, options) => {
    try {
      // Set DATA_ROOT if provided
      if (options.root) {
        process.env.DATA_ROOT = path.resolve(options.root);
      }

      const success = await revokeInvite(code);

      if (success) {
        console.log(`\nRevoked invite code: ${code.toUpperCase()}\n`);
      } else {
        console.log(`\nInvite code not found: ${code.toUpperCase()}\n`);
        process.exit(1);
      }
    } catch (err) {
      console.error(`Error: ${err.message}`);
      process.exit(1);
    }
  });

/**
 * Quota command - manage storage quotas
 */
const quotaCmd = program
  .command('quota')
  .description('Manage storage quotas for pods');

quotaCmd
  .command('set  ')
  .description('Set quota limit for a user (e.g., 50MB, 1GB)')
  .option('-r, --root ', 'Data directory')
  .action(async (username, size, options) => {
    try {
      if (options.root) {
        process.env.DATA_ROOT = path.resolve(options.root);
      }

      const bytes = parseSize(size);
      if (bytes === 0) {
        console.error('Invalid size format. Use e.g., 50MB, 1GB');
        process.exit(1);
      }

      const quota = await setQuotaLimit(username, bytes);
      console.log(`\nQuota set for ${username}: ${formatBytes(quota.limit)}`);
      console.log(`Current usage: ${formatBytes(quota.used)} (${Math.round(quota.used / quota.limit * 100)}%)\n`);
    } catch (err) {
      console.error(`Error: ${err.message}`);
      process.exit(1);
    }
  });

quotaCmd
  .command('show ')
  .description('Show quota info for a user')
  .option('-r, --root ', 'Data directory')
  .action(async (username, options) => {
    try {
      if (options.root) {
        process.env.DATA_ROOT = path.resolve(options.root);
      }

      const quota = await getQuotaInfo(username);

      if (quota.limit === 0) {
        console.log(`\n${username}: No quota set (unlimited)\n`);
      } else {
        console.log(`\n${username}:`);
        console.log(`  Used:  ${formatBytes(quota.used)}`);
        console.log(`  Limit: ${formatBytes(quota.limit)}`);
        console.log(`  Free:  ${formatBytes(quota.limit - quota.used)}`);
        console.log(`  Usage: ${quota.percent}%\n`);
      }
    } catch (err) {
      console.error(`Error: ${err.message}`);
      process.exit(1);
    }
  });

quotaCmd
  .command('reconcile ')
  .description('Recalculate quota usage from actual disk usage')
  .option('-r, --root ', 'Data directory')
  .action(async (username, options) => {
    try {
      if (options.root) {
        process.env.DATA_ROOT = path.resolve(options.root);
      }

      console.log(`Calculating actual disk usage for ${username}...`);
      const quota = await reconcileQuota(username);

      if (quota.limit === 0) {
        console.log(`\n${username}: No quota configured\n`);
      } else {
        console.log(`\nReconciled ${username}:`);
        console.log(`  Used:  ${formatBytes(quota.used)}`);
        console.log(`  Limit: ${formatBytes(quota.limit)}`);
        console.log(`  Usage: ${Math.round(quota.used / quota.limit * 100)}%\n`);
      }
    } catch (err) {
      console.error(`Error: ${err.message}`);
      process.exit(1);
    }
  });

/**
 * Token command - manage MRC20 tokens
 */
const tokenCmd = program
  .command('token')
  .description('Manage MRC20 tokens anchored to Bitcoin');

tokenCmd
  .command('mint')
  .description('Create a new MRC20 token')
  .requiredOption('-t, --ticker ', 'Token ticker symbol')
  .requiredOption('-s, --supply ', 'Total supply', parseInt)
  .requiredOption('-v, --voucher ', 'Funded TXO URI (txo:btc:txid:vout?amount=N&key=hex)')
  .option('-n, --name ', 'Token name (defaults to ticker)')
  .option('-r, --root ', 'Data directory')
  .option('--mempool-url ', 'Mempool API URL', 'https://mempool.space/testnet4')
  .option('--network ', 'Bitcoin network (testnet4 or mainnet)', 'testnet4')
  .action(async (options) => {
    try {
      if (options.root) process.env.DATA_ROOT = path.resolve(options.root);
      const { mintToken } = await import('../src/token.js');
      console.log(`\nMinting ${options.supply} ${options.ticker}...`);
      const result = await mintToken({
        ticker: options.ticker,
        name: options.name,
        supply: options.supply,
        voucher: options.voucher,
        mempoolUrl: options.mempoolUrl,
        network: options.network
      });
      console.log(`\nToken minted!`);
      console.log(`  Ticker:  ${options.ticker}`);
      console.log(`  Supply:  ${options.supply}`);
      console.log(`  Issuer:  ${result.trail.pubkeyBase}`);
      console.log(`  TX:      ${result.txid}`);
      console.log(`  Address: ${result.address}`);
      console.log('');
    } catch (err) {
      console.error(`Error: ${err.message}`);
      process.exit(1);
    }
  });

tokenCmd
  .command('transfer')
  .description('Transfer tokens to an address')
  .requiredOption('-t, --ticker ', 'Token ticker symbol')
  .requiredOption('--to ', 'Recipient address (pubkey hex)')
  .requiredOption('-a, --amount ', 'Amount to transfer', parseInt)
  .option('-r, --root ', 'Data directory')
  .option('--mempool-url ', 'Mempool API URL', 'https://mempool.space/testnet4')
  .action(async (options) => {
    try {
      if (options.root) process.env.DATA_ROOT = path.resolve(options.root);
      const { transferToken } = await import('../src/token.js');
      console.log(`\nTransferring ${options.amount} ${options.ticker} to ${options.to.slice(0, 16)}...`);
      const result = await transferToken({
        ticker: options.ticker,
        to: options.to,
        amount: options.amount,
        mempoolUrl: options.mempoolUrl
      });
      console.log(`\nTransfer complete!`);
      console.log(`  TX:      ${result.txid}`);
      console.log(`  Address: ${result.address}`);
      console.log(`  Balance: ${JSON.stringify(result.trail.states[result.trail.states.length - 1].balances)}`);
      console.log('');
    } catch (err) {
      console.error(`Error: ${err.message}`);
      process.exit(1);
    }
  });

tokenCmd
  .command('info [ticker]')
  .description('Show token info (or list all tokens)')
  .option('-r, --root ', 'Data directory')
  .action(async (ticker, options) => {
    try {
      if (options.root) process.env.DATA_ROOT = path.resolve(options.root);
      const { tokenInfo, listTrails } = await import('../src/token.js');

      if (!ticker) {
        // List all tokens
        const trails = await listTrails();
        if (trails.length === 0) {
          console.log('\nNo tokens found.\n');
          return;
        }
        console.log('\n  TICKER   SUPPLY   SEQ   SATS       CREATED');
        console.log('  ' + '-'.repeat(55));
        for (const t of trails) {
          const state = t.states[t.states.length - 1];
          console.log(`  ${t.ticker.padEnd(8)} ${String(t.supply).padEnd(8)} ${String(state.seq).padEnd(5)} ${String(t.currentAmount).padEnd(10)} ${t.dateCreated.split('T')[0]}`);
        }
        console.log('');
        return;
      }

      const info = await tokenInfo(ticker);
      console.log(`\n  ${info.ticker}  ${info.name}`);
      console.log('  ' + '-'.repeat(40));
      console.log(`  Supply:    ${info.supply}`);
      console.log(`  Seq:       ${info.seq}`);
      console.log(`  Issuer:    ${info.pubkeyBase}`);
      console.log(`  Network:   ${info.network}`);
      console.log(`  TX:        ${info.currentTxid}`);
      console.log(`  Address:   ${info.currentAddress}`);
      console.log(`  UTXO sats: ${info.currentAmount}`);
      console.log(`  Created:   ${info.dateCreated}`);
      console.log('  Balances:');
      for (const [addr, bal] of Object.entries(info.balances)) {
        const label = addr === info.pubkeyBase ? `${addr.slice(0, 16)}... (issuer)` : `${addr.slice(0, 16)}...`;
        console.log(`    ${label}: ${bal}`);
      }
      console.log('');
    } catch (err) {
      console.error(`Error: ${err.message}`);
      process.exit(1);
    }
  });

/**
 * Passwd command - change a user's password
 */
program
  .command('passwd ')
  .description('Change password for a user account')
  .option('-p, --password ', 'New password (non-interactive)')
  .option('-g, --generate', 'Generate a random password')
  .option('-r, --root ', 'Data directory')
  .action(async (username, options) => {
    try {
      if (options.root) {
        process.env.DATA_ROOT = path.resolve(options.root);
      }

      const account = await findByUsername(username);
      if (!account) {
        console.error(`Error: User not found: ${username}`);
        process.exit(1);
      }

      // Determine new password
      let newPassword;

      if (options.generate) {
        newPassword = crypto.randomBytes(16).toString('base64url');
      } else if (options.password) {
        newPassword = options.password;
      } else {
        // Interactive prompt
        newPassword = await promptPassword('New password: ');
        const confirmation = await promptPassword('Confirm password: ');
        if (newPassword !== confirmation) {
          console.error('Error: Passwords do not match');
          process.exit(1);
        }
      }

      if (!newPassword) {
        console.error('Error: Password cannot be empty');
        process.exit(1);
      }

      await updatePassword(account.id, newPassword);

      if (options.generate) {
        console.log(`\nPassword updated for ${account.username}`);
        console.log(`Generated password: ${newPassword}\n`);
      } else {
        console.log(`\nPassword updated for ${account.username}\n`);
      }
    } catch (err) {
      console.error(`Error: ${err.message}`);
      process.exit(1);
    }
  });

/**
 * Account commands - manage user accounts
 */
const accountCmd = program
  .command('account')
  .description('Manage user accounts');

accountCmd
  .command('delete ')
  .description('Delete a user account from the IdP')
  .option('-r, --root ',  'Data directory')
  .option('-y, --yes',          'Skip the confirmation prompt')
  .option('--purge',            'Also delete pod data at //')
  .action(async (username, options) => {
    try {
      if (options.root) {
        process.env.DATA_ROOT = path.resolve(options.root);
      }

      const account = await findByUsername(username);
      if (!account) {
        console.error(`Error: User not found: ${username}`);
        process.exit(1);
      }

      if (!options.yes) {
        const summary = `Delete account '${account.username}' (${account.webId})${options.purge ? ' AND purge pod data' : ''}?`;
        const ok = await confirm(summary, false);
        if (!ok) {
          console.log('Cancelled.');
          process.exit(0);
        }
      }

      await deleteAccount(account.id);

      if (options.purge) {
        const dataRoot = process.env.DATA_ROOT || './data';
        // Use podName, not username  createAccount lowercases the
        // username but pod directories on disk preserve the original
        // case. On case-sensitive filesystems they can differ.
        const podPath = path.join(dataRoot, account.podName || account.username);
        await fs.remove(podPath);
        console.log(`\nDeleted account ${account.username}. Pod data removed from ${podPath}.\n`);
      } else {
        const podDir = account.podName || account.username;
        console.log(`\nDeleted account ${account.username}. Pod data preserved at /${podDir}/ (use --purge to remove).\n`);
      }
    } catch (err) {
      console.error(`Error: ${err.message}`);
      process.exit(1);
    }
  });

/**
 * Helper: Prompt for a password (hidden input)
 */
async function promptPassword(question) {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });

  return new Promise((resolve) => {
    // Disable echo for password input
    if (process.stdin.isTTY) {
      process.stdout.write(`  ${question}`);
      const stdin = process.openStdin();
      process.stdin.setRawMode(true);
      let password = '';
      const onData = (ch) => {
        const c = ch.toString('utf8');
        if (c === '\n' || c === '\r' || c === '\u0004') {
          process.stdin.setRawMode(false);
          process.stdin.removeListener('data', onData);
          process.stdout.write('\n');
          rl.close();
          resolve(password);
        } else if (c === '\u0003') {
          // Ctrl+C
          process.exit(0);
        } else if (c === '\u007f' || c === '\b') {
          // Backspace
          if (password.length > 0) {
            password = password.slice(0, -1);
          }
        } else {
          password += c;
        }
      };
      process.stdin.on('data', onData);
    } else {
      // Non-TTY: read line normally (piped input)
      rl.question(`  ${question}`, (answer) => {
        rl.close();
        resolve(answer.trim());
      });
    }
  });
}

/**
 * Helper: Prompt for input
 */
async function prompt(question, defaultValue) {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });

  return new Promise((resolve) => {
    const defaultStr = defaultValue !== undefined ? ` (${defaultValue})` : '';
    rl.question(`  ${question}${defaultStr}: `, (answer) => {
      rl.close();
      const value = answer.trim() || defaultValue;
      // Parse numbers
      if (typeof defaultValue === 'number' && !isNaN(value)) {
        resolve(parseInt(value, 10));
      } else {
        resolve(value);
      }
    });
  });
}

/**
 * Helper: Confirm yes/no
 */
async function confirm(question, defaultValue = false) {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });

  return new Promise((resolve) => {
    const hint = defaultValue ? '[Y/n]' : '[y/N]';
    rl.question(`  ${question} ${hint}: `, (answer) => {
      rl.close();
      const normalized = answer.trim().toLowerCase();
      if (normalized === '') {
        resolve(defaultValue);
      } else {
        resolve(normalized === 'y' || normalized === 'yes');
      }
    });
  });
}

// Parse and run
program.parse();

Web Proxy Viewer  |  New URL  |  Original Page