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 +25 -0
- package/README.md +24 -0
- package/VERSION +1 -1
- package/bin/baldart.js +14 -0
- package/package.json +1 -1
- package/src/commands/doctor.js +54 -2
- package/src/commands/update.js +15 -1
- package/src/commands/version.js +5 -1
- package/src/utils/state.js +27 -0
- package/src/utils/update-notifier.js +181 -0
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.
|
|
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
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
-
?
|
|
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) {
|
package/src/commands/update.js
CHANGED
|
@@ -246,7 +246,21 @@ async function update(options = {}) {
|
|
|
246
246
|
|
|
247
247
|
newVersion = await git.getFrameworkVersion();
|
|
248
248
|
UI.success(`New version: ${newVersion}`);
|
|
249
|
-
|
|
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.
|
package/src/commands/version.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/src/utils/state.js
CHANGED
|
@@ -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
|
+
};
|