baldart 3.12.0 → 3.13.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable changes to BALDART will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.13.0] - 2026-05-23
9
+
10
+ BALDART closes **two drift visibility gaps in one release**. (1) The CLI binary (installed globally via `npm i -g baldart`) and the framework payload (`.framework/` inside each consumer repo, updated by `baldart update`) drifted silently — users on an old CLI never learned that new flags, new doctor checks, or new adapters had shipped. (2) The framework payload on disk and the `.baldart/state.json` ledger could drift silently when the ledger write failed (the catch in `update.js` was silent until now), when an update flow exited mid-way, or when a manual commit excluded `.baldart/state.json` — `baldart doctor` had no way to detect or fix this. Both gaps are now closed: a **non-invasive CLI self-update notifier** for the first, and **ledger drift detection + self-heal** in the doctor for the second. The unifying theme is "no silent drift" — every layer that can fall out of sync now reports it and offers a one-step fix.
11
+
12
+ ### Added
13
+
14
+ - **`src/utils/update-notifier.js`** — best-effort CLI self-update check. HTTPS GET to `https://registry.npmjs.org/baldart/latest` with a 1.5s timeout, cached at `~/.baldart/cli-update-check.json` (TTL 24h). Banner is printed from the cache on subsequent invocations (the very first run shows nothing — that is intentional; the check never blocks the command). The background refresh fire-and-forget: its timer is `unref()`'d and the underlying socket dies with the process, so the check can never hold the event loop open. Suppression: `BALDART_NO_UPDATE_CHECK=1`, `CI=true`, `NODE_ENV=test`, non-TTY stdout, or `--offline` on `baldart` / `baldart doctor` / `baldart version`.
15
+ - **Top-of-run banner in `bin/baldart.js`** — when a newer baldart npm package has been detected on a previous run, every command prints a 3-line hint at startup (yellow arrow + bold version, then two gray lines): `↑ baldart X.Y.Z available (you have A.B.C)` + `Update with: npm i -g baldart@latest` + `Suppress with: BALDART_NO_UPDATE_CHECK=1`. Zero-latency (reads cache only); no banner if cache is missing or up-to-date.
16
+ - **`CLI` row in `baldart doctor` diagnostic table** — always shown (independent of git/framework state), surfaces drift inline alongside framework version / config / overlays / hooks so the doctor remains the single pane of glass. Severity `warn` when an update is available, `ok` otherwise.
17
+ - **CLI drift in `baldart version` box** — the existing `CLI: vX.Y.Z` line now appends `→ vA.B.C available (npm i -g baldart@latest)` when a newer version was detected.
18
+ - **`State.reconcileInstalledVersion({ to, reason })` in `src/utils/state.js`** — new ledger reconciliation helper. Idempotent (no-op when `from === to`), audit-trailed (writes a `ledger-reconcile` history entry distinct from real `update` events), and reversible (pure local file write). Used by the doctor's new `reconcile-state-ledger` action and available for direct call from future recovery flows.
19
+ - **Ledger drift detection in `baldart doctor`** — `detectState` now compares `.framework/VERSION` against `.baldart/state.json:installed_version` and surfaces a `stateLedgerDrift` state field when they diverge. The action planner inserts a `reconcile-state-ledger` action (`autoOk: true`, idempotent, no remote effects) that one-shot fixes the drift via `State.reconcileInstalledVersion()`. Drift is non-blocking — it pairs with other proposed actions instead of short-circuiting them.
20
+
21
+ ### Changed
22
+
23
+ - **`src/commands/update.js`** — replaced the silent `try { State.recordUpdate(...) } catch (_) {}` on the ledger write with a visible warning + actionable next step. When the write fails or returns an unexpected `installed_version`, the user now sees a yellow warning explaining the drift (framework on disk is at vNEW but ledger still records vOLD) and is told to run `baldart doctor` to reconcile. This is the root cause of most "my doctor says I'm on the wrong version" reports — silent catches were hiding write failures (permissions, EBUSY) for weeks. New behavior makes the failure immediately visible and self-healable.
24
+
25
+ ### Notes
26
+
27
+ - **CLI vs framework — two independent layers.** `npm i -g baldart@latest` updates the binary on disk (this package); `baldart update` updates the *framework payload* inside the consumer repo (the `.framework/` subtree). They are *independent* by design — a consumer may want to update the framework without touching the global CLI (or vice versa). The notifier surfaces drift on the *CLI* layer; the existing `Remote` row in doctor (and the `Remote:` line in `version`) surfaces drift on the *framework* layer. Both layers are now visible at a glance from the same diagnostic surface.
28
+ - **Why not auto-install.** Three reasons we deliberately reject `npx baldart` auto-running `npm i -g`: (1) blast radius — global install impacts *every* project on the user's machine, not just the one they invoked from; (2) permissions — `npm i -g` often needs sudo (without nvm/volta), and a no-args `baldart` cannot trigger sudo prompts silently; (3) reproducibility — `--auto` runs in CI must be deterministic, so auto-updating the CLI mid-run would change behavior unpredictably. The notifier is the right level: visible, dismissible, never invasive.
29
+ - **No new `baldart.config.yml` keys.** The notifier cache lives in `~/.baldart/cli-update-check.json` (per-user, not per-project), so the schema-change propagation rule (per `feedback_schema_change_propagation`) does not apply to this release. Suppression is via environment variable (`BALDART_NO_UPDATE_CHECK=1`) which is intentionally global — disabling the notifier in one project but enabling it in another is not a use case worth modeling.
30
+ - **Backwards-compatible.** Pre-3.13 consumers see the new banner on their next `baldart` run after the npm package upgrades. Consumers that never want the check set `BALDART_NO_UPDATE_CHECK=1` in their shell rc; CI runs are auto-suppressed.
31
+ - **No framework payload changes.** All work is inside `src/` and `bin/` — the framework agents/skills/commands/protocols are unchanged. This is purely a CLI-layer improvement; no consumer files are touched by `baldart update`.
32
+
8
33
  ## [3.12.0] - 2026-05-23
