/ bin / daemon-cmd.js
daemon-cmd.js
  1  #!/usr/bin/env node
  2  'use strict'
  3  /* eslint-disable @typescript-eslint/no-require-imports */
  4  
  5  const crypto = require('node:crypto')
  6  const fs = require('node:fs')
  7  const net = require('node:net')
  8  const path = require('node:path')
  9  const { spawn } = require('node:child_process')
 10  
 11  const {
 12    BROWSER_PROFILES_DIR,
 13    DATA_DIR,
 14    PKG_ROOT,
 15    SWARMCLAW_HOME,
 16    WORKSPACE_DIR,
 17    resolvePackageBuildRoot,
 18  } = require('./server-cmd.js')
 19  
 20  function printHelp() {
 21    const help = `
 22  Usage: swarmclaw daemon run [options]
 23  
 24  Run the detached SwarmClaw runtime daemon outside the public web process.
 25  
 26  Options:
 27    -d, --detach      Start daemon in background
 28    --port <port>     Admin port to bind on localhost (default: random)
 29    --token <token>   Admin bearer token (default: random)
 30    -h, --help        Show this help message
 31  
 32  Other daemon controls remain available through the API-backed CLI:
 33    swarmclaw daemon status
 34    swarmclaw daemon start
 35    swarmclaw daemon stop
 36    swarmclaw daemon health-check
 37  `.trim()
 38    console.log(help)
 39  }
 40  
 41  function resolveRoot() {
 42    const buildRoot = process.env.SWARMCLAW_BUILD_ROOT || resolvePackageBuildRoot(PKG_ROOT)
 43    if (fs.existsSync(path.join(buildRoot, 'src', 'lib', 'server', 'daemon', 'daemon-runtime.ts'))) return buildRoot
 44    return PKG_ROOT
 45  }
 46  
 47  function resolveEntry(root) {
 48    const entry = path.join(root, 'src', 'lib', 'server', 'daemon', 'daemon-runtime.ts')
 49    if (!fs.existsSync(entry)) {
 50      throw new Error(`Daemon runtime entry not found at ${entry}`)
 51    }
 52    return entry
 53  }
 54  
 55  function reservePort() {
 56    return new Promise((resolve, reject) => {
 57      const server = net.createServer()
 58      server.once('error', reject)
 59      server.listen(0, '127.0.0.1', () => {
 60        const address = server.address()
 61        if (!address || typeof address === 'string') {
 62          server.close(() => reject(new Error('Failed to reserve daemon port.')))
 63          return
 64        }
 65        const port = address.port
 66        server.close((err) => {
 67          if (err) reject(err)
 68          else resolve(port)
 69        })
 70      })
 71    })
 72  }
 73  
 74  function buildEnv(root, port, token) {
 75    return {
 76      ...process.env,
 77      SWARMCLAW_HOME,
 78      DATA_DIR,
 79      WORKSPACE_DIR,
 80      BROWSER_PROFILES_DIR,
 81      SWARMCLAW_PACKAGE_ROOT: PKG_ROOT,
 82      SWARMCLAW_BUILD_ROOT: root,
 83      SWARMCLAW_RUNTIME_ROLE: 'daemon',
 84      SWARMCLAW_DAEMON_BACKGROUND_SERVICES: '1',
 85      SWARMCLAW_DAEMON_ADMIN_PORT: String(port),
 86      SWARMCLAW_DAEMON_ADMIN_TOKEN: token,
 87    }
 88  }
 89  
 90  async function runDaemon(options) {
 91    const root = resolveRoot()
 92    const entry = resolveEntry(root)
 93    const port = options.port || await reservePort()
 94    const token = options.token || crypto.randomBytes(24).toString('hex')
 95    const env = buildEnv(root, port, token)
 96    const args = ['--no-warnings', '--import', 'tsx', entry, '--port', String(port), '--token', token]
 97  
 98    if (options.detach) {
 99      const logPath = path.join(SWARMCLAW_HOME, 'daemon.log')
100      fs.mkdirSync(path.dirname(logPath), { recursive: true })
101      const logStream = fs.openSync(logPath, 'a')
102      const child = spawn(process.execPath, args, {
103        cwd: root,
104        detached: true,
105        env,
106        stdio: ['ignore', logStream, logStream],
107      })
108      child.unref()
109      console.log(`[swarmclaw] Daemon started in background (PID: ${child.pid})`)
110      console.log(`[swarmclaw] Admin port: ${port}`)
111      console.log(`[swarmclaw] Logs: ${logPath}`)
112      return
113    }
114  
115    console.log(`[swarmclaw] Starting daemon runtime on 127.0.0.1:${port}`)
116    const child = spawn(process.execPath, args, {
117      cwd: root,
118      env,
119      stdio: 'inherit',
120    })
121    child.on('exit', (code) => {
122      process.exit(code || 0)
123    })
124    for (const signal of ['SIGINT', 'SIGTERM']) {
125      process.on(signal, () => child.kill(signal))
126    }
127  }
128  
129  async function main(args = process.argv.slice(3)) {
130    let detach = false
131    let port = null
132    let token = ''
133    let command = 'run'
134  
135    for (let index = 0; index < args.length; index += 1) {
136      const arg = args[index]
137      if (arg === 'run') {
138        command = 'run'
139      } else if (arg === '-d' || arg === '--detach') {
140        detach = true
141      } else if (arg === '--port' && index + 1 < args.length) {
142        port = Number.parseInt(args[index + 1], 10)
143        index += 1
144      } else if (arg === '--token' && index + 1 < args.length) {
145        token = args[index + 1] || ''
146        index += 1
147      } else if (arg === '-h' || arg === '--help' || arg === 'help') {
148        printHelp()
149        return
150      } else {
151        throw new Error(`Unknown daemon argument: ${arg}`)
152      }
153    }
154  
155    if (command !== 'run') {
156      throw new Error(`Unsupported daemon command: ${command}`)
157    }
158  
159    await runDaemon({ detach, port, token })
160  }
161  
162  if (require.main === module) {
163    void main().catch((err) => {
164      console.error(`[swarmclaw] ${err?.message || String(err)}`)
165      process.exit(1)
166    })
167  }
168  
169  module.exports = { main }