@polderlabs/bizar 4.4.6 → 4.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/bin.mjs +39 -30
- package/cli/install.mjs +13 -51
- package/cli/provision.mjs +967 -0
- package/cli/update.mjs +28 -878
- package/install.sh +226 -439
- package/package.json +1 -1
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/provision.mjs
|
|
3
|
+
*
|
|
4
|
+
* v4.4.7 — Unified installer + updater.
|
|
5
|
+
*
|
|
6
|
+
* `bizar install` and `bizar update` used to be two separate code paths
|
|
7
|
+
* (install.sh + cli/install.mjs for install, cli/update.mjs for update)
|
|
8
|
+
* with massive overlap: both copied agents, both patched opencode.json,
|
|
9
|
+
* both ran the plugin copy, both kicked off the service, both called
|
|
10
|
+
* `bizar doctor` at the end. The two paths diverged over time, and the
|
|
11
|
+
* user-visible bug was that `update` tried to install separate npm
|
|
12
|
+
* packages (@polderlabs/bizar-plugin, @polderlabs/bizar-dash) that no
|
|
13
|
+
* longer exist.
|
|
14
|
+
*
|
|
15
|
+
* This module is the single source of truth. Both `bizar install` and
|
|
16
|
+
* `bizar update` call `runProvision({ mode, ... })`. The differences
|
|
17
|
+
* between modes are explicit and small:
|
|
18
|
+
*
|
|
19
|
+
* install: bootstrap a fresh install. Detects what exists, installs
|
|
20
|
+
* everything that's missing, configures the service, runs
|
|
21
|
+
* doctor. Does NOT kill running instances (fresh install
|
|
22
|
+
* has none).
|
|
23
|
+
* update: refresh an existing install. Kills running instances,
|
|
24
|
+
* upgrades @polderlabs/bizar + opencode-ai via npm, re-copies
|
|
25
|
+
* agent files / plugin / skills, re-patches opencode.json
|
|
26
|
+
* (idempotent), restarts the dashboard, runs doctor.
|
|
27
|
+
*
|
|
28
|
+
* Both modes are safe to re-run — every step is idempotent and skips
|
|
29
|
+
* work that's already done.
|
|
30
|
+
*
|
|
31
|
+
* Public API:
|
|
32
|
+
* runProvision({ mode, dryRun, force, restart, ...flags })
|
|
33
|
+
* Runs the full provision flow for `mode` ('install' | 'update').
|
|
34
|
+
* detectState()
|
|
35
|
+
* Probe what's installed without modifying anything.
|
|
36
|
+
* Returns a structured state object.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import chalk from 'chalk';
|
|
40
|
+
import { execSync, spawn, spawnSync } from 'node:child_process';
|
|
41
|
+
import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
42
|
+
import { homedir } from 'node:os';
|
|
43
|
+
import { join, dirname } from 'node:path';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
|
|
46
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
47
|
+
const __dirname = dirname(__filename);
|
|
48
|
+
|
|
49
|
+
// `cli/provision.mjs` lives at `<pkg>/cli/provision.mjs`. The repo root
|
|
50
|
+
// (where `plugins/bizar/`, `bizar-dash/`, `package.json` etc. live) is
|
|
51
|
+
// one level up. This works both in source checkouts AND in global npm
|
|
52
|
+
// installs (`<npm root -g>/@polderlabs/bizar/cli/provision.mjs`).
|
|
53
|
+
export const REPO_ROOT = join(__dirname, '..');
|
|
54
|
+
export const PKG_MAIN = '@polderlabs/bizar';
|
|
55
|
+
|
|
56
|
+
const HOME = homedir();
|
|
57
|
+
|
|
58
|
+
function bizarConfigDir() {
|
|
59
|
+
if (process.platform === 'win32') {
|
|
60
|
+
return process.env.APPDATA
|
|
61
|
+
? join(process.env.APPDATA, 'bizar')
|
|
62
|
+
: join(HOME, '.config', 'bizar');
|
|
63
|
+
}
|
|
64
|
+
return process.env.XDG_CONFIG_HOME
|
|
65
|
+
? join(process.env.XDG_CONFIG_HOME, 'bizar')
|
|
66
|
+
: join(HOME, '.config', 'bizar');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const BIZAR_HOME = bizarConfigDir();
|
|
70
|
+
export const OPENCODE_DIR =
|
|
71
|
+
process.platform === 'win32'
|
|
72
|
+
? join(process.env.APPDATA || HOME, 'opencode')
|
|
73
|
+
: join(process.env.XDG_CONFIG_HOME || join(HOME, '.config'), 'opencode');
|
|
74
|
+
|
|
75
|
+
const SERVICE_PID_FILE = join(BIZAR_HOME, 'service.pid');
|
|
76
|
+
const DASHBOARD_PID_FILE = join(BIZAR_HOME, 'dashboard.pid');
|
|
77
|
+
const DASHBOARD_PORT_FILE = join(BIZAR_HOME, 'dashboard.port');
|
|
78
|
+
|
|
79
|
+
// ─── Tiny utilities ──────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function haveCmd(cmd) {
|
|
82
|
+
try {
|
|
83
|
+
execSync(`command -v ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readTextSafe(file, fallback = '') {
|
|
91
|
+
try {
|
|
92
|
+
if (!existsSync(file)) return fallback;
|
|
93
|
+
return readFileSync(file, 'utf8');
|
|
94
|
+
} catch {
|
|
95
|
+
return fallback;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readJsonSafe(file, fallback = null) {
|
|
100
|
+
try {
|
|
101
|
+
if (!existsSync(file)) return fallback;
|
|
102
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
103
|
+
} catch {
|
|
104
|
+
return fallback;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read a PID file and return the live PID, or null if missing/stale. */
|
|
109
|
+
export function readLivePid(pidFile) {
|
|
110
|
+
if (!existsSync(pidFile)) return null;
|
|
111
|
+
const raw = readTextSafe(pidFile).trim();
|
|
112
|
+
if (!raw) {
|
|
113
|
+
try { rmSync(pidFile, { force: true }); } catch { /* ignore */ }
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const pid = parseInt(raw, 10);
|
|
117
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
118
|
+
try { rmSync(pidFile, { force: true }); } catch { /* ignore */ }
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
process.kill(pid, 0);
|
|
123
|
+
return pid;
|
|
124
|
+
} catch {
|
|
125
|
+
try { rmSync(pidFile, { force: true }); } catch { /* ignore */ }
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Send SIGTERM, wait, then SIGKILL if needed. Cross-platform. */
|
|
131
|
+
export async function killAndWait(pid, { timeoutMs = 5000, label = 'process' } = {}) {
|
|
132
|
+
if (!pid) return true;
|
|
133
|
+
let sigtermOk = false;
|
|
134
|
+
try {
|
|
135
|
+
process.kill(pid);
|
|
136
|
+
sigtermOk = true;
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err.code === 'ESRCH') return true;
|
|
139
|
+
console.log(chalk.yellow(` ! could not signal ${label} (pid ${pid}): ${err.message}`));
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
const start = Date.now();
|
|
143
|
+
let sawExit = false;
|
|
144
|
+
while (Date.now() - start < timeoutMs) {
|
|
145
|
+
try {
|
|
146
|
+
process.kill(pid, 0);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (err.code === 'ESRCH') { sawExit = true; break; }
|
|
149
|
+
}
|
|
150
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
151
|
+
}
|
|
152
|
+
if (sawExit) return true;
|
|
153
|
+
try {
|
|
154
|
+
if (process.platform === 'win32') {
|
|
155
|
+
spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], { stdio: 'ignore' });
|
|
156
|
+
} else {
|
|
157
|
+
process.kill(pid, 'SIGKILL');
|
|
158
|
+
}
|
|
159
|
+
if (sigtermOk) {
|
|
160
|
+
console.log(chalk.yellow(` ! ${label} (pid ${pid}) did not exit gracefully; sent forced kill`));
|
|
161
|
+
}
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
163
|
+
return true;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (err.code === 'ESRCH') return true;
|
|
166
|
+
console.log(chalk.red(` ✗ could not force-kill ${label} (pid ${pid}): ${err.message}`));
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── State detection ──────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Probe the current state without modifying anything. Both install and
|
|
175
|
+
* update flows start here so they can decide what to skip.
|
|
176
|
+
*
|
|
177
|
+
* Returned shape:
|
|
178
|
+
* {
|
|
179
|
+
* pkgRoot: string, // <npm root -g>/@polderlabs/bizar
|
|
180
|
+
* pkgVersion: string|null, // installed @polderlabs/bizar version
|
|
181
|
+
* pkgLatest: string|null, // latest @polderlabs/bizar version on npm
|
|
182
|
+
* plugin: { sourceDir, destDir, installed, upToDate, symlink },
|
|
183
|
+
* opencodeJson: { path, hasPluginEntry, exists },
|
|
184
|
+
* service: { installed, running, unitPath },
|
|
185
|
+
* dashboard: { running, pid, port },
|
|
186
|
+
* opencodeCli: { version, latest },
|
|
187
|
+
* headsUpState: { ok, blockerCount, warningCount },
|
|
188
|
+
* gitRepo: boolean, // are we running from a git checkout?
|
|
189
|
+
* }
|
|
190
|
+
*/
|
|
191
|
+
export function detectState({ cwd = process.cwd() } = {}) {
|
|
192
|
+
// ── npm-global package location ─────────────────────────────────────
|
|
193
|
+
let globalRoot = null;
|
|
194
|
+
try {
|
|
195
|
+
globalRoot = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
196
|
+
.toString().trim();
|
|
197
|
+
} catch { /* ignore */ }
|
|
198
|
+
|
|
199
|
+
const pkgRoot = globalRoot ? join(globalRoot, '@polderlabs', 'bizar') : null;
|
|
200
|
+
const pkgVersion = globalRoot ? currentVersion(PKG_MAIN) : null;
|
|
201
|
+
const pkgLatest = latestVersion(PKG_MAIN);
|
|
202
|
+
|
|
203
|
+
// ── Plugin copy (deployed to ~/.config/opencode/plugins/bizar) ──
|
|
204
|
+
const pluginSourceDir = pkgRoot ? join(pkgRoot, 'plugins', 'bizar') : null;
|
|
205
|
+
const pluginDestDir = join(OPENCODE_DIR, 'plugins', 'bizar');
|
|
206
|
+
let pluginInstalled = false;
|
|
207
|
+
let pluginUpToDate = false;
|
|
208
|
+
let pluginSymlink = false;
|
|
209
|
+
try {
|
|
210
|
+
const st = statSync(pluginDestDir);
|
|
211
|
+
pluginSymlink = st.isSymbolicLink();
|
|
212
|
+
pluginInstalled = true;
|
|
213
|
+
if (pluginSourceDir && existsSync(pluginSourceDir)) {
|
|
214
|
+
// Cheap freshness check: compare source vs dest mtime. The plugin
|
|
215
|
+
// source ships its bundled node_modules + compiled JS; if dest's
|
|
216
|
+
// mtime is older than source's, treat as out of date.
|
|
217
|
+
try {
|
|
218
|
+
const srcSt = statSync(pluginSourceDir);
|
|
219
|
+
const dstSt = statSync(pluginDestDir);
|
|
220
|
+
pluginUpToDate = srcSt.mtimeMs <= dstSt.mtimeMs;
|
|
221
|
+
} catch {
|
|
222
|
+
pluginUpToDate = false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
pluginInstalled = false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── opencode.json plugin entry ───────────────────────────────────────
|
|
230
|
+
const opencodeJsonPath = join(OPENCODE_DIR, 'opencode.json');
|
|
231
|
+
const opencodeJson = readJsonSafe(opencodeJsonPath, null);
|
|
232
|
+
let hasPluginEntry = false;
|
|
233
|
+
if (opencodeJson && Array.isArray(opencodeJson.plugin)) {
|
|
234
|
+
hasPluginEntry = opencodeJson.plugin.some(
|
|
235
|
+
(p) => Array.isArray(p) && typeof p[0] === 'string' && p[0].includes('plugins/bizar'),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Background service ─────────────────────────────────────────────
|
|
240
|
+
let serviceInstalled = false;
|
|
241
|
+
let serviceUnitPath = null;
|
|
242
|
+
if (pkgRoot) {
|
|
243
|
+
try {
|
|
244
|
+
const sc = require_safe(join(pkgRoot, 'cli', 'service-controller.mjs'));
|
|
245
|
+
serviceInstalled = sc?.isInstalled?.() ?? false;
|
|
246
|
+
serviceUnitPath = sc?.serviceUnitPath?.() ?? null;
|
|
247
|
+
} catch { /* ignore */ }
|
|
248
|
+
}
|
|
249
|
+
const servicePid = readLivePid(SERVICE_PID_FILE);
|
|
250
|
+
const serviceRunning = servicePid !== null;
|
|
251
|
+
|
|
252
|
+
// ── Dashboard process ──────────────────────────────────────────────
|
|
253
|
+
const dashboardPid = readLivePid(DASHBOARD_PID_FILE);
|
|
254
|
+
const dashboardPort = parseInt(readTextSafe(DASHBOARD_PORT_FILE, '').trim(), 10) || null;
|
|
255
|
+
|
|
256
|
+
// ── opencode CLI version ───────────────────────────────────────────
|
|
257
|
+
const opencodeCli = {
|
|
258
|
+
version: currentVersion('opencode-ai'),
|
|
259
|
+
latest: latestVersion('opencode-ai'),
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// ── Heads-up gate (.bizar/PRE_PUSH_NOTES.md) ───────────────────────
|
|
263
|
+
let headsUpState = { ok: true, blockerCount: 0, warningCount: 0 };
|
|
264
|
+
try {
|
|
265
|
+
const { checkHeadsUps, findBizarDir } = require_safe('./heads-up.mjs');
|
|
266
|
+
const bizarDir = findBizarDir(cwd);
|
|
267
|
+
if (bizarDir && checkHeadsUps) {
|
|
268
|
+
headsUpState = checkHeadsUps(bizarDir);
|
|
269
|
+
}
|
|
270
|
+
} catch { /* ignore */ }
|
|
271
|
+
|
|
272
|
+
// ── Git checkout? ─────────────────────────────────────────────────
|
|
273
|
+
const gitRepo = existsSync(join(REPO_ROOT, '.git'));
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
pkgRoot,
|
|
277
|
+
pkgVersion,
|
|
278
|
+
pkgLatest,
|
|
279
|
+
plugin: {
|
|
280
|
+
sourceDir: pluginSourceDir,
|
|
281
|
+
destDir: pluginDestDir,
|
|
282
|
+
installed: pluginInstalled,
|
|
283
|
+
upToDate: pluginUpToDate,
|
|
284
|
+
symlink: pluginSymlink,
|
|
285
|
+
},
|
|
286
|
+
opencodeJson: {
|
|
287
|
+
path: opencodeJsonPath,
|
|
288
|
+
exists: !!opencodeJson,
|
|
289
|
+
hasPluginEntry,
|
|
290
|
+
},
|
|
291
|
+
service: {
|
|
292
|
+
installed: serviceInstalled,
|
|
293
|
+
running: serviceRunning,
|
|
294
|
+
pid: servicePid,
|
|
295
|
+
unitPath: serviceUnitPath,
|
|
296
|
+
},
|
|
297
|
+
dashboard: {
|
|
298
|
+
running: dashboardPid !== null,
|
|
299
|
+
pid: dashboardPid,
|
|
300
|
+
port: dashboardPort,
|
|
301
|
+
},
|
|
302
|
+
opencodeCli,
|
|
303
|
+
headsUpState,
|
|
304
|
+
gitRepo,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Lightweight CommonJS-ish require for ESM contexts. Used to detect the
|
|
310
|
+
* service-controller + heads-up modules without paying the static-import
|
|
311
|
+
* cost when those features aren't needed. Falls back gracefully if the
|
|
312
|
+
* import fails (e.g. during `bizar install` from a corrupted package).
|
|
313
|
+
*/
|
|
314
|
+
async function require_safe(spec) {
|
|
315
|
+
try {
|
|
316
|
+
const url = new URL(spec, `file://${__dirname}/`).href;
|
|
317
|
+
return await import(url);
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ─── Version + npm helpers ───────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
export function currentVersion(pkg) {
|
|
326
|
+
try {
|
|
327
|
+
const out = execSync(`npm ls -g ${pkg} --depth=0 --json`, {
|
|
328
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
329
|
+
timeout: 15000,
|
|
330
|
+
}).toString();
|
|
331
|
+
const parsed = JSON.parse(out);
|
|
332
|
+
const deps = parsed.dependencies ?? {};
|
|
333
|
+
return deps[pkg]?.version ?? null;
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function latestVersion(pkg) {
|
|
340
|
+
try {
|
|
341
|
+
const out = execSync(`npm view ${pkg} version`, {
|
|
342
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
343
|
+
timeout: 15000,
|
|
344
|
+
}).toString().trim();
|
|
345
|
+
return out || null;
|
|
346
|
+
} catch {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Print the installed-vs-latest version matrix in a stable column layout.
|
|
353
|
+
*/
|
|
354
|
+
export function printVersionMatrix(components) {
|
|
355
|
+
const colWidth = Math.max(8, ...components.map((c) => c.label.length));
|
|
356
|
+
for (const c of components) {
|
|
357
|
+
const cur = c.current ?? '(not installed)';
|
|
358
|
+
const lat = c.latest ?? '(unknown)';
|
|
359
|
+
const same = c.current && c.current === c.latest;
|
|
360
|
+
const marker = same ? chalk.green('✓ up to date') : chalk.yellow('⤵ update available');
|
|
361
|
+
console.log(` ${c.label.padEnd(colWidth)} ${cur.padEnd(15)} → ${lat} ${marker}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ─── Step implementations ───────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Ensure @polderlabs/bizar is installed via npm. Returns
|
|
369
|
+
* `{ ok, message, installed: <version|null> }`.
|
|
370
|
+
*
|
|
371
|
+
* `mode`:
|
|
372
|
+
* 'install' — install if missing; never upgrade an existing install
|
|
373
|
+
* 'update' — install the latest version (upgrade or install)
|
|
374
|
+
*/
|
|
375
|
+
export async function ensureNpmPackage(pkg, { mode, dryRun, force }) {
|
|
376
|
+
const current = currentVersion(pkg);
|
|
377
|
+
const latest = latestVersion(pkg);
|
|
378
|
+
|
|
379
|
+
if (mode === 'install' && current && !force) {
|
|
380
|
+
return { ok: true, message: `${pkg}@${current} already installed`, installed: current };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (current && current === latest && !force) {
|
|
384
|
+
return { ok: true, message: `${pkg}@${current} already up to date`, installed: current };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (dryRun) {
|
|
388
|
+
return {
|
|
389
|
+
ok: true,
|
|
390
|
+
message: `[dry-run] would run: npm install -g ${pkg}${latest ? `@${latest}` : '@latest'}`,
|
|
391
|
+
installed: latest ?? current,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// If we're inside a `bizar update`, the dashboard service / dashboard
|
|
396
|
+
// processes are reading files inside the npm-global install dir. We
|
|
397
|
+
// can't replace those files atomically while they're open. The caller
|
|
398
|
+
// is expected to have already killed them via ensureInstancesKilled().
|
|
399
|
+
const r = spawnSync('npm', ['install', '-g', `${pkg}@latest`], { stdio: 'inherit' });
|
|
400
|
+
if (r.status !== 0) {
|
|
401
|
+
return { ok: false, message: `${pkg} install failed`, installed: current };
|
|
402
|
+
}
|
|
403
|
+
return { ok: true, message: `${pkg} updated`, installed: latestVersion(pkg) };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function updateOpencodeCli({ dryRun, force }) {
|
|
407
|
+
const current = currentVersion('opencode-ai');
|
|
408
|
+
const latest = latestVersion('opencode-ai');
|
|
409
|
+
|
|
410
|
+
if (current && current === latest && !force) {
|
|
411
|
+
return { ok: true, message: `opencode-ai@${current} up to date`, installed: current };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (dryRun) {
|
|
415
|
+
return { ok: true, message: '[dry-run] opencode-ai upgrade' };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Prefer the upstream installer (`opencode upgrade`). Falls back to npm.
|
|
419
|
+
const r1 = spawnSync('opencode', ['upgrade'], { stdio: 'inherit' });
|
|
420
|
+
if (r1.status === 0) {
|
|
421
|
+
return { ok: true, message: 'opencode updated via `opencode upgrade`' };
|
|
422
|
+
}
|
|
423
|
+
console.log(chalk.dim(' opencode upgrade not available; falling back to npm'));
|
|
424
|
+
const r2 = spawnSync('npm', ['install', '-g', 'opencode-ai@latest'], { stdio: 'inherit' });
|
|
425
|
+
if (r2.status === 0) {
|
|
426
|
+
return { ok: true, message: 'opencode updated via npm' };
|
|
427
|
+
}
|
|
428
|
+
return { ok: false, message: 'opencode update failed' };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Copy `plugins/bizar/` from the npm-installed package into
|
|
433
|
+
* `~/.config/opencode/plugins/bizar/`. Skips if the dest is a dev symlink
|
|
434
|
+
* (set by `bizar dev-link`). Idempotent — safe to re-run.
|
|
435
|
+
*/
|
|
436
|
+
export async function copyPluginToOpencode({ dryRun, force }) {
|
|
437
|
+
const state = detectState();
|
|
438
|
+
const src = state.plugin.sourceDir;
|
|
439
|
+
const dest = state.plugin.destDir;
|
|
440
|
+
|
|
441
|
+
if (!src || !existsSync(src)) {
|
|
442
|
+
return {
|
|
443
|
+
ok: false,
|
|
444
|
+
message: `plugin source not found at ${src ?? '(unknown)'} — reinstall @polderlabs/bizar`,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (state.plugin.symlink && !force) {
|
|
449
|
+
return {
|
|
450
|
+
ok: true,
|
|
451
|
+
message: `plugin dest is a dev symlink — skipping copy (use \`bizar dev-unlink\` to restore)`,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (state.plugin.upToDate && !force && state.plugin.installed) {
|
|
456
|
+
return { ok: true, message: 'plugin copy is up to date' };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (dryRun) {
|
|
460
|
+
return { ok: true, message: `[dry-run] would copy ${src} → ${dest}` };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (state.plugin.symlink && force) {
|
|
464
|
+
try { rmSync(dest, { force: true }); } catch { /* ignore */ }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const { cp } = await import('node:fs/promises');
|
|
468
|
+
try {
|
|
469
|
+
mkdirSync(dest, { recursive: true });
|
|
470
|
+
await cp(src, dest, {
|
|
471
|
+
recursive: true,
|
|
472
|
+
filter: (p) => !p.includes('node_modules') && !p.includes('dist') && !p.endsWith('.DS_Store'),
|
|
473
|
+
});
|
|
474
|
+
// Copy the SDK into the deployed plugin's node_modules so Bun can
|
|
475
|
+
// resolve @polderlabs/bizar-sdk when loading the plugin from
|
|
476
|
+
// ~/.config/opencode/plugins/bizar/.
|
|
477
|
+
const sdkSrc = join(state.pkgRoot, 'node_modules', '@polderlabs', 'bizar-sdk');
|
|
478
|
+
const sdkDst = join(dest, 'node_modules', '@polderlabs', 'bizar-sdk');
|
|
479
|
+
if (existsSync(sdkSrc)) {
|
|
480
|
+
mkdirSync(join(dest, 'node_modules', '@polderlabs'), { recursive: true });
|
|
481
|
+
await cp(sdkSrc, sdkDst, { recursive: true });
|
|
482
|
+
}
|
|
483
|
+
return { ok: true, message: `plugin copied to ${dest}` };
|
|
484
|
+
} catch (err) {
|
|
485
|
+
return { ok: false, message: `plugin copy failed: ${err.message}` };
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Ensure the Bizar plugin entry exists in `~/.config/opencode/opencode.json`.
|
|
491
|
+
* Idempotent: if the entry already exists, no-op.
|
|
492
|
+
*/
|
|
493
|
+
export async function patchOpencodeJson({ dryRun, force }) {
|
|
494
|
+
const cfgPath = join(OPENCODE_DIR, 'opencode.json');
|
|
495
|
+
if (!existsSync(cfgPath)) {
|
|
496
|
+
if (dryRun) {
|
|
497
|
+
return { ok: true, message: `[dry-run] would bootstrap ${cfgPath}` };
|
|
498
|
+
}
|
|
499
|
+
mkdirSync(OPENCODE_DIR, { recursive: true });
|
|
500
|
+
const templateSrc = join(REPO_ROOT, 'config', 'opencode.json');
|
|
501
|
+
if (existsSync(templateSrc)) {
|
|
502
|
+
const { copyFileSync } = await import('node:fs');
|
|
503
|
+
copyFileSync(templateSrc, cfgPath);
|
|
504
|
+
return { ok: true, message: `${cfgPath} bootstrapped from package template` };
|
|
505
|
+
}
|
|
506
|
+
writeFileSync(cfgPath, JSON.stringify({
|
|
507
|
+
$schema: 'https://opencode.ai/config.json',
|
|
508
|
+
plugin: [],
|
|
509
|
+
}, null, 2));
|
|
510
|
+
return { ok: true, message: `${cfgPath} created` };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// File exists. Check whether the Bizar entry is already there.
|
|
514
|
+
const cfg = readJsonSafe(cfgPath, null);
|
|
515
|
+
if (!cfg || typeof cfg !== 'object') {
|
|
516
|
+
return { ok: false, message: `${cfgPath} is not valid JSON` };
|
|
517
|
+
}
|
|
518
|
+
const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : [];
|
|
519
|
+
const hasEntry = plugins.some(
|
|
520
|
+
(p) => Array.isArray(p) && typeof p[0] === 'string' && p[0].includes('plugins/bizar'),
|
|
521
|
+
);
|
|
522
|
+
if (hasEntry && !force) {
|
|
523
|
+
return { ok: true, message: 'opencode.json already has Bizar plugin entry' };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (dryRun) {
|
|
527
|
+
return { ok: true, message: `[dry-run] would patch opencode.json with plugin entry` };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (!hasEntry) {
|
|
531
|
+
plugins.push(['./plugins/bizar/index.ts', {
|
|
532
|
+
loopThresholdWarn: 5,
|
|
533
|
+
loopThresholdEscalate: 8,
|
|
534
|
+
loopThresholdBlock: 12,
|
|
535
|
+
loopWindowSize: 10,
|
|
536
|
+
}]);
|
|
537
|
+
cfg.plugin = plugins;
|
|
538
|
+
}
|
|
539
|
+
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
540
|
+
return { ok: true, message: 'opencode.json patched with Bizar plugin entry' };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Copy `config/agents/*.md` into `~/.config/opencode/agents/`. Idempotent.
|
|
545
|
+
* Doesn't overwrite existing files unless `force: true`.
|
|
546
|
+
*/
|
|
547
|
+
export async function syncAgentFiles({ dryRun, force }) {
|
|
548
|
+
const srcDir = join(REPO_ROOT, 'config', 'agents');
|
|
549
|
+
const dstDir = join(OPENCODE_DIR, 'agents');
|
|
550
|
+
if (!existsSync(srcDir)) {
|
|
551
|
+
return { ok: true, message: 'no bundled agents to sync' };
|
|
552
|
+
}
|
|
553
|
+
if (dryRun) {
|
|
554
|
+
return { ok: true, message: `[dry-run] would sync ${srcDir} → ${dstDir}` };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
mkdirSync(dstDir, { recursive: true });
|
|
558
|
+
mkdirSync(join(dstDir, '_shared'), { recursive: true });
|
|
559
|
+
|
|
560
|
+
const { readdirSync, copyFileSync } = await import('node:fs');
|
|
561
|
+
let copied = 0;
|
|
562
|
+
let skipped = 0;
|
|
563
|
+
const files = readdirSync(srcDir, { withFileTypes: true });
|
|
564
|
+
for (const entry of files) {
|
|
565
|
+
if (entry.isDirectory()) continue;
|
|
566
|
+
if (entry.name === '_shared') continue;
|
|
567
|
+
const dst = join(dstDir, entry.name);
|
|
568
|
+
if (existsSync(dst) && !force) {
|
|
569
|
+
skipped++;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
copyFileSync(join(srcDir, entry.name), dst);
|
|
573
|
+
copied++;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// _shared/ — always overwrite (it's tiny + ships agent defaults).
|
|
577
|
+
const sharedSrc = join(srcDir, '_shared');
|
|
578
|
+
if (existsSync(sharedSrc)) {
|
|
579
|
+
for (const entry of readdirSync(sharedSrc, { withFileTypes: true })) {
|
|
580
|
+
if (!entry.isFile()) continue;
|
|
581
|
+
copyFileSync(join(sharedSrc, entry.name), join(dstDir, '_shared', entry.name));
|
|
582
|
+
copied++;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return { ok: true, message: `agents synced (${copied} copied, ${skipped} kept)`, copied, skipped };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Copy slash commands + skills to the opencode config dir.
|
|
591
|
+
*/
|
|
592
|
+
export async function syncConfigExtras({ dryRun }) {
|
|
593
|
+
if (dryRun) {
|
|
594
|
+
return { ok: true, message: '[dry-run] would sync commands + skills' };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const dst = OPENCODE_DIR;
|
|
598
|
+
mkdirSync(join(dst, 'command'), { recursive: true });
|
|
599
|
+
mkdirSync(join(dst, 'commands'), { recursive: true });
|
|
600
|
+
mkdirSync(join(dst, 'skill'), { recursive: true });
|
|
601
|
+
mkdirSync(join(dst, 'skills'), { recursive: true });
|
|
602
|
+
|
|
603
|
+
const { cp, readdirSync, copyFileSync, statSync } = await import('node:fs');
|
|
604
|
+
const copyDirIfExists = async (srcDir, dstDir) => {
|
|
605
|
+
if (!existsSync(srcDir)) return;
|
|
606
|
+
mkdirSync(dstDir, { recursive: true });
|
|
607
|
+
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
608
|
+
const src = join(srcDir, entry.name);
|
|
609
|
+
const dst = join(dstDir, entry.name);
|
|
610
|
+
if (entry.isDirectory()) await copyDirIfExists(src, dst);
|
|
611
|
+
else copyFileSync(src, dst);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
for (const sub of ['command', 'commands']) {
|
|
616
|
+
const s = join(REPO_ROOT, 'config', sub);
|
|
617
|
+
if (existsSync(s)) await copyDirIfExists(s, join(dst, sub));
|
|
618
|
+
}
|
|
619
|
+
for (const skill of ['obsidian', 'glyph', 'read-the-damn-docs']) {
|
|
620
|
+
const s = join(REPO_ROOT, 'config', 'skills', skill);
|
|
621
|
+
if (existsSync(s)) {
|
|
622
|
+
await copyDirIfExists(s, join(dst, 'skill'));
|
|
623
|
+
await copyDirIfExists(s, join(dst, 'skills'));
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return { ok: true, message: 'commands + skills synced' };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Run the system-deps + service-registration steps. These are shell-only
|
|
631
|
+
* (need sudo + platform package manager). We shell to the bundled
|
|
632
|
+
* install.sh which knows the platform.
|
|
633
|
+
*/
|
|
634
|
+
export async function ensureSystemDeps({ dryRun, mode }) {
|
|
635
|
+
const installSh = join(REPO_ROOT, 'install.sh');
|
|
636
|
+
if (!existsSync(installSh)) {
|
|
637
|
+
return {
|
|
638
|
+
ok: true,
|
|
639
|
+
message: `install.sh not found at ${installSh} — skipping system deps`,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (dryRun) {
|
|
644
|
+
return { ok: true, message: '[dry-run] would shell to install.sh for system deps' };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const useBash = process.platform !== 'win32' || process.env.WSL_DISTRO_NAME;
|
|
648
|
+
if (useBash) {
|
|
649
|
+
// install.sh accepts --mode and a few other flags. Pass --mode
|
|
650
|
+
// through so the bash script can skip what we already did in JS.
|
|
651
|
+
const args = [installSh, '--mode', mode, '--non-interactive'];
|
|
652
|
+
const r = spawnSync('bash', args, { stdio: 'inherit' });
|
|
653
|
+
if (r.status !== 0) {
|
|
654
|
+
return { ok: false, message: `install.sh exited with code ${r.status}` };
|
|
655
|
+
}
|
|
656
|
+
return { ok: true, message: 'install.sh completed' };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Windows without WSL — bash isn't available. The user can install
|
|
660
|
+
// system deps manually (Windows doesn't need apt/dnf for Node tooling).
|
|
661
|
+
return {
|
|
662
|
+
ok: true,
|
|
663
|
+
message: 'Windows without WSL: skipping system deps (none required for Node tooling)',
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Run `bizar doctor` to verify the install.
|
|
669
|
+
*/
|
|
670
|
+
export async function runDoctor({ silent = false } = {}) {
|
|
671
|
+
try {
|
|
672
|
+
const { runDoctor: doctorFn } = await import('./doctor.mjs');
|
|
673
|
+
const result = await doctorFn({ silent });
|
|
674
|
+
return result;
|
|
675
|
+
} catch (err) {
|
|
676
|
+
return {
|
|
677
|
+
ok: false,
|
|
678
|
+
failed: 1,
|
|
679
|
+
results: [{ name: 'doctor', ok: false, message: err.message }],
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ─── Top-level orchestration ──────────────────────────────────────────────────
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Kill running Bizar instances before mutating npm-global files. Returns
|
|
688
|
+
* the kill results so the caller can report them.
|
|
689
|
+
*/
|
|
690
|
+
export async function ensureInstancesKilled({ dryRun, force, instances }) {
|
|
691
|
+
const live = instances ?? detectInstances();
|
|
692
|
+
const running = [];
|
|
693
|
+
if (live.service?.running) running.push({ kind: 'service', pid: live.service.pid });
|
|
694
|
+
if (live.dashboard?.running) running.push({ kind: 'dashboard', pid: live.dashboard.pid });
|
|
695
|
+
|
|
696
|
+
if (running.length === 0) {
|
|
697
|
+
return { killed: [], skipped: [] };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (dryRun) {
|
|
701
|
+
return {
|
|
702
|
+
killed: running.map((r) => ({ ...r, ok: true, dryRun: true })),
|
|
703
|
+
skipped: [],
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Always kill (the npm update will replace on-disk files; running
|
|
708
|
+
// processes have those files open). `--force` skips the prompt.
|
|
709
|
+
// We don't prompt here — the caller (runProvision) is expected to
|
|
710
|
+
// confirm before calling this function. If we ever want to make
|
|
711
|
+
// it interactive, plumb `assumeYes` through here.
|
|
712
|
+
void force;
|
|
713
|
+
const killed = [];
|
|
714
|
+
for (const r of running) {
|
|
715
|
+
const label = r.kind === 'service' ? 'bizar service' : 'bizar-dash';
|
|
716
|
+
const ok = await killAndWait(r.pid, { label });
|
|
717
|
+
killed.push({ ...r, ok });
|
|
718
|
+
if (ok) {
|
|
719
|
+
try { rmSync(r.kind === 'service' ? SERVICE_PID_FILE : DASHBOARD_PID_FILE, { force: true }); } catch { /* ignore */ }
|
|
720
|
+
if (r.kind === 'dashboard') {
|
|
721
|
+
try { rmSync(DASHBOARD_PORT_FILE, { force: true }); } catch { /* ignore */ }
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return { killed, skipped: [] };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Spawn a fresh dashboard detached. Used at the end of an `update` flow
|
|
730
|
+
* to pick up the just-upgraded code.
|
|
731
|
+
*/
|
|
732
|
+
export function spawnFreshDashboard({ port } = {}) {
|
|
733
|
+
const state = detectState();
|
|
734
|
+
if (!state.pkgRoot) {
|
|
735
|
+
return { ok: false, message: 'could not locate npm global root' };
|
|
736
|
+
}
|
|
737
|
+
const dashBin = join(state.pkgRoot, 'cli', 'bin.mjs');
|
|
738
|
+
if (!existsSync(dashBin)) {
|
|
739
|
+
return { ok: false, message: `dashboard binary not found at ${dashBin}` };
|
|
740
|
+
}
|
|
741
|
+
try {
|
|
742
|
+
mkdirSync(BIZAR_HOME, { recursive: true });
|
|
743
|
+
} catch { /* ignore */ }
|
|
744
|
+
const args = [dashBin, 'start', '--bg'];
|
|
745
|
+
if (port) args.push(`--port=${port}`);
|
|
746
|
+
try {
|
|
747
|
+
const child = spawn(process.execPath, args, {
|
|
748
|
+
detached: true,
|
|
749
|
+
stdio: 'ignore',
|
|
750
|
+
env: { ...process.env, BIZAR_AUTO_RESPAWN: '1' },
|
|
751
|
+
});
|
|
752
|
+
child.on('error', () => { /* ignore */ });
|
|
753
|
+
child.unref();
|
|
754
|
+
return { ok: true, message: `dashboard re-spawned (pid ${child.pid})` };
|
|
755
|
+
} catch (err) {
|
|
756
|
+
return { ok: false, message: `dashboard re-spawn failed: ${err.message}` };
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* The unified provision flow. `mode` is 'install' or 'update'.
|
|
762
|
+
*
|
|
763
|
+
* Steps performed:
|
|
764
|
+
* 1. Detect state (no side effects).
|
|
765
|
+
* 2. (update only) Show installed-vs-latest version matrix.
|
|
766
|
+
* 3. (update only) Heads-up gate.
|
|
767
|
+
* 4. Kill running instances (update only — installs have none).
|
|
768
|
+
* 5. Upgrade npm packages (bizar + opencode-ai).
|
|
769
|
+
* 6. Shell to install.sh for system-deps + service registration.
|
|
770
|
+
* 7. Sync agent files, slash commands, skills.
|
|
771
|
+
* 8. Copy plugin to ~/.config/opencode/plugins/bizar/.
|
|
772
|
+
* 9. Patch opencode.json with the Bizar plugin entry.
|
|
773
|
+
* 10. (update only) Restart dashboard.
|
|
774
|
+
* 11. Doctor health check.
|
|
775
|
+
* 12. Summary.
|
|
776
|
+
*
|
|
777
|
+
* Every step is idempotent — running this twice is safe.
|
|
778
|
+
*/
|
|
779
|
+
export async function runProvision(opts = {}) {
|
|
780
|
+
const {
|
|
781
|
+
mode = 'install',
|
|
782
|
+
dryRun = false,
|
|
783
|
+
force = false,
|
|
784
|
+
restart = mode === 'update',
|
|
785
|
+
skipSystemDeps = false,
|
|
786
|
+
skipHeadsUps = false,
|
|
787
|
+
yes = false,
|
|
788
|
+
} = opts;
|
|
789
|
+
|
|
790
|
+
const banner = mode === 'update'
|
|
791
|
+
? chalk.bold.hex('#a855f7')('\n ᚦ BIZAR UPDATE ᚦ\n')
|
|
792
|
+
: chalk.bold.hex('#6366f1')('\n ⚡ BIZAR INSTALL ᚦ\n');
|
|
793
|
+
console.log(banner);
|
|
794
|
+
if (dryRun) {
|
|
795
|
+
console.log(chalk.dim(' --dry-run set: no installs, kills, or restarts will be performed.\n'));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ── 1. Detect state ──────────────────────────────────────────────────
|
|
799
|
+
const state = detectState();
|
|
800
|
+
|
|
801
|
+
// ── 2. (update) Version matrix ───────────────────────────────────────
|
|
802
|
+
if (mode === 'update') {
|
|
803
|
+
console.log(' Installed vs. latest:');
|
|
804
|
+
printVersionMatrix([
|
|
805
|
+
{ label: 'opencode-ai', current: state.opencodeCli.version, latest: state.opencodeCli.latest },
|
|
806
|
+
{ label: PKG_MAIN, current: state.pkgVersion, latest: state.pkgLatest },
|
|
807
|
+
]);
|
|
808
|
+
console.log('');
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// ── 3. (update) Heads-up gate ────────────────────────────────────────
|
|
812
|
+
if (mode === 'update' && !skipHeadsUps) {
|
|
813
|
+
const h = state.headsUpState;
|
|
814
|
+
if (h.blockerCount > 0) {
|
|
815
|
+
if (dryRun) {
|
|
816
|
+
console.log(chalk.yellow(` Dry-run: ${h.blockerCount} active blocker(s) — update would be blocked.`));
|
|
817
|
+
} else if (yes || force) {
|
|
818
|
+
console.log(chalk.yellow(` ⚠ ${h.blockerCount} active blocker(s) present — proceeding due to ${yes ? '--yes' : '--force'}.`));
|
|
819
|
+
} else {
|
|
820
|
+
console.error(chalk.red(` ✗ ${h.blockerCount} active blocker(s) in .bizar/PRE_PUSH_NOTES.md`));
|
|
821
|
+
console.error(chalk.dim(' Archive with `bizar heads-up archive` or override with --force.'));
|
|
822
|
+
process.exit(1);
|
|
823
|
+
}
|
|
824
|
+
} else if (h.warningCount > 0) {
|
|
825
|
+
console.log(chalk.yellow(` ⚠ ${h.warningCount} warning(s) in active heads-ups.`));
|
|
826
|
+
} else {
|
|
827
|
+
console.log(chalk.green(' ✓ Heads-ups: clear'));
|
|
828
|
+
}
|
|
829
|
+
console.log('');
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ── 4. Kill running instances (update only) ─────────────────────────
|
|
833
|
+
if (mode === 'update') {
|
|
834
|
+
const kill = await ensureInstancesKilled({ dryRun, force, instances: {
|
|
835
|
+
service: state.service.running ? { pid: state.service.pid } : null,
|
|
836
|
+
dashboard: state.dashboard.running ? { pid: state.dashboard.pid } : null,
|
|
837
|
+
} });
|
|
838
|
+
if (kill.killed.length > 0) {
|
|
839
|
+
const labels = kill.killed.map((k) => `${k.kind}${dryRun ? ' (dry-run)' : ''}`).join(', ');
|
|
840
|
+
console.log(chalk.yellow(` ⚠ Stopped running instances: ${labels}`));
|
|
841
|
+
for (const k of kill.killed) {
|
|
842
|
+
const marker = k.ok ? chalk.green('✓') : chalk.red('✗');
|
|
843
|
+
console.log(` ${marker} ${k.kind} ${k.ok ? 'stopped' : 'kill failed'}`);
|
|
844
|
+
}
|
|
845
|
+
// Give the kernel a moment to release file handles.
|
|
846
|
+
if (!dryRun) await new Promise((r) => setTimeout(r, 500));
|
|
847
|
+
} else {
|
|
848
|
+
console.log(chalk.dim(' No running Bizar instances detected.'));
|
|
849
|
+
}
|
|
850
|
+
console.log('');
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ── 5. npm package upgrades ─────────────────────────────────────────
|
|
854
|
+
const stepResults = [];
|
|
855
|
+
const runStep = async (label, fn) => {
|
|
856
|
+
console.log(chalk.bold(` → ${label}`));
|
|
857
|
+
const r = await fn();
|
|
858
|
+
console.log(` ${r.ok ? chalk.green('✓') : chalk.red('✗')} ${r.message}`);
|
|
859
|
+
stepResults.push({ label, ...r });
|
|
860
|
+
return r;
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
if (mode === 'update') {
|
|
864
|
+
await runStep('opencode-ai', () => updateOpencodeCli({ dryRun, force }));
|
|
865
|
+
}
|
|
866
|
+
await runStep(PKG_MAIN, () => ensureNpmPackage(PKG_MAIN, { mode, dryRun, force }));
|
|
867
|
+
|
|
868
|
+
// ── 6. System deps + service registration (shell) ──────────────────
|
|
869
|
+
if (!skipSystemDeps) {
|
|
870
|
+
console.log('');
|
|
871
|
+
await runStep('system-deps + service', () => ensureSystemDeps({ dryRun, mode }));
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// ── 7-9. Sync agent files, commands, skills, plugin, opencode.json ──
|
|
875
|
+
console.log('');
|
|
876
|
+
await runStep('agent files + slash commands + skills', () =>
|
|
877
|
+
Promise.all([
|
|
878
|
+
syncAgentFiles({ dryRun, force }),
|
|
879
|
+
syncConfigExtras({ dryRun }),
|
|
880
|
+
]).then((results) => {
|
|
881
|
+
const allOk = results.every((r) => r.ok);
|
|
882
|
+
const msgs = results.map((r) => r.message).join('; ');
|
|
883
|
+
return { ok: allOk, message: msgs || 'synced' };
|
|
884
|
+
}),
|
|
885
|
+
);
|
|
886
|
+
await runStep('plugin → ~/.config/opencode/plugins/bizar/', () =>
|
|
887
|
+
copyPluginToOpencode({ dryRun, force }),
|
|
888
|
+
);
|
|
889
|
+
await runStep('opencode.json plugin entry', () => patchOpencodeJson({ dryRun, force }));
|
|
890
|
+
|
|
891
|
+
// ── 10. (update) Restart dashboard ──────────────────────────────────
|
|
892
|
+
if (mode === 'update' && restart && state.dashboard.running) {
|
|
893
|
+
console.log('');
|
|
894
|
+
console.log(chalk.cyan(' Restarting dashboard with the new code...'));
|
|
895
|
+
const r = spawnFreshDashboard({ port: state.dashboard.port });
|
|
896
|
+
console.log(` ${r.ok ? chalk.green('✓') : chalk.yellow('⚠')} ${r.message}`);
|
|
897
|
+
stepResults.push({ label: 'dashboard-restart', ...r });
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ── 11. Doctor health check ────────────────────────────────────────
|
|
901
|
+
console.log('');
|
|
902
|
+
const doctor = await runDoctor({ silent: true });
|
|
903
|
+
if (doctor.failed > 0) {
|
|
904
|
+
console.log(chalk.yellow(` ⚠ Post-${mode} health check found ${doctor.failed} issue(s):`));
|
|
905
|
+
for (const r of (doctor.results || [])) {
|
|
906
|
+
if (!r.ok) {
|
|
907
|
+
console.log(chalk.red(` ✗ ${r.name}: ${r.message}`));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
console.log(chalk.dim(` Run \`bizar doctor\` for details.`));
|
|
911
|
+
} else {
|
|
912
|
+
console.log(chalk.green(' ✓ Doctor: all checks passed'));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// ── 12. Summary ─────────────────────────────────────────────────────
|
|
916
|
+
console.log('');
|
|
917
|
+
console.log(' Summary:');
|
|
918
|
+
for (const r of stepResults) {
|
|
919
|
+
const marker = r.ok ? chalk.green('✓') : chalk.red('✗');
|
|
920
|
+
console.log(` ${marker} ${r.label.padEnd(36)} ${r.message}`);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const anyFail = stepResults.some((r) => !r.ok);
|
|
924
|
+
if (anyFail) {
|
|
925
|
+
console.log(chalk.yellow('\n Some steps had issues. See messages above.'));
|
|
926
|
+
} else {
|
|
927
|
+
console.log(chalk.green(`\n ✓ ${mode === 'update' ? 'Update' : 'Install'} complete\n`));
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return {
|
|
931
|
+
ok: !anyFail,
|
|
932
|
+
mode,
|
|
933
|
+
state,
|
|
934
|
+
stepResults,
|
|
935
|
+
doctor,
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* CLI-flag-parsing entrypoint for `bizar update`. Kept here so that
|
|
941
|
+
* `cli/update.mjs` (the bin.mjs-facing module) can stay a thin shim
|
|
942
|
+
* without duplicating flag parsing. Accepts the legacy `subargs: string[]`
|
|
943
|
+
* shape so any external callers keep working.
|
|
944
|
+
*/
|
|
945
|
+
export async function runUpdate(subargs = []) {
|
|
946
|
+
return runProvision({
|
|
947
|
+
mode: 'update',
|
|
948
|
+
dryRun: subargs.includes('--dry-run'),
|
|
949
|
+
force: subargs.includes('--force'),
|
|
950
|
+
yes: subargs.includes('--yes') || subargs.includes('-y'),
|
|
951
|
+
restart: !subargs.includes('--no-restart'),
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* CLI-flag-parsing entrypoint for `bizar install`. Mirrors `runUpdate`
|
|
957
|
+
* so `cli/install.mjs` can stay a thin shim too.
|
|
958
|
+
*/
|
|
959
|
+
export async function runInstallerCli(opts = []) {
|
|
960
|
+
const subargs = Array.isArray(opts) ? opts : [];
|
|
961
|
+
return runProvision({
|
|
962
|
+
mode: 'install',
|
|
963
|
+
dryRun: subargs.includes('--dry-run'),
|
|
964
|
+
force: subargs.includes('--force'),
|
|
965
|
+
yes: subargs.includes('--yes') || subargs.includes('-y'),
|
|
966
|
+
});
|
|
967
|
+
}
|