@polderlabs/bizar 3.7.1 → 3.7.3

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 CHANGED
@@ -157,18 +157,40 @@ function showInstallHelp() {
157
157
 
158
158
  function showUpdateHelp() {
159
159
  console.log(`
160
- bizar update — Update opencode, bizar, and/or bizar-plugin
160
+ bizar update — Update opencode, bizar, bizar-dash, and/or bizar-plugin
161
161
 
162
162
  Usage:
163
- bizar update
164
- bizar update --all
165
- bizar update opencode
166
- bizar update bizar
167
- bizar update plugin
168
-
169
- Description:
170
- Prompts interactively by default. Use --all for non-interactive
171
- updates, or pass explicit component names.
163
+ bizar update Interactive prompt for components
164
+ bizar update --all Update every component
165
+ bizar update opencode bizar dash Update specific components
166
+ bizar update --all --yes Update everything + auto-kill running instances
167
+ bizar update --no-restart Don't auto-restart the dashboard after update
168
+ bizar update --help Show this help
169
+
170
+ Components:
171
+ opencode the opencode CLI itself
172
+ bizar @polderlabs/bizar (this CLI + installer + agents + rules)
173
+ dash @polderlabs/bizar-dash (web dashboard + TUI)
174
+ plugin @polderlabs/bizar-plugin (opencode plugin)
175
+
176
+ Behavior:
177
+ • Detects running Bizar instances (background service daemon, web
178
+ dashboard) by reading ~/.config/bizar/{service,dashboard}.pid and
179
+ cleaning up any stale or empty PID files.
180
+ • Warns the user explicitly before killing each instance. Pass
181
+ --yes / -y / --force to skip the confirmation (required for
182
+ non-interactive shells).
183
+ • Sends SIGTERM, waits up to 5s, escalates to SIGKILL if needed.
184
+ • Re-runs the install script so the deployed plugin source matches
185
+ the just-upgraded npm version (avoids the version-skew trap).
186
+ • If the dashboard was running and bizar / bizar-dash were updated,
187
+ spawns a fresh detached dashboard process with the new code
188
+ (skipped with --no-restart).
189
+
190
+ Examples:
191
+ bizar update --all --yes Headless full update + restart
192
+ bizar update dash Update only the dashboard
193
+ bizar update plugin --no-restart Plugin-only, leave dashboard alone
172
194
  `);
173
195
  }
174
196
 
package/cli/update.mjs CHANGED
@@ -1,36 +1,180 @@
1
1
  /**
2
2
  * update.mjs — `bizar update` subcommand.
3
3
  *
4
- * Updates the opencode CLI, the @polderlabs/bizar package, and/or the
5
- * @polderlabs/bizar-plugin package. By default, prompts for each
6
- * component individually. With `--all`, updates everything without
7
- * prompting. With explicit subcommands (`opencode`, `bizar`, `plugin`),
8
- * runs only the named update.
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.
9
8
  *
10
- * Each update is a thin wrapper around the underlying installer:
11
- * - opencode: `opencode upgrade` (or `npm install -g opencode-ai@latest` as a fallback)
12
- * - bizar: `npm install -g @polderlabs/bizar@latest`
13
- * - plugin: `npm install -g @polderlabs/bizar-plugin@latest`
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.
14
20
  *
15
- * After the package updates, re-runs the install script so the locally
16
- * deployed plugin source matches the just-upgraded npm version. Without
17
- * this, the plugin in `~/.config/opencode/plugins/bizar/` would be one
18
- * version behind the npm registry, and the BUGS.md "version skew"
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"
19
30
  * trap would bite.
20
31
  *
21
32
  * Exit codes:
22
33
  * 0 — all requested updates succeeded (or none were requested)
23
- * 1 — at least one update failed
34
+ * 1 — at least one update failed, or the user cancelled the kill
24
35
  */
25
36
 
26
37
  import chalk from 'chalk';
27
- import { execSync, spawnSync } from 'node:child_process';
28
- import { existsSync } from 'node:fs';
29
- import { join } from 'node:path';
38
+ import { execSync, spawn, spawnSync } from 'node:child_process';
39
+ import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
40
+ import { homedir } from 'node:os';
41
+ import { join, dirname } from 'node:path';
42
+ import { fileURLToPath } from 'node:url';
43
+
44
+ const __filename = fileURLToPath(import.meta.url);
45
+ const __dirname = dirname(__filename);
30
46
 
31
47
  const PKG_MAIN = '@polderlabs/bizar';
48
+ const PKG_DASH = '@polderlabs/bizar-dash';
32
49
  const PKG_PLUGIN = '@polderlabs/bizar-plugin';
33
50
 
51
+ // All known components, in the order they should be prompted + updated.
52
+ const COMPONENTS = ['opencode', 'bizar', 'dash', 'plugin'];
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Config paths (mirror the dashboard + service for consistency)
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function bizarConfigDir() {
59
+ if (process.platform === 'win32') {
60
+ return process.env.APPDATA
61
+ ? join(process.env.APPDATA, 'bizar')
62
+ : join(homedir(), '.config', 'bizar');
63
+ }
64
+ return process.env.XDG_CONFIG_HOME
65
+ ? join(process.env.XDG_CONFIG_HOME, 'bizar')
66
+ : join(homedir(), '.config', 'bizar');
67
+ }
68
+
69
+ const BIZAR_HOME = bizarConfigDir();
70
+ const SERVICE_PID_FILE = join(BIZAR_HOME, 'service.pid');
71
+ const DASHBOARD_PID_FILE = join(BIZAR_HOME, 'dashboard.pid');
72
+ const DASHBOARD_PORT_FILE = join(BIZAR_HOME, 'dashboard.port');
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // PID helpers (exported for testability)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Read and validate a PID file. Returns a live integer PID, or `null`
80
+ * if the file is missing / empty / contains a non-numeric value / the
81
+ * process is not running. Stale or corrupt PID files are removed.
82
+ *
83
+ * Exported so tests can exercise the cleanup behavior without going
84
+ * through the full update flow.
85
+ */
86
+ export function readLivePid(pidFile) {
87
+ if (!existsSync(pidFile)) return null;
88
+ let raw;
89
+ try {
90
+ raw = readFileSync(pidFile, 'utf8').trim();
91
+ } catch {
92
+ return null;
93
+ }
94
+ if (!raw) {
95
+ // Empty / corrupt — clean it up so future checks are accurate.
96
+ try { rmSync(pidFile, { force: true }); } catch { /* ignore */ }
97
+ return null;
98
+ }
99
+ const pid = parseInt(raw, 10);
100
+ if (!Number.isFinite(pid) || pid <= 0) {
101
+ try { rmSync(pidFile, { force: true }); } catch { /* ignore */ }
102
+ return null;
103
+ }
104
+ try {
105
+ process.kill(pid, 0);
106
+ return pid;
107
+ } catch {
108
+ // PID no longer alive — stale PID file.
109
+ try { rmSync(pidFile, { force: true }); } catch { /* ignore */ }
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Send SIGTERM, wait up to `timeoutMs`, then SIGKILL if still alive.
116
+ * Returns true if a signal was successfully delivered (or never needed).
117
+ *
118
+ * Implementation note: Linux PIDs are recycled immediately after a
119
+ * process dies, so `process.kill(pid, 0)` after a kill is an unreliable
120
+ * liveness check — the PID may already belong to a brand-new process.
121
+ * We treat any successful signal delivery as success; if SIGKILL is
122
+ * sent, the original process is certainly dead (uncatchable).
123
+ *
124
+ * Exported for testability.
125
+ */
126
+ export function killAndWait(pid, { timeoutMs = 5000, label = 'process' } = {}) {
127
+ if (!pid) return true;
128
+
129
+ // Phase 1: graceful SIGTERM
130
+ let sigtermOk = false;
131
+ try {
132
+ process.kill(pid, 'SIGTERM');
133
+ sigtermOk = true;
134
+ } catch (err) {
135
+ if (err.code === 'ESRCH') return true; // already dead
136
+ console.log(chalk.yellow(` ! could not SIGTERM ${label} (pid ${pid}): ${err.message}`));
137
+ return false;
138
+ }
139
+
140
+ // Best-effort poll for graceful exit. Note: a SIGTERM-handling process
141
+ // (Express dashboard, Node sleeper) usually exits within ~100ms. We
142
+ // poll for up to `timeoutMs`; if anything responds to kill -0 it may be
143
+ // a recycled PID, so we don't treat that as "still our process".
144
+ const start = Date.now();
145
+ let sawExit = false;
146
+ while (Date.now() - start < timeoutMs) {
147
+ try {
148
+ process.kill(pid, 0);
149
+ } catch (err) {
150
+ if (err.code === 'ESRCH') { sawExit = true; break; }
151
+ }
152
+ spawnSync('sleep', ['0.1']);
153
+ }
154
+
155
+ if (sawExit) {
156
+ // Confirmed gone via ESRCH.
157
+ return true;
158
+ }
159
+
160
+ // Phase 2: escalate to SIGKILL. Even if `kill -0` still succeeds (PID
161
+ // recycled or process truly stuck), SIGKILL is uncatchable and the
162
+ // original process — if it was still our PID — is now dead.
163
+ try {
164
+ process.kill(pid, 'SIGKILL');
165
+ if (sigtermOk) {
166
+ console.log(chalk.yellow(` ! ${label} (pid ${pid}) did not exit gracefully; sent SIGKILL`));
167
+ }
168
+ // Brief settle for the kernel.
169
+ spawnSync('sleep', ['0.2']);
170
+ return true;
171
+ } catch (err) {
172
+ if (err.code === 'ESRCH') return true;
173
+ console.log(chalk.red(` ✗ could not SIGKILL ${label} (pid ${pid}): ${err.message}`));
174
+ return false;
175
+ }
176
+ }
177
+
34
178
  // ---------------------------------------------------------------------------
35
179
  // Version helpers
36
180
  // ---------------------------------------------------------------------------
@@ -41,8 +185,10 @@ const PKG_PLUGIN = '@polderlabs/bizar-plugin';
41
185
  */
42
186
  function currentVersion(pkg) {
43
187
  try {
44
- const out = execSync(`npm ls -g ${pkg} --depth=0 --json`, { stdio: ['ignore', 'pipe', 'ignore'] })
45
- .toString();
188
+ const out = execSync(`npm ls -g ${pkg} --depth=0 --json`, {
189
+ stdio: ['ignore', 'pipe', 'ignore'],
190
+ timeout: 15000,
191
+ }).toString();
46
192
  const parsed = JSON.parse(out);
47
193
  const deps = parsed.dependencies ?? {};
48
194
  return deps[pkg]?.version ?? null;
@@ -57,9 +203,10 @@ function currentVersion(pkg) {
57
203
  */
58
204
  function latestVersion(pkg) {
59
205
  try {
60
- const out = execSync(`npm view ${pkg} version`, { stdio: ['ignore', 'pipe', 'ignore'] })
61
- .toString()
62
- .trim();
206
+ const out = execSync(`npm view ${pkg} version`, {
207
+ stdio: ['ignore', 'pipe', 'ignore'],
208
+ timeout: 15000,
209
+ }).toString().trim();
63
210
  return out || null;
64
211
  } catch {
65
212
  return null;
@@ -76,12 +223,10 @@ function latestVersion(pkg) {
76
223
  * Returns `{ ok: boolean, message: string }`.
77
224
  */
78
225
  function updateOpencode() {
79
- // Try `opencode upgrade` first — that's the canonical updater.
80
226
  const r1 = spawnSync('opencode', ['upgrade'], { stdio: 'inherit' });
81
227
  if (r1.status === 0) {
82
228
  return { ok: true, message: 'opencode updated via `opencode upgrade`' };
83
229
  }
84
- // Fall back to npm. `opencode-ai` is the npm package name.
85
230
  console.log(chalk.dim(' opencode upgrade not available; falling back to npm'));
86
231
  const r2 = spawnSync('npm', ['install', '-g', 'opencode-ai@latest'], { stdio: 'inherit' });
87
232
  if (r2.status === 0) {
@@ -90,26 +235,12 @@ function updateOpencode() {
90
235
  return { ok: false, message: 'opencode update failed — try `opencode upgrade` manually' };
91
236
  }
92
237
 
93
- /**
94
- * Update @polderlabs/bizar globally. Returns `{ ok, message }`.
95
- */
96
- function updateBizar() {
97
- const r = spawnSync('npm', ['install', '-g', `${PKG_MAIN}@latest`], { stdio: 'inherit' });
238
+ function updatePackage(pkg) {
239
+ const r = spawnSync('npm', ['install', '-g', `${pkg}@latest`], { stdio: 'inherit' });
98
240
  if (r.status !== 0) {
99
- return { ok: false, message: `${PKG_MAIN} update failed` };
241
+ return { ok: false, message: `${pkg} update failed` };
100
242
  }
101
- return { ok: true, message: `${PKG_MAIN} updated` };
102
- }
103
-
104
- /**
105
- * Update @polderlabs/bizar-plugin globally. Returns `{ ok, message }`.
106
- */
107
- function updateBizarPlugin() {
108
- const r = spawnSync('npm', ['install', '-g', `${PKG_PLUGIN}@latest`], { stdio: 'inherit' });
109
- if (r.status !== 0) {
110
- return { ok: false, message: `${PKG_PLUGIN} update failed` };
111
- }
112
- return { ok: true, message: `${PKG_PLUGIN} updated` };
243
+ return { ok: true, message: `${pkg} updated` };
113
244
  }
114
245
 
115
246
  // ---------------------------------------------------------------------------
@@ -122,8 +253,6 @@ function updateBizarPlugin() {
122
253
  * fix for the BUGS.md "version skew" trap.
123
254
  */
124
255
  function rerunInstallScript() {
125
- // Find the package root via `npm root -g` + package name. This works
126
- // whether the user installed via `npm install -g` or `npx`.
127
256
  let globalRoot;
128
257
  try {
129
258
  globalRoot = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] })
@@ -142,12 +271,10 @@ function rerunInstallScript() {
142
271
  }
143
272
  return { ok: false, message: 'setup rerun failed' };
144
273
  }
145
-
146
274
  const installSh = join(pkgRoot, 'install.sh');
147
275
  if (process.platform === 'win32' || !existsSync(installSh)) {
148
276
  return { ok: false, message: 'could not locate a compatible setup script to re-run' };
149
277
  }
150
-
151
278
  console.log(chalk.dim(`\n Re-running install script at ${installSh}...`));
152
279
  const r = spawnSync('bash', [installSh], { stdio: 'inherit' });
153
280
  if (r.status !== 0) {
@@ -157,19 +284,141 @@ function rerunInstallScript() {
157
284
  }
158
285
 
159
286
  // ---------------------------------------------------------------------------
160
- // Prompt helpers
287
+ // Instance detection + kill
161
288
  // ---------------------------------------------------------------------------
162
289
 
163
290
  /**
164
- * Interactive prompt for which components to update. Uses inquirer.
165
- * Returns a Set of `opencode`, `bizar`, `plugin` strings.
291
+ * Detect live Bizar instances by reading the well-known PID files.
292
+ * Returns:
293
+ * { service: { pid, label } | null,
294
+ * dashboard: { pid, port } | null,
295
+ * other: Array<{ pid, cmd }> }
166
296
  */
167
- async function promptForUpdates(forceAll) {
168
- if (forceAll) {
169
- return new Set(['opencode', 'bizar', 'plugin']);
297
+ function detectInstances() {
298
+ const servicePid = readLivePid(SERVICE_PID_FILE);
299
+ const dashboardPid = readLivePid(DASHBOARD_PID_FILE);
300
+ let port = null;
301
+ if (dashboardPid && existsSync(DASHBOARD_PORT_FILE)) {
302
+ try {
303
+ port = parseInt(readFileSync(DASHBOARD_PORT_FILE, 'utf8').trim(), 10) || null;
304
+ } catch { /* ignore */ }
170
305
  }
171
- // Dynamic import so the rest of the file can be loaded even if
172
- // inquirer is unavailable for some reason.
306
+ return {
307
+ service: servicePid ? { pid: servicePid, label: `bizar service (pid ${servicePid})` } : null,
308
+ dashboard: dashboardPid ? { pid: dashboardPid, port, label: `bizar-dash web dashboard (pid ${dashboardPid}${port ? `, port ${port}` : ''})` } : null,
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Ask the user to confirm killing the listed instances. Returns true if
314
+ * they confirmed, false if they cancelled. Skipped entirely with `assumeYes`.
315
+ */
316
+ async function confirmKill(instances, { assumeYes } = {}) {
317
+ const lines = [];
318
+ if (instances.service) lines.push(` • ${instances.service.label} — background schedule runner`);
319
+ if (instances.dashboard) lines.push(` • ${instances.dashboard.label} — web UI + API`);
320
+ if (lines.length === 0) return true;
321
+ if (assumeYes) return true;
322
+ console.log('');
323
+ console.log(chalk.bold.yellow(' ⚠ Running Bizar instances detected:'));
324
+ for (const l of lines) console.log(chalk.yellow(l));
325
+ console.log('');
326
+ console.log(chalk.yellow(' These must be stopped before npm can replace the on-disk files.'));
327
+ console.log(chalk.yellow(' Killing them will close any open web tabs / TUI sessions.'));
328
+ console.log('');
329
+ // Best-effort interactive confirmation. Non-TTY (CI / piped input) skips the
330
+ // prompt and aborts — callers should pass `--yes` explicitly in that case.
331
+ if (!process.stdin.isTTY) {
332
+ console.log(chalk.red(' Non-interactive shell detected; rerun with --yes to confirm the kill, or stop the processes manually.'));
333
+ return false;
334
+ }
335
+ try {
336
+ const inquirer = await import('inquirer');
337
+ const { ok } = await inquirer.default.prompt([
338
+ {
339
+ type: 'confirm',
340
+ name: 'ok',
341
+ message: 'Kill the running instance(s) and continue?',
342
+ default: true,
343
+ },
344
+ ]);
345
+ return Boolean(ok);
346
+ } catch (err) {
347
+ console.log(chalk.red(` ! inquirer unavailable: ${err.message}`));
348
+ return false;
349
+ }
350
+ }
351
+
352
+ function killInstances(instances) {
353
+ const out = [];
354
+ if (instances.service) {
355
+ const ok = killAndWait(instances.service.pid, { label: 'bizar service' });
356
+ out.push({ name: 'service', ok });
357
+ if (ok) {
358
+ try { rmSync(SERVICE_PID_FILE, { force: true }); } catch { /* ignore */ }
359
+ }
360
+ }
361
+ if (instances.dashboard) {
362
+ const ok = killAndWait(instances.dashboard.pid, { label: 'bizar-dash' });
363
+ out.push({ name: 'dashboard', ok });
364
+ if (ok) {
365
+ try { rmSync(DASHBOARD_PID_FILE, { force: true }); } catch { /* ignore */ }
366
+ try { rmSync(DASHBOARD_PORT_FILE, { force: true }); } catch { /* ignore */ }
367
+ }
368
+ }
369
+ return out;
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Restart the dashboard after the update completes
374
+ // ---------------------------------------------------------------------------
375
+
376
+ /**
377
+ * Spawn a fresh dashboard process detached, returning the new PID. The
378
+ * dashboard will use the just-updated @polderlabs/bizar-dash code from
379
+ * the global npm install.
380
+ */
381
+ function spawnFreshDashboard({ port } = {}) {
382
+ let globalRoot;
383
+ try {
384
+ globalRoot = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] })
385
+ .toString()
386
+ .trim();
387
+ } catch {
388
+ return { ok: false, message: 'could not locate npm global root' };
389
+ }
390
+ const dashBin = join(globalRoot, ...PKG_DASH.split('/'), 'src', 'cli.mjs');
391
+ if (!existsSync(dashBin)) {
392
+ return {
393
+ ok: false,
394
+ message: `dashboard binary not found at ${dashBin} (was @polderlabs/bizar-dash installed?)`,
395
+ };
396
+ }
397
+ try {
398
+ mkdirSync(BIZAR_HOME, { recursive: true });
399
+ } catch { /* ignore */ }
400
+ const args = [dashBin, 'start', '--bg'];
401
+ if (port) args.push(`--port=${port}`);
402
+ try {
403
+ const child = spawn(process.execPath, args, {
404
+ detached: true,
405
+ stdio: 'ignore',
406
+ env: { ...process.env, BIZAR_AUTO_RESPAWN: '1' },
407
+ });
408
+ child.on('error', () => { /* ignore */ });
409
+ child.unref();
410
+ return { ok: true, message: `dashboard re-spawned (pid ${child.pid})` };
411
+ } catch (err) {
412
+ return { ok: false, message: `dashboard re-spawn failed: ${err.message}` };
413
+ }
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Prompt helpers
418
+ // ---------------------------------------------------------------------------
419
+
420
+ async function promptForUpdates(forceAll) {
421
+ if (forceAll) return new Set(COMPONENTS);
173
422
  const inquirer = await import('inquirer');
174
423
  const { selections } = await inquirer.default.prompt([
175
424
  {
@@ -179,6 +428,7 @@ async function promptForUpdates(forceAll) {
179
428
  choices: [
180
429
  { name: 'opencode (the opencode CLI itself)', value: 'opencode', checked: true },
181
430
  { name: `bizar (${PKG_MAIN})`, value: 'bizar', checked: true },
431
+ { name: `bizar-dash (${PKG_DASH}) — web dashboard`, value: 'dash', checked: true },
182
432
  { name: `plugin (${PKG_PLUGIN})`, value: 'plugin', checked: true },
183
433
  ],
184
434
  },
@@ -190,29 +440,60 @@ async function promptForUpdates(forceAll) {
190
440
  // Main entry point
191
441
  // ---------------------------------------------------------------------------
192
442
 
193
- /**
194
- * Run the update subcommand.
195
- *
196
- * @param {string[]} subargs - arguments after `bizar update`
197
- */
198
443
  export async function runUpdate(subargs = []) {
199
444
  console.log(chalk.bold.hex('#a855f7')('\n ᚦ BIZAR UPDATE ᚦ\n'));
200
445
 
201
- // Show what we know before asking.
446
+ // Parse flags
447
+ const assumeYes = subargs.includes('--yes') || subargs.includes('-y') || subargs.includes('--force');
448
+ const restartAfter = !subargs.includes('--no-restart');
449
+ const forceAll = subargs.includes('--all');
450
+
451
+ // 1. Detect running instances BEFORE doing anything else.
452
+ const instances = detectInstances();
453
+ const runningCount = (instances.service ? 1 : 0) + (instances.dashboard ? 1 : 0);
454
+
455
+ if (runningCount > 0) {
456
+ const ok = await confirmKill(instances, { assumeYes });
457
+ if (!ok) {
458
+ console.log(chalk.yellow('\n Update cancelled — instances still running.'));
459
+ console.log(chalk.dim(' Stop them with `bizar service stop` and `bizar dashboard stop`, then retry.'));
460
+ process.exit(1);
461
+ }
462
+ console.log(chalk.cyan('\n Stopping running instances...'));
463
+ const kills = killInstances(instances);
464
+ for (const k of kills) {
465
+ const marker = k.ok ? chalk.green('✓') : chalk.red('✗');
466
+ console.log(` ${marker} ${k.name} stopped`);
467
+ }
468
+ // Give the kernel a moment to release any open file handles on the
469
+ // npm-global directory before npm tries to replace files.
470
+ spawnSync('sleep', ['0.5']);
471
+ } else {
472
+ console.log(chalk.dim(' No running Bizar instances detected.'));
473
+ }
474
+
475
+ // 2. Show installed vs. latest versions.
202
476
  const cur = {
203
477
  opencode: currentVersion('opencode-ai'),
204
478
  bizar: currentVersion(PKG_MAIN),
479
+ dash: currentVersion(PKG_DASH),
205
480
  plugin: currentVersion(PKG_PLUGIN),
206
481
  };
207
482
  const latest = {
208
483
  opencode: latestVersion('opencode-ai'),
209
484
  bizar: latestVersion(PKG_MAIN),
485
+ dash: latestVersion(PKG_DASH),
210
486
  plugin: latestVersion(PKG_PLUGIN),
211
487
  };
212
488
 
489
+ console.log('');
213
490
  console.log(' Installed vs. latest:');
214
- for (const k of Object.keys(cur)) {
215
- const label = k === 'plugin' ? PKG_PLUGIN : k === 'bizar' ? PKG_MAIN : 'opencode-ai';
491
+ for (const k of COMPONENTS) {
492
+ const label =
493
+ k === 'plugin' ? PKG_PLUGIN :
494
+ k === 'dash' ? PKG_DASH :
495
+ k === 'bizar' ? PKG_MAIN :
496
+ 'opencode-ai';
216
497
  const c = cur[k] ?? '(not installed)';
217
498
  const l = latest[k] ?? '(unknown)';
218
499
  const same = c === l;
@@ -220,19 +501,18 @@ export async function runUpdate(subargs = []) {
220
501
  }
221
502
  console.log('');
222
503
 
223
- // Decide what to update.
504
+ // 3. Decide what to update.
224
505
  let selected;
225
- if (subargs.includes('--all')) {
226
- selected = new Set(['opencode', 'bizar', 'plugin']);
506
+ if (forceAll || assumeYes) {
507
+ selected = new Set(COMPONENTS);
227
508
  } else if (subargs.length === 0) {
228
509
  selected = await promptForUpdates(false);
229
510
  } else {
230
- // Explicit subcommands.
231
- const valid = new Set(['opencode', 'bizar', 'plugin']);
232
- selected = new Set(subargs.filter((a) => valid.has(a)));
511
+ const valid = new Set(COMPONENTS);
512
+ selected = new Set(subargs.filter((a) => !a.startsWith('-') && valid.has(a)));
233
513
  if (selected.size === 0) {
234
514
  console.log(chalk.yellow(` No valid components selected from: ${subargs.join(' ')}`));
235
- console.log(chalk.dim(' Valid components: opencode, bizar, plugin'));
515
+ console.log(chalk.dim(' Valid components: opencode, bizar, dash, plugin'));
236
516
  console.log(chalk.dim(' Run `bizar update --help` for usage.'));
237
517
  process.exit(1);
238
518
  }
@@ -252,15 +532,18 @@ export async function runUpdate(subargs = []) {
252
532
  }
253
533
  if (selected.has('bizar')) {
254
534
  console.log(chalk.bold(` → ${PKG_MAIN}`));
255
- results.push(['bizar', updateBizar()]);
535
+ results.push(['bizar', updatePackage(PKG_MAIN)]);
536
+ }
537
+ if (selected.has('dash')) {
538
+ console.log(chalk.bold(` → ${PKG_DASH}`));
539
+ results.push(['dash', updatePackage(PKG_DASH)]);
256
540
  }
257
541
  if (selected.has('plugin')) {
258
542
  console.log(chalk.bold(` → ${PKG_PLUGIN}`));
259
- results.push(['plugin', updateBizarPlugin()]);
543
+ results.push(['plugin', updatePackage(PKG_PLUGIN)]);
260
544
  }
261
545
 
262
- // Re-run the install script if anything was updated, so the deployed
263
- // plugin source matches the registry.
546
+ // 4. Re-run the install script if anything relevant changed.
264
547
  const anySuccess = results.some(([, r]) => r.ok);
265
548
  if (anySuccess && (selected.has('bizar') || selected.has('plugin'))) {
266
549
  console.log('');
@@ -273,6 +556,20 @@ export async function runUpdate(subargs = []) {
273
556
  }
274
557
  }
275
558
 
559
+ // 5. Restart the dashboard if it was running before the update.
560
+ if (restartAfter && instances.dashboard && (selected.has('bizar') || selected.has('dash'))) {
561
+ console.log('');
562
+ console.log(chalk.cyan(' Restarting dashboard with the new code...'));
563
+ const res = spawnFreshDashboard({ port: instances.dashboard.port || undefined });
564
+ if (res.ok) {
565
+ console.log(chalk.green(` ✓ ${res.message}`));
566
+ } else {
567
+ console.log(chalk.yellow(` ⚠ ${res.message}`));
568
+ console.log(chalk.dim(' Start it manually with `bizar-dash start --bg`.'));
569
+ }
570
+ }
571
+
572
+ // 6. Summary
276
573
  console.log('');
277
574
  console.log(' Summary:');
278
575
  for (const [name, r] of results) {
@@ -286,4 +583,4 @@ export async function runUpdate(subargs = []) {
286
583
  process.exit(1);
287
584
  }
288
585
  console.log(chalk.green('\n ✓ Update complete\n'));
289
- }
586
+ }