/ bin / update-cmd.js
update-cmd.js
  1  #!/usr/bin/env node
  2  'use strict'
  3  /* eslint-disable @typescript-eslint/no-require-imports */
  4  
  5  const { execSync, execFileSync } = require('node:child_process')
  6  const path = require('node:path')
  7  const {
  8    dependenciesChanged,
  9    detectPackageManager,
 10    getGlobalUpdateSpec,
 11    getInstallCommand,
 12  } = require('./package-manager.js')
 13  const {
 14    PACKAGE_NAME,
 15    detectGlobalInstallManagerForRoot,
 16    resolvePackageRoot,
 17  } = require('./install-root.js')
 18  
 19  const isWindows = process.platform === 'win32'
 20  
 21  const PKG_ROOT = resolvePackageRoot({
 22    moduleDir: __dirname,
 23    argv1: process.argv[1],
 24    cwd: process.cwd(),
 25  })
 26  const RELEASE_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/
 27  const FALLBACK_PACKAGE_MANAGER = detectPackageManager(PKG_ROOT)
 28  
 29  function run(cmd) {
 30    return execSync(cmd, { encoding: 'utf-8', cwd: PKG_ROOT, timeout: 60_000 }).trim()
 31  }
 32  
 33  function log(msg) {
 34    process.stdout.write(`[swarmclaw] ${msg}\n`)
 35  }
 36  
 37  function logError(msg) {
 38    process.stderr.write(`[swarmclaw] ${msg}\n`)
 39  }
 40  
 41  function getLatestStableTag() {
 42    const tags = run("git tag --list 'v*' --sort=-v:refname")
 43      .split('\n')
 44      .map((l) => l.trim())
 45      .filter(Boolean)
 46    return tags.find((t) => RELEASE_TAG_RE.test(t)) || null
 47  }
 48  
 49  function resolveRegistryPackageManager(execImpl = execFileSync) {
 50    return detectGlobalInstallManagerForRoot(PKG_ROOT, execImpl, process.env)
 51      || detectPackageManager(PKG_ROOT, process.env)
 52      || FALLBACK_PACKAGE_MANAGER
 53  }
 54  
 55  function rebuildStandaloneServer(
 56    execImpl = execFileSync,
 57    logger = { log, logError },
 58  ) {
 59    const serverCmdPath = path.join(PKG_ROOT, 'bin', 'server-cmd.js')
 60    logger.log('Rebuilding the standalone server bundle...')
 61    try {
 62      execImpl(process.execPath, [serverCmdPath, '--build'], {
 63        cwd: PKG_ROOT,
 64        stdio: 'inherit',
 65        timeout: 10 * 60_000,
 66      })
 67      logger.log('Standalone server bundle rebuilt.')
 68      return 0
 69    } catch (err) {
 70      logger.logError(`Standalone rebuild failed: ${err.message}`)
 71      logger.logError('Retry manually with: swarmclaw server --build')
 72      return 1
 73    }
 74  }
 75  
 76  function runRegistrySelfUpdate(
 77    packageManager = resolveRegistryPackageManager(),
 78    execImpl = execFileSync,
 79    logger = { log, logError },
 80  ) {
 81    const update = getGlobalUpdateSpec(packageManager, PACKAGE_NAME)
 82    logger.log(`No git checkout detected. Updating the global ${PACKAGE_NAME} install via ${packageManager}...`)
 83    try {
 84      execImpl(update.command, update.args, {
 85        cwd: PKG_ROOT,
 86        stdio: 'inherit',
 87        timeout: 120_000,
 88        ...(isWindows && { shell: true }),
 89      })
 90      logger.log(`Global update complete via ${packageManager}.`)
 91    } catch (err) {
 92      logger.logError(`Registry update failed: ${err.message}`)
 93      logger.logError(`Retry manually with: ${update.display}`)
 94      return 1
 95    }
 96  
 97    logger.log('Restart the server to apply changes: swarmclaw server stop && swarmclaw server start')
 98    return 0
 99  }