9
34
 
10
35
  BALDART completes the **UI excellence loop**: the `ui-expert` agent is upgraded from a "good but generic" baseline to a world-class UI/UX reviewer/designer, and — crucially — the registry-first discipline gains a **post-intervention coherence gate** so visual coherence is enforced not only when work begins (BLOCKING reads) but also when work ends (BLOCKING completion check). The asymmetry of v3.11.0 (registry read before, no verification after) is closed: every UI change introduced by `ui-expert`, the `ui-design` skill, the `frontend-design` skill, or caught by `code-reviewer` must now reconcile the registry (INDEX + per-component specs + tokens-reference) in the **same change**, never deferring drift to the weekly `ds-drift` routine. This release contains zero schema changes — the cascade reuses `features.has_design_system` + `paths.*` already in place.
package/README.md CHANGED
@@ -156,6 +156,30 @@ last-push info. Use `--offline` to skip the upstream fetch.
156
156
 
157
157
  State updates happen automatically — no manual bookkeeping required.
158
158
 
159
+ ### CLI self-update notifier (new in v3.13.0)
160
+
161
+ The CLI binary (`baldart`, installed globally via `npm i -g baldart`) and the
162
+ framework payload (`.framework/` inside each repo, updated by `baldart update`)
163
+ are two independent layers. Until now the CLI drifted silently — users on an
164
+ old CLI never learned about new flags or new doctor checks.
165
+
166
+ Now every `baldart` run does a best-effort check against `registry.npmjs.org`
167
+ (cached at `~/.baldart/cli-update-check.json` with a 24h TTL, 1.5s network
168
+ timeout, fire-and-forget). When a newer version is available, a one-shot
169
+ banner appears at the top of the run:
170
+
171
+ ```
172
+ ↑ baldart 3.13.0 available (you have 3.12.0)
173
+ Update with: npm i -g baldart@latest
174
+ Suppress with: BALDART_NO_UPDATE_CHECK=1
175
+ ```
176
+
177
+ The notifier never installs anything automatically — global installs can
178
+ require sudo and affect every other project on your machine, so the choice
179
+ stays with you. CLI drift also appears inline in `baldart doctor` (new `CLI`
180
+ row) and in `baldart version`. Auto-suppressed in CI, in non-TTY runs, with
181
+ `--offline`, with `NODE_ENV=test`, or via `BALDART_NO_UPDATE_CHECK=1`.
182
+
159
183
  ### Project Configuration (new in v3.0.0)
