baldart 3.31.0 → 3.33.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 +53 -0
- package/README.md +12 -1
- package/VERSION +1 -1
- package/bin/baldart.js +14 -5
- package/framework/.claude/skills/baldart-update/SKILL.md +81 -15
- package/package.json +1 -1
- package/src/commands/add.js +66 -24
- package/src/commands/update.js +124 -15
- package/src/commands/version.js +42 -0
- package/src/utils/__tests__/classify-divergence.test.js +82 -0
- package/src/utils/git.js +45 -1
- package/src/utils/hooks.js +33 -0
- package/src/utils/overlay-merger.js +41 -0
- package/src/utils/symlinks.js +10 -1
- package/src/utils/ui.js +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,59 @@ 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.33.0] - 2026-05-30
|
|
9
|
+
|
|
10
|
+
Closes a **structural silent-partial-install** failure mode (observed on the `mayo` consumer, 3.28→3.31, still live in 3.32.0; same class as the historic v3.27.1 bug): `add` was **non-atomic** — the hook registration ran *after* optional blocking prompts (git aliases, configure). In a non-TTY context (Claude's Bash tool never has a TTY on stdin) or on any interruption, those `inquirer` prompts throw/hang, so `.framework/` + `state.json` landed on the new version while `Hooks.registerAll` never ran — a half-install that `baldart status` still reported as "Valid". The fix makes install atomic (correctness-critical mutations run **before** every optional prompt), adds a **post-flight hook assert** that refuses to declare success with any active hook missing, and finally exposes `add --yes` so agents/CI can install unattended. Two additive, non-breaking follow-ups ship alongside. **No new `baldart.config.yml` keys** — `--yes` is a CLI flag, so the schema-propagation rule does not apply.
|
|
11
|
+
|
|
12
|
+
### Fixed — `add` is now atomic + never declares a half-install successful
|
|
13
|
+
|
|
14
|
+
- **[src/commands/add.js](src/commands/add.js)**: `Hooks.registerAll` is **reordered ahead of** the optional git-aliases and configure prompts. Hook registration determines install correctness and must never sit behind a blocking prompt — if `add` is interrupted at an optional prompt, the hooks are already in place. The drift callback now passes `{ autoYes: nonInteractive }` (parity with `update.js`), so a drifted hook in non-TTY no longer throws.
|
|
15
|
+
- **[src/utils/hooks.js](src/utils/hooks.js)**: new exported `verifyAll(cwd)` — a pure read over `getStatus` returning `{ ok, malformed, missing, path }`. It iterates only the **active** `HOOK_REGISTRY` (commented-out entries like `capture-detect` excluded for free). A malformed `settings.json` counts as a failure (registration was skipped).
|
|
16
|
+
- **[src/commands/add.js](src/commands/add.js)**: **post-flight assert** before the success summary — re-reads `settings.json`, attempts a single best-effort re-registration if anything is missing, then exits non-zero ("Install is NOT complete") if a hook is still absent. Covers `update --reset` too, since that path delegates to `add(undefined, { yes: true })`.
|
|
17
|
+
|
|
18
|
+
### Added — `add --yes` (non-interactive install for CI / agents)
|
|
19
|
+
|
|
20
|
+
- **[bin/baldart.js](bin/baldart.js)**: registers `-y, --yes` on the `add` subcommand (the underlying `add(repo, options)` already honored `options.yes`, it was just unreachable from the CLI). With `--yes`, `add` auto-confirms optional prompts and passes `{ nonInteractive: true }` to `configure` so its wizard writes autodetected values without blocking.
|
|
21
|
+
- **[src/commands/add.js](src/commands/add.js)**: `add --yes` on a repo that **already has** `.framework/` now fails fast with a clear pointer to `npx baldart update --reset --yes --i-know` instead of hitting the deliberately-interactive destructive "Remove and reinstall?" prompt (which would throw under non-TTY and surface as a generic failure). Clean installs only; reinstall goes through `--reset`.
|
|
22
|
+
|
|
23
|
+
### Added — divergence classifier flags overlay-covered edits (`--reset` is safe)
|
|
24
|
+
|
|
25
|
+
- **[src/utils/git.js](src/utils/git.js)**: `classifyDivergence` now tags each `custom-overlay-able` commit with `overlayCovered` — true when **every** framework file it touches already has a matching overlay in `.baldart/overlays/`. New pure static `overlayRelForFrameworkFile(path)` mirrors the `overlay.js` path conventions (unit-tested).
|
|
26
|
+
- **[src/commands/update.js](src/commands/update.js)**: in the overlay-able divergence branch, when all commits are `overlayCovered` the UI/JSON now recommend `--reset --yes --i-know` as the **safe** resolution (intent already preserved in the overlays) and note that `scaffold-overlays` would create redundant skeletons. New `overlay_already_covered` field in the `--json` output.
|
|
27
|
+
|
|
28
|
+
### Added — deterministic `overlay drift` via base-sha backfill
|
|
29
|
+
|
|
30
|
+
- **[src/utils/overlay-merger.js](src/utils/overlay-merger.js)**: new `ensureBaseFileShaInOverlay(overlayPath, baseSha)` — **additive-only, idempotent, fail-safe**: stamps `base_file_sha` into an overlay's frontmatter when absent (never overwrites, never fabricates frontmatter). Called from the merger in **[src/utils/symlinks.js](src/utils/symlinks.js)** `_generateOverlayedFile` at `add`/`update` time, so `baldart overlay drift` becomes deterministic for **every** overlay — not just freshly-scaffolded ones (hand-written / pre-v3.19.0 overlays were "unknown" to drift).
|
|
31
|
+
|
|
32
|
+
### Tests
|
|
33
|
+
|
|
34
|
+
- **[scripts/test-add-postflight.sh](scripts/test-add-postflight.sh)**: new — encodes the post-flight **invariant** (a `settings.json` missing an active hook → `verifyAll().ok === false`; the dropped hook in the test is literally `baldart-overlay-telemetry`, the mayo failure). Fails on pre-v3.33.0 code (no `verifyAll`).
|
|
35
|
+
- **[scripts/test-overlay-backfill.sh](scripts/test-overlay-backfill.sh)**: new — backfill is additive, idempotent, never overwrites an existing sha, never fabricates frontmatter, fail-safe.
|
|
36
|
+
- **[src/utils/__tests__/classify-divergence.test.js](src/utils/__tests__/classify-divergence.test.js)**: extended with overlay-path-mapping fixtures (38 assertions, all green).
|
|
37
|
+
|
|
38
|
+
### Changed — `/baldart-update` skill
|
|
39
|
+
|
|
40
|
+
- **[framework/.claude/skills/baldart-update/SKILL.md](framework/.claude/skills/baldart-update/SKILL.md)**: post-flight report (step 4) documents the new CLI hook assert in `add`/`--reset`; `last_verified` v3.32.0 → v3.33.0 (`hook_registry_entries`/`update_js_prompts` unchanged).
|
|
41
|
+
|
|
42
|
+
## [3.32.0] - 2026-05-30
|
|
43
|
+
|
|
44
|
+
Makes the CLI **agent-native at the output layer**: `npx baldart version --json` and `npx baldart update --json --yes` now emit a single **machine-readable JSON object** on stdout (schemas `baldart.version/1` / `baldart.update/1`), with every human line routed to stderr. This closes the last fragile seam in the autonomous-update path — the `/baldart-update` skill previously had to **regex-scrape human-readable prose** (the v3.26.0 wording change had already forced a dual-format tolerant parser in Step 0). An agent now reads a field instead of matching a sentence. Borrowed from the [CLI-Anything](https://github.com/HKUDS/CLI-Anything) philosophy (`--json` on every command) — but BALDART is already a headless CLI, so this is a thin, hand-rolled output layer, not a generated wrapper. **No new `baldart.config.yml` keys** — `--json` is a CLI flag, not a config key, so the schema-propagation rule does not apply.
|
|
45
|
+
|
|
46
|
+
### Added — `--json` structured output
|
|
47
|
+
|
|
48
|
+
- **[src/commands/version.js](src/commands/version.js)**: `--json` emits `{schema:"baldart.version/1", installed, installed_version, remote_version, aligned, offline, fetched, uncommitted_files, commits_ahead/behind, cli_version, cli_latest, cli_update_available, repository, last_update_date, last_pushed_version, last_push_date}` — exactly the facts the human box shows. Framework-not-installed and error paths emit `{installed:false|null, error, …}` so the agent always gets parseable stdout.
|
|
49
|
+
- **[src/commands/update.js](src/commands/update.js)**: `--json` emits a single `{schema:"baldart.update/1", ok, action, …}` result at **every** terminal point through one `emitUpdateJson` chokepoint (all-or-nothing: a missed exit would leave stdout empty). `ok:true` ⟺ the framework is now at the remote version (`action` `updated` / `already-current`); every refusal/failure is `ok:false` with `action` (`divergence-refused`, `divergence-scaffolded`, `aborted`, `failed`, `framework-missing`, `usage-error`), `reason`, and a `next_command` the human can re-run. `--json` **requires `--yes`** (so no prompt can block) and **rejects `--reset`** (the nuclear path stays an interactive human escape hatch).
|
|
50
|
+
- **[src/utils/ui.js](src/utils/ui.js)**: new `UI.setJsonMode(on)` redirects every `console.log` the UI helpers emit to **stderr** (reversible), keeping stdout clean for the JSON. `ora` spinners already write to stderr, so they need no change. The JSON itself is written with `process.stdout.write`, never `console.log`, so it survives the redirect.
|
|
51
|
+
|
|
52
|
+
### Changed — autonomous `/baldart-update` reads JSON, not prose
|
|
53
|
+
|
|
54
|
+
- **[framework/.claude/skills/baldart-update/SKILL.md](framework/.claude/skills/baldart-update/SKILL.md)**: Step 0 now **prefers** `version --json` (field reads) and **falls back** to the dual-format text parser when the global CLI predates v3.32.0 (`--json` unknown / non-JSON stdout) — the same "new skill + old global CLI" transition window the text parser already guards. Autonomous mode runs `update --json --yes`, parses the single result object (`ok` / `action` / `installed_after` / `backup_tag` / `next_command`), and keeps the **exit code as the safety backstop**. The post-flight assert cross-checks `installed_after` against on-disk `.framework/VERSION` and the Step 0 `remote_version`. `last_verified` v3.31.0 → v3.32.0; added a **JSON-OUTPUT NOTE** recording that `--json` is a flag, not a config key.
|
|
55
|
+
|
|
56
|
+
### Fixed — self-relaunch flag forwarding
|
|
57
|
+
|
|
58
|
+
- **[src/commands/update.js](src/commands/update.js)** `maybeRelaunchUnderLatest`: the stale-CLI self-relaunch now forwards `--json` **and** `--on-divergence <strategy>` to the `npx baldart@latest` child. The latter was a pre-existing latent drop (an agent's non-interactive divergence strategy would silently vanish on relaunch); both are fixed together.
|
|
59
|
+
- **[bin/baldart.js](bin/baldart.js)**: the CLI self-update notifier's stdout `announce` is suppressed under `--json` so it cannot pollute the single JSON object.
|
|
60
|
+
|
|
8
61
|
## [3.31.0] - 2026-05-30
|
|
9
62
|
|
|
10
63
|
Gives `/baldart-update` an **autonomous mode** so agents and scheduled routines can bring the framework up-to-date with **zero human interaction** — closing the gap that the skill, as shipped, was *purely* human-in-the-loop (it replicated every CLI decision point as a chat confirmation, which an unattended agent cannot answer). The new mode is **auto-detected** from a positive automation signal and keeps every one of the CLI's safety gates: it drives `npx baldart update --yes` and **stops hard** (never forces a merge or reset) the moment the CLI refuses on custom divergence or a conflict — exactly the calls a human should own. **No new `baldart.config.yml` keys** — `BALDART_AUTONOMOUS` is a runtime env var, not a config flag, so the schema-propagation rule does not apply.
|
package/README.md
CHANGED
|
@@ -456,6 +456,14 @@ Update framework to latest version.
|
|
|
456
456
|
- Updates via Git subtree
|
|
457
457
|
- Verifies symlinks
|
|
458
458
|
|
|
459
|
+
**For agents / CI (v3.32.0+):** `npx baldart update --json --yes` emits a single
|
|
460
|
+
machine-readable result object on stdout (`{schema:"baldart.update/1", ok,
|
|
461
|
+
action, installed_after, backup_tag, next_command, …}`) with all human output on
|
|
462
|
+
stderr — no prose to scrape. `ok:true` ⟺ the framework is now at the remote
|
|
463
|
+
version. `--json` requires `--yes` and rejects `--reset`. Pair with
|
|
464
|
+
`--on-divergence pull|scaffold-overlays|abort` for non-interactive divergence
|
|
465
|
+
resolution.
|
|
466
|
+
|
|
459
467
|
### `npx baldart` (no args) / `doctor` (v3.2.0+)
|
|
460
468
|
|
|
461
469
|
Smart diagnostic that detects the install state and proposes the next sensible
|
|
@@ -474,7 +482,10 @@ npx baldart doctor # explicit alias (same behaviour)
|
|
|
474
482
|
|
|
475
483
|
Show installed framework version, install date, drift from remote (commits
|
|
476
484
|
ahead/behind), uncommitted-files count in `.framework/`, and last-push info.
|
|
477
|
-
Use `--offline` to skip the upstream fetch when offline.
|
|
485
|
+
Use `--offline` to skip the upstream fetch when offline. Use `--json`
|
|
486
|
+
(v3.32.0+) for a machine-readable object (`schema:"baldart.version/1"`) on
|
|
487
|
+
stdout — agents read `installed_version` / `remote_version` / `aligned`
|
|
488
|
+
instead of scraping the human box.
|
|
478
489
|
|
|
479
490
|
### `npx baldart push` / `/baldart-push` (v3.1.0+)
|
|
480
491
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.33.0
|
package/bin/baldart.js
CHANGED
|
@@ -43,10 +43,16 @@ const CLI_VERSION = resolveVersion();
|
|
|
43
43
|
// command on a network call.
|
|
44
44
|
const rawArgsForNotifier = process.argv.slice(2);
|
|
45
45
|
const offlineForNotifier = rawArgsForNotifier.includes('--offline');
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
// In --json mode STDOUT must carry exactly one JSON object — the notifier hint
|
|
47
|
+
// would pollute it. Suppress the announce entirely (the refresh is harmless but
|
|
48
|
+
// the announce prints to stdout). (v3.32.0+)
|
|
49
|
+
const jsonForNotifier = rawArgsForNotifier.includes('--json');
|
|
50
|
+
if (!jsonForNotifier) {
|
|
51
|
+
updateNotifier.announceAndRefresh({
|
|
52
|
+
currentVersion: CLI_VERSION,
|
|
53
|
+
offline: offlineForNotifier,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
50
56
|
|
|
51
57
|
const program = new Command();
|
|
52
58
|
|
|
@@ -59,6 +65,7 @@ program
|
|
|
59
65
|
.command('add [repo]')
|
|
60
66
|
.description('Install the framework in your project')
|
|
61
67
|
.option('-b, --branch <branch>', 'Branch to use', 'main')
|
|
68
|
+
.option('-y, --yes', 'Non-interactive install (CI / agents): auto-confirm all optional prompts and skip the interactive configure wizard. Refuses to reinstall over an existing .framework/ — use `update --reset` for that.')
|
|
62
69
|
.action(async (repo, options) => {
|
|
63
70
|
const addCommand = require('../src/commands/add');
|
|
64
71
|
await addCommand(repo || 'antbald/BALDART', options);
|
|
@@ -73,6 +80,7 @@ program
|
|
|
73
80
|
.option('--reset', 'Nuclear option: rm -rf .framework/ + fresh install, preserving baldart.config.yml + .baldart/ + custom .claude/{agents,skills,commands}/. Requires clean working tree.')
|
|
74
81
|
.option('--i-know', 'Acknowledges --reset will wipe untracked/ignored files inside .framework/ (required to use --reset --yes).')
|
|
75
82
|
.option('--on-divergence <strategy>', 'Non-interactive resolution when the consumer has local commits on .framework/ (for agents / CI): "scaffold-overlays" (auto-create overlay skeletons, then stop), "pull" (keep commits + merge — non-destructive), or "abort".')
|
|
83
|
+
.option('--json', 'Machine-readable output (agents / CI): emit a single JSON result object on stdout, route all human output to stderr. Requires --yes (non-interactive); rejects --reset.')
|
|
76
84
|
.action(async (options) => {
|
|
77
85
|
const updateCommand = require('../src/commands/update');
|
|
78
86
|
await updateCommand(options);
|
|
@@ -90,9 +98,10 @@ program
|
|
|
90
98
|
.command('version')
|
|
91
99
|
.description('Show installed framework version + drift from remote + last-push info')
|
|
92
100
|
.option('--offline', 'Skip the upstream fetch (no remote drift report)')
|
|
101
|
+
.option('--json', 'Machine-readable output: emit a single JSON object on stdout, route human output to stderr (for agents / CI).')
|
|
93
102
|
.action(async (options) => {
|
|
94
103
|
const versionCommand = require('../src/commands/version');
|
|
95
|
-
await versionCommand({ offline: !!options.offline });
|
|
104
|
+
await versionCommand({ offline: !!options.offline, json: !!options.json });
|
|
96
105
|
});
|
|
97
106
|
|
|
98
107
|
program
|
|
@@ -15,7 +15,17 @@ this skill needs an update is left to a human reviewer.
|
|
|
15
15
|
update_js_prompts: 6
|
|
16
16
|
hook_registry_entries: 4
|
|
17
17
|
config_template_top_level_keys: features,git,identity,lsp,paths,stack,tools,version
|
|
18
|
-
last_verified: v3.
|
|
18
|
+
last_verified: v3.33.0
|
|
19
|
+
-->
|
|
20
|
+
|
|
21
|
+
<!--
|
|
22
|
+
JSON-OUTPUT NOTE (v3.32.0)
|
|
23
|
+
`npx baldart version --json` and `npx baldart update --json --yes` emit a single
|
|
24
|
+
machine-readable object on stdout (schemas `baldart.version/1` /
|
|
25
|
+
`baldart.update/1`), with all human output on stderr. The skill PREFERS these
|
|
26
|
+
over regex-scraping prose, but keeps the text parser as a fallback for the
|
|
27
|
+
"new skill + old global CLI" transition window. `--json` is a CLI FLAG, not a
|
|
28
|
+
baldart.config.yml key — the schema-change propagation rule does NOT apply.
|
|
19
29
|
-->
|
|
20
30
|
|
|
21
31
|
<!--
|
|
@@ -116,27 +126,46 @@ touches the user's custom content.**
|
|
|
116
126
|
global CLI crossing the v3.27.0 payload boundary → STOP and report; do not
|
|
117
127
|
launch).
|
|
118
128
|
- Aligned / already up-to-date → report "already current", exit success.
|
|
119
|
-
2. **Launch directly — no preview/stash confirmation
|
|
129
|
+
2. **Launch directly — no preview/stash confirmation. Prefer `--json`
|
|
130
|
+
(v3.32.0+) so the result is a parseable object, not prose:**
|
|
120
131
|
```bash
|
|
121
|
-
npx baldart update --yes
|
|
132
|
+
npx baldart update --json --yes
|
|
122
133
|
```
|
|
123
134
|
Use the Bash tool with `timeout: 600000`. `--yes` implies `--auto-stash`,
|
|
124
|
-
so a dirty tree is handled (stashed + re-applied) without asking.
|
|
135
|
+
so a dirty tree is handled (stashed + re-applied) without asking. `--json`
|
|
136
|
+
requires `--yes` (it refuses interactively) and routes all human output to
|
|
137
|
+
stderr — **stdout carries exactly one JSON object**.
|
|
125
138
|
**Do NOT add `--on-divergence pull` or `--reset`.** Bare `--yes` is the
|
|
126
139
|
safe ceiling: the CLI auto-resolves only mechanical noise and *refuses*
|
|
127
140
|
(exits non-zero) on real custom divergence.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
141
|
+
|
|
142
|
+
**Fallback:** if the CLI predates v3.32.0 it rejects `--json` (unknown
|
|
143
|
+
option, non-zero exit, empty/garbled stdout). Re-run without it
|
|
144
|
+
(`npx baldart update --yes`) and fall back to exit-code + stdout-grep
|
|
145
|
+
interpretation. Never let a missing `--json` block the update.
|
|
146
|
+
3. **Interpret the result — JSON first, exit code as the safety backstop:**
|
|
147
|
+
- With `--json`, parse the single stdout object. `ok === true` (action
|
|
148
|
+
`updated` or `already-current`) → success, go to post-flight using
|
|
149
|
+
`installed_after` / `backup_tag`. `ok === false` → the CLI refused or
|
|
150
|
+
failed; read `action` (`divergence-refused`, `divergence-scaffolded`,
|
|
151
|
+
`failed`, `framework-missing`, `usage-error`), `reason`, and
|
|
152
|
+
`next_command`. Report them verbatim.
|
|
153
|
+
- The **exit code is still the contract** regardless of `--json`:
|
|
154
|
+
- **Exit 0** → update applied (or already aligned). Go to post-flight.
|
|
155
|
+
- **Exit non-zero** → the CLI hit a wall it will not cross unattended:
|
|
156
|
+
custom-overlay-able / real-custom divergence, a subtree-pull conflict, or
|
|
157
|
+
a stale-CLI halt. **STOP. Do NOT retry with `--on-divergence` or
|
|
158
|
+
`--reset`.** Surface the JSON `reason` + `next_command` (or, in fallback
|
|
159
|
+
mode, the verbatim stderr) so the human who reads the routine log knows
|
|
160
|
+
exactly what needs a hands-on decision. Forcing a merge or a reset
|
|
161
|
+
unattended is precisely the silent destruction this mode forbids.
|
|
136
162
|
4. **Post-flight assert (MANDATORY)** — identical to interactive Step 4:
|
|
137
163
|
`cat .framework/VERSION`, compare to the pre-update version. If the CLI
|
|
138
164
|
exited 0 but VERSION did not change (and stdout is not "Already up to
|
|
139
|
-
date"), **FAIL LOUD** — do not report success.
|
|
165
|
+
date"), **FAIL LOUD** — do not report success. With `--json` this is a
|
|
166
|
+
direct check: the result's `installed_after` MUST equal both
|
|
167
|
+
`.framework/VERSION` and the Step 0 `remote_version`; any mismatch (e.g.
|
|
168
|
+
`ok:true` but `installed_after` ≠ on-disk VERSION) is the same FAIL LOUD.
|
|
140
169
|
5. **Report** — emit a structured, log-friendly summary (no questions):
|
|
141
170
|
version delta, backup tag + rollback command, any agent-layout migration
|
|
142
171
|
(v3.27.0+), and — on a non-zero exit — the verbatim CLI stderr plus the
|
|
@@ -260,7 +289,36 @@ post-flight assert:
|
|
|
260
289
|
npx baldart version
|
|
261
290
|
```
|
|
262
291
|
|
|
263
|
-
|
|
292
|
+
#### Preferred: structured `--json` (v3.32.0+) — NO regex
|
|
293
|
+
|
|
294
|
+
As of v3.32.0 the CLI emits a machine-readable object — read fields instead
|
|
295
|
+
of scraping prose:
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
npx baldart version --json 2>/dev/null
|
|
299
|
+
# → {"schema":"baldart.version/1","installed":true,"installed_version":"3.32.0",
|
|
300
|
+
# "remote_version":"3.33.0","aligned":false,"offline":false,"fetched":true,
|
|
301
|
+
# "uncommitted_files":0,"cli_version":"3.32.0","cli_latest":null,
|
|
302
|
+
# "cli_update_available":false,"repository":"https://github.com/antbald/BALDART",...}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Decision logic on the JSON:
|
|
306
|
+
- `installed === false` → framework not installed → STOP, instruct `npx baldart add`.
|
|
307
|
+
- `aligned === true` → already up-to-date (`installed_version`). STOP.
|
|
308
|
+
- `aligned === false` AND `remote_version !== "unknown"` → continue (save
|
|
309
|
+
`installed_version` for the post-flight assert, `remote_version` as target).
|
|
310
|
+
- `offline === true` / `fetched === false` → ask the user how to proceed.
|
|
311
|
+
- Still apply the **CLI version drift gate** below using `cli_version` /
|
|
312
|
+
`cli_latest` (same semver logic).
|
|
313
|
+
|
|
314
|
+
**Fallback (BLOCKING for the transition window):** if `version --json` errors
|
|
315
|
+
(non-zero exit, unknown-option message, or stdout that is NOT valid JSON), the
|
|
316
|
+
global CLI predates v3.32.0. Do **not** abort — fall back to the **Parser
|
|
317
|
+
tollerante** below, which scrapes the human-readable output. This is the same
|
|
318
|
+
"new skill + old global CLI" case the dual-format text parser already exists
|
|
319
|
+
for. Never remove the text parser.
|
|
320
|
+
|
|
321
|
+
**Parser tollerante (fallback)** — the output format changed in v3.26.0 (version-compare
|
|
264
322
|
authority instead of subtree-merge commit count). The skill must accept BOTH
|
|
265
323
|
the new and legacy formats so it works during the transition window where
|
|
266
324
|
the consumer has the new skill but an older global CLI.
|
|
@@ -440,7 +498,15 @@ Report to the user, in order:
|
|
|
440
498
|
3. **CLI self-upgrade**: if stdout mentions "Auto-relaunching via npx
|
|
441
499
|
baldart@…", surface it once so the user knows the CLI bumped too.
|
|
442
500
|
4. **Hooks reconciled**: any hook backfilled (parse "Auto-registered N
|
|
443
|
-
missing hook(s)" or per-line "Registered hook" from stdout).
|
|
501
|
+
missing hook(s)" or per-line "Registered hook" from stdout). Since v3.33.0
|
|
502
|
+
the CLI also runs a **post-flight hook assert** at the end of `add` (and
|
|
503
|
+
therefore of `update --reset`, which delegates to `add`): it re-reads
|
|
504
|
+
`.claude/settings.json` and refuses to declare success — exiting non-zero
|
|
505
|
+
with "Install is NOT complete" — if any active `HOOK_REGISTRY` entry is
|
|
506
|
+
missing. So a clean `--reset` can no longer leave a half-install that
|
|
507
|
+
reports "Valid" (the mayo 3.28→3.31 / v3.27.1 failure class). If you see
|
|
508
|
+
that error, the repo is in an inconsistent state: run `npx baldart doctor`
|
|
509
|
+
to repair, do NOT report success.
|
|
444
510
|
5. **Agent layout migration (v3.27.0+)**: parse stdout for lines matching
|
|
445
511
|
`agent generated (overlay applied): .claude/agents/<name>.md → ../../.baldart/generated/agents/<name>.md`.
|
|
446
512
|
Pre-v3.27.0 overlay-merged agents (`coder`, `ui-expert`, `code-reviewer`,
|
package/package.json
CHANGED
package/src/commands/add.js
CHANGED
|
@@ -44,6 +44,15 @@ async function add(repo, options) {
|
|
|
44
44
|
const exists = await git.frameworkExists();
|
|
45
45
|
if (exists) {
|
|
46
46
|
UI.warning('Framework already installed!');
|
|
47
|
+
// A non-interactive `add --yes` must never block on (or auto-confirm) the
|
|
48
|
+
// destructive "Remove and reinstall?" prompt. Reinstalling is an explicit
|
|
49
|
+
// user decision routed through `update --reset` (which rm -rf's
|
|
50
|
+
// `.framework/` itself, so this branch is never reached on that path).
|
|
51
|
+
// Fail fast with a clear pointer instead of a generic catch below.
|
|
52
|
+
if (nonInteractive) {
|
|
53
|
+
UI.error('.framework/ already present — a non-interactive reinstall must go through `npx baldart update --reset --yes --i-know`.');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
47
56
|
const shouldReinstall = await UI.confirm(
|
|
48
57
|
'Remove and reinstall? (You will lose customizations)',
|
|
49
58
|
false
|
|
@@ -163,6 +172,35 @@ async function add(repo, options) {
|
|
|
163
172
|
UI.newline();
|
|
164
173
|
symlinks.copyCustomizableFiles();
|
|
165
174
|
|
|
175
|
+
// BALDART hooks (multi-hook registry since v3.18.0)
|
|
176
|
+
// Auto-register every hook in src/utils/hooks.js HOOK_REGISTRY into
|
|
177
|
+
// .claude/settings.json. Drift on an existing entry triggers an
|
|
178
|
+
// interactive prompt (Keep / Replace / Show diff), or 'keep' in --yes mode.
|
|
179
|
+
//
|
|
180
|
+
// This runs BEFORE the optional prompts below (git aliases, configure) —
|
|
181
|
+
// hook registration determines the correctness of the install and must
|
|
182
|
+
// never sit behind a blocking prompt. If `add` is interrupted at an
|
|
183
|
+
// optional prompt (non-TTY, Ctrl-C), the hooks are already in place.
|
|
184
|
+
// (Reordered in v3.33.0 to close the silent partial-install gap.)
|
|
185
|
+
try {
|
|
186
|
+
const res = await Hooks.registerAll(process.cwd(), {
|
|
187
|
+
onDrift: Hooks.createDriftPrompt(UI, { autoYes: nonInteractive }),
|
|
188
|
+
});
|
|
189
|
+
if (res.malformed) {
|
|
190
|
+
UI.warning('.claude/settings.json is malformed; skipped hook registration. Run `npx baldart doctor` after fixing it.');
|
|
191
|
+
} else {
|
|
192
|
+
for (const r of res.results) {
|
|
193
|
+
if (r.status === 'created') UI.success(`Registered hook \`${r.id}\` in .claude/settings.json`);
|
|
194
|
+
else if (r.status === 'legacy-migrated') UI.success(`Migrated legacy hook to \`${r.id}\` (previous: ${r.previousCommand})`);
|
|
195
|
+
else if (r.status === 'drift-replaced') UI.success(`Replaced drifted hook \`${r.id}\` (was: ${r.previousCommand})`);
|
|
196
|
+
else if (r.status === 'drift-kept') UI.info(`Kept user-customized hook \`${r.id}\` as-is.`);
|
|
197
|
+
else if (r.status === 'already') UI.info(`Hook \`${r.id}\` already registered.`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch (err) {
|
|
201
|
+
UI.warning(`Hook registration skipped: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
166
204
|
// Configure Git aliases
|
|
167
205
|
UI.section('Configuring Git Aliases');
|
|
168
206
|
|
|
@@ -201,29 +239,6 @@ async function add(repo, options) {
|
|
|
201
239
|
}
|
|
202
240
|
}
|
|
203
241
|
|
|
204
|
-
// BALDART hooks (multi-hook registry since v3.18.0)
|
|
205
|
-
// Auto-register every hook in src/utils/hooks.js HOOK_REGISTRY into
|
|
206
|
-
// .claude/settings.json. Drift on an existing entry triggers an
|
|
207
|
-
// interactive prompt (Keep / Replace / Show diff).
|
|
208
|
-
try {
|
|
209
|
-
const res = await Hooks.registerAll(process.cwd(), {
|
|
210
|
-
onDrift: Hooks.createDriftPrompt(UI),
|
|
211
|
-
});
|
|
212
|
-
if (res.malformed) {
|
|
213
|
-
UI.warning('.claude/settings.json is malformed; skipped hook registration. Run `npx baldart doctor` after fixing it.');
|
|
214
|
-
} else {
|
|
215
|
-
for (const r of res.results) {
|
|
216
|
-
if (r.status === 'created') UI.success(`Registered hook \`${r.id}\` in .claude/settings.json`);
|
|
217
|
-
else if (r.status === 'legacy-migrated') UI.success(`Migrated legacy hook to \`${r.id}\` (previous: ${r.previousCommand})`);
|
|
218
|
-
else if (r.status === 'drift-replaced') UI.success(`Replaced drifted hook \`${r.id}\` (was: ${r.previousCommand})`);
|
|
219
|
-
else if (r.status === 'drift-kept') UI.info(`Kept user-customized hook \`${r.id}\` as-is.`);
|
|
220
|
-
else if (r.status === 'already') UI.info(`Hook \`${r.id}\` already registered.`);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
} catch (err) {
|
|
224
|
-
UI.warning(`Hook registration skipped: ${err.message}`);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
242
|
// Routines wizard (since v2.1.0)
|
|
228
243
|
try {
|
|
229
244
|
const routinesCmd = require('./routines');
|
|
@@ -245,7 +260,9 @@ async function add(repo, options) {
|
|
|
245
260
|
if (runConfigure) {
|
|
246
261
|
try {
|
|
247
262
|
const configureCmd = require('./configure');
|
|
248
|
-
|
|
263
|
+
// In --yes mode, configure must write autodetected values without
|
|
264
|
+
// prompting — otherwise its inquirer prompts block in non-TTY.
|
|
265
|
+
await configureCmd(nonInteractive ? { nonInteractive: true } : undefined);
|
|
249
266
|
} catch (err) {
|
|
250
267
|
UI.warning(`Configure step failed: ${err.message}`);
|
|
251
268
|
UI.info('Run `npx baldart configure` later to complete setup.');
|
|
@@ -254,6 +271,31 @@ async function add(repo, options) {
|
|
|
254
271
|
UI.info('Skipped. Run `npx baldart configure` before invoking skills.');
|
|
255
272
|
}
|
|
256
273
|
|
|
274
|
+
// Post-flight assert — never declare success with missing hooks.
|
|
275
|
+
// Re-reads .claude/settings.json and confirms every active hook in
|
|
276
|
+
// HOOK_REGISTRY is present. Guards against the silent partial-install
|
|
277
|
+
// class (v3.27.1 / mayo 3.28→3.31). Covers `update --reset` too, since
|
|
278
|
+
// that path delegates to add(undefined, { yes: true }).
|
|
279
|
+
let verify = Hooks.verifyAll(process.cwd());
|
|
280
|
+
if (!verify.ok && !verify.malformed) {
|
|
281
|
+
// A single best-effort re-registration before failing hard.
|
|
282
|
+
try {
|
|
283
|
+
await Hooks.registerAll(process.cwd(), {
|
|
284
|
+
onDrift: Hooks.createDriftPrompt(UI, { autoYes: nonInteractive }),
|
|
285
|
+
});
|
|
286
|
+
} catch (_) { /* fall through to the assert below */ }
|
|
287
|
+
verify = Hooks.verifyAll(process.cwd());
|
|
288
|
+
}
|
|
289
|
+
if (!verify.ok) {
|
|
290
|
+
UI.error(
|
|
291
|
+
verify.malformed
|
|
292
|
+
? '.claude/settings.json is malformed — cannot verify hooks. Install is NOT complete.'
|
|
293
|
+
: `Hooks missing after install: ${verify.missing.join(', ')}. Install is NOT complete.`
|
|
294
|
+
);
|
|
295
|
+
UI.info('Run `npx baldart doctor` to repair, then re-check `npx baldart status`.');
|
|
296
|
+
process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
|
|
257
299
|
// Success summary
|
|
258
300
|
UI.header('✓ INSTALLATION COMPLETE');
|
|
259
301
|
|
package/src/commands/update.js
CHANGED
|
@@ -28,6 +28,20 @@ function readEnabledTools(cwd = process.cwd()) {
|
|
|
28
28
|
// agent-overlay feedback that prompted this).
|
|
29
29
|
const DIVERGENCE_STRATEGIES = ['abort', 'pull', 'scaffold-overlays'];
|
|
30
30
|
|
|
31
|
+
// Machine-readable result channel (v3.32.0+). When --json is active, STDOUT
|
|
32
|
+
// must carry exactly ONE JSON object and nothing else — UI.setJsonMode() has
|
|
33
|
+
// already redirected every human line to STDERR. Every terminal point in the
|
|
34
|
+
// standard flow routes through `emitUpdateJson` so the contract holds: one
|
|
35
|
+
// object, then exit. `ok: true` means "framework is now at the remote version"
|
|
36
|
+
// (action `updated` or `already-current`); everything else is `ok: false`.
|
|
37
|
+
let JSON_MODE = false;
|
|
38
|
+
function emitUpdateJson(obj, exitCode) {
|
|
39
|
+
if (JSON_MODE) {
|
|
40
|
+
process.stdout.write(JSON.stringify({ schema: 'baldart.update/1', ...obj }) + '\n');
|
|
41
|
+
}
|
|
42
|
+
process.exit(exitCode);
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
// Map a touched framework path to an `overlay scaffold` target string.
|
|
32
46
|
// .framework/framework/.claude/agents/coder.md → 'agents/coder'
|
|
33
47
|
// .framework/framework/.claude/commands/check.md → 'commands/check'
|
|
@@ -394,6 +408,10 @@ async function maybeRelaunchUnderLatest(options) {
|
|
|
394
408
|
if (options.commit === false) flags.push('--no-commit');
|
|
395
409
|
if (options.reset === true) flags.push('--reset');
|
|
396
410
|
if (options.iKnow === true) flags.push('--i-know');
|
|
411
|
+
// Forward the non-interactive resolution + machine-readable flags too, else
|
|
412
|
+
// a stale-CLI self-relaunch would silently drop them (v3.32.0+).
|
|
413
|
+
if (options.onDivergence) flags.push('--on-divergence', options.onDivergence);
|
|
414
|
+
if (options.json === true) flags.push('--json');
|
|
397
415
|
|
|
398
416
|
const spawn = require('child_process').spawnSync;
|
|
399
417
|
UI.info(`Auto-relaunching via npx baldart@${info.latest}…`);
|
|
@@ -421,10 +439,31 @@ async function update(options = {}) {
|
|
|
421
439
|
const autoStash = autoYes || options.autoStash === true;
|
|
422
440
|
const confirm = async (msg, def = true) => (autoYes ? def : UI.confirm(msg, def));
|
|
423
441
|
|
|
442
|
+
// Machine-readable mode (v3.32.0+). Must be fully non-interactive: --json
|
|
443
|
+
// requires --yes (so no UI.select/confirm can block) and rejects --reset
|
|
444
|
+
// (the nuclear path is a deliberate destructive human escape hatch, not an
|
|
445
|
+
// agent flow — see the autonomous /baldart-update skill, which never resets).
|
|
446
|
+
JSON_MODE = options.json === true;
|
|
447
|
+
if (JSON_MODE) {
|
|
448
|
+
UI.setJsonMode(true);
|
|
449
|
+
if (options.reset === true) {
|
|
450
|
+
emitUpdateJson({ ok: false, action: 'usage-error',
|
|
451
|
+
reason: '--json does not support --reset. Reset is an interactive destructive escape hatch; run it manually.' }, 2);
|
|
452
|
+
}
|
|
453
|
+
if (!autoYes) {
|
|
454
|
+
emitUpdateJson({ ok: false, action: 'usage-error',
|
|
455
|
+
reason: '--json requires --yes (non-interactive). Re-run: npx baldart update --json --yes [--on-divergence pull|scaffold-overlays]' }, 2);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
424
459
|
// Non-interactive divergence strategy (since v3.29.0). Validated up-front so
|
|
425
460
|
// a typo fails fast instead of mid-flow after the network fetch.
|
|
426
461
|
const divStrategy = options.onDivergence;
|
|
427
462
|
if (divStrategy !== undefined && !DIVERGENCE_STRATEGIES.includes(divStrategy)) {
|
|
463
|
+
if (JSON_MODE) {
|
|
464
|
+
emitUpdateJson({ ok: false, action: 'usage-error',
|
|
465
|
+
reason: `Invalid --on-divergence "${divStrategy}". Use one of: ${DIVERGENCE_STRATEGIES.join(', ')}.` }, 1);
|
|
466
|
+
}
|
|
428
467
|
UI.error(`Invalid --on-divergence "${divStrategy}". Use one of: ${DIVERGENCE_STRATEGIES.join(', ')}.`);
|
|
429
468
|
process.exit(1);
|
|
430
469
|
}
|
|
@@ -443,7 +482,9 @@ async function update(options = {}) {
|
|
|
443
482
|
'Solution:',
|
|
444
483
|
' Install first: npx baldart add'
|
|
445
484
|
]);
|
|
446
|
-
|
|
485
|
+
emitUpdateJson({ ok: false, action: 'framework-missing',
|
|
486
|
+
reason: 'Framework directory .framework/ not found.',
|
|
487
|
+
next_command: 'npx baldart add' }, 1);
|
|
447
488
|
}
|
|
448
489
|
|
|
449
490
|
// v3.26.0+ NUCLEAR OPTION: --reset rips out .framework/ and reinstalls
|
|
@@ -480,7 +521,8 @@ async function update(options = {}) {
|
|
|
480
521
|
UI.error('.framework/ is STILL ignored after auto-heal — likely a parent-dir .gitignore outside this repo.');
|
|
481
522
|
UI.info(`Source: ${heal.recheck.source}:${heal.recheck.line} (${heal.recheck.pattern})`);
|
|
482
523
|
UI.info('Resolve by removing that rule, then re-run `baldart update`.');
|
|
483
|
-
|
|
524
|
+
emitUpdateJson({ ok: false, action: 'failed',
|
|
525
|
+
reason: `.framework/ is still ignored after auto-heal (${heal.recheck.source}:${heal.recheck.line}). Remove that .gitignore rule and re-run.` }, 1);
|
|
484
526
|
}
|
|
485
527
|
}
|
|
486
528
|
} catch (err) {
|
|
@@ -512,19 +554,24 @@ async function update(options = {}) {
|
|
|
512
554
|
' • Check internet connection',
|
|
513
555
|
' • Try again later'
|
|
514
556
|
]);
|
|
515
|
-
|
|
557
|
+
emitUpdateJson({ ok: false, action: 'connect-failed',
|
|
558
|
+
reason: 'Cannot connect to repository (network / access).' }, 1);
|
|
516
559
|
}
|
|
517
560
|
|
|
518
561
|
if (!status.fetched || !status.hasFetchHead) {
|
|
519
562
|
UI.error('Could not fetch upstream — try again with network access.');
|
|
520
|
-
|
|
563
|
+
emitUpdateJson({ ok: false, action: 'fetch-failed',
|
|
564
|
+
reason: 'Could not fetch upstream — try again with network access.' }, 1);
|
|
521
565
|
}
|
|
522
566
|
|
|
523
567
|
// VERSION-compare authority. Subtree-merge commit count is noise; never
|
|
524
568
|
// use behind > 0 as the up-to-date signal (root cause of v3.24.x bug).
|
|
525
569
|
if (status.isAligned) {
|
|
526
570
|
UI.success(`Already up to date! (v${status.installedVersion})`);
|
|
527
|
-
|
|
571
|
+
emitUpdateJson({ ok: true, action: 'already-current',
|
|
572
|
+
installed_before: status.installedVersion,
|
|
573
|
+
installed_after: status.installedVersion,
|
|
574
|
+
remote_version: status.remoteVersion }, 0);
|
|
528
575
|
}
|
|
529
576
|
|
|
530
577
|
UI.warning(`Update available: v${status.installedVersion} → v${status.remoteVersion}`);
|
|
@@ -545,6 +592,19 @@ async function update(options = {}) {
|
|
|
545
592
|
UI.info(` • ${c.sha.slice(0, 7)} — ${c.subject}`);
|
|
546
593
|
}
|
|
547
594
|
|
|
595
|
+
// If every overlay-able commit only touches files that ALREADY have a
|
|
596
|
+
// matching overlay in `.baldart/overlays/`, the divergent edit's intent
|
|
597
|
+
// is already preserved — `--reset` is safe and `scaffold-overlays`
|
|
598
|
+
// would only create redundant skeletons. Surface that so the user/agent
|
|
599
|
+
// picks the cheap, correct resolution. (v3.33.0)
|
|
600
|
+
const allCovered = overlayCommits.length > 0
|
|
601
|
+
&& overlayCommits.every((c) => c.overlayCovered === true);
|
|
602
|
+
if (allCovered) {
|
|
603
|
+
UI.newline();
|
|
604
|
+
UI.success('These edits are already preserved in `.baldart/overlays/` — `--reset` is safe (your overlays are re-applied); scaffolding new overlays would be redundant.');
|
|
605
|
+
UI.info('Fast path: `npx baldart update --reset --yes --i-know`.');
|
|
606
|
+
}
|
|
607
|
+
|
|
548
608
|
// Non-interactive resolution (agents / CI) via --on-divergence.
|
|
549
609
|
if (divStrategy === 'scaffold-overlays') {
|
|
550
610
|
UI.newline();
|
|
@@ -556,9 +616,18 @@ async function update(options = {}) {
|
|
|
556
616
|
'Then complete the update with `npx baldart update --reset --yes --i-know` — this discards the now-redundant `.framework/` edits and reinstalls clean; your `.baldart/overlays/` are preserved and re-applied.',
|
|
557
617
|
'Alternatively, push the edits upstream with `/baldart-push` if they are generic improvements.',
|
|
558
618
|
], 'cyan');
|
|
559
|
-
|
|
619
|
+
emitUpdateJson({ ok: false, action: 'divergence-scaffolded',
|
|
620
|
+
divergence_class: status.divergenceClass, overlay_already_covered: allCovered,
|
|
621
|
+
installed_before: status.installedVersion, remote_version: status.remoteVersion,
|
|
622
|
+
reason: 'Scaffolded overlay skeleton(s); fill them in, then re-run.',
|
|
623
|
+
next_command: 'npx baldart update --reset --yes --i-know' }, 0);
|
|
624
|
+
}
|
|
625
|
+
if (divStrategy === 'abort') {
|
|
626
|
+
UI.info('Update cancelled (--on-divergence abort).');
|
|
627
|
+
emitUpdateJson({ ok: false, action: 'aborted',
|
|
628
|
+
divergence_class: status.divergenceClass,
|
|
629
|
+
reason: 'Update cancelled (--on-divergence abort).' }, 0);
|
|
560
630
|
}
|
|
561
|
-
if (divStrategy === 'abort') { UI.info('Update cancelled (--on-divergence abort).'); process.exit(0); }
|
|
562
631
|
if (divStrategy === 'pull') {
|
|
563
632
|
UI.info('Keeping the custom commits (--on-divergence pull). The subtree pull will attempt a merge.');
|
|
564
633
|
// fall through
|
|
@@ -568,11 +637,23 @@ async function update(options = {}) {
|
|
|
568
637
|
UI.error('Custom edits present — refusing to resolve them unattended under --yes.');
|
|
569
638
|
UI.info('Re-run with one of:');
|
|
570
639
|
UI.list([
|
|
571
|
-
|
|
640
|
+
allCovered
|
|
641
|
+
? '--reset --yes --i-know → SAFE here: these edits are already in `.baldart/overlays/` (recommended)'
|
|
642
|
+
: '--on-divergence scaffold-overlays → auto-create overlay skeleton(s), then stop so you can fill them',
|
|
572
643
|
'--on-divergence pull → keep the commits and merge the update (non-destructive)',
|
|
573
|
-
|
|
644
|
+
allCovered
|
|
645
|
+
? '--on-divergence scaffold-overlays → would create redundant skeletons (edits already covered)'
|
|
646
|
+
: '--reset --yes --i-know → discard .framework/ edits and reinstall clean (destructive)',
|
|
574
647
|
], 'cyan');
|
|
575
|
-
|
|
648
|
+
emitUpdateJson({ ok: false, action: 'divergence-refused',
|
|
649
|
+
divergence_class: status.divergenceClass, overlay_already_covered: allCovered,
|
|
650
|
+
installed_before: status.installedVersion, remote_version: status.remoteVersion,
|
|
651
|
+
reason: allCovered
|
|
652
|
+
? 'Custom edits present but already preserved in .baldart/overlays/ — `--reset --yes --i-know` is the safe resolution.'
|
|
653
|
+
: 'Custom edits present — refusing to resolve them unattended under --yes.',
|
|
654
|
+
next_command: allCovered
|
|
655
|
+
? 'npx baldart update --reset --yes --i-know'
|
|
656
|
+
: 'npx baldart update --json --yes --on-divergence scaffold-overlays' }, 1);
|
|
576
657
|
} else {
|
|
577
658
|
const choice = await UI.select('How would you like to proceed?', [
|
|
578
659
|
{ name: 'Scaffold overlay skeleton(s) now (recommended — then fill them in & re-run)', value: 'overlay' },
|
|
@@ -605,16 +686,29 @@ async function update(options = {}) {
|
|
|
605
686
|
if (overlayCommits.length === 0) {
|
|
606
687
|
UI.error('No overlay-able commits to scaffold — every custom commit touches non-overlayable paths (src/, CHANGELOG, …).');
|
|
607
688
|
UI.info('Use `/baldart-push` to contribute them upstream, or `--on-divergence pull` to merge.');
|
|
608
|
-
|
|
689
|
+
emitUpdateJson({ ok: false, action: 'divergence-refused',
|
|
690
|
+
divergence_class: status.divergenceClass,
|
|
691
|
+
installed_before: status.installedVersion, remote_version: status.remoteVersion,
|
|
692
|
+
reason: 'No overlay-able commits to scaffold — every custom commit touches non-overlayable paths.',
|
|
693
|
+
next_command: 'npx baldart update --json --yes --on-divergence pull' }, 1);
|
|
609
694
|
}
|
|
610
695
|
UI.newline();
|
|
611
696
|
scaffoldOverlaysForCommits(overlayCommits);
|
|
612
697
|
UI.newline();
|
|
613
698
|
UI.warning('Scaffolded overlays for the overlay-able commits, but custom-other commit(s) remain — NOT pulling (would be destructive to those edits).');
|
|
614
699
|
UI.info('Resolve the remaining commits (push upstream with `/baldart-push`, or revert), then re-run `npx baldart update`.');
|
|
615
|
-
|
|
700
|
+
emitUpdateJson({ ok: false, action: 'divergence-scaffolded',
|
|
701
|
+
divergence_class: status.divergenceClass,
|
|
702
|
+
installed_before: status.installedVersion, remote_version: status.remoteVersion,
|
|
703
|
+
reason: 'Scaffolded overlay-able subset; custom-other commit(s) still need handling before pulling.',
|
|
704
|
+
next_command: 'npx baldart update --json --yes' }, 0);
|
|
705
|
+
}
|
|
706
|
+
if (divStrategy === 'abort') {
|
|
707
|
+
UI.info('Update cancelled (--on-divergence abort).');
|
|
708
|
+
emitUpdateJson({ ok: false, action: 'aborted',
|
|
709
|
+
divergence_class: status.divergenceClass,
|
|
710
|
+
reason: 'Update cancelled (--on-divergence abort).' }, 0);
|
|
616
711
|
}
|
|
617
|
-
if (divStrategy === 'abort') { UI.info('Update cancelled (--on-divergence abort).'); process.exit(0); }
|
|
618
712
|
if (divStrategy === 'pull') {
|
|
619
713
|
UI.info('Pulling over the custom commits (--on-divergence pull). A merge commit will be created — resolve any conflicts that surface.');
|
|
620
714
|
// fall through
|
|
@@ -627,7 +721,11 @@ async function update(options = {}) {
|
|
|
627
721
|
'--on-divergence abort → do nothing',
|
|
628
722
|
], 'cyan');
|
|
629
723
|
UI.info('Best practice: contribute generic improvements upstream first with `/baldart-push`.');
|
|
630
|
-
|
|
724
|
+
emitUpdateJson({ ok: false, action: 'divergence-refused',
|
|
725
|
+
divergence_class: status.divergenceClass,
|
|
726
|
+
installed_before: status.installedVersion, remote_version: status.remoteVersion,
|
|
727
|
+
reason: 'Custom commits present — refusing to pull over them unattended under --yes.',
|
|
728
|
+
next_command: 'npx baldart update --json --yes --on-divergence pull' }, 1);
|
|
631
729
|
} else {
|
|
632
730
|
const choice = await UI.select('How would you like to proceed?', [
|
|
633
731
|
{ name: 'Push these commits upstream first (recommended — `/baldart-push`)', value: 'push' },
|
|
@@ -669,6 +767,7 @@ async function update(options = {}) {
|
|
|
669
767
|
// "fatal: working tree has modifications. Cannot add." Offer to stash
|
|
670
768
|
// and re-apply after the update, so the user doesn't have to abort mid-flow.
|
|
671
769
|
let stashRef = null;
|
|
770
|
+
let stashConflict = false; // surfaced in the --json result (v3.32.0+)
|
|
672
771
|
const isClean = await git.hasCleanWorkingTree();
|
|
673
772
|
if (!isClean) {
|
|
674
773
|
UI.newline();
|
|
@@ -703,7 +802,8 @@ async function update(options = {}) {
|
|
|
703
802
|
UI.success(`Stashed working tree as "${stashRef}".`);
|
|
704
803
|
} catch (err) {
|
|
705
804
|
UI.error(`Could not stash: ${err.message}`);
|
|
706
|
-
|
|
805
|
+
emitUpdateJson({ ok: false, action: 'failed',
|
|
806
|
+
reason: `Could not stash working tree before update: ${err.message}` }, 1);
|
|
707
807
|
}
|
|
708
808
|
}
|
|
709
809
|
|
|
@@ -756,6 +856,7 @@ async function update(options = {}) {
|
|
|
756
856
|
await git.git.stash(['pop']);
|
|
757
857
|
UI.success(`Re-applied pre-update stash "${stashRef}".`);
|
|
758
858
|
} catch (err) {
|
|
859
|
+
stashConflict = true;
|
|
759
860
|
UI.newline();
|
|
760
861
|
UI.warning(`Could not auto-re-apply stash "${stashRef}" — conflicts with the framework update.`);
|
|
761
862
|
UI.box('STASH CONFLICT', [
|
|
@@ -1006,7 +1107,15 @@ async function update(options = {}) {
|
|
|
1006
1107
|
UI.newline();
|
|
1007
1108
|
UI.success('Framework updated successfully!');
|
|
1008
1109
|
|
|
1110
|
+
emitUpdateJson({ ok: true, action: 'updated',
|
|
1111
|
+
installed_before: currentVersion, installed_after: newVersion,
|
|
1112
|
+
remote_version: status.remoteVersion, backup_tag: backupTag,
|
|
1113
|
+
stash_conflict: stashConflict }, 0);
|
|
1114
|
+
|
|
1009
1115
|
} catch (error) {
|
|
1116
|
+
if (JSON_MODE) {
|
|
1117
|
+
emitUpdateJson({ ok: false, action: 'failed', reason: error.message }, 1);
|
|
1118
|
+
}
|
|
1010
1119
|
UI.error(`Update failed: ${error.message}`);
|
|
1011
1120
|
process.exit(1);
|
|
1012
1121
|
}
|
package/src/commands/version.js
CHANGED
|
@@ -30,12 +30,25 @@ async function describeDrift(git, status) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// Emit a structured JSON object on stdout (v3.32.0+). Used by agents / CI to
|
|
34
|
+
// avoid scraping the human-readable box with fragile regex. Schema is
|
|
35
|
+
// versioned (`baldart.version/1`) so consumers can detect breaking changes.
|
|
36
|
+
function emitVersionJson(obj) {
|
|
37
|
+
process.stdout.write(JSON.stringify({ schema: 'baldart.version/1', ...obj }) + '\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
async function version(opts = {}) {
|
|
34
41
|
const git = new GitUtils();
|
|
42
|
+
const jsonMode = opts.json === true;
|
|
43
|
+
if (jsonMode) UI.setJsonMode(true);
|
|
35
44
|
|
|
36
45
|
try {
|
|
37
46
|
const exists = await git.frameworkExists();
|
|
38
47
|
if (!exists) {
|
|
48
|
+
if (jsonMode) {
|
|
49
|
+
emitVersionJson({ installed: false, error: 'framework_not_installed' });
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
39
52
|
UI.warning('Framework not installed');
|
|
40
53
|
UI.info('Install with: npx baldart add');
|
|
41
54
|
process.exit(0);
|
|
@@ -63,6 +76,31 @@ async function version(opts = {}) {
|
|
|
63
76
|
}
|
|
64
77
|
const drift = await describeDrift(git, status);
|
|
65
78
|
|
|
79
|
+
// Machine-readable path (v3.32.0+) — emit the structured object and stop.
|
|
80
|
+
// Mirrors exactly the facts the human box shows below, so the autonomous
|
|
81
|
+
// /baldart-update skill can read fields instead of regex-scraping prose.
|
|
82
|
+
if (jsonMode) {
|
|
83
|
+
emitVersionJson({
|
|
84
|
+
installed: true,
|
|
85
|
+
installed_version: frameworkVersion,
|
|
86
|
+
remote_version: drift.remoteVersion, // string version, or 'unknown'
|
|
87
|
+
aligned: drift.isAligned,
|
|
88
|
+
offline: opts.offline === true || !drift.hasFetchHead,
|
|
89
|
+
fetched: drift.hasFetchHead,
|
|
90
|
+
uncommitted_files: drift.uncommittedFiles,
|
|
91
|
+
commits_ahead: drift.ahead,
|
|
92
|
+
commits_behind: drift.behind,
|
|
93
|
+
cli_version: cliVersion,
|
|
94
|
+
cli_latest: cliUpdate ? cliUpdate.latest : null,
|
|
95
|
+
cli_update_available: !!cliUpdate,
|
|
96
|
+
repository: `https://github.com/${repo}`,
|
|
97
|
+
last_update_date: state.last_update_date || null,
|
|
98
|
+
last_pushed_version: state.last_pushed_version || null,
|
|
99
|
+
last_push_date: state.last_push_date || null,
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
66
104
|
// Messaging based on `isAligned` (not commit count, which is subtree noise).
|
|
67
105
|
let remoteLine;
|
|
68
106
|
if (!drift.hasFetchHead) {
|
|
@@ -103,6 +141,10 @@ async function version(opts = {}) {
|
|
|
103
141
|
UI.box('BALDART VERSION', lines.filter((l) => l !== ''));
|
|
104
142
|
UI.newline();
|
|
105
143
|
} catch (error) {
|
|
144
|
+
if (jsonMode) {
|
|
145
|
+
emitVersionJson({ installed: null, error: 'version_failed', reason: error.message });
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
106
148
|
UI.error(`Failed to get version: ${error.message}`);
|
|
107
149
|
process.exit(1);
|
|
108
150
|
}
|
|
@@ -149,6 +149,45 @@ const PATH_FIXTURES = [
|
|
|
149
149
|
},
|
|
150
150
|
];
|
|
151
151
|
|
|
152
|
+
// overlayRelForFrameworkFile(p) — pure mapping of a touched .framework/ payload
|
|
153
|
+
// file to the consumer overlay path that would preserve a customization of it.
|
|
154
|
+
// Mirrors src/commands/overlay.js conventions (overlayPath). null when no
|
|
155
|
+
// overlay maps to the file. Used by overlayCoversTouched() to flag commits
|
|
156
|
+
// whose intent is already preserved (→ `--reset` safe, scaffold redundant).
|
|
157
|
+
const path = require('path');
|
|
158
|
+
const OVERLAY_REL_FIXTURES = [
|
|
159
|
+
{
|
|
160
|
+
name: 'skill SKILL.md → flat overlay',
|
|
161
|
+
file: '.framework/framework/.claude/skills/ui-design/SKILL.md',
|
|
162
|
+
expected: path.join('.baldart', 'overlays', 'ui-design.md'),
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: 'skill nested script → same flat overlay',
|
|
166
|
+
file: '.framework/framework/.claude/skills/ui-design/scripts/foo.sh',
|
|
167
|
+
expected: path.join('.baldart', 'overlays', 'ui-design.md'),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: 'agent .md → agents/ overlay',
|
|
171
|
+
file: '.framework/framework/.claude/agents/ui-expert.md',
|
|
172
|
+
expected: path.join('.baldart', 'overlays', 'agents', 'ui-expert.md'),
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'command .md → commands/ overlay',
|
|
176
|
+
file: '.framework/framework/.claude/commands/check.md',
|
|
177
|
+
expected: path.join('.baldart', 'overlays', 'commands', 'check.md'),
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: 'src file → null (no overlay maps)',
|
|
181
|
+
file: '.framework/src/utils/git.js',
|
|
182
|
+
expected: null,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'nested agent path → null (agents are flat .md)',
|
|
186
|
+
file: '.framework/framework/.claude/agents/sub/x.md',
|
|
187
|
+
expected: null,
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
|
|
152
191
|
let pass = 0, fail = 0;
|
|
153
192
|
|
|
154
193
|
console.log('─── Subject classifier ───');
|
|
@@ -187,5 +226,48 @@ for (const f of AGGREGATE_FIXTURES) {
|
|
|
187
226
|
}
|
|
188
227
|
}
|
|
189
228
|
|
|
229
|
+
console.log('\n─── Overlay path mapping ───');
|
|
230
|
+
for (const f of OVERLAY_REL_FIXTURES) {
|
|
231
|
+
const actual = GitUtils.overlayRelForFrameworkFile(f.file);
|
|
232
|
+
if (actual === f.expected) {
|
|
233
|
+
pass++;
|
|
234
|
+
console.log(` ✓ ${f.name} → ${actual}`);
|
|
235
|
+
} else {
|
|
236
|
+
fail++;
|
|
237
|
+
console.log(` ✗ ${f.name} → ${actual} (expected ${f.expected})`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// overlayCoversTouched(touched) — fs-backed: true only when EVERY touched file
|
|
242
|
+
// maps to an EXISTING overlay. This is the `overlayCovered` field that flows
|
|
243
|
+
// (via classifyDivergence → getUpdateStatus.divergenceCommits, a direct
|
|
244
|
+
// pass-through) into update.js's "reset is safe" branch. Lock the fs semantics.
|
|
245
|
+
console.log('\n─── overlayCoversTouched (fs-backed) ───');
|
|
246
|
+
{
|
|
247
|
+
const fsMod = require('fs');
|
|
248
|
+
const os = require('os');
|
|
249
|
+
const tmp = fsMod.mkdtempSync(path.join(os.tmpdir(), 'baldart-ovl-'));
|
|
250
|
+
fsMod.mkdirSync(path.join(tmp, '.baldart', 'overlays', 'agents'), { recursive: true });
|
|
251
|
+
fsMod.writeFileSync(path.join(tmp, '.baldart', 'overlays', 'agents', 'coder.md'), '---\n---\n');
|
|
252
|
+
const g2 = new GitUtils(tmp);
|
|
253
|
+
const cases = [
|
|
254
|
+
{ name: 'touched file has an existing overlay → covered',
|
|
255
|
+
touched: ['.framework/framework/.claude/agents/coder.md'], expected: true },
|
|
256
|
+
{ name: 'touched file has NO overlay → not covered',
|
|
257
|
+
touched: ['.framework/framework/.claude/agents/ui-expert.md'], expected: false },
|
|
258
|
+
{ name: 'one covered + one uncovered → not covered (every)',
|
|
259
|
+
touched: ['.framework/framework/.claude/agents/coder.md',
|
|
260
|
+
'.framework/framework/.claude/agents/ui-expert.md'], expected: false },
|
|
261
|
+
{ name: 'empty touched → not covered',
|
|
262
|
+
touched: [], expected: false },
|
|
263
|
+
];
|
|
264
|
+
for (const c of cases) {
|
|
265
|
+
const actual = g2.overlayCoversTouched(c.touched);
|
|
266
|
+
if (actual === c.expected) { pass++; console.log(` ✓ ${c.name} → ${actual}`); }
|
|
267
|
+
else { fail++; console.log(` ✗ ${c.name} → ${actual} (expected ${c.expected})`); }
|
|
268
|
+
}
|
|
269
|
+
fsMod.rmSync(tmp, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
|
|
190
272
|
console.log(`\nResult: ${pass} pass, ${fail} fail`);
|
|
191
273
|
process.exit(fail > 0 ? 1 : 0);
|
package/src/utils/git.js
CHANGED
|
@@ -256,6 +256,47 @@ class GitUtils {
|
|
|
256
256
|
return allOverlayable ? 'custom-overlay-able' : 'custom-other';
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
// Maps a touched `.framework/` payload file to the consumer overlay path that
|
|
260
|
+
// would preserve a customization of it, or null when no overlay maps to it.
|
|
261
|
+
// Mirrors the conventions in src/commands/overlay.js (overlayPath):
|
|
262
|
+
// .framework/framework/.claude/skills/<name>/... → .baldart/overlays/<name>.md
|
|
263
|
+
// .framework/framework/.claude/agents/<name>.md → .baldart/overlays/agents/<name>.md
|
|
264
|
+
// .framework/framework/.claude/commands/<name>.md → .baldart/overlays/commands/<name>.md
|
|
265
|
+
// Pure (no fs) so it stays unit-testable.
|
|
266
|
+
static overlayRelForFrameworkFile(p) {
|
|
267
|
+
const prefix = '.framework/framework/.claude/';
|
|
268
|
+
if (!p.startsWith(prefix)) return null;
|
|
269
|
+
const rest = p.slice(prefix.length);
|
|
270
|
+
const slash = rest.indexOf('/');
|
|
271
|
+
if (slash < 0) return null;
|
|
272
|
+
const dir = rest.slice(0, slash);
|
|
273
|
+
if (dir === 'skills') {
|
|
274
|
+
const name = rest.slice(slash + 1).split('/')[0];
|
|
275
|
+
if (!name) return null;
|
|
276
|
+
return path.join('.baldart', 'overlays', `${name}.md`);
|
|
277
|
+
}
|
|
278
|
+
if (dir === 'agents' || dir === 'commands') {
|
|
279
|
+
const file = rest.slice(slash + 1);
|
|
280
|
+
if (!file || file.includes('/')) return null; // flat <name>.md files only
|
|
281
|
+
const name = file.replace(/\.md$/, '');
|
|
282
|
+
if (!name) return null;
|
|
283
|
+
return path.join('.baldart', 'overlays', dir, `${name}.md`);
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// True when EVERY overlay-able file touched by the commit already has an
|
|
289
|
+
// existing overlay in `.baldart/overlays/` — i.e. the divergent edit's intent
|
|
290
|
+
// is already preserved, so `--reset` is safe and `scaffold-overlays` would
|
|
291
|
+
// only create redundant skeletons.
|
|
292
|
+
overlayCoversTouched(touched) {
|
|
293
|
+
if (!touched || !touched.length) return false;
|
|
294
|
+
return touched.every((p) => {
|
|
295
|
+
const rel = GitUtils.overlayRelForFrameworkFile(p);
|
|
296
|
+
return rel ? fs.existsSync(path.join(this.cwd, rel)) : false;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
259
300
|
async classifyDivergence() {
|
|
260
301
|
// Inspect aheadScoped commits — those not on FETCH_HEAD that touch .framework/.
|
|
261
302
|
const commits = [];
|
|
@@ -297,7 +338,10 @@ class GitUtils {
|
|
|
297
338
|
} catch (_) { /* leave empty → custom-other */ }
|
|
298
339
|
category = this.classifyCommitByPaths(touched);
|
|
299
340
|
}
|
|
300
|
-
|
|
341
|
+
const overlayCovered = category === 'custom-overlay-able'
|
|
342
|
+
? this.overlayCoversTouched(touched)
|
|
343
|
+
: false;
|
|
344
|
+
commits.push({ sha, subject, category, parents, touched: touched || [], overlayCovered });
|
|
301
345
|
}
|
|
302
346
|
} catch (_) {
|
|
303
347
|
return { class: 'unknown', commits: [] };
|
package/src/utils/hooks.js
CHANGED
|
@@ -384,6 +384,38 @@ function unregisterAll(cwd = process.cwd()) {
|
|
|
384
384
|
return { malformed: false, path: settingsPath, results };
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// Verify — post-flight assertion that every active hook is registered
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Confirms that every active hook in HOOK_REGISTRY is present in
|
|
393
|
+
* `.claude/settings.json`. Pure read (delegates to `getStatus`). Designed as
|
|
394
|
+
* the post-flight gate for `add` / `update --reset`: the install must never
|
|
395
|
+
* declare success while a hook is missing (the silent partial-install class,
|
|
396
|
+
* v3.27.1 / mayo 3.28→3.31). Commented-out registry entries (e.g.
|
|
397
|
+
* capture-detect) are excluded for free — `getStatus` only iterates the array.
|
|
398
|
+
*
|
|
399
|
+
* A malformed settings.json counts as a failure: registration was skipped, so
|
|
400
|
+
* the hooks are not verifiably present.
|
|
401
|
+
*
|
|
402
|
+
* @param {string} [cwd]
|
|
403
|
+
* @returns {{ ok: boolean, malformed: boolean, missing: string[], path: string }}
|
|
404
|
+
*/
|
|
405
|
+
function verifyAll(cwd = process.cwd()) {
|
|
406
|
+
const status = getStatus(cwd);
|
|
407
|
+
if (status.malformed) {
|
|
408
|
+
return {
|
|
409
|
+
ok: false,
|
|
410
|
+
malformed: true,
|
|
411
|
+
missing: HOOK_REGISTRY.map((d) => d.id),
|
|
412
|
+
path: status.path,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const missing = status.hooks.filter((h) => !h.registered).map((h) => h.id);
|
|
416
|
+
return { ok: missing.length === 0, malformed: false, missing, path: status.path };
|
|
417
|
+
}
|
|
418
|
+
|
|
387
419
|
// ---------------------------------------------------------------------------
|
|
388
420
|
// Drift prompt helper (interactive Keep / Replace / Show diff)
|
|
389
421
|
// ---------------------------------------------------------------------------
|
|
@@ -434,6 +466,7 @@ module.exports = {
|
|
|
434
466
|
HOOK_REGISTRY,
|
|
435
467
|
SETTINGS_FILE,
|
|
436
468
|
getStatus,
|
|
469
|
+
verifyAll,
|
|
437
470
|
registerAll,
|
|
438
471
|
unregisterAll,
|
|
439
472
|
createDriftPrompt,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
2
3
|
const yaml = require('js-yaml');
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -41,6 +42,45 @@ function computeBaseFileSha(content) {
|
|
|
41
42
|
return shortSha(content);
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Idempotently backfill `base_file_sha` into an overlay file's frontmatter so
|
|
47
|
+
* `baldart overlay drift` is deterministic for EVERY overlay — not only those
|
|
48
|
+
* freshly scaffolded by `overlay scaffold` (which already writes it). Overlays
|
|
49
|
+
* authored by hand or by pre-v3.19.0 versions are otherwise "unknown" to drift
|
|
50
|
+
* (overlay.js drift § no base_file_sha). The merger knows the base file, so it
|
|
51
|
+
* stamps the sha here at `add`/`update` time.
|
|
52
|
+
*
|
|
53
|
+
* Contract: ADDITIVE-ONLY. Never overwrites an existing `base_file_sha`. Only
|
|
54
|
+
* touches a file that already has a leading YAML frontmatter block — never
|
|
55
|
+
* fabricates frontmatter on a file that lacks it. Fail-safe: any I/O error is
|
|
56
|
+
* swallowed (returns `changed:false`) so a backfill can never block a merge.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} overlayPath absolute path to `.baldart/overlays/<…>.md`
|
|
59
|
+
* @param {string} baseSha 12-char hex from computeBaseFileSha(baseContent)
|
|
60
|
+
* @returns {{ changed: boolean, reason: string }}
|
|
61
|
+
*/
|
|
62
|
+
function ensureBaseFileShaInOverlay(overlayPath, baseSha) {
|
|
63
|
+
if (!baseSha) return { changed: false, reason: 'no-base-sha' };
|
|
64
|
+
let content;
|
|
65
|
+
try { content = fs.readFileSync(overlayPath, 'utf8'); }
|
|
66
|
+
catch (_) { return { changed: false, reason: 'unreadable' }; }
|
|
67
|
+
// Leading frontmatter block only (optionally after a BOM / blank lines).
|
|
68
|
+
const m = content.match(/^(\uFEFF?\s*)---\n([\s\S]*?)\n---(\r?\n|$)/);
|
|
69
|
+
if (!m) return { changed: false, reason: 'no-frontmatter' };
|
|
70
|
+
const fmBody = m[2];
|
|
71
|
+
if (/^\s*base_file_sha\s*:/m.test(fmBody)) {
|
|
72
|
+
return { changed: false, reason: 'already-present' };
|
|
73
|
+
}
|
|
74
|
+
// Quote the value — a 12-char hex can be all digits, which YAML would
|
|
75
|
+
// otherwise parse as a number (matches overlay.js buildFrontmatter).
|
|
76
|
+
const newFmBody = `${fmBody.replace(/\n+$/, '')}\nbase_file_sha: "${baseSha}"`;
|
|
77
|
+
const replaced = `${m[1]}---\n${newFmBody}\n---${m[3]}`;
|
|
78
|
+
const updated = content.slice(0, m.index) + replaced + content.slice(m.index + m[0].length);
|
|
79
|
+
try { fs.writeFileSync(overlayPath, updated, 'utf8'); }
|
|
80
|
+
catch (_) { return { changed: false, reason: 'unwritable' }; }
|
|
81
|
+
return { changed: true, reason: 'backfilled' };
|
|
82
|
+
}
|
|
83
|
+
|
|
44
84
|
function buildMarker({ kind, name, baseVersion, baseSha, overlaySha }) {
|
|
45
85
|
const baseShaField = baseSha ? ` base_sha=${baseSha}` : '';
|
|
46
86
|
return `${MARKER_PREFIX} kind=${kind} name=${name} base_version=${baseVersion}${baseShaField} overlay_sha=${overlaySha}
|
|
@@ -239,5 +279,6 @@ module.exports = {
|
|
|
239
279
|
parseMarkdown,
|
|
240
280
|
shortSha,
|
|
241
281
|
computeBaseFileSha,
|
|
282
|
+
ensureBaseFileShaInOverlay,
|
|
242
283
|
MARKER_PREFIX
|
|
243
284
|
};
|
package/src/utils/symlinks.js
CHANGED
|
@@ -484,8 +484,17 @@ class SymlinkUtils {
|
|
|
484
484
|
}
|
|
485
485
|
|
|
486
486
|
_generateOverlayedFile({ kind, name, fwAbsolute, overlayAbs, linkPath, lstat, frameworkVersion, aggregate, adapter, targetRel }) {
|
|
487
|
-
const { mergeOverlay,
|
|
487
|
+
const { mergeOverlay, isGeneratedFile, computeBaseFileSha, ensureBaseFileShaInOverlay } = require('./overlay-merger');
|
|
488
488
|
const baseContent = fs.readFileSync(fwAbsolute, 'utf8');
|
|
489
|
+
// Backfill base_file_sha into the overlay frontmatter so `overlay drift` is
|
|
490
|
+
// deterministic for EVERY overlay, not just freshly-scaffolded ones
|
|
491
|
+
// (additive-only, never overwrites). Done BEFORE reading overlayContent so
|
|
492
|
+
// the marker's overlay_sha already reflects it — no one-time regen churn.
|
|
493
|
+
// (v3.33.0)
|
|
494
|
+
try {
|
|
495
|
+
const bf = ensureBaseFileShaInOverlay(overlayAbs, computeBaseFileSha(baseContent));
|
|
496
|
+
if (bf.changed) UI.info(`[${adapter.label}] ${kind} ${name}: backfilled base_file_sha into overlay frontmatter.`);
|
|
497
|
+
} catch (_) { /* non-fatal — never block a merge on backfill */ }
|
|
489
498
|
const overlayContent = fs.readFileSync(overlayAbs, 'utf8');
|
|
490
499
|
const merged = mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion });
|
|
491
500
|
|
package/src/utils/ui.js
CHANGED
|
@@ -3,6 +3,22 @@ const ora = require('ora');
|
|
|
3
3
|
const inquirer = require('inquirer');
|
|
4
4
|
|
|
5
5
|
class UI {
|
|
6
|
+
// Machine-readable mode (v3.32.0+). When on, every human-facing line that
|
|
7
|
+
// these helpers emit via `console.log` is redirected to STDERR, leaving
|
|
8
|
+
// STDOUT free for a single JSON object the caller (agent / CI) can parse.
|
|
9
|
+
// The JSON itself MUST be written with `process.stdout.write` directly —
|
|
10
|
+
// never through `console.log` — so it survives this redirect. Reversible;
|
|
11
|
+
// `ora` spinners already write to stderr, so they need no special handling.
|
|
12
|
+
static setJsonMode(on) {
|
|
13
|
+
if (on && !UI._origLog) {
|
|
14
|
+
UI._origLog = console.log;
|
|
15
|
+
console.log = (...args) => process.stderr.write(args.join(' ') + '\n');
|
|
16
|
+
} else if (!on && UI._origLog) {
|
|
17
|
+
console.log = UI._origLog;
|
|
18
|
+
UI._origLog = null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
6
22
|
static header(text) {
|
|
7
23
|
console.log('');
|
|
8
24
|
console.log(chalk.cyan('━'.repeat(60)));
|