100  
101  function stopRunningServer(logger = { log, logError }) {
102    // Stop the server gracefully before updating to prevent SQLite WAL corruption.
103    try {
104      const serverCmd = path.join(PKG_ROOT, 'bin', 'swarmclaw.js')
105      const status = execFileSync(process.execPath, [serverCmd, 'status'], {
106        encoding: 'utf-8',
107        cwd: PKG_ROOT,
108        timeout: 10_000,
109      }).trim()
110      if (status.toLowerCase().includes('running')) {
111        logger.log('Stopping running server before update...')
112        execFileSync(process.execPath, [serverCmd, 'stop'], {
113          encoding: 'utf-8',
114          cwd: PKG_ROOT,
115          timeout: 15_000,
116        })
117        logger.log('Server stopped.')
118      }
119    } catch {
120      // Server may not be running or status command unavailable — continue.
121    }
122  }
123  
124  function main(args = process.argv.slice(3)) {
125    if (args.includes('-h') || args.includes('--help')) {
126      console.log(`
127  Usage: swarmclaw update
128  
129  If running from a git checkout, pull the latest SwarmClaw release tag.
130  If running from a registry install, update the global package with its owning package manager.
131  `.trim())
132      process.exit(0)
133    }
134  
135    try {
136      run('git rev-parse --git-dir')
137    } catch {
138      stopRunningServer()
139      process.exit(runRegistrySelfUpdate())
140    }
141  
142    stopRunningServer()
143  
144    const beforeRef = run('git rev-parse HEAD')
145    const beforeSha = run('git rev-parse --short HEAD')
146  
147    log('Fetching latest releases...')
148    try {
149      run('git fetch --tags origin --quiet')
150    } catch (err) {
151      logError(`Fetch failed: ${err.message}`)
152      process.exit(1)
153    }
154  
155    const latestTag = getLatestStableTag()
156    let channel = 'main'
157    let pullOutput = ''
158  
159    if (latestTag) {
160      channel = 'stable'
161      const targetSha = run(`git rev-parse ${latestTag}^{commit}`)
162      if (targetSha === beforeRef) {
163        log(`Already up to date (${latestTag}, ${beforeSha}).`)
164        process.exit(0)
165      }
166  
167      const dirty = run('git status --porcelain')
168      if (dirty) {
169        logError('Local changes detected. Commit or stash them first, then retry.')
170        process.exit(1)
171      }
172  
173      log(`Updating to ${latestTag}...`)
174      run(`git checkout -B stable ${latestTag}^{commit}`)
175      pullOutput = `Updated to stable release ${latestTag}.`
176    } else {
177      const behindCount = parseInt(run('git rev-list HEAD..origin/main --count'), 10) || 0
178      if (behindCount === 0) {
179        log(`Already up to date (${beforeSha}).`)
180        process.exit(0)
181      }
182  
183      const dirty = run('git status --porcelain')
184      if (dirty) {
185        logError('Local changes detected. Commit or stash them first, then retry.')
186        process.exit(1)
187      }
188  
189      log(`Pulling ${behindCount} commit(s) from origin/main...`)
190      pullOutput = run('git pull --ff-only origin main')
191    }
192  
193    const newSha = run('git rev-parse --short HEAD')
194    log(pullOutput)
195  
196    try {
197      const diff = run(`git diff --name-only ${beforeSha}..HEAD`)
198      if (dependenciesChanged(diff)) {
199        const packageManager = detectPackageManager(PKG_ROOT, process.env)
200        const install = getInstallCommand(packageManager, true)
201        log(`Package files changed — running ${packageManager} install...`)
202        execFileSync(install.command, install.args, { cwd: PKG_ROOT, stdio: 'inherit', timeout: 120_000, ...(isWindows && { shell: true }) })
203      }
204    } catch {
205      // If diff fails, skip install check.
206    }
207  
208    const rebuildExitCode = rebuildStandaloneServer()
209    if (rebuildExitCode !== 0) {
210      process.exit(rebuildExitCode)
211    }
212  
213    log(`Done (${beforeSha} → ${newSha}, channel: ${channel}).`)
214    log('Restart the server to apply changes: swarmclaw server stop && swarmclaw server start')
215  }
216  
217  if (require.main === module) {
218    main()
219  }
220  
221  module.exports = {
222    main,
223    rebuildStandaloneServer,
224    resolveRegistryPackageManager,
225    runRegistrySelfUpdate,
226  }