/ bin / server-cmd.js
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  }