/ scripts / easy-update.mjs
easy-update.mjs
  1  #!/usr/bin/env node
  2  
  3  import fs from 'node:fs'
  4  import path from 'node:path'
  5  import { spawnSync } from 'node:child_process'
  6  
  7  const args = new Set(process.argv.slice(2))
  8  const skipBuild = args.has('--skip-build')
  9  const allowDirty = args.has('--allow-dirty')
 10  const forceMain = args.has('--main')
 11  const cwd = process.cwd()
 12  const RELEASE_TAG_RE = /^v\d+\.\d+\.\d+([-.+][0-9A-Za-z.-]+)?$/
 13  
 14  function log(message) {
 15    process.stdout.write(`[update] ${message}\n`)
 16  }
 17  
 18  function fail(message, code = 1) {
 19    process.stderr.write(`[update] ERROR: ${message}\n`)
 20    process.exit(code)
 21  }
 22  
 23  function run(command, commandArgs, options = {}) {
 24    const result = spawnSync(command, commandArgs, {
 25      cwd,
 26      encoding: 'utf8',
 27      ...options,
 28    })
 29    if (result.error) {
 30      return { ok: false, out: '', err: result.error.message, code: result.status ?? 1 }
 31    }
 32    if ((result.status ?? 1) !== 0) {
 33      const err = String(result.stderr || result.stdout || '').trim() || `exit ${result.status}`
 34      return { ok: false, out: '', err, code: result.status ?? 1 }
 35    }
 36    return { ok: true, out: String(result.stdout || '').trim(), err: '', code: 0 }
 37  }
 38  
 39  function runOrThrow(command, commandArgs, options = {}) {
 40    log(`$ ${command} ${commandArgs.join(' ')}`.trim())
 41    const result = spawnSync(command, commandArgs, {
 42      cwd,
 43      stdio: 'inherit',
 44      ...options,
 45    })
 46    if (result.error) fail(result.error.message)
 47    if ((result.status ?? 1) !== 0) {
 48      fail(`Command failed: ${command} ${commandArgs.join(' ')}`, result.status ?? 1)
 49    }
 50  }
 51  
 52  function runOptional(command, commandArgs, options = {}) {
 53    log(`$ ${command} ${commandArgs.join(' ')}`.trim())
 54    const result = spawnSync(command, commandArgs, {
 55      cwd,
 56      stdio: 'inherit',
 57      ...options,
 58    })
 59    if (result.error || (result.status ?? 1) !== 0) {
 60      log(`Optional step failed: ${command} ${commandArgs.join(' ')}`)
 61      return false
 62    }
 63    return true
 64  }
 65  
 66  function getLatestStableTag() {
 67    const tagList = run('git', ['tag', '--list', 'v*', '--sort=-v:refname'])
 68    if (!tagList.ok) return null
 69    const tags = tagList.out.split('\n').map((line) => line.trim()).filter(Boolean)
 70    return tags.find((tag) => RELEASE_TAG_RE.test(tag)) || null
 71  }
 72  
 73  function stopRunningServer() {
 74    // Try to stop the SwarmClaw server gracefully before updating.
 75    // An unclean shutdown while npm rebuild replaces native modules
 76    // can corrupt the SQLite WAL journal on Linux.
 77    const cliPath = path.join(cwd, 'bin', 'swarmclaw.js')
 78    if (!fs.existsSync(cliPath)) return
 79  
 80    const status = run('node', [cliPath, 'status'])
 81    if (!status.ok || !status.out.toLowerCase().includes('running')) return
 82  
 83    log('Stopping running SwarmClaw server before update...')
 84    const stop = run('node', [cliPath, 'stop'])
 85    if (stop.ok) {
 86      log('Server stopped.')
 87    } else {
 88      log('Could not stop the server automatically. Please stop it manually before updating.')
 89    }
 90  }
 91  
 92  function main() {
 93    const gitCheck = run('git', ['rev-parse', '--is-inside-work-tree'])
 94    if (!gitCheck.ok) {
 95      fail('This folder is not a git repository. Automatic updates require git.')
 96    }
 97  
 98    stopRunningServer()
 99  
100    const dirty = run('git', ['status', '--porcelain'])
101    const isDirty = !!dirty.out
102    if (isDirty && !allowDirty) {
103      const changed = dirty.out.split('\n').map((line) => line.trim()).filter(Boolean)
104      const preview = changed.slice(0, 20)
105      process.stdout.write(`${preview.join('\n')}\n`)
106      if (changed.length > preview.length) {
107        log(`...and ${changed.length - preview.length} more changed file(s).`)
108      }
109      fail('Local changes detected. Commit/stash them first, or rerun with --allow-dirty.')
110    }
111  
112    const beforeSha = run('git', ['rev-parse', '--short', 'HEAD'])
113    if (!beforeSha.ok || !beforeSha.out) {
114      fail('Could not resolve current git SHA.')
115    }
116  
117    runOrThrow('git', ['fetch', '--tags', 'origin', '--quiet'])
118  
119    let updateSource = 'main'
120    let pullOutput = ''
121    const latestTag = forceMain ? null : getLatestStableTag()
122  
123    if (latestTag) {
124      const behind = run('git', ['rev-list', `HEAD..${latestTag}^{commit}`, '--count'])
125      const behindBy = Number.parseInt(behind.out || '0', 10) || 0
126  
127      if (behindBy <= 0) {
128        log(`Already on latest stable release (${latestTag}) or newer.`)
129        return
130      }
131  
132      updateSource = `stable release ${latestTag}`
133      log(`Found ${behindBy} commit(s) behind ${latestTag}. Updating now...`)
134      runOrThrow('git', ['checkout', '-B', 'stable', `${latestTag}^{commit}`])
135      pullOutput = `Updated to ${latestTag}`
136    } else {
137      runOrThrow('git', ['fetch', 'origin', 'main', '--quiet'])
138      const behind = run('git', ['rev-list', 'HEAD..origin/main', '--count'])
139      const behindBy = Number.parseInt(behind.out || '0', 10) || 0
140  
141      if (behindBy <= 0) {
142        log('Already up to date. Nothing to install.')
143        return
144      }
145  
146      updateSource = 'main branch'
147      log(`Found ${behindBy} new commit(s) on origin/main. Updating now...`)
148      runOrThrow('git', ['pull', '--ff-only', 'origin', 'main'])
149      pullOutput = `Pulled origin/main (+${behindBy})`
150    }
151  
152    const changed = run('git', ['diff', '--name-only', `${beforeSha.out}..HEAD`])
153    const changedFiles = new Set((changed.out || '').split('\n').map((s) => s.trim()).filter(Boolean))
154    const depsChanged = changedFiles.has('package.json') || changedFiles.has('package-lock.json')
155  
156    if (depsChanged) {
157      runOrThrow('npm', ['install'])
158    } else {
159      log('No dependency changes detected. Skipping npm install.')
160    }
161  
162    if (!skipBuild) {
163      runOptional('node', ['./scripts/ensure-sandbox-browser-image.mjs'])
164      runOrThrow('npm', ['run', 'build'])
165    } else {
166      log('Skipping build step (--skip-build).')
167    }
168  
169    log('Update complete.')
170    log(`Source: ${updateSource}. ${pullOutput}`.trim())
171    log('Restart SwarmClaw to apply the new version.')
172  }
173  
174  main()