#!/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();