@polderlabs/bizar 4.4.6 → 4.4.8

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/update.mjs CHANGED
@@ -1,879 +1,29 @@
1
1
  /**
2
- * update.mjs — `bizar update` subcommand.
3
- *
4
- * Updates the opencode CLI, the @polderlabs/bizar package, the
5
- * @polderlabs/bizar-dash package, and/or the @polderlabs/bizar-plugin
6
- * package. By default, prompts for each component individually. With
7
- * `--all`, updates everything without prompting.
8
- *
9
- * Before any install, the command:
10
- * 1. Detects running instances (background service daemon, dashboard
11
- * server, TUI dashboard) by reading the PID files at
12
- * ~/.config/bizar/{service,dashboard}.pid and cleaning up any
13
- * stale or empty ones.
14
- * 2. Warns the user explicitly about each running instance, lists
15
- * what will be killed, and asks for confirmation. Skipped only
16
- * with `--yes` (or `--force`).
17
- * 3. Sends SIGTERM to each live instance, waits up to 5s for
18
- * graceful shutdown, then escalates to SIGKILL for anything
19
- * still alive.
20
- *
21
- * After a successful update of `bizar` or `bizar-dash`, the dashboard
22
- * is automatically restarted (using its own `POST /api/restart`
23
- * endpoint when reachable, or by spawning a new process when not).
24
- * Use `--no-restart` to skip the restart.
25
- *
26
- * After the npm packages are updated, the install script is re-run so
27
- * the locally deployed plugin source matches the just-upgraded npm
28
- * version. Without this, the plugin in `~/.config/opencode/plugins/bizar/`
29
- * would lag one version behind npm and the BUGS.md "version skew"
30
- * trap would bite.
31
- *
32
- * Exit codes:
33
- * 0 — all requested updates succeeded (or none were requested)
34
- * 1 — at least one update failed, or the user cancelled the kill
35
- */
36
-
37
- import chalk from 'chalk';
38
- import { execSync, spawn, spawnSync } from 'node:child_process';
39
- import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
40
- import { cp } from 'node:fs/promises';
41
- import { homedir } from 'node:os';
42
- import { join, dirname } from 'node:path';
43
- import { fileURLToPath } from 'node:url';
44
- import { checkHeadsUps, findBizarDir } from './heads-up.mjs';
45
-
46
- const __filename = fileURLToPath(import.meta.url);
47
- const __dirname = dirname(__filename);
48
- const REPO_ROOT = join(__dirname, '..');
49
-
50
- // v4.4.5 — The plugin and dashboard are both shipped inside @polderlabs/bizar.
51
- // No separate @polderlabs/bizar-plugin or @polderlabs/bizar-dash packages.
52
- // Updating the main package updates everything.
53
- const PKG_MAIN = '@polderlabs/bizar';
54
-
55
- // All known components, in the order they should be prompted + updated.
56
- const COMPONENTS = ['opencode', 'bizar'];
57
-
58
- // ---------------------------------------------------------------------------
59
- // Config paths (mirror the dashboard + service for consistency)
60
- // ---------------------------------------------------------------------------
61
-
62
- function bizarConfigDir() {
63
- if (process.platform === 'win32') {
64
- return process.env.APPDATA
65
- ? join(process.env.APPDATA, 'bizar')
66
- : join(homedir(), '.config', 'bizar');
67
- }
68
- return process.env.XDG_CONFIG_HOME
69
- ? join(process.env.XDG_CONFIG_HOME, 'bizar')
70
- : join(homedir(), '.config', 'bizar');
71
- }
72
-
73
- const BIZAR_HOME = bizarConfigDir();
74
- const SERVICE_PID_FILE = join(BIZAR_HOME, 'service.pid');
75
- const DASHBOARD_PID_FILE = join(BIZAR_HOME, 'dashboard.pid');
76
- const DASHBOARD_PORT_FILE = join(BIZAR_HOME, 'dashboard.port');
77
-
78
- // ---------------------------------------------------------------------------
79
- // PID helpers (exported for testability)
80
- // ---------------------------------------------------------------------------
81
-
82
- /**
83
- * Read and validate a PID file. Returns a live integer PID, or `null`
84
- * if the file is missing / empty / contains a non-numeric value / the
85
- * process is not running. Stale or corrupt PID files are removed.
86
- *
87
- * Exported so tests can exercise the cleanup behavior without going
88
- * through the full update flow.
89
- */
90
- export function readLivePid(pidFile) {
91
- if (!existsSync(pidFile)) return null;
92
- let raw;
93
- try {
94
- raw = readFileSync(pidFile, 'utf8').trim();
95
- } catch {
96
- return null;
97
- }
98
- if (!raw) {
99
- // Empty / corrupt — clean it up so future checks are accurate.
100
- try { rmSync(pidFile, { force: true }); } catch { /* ignore */ }
101
- return null;
102
- }
103
- const pid = parseInt(raw, 10);
104
- if (!Number.isFinite(pid) || pid <= 0) {
105
- try { rmSync(pidFile, { force: true }); } catch { /* ignore */ }
106
- return null;
107
- }
108
- try {
109
- process.kill(pid, 0);
110
- return pid;
111
- } catch {
112
- // PID no longer alive — stale PID file.
113
- try { rmSync(pidFile, { force: true }); } catch { /* ignore */ }
114
- return null;
115
- }
116
- }
117
-
118
- /**
119
- * Send SIGTERM, wait up to `timeoutMs`, then SIGKILL if still alive.
120
- * Returns true if a signal was successfully delivered (or never needed).
121
- *
122
- * Implementation note: Linux PIDs are recycled immediately after a
123
- * process dies, so `process.kill(pid, 0)` after a kill is an unreliable
124
- * liveness check — the PID may already belong to a brand-new process.
125
- * We treat any successful signal delivery as success; if SIGKILL is
126
- * sent, the original process is certainly dead (uncatchable).
127
- *
128
- * Cross-platform note: Node.js 14+ maps `process.kill(pid)` without an
129
- * explicit signal to the platform-appropriate default (`SIGTERM` on
130
- * POSIX, `TerminateProcess` on Windows). We drop the signal argument so
131
- * the same code works on both platforms. For the forced-kill phase on
132
- * Windows we use `taskkill /F /PID <pid>` because `SIGKILL` is not
133
- * delivered the same way there.
134
- *
135
- * Exported for testability.
136
- */
137
- export async function killAndWait(pid, { timeoutMs = 5000, label = 'process' } = {}) {
138
- if (!pid) return true;
139
-
140
- // Phase 1: graceful SIGTERM (or platform default on Windows)
141
- let sigtermOk = false;
142
- try {
143
- process.kill(pid);
144
- sigtermOk = true;
145
- } catch (err) {
146
- if (err.code === 'ESRCH') return true; // already dead
147
- console.log(chalk.yellow(` ! could not signal ${label} (pid ${pid}): ${err.message}`));
148
- return false;
149
- }
150
-
151
- // Best-effort poll for graceful exit. Note: a signal-handling process
152
- // (Express dashboard, Node sleeper) usually exits within ~100ms. We
153
- // poll for up to `timeoutMs`; if anything responds to kill -0 it may be
154
- // a recycled PID, so we don't treat that as "still our process".
155
- const start = Date.now();
156
- let sawExit = false;
157
- while (Date.now() - start < timeoutMs) {
158
- try {
159
- process.kill(pid, 0);
160
- } catch (err) {
161
- if (err.code === 'ESRCH') { sawExit = true; break; }
162
- }
163
- await new Promise((resolve) => setTimeout(resolve, 100));
164
- }
165
-
166
- if (sawExit) {
167
- // Confirmed gone via ESRCH.
168
- return true;
169
- }
170
-
171
- // Phase 2: escalate to forced kill. Even if `kill -0` still succeeds
172
- // (PID recycled or process truly stuck), SIGKILL is uncatchable and
173
- // the original process — if it was still our PID — is now dead.
174
- // On Windows, use `taskkill /F` because POSIX SIGKILL semantics don't
175
- // exist there.
176
- try {
177
- if (process.platform === 'win32') {
178
- spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], { stdio: 'ignore' });
179
- } else {
180
- process.kill(pid, 'SIGKILL');
181
- }
182
- if (sigtermOk) {
183
- console.log(chalk.yellow(` ! ${label} (pid ${pid}) did not exit gracefully; sent forced kill`));
184
- }
185
- // Brief settle for the kernel.
186
- await new Promise((resolve) => setTimeout(resolve, 200));
187
- return true;
188
- } catch (err) {
189
- if (err.code === 'ESRCH') return true;
190
- console.log(chalk.red(` ✗ could not force-kill ${label} (pid ${pid}): ${err.message}`));
191
- return false;
192
- }
193
- }
194
-
195
- // ---------------------------------------------------------------------------
196
- // Version helpers
197
- // ---------------------------------------------------------------------------
198
-
199
- /**
200
- * Read the currently installed version of an npm package.
201
- * Returns `null` if not installed globally.
202
- */
203
- function currentVersion(pkg) {
204
- try {
205
- const out = execSync(`npm ls -g ${pkg} --depth=0 --json`, {
206
- stdio: ['ignore', 'pipe', 'ignore'],
207
- timeout: 15000,
208
- }).toString();
209
- const parsed = JSON.parse(out);
210
- const deps = parsed.dependencies ?? {};
211
- return deps[pkg]?.version ?? null;
212
- } catch {
213
- return null;
214
- }
215
- }
216
-
217
- /**
218
- * Read the latest version of an npm package from the registry.
219
- * Returns `null` if the registry is unreachable or the package is not published.
220
- */
221
- function latestVersion(pkg) {
222
- try {
223
- const out = execSync(`npm view ${pkg} version`, {
224
- stdio: ['ignore', 'pipe', 'ignore'],
225
- timeout: 15000,
226
- }).toString().trim();
227
- return out || null;
228
- } catch {
229
- return null;
230
- }
231
- }
232
-
233
- // ---------------------------------------------------------------------------
234
- // Update actions
235
- // ---------------------------------------------------------------------------
236
-
237
- /**
238
- * Update opencode. Tries `opencode upgrade` first (the upstream installer's
239
- * own command); falls back to `npm install -g opencode-ai@latest`.
240
- * Pass `{ dryRun: true }` to print what would run without executing.
241
- * Returns `{ ok: boolean, message: string }`.
242
- */
243
- function updateOpencode({ dryRun = false } = {}) {
244
- if (dryRun) {
245
- console.log(
246
- chalk.dim(
247
- ' [dry-run] would run: opencode upgrade (fallback: npm install -g opencode-ai@latest)',
248
- ),
249
- );
250
- return { ok: true, message: '[dry-run] opencode' };
251
- }
252
- const r1 = spawnSync('opencode', ['upgrade'], { stdio: 'inherit' });
253
- if (r1.status === 0) {
254
- return { ok: true, message: 'opencode updated via `opencode upgrade`' };
255
- }
256
- console.log(chalk.dim(' opencode upgrade not available; falling back to npm'));
257
- const r2 = spawnSync('npm', ['install', '-g', 'opencode-ai@latest'], { stdio: 'inherit' });
258
- if (r2.status === 0) {
259
- return { ok: true, message: 'opencode updated via npm' };
260
- }
261
- return { ok: false, message: 'opencode update failed — try `opencode upgrade` manually' };
262
- }
263
-
264
- function updatePackage(pkg, { dryRun = false } = {}) {
265
- if (dryRun) {
266
- console.log(
267
- chalk.dim(` [dry-run] would run: npm install -g ${pkg}@latest`),
268
- );
269
- return { ok: true, message: `[dry-run] ${pkg}` };
270
- }
271
- const r = spawnSync('npm', ['install', '-g', `${pkg}@latest`], { stdio: 'inherit' });
272
- if (r.status !== 0) {
273
- return { ok: false, message: `${pkg} update failed` };
274
- }
275
- return { ok: true, message: `${pkg} updated` };
276
- }
277
-
278
- // ---------------------------------------------------------------------------
279
- // Re-run the install script so the locally deployed plugin matches npm
280
- // ---------------------------------------------------------------------------
281
-
282
- /**
283
- * After updating the npm packages, re-run the install script so the
284
- * plugin source on disk matches the just-installed version. This is the
285
- * fix for the BUGS.md "version skew" trap.
286
- */
287
- function rerunInstallScript() {
288
- let globalRoot;
289
- try {
290
- globalRoot = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] })
291
- .toString()
292
- .trim();
293
- } catch {
294
- return { ok: false, message: 'could not locate npm global root; skipping install-script rerun' };
295
- }
296
- const pkgRoot = join(globalRoot, ...PKG_MAIN.split('/'));
297
- // v3.20.11: prefer the canonical `install.sh` script over `cli/bin.mjs
298
- // --setup`. The bash script is the single source of truth (it handles
299
- // system deps, chrome-headless-shell runtime libs, browser-harness,
300
- // opencode.json merging, etc.); the --setup path is a legacy fallback
301
- // for environments where bash isn't on PATH.
302
- const installSh = join(pkgRoot, 'install.sh');
303
- if (process.platform === 'win32') {
304
- // On Windows without WSL, bash isn't available. The bash script has
305
- // a Windows fallback that uses npm-based install paths. Fall through
306
- // to the bin.mjs --setup path, which still does the agent copy +
307
- // plugin-from-global install on Windows.
308
- console.log(chalk.dim(' Windows: using bin.mjs --setup path (bash not available)'));
309
- } else if (existsSync(installSh)) {
310
- console.log(chalk.dim(`\n Re-running install script at ${installSh}...`));
311
- const r = spawnSync('bash', [installSh], { stdio: 'inherit' });
312
- if (r.status !== 0) {
313
- return { ok: false, message: 'install script rerun failed' };
314
- }
315
- return { ok: true, message: 'install script re-run' };
316
- } else {
317
- console.log(chalk.dim(' install.sh not present — falling back to bin.mjs --setup'));
318
- }
319
- const binPath = join(pkgRoot, 'cli', 'bin.mjs');
320
- if (!existsSync(binPath)) {
321
- return { ok: false, message: 'could not locate a compatible setup script to re-run' };
322
- }
323
- console.log(chalk.dim(`\n Re-running setup via ${binPath} --setup...`));
324
- const r = spawnSync(process.execPath, [binPath, '--setup'], { stdio: 'inherit' });
325
- if (r.status === 0) {
326
- return { ok: true, message: 'setup re-run' };
327
- }
328
- return { ok: false, message: 'setup rerun failed' };
329
- }
330
-
331
- // ---------------------------------------------------------------------------
332
- // Instance detection + kill
333
- // ---------------------------------------------------------------------------
334
-
335
- /**
336
- * Detect live Bizar instances by reading the well-known PID files.
337
- * Returns:
338
- * { service: { pid, label } | null,
339
- * dashboard: { pid, port } | null,
340
- * other: Array<{ pid, cmd }> }
341
- */
342
- function detectInstances() {
343
- const servicePid = readLivePid(SERVICE_PID_FILE);
344
- const dashboardPid = readLivePid(DASHBOARD_PID_FILE);
345
- let port = null;
346
- if (dashboardPid && existsSync(DASHBOARD_PORT_FILE)) {
347
- try {
348
- port = parseInt(readFileSync(DASHBOARD_PORT_FILE, 'utf8').trim(), 10) || null;
349
- } catch { /* ignore */ }
350
- }
351
- return {
352
- service: servicePid ? { pid: servicePid, label: `bizar service (pid ${servicePid})` } : null,
353
- dashboard: dashboardPid ? { pid: dashboardPid, port, label: `bizar-dash web dashboard (pid ${dashboardPid}${port ? `, port ${port}` : ''})` } : null,
354
- };
355
- }
356
-
357
- /**
358
- * Ask the user to confirm killing the listed instances. Returns true if
359
- * they confirmed, false if they cancelled. Skipped entirely with `assumeYes`.
360
- */
361
- async function confirmKill(instances, { assumeYes } = {}) {
362
- const lines = [];
363
- if (instances.service) lines.push(` • ${instances.service.label} — background schedule runner`);
364
- if (instances.dashboard) lines.push(` • ${instances.dashboard.label} — web UI + API`);
365
- if (lines.length === 0) return true;
366
- if (assumeYes) return true;
367
- console.log('');
368
- console.log(chalk.bold.yellow(' ⚠ Running Bizar instances detected:'));
369
- for (const l of lines) console.log(chalk.yellow(l));
370
- console.log('');
371
- console.log(chalk.yellow(' These must be stopped before npm can replace the on-disk files.'));
372
- console.log(chalk.yellow(' Killing them will close any open web tabs / TUI sessions.'));
373
- console.log('');
374
- // Best-effort interactive confirmation. Non-TTY (CI / piped input) skips the
375
- // prompt and aborts — callers should pass `--yes` explicitly in that case.
376
- if (!process.stdin.isTTY) {
377
- console.log(chalk.red(' Non-interactive shell detected; rerun with --yes to confirm the kill, or stop the processes manually.'));
378
- return false;
379
- }
380
- try {
381
- const inquirer = await import('inquirer');
382
- const { ok } = await inquirer.default.prompt([
383
- {
384
- type: 'confirm',
385
- name: 'ok',
386
- message: 'Kill the running instance(s) and continue?',
387
- default: true,
388
- },
389
- ]);
390
- return Boolean(ok);
391
- } catch (err) {
392
- console.log(chalk.red(` ! inquirer unavailable: ${err.message}`));
393
- return false;
394
- }
395
- }
396
-
397
- async function killInstances(instances) {
398
- const out = [];
399
- if (instances.service) {
400
- const ok = await killAndWait(instances.service.pid, { label: 'bizar service' });
401
- out.push({ name: 'service', ok });
402
- if (ok) {
403
- try { rmSync(SERVICE_PID_FILE, { force: true }); } catch { /* ignore */ }
404
- }
405
- }
406
- if (instances.dashboard) {
407
- const ok = await killAndWait(instances.dashboard.pid, { label: 'bizar-dash' });
408
- out.push({ name: 'dashboard', ok });
409
- if (ok) {
410
- try { rmSync(DASHBOARD_PID_FILE, { force: true }); } catch { /* ignore */ }
411
- try { rmSync(DASHBOARD_PORT_FILE, { force: true }); } catch { /* ignore */ }
412
- }
413
- }
414
- return out;
415
- }
416
-
417
- // ---------------------------------------------------------------------------
418
- // Restart the dashboard after the update completes
419
- // ---------------------------------------------------------------------------
420
-
421
- /**
422
- * Spawn a fresh dashboard process detached, returning the new PID. The
423
- * dashboard will use the just-updated @polderlabs/bizar-dash code from
424
- * the global npm install.
425
- */
426
- function spawnFreshDashboard({ port } = {}) {
427
- let globalRoot;
428
- try {
429
- globalRoot = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] })
430
- .toString()
431
- .trim();
432
- } catch {
433
- return { ok: false, message: 'could not locate npm global root' };
434
- }
435
- // v4.4.5 — Dashboard ships inside @polderlabs/bizar (no separate
436
- // @polderlabs/bizar-dash). Resolve the main package's cli.mjs.
437
- const dashBin = join(globalRoot, '@polderlabs', 'bizar', 'cli', 'bin.mjs');
438
- if (!existsSync(dashBin)) {
439
- return {
440
- ok: false,
441
- message: `dashboard binary not found at ${dashBin} (was @polderlabs/bizar installed?)`,
442
- };
443
- }
444
- try {
445
- mkdirSync(BIZAR_HOME, { recursive: true });
446
- } catch { /* ignore */ }
447
- const args = [dashBin, 'start', '--bg'];
448
- if (port) args.push(`--port=${port}`);
449
- try {
450
- const child = spawn(process.execPath, args, {
451
- detached: true,
452
- stdio: 'ignore',
453
- env: { ...process.env, BIZAR_AUTO_RESPAWN: '1' },
454
- });
455
- child.on('error', () => { /* ignore */ });
456
- child.unref();
457
- return { ok: true, message: `dashboard re-spawned (pid ${child.pid})` };
458
- } catch (err) {
459
- return { ok: false, message: `dashboard re-spawn failed: ${err.message}` };
460
- }
461
- }
462
-
463
- // ---------------------------------------------------------------------------
464
- // Repo-level update helpers
465
- // ---------------------------------------------------------------------------
466
-
467
- /**
468
- * v4.4.2 — Detect whether `bizar update` is running from a git checkout
469
- * or from a globally-installed npm package. When run from the npm
470
- * install (REPO_ROOT has no `.git/`), the git-pull step is meaningless
471
- * and aborts the entire update. We no-op it and let the `npm update -g`
472
- * path handle the version bump.
473
- *
474
- * Returns `{ inRepo: boolean, repoRoot: string }` so the caller can
475
- * decide what to do.
476
- */
477
- function detectRepoMode() {
478
- const gitDir = join(REPO_ROOT, '.git');
479
- return {
480
- inRepo: existsSync(gitDir),
481
- repoRoot: REPO_ROOT,
482
- };
483
- }
484
-
485
- /**
486
- * Pull the latest changes from the git origin. Exits the process if the
487
- * pull fails (merge conflict etc.), matching the task flow — an update
488
- * should not proceed when the working tree is dirty.
489
- */
490
- function runGitPull({ dryRun = false } = {}) {
491
- const { inRepo } = detectRepoMode();
492
- if (!inRepo) {
493
- // Running from a global npm install — there's no checkout to pull.
494
- // The npm package update is handled separately by updatePackage().
495
- if (dryRun) {
496
- console.log(chalk.dim(' [dry-run] skipping git pull (not a git checkout)'));
497
- }
498
- return { ok: true, message: 'skipped git pull (not a git checkout)' };
499
- }
500
- if (dryRun) {
501
- console.log(chalk.dim(' [dry-run] would run: git pull --rebase'));
502
- return { ok: true, message: '[dry-run] git pull --rebase' };
503
- }
504
- try {
505
- execSync('git pull --rebase', { stdio: 'inherit', cwd: REPO_ROOT, timeout: 60000 });
506
- return { ok: true, message: 'git pull --rebase succeeded' };
507
- } catch {
508
- return { ok: false, message: 'git pull --rebase failed — resolve conflicts manually' };
509
- }
510
- }
511
-
512
- /**
513
- * Copy bundled skills from config/skills/ to the user's .opencode/skills/
514
- * directory. Currently installs: obsidian, glyph, read-the-damn-docs.
515
- */
516
- async function installSkills({ dryRun = false } = {}) {
517
- const skills = ['obsidian', 'glyph', 'read-the-damn-docs'];
518
- const results = [];
519
- for (const skill of skills) {
520
- const src = join(REPO_ROOT, 'config', 'skills', skill);
521
- const dst = join(homedir(), '.opencode', 'skills', skill);
522
- if (dryRun) {
523
- if (existsSync(src)) {
524
- console.log(chalk.dim(` [dry-run] would install skill: ${skill}`));
525
- }
526
- } else if (existsSync(src)) {
527
- try {
528
- await cp(src, dst, { recursive: true });
529
- console.log(chalk.green(` ✓ Installed skill: ${skill}`));
530
- results.push({ skill, ok: true });
531
- } catch (err) {
532
- console.log(chalk.yellow(` ⚠ Failed to install skill: ${skill} — ${err.message}`));
533
- results.push({ skill, ok: false });
534
- }
535
- } else {
536
- console.log(chalk.dim(` — Source not found: config/skills/${skill} (skipping)`));
537
- }
538
- }
539
- return results;
540
- }
541
-
542
- // ---------------------------------------------------------------------------
543
- // Prompt helpers
544
- // ---------------------------------------------------------------------------
545
-
546
- async function promptForUpdates(forceAll) {
547
- if (forceAll) return new Set(COMPONENTS);
548
- const inquirer = await import('inquirer');
549
- const { selections } = await inquirer.default.prompt([
550
- {
551
- type: 'checkbox',
552
- name: 'selections',
553
- message: 'Which components do you want to update?',
554
- choices: [
555
- { name: 'opencode (the opencode CLI itself)', value: 'opencode', checked: true },
556
- { name: `bizar (${PKG_MAIN}) — includes plugin + dashboard`, value: 'bizar', checked: true },
557
- ],
558
- },
559
- ]);
560
- return new Set(selections);
561
- }
562
-
563
- // ---------------------------------------------------------------------------
564
- // Main entry point
565
- // ---------------------------------------------------------------------------
566
-
567
- /**
568
- * Default behavior policy for `bizar update`:
569
- * - Update EVERYTHING (opencode + bizar + dash + plugin) automatically.
570
- * - Auto-install any missing component without asking.
571
- * - Kill running instances without confirmation (with a brief notice).
572
- * - Re-run the install script to refresh the on-disk plugin.
573
- * - Restart the dashboard if it was running.
574
- *
575
- * The `--pick` / `-p` flag opts into the legacy per-component picker
576
- * (checkbox UI) for users who want to update only some components.
577
- *
578
- * The `--dry-run` flag previews what would happen without touching
579
- * anything.
580
- *
581
- * The `--no-restart` flag skips the dashboard restart step.
582
- */
583
- export async function runUpdate(subargs = []) {
584
- console.log(chalk.bold.hex('#a855f7')('\n ᚦ BIZAR UPDATE ᚦ\n'));
585
-
586
- // Parse flags
587
- const assumeYes = subargs.includes('--yes') || subargs.includes('-y') || subargs.includes('--force');
588
- const restartAfter = !subargs.includes('--no-restart');
589
- const forceAll = subargs.includes('--all');
590
- const dryRun = subargs.includes('--dry-run');
591
- // Opt-in interactive picker. Without `--pick`, we update everything
592
- // automatically. This matches what the user actually wants 95% of
593
- // the time ("update my install") and removes a prompt that
594
- // interrupted the flow.
595
- const interactivePick = subargs.includes('--pick') || subargs.includes('-p');
596
-
597
- if (dryRun) {
598
- console.log(
599
- chalk.dim(' --dry-run set: no installs, kills, or restarts will be performed.\n'),
600
- );
601
- }
602
-
603
- // 1. Detect running instances BEFORE doing anything else.
604
- const instances = detectInstances();
605
- const runningCount = (instances.service ? 1 : 0) + (instances.dashboard ? 1 : 0);
606
-
607
- if (runningCount > 0) {
608
- if (dryRun) {
609
- const labels = [];
610
- if (instances.service) labels.push(instances.service.label);
611
- if (instances.dashboard) labels.push(instances.dashboard.label);
612
- console.log(
613
- chalk.dim(` [dry-run] would stop running instances: ${labels.join(', ')}`),
614
- );
615
- } else {
616
- // In automatic mode we still print what we're about to kill
617
- // but don't prompt — the operator asked for a full update and
618
- // these processes would block the npm replace anyway.
619
- const labels = [];
620
- if (instances.service) labels.push(instances.service.label);
621
- if (instances.dashboard) labels.push(instances.dashboard.label);
622
- console.log(chalk.yellow(` ⚠ Stopping running instances: ${labels.join(', ')}`));
623
- console.log(
624
- chalk.dim(' (use `--pick` to be prompted before killing in future)'),
625
- );
626
- const kills = await killInstances(instances);
627
- for (const k of kills) {
628
- const marker = k.ok ? chalk.green('✓') : chalk.red('✗');
629
- console.log(` ${marker} ${k.name} stopped`);
630
- }
631
- // Give the kernel a moment to release any open file handles on the
632
- // npm-global directory before npm tries to replace files.
633
- await new Promise((resolve) => setTimeout(resolve, 500));
634
- }
635
- } else {
636
- console.log(chalk.dim(' No running Bizar instances detected.'));
637
- }
638
-
639
- // ── Git pull ──────────────────────────────────────────────────────────
640
- const { inRepo } = detectRepoMode();
641
- if (dryRun) {
642
- if (inRepo) {
643
- console.log(chalk.dim('\n [dry-run] would pull latest from origin (git pull --rebase)'));
644
- } else {
645
- console.log(chalk.dim('\n [dry-run] skipping git pull (not a git checkout — npm install -g handles version bump)'));
646
- }
647
- } else {
648
- if (inRepo) {
649
- console.log(chalk.dim('\n Pulling latest from origin...'));
650
- } else {
651
- console.log(chalk.dim('\n Skipping git pull (not a git checkout — using npm install -g for version bump)'));
652
- }
653
- const pull = runGitPull();
654
- if (pull.ok) {
655
- console.log(chalk.green(` ✓ ${pull.message}`));
656
- } else {
657
- console.log(chalk.red(` ✗ ${pull.message}`));
658
- console.log(chalk.yellow(' Update aborted — resolve git conflicts and retry.'));
659
- process.exit(1);
660
- }
661
- }
662
- console.log('');
663
-
664
- // 2. Show installed vs. latest versions.
665
- const cur = {
666
- opencode: currentVersion('opencode-ai'),
667
- bizar: currentVersion(PKG_MAIN),
668
- };
669
- const latest = {
670
- opencode: latestVersion('opencode-ai'),
671
- bizar: latestVersion(PKG_MAIN),
672
- };
673
-
674
- console.log('');
675
- console.log(' Installed vs. latest:');
676
- for (const k of COMPONENTS) {
677
- const label = k === 'bizar' ? PKG_MAIN : 'opencode-ai';
678
- const c = cur[k] ?? '(not installed)';
679
- const l = latest[k] ?? '(unknown)';
680
- const same = c === l;
681
- console.log(` ${label.padEnd(28)} ${c.padEnd(15)} → ${l}${same ? ' ✓ up to date' : ' ⤵ update available'}`);
682
- }
683
- console.log('');
684
-
685
- // ── Heads-up gate ───────────────────────────────────────────────────────
686
- // Check .bizar/PRE_PUSH_NOTES.md for active blockers or warnings before
687
- // allowing the update to proceed. Blocker entries require --force.
688
- const bizarDir = findBizarDir(process.cwd());
689
- if (bizarDir) {
690
- const headsUp = await checkHeadsUps(bizarDir);
691
- if (!headsUp.ok) {
692
- if (dryRun) {
693
- console.log(chalk.yellow(' Dry-run: found active blocker(s) — update would be blocked.'));
694
- } else if (assumeYes || subargs.includes('--force')) {
695
- console.log(chalk.yellow(' ⚠ Active blocker(s) present — proceeding due to --force.'));
696
- } else {
697
- console.error(chalk.red(' ✗ Active blocker(s) found in .bizar/PRE_PUSH_NOTES.md.'));
698
- console.error(chalk.dim(' Archive them with `bizar heads-up archive` or'));
699
- console.error(chalk.dim(' override with `--force`.'));
700
- process.exit(1);
701
- }
702
- } else if (headsUp.warningCount > 0) {
703
- console.log(chalk.yellow(` ⚠ ${headsUp.warningCount} warning(s) in active heads-ups.`));
704
- // In automatic mode, print the warning count and continue.
705
- // In interactive mode, ask for confirmation.
706
- if (!assumeYes && !forceAll && process.stdin.isTTY) {
707
- try {
708
- const inquirer = await import('inquirer');
709
- const { proceed } = await inquirer.default.prompt([
710
- {
711
- type: 'confirm',
712
- name: 'proceed',
713
- message: 'Heads-up warnings exist. Continue with update?',
714
- default: true,
715
- },
716
- ]);
717
- if (!proceed) {
718
- console.log(chalk.yellow(' Update cancelled by user.'));
719
- process.exit(0);
720
- }
721
- } catch {
722
- // inquirer unavailable — continue anyway
723
- }
724
- }
725
- } else {
726
- console.log(chalk.green(' ✓ Heads-ups: clear'));
727
- }
728
- }
729
- console.log('');
730
-
731
- // 3. Decide what to update. Default = everything. Any missing package
732
- // is automatically included so a partial install gets completed.
733
- let selected;
734
- if (interactivePick && !dryRun && !forceAll && !assumeYes) {
735
- selected = await promptForUpdates(false);
736
- } else {
737
- // Auto-select: all components + any missing one (so a broken install
738
- // gets repaired automatically).
739
- selected = new Set(COMPONENTS);
740
- for (const k of COMPONENTS) {
741
- if (cur[k] === null && latest[k] !== null) selected.add(k);
742
- }
743
- if (interactivePick) {
744
- // --pick + --dry-run / --all / --yes → still update everything
745
- // but make the auto-selection visible so the dry-run output is
746
- // informative.
747
- console.log(chalk.dim(` Auto-selecting all components (--pick ignored due to flags).`));
748
- }
749
- }
750
-
751
- if (selected.size === 0) {
752
- console.log(chalk.dim(' Nothing to update.'));
753
- return;
754
- }
755
-
756
- console.log(chalk.cyan(` Updating: ${[...selected].join(', ')}\n`));
757
-
758
- const results = [];
759
- if (selected.has('opencode')) {
760
- console.log(chalk.bold(' → opencode'));
761
- results.push(['opencode', updateOpencode({ dryRun })]);
762
- }
763
- if (selected.has('bizar')) {
764
- console.log(chalk.bold(` → ${PKG_MAIN}`));
765
- results.push(['bizar', updatePackage(PKG_MAIN, { dryRun })]);
766
- }
767
-
768
- // 4. Re-run the install script if anything relevant changed.
769
- const anySuccess = results.some(([, r]) => r.ok);
770
- if (anySuccess && selected.has('bizar')) {
771
- console.log('');
772
- if (dryRun) {
773
- console.log(
774
- chalk.dim(
775
- ' [dry-run] would re-run install.sh to refresh agent files + plugin copy',
776
- ),
777
- );
778
- } else {
779
- const rerun = rerunInstallScript();
780
- if (rerun.ok) {
781
- console.log(chalk.green(`\n ✓ ${rerun.message}`));
782
- } else {
783
- console.log(chalk.yellow(`\n ⚠ ${rerun.message}`));
784
- console.log(chalk.dim(' Run `bizar install` to refresh agent files + plugin copy.'));
785
- }
786
- }
787
- }
788
-
789
- // ── Install bundled skills ──────────────────────────────────────────
790
- console.log(chalk.bold('\n → Installing bundled skills...'));
791
- const skillResults = await installSkills({ dryRun });
792
- const skillOk = skillResults.length === 0 || skillResults.every((r) => r.ok);
793
-
794
- // v4.4.5 — Dashboard dist ships prebuilt inside the npm package. No
795
- // rebuild step is needed (and we can't run `vite build` from the
796
- // installed package anyway — node_modules/vite lives outside the
797
- // plugin's runtime context). The next launch picks up the new dist
798
- // when `bizar dash start --bg` runs.
799
-
800
- // ── Restart dashboard ─────────────────────────────────────────────
801
- // Restart if it was running before.
802
- if (restartAfter && instances.dashboard) {
803
- if (dryRun) {
804
- console.log('');
805
- console.log(chalk.dim(' [dry-run] would restart dashboard with the new code'));
806
- } else {
807
- console.log('');
808
- console.log(chalk.cyan(' Restarting dashboard with the new code...'));
809
- const res = spawnFreshDashboard({ port: instances.dashboard?.port || undefined });
810
- if (res.ok) {
811
- console.log(chalk.green(` ✓ ${res.message}`));
812
- } else {
813
- console.log(chalk.yellow(` ⚠ ${res.message}`));
814
- console.log(chalk.dim(' Start it manually with `bizar dash start --bg`.'));
815
- }
816
- }
817
- }
818
-
819
- // 6. Summary
820
- console.log('');
821
- console.log(' Summary:');
822
- for (const [name, r] of results) {
823
- const marker = r.ok ? chalk.green('✓') : chalk.red('✗');
824
- console.log(` ${marker} ${name.padEnd(10)} ${r.message}`);
825
- }
826
- // Skills
827
- for (const sr of skillResults) {
828
- const marker = sr.ok ? chalk.green('✓') : chalk.red('✗');
829
- console.log(` ${marker} ${'skill/skills'.padEnd(10)} ${sr.ok ? 'installed' : `failed: ${sr.skill}`}`);
830
- }
831
-
832
- const allResults = [
833
- ...results.map(([, r]) => r),
834
- ...skillResults,
835
- ];
836
- const anyFail = allResults.some((r) => !r.ok);
837
- if (anyFail) {
838
- console.log(chalk.yellow('\n Some steps had issues. See messages above.'));
839
- if (allResults.filter((r) => !r.ok).some((r) => r.message && r.message.includes('failed'))) {
840
- process.exit(1);
841
- }
842
- }
843
- if (dryRun) {
844
- console.log(
845
- chalk.green('\n ✓ Dry-run complete (no installs, kills, or restarts performed)\n'),
846
- );
847
- return;
848
- }
849
- console.log(chalk.green('\n ✓ Update complete\n'));
850
-
851
- // 7. Post-update health check (v3.12.2). Catches a bad config merge or
852
- // missing files before the user discovers it via a broken opencode session.
853
- try {
854
- const { runDoctor } = await import('./doctor.mjs');
855
- const result = await runDoctor({ silent: true });
856
- if (result.failed > 0) {
857
- console.log('');
858
- console.log(
859
- chalk.yellow(' ⚠ Post-update health check found issues:'),
860
- );
861
- for (const r of result.results) {
862
- if (!r.ok) {
863
- console.log(chalk.red(` ✗ ${r.name}: ${r.message}`));
864
- }
865
- }
866
- console.log(chalk.dim(' Run `bizar doctor` for details.'));
867
- process.exit(1);
868
- }
869
- } catch (err) {
870
- // Doctor import or runtime failure shouldn't crash the update —
871
- // log a hint and let the user run `bizar doctor` themselves.
872
- console.log(
873
- chalk.yellow(
874
- ` ⚠ Post-update health check could not run: ${err.message}`,
875
- ),
876
- );
877
- console.log(chalk.dim(' Run `bizar doctor` manually to verify the install.'));
878
- }
879
- }
2
+ * update.mjs — `bizar update` subcommand (v4.4.7 — thin wrapper).
3
+ *
4
+ * The full update logic now lives in `cli/provision.mjs:runProvision`.
5
+ * `bizar install` and `bizar update` are the same code path with
6
+ * different `mode` flags. This file just re-exports the API the rest of
7
+ * the codebase expects.
8
+ *
9
+ * Why a thin wrapper:
10
+ * - One source of truth. Install + update used to drift apart (and
11
+ * did install.mjs and update.mjs each grew their own copy of
12
+ * "copy plugin, patch opencode.json, sync skills, run doctor").
13
+ * Now both call the same function.
14
+ * - Easier to maintain. Adding a new step means editing provision.mjs
15
+ * once; both `install` and `update` pick it up.
16
+ * - Same idempotency guarantees. Both modes probe existing state
17
+ * first and skip work that's already done.
18
+ *
19
+ * Backward-compatible: `runUpdate` is still exported with the same
20
+ * signature (`runUpdate(subargs: string[])`).
21
+ */
22
+
23
+ export {
24
+ runUpdate,
25
+ runProvision,
26
+ detectState,
27
+ readLivePid,
28
+ killAndWait,
29
+ } from './provision.mjs';