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()