server-cmd.js
1 #!/usr/bin/env node 2 'use strict' 3 /* eslint-disable @typescript-eslint/no-require-imports */ 4 5 const fs = require('node:fs') 6 const http = require('node:http') 7 const path = require('node:path') 8 const { spawn, execFileSync } = require('node:child_process') 9 const { 10 detectPackageManager, 11 getInstallCommand, 12 } = require('./package-manager.js') 13 const { 14 readPackageVersion, 15 resolvePackageRoot, 16 resolveStateHome, 17 } = require('./install-root.js') 18 19 // --------------------------------------------------------------------------- 20 // Paths 21 // --------------------------------------------------------------------------- 22 23 const PKG_ROOT = resolvePackageRoot({ 24 moduleDir: __dirname, 25 argv1: process.argv[1], 26 cwd: process.cwd(), 27 }) 28 const SWARMCLAW_HOME = resolveStateHome({ 29 pkgRoot: PKG_ROOT, 30 moduleDir: __dirname, 31 argv1: process.argv[1], 32 cwd: process.cwd(), 33 env: process.env, 34 }) 35 const PID_FILE = path.join(SWARMCLAW_HOME, 'server.pid') 36 const LOG_FILE = path.join(SWARMCLAW_HOME, 'server.log') 37 const DATA_DIR = path.join(SWARMCLAW_HOME, 'data') 38 const WORKSPACE_DIR = path.join(SWARMCLAW_HOME, 'workspace') 39 const BROWSER_PROFILES_DIR = path.join(SWARMCLAW_HOME, 'browser-profiles') 40 const BUILD_WORKSPACES_DIR = path.join(SWARMCLAW_HOME, 'builds') 41 42 // --------------------------------------------------------------------------- 43 // Helpers 44 // --------------------------------------------------------------------------- 45 46 function ensureDir(dir) { 47 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) 48 } 49 50 function resolveStandaloneRuntimeDir(serverJs) { 51 return path.dirname(serverJs) 52 } 53 54 function log(msg) { 55 process.stdout.write(`[swarmclaw] ${msg}\n`) 56 } 57 58 function logError(msg) { 59 process.stderr.write(`[swarmclaw] ${msg}\n`) 60 } 61 62 function readPid() { 63 try { 64 const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10) 65 return Number.isFinite(pid) ? pid : null 66 } catch { 67 return null 68 } 69 } 70 71 function isProcessRunning(pid) { 72 try { 73 process.kill(pid, 0) 74 return true 75 } catch { 76 return false 77 } 78 } 79 80 function resolveReadyCheckHost(host) { 81 if (host === '0.0.0.0') return '127.0.0.1' 82 if (host === '::') return '::1' 83 return host 84 } 85 86 function probeHttpReady(host, port, timeoutMs = 1_000) { 87 return new Promise((resolve) => { 88 const req = http.request( 89 { 90 host, 91 port: Number(port), 92 path: '/api/auth', 93 method: 'GET', 94 timeout: timeoutMs, 95 }, 96 (res) => { 97 res.resume() 98 resolve(res.statusCode >= 200 && res.statusCode < 500) 99 }, 100 ) 101 102 req.once('timeout', () => { 103 req.destroy() 104 resolve(false) 105 }) 106 req.once('error', () => resolve(false)) 107 req.end() 108 }) 109 } 110 111 async function waitForPortReady({ 112 host, 113 port, 114 timeoutMs = 30_000, 115 intervalMs = 250, 116 pid = null, 117 isProcessRunningFn = isProcessRunning, 118 probeFn = probeHttpReady, 119 } = {}) { 120 const readyHost = resolveReadyCheckHost(host) 121 const deadline = Date.now() + timeoutMs 122 123 while (Date.now() < deadline) { 124 if (pid && !isProcessRunningFn(pid)) { 125 throw new Error(`Detached server process ${pid} exited before becoming ready.`) 126 } 127 128 if (await probeFn(readyHost, port)) return 129 130 await new Promise((resolve) => setTimeout(resolve, intervalMs)) 131 } 132 133 throw new Error(`Timed out waiting for ${readyHost}:${port} to become ready.`) 134 } 135 136 function resolveStandaloneBase(pkgRoot = PKG_ROOT) { 137 return path.join(pkgRoot, '.next', 'standalone') 138 } 139 140 function isGitCheckout(pkgRoot = PKG_ROOT) { 141 return fs.existsSync(path.join(pkgRoot, '.git')) 142 } 143 144 function getVersion() { 145 return readPackageVersion(PKG_ROOT) || 'unknown' 146 } 147 148 function resolveInstalledNext(pkgRoot = PKG_ROOT) { 149 try { 150 const nextPackageJson = require.resolve('next/package.json', { paths: [pkgRoot] }) 151 const nextPackageDir = path.dirname(nextPackageJson) 152 return { 153 nextCli: path.join(nextPackageDir, 'dist', 'bin', 'next'), 154 nodeModulesDir: path.dirname(nextPackageDir), 155 } 156 } catch { 157 return null 158 } 159 } 160 161 const isWindows = process.platform === 'win32' 162 163 function ensurePackageDependencies(pkgRoot = PKG_ROOT) { 164 const resolved = resolveInstalledNext(pkgRoot) 165 if (resolved && fs.existsSync(resolved.nextCli)) return resolved 166 167 const packageManager = detectPackageManager(pkgRoot, process.env) 168 const install = getInstallCommand(packageManager) 169 log(`Installing dependencies with ${packageManager}...`) 170 execFileSync(install.command, install.args, { cwd: pkgRoot, stdio: 'inherit', ...(isWindows && { shell: true }) }) 171 172 const installed = resolveInstalledNext(pkgRoot) 173 if (installed && fs.existsSync(installed.nextCli)) return installed 174 175 throw new Error('Next.js CLI was not found after installing dependencies.') 176 } 177 178 function resolvePackageBuildRoot(pkgRoot = PKG_ROOT) { 179 if (isGitCheckout(pkgRoot)) return pkgRoot 180 const version = readPackageVersion(pkgRoot) || 'unknown' 181 return path.join(BUILD_WORKSPACES_DIR, `package-${version}`) 182 } 183 184 function copyBuildWorkspaceContents(sourceRoot, targetRoot) { 185 const excluded = new Set([ 186 '.git', 187 '.next', 188 'data', 189 'node_modules', 190 ]) 191 192 ensureDir(targetRoot) 193 194 for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) { 195 if (excluded.has(entry.name)) continue 196 197 const sourcePath = path.join(sourceRoot, entry.name) 198 const targetPath = path.join(targetRoot, entry.name) 199 fs.rmSync(targetPath, { recursive: true, force: true }) 200 fs.cpSync(sourcePath, targetPath, { 201 recursive: true, 202 force: true, 203 dereference: true, 204 }) 205 } 206 } 207 208 function symlinkDir(targetPath, linkPath) { 209 fs.rmSync(linkPath, { recursive: true, force: true }) 210 fs.symlinkSync(targetPath, linkPath, process.platform === 'win32' ? 'junction' : 'dir') 211 } 212 213 function syncStandaloneRuntimeAssets({ 214 sourceRoot, 215 runtimeDir, 216 force = false, 217 } = {}) { 218 const assets = [ 219 { 220 key: 'staticCopied', 221 sourcePath: path.join(sourceRoot, '.next', 'static'), 222 targetPath: path.join(runtimeDir, '.next', 'static'), 223 optional: false, 224 }, 225 { 226 key: 'publicCopied', 227 sourcePath: path.join(sourceRoot, 'public'), 228 targetPath: path.join(runtimeDir, 'public'), 229 optional: true, 230 }, 231 ] 232 233 const result = { 234 staticCopied: false, 235 publicCopied: false, 236 } 237 238 for (const asset of assets) { 239 if (!fs.existsSync(asset.sourcePath)) { 240 if (!asset.optional) result[asset.key] = false 241 continue 242 } 243 if (!force && fs.existsSync(asset.targetPath)) continue 244 245 ensureDir(path.dirname(asset.targetPath)) 246 fs.rmSync(asset.targetPath, { recursive: true, force: true }) 247 fs.cpSync(asset.sourcePath, asset.targetPath, { 248 recursive: true, 249 force: true, 250 dereference: true, 251 }) 252 result[asset.key] = true 253 } 254 255 return result 256 } 257 258 function prepareBuildWorkspace({ pkgRoot = PKG_ROOT, buildRoot = resolvePackageBuildRoot(pkgRoot), nodeModulesDir } = {}) { 259 copyBuildWorkspaceContents(pkgRoot, buildRoot) 260 symlinkDir(nodeModulesDir, path.join(buildRoot, 'node_modules')) 261 return buildRoot 262 } 263 264 function resolveStandaloneCandidateRoots(pkgRoot = PKG_ROOT) { 265 const roots = [pkgRoot] 266 const buildRoot = resolvePackageBuildRoot(pkgRoot) 267 if (buildRoot !== pkgRoot) roots.push(buildRoot) 268 return roots 269 } 270 271 function locateStandaloneServer({ pkgRoot = PKG_ROOT } = {}) { 272 for (const root of resolveStandaloneCandidateRoots(pkgRoot)) { 273 const standaloneBase = resolveStandaloneBase(root) 274 if (!fs.existsSync(standaloneBase)) continue 275 276 const direct = path.join(standaloneBase, 'server.js') 277 if (fs.existsSync(direct)) { 278 return { root, serverJs: direct } 279 } 280 281 function search(dir) { 282 const entries = fs.readdirSync(dir, { withFileTypes: true }) 283 for (const entry of entries) { 284 const full = path.join(dir, entry.name) 285 if (entry.isFile() && entry.name === 'server.js') return full 286 if (entry.isDirectory() && entry.name !== 'node_modules') { 287 const found = search(full) 288 if (found) return found 289 } 290 } 291 return null 292 } 293 294 const nested = search(standaloneBase) 295 if (nested) { 296 return { root, serverJs: nested } 297 } 298 } 299 300 return null 301 } 302 303 // --------------------------------------------------------------------------- 304 // Build 305 // --------------------------------------------------------------------------- 306 307 function needsBuild(forceBuild, { pkgRoot = PKG_ROOT } = {}) { 308 if (forceBuild) return true 309 return !findStandaloneServer({ pkgRoot }) 310 } 311 312 function runBuild({ pkgRoot = PKG_ROOT } = {}) { 313 log('Preparing build environment...') 314 ensureDir(SWARMCLAW_HOME) 315 ensureDir(DATA_DIR) 316 317 const { nextCli, nodeModulesDir } = ensurePackageDependencies(pkgRoot) 318 const buildRoot = resolvePackageBuildRoot(pkgRoot) 319 320 if (buildRoot !== pkgRoot) { 321 prepareBuildWorkspace({ pkgRoot, buildRoot, nodeModulesDir }) 322 log(`Using build workspace: ${buildRoot}`) 323 } 324 325 log('Building Next.js application (this may take a minute)...') 326 execFileSync(process.execPath, [nextCli, 'build', '--webpack'], { 327 cwd: buildRoot, 328 stdio: 'inherit', 329 env: { 330 ...process.env, 331 SWARMCLAW_HOME, 332 DATA_DIR, 333 SWARMCLAW_BUILD_MODE: '1', 334 }, 335 }) 336 337 const standalone = locateStandaloneServer({ pkgRoot: buildRoot }) 338 if (standalone) { 339 syncStandaloneRuntimeAssets({ 340 sourceRoot: buildRoot, 341 runtimeDir: resolveStandaloneRuntimeDir(standalone.serverJs), 342 force: true, 343 }) 344 } 345 346 log('Build complete.') 347 } 348 349 // --------------------------------------------------------------------------- 350 // Find standalone server.js 351 // --------------------------------------------------------------------------- 352 353 function findStandaloneServer({ pkgRoot = PKG_ROOT } = {}) { 354 return locateStandaloneServer({ pkgRoot })?.serverJs || null 355 } 356 357 // --------------------------------------------------------------------------- 358 // Start server 359 // --------------------------------------------------------------------------- 360 361 async function startServer(opts, { pkgRoot = PKG_ROOT } = {}) { 362 const standalone = locateStandaloneServer({ pkgRoot }) 363 if (!standalone) { 364 logError('Standalone server.js not found in the installed package. Try running: swarmclaw server --build') 365 process.exit(1) 366 } 367 const { root: buildRoot, serverJs } = standalone 368 const runtimeRoot = resolveStandaloneRuntimeDir(serverJs) 369 370 ensureDir(SWARMCLAW_HOME) 371 ensureDir(DATA_DIR) 372 syncStandaloneRuntimeAssets({ sourceRoot: buildRoot, runtimeDir: runtimeRoot }) 373 374 const port = opts.port || '3456' 375 const wsPort = opts.wsPort || String(Number(port) + 1) 376 const host = opts.host || '0.0.0.0' 377 378 const env = { 379 ...process.env, 380 SWARMCLAW_HOME, 381 DATA_DIR, 382 WORKSPACE_DIR, 383 BROWSER_PROFILES_DIR, 384 SWARMCLAW_PACKAGE_ROOT: pkgRoot, 385 SWARMCLAW_BUILD_ROOT: buildRoot, 386 SWARMCLAW_RUNTIME_ROLE: 'web', 387 HOSTNAME: host, 388 PORT: port, 389 WS_PORT: wsPort, 390 } 391 392 log(`Starting server on ${host}:${port} (WebSocket: ${wsPort})...`) 393 log(`Package root: ${pkgRoot}`) 394 log(`Build root: ${buildRoot}`) 395 log(`Runtime root: ${runtimeRoot}`) 396 log(`Home: ${SWARMCLAW_HOME}`) 397 log(`Data directory: ${DATA_DIR}`) 398 399 if (opts.detach) { 400 const logStream = fs.openSync(LOG_FILE, 'a') 401 const child = spawn(process.execPath, [serverJs], { 402 cwd: runtimeRoot, 403 detached: true, 404 env, 405 stdio: ['ignore', logStream, logStream], 406 }) 407 408 fs.writeFileSync(PID_FILE, String(child.pid)) 409 try { 410 await waitForPortReady({ host, port, pid: child.pid }) 411 child.unref() 412 log(`Server started in background (PID: ${child.pid})`) 413 log(`Logs: ${LOG_FILE}`) 414 process.exit(0) 415 } catch (err) { 416 try { 417 if (isProcessRunning(child.pid)) process.kill(child.pid, 'SIGTERM') 418 } catch {} 419 try { fs.unlinkSync(PID_FILE) } catch {} 420 logError(`Detached start failed: ${err.message}`) 421 logError(`Check logs: ${LOG_FILE}`) 422 process.exit(1) 423 } 424 } else { 425 const child = spawn(process.execPath, [serverJs], { 426 cwd: runtimeRoot, 427 env, 428 stdio: 'inherit', 429 }) 430 431 child.on('exit', (code) => { 432 process.exit(code || 0) 433 }) 434 435 for (const sig of ['SIGINT', 'SIGTERM']) { 436 process.on(sig, () => child.kill(sig)) 437 } 438 } 439 } 440 441 // --------------------------------------------------------------------------- 442 // Stop server 443 // --------------------------------------------------------------------------- 444 445 function stopServer() { 446 const pid = readPid() 447 if (!pid) { 448 log('No PID file found. Server may not be running in detached mode.') 449 return 450 } 451 452 if (!isProcessRunning(pid)) { 453 log(`Process ${pid} is not running. Cleaning up PID file.`) 454 try { fs.unlinkSync(PID_FILE) } catch {} 455 return 456 } 457 458 log(`Stopping server (PID: ${pid})...`) 459 try { 460 process.kill(pid, 'SIGTERM') 461 log('Server stopped.') 462 } catch (err) { 463 logError(`Failed to stop server: ${err.message}`) 464 } 465 try { fs.unlinkSync(PID_FILE) } catch {} 466 } 467 468 // --------------------------------------------------------------------------- 469 // Status 470 // --------------------------------------------------------------------------- 471 472 function showStatus() { 473 const pid = readPid() 474 if (!pid) { 475 log('Server: not running (no PID file)') 476 } else if (isProcessRunning(pid)) { 477 log(`Server: running (PID: ${pid})`) 478 } else { 479 log(`Server: not running (stale PID: ${pid})`) 480 try { fs.unlinkSync(PID_FILE) } catch {} 481 } 482 483 log(`Package: ${PKG_ROOT}`) 484 log(`Build workspace: ${resolvePackageBuildRoot()}`) 485 log(`Home: ${SWARMCLAW_HOME}`) 486 log(`Data: ${DATA_DIR}`) 487 log(`Workspace: ${WORKSPACE_DIR}`) 488 log(`Browser profiles: ${BROWSER_PROFILES_DIR}`) 489 log(`WebSocket port: ${process.env.WS_PORT || '(PORT + 1)'}`) 490 491 const serverJs = findStandaloneServer() 492 if (serverJs) { 493 log(`Built: yes (${serverJs})`) 494 } else { 495 log('Built: no') 496 } 497 } 498 499 // --------------------------------------------------------------------------- 500 // CLI parsing 501 // --------------------------------------------------------------------------- 502 503 function printHelp() { 504 const help = ` 505 Usage: swarmclaw server [command] [options] 506 507 Commands: 508 start Start the server (default) 509 stop Stop a detached server 510 status Show server status 511 512 Options: 513 --build Force rebuild before starting 514 -d, --detach Start server in background 515 --port <port> Server port (default: 3456) 516 --ws-port <port> WebSocket port (default: PORT + 1) 517 --host <host> Server host (default: 0.0.0.0) 518 -h, --help Show this help message 519 `.trim() 520 console.log(help) 521 } 522 523 async function main(args = process.argv.slice(3)) { 524 let command = 'start' 525 let forceBuild = false 526 let detach = false 527 let port = null 528 let wsPort = null 529 let host = null 530 531 for (let i = 0; i < args.length; i++) { 532 const arg = args[i] 533 if (arg === 'start') { 534 command = 'start' 535 } else if (arg === 'stop') { 536 command = 'stop' 537 } else if (arg === 'status') { 538 command = 'status' 539 } else if (arg === '--build') { 540 forceBuild = true 541 } else if (arg === '-d' || arg === '--detach') { 542 detach = true 543 } else if (arg === '--port' && i + 1 < args.length) { 544 port = args[++i] 545 } else if (arg === '--ws-port' && i + 1 < args.length) { 546 wsPort = args[++i] 547 } else if (arg === '--host' && i + 1 < args.length) { 548 host = args[++i] 549 } else if (arg === '-h' || arg === '--help') { 550 printHelp() 551 process.exit(0) 552 } else { 553 logError(`Unknown argument: ${arg}`) 554 printHelp() 555 process.exit(1) 556 } 557 } 558 559 if (command === 'stop') { 560 stopServer() 561 return 562 } 563 564 if (command === 'status') { 565 showStatus() 566 return 567 } 568 569 if (needsBuild(forceBuild)) { 570 if (!forceBuild) { 571 const installKind = isGitCheckout() ? 'checkout' : 'installed package' 572 log(`Standalone server bundle not found in this ${installKind}. Building locally...`) 573 } 574 try { 575 runBuild() 576 } catch (err) { 577 logError(`Build failed: ${err.message}`) 578 logError('Retry manually with: swarmclaw server --build') 579 process.exit(1) 580 } 581 } 582 583 await startServer({ port, wsPort, host, detach }) 584 } 585 586 if (require.main === module) { 587 void main().catch((err) => { 588 logError(err?.message || String(err)) 589 process.exit(1) 590 }) 591 } 592 593 module.exports = { 594 DATA_DIR, 595 BUILD_WORKSPACES_DIR, 596 BROWSER_PROFILES_DIR, 597 PKG_ROOT, 598 SWARMCLAW_HOME, 599 WORKSPACE_DIR, 600 findStandaloneServer, 601 getVersion, 602 isGitCheckout, 603 locateStandaloneServer, 604 main, 605 needsBuild, 606 prepareBuildWorkspace, 607 resolveInstalledNext, 608 resolvePackageBuildRoot, 609 resolveReadyCheckHost, 610 resolveStandaloneCandidateRoots, 611 resolveStandaloneBase, 612 resolveStandaloneRuntimeDir, 613 runBuild, 614 syncStandaloneRuntimeAssets, 615 waitForPortReady, 616 }