@polderlabs/bizar 4.4.5 → 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.
@@ -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
+ }