diff --git a/bin/jss.js b/bin/jss.js index 69b7e2e..237f531 100755 --- a/bin/jss.js +++ b/bin/jss.js @@ -8,78 +8,128 @@ * jss init Initialize configuration */ -import { Command } from 'commander'; -import { createServer } from '../src/server.js'; -import { loadConfig, saveConfig, printConfig, defaults } from '../src/config.js'; -import { createInvite, listInvites, revokeInvite } from '../src/idp/invites.js'; -import { setQuotaLimit, getQuotaInfo, reconcileQuota, formatBytes } from '../src/storage/quota.js'; -import { parseSize } from '../src/config.js'; -import fs from 'fs-extra'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import readline from 'readline'; +import { Command } from "commander"; +import { createServer } from "../src/server.js"; +import { + loadConfig, + saveConfig, + printConfig, + defaults, +} from "../src/config.js"; +import { createInvite, listInvites, revokeInvite } from "../src/idp/invites.js"; +import { + setQuotaLimit, + getQuotaInfo, + reconcileQuota, + formatBytes, +} from "../src/storage/quota.js"; +import { parseSize } from "../src/config.js"; +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 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') + .name("jss") + .description( + "JavaScript Solid Server - A minimal, fast, JSON-LD native Solid server", + ) .version(pkg.version); /** * 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('--multiuser', 'Enable multi-user mode') - .option('--no-multiuser', '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('--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', 'Enable Mashlib data browser (local mode, requires mashlib in node_modules)') - .option('--mashlib-cdn', 'Enable Mashlib data browser (CDN mode, no local files needed)') - .option('--no-mashlib', 'Disable Mashlib data browser') - .option('--mashlib-version ', 'Mashlib version for CDN mode (default: 2.0.0)') - .option('--solidos-ui', 'Enable modern Nextcloud-style UI (requires --mashlib)') - .option('--git', 'Enable Git HTTP backend (clone/push support)') - .option('--no-git', 'Disable Git HTTP backend') - .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('--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 ', 'Username for single-user mode (default: me)') - .option('--webid-tls', 'Enable WebID-TLS client certificate authentication') - .option('--no-webid-tls', 'Disable WebID-TLS authentication') - .option('-q, --quiet', 'Suppress log output') - .option('--print-config', 'Print configuration and exit') + .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("--multiuser", "Enable multi-user mode") + .option("--no-multiuser", "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("--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", + "Enable Mashlib data browser (local mode, requires mashlib in node_modules)", + ) + .option( + "--mashlib-cdn", + "Enable Mashlib data browser (CDN mode, no local files needed)", + ) + .option("--no-mashlib", "Disable Mashlib data browser") + .option( + "--mashlib-version ", + "Mashlib version for CDN mode (default: 2.0.0)", + ) + .option( + "--solidos-ui", + "Enable modern Nextcloud-style UI (requires --mashlib)", + ) + .option("--git", "Enable Git HTTP backend (clone/push support)") + .option("--no-git", "Disable Git HTTP backend") + .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("--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 ", + "Username for single-user mode (default: me)", + ) + .option("--webid-tls", "Enable WebID-TLS client certificate authentication") + .option("--no-webid-tls", "Disable WebID-TLS authentication") + .option("-q, --quiet", "Suppress log output") + .option("--print-config", "Print configuration and exit") .action(async (options) => { try { + // ADD VALIDATION CLI FOR SINGLE USERNAME + if (options.singleUserName && options.singleUserName.startsWith("--")) { + console.error( + `Error: --single-user-name value "${options.singleUserName}" looks like a flag.`, + ); + console.error( + "Did you forget to provide a username? Example: --single-user-name melvin", + ); + process.exit(1); + } + const config = await loadConfig(options, options.config); // Set DATA_ROOT env var so all modules use the same data directory @@ -91,13 +141,13 @@ program } // Determine IdP issuer URL - const protocol = config.ssl ? 'https' : 'http'; - const serverHost = config.host === '0.0.0.0' ? 'localhost' : config.host; + const protocol = config.ssl ? "https" : "http"; + const serverHost = config.host === "0.0.0.0" ? "localhost" : config.host; const baseUrl = `${protocol}://${serverHost}:${config.port}`; // Ensure issuer has trailing slash for CTH compatibility let idpIssuer = config.idpIssuer || baseUrl; - if (idpIssuer && !idpIssuer.endsWith('/')) { - idpIssuer = idpIssuer + '/'; + if (idpIssuer && !idpIssuer.endsWith("/")) { + idpIssuer = idpIssuer + "/"; } // Create and start server @@ -107,10 +157,12 @@ program notifications: config.notifications, idp: config.idp, idpIssuer: idpIssuer, - ssl: config.ssl ? { - key: await fs.readFile(config.sslKey), - cert: await fs.readFile(config.sslCert), - } : null, + 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, @@ -139,36 +191,44 @@ program 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.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.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.solidosUi) console.log(' SolidOS UI: enabled (modern interface)'); - if (config.git) console.log(' Git: enabled (clone/push support)'); + if (config.solidosUi) + console.log(" SolidOS UI: enabled (modern interface)"); + if (config.git) console.log(" Git: enabled (clone/push support)"); if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`); - 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)'); - console.log('\n Press Ctrl+C to stop\n'); + 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)"); + console.log("\n Press Ctrl+C to stop\n"); } // Handle shutdown const shutdown = async () => { - if (!config.quiet) console.log('\n Shutting down...'); + if (!config.quiet) console.log("\n Shutting down..."); await server.close(); process.exit(0); }; - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); - + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); } catch (err) { console.error(`Error: ${err.message}`); process.exit(1); @@ -179,19 +239,19 @@ program * 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') + .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?'); + const overwrite = options.yes ? true : await confirm("Overwrite?"); if (!overwrite) { - console.log('Aborted.'); + console.log("Aborted."); process.exit(0); } } @@ -203,32 +263,41 @@ program config = { ...defaults }; } else { // Interactive prompts - console.log('\n JavaScript Solid Server Setup\n'); + 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), + 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); + 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'); + 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); + config.idp = await confirm("Enable built-in Identity Provider?", false); if (config.idp) { - const customIssuer = await confirm('Use custom issuer URL?', false); + const customIssuer = await confirm("Use custom issuer URL?", false); if (customIssuer) { - config.idpIssuer = await prompt('IdP issuer URL', 'https://example.com'); + config.idpIssuer = await prompt( + "IdP issuer URL", + "https://example.com", + ); } } - console.log(''); + console.log(""); } // Save config @@ -240,22 +309,22 @@ program await fs.ensureDir(dataDir); console.log(`Data directory created: ${dataDir}`); - console.log('\nRun `jss start` to start the server.\n'); + 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'); + .command("invite") + .description("Manage invite codes for registration"); inviteCmd - .command('create') - .description('Create a new invite code') - .option('-u, --uses ', 'Maximum uses (default: 1)', parseInt, 1) - .option('-n, --note ', 'Optional note/description') - .option('-r, --root ', 'Data directory') + .command("create") + .description("Create a new invite code") + .option("-u, --uses ", "Maximum uses (default: 1)", parseInt, 1) + .option("-n, --note ", "Optional note/description") + .option("-r, --root ", "Data directory") .action(async (options) => { try { // Set DATA_ROOT if provided @@ -265,7 +334,7 @@ inviteCmd const { code, invite } = await createInvite({ maxUses: options.uses, - note: options.note || '' + note: options.note || "", }); console.log(`\nCreated invite code: ${code}`); @@ -275,7 +344,7 @@ inviteCmd if (invite.note) { console.log(`Note: ${invite.note}`); } - console.log(''); + console.log(""); } catch (err) { console.error(`Error: ${err.message}`); process.exit(1); @@ -283,9 +352,9 @@ inviteCmd }); inviteCmd - .command('list') - .description('List all invite codes') - .option('-r, --root ', 'Data directory') + .command("list") + .description("List all invite codes") + .option("-r, --root ", "Data directory") .action(async (options) => { try { // Set DATA_ROOT if provided @@ -296,20 +365,20 @@ inviteCmd const invites = await listInvites(); if (invites.length === 0) { - console.log('\nNo invite codes found.\n'); + console.log("\nNo invite codes found.\n"); return; } - console.log('\n CODE USES CREATED NOTE'); - console.log(' ' + '-'.repeat(55)); + 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 || ''; + const created = invite.created.split("T")[0]; + const note = invite.note || ""; console.log(` ${invite.code} ${uses} ${created} ${note}`); } - console.log(''); + console.log(""); } catch (err) { console.error(`Error: ${err.message}`); process.exit(1); @@ -317,9 +386,9 @@ inviteCmd }); inviteCmd - .command('revoke ') - .description('Revoke an invite code') - .option('-r, --root ', 'Data directory') + .command("revoke ") + .description("Revoke an invite code") + .option("-r, --root ", "Data directory") .action(async (code, options) => { try { // Set DATA_ROOT if provided @@ -345,13 +414,13 @@ inviteCmd * Quota command - manage storage quotas */ const quotaCmd = program - .command('quota') - .description('Manage storage quotas for pods'); + .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') + .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) { @@ -360,13 +429,15 @@ quotaCmd const bytes = parseSize(size); if (bytes === 0) { - console.error('Invalid size format. Use e.g., 50MB, 1GB'); + 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`); + 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); @@ -374,9 +445,9 @@ quotaCmd }); quotaCmd - .command('show ') - .description('Show quota info for a user') - .option('-r, --root ', 'Data directory') + .command("show ") + .description("Show quota info for a user") + .option("-r, --root ", "Data directory") .action(async (username, options) => { try { if (options.root) { @@ -401,9 +472,9 @@ quotaCmd }); quotaCmd - .command('reconcile ') - .description('Recalculate quota usage from actual disk usage') - .option('-r, --root ', 'Data directory') + .command("reconcile ") + .description("Recalculate quota usage from actual disk usage") + .option("-r, --root ", "Data directory") .action(async (username, options) => { try { if (options.root) { @@ -419,7 +490,9 @@ quotaCmd 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`); + console.log( + ` Usage: ${Math.round((quota.used / quota.limit) * 100)}%\n`, + ); } } catch (err) { console.error(`Error: ${err.message}`); @@ -433,16 +506,16 @@ quotaCmd async function prompt(question, defaultValue) { const rl = readline.createInterface({ input: process.stdin, - output: process.stdout + output: process.stdout, }); return new Promise((resolve) => { - const defaultStr = defaultValue !== undefined ? ` (${defaultValue})` : ''; + 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)) { + if (typeof defaultValue === "number" && !isNaN(value)) { resolve(parseInt(value, 10)); } else { resolve(value); @@ -457,18 +530,18 @@ async function prompt(question, defaultValue) { async function confirm(question, defaultValue = false) { const rl = readline.createInterface({ input: process.stdin, - output: process.stdout + output: process.stdout, }); return new Promise((resolve) => { - const hint = defaultValue ? '[Y/n]' : '[y/N]'; + const hint = defaultValue ? "[Y/n]" : "[y/N]"; rl.question(` ${question} ${hint}: `, (answer) => { rl.close(); const normalized = answer.trim().toLowerCase(); - if (normalized === '') { + if (normalized === "") { resolve(defaultValue); } else { - resolve(normalized === 'y' || normalized === 'yes'); + resolve(normalized === "y" || normalized === "yes"); } }); });