160
184
 
161
185
  BALDART skills are **portable across projects**. Instead of hard-coding paths
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.12.0
1
+ 3.13.0
package/bin/baldart.js CHANGED
@@ -3,6 +3,20 @@
3
3
  const { Command } = require('commander');
4
4
  const chalk = require('chalk');
5
5
  const packageJson = require('../package.json');
6
+ const updateNotifier = require('../src/utils/update-notifier');
7
+
8
+ // CLI self-update notifier (v3.13.0+). Best-effort; non-blocking; suppressed
9
+ // in CI / non-TTY / via BALDART_NO_UPDATE_CHECK=1 / when --offline is passed.
10
+ // Prints a one-shot hint at the top of the run if a newer npm version was
11
+ // detected on a previous invocation, then kicks off a background refresh.
12
+ // First-ever run shows nothing — that is intentional; we never block the
13
+ // command on a network call.
14
+ const rawArgsForNotifier = process.argv.slice(2);
15
+ const offlineForNotifier = rawArgsForNotifier.includes('--offline');
16
+ updateNotifier.announceAndRefresh({
17
+ currentVersion: packageJson.version,
18
+ offline: offlineForNotifier,
19
+ });
6
20
 
7
21
  const program = new Command();
8
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "3.12.0",
3
+ "version": "3.13.0",
4
4
  "description": "Claude Agent Framework - Reusable framework for coordinating AI agents and humans in software projects",
5
5
  "bin": {
6
6
  "baldart": "./bin/baldart.js"
@@ -31,6 +31,8 @@ const UI = require('../utils/ui');
31
31
  const State = require('../utils/state');
32
32
  const Hooks = require('../utils/hooks');
33
33
  const LspInstaller = require('../utils/lsp-installer');
34
+ const UpdateNotifier = require('../utils/update-notifier');
35
+ const cliPackageJson = require('../../package.json');
34
36
 
35
37
  const FRAMEWORK_DIR = '.framework';
36
38
  const REPO_DEFAULT = 'antbald/BALDART';
@@ -146,6 +148,8 @@ async function detectState(cwd, opts = {}) {
146
148
  const git = new GitUtils(cwd);
147
149
  const state = {
148
150
  cwd,
151
+ cliVersion: cliPackageJson.version,
152
+ cliUpdate: UpdateNotifier.hint({ currentVersion: cliPackageJson.version }),
149
153
  isGitRepo: await git.isGitRepo(),
150
154
  frameworkPresent: await git.frameworkExists(),
151
155
  legacyLayout: false,
@@ -188,6 +192,19 @@ async function detectState(cwd, opts = {}) {
188
192
  ? `v${ledger.last_pushed_version} (${(ledger.last_push_date || '').slice(0, 10) || '—'})`
189
193
  : null;
190
194
 
195
+ // Ledger drift detection (v3.13.0+): the framework on disk says one thing,
196
+ // the ledger says another. Caused by interrupted updates, manual commits
197
+ // that excluded state.json, or filesystem write failures the old silent
198
+ // catch in update.js used to hide. Self-healable via `reconcile-state-ledger`.
199
+ state.stateLedgerDrift = null;
200
+ if (state.frameworkVersion && ledger.installed_version &&
201
+ state.frameworkVersion !== ledger.installed_version) {
202
+ state.stateLedgerDrift = {
203
+ payload: state.frameworkVersion,
204
+ ledger: ledger.installed_version
205
+ };
206
+ }
207
+
191
208
  state.remote = await describeRemote(git, ledger.framework_repo || REPO_DEFAULT, !!opts.offline);
192
209
  state.commitsAhead = await commitsAheadOfRemote(git);
193
210
  state.workingTreeDirty = await localFrameworkChanges(git);
@@ -313,6 +330,28 @@ function planActions(state) {
313
330
  // Drift is non-blocking — keep checking for other actions after.
314
331
  }
315
332
 
333
+ if (state.stateLedgerDrift) {
334
+ actions.push({
335
+ key: 'reconcile-state-ledger',
336
+ label: `Reconcile state ledger (${state.stateLedgerDrift.ledger} → ${state.stateLedgerDrift.payload})`,
337
+ why: `.framework/VERSION is v${state.stateLedgerDrift.payload} but .baldart/state.json records installed_version=${state.stateLedgerDrift.ledger}. Typical causes: an update flow that exited mid-way (configure declined, stash conflict), a manual commit that excluded .baldart/state.json, or a write failure that the old silent catch in update.js used to hide (fixed in v3.13.0).`,
338
+ autoOk: true, // pure local file write, idempotent, fully reversible
339
+ run: () => {
340
+ const res = State.reconcileInstalledVersion({
341
+ to: state.stateLedgerDrift.payload,
342
+ reason: 'doctor reconcile — payload/ledger drift'
343
+ });
344
+ if (res.changed) {
345
+ UI.success(`Ledger updated: installed_version ${res.from} → ${res.to}`);
346
+ UI.info('History entry "ledger-reconcile" recorded for audit. Commit .baldart/state.json when ready.');
347
+ } else {
348
+ UI.info('No change needed — ledger already matches payload.');
349
+ }
350
+ },
351
+ });
352
+ // Drift is non-blocking — keep checking for other actions after.
353
+ }
354
+
316
355
  if (state.editGateRegistered === false) {
317
356
  actions.push({
318
357
  key: 'register-edit-gate',
@@ -394,6 +433,17 @@ function renderDiagnostic(state) {
394
433
  console.log(`Repository: ${state.cwd}`);
395
434
  console.log();
396
435
 
436
+ // CLI version line — always shown (independent of git repo state). When a
437
+ // newer baldart npm package was detected on a previous run, surface it here
438
+ // so the doctor table is the single pane of glass for "what's stale".
439
+ console.log(statusLine(
440
+ 'CLI',
441
+ state.cliUpdate
442
+ ? `v${state.cliVersion} → v${state.cliUpdate.latest} available (npm i -g baldart@latest)`
443
+ : `v${state.cliVersion}`,
444
+ state.cliUpdate ? 'warn' : 'ok'
445
+ ));
446
+
397
447
  console.log(statusLine('Git repo', state.isGitRepo ? 'yes' : 'no', state.isGitRepo ? 'ok' : 'err'));
398
448
 
399
449
  if (!state.isGitRepo) return;
@@ -429,9 +479,11 @@ function renderDiagnostic(state) {
429
479
  console.log(statusLine(
430
480
  'State ledger',
431
481
  state.statePresent
432
- ? `installed v${state.stateInstalled} (${fmtDate(state.stateInstallDate)})`
482
+ ? (state.stateLedgerDrift
483
+ ? `drift: ledger=v${state.stateLedgerDrift.ledger}, payload=v${state.stateLedgerDrift.payload}`
484
+ : `installed v${state.stateInstalled} (${fmtDate(state.stateInstallDate)})`)
433
485
  : 'not seeded',
434
- state.statePresent ? 'ok' : 'warn'
486
+ state.stateLedgerDrift ? 'warn' : (state.statePresent ? 'ok' : 'warn')
435
487
  ));
436
488
 
437
489
  if (state.stateLastUpdate) {
@@ -246,7 +246,21 @@ async function update(options = {}) {
246
246
 
247
247
  newVersion = await git.getFrameworkVersion();
248
248
  UI.success(`New version: ${newVersion}`);
249
- try { State.recordUpdate({ from: currentVersion, to: newVersion }); } catch (_) { /* non-fatal */ }
249
+ // Update the ledger and VERIFY the write landed. Prior to v3.13.0 this
250
+ // call used a silent catch — write failures (permissions, EBUSY) were
251
+ // hidden, and the user only noticed the drift weeks later via doctor.
252
+ // Now: visible warning + always-actionable next step.
253
+ try {
254
+ const after = State.recordUpdate({ from: currentVersion, to: newVersion });
255
+ if (after.installed_version !== newVersion) {
256
+ UI.warning(`Ledger write returned installed_version=${after.installed_version}, expected ${newVersion}.`);
257
+ UI.info('Run `baldart doctor` to reconcile the ledger.');
258
+ }
259
+ } catch (err) {
260
+ UI.warning(`Could not write .baldart/state.json: ${err.message}`);
261
+ UI.info(`Framework on disk is at v${newVersion} but the ledger still records v${currentVersion}.`);
262
+ UI.info('Run `baldart doctor` to reconcile, or check filesystem permissions on .baldart/state.json.');
263
+ }
250
264
 
251
265
  // Re-apply the pre-update stash (if any). Conflicts mean we leave the
252
266
  // stash intact and tell the user how to resolve — never silently drop.
@@ -1,6 +1,7 @@
1
1
  const GitUtils = require('../utils/git');
2
2
  const UI = require('../utils/ui');
3
3
  const State = require('../utils/state');
4
+ const UpdateNotifier = require('../utils/update-notifier');
4
5
 
5
6
  const REPO_DEFAULT = 'antbald/BALDART';
6
7
  const FRAMEWORK_DIR = '.framework';
@@ -48,6 +49,7 @@ async function version(opts = {}) {
48
49
  const frameworkVersion = await git.getFrameworkVersion();
49
50
  const state = State.load();
50
51
  const cliVersion = require('../../package.json').version;
52
+ const cliUpdate = UpdateNotifier.hint({ currentVersion: cliVersion });
51
53
 
52
54
  // Optionally fetch upstream to compute drift (default: yes).
53
55
  const offline = opts.offline === true;
@@ -75,7 +77,9 @@ async function version(opts = {}) {
75
77
  `Last update: ${fmtDate(state.last_update_date)}`,
76
78
  `Last push: v${state.last_pushed_version || '—'} ${state.last_push_date ? `(${fmtDate(state.last_push_date)})` : ''}`.trim(),
77
79
  '',
78
- `CLI: v${cliVersion}`,
80
+ cliUpdate
81
+ ? `CLI: v${cliVersion} → v${cliUpdate.latest} available (npm i -g baldart@latest)`
82
+ : `CLI: v${cliVersion}`,
79
83
  `Repository: https://github.com/${state.framework_repo || REPO_DEFAULT}`,
80
84
  ].filter((l) => l !== ''));
81
85
  UI.newline();
@@ -106,6 +106,32 @@ function recordUpdate({ from, to }, cwd = process.cwd()) {
106
106
  return state;
107
107
  }
108
108
 
109
+ /**
110
+ * Reconcile the ledger's installed_version with the actual framework version
111
+ * on disk (read from .framework/VERSION). Used by `baldart doctor` when it
112
+ * detects a mismatch between the payload and the ledger — typically caused by
113
+ * an interrupted update flow, a manual commit that excluded state.json, or a
114
+ * filesystem write failure that the silent catch in update.js used to hide.
115
+ *
116
+ * Idempotent: calling with the already-current version is a no-op (no history
117
+ * entry produced). History entry uses event "ledger-reconcile" so the source
118
+ * of the bump is auditable separately from real updates.
119
+ */
120
+ function reconcileInstalledVersion({ to, reason }, cwd = process.cwd()) {
121
+ const state = load(cwd);
122
+ const from = state.installed_version;
123
+ if (from === to) return { state, changed: false };
124
+ state.installed_version = to;
125
+ state.history = appendHistory(state, {
126
+ event: 'ledger-reconcile',
127
+ from,
128
+ to,
129
+ note: reason || 'reconcile ledger with .framework/VERSION'
130
+ });
131
+ save(state, cwd);
132
+ return { state, changed: true, from, to };
133
+ }
134
+
109
135
  function recordPush({ from, to, description }, cwd = process.cwd()) {
110
136
  const state = load(cwd);
111
137
  state.last_pushed_version = to;
@@ -137,6 +163,7 @@ module.exports = {
137
163
  recordInstall,
138
164
  recordUpdate,
139
165
  recordPush,
166
+ reconcileInstalledVersion,
140
167
  readFrameworkVersion,
141
168
  STATE_FILE,
142
169
  STATE_VERSION,
@@ -0,0 +1,181 @@
1
+ /**
2
+ * CLI self-update notifier (v3.13.0+).
3
+ *
4
+ * The framework payload inside `.framework/` is updated by `baldart update`.
5
+ * The CLI itself (this package) is updated by `npm i -g baldart@latest`.
6
+ * Until now those two layers drifted silently — users on an old CLI never
7
+ * learned new flags, new doctor checks, new adapters had shipped.
8
+ *
9
+ * This module surfaces CLI drift WITHOUT auto-installing anything. It is
10
+ * deliberately non-invasive:
11
+ *
12
+ * - Best-effort HTTP check against the npm registry with a short timeout.
13
+ * - Result cached at `~/.baldart/cli-update-check.json` (TTL 24h).
14
+ * - Banner printed from the cache on the NEXT invocation (zero latency on
15
+ * every run). First-ever run shows nothing — that's intentional.
16
+ * - Suppressed in CI, non-TTY, --offline, NODE_ENV=test, or via
17
+ * BALDART_NO_UPDATE_CHECK=1.
18
+ * - Never blocks the process: the background refresh is fire-and-forget,
19
+ * its timer/socket are unref'd so they cannot hold the event loop open.
20
+ *
21
+ * Design rationale (see CHANGELOG v3.13.0): we explicitly DO NOT run
22
+ * `npm i -g baldart@latest` automatically. Auto-updates inside a per-project
23
+ * CLI invocation would (a) impact every other project on the user's machine,
24
+ * (b) potentially require sudo, (c) break determinism in CI runs. The banner
25
+ * gives the user the choice; the cache means they don't pay for it.
26
+ */
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const os = require('os');
31
+
32
+ const REGISTRY_URL = 'https://registry.npmjs.org/baldart/latest';
33
+ const CACHE_DIR = path.join(os.homedir(), '.baldart');
34
+ const CACHE_FILE = path.join(CACHE_DIR, 'cli-update-check.json');
35
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
36
+ const DEFAULT_TIMEOUT_MS = 1500;
37
+
38
+ function isSuppressed({ offline } = {}) {
39
+ if (offline) return true;
40
+ if (process.env.BALDART_NO_UPDATE_CHECK === '1') return true;
41
+ if (process.env.CI === 'true' || process.env.CI === '1') return true;
42
+ if (process.env.NODE_ENV === 'test') return true;
43
+ // `process.stdout.isTTY` is `true` when attached to a terminal and
44
+ // `undefined` (NOT `false`) when not — so we must test for "not strictly
45
+ // true" rather than "=== false", otherwise piped/redirected runs silently
46
+ // bypass suppression.
47
+ if (!(process.stdout && process.stdout.isTTY === true)) return true;
48
+ return false;
49
+ }
50
+
51
+ function readCache() {
52
+ try {
53
+ if (!fs.existsSync(CACHE_FILE)) return null;
54
+ const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
55
+ if (!data || typeof data !== 'object') return null;
56
+ return data;
57
+ } catch (_) {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function writeCache(data) {
63
+ try {
64
+ if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
65
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2) + '\n');
66
+ } catch (_) {
67
+ // Best-effort: a read-only HOME or denied write is not worth surfacing.
68
+ }
69
+ }
70
+
71
+ function cmpSemver(a, b) {
72
+ // Returns 1 if a>b, -1 if a<b, 0 if equal. Strips prerelease and build
73
+ // metadata; only X.Y.Z is compared. That is enough for a "is there a newer
74
+ // stable on npm?" prompt — anything fancier belongs in `semver`, and we are
75
+ // deliberately staying dep-free here.
76
+ if (!a || !b) return 0;
77
+ const norm = (v) => String(v).split(/[-+]/)[0].split('.').map((n) => Number(n) || 0);
78
+ const pa = norm(a);
79
+ const pb = norm(b);
80
+ for (let i = 0; i < 3; i++) {
81
+ const x = pa[i] || 0, y = pb[i] || 0;
82
+ if (x > y) return 1;
83
+ if (x < y) return -1;
84
+ }
85
+ return 0;
86
+ }
87
+
88
+ async function fetchLatest(timeoutMs) {
89
+ if (typeof fetch !== 'function') return null; // Node < 18 fallback — node engine constraint covers this, but defensive.
90
+ const ctrl = new AbortController();
91
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
92
+ if (typeof timer.unref === 'function') timer.unref();
93
+ try {
94
+ const res = await fetch(REGISTRY_URL, {
95
+ signal: ctrl.signal,
96
+ headers: { Accept: 'application/json' },
97
+ });
98
+ if (!res.ok) return null;
99
+ const body = await res.json();
100
+ return body && body.version ? String(body.version) : null;
101
+ } catch (_) {
102
+ return null;
103
+ } finally {
104
+ clearTimeout(timer);
105
+ }
106
+ }
107
+
108
+ async function refresh({ currentVersion, timeoutMs = DEFAULT_TIMEOUT_MS, force = false } = {}) {
109
+ const cached = readCache();
110
+ const now = Date.now();
111
+ if (!force && cached && cached.checked_at) {
112
+ const age = now - new Date(cached.checked_at).getTime();
113
+ if (Number.isFinite(age) && age < DEFAULT_TTL_MS) return cached;
114
+ }
115
+ const latest = await fetchLatest(timeoutMs);
116
+ if (!latest) return cached; // Keep stale cache rather than zeroing it.
117
+ const data = {
118
+ checked_at: new Date().toISOString(),
119
+ latest,
120
+ current_at_check: currentVersion,
121
+ };
122
+ writeCache(data);
123
+ return data;
124
+ }
125
+
126
+ function hint({ currentVersion } = {}) {
127
+ if (!currentVersion) return null;
128
+ const cached = readCache();
129
+ if (!cached || !cached.latest) return null;
130
+ if (cmpSemver(cached.latest, currentVersion) <= 0) return null;
131
+ return {
132
+ latest: cached.latest,
133
+ current: currentVersion,
134
+ checked_at: cached.checked_at,
135
+ };
136
+ }
137
+
138
+ function printBanner(info) {
139
+ if (!info) return;
140
+ const chalk = require('chalk');
141
+ const line1 = chalk.yellow('↑') + ' ' + chalk.bold(`baldart ${info.latest}`) +
142
+ chalk.gray(` available (you have ${info.current})`);
143
+ const line2 = chalk.gray(' Update with: ') + chalk.cyan('npm i -g baldart@latest');
144
+ const line3 = chalk.gray(' Suppress with: BALDART_NO_UPDATE_CHECK=1');
145
+ console.log('');
146
+ console.log(line1);
147
+ console.log(line2);
148
+ console.log(line3);
149
+ console.log('');
150
+ }
151
+
152
+ function backgroundCheck({ currentVersion, offline = false } = {}) {
153
+ // Fire-and-forget. Returns immediately; never throws; cannot block exit.
154
+ if (isSuppressed({ offline })) return;
155
+ refresh({ currentVersion }).catch(() => {});
156
+ }
157
+
158
+ /**
159
+ * Cheap entry point for bin/baldart.js: print the banner if a cached update
160
+ * is known, then kick off a refresh for next time. No-op when suppressed.
161
+ */
162
+ function announceAndRefresh({ currentVersion, offline = false } = {}) {
163
+ if (isSuppressed({ offline })) return null;
164
+ const info = hint({ currentVersion });
165
+ if (info) printBanner(info);
166
+ backgroundCheck({ currentVersion, offline });
167
+ return info;
168
+ }
169
+
170
+ module.exports = {
171
+ isSuppressed,
172
+ refresh,
173
+ hint,
174
+ printBanner,
175
+ backgroundCheck,
176
+ announceAndRefresh,
177
+ // Exported for tests / introspection:
178
+ _cmpSemver: cmpSemver,
179
+ _CACHE_FILE: CACHE_FILE,
180
+ _DEFAULT_TTL_MS: DEFAULT_TTL_MS,
181
+ };