#!/usr/bin/env bun import { spawn, type ChildProcess } from 'node:child_process'; const args = process.argv.slice(2); await import('./server/env-resolve.ts'); function flag(name: string, short?: string): string | undefined { for (let i = 0; i < args.length; i++) { if (args[i] === `--${name}` || (short && args[i] === `-${short}`)) { return args[i + 1]; } } } if (args.includes('--help') || args.includes('-h')) { console.log(` shraga — multi-user Claude Code web UI Usage: shraga [options] Options: -p, --port Server port (default: 3032, or PORT env) -d, --data-dir Data directory (default: ./data, or DATA_DIR env) -h, --help Show this help Subcommands: ingress Run the host-header TCP router (INGRESS_PORT, default 3100) for previews + blue-green flips. Own process, survives restarts. user add Seed a local username/password user Any other subcommand is handed to SHRAGA_CLI_EXT, if set. Environment: CLOUDFLARE_TUNNEL_TOKEN If set, starts a Cloudflare Tunnel alongside the server. Get the token from Cloudflare Zero Trust > Tunnels > Configure. ANTHROPIC_API_KEY Claude API key (or use \`claude auth login\` for subscription auth) VITE_FIREBASE_CONFIG_PROD Firebase config JSON for auth (prod project) SHRAGA_CLI_EXT Absolute path to an add-on CLI module. Any subcommand this CLI does not own is handed to it before the server boots. See the CLI EXTENSION SEAM comment in src/cli.ts. `.trim()); process.exit(0); } /** Subcommands the core owns. Everything else is offered to SHRAGA_CLI_EXT (see below). */ const CORE_SUBCOMMANDS = new Set(['user', 'ingress']); const port = flag('port', 'p'); const dataDir = flag('data-dir', 'd'); if (port) process.env.PORT = port; if (dataDir) process.env.DATA_DIR = dataDir; // `shraga user add ` — seed a local (username/password) user. No server boot. if (args[0] === 'user' && args[1] === 'add') { const email = args[2]; const password = args[3] || process.env.SHRAGA_PASSWORD; if (!email || !password) { console.error('usage: shraga user add (or SHRAGA_PASSWORD env)'); process.exit(1); } const { addLocalUser } = await import('./server/auth.ts'); addLocalUser(email, password); console.log(`✅ added local user ${email}`); process.exit(0); } // ── CLI EXTENSION SEAM ─────────────────────────────────────────────────────── // // The twin of the `registerFeature` / SHRAGA_OVERLAY seam, for the command line. The core owns a // short, fixed list of subcommands; ANY other one is handed to the module named by SHRAGA_CLI_EXT, // so a downstream distribution can ship its own commands without the core naming them. // // CONTRACT. The module is imported for side effects, with `process.argv` untouched — it reads argv // itself, and calls `process.exit()` when it has handled the command. If it returns without // exiting, we fall through to the normal server boot, which is exactly what an unrecognised // subcommand does today. It runs AFTER env resolution (so DATA_DIR/PORT are settled and the // extension sees the same data dir the server would) and BEFORE any server boot. // // WHY AN ENV VAR AND NOT A REGISTRY. There is no server process to register against — this is a // one-shot CLI. The path must come from the deployment, and the deployment already sets DATA_DIR // and friends the same way. const cliExt = process.env.SHRAGA_CLI_EXT?.trim(); if (cliExt && args[0] && !args[0].startsWith('-') && !CORE_SUBCOMMANDS.has(args[0])) { const { pathToFileURL } = await import('node:url'); const { resolve } = await import('node:path'); try { await import(pathToFileURL(resolve(cliExt)).href); } catch (err) { console.error(`[cli-ext] failed to load ${cliExt}:`, (err as Error)?.stack || err); process.exit(1); } } // `shraga ingress` — host-header TCP router for previews + blue-green flips. // Runs as its OWN process (INGRESS_PORT), deliberately separate from the server so it // survives server restarts during a flip. Reads dataPath('ingress-router.json'). if (args[0] === 'ingress') { await import('./server/ingress-router.ts'); // ingress-router keeps the process alive via its listening socket; do not fall through. } else { let tunnel: ChildProcess | null = null; const tunnelToken = process.env.CLOUDFLARE_TUNNEL_TOKEN; if (tunnelToken) { console.log('[tunnel] Starting Cloudflare Tunnel...'); tunnel = spawn('cloudflared', ['tunnel', 'run', '--token', tunnelToken], { stdio: 'inherit', }); tunnel.on('error', (err) => { console.error(`[tunnel] Failed to start cloudflared: ${err.message}`); console.error('[tunnel] Install cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'); }); tunnel.on('exit', (code) => { if (code !== null && code !== 0) { console.error(`[tunnel] cloudflared exited with code ${code}`); } }); } function cleanup() { if (tunnel && !tunnel.killed) { console.log('[tunnel] Stopping Cloudflare Tunnel...'); tunnel.kill('SIGTERM'); } process.exit(0); } process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); // Dogfood the public library surface — the CLI's server-run path IS createShraga(...).start(). const { createShraga, fromEnv } = await import('./index.ts'); await createShraga(fromEnv()).start(); } // end non-ingress server path