baldart 3.24.0 → 3.26.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 +99 -0
- package/VERSION +1 -1
- package/bin/baldart.js +14 -0
- package/framework/.claude/agents/coder.md +9 -4
- package/framework/.claude/skills/baldart-update/SKILL.md +110 -72
- package/framework/.claude/skills/new/SKILL.md +171 -9
- package/framework/.claude/skills/worktree-manager/SKILL.md +4 -0
- package/framework/agents/workflows.md +4 -0
- package/package.json +1 -1
- package/src/commands/doctor.js +67 -35
- package/src/commands/update.js +334 -13
- package/src/commands/version.js +60 -41
- package/src/utils/__tests__/classify-divergence.test.js +120 -0
- package/src/utils/git.js +199 -0
package/src/commands/update.js
CHANGED
|
@@ -3,6 +3,7 @@ const SymlinkUtils = require('../utils/symlinks');
|
|
|
3
3
|
const UI = require('../utils/ui');
|
|
4
4
|
const State = require('../utils/state');
|
|
5
5
|
const Hooks = require('../utils/hooks');
|
|
6
|
+
const UpdateNotifier = require('../utils/update-notifier');
|
|
6
7
|
|
|
7
8
|
function readEnabledTools(cwd = process.cwd()) {
|
|
8
9
|
try {
|
|
@@ -108,6 +109,239 @@ async function postUpdateAutoCommit(git, newVersion, options) {
|
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
// --reset nuclear option (v3.26.0+).
|
|
113
|
+
//
|
|
114
|
+
// When the standard update keeps failing due to subtree divergence the user
|
|
115
|
+
// doesn't want to investigate, --reset wipes .framework/ and reinstalls
|
|
116
|
+
// cleanly. The user keeps every customization that lives OUTSIDE .framework/:
|
|
117
|
+
//
|
|
118
|
+
// - baldart.config.yml (real file, root of consumer)
|
|
119
|
+
// - .baldart/ (whole dir: overlays/, state.json, ...)
|
|
120
|
+
// - .claude/settings.json
|
|
121
|
+
// - .claude/{agents,skills,commands}/<*.md> ONLY if NOT a symlink into
|
|
122
|
+
// .framework/ (those are
|
|
123
|
+
// regenerated by `add`)
|
|
124
|
+
//
|
|
125
|
+
// Safety:
|
|
126
|
+
// - Refuses if the working tree is dirty (commit/stash first).
|
|
127
|
+
// - Refuses --yes unless --i-know is ALSO passed: --reset rm -rf's
|
|
128
|
+
// untracked & .gitignored files inside .framework/ that the user
|
|
129
|
+
// might not realize exist.
|
|
130
|
+
// - Always creates a backup tag before deleting anything.
|
|
131
|
+
async function runReset(git, options, autoYes) {
|
|
132
|
+
const fs = require('fs');
|
|
133
|
+
const path = require('path');
|
|
134
|
+
const { spawnSync } = require('child_process');
|
|
135
|
+
|
|
136
|
+
UI.newline();
|
|
137
|
+
UI.header('BALDART RESET (NUCLEAR OPTION)');
|
|
138
|
+
UI.warning('This will rm -rf .framework/ and reinstall from upstream.');
|
|
139
|
+
UI.info('What is preserved (NEVER touched):');
|
|
140
|
+
UI.list([
|
|
141
|
+
'baldart.config.yml',
|
|
142
|
+
'.baldart/ (overlays, state.json, skill-conflicts.json, …)',
|
|
143
|
+
'.claude/settings.json',
|
|
144
|
+
'.claude/{agents,skills,commands}/*.md authored by you (non-framework symlinks)',
|
|
145
|
+
], 'cyan');
|
|
146
|
+
|
|
147
|
+
// Safety gate #1: working tree MUST be clean.
|
|
148
|
+
const clean = await git.hasCleanWorkingTree();
|
|
149
|
+
if (!clean) {
|
|
150
|
+
UI.error('Working tree is dirty. Commit or `git stash -u` first, then re-run.');
|
|
151
|
+
UI.info('Reset cannot run with pending changes — too easy to lose work.');
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Safety gate #2: any untracked / ignored files inside .framework/?
|
|
156
|
+
let untrackedInsideFramework = '';
|
|
157
|
+
try {
|
|
158
|
+
untrackedInsideFramework = await git.git.raw([
|
|
159
|
+
'ls-files', '--others', '--ignored', '--exclude-standard', '--', '.framework/'
|
|
160
|
+
]).catch(() => '');
|
|
161
|
+
// Some git versions return non-zero when --ignored has no matches; treat as empty.
|
|
162
|
+
if (typeof untrackedInsideFramework !== 'string') untrackedInsideFramework = '';
|
|
163
|
+
} catch (_) { untrackedInsideFramework = ''; }
|
|
164
|
+
const ignoredLines = untrackedInsideFramework.split('\n').filter((l) => l.trim());
|
|
165
|
+
|
|
166
|
+
if (ignoredLines.length) {
|
|
167
|
+
UI.newline();
|
|
168
|
+
UI.warning(`${ignoredLines.length} untracked / ignored file(s) inside .framework/ will be DELETED:`);
|
|
169
|
+
UI.list(ignoredLines.slice(0, 10), 'yellow');
|
|
170
|
+
if (ignoredLines.length > 10) UI.info(`…and ${ignoredLines.length - 10} more.`);
|
|
171
|
+
if (autoYes && options.iKnow !== true) {
|
|
172
|
+
UI.error('--reset --yes refuses to delete untracked/ignored files without --i-know.');
|
|
173
|
+
UI.info('Run: npx baldart update --reset --yes --i-know');
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
if (!autoYes) {
|
|
177
|
+
const ok = await UI.confirm('Proceed and DELETE these files permanently?', false);
|
|
178
|
+
if (!ok) { UI.info('Reset aborted.'); process.exit(0); }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Safety gate #3: backup tag before doing anything destructive.
|
|
183
|
+
const backupTag = await git.createBackupTag();
|
|
184
|
+
UI.success(`Backup tag created: ${backupTag}`);
|
|
185
|
+
|
|
186
|
+
// Snapshot user-owned files OUTSIDE .framework/ for restore-after-add.
|
|
187
|
+
// All these live OUTSIDE .framework/ so `rm -rf .framework/` doesn't touch
|
|
188
|
+
// them — but we record what was present so a post-install sanity check can
|
|
189
|
+
// confirm nothing got clobbered by the fresh install flow.
|
|
190
|
+
const preserved = {
|
|
191
|
+
baldartConfig: fs.existsSync('baldart.config.yml'),
|
|
192
|
+
baldartDir: fs.existsSync('.baldart'),
|
|
193
|
+
claudeSettings: fs.existsSync(path.join('.claude', 'settings.json')),
|
|
194
|
+
customAgents: snapshotCustomEntries('.claude/agents'),
|
|
195
|
+
customSkills: snapshotCustomEntries('.claude/skills'),
|
|
196
|
+
customCommands: snapshotCustomEntries('.claude/commands'),
|
|
197
|
+
};
|
|
198
|
+
UI.info(`Preserved snapshot: config=${preserved.baldartConfig}, .baldart=${preserved.baldartDir}, settings.json=${preserved.claudeSettings}, custom files: ${preserved.customAgents.length + preserved.customSkills.length + preserved.customCommands.length}`);
|
|
199
|
+
|
|
200
|
+
// Wipe .framework/. The `git rm` first ensures git's index reflects the
|
|
201
|
+
// deletion (otherwise a stray subtree commit might recreate refs); then
|
|
202
|
+
// rm -rf removes any untracked leftovers.
|
|
203
|
+
UI.newline();
|
|
204
|
+
UI.info('Removing .framework/…');
|
|
205
|
+
try {
|
|
206
|
+
spawnSync('git', ['rm', '-rf', '--ignore-unmatch', '.framework'], { stdio: 'pipe' });
|
|
207
|
+
} catch (_) { /* fall through to rm -rf */ }
|
|
208
|
+
try {
|
|
209
|
+
fs.rmSync('.framework', { recursive: true, force: true });
|
|
210
|
+
} catch (err) {
|
|
211
|
+
UI.error(`Could not remove .framework/: ${err.message}`);
|
|
212
|
+
UI.info(`Rollback with: git reset --hard ${backupTag}`);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
UI.success('.framework/ removed.');
|
|
216
|
+
|
|
217
|
+
// Commit the deletion so `subtree add` has a clean tree to land on.
|
|
218
|
+
try {
|
|
219
|
+
await git.git.add(['-A']);
|
|
220
|
+
const idx = await git.git.status();
|
|
221
|
+
if (idx.staged.length || idx.modified.length || idx.deleted.length) {
|
|
222
|
+
await git.git.commit(`chore(baldart): reset .framework/ (pre-reinstall, backup ${backupTag})`);
|
|
223
|
+
UI.success('Committed reset.');
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {
|
|
226
|
+
UI.warning(`Could not commit reset (${err.message}). Continuing — subtree add may still succeed.`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Hand off to `add` for the fresh install. `add` will:
|
|
230
|
+
// - `git subtree add --squash` → fresh subtree, no divergence history.
|
|
231
|
+
// - Reconcile symlinks (custom files preserved by existing collision logic).
|
|
232
|
+
// - Run configure if missing baldart.config.yml (we preserved it, so skip).
|
|
233
|
+
// - Register hooks (will see existing settings.json, idempotent).
|
|
234
|
+
UI.newline();
|
|
235
|
+
UI.info('Re-installing framework via `baldart add`…');
|
|
236
|
+
const addCmd = require('./add');
|
|
237
|
+
await addCmd({ yes: true });
|
|
238
|
+
|
|
239
|
+
// Post-restore sanity check — confirm nothing user-owned got clobbered.
|
|
240
|
+
const stillPresent = {
|
|
241
|
+
baldartConfig: fs.existsSync('baldart.config.yml'),
|
|
242
|
+
baldartDir: fs.existsSync('.baldart'),
|
|
243
|
+
claudeSettings: fs.existsSync(path.join('.claude', 'settings.json')),
|
|
244
|
+
};
|
|
245
|
+
const lost = Object.entries(stillPresent).filter(([k, v]) => preserved[k] && !v).map(([k]) => k);
|
|
246
|
+
if (lost.length) {
|
|
247
|
+
UI.error(`Reset DROPPED user-owned files: ${lost.join(', ')}.`);
|
|
248
|
+
UI.info(`Rollback IMMEDIATELY: git reset --hard ${backupTag}`);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
UI.header('✓ RESET COMPLETE');
|
|
253
|
+
UI.box('RESET RESULT', [
|
|
254
|
+
`Framework: fresh install (${await git.getFrameworkVersion()})`,
|
|
255
|
+
`Backup tag: ${backupTag}`,
|
|
256
|
+
`Rollback if needed: git reset --hard ${backupTag}`,
|
|
257
|
+
'',
|
|
258
|
+
'All user-owned files preserved.',
|
|
259
|
+
]);
|
|
260
|
+
process.exit(0);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function snapshotCustomEntries(dir) {
|
|
264
|
+
const fs = require('fs');
|
|
265
|
+
const path = require('path');
|
|
266
|
+
if (!fs.existsSync(dir)) return [];
|
|
267
|
+
try {
|
|
268
|
+
return fs.readdirSync(dir)
|
|
269
|
+
.filter((name) => {
|
|
270
|
+
// Symlinks pointing into .framework/ are framework-owned (will be
|
|
271
|
+
// recreated by `add`). Real files / dirs are user-authored.
|
|
272
|
+
try {
|
|
273
|
+
const lst = fs.lstatSync(path.join(dir, name));
|
|
274
|
+
if (!lst.isSymbolicLink()) return true;
|
|
275
|
+
const target = fs.readlinkSync(path.join(dir, name));
|
|
276
|
+
return !target.includes('.framework/');
|
|
277
|
+
} catch (_) { return false; }
|
|
278
|
+
});
|
|
279
|
+
} catch (_) { return []; }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// CLI self-upgrade auto-relaunch (v3.25.0+).
|
|
283
|
+
// When the globally-installed `baldart` CLI is older than the latest on npm,
|
|
284
|
+
// transparently re-invoke `npx baldart@latest update <flags>` so the user
|
|
285
|
+
// gets the fix without having to remember `npm i -g baldart@latest`. The
|
|
286
|
+
// child inherits stdio (preserves prompts), and BALDART_RELAUNCHED=1 in env
|
|
287
|
+
// is a loop guard: if npm cache somehow serves an older version, the child
|
|
288
|
+
// sees the flag and skips its own relaunch check.
|
|
289
|
+
async function maybeRelaunchUnderLatest(options) {
|
|
290
|
+
if (process.env.BALDART_RELAUNCHED === '1') return false;
|
|
291
|
+
if (process.env.BALDART_NO_RELAUNCH === '1') return false;
|
|
292
|
+
|
|
293
|
+
const cliVersion = require('../../package.json').version;
|
|
294
|
+
let info = UpdateNotifier.hint({ currentVersion: cliVersion });
|
|
295
|
+
if (!info) {
|
|
296
|
+
// No cached hint — force a refresh so a brand-new `baldart update`
|
|
297
|
+
// invocation can still benefit. Best-effort, network-bounded.
|
|
298
|
+
try {
|
|
299
|
+
await UpdateNotifier.refresh({ currentVersion: cliVersion, timeoutMs: 3000, force: true });
|
|
300
|
+
info = UpdateNotifier.hint({ currentVersion: cliVersion });
|
|
301
|
+
} catch (_) { /* offline / blocked — fall through */ }
|
|
302
|
+
}
|
|
303
|
+
if (!info) return false;
|
|
304
|
+
|
|
305
|
+
const autoYes = options.yes === true;
|
|
306
|
+
let proceed = autoYes;
|
|
307
|
+
if (!autoYes) {
|
|
308
|
+
UI.newline();
|
|
309
|
+
UI.warning(`The installed CLI is older than npm latest: v${cliVersion} → v${info.latest}.`);
|
|
310
|
+
const choice = await UI.select('How would you like to proceed?', [
|
|
311
|
+
{ name: `Auto-relaunch via npx baldart@${info.latest} (recommended)`, value: 'relaunch' },
|
|
312
|
+
{ name: 'Proceed with the current CLI (you may hit fixed bugs)', value: 'current' },
|
|
313
|
+
{ name: 'Abort and upgrade manually (`npm i -g baldart@latest`)', value: 'abort' },
|
|
314
|
+
]);
|
|
315
|
+
if (choice === 'abort') {
|
|
316
|
+
UI.info(`Run: npm i -g baldart@latest && npx baldart update${options.yes ? ' --yes' : ''}`);
|
|
317
|
+
process.exit(0);
|
|
318
|
+
}
|
|
319
|
+
proceed = (choice === 'relaunch');
|
|
320
|
+
}
|
|
321
|
+
if (!proceed) return false;
|
|
322
|
+
|
|
323
|
+
// Reconstruct the original argv flags for the child.
|
|
324
|
+
const flags = [];
|
|
325
|
+
if (options.yes === true) flags.push('--yes');
|
|
326
|
+
if (options.autoStash === true && options.yes !== true) flags.push('--auto-stash');
|
|
327
|
+
if (options.commit === false) flags.push('--no-commit');
|
|
328
|
+
if (options.reset === true) flags.push('--reset');
|
|
329
|
+
if (options.iKnow === true) flags.push('--i-know');
|
|
330
|
+
|
|
331
|
+
const spawn = require('child_process').spawnSync;
|
|
332
|
+
UI.info(`Auto-relaunching via npx baldart@${info.latest}…`);
|
|
333
|
+
const res = spawn('npx', ['-y', `baldart@${info.latest}`, 'update', ...flags], {
|
|
334
|
+
stdio: 'inherit',
|
|
335
|
+
env: { ...process.env, BALDART_RELAUNCHED: '1' },
|
|
336
|
+
timeout: 600000, // mirror the longest legitimate flow downstream
|
|
337
|
+
});
|
|
338
|
+
if (res.error || res.status === null) {
|
|
339
|
+
UI.warning(`Auto-relaunch failed (${res.error ? res.error.message : 'no exit'}). Falling back to current CLI.`);
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
process.exit(res.status || 0);
|
|
343
|
+
}
|
|
344
|
+
|
|
111
345
|
async function update(options = {}) {
|
|
112
346
|
const git = new GitUtils();
|
|
113
347
|
const symlinks = new SymlinkUtils();
|
|
@@ -137,6 +371,21 @@ async function update(options = {}) {
|
|
|
137
371
|
process.exit(1);
|
|
138
372
|
}
|
|
139
373
|
|
|
374
|
+
// v3.26.0+ NUCLEAR OPTION: --reset rips out .framework/ and reinstalls
|
|
375
|
+
// fresh, preserving everything user-owned that lives OUTSIDE .framework/.
|
|
376
|
+
// Branches the flow entirely — never proceeds to the standard subtree
|
|
377
|
+
// pull path.
|
|
378
|
+
if (options.reset === true) {
|
|
379
|
+
await runReset(git, options, autoYes);
|
|
380
|
+
return; // runReset exits the process on success
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// v3.25.0+: CLI self-upgrade — if a newer baldart is on npm, transparently
|
|
384
|
+
// relaunch under `npx baldart@latest` so the user always runs the fixed
|
|
385
|
+
// code path. Loop guard via BALDART_RELAUNCHED env. No-op when already on
|
|
386
|
+
// latest or when the user opts out / network is down.
|
|
387
|
+
await maybeRelaunchUnderLatest(options);
|
|
388
|
+
|
|
140
389
|
const currentVersion = await git.getFrameworkVersion();
|
|
141
390
|
UI.success(`Current version: ${currentVersion}`);
|
|
142
391
|
|
|
@@ -168,8 +417,12 @@ async function update(options = {}) {
|
|
|
168
417
|
|
|
169
418
|
const spinner = UI.spinner('Connecting to repository...').start();
|
|
170
419
|
|
|
420
|
+
// SSOT (v3.25.0+): one query gives version compare + commit counts +
|
|
421
|
+
// divergence class. Replaces hasChangesToPush() (which checked the wrong
|
|
422
|
+
// remote AND wrong direction — see git.js history for context).
|
|
423
|
+
let status;
|
|
171
424
|
try {
|
|
172
|
-
await git.
|
|
425
|
+
status = await git.getUpdateStatus(repo);
|
|
173
426
|
spinner.succeed('Connected to repository');
|
|
174
427
|
} catch (error) {
|
|
175
428
|
spinner.fail();
|
|
@@ -187,16 +440,74 @@ async function update(options = {}) {
|
|
|
187
440
|
process.exit(1);
|
|
188
441
|
}
|
|
189
442
|
|
|
190
|
-
|
|
191
|
-
|
|
443
|
+
if (!status.fetched || !status.hasFetchHead) {
|
|
444
|
+
UI.error('Could not fetch upstream — try again with network access.');
|
|
445
|
+
process.exit(1);
|
|
446
|
+
}
|
|
192
447
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
448
|
+
// VERSION-compare authority. Subtree-merge commit count is noise; never
|
|
449
|
+
// use behind > 0 as the up-to-date signal (root cause of v3.24.x bug).
|
|
450
|
+
if (status.isAligned) {
|
|
451
|
+
UI.success(`Already up to date! (v${status.installedVersion})`);
|
|
196
452
|
process.exit(0);
|
|
197
453
|
}
|
|
198
454
|
|
|
199
|
-
UI.warning(
|
|
455
|
+
UI.warning(`Update available: v${status.installedVersion} → v${status.remoteVersion}`);
|
|
456
|
+
|
|
457
|
+
// Divergence handling: when consumer has commits on .framework/ that
|
|
458
|
+
// aren't upstream, classify before deciding. all-noise = subtree
|
|
459
|
+
// plumbing autogenerated → auto-resolve. overlay-able = user-customised
|
|
460
|
+
// a framework file → suggest /overlay. real-custom = prompt 3-way.
|
|
461
|
+
if (status.hasDivergence) {
|
|
462
|
+
UI.newline();
|
|
463
|
+
if (status.divergenceClass === 'all-noise') {
|
|
464
|
+
UI.info(`Detected ${status.divergenceCommits.length} subtree-merge / chore commit(s) (git plumbing noise — no real content drift). Auto-resolving…`);
|
|
465
|
+
} else if (status.divergenceClass === 'overlay-able') {
|
|
466
|
+
UI.warning('Detected custom edits to framework files inside `.framework/.claude/{agents,skills,commands}/`.');
|
|
467
|
+
UI.info('These are better expressed as overlays under `.baldart/overlays/` — they survive updates without divergence.');
|
|
468
|
+
for (const c of status.divergenceCommits.filter((c) => c.category === 'custom-overlay-able')) {
|
|
469
|
+
UI.info(` • ${c.sha.slice(0, 7)} — ${c.subject}`);
|
|
470
|
+
}
|
|
471
|
+
if (autoYes) {
|
|
472
|
+
UI.error('Refusing to overwrite custom edits in --yes mode. Re-run without --yes to choose.');
|
|
473
|
+
UI.info('Recommended: use the `/overlay` skill to migrate these edits, then re-run update.');
|
|
474
|
+
process.exit(1);
|
|
475
|
+
}
|
|
476
|
+
const choice = await UI.select('How would you like to proceed?', [
|
|
477
|
+
{ name: 'Migrate to overlay and pull anyway (recommended — overlay wins)', value: 'overlay' },
|
|
478
|
+
{ name: 'Keep the custom commits (subtree pull may conflict)', value: 'keep' },
|
|
479
|
+
{ name: 'Abort', value: 'abort' },
|
|
480
|
+
]);
|
|
481
|
+
if (choice === 'abort') { UI.info('Update cancelled.'); process.exit(0); }
|
|
482
|
+
if (choice === 'overlay') {
|
|
483
|
+
UI.info('Run `/overlay` from Claude Code to migrate the custom edits, then re-run `npx baldart update`.');
|
|
484
|
+
process.exit(0);
|
|
485
|
+
}
|
|
486
|
+
// 'keep' → fall through, subtree pull will attempt merge
|
|
487
|
+
} else if (status.divergenceClass === 'real-custom' || status.divergenceClass === 'mixed') {
|
|
488
|
+
UI.warning(`Detected ${status.divergenceCommits.filter((c) => c.category === 'custom-other').length} custom commit(s) on .framework/ that are NOT auto-classifiable as overlays.`);
|
|
489
|
+
for (const c of status.divergenceCommits.filter((c) => !['subtree-merge', 'subtree-squash', 'chore-wrapper'].includes(c.category))) {
|
|
490
|
+
UI.info(` • ${c.sha.slice(0, 7)} [${c.category}] — ${c.subject}`);
|
|
491
|
+
}
|
|
492
|
+
if (autoYes) {
|
|
493
|
+
UI.error('Refusing to pull over custom commits in --yes mode. Re-run without --yes to choose.');
|
|
494
|
+
process.exit(1);
|
|
495
|
+
}
|
|
496
|
+
const choice = await UI.select('How would you like to proceed?', [
|
|
497
|
+
{ name: 'Push these commits upstream first (recommended — `/baldart-push`)', value: 'push' },
|
|
498
|
+
{ name: 'Pull anyway (will create a merge commit — possible conflicts)', value: 'pull' },
|
|
499
|
+
{ name: 'Abort', value: 'abort' },
|
|
500
|
+
]);
|
|
501
|
+
if (choice === 'abort') { UI.info('Update cancelled.'); process.exit(0); }
|
|
502
|
+
if (choice === 'push') {
|
|
503
|
+
UI.info('Run `/baldart-push` from Claude Code (or `npx baldart push`) to contribute upstream, then re-run update.');
|
|
504
|
+
process.exit(0);
|
|
505
|
+
}
|
|
506
|
+
// 'pull' → fall through
|
|
507
|
+
}
|
|
508
|
+
// 'unknown' → conservative: continue silently, the subtree pull will
|
|
509
|
+
// surface any real conflict.
|
|
510
|
+
}
|
|
200
511
|
|
|
201
512
|
// Step 3: Show changes preview
|
|
202
513
|
UI.header('STEP 3/5: Preview Changes');
|
|
@@ -412,13 +723,23 @@ async function update(options = {}) {
|
|
|
412
723
|
if (res.malformed) {
|
|
413
724
|
UI.warning('.claude/settings.json is malformed; skipped hook registration. Run `npx baldart doctor` after fixing it.');
|
|
414
725
|
} else {
|
|
415
|
-
for (
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
726
|
+
// Aggregate output for missing-backfill (the common case after a major
|
|
727
|
+
// bump) to keep the noise floor low. Drift events stay per-line since
|
|
728
|
+
// they represent user-visible decisions.
|
|
729
|
+
const created = res.results.filter((r) => r.status === 'created').map((r) => r.id);
|
|
730
|
+
const legacy = res.results.filter((r) => r.status === 'legacy-migrated');
|
|
731
|
+
const drifts = res.results.filter((r) => r.status === 'drift-replaced' || r.status === 'drift-kept');
|
|
732
|
+
if (created.length === 1) {
|
|
733
|
+
UI.success(`Registered hook \`${created[0]}\` in .claude/settings.json.`);
|
|
734
|
+
} else if (created.length > 1) {
|
|
735
|
+
UI.success(`Auto-registered ${created.length} missing hook(s): ${created.join(', ')}.`);
|
|
736
|
+
}
|
|
737
|
+
for (const r of legacy) UI.success(`Migrated legacy hook to \`${r.id}\` (previous: ${r.previousCommand})`);
|
|
738
|
+
for (const r of drifts) {
|
|
739
|
+
if (r.status === 'drift-replaced') UI.success(`Replaced drifted hook \`${r.id}\` (was: ${r.previousCommand})`);
|
|
740
|
+
else UI.info(`Kept user-customized hook \`${r.id}\` as-is.`);
|
|
421
741
|
}
|
|
742
|
+
// 'already' is silent on update to avoid noise
|
|
422
743
|
}
|
|
423
744
|
} catch (err) {
|
|
424
745
|
UI.warning(`Hook registration skipped: ${err.message}`);
|
package/src/commands/version.js
CHANGED
|
@@ -11,28 +11,23 @@ function fmtDate(iso) {
|
|
|
11
11
|
try { return iso.slice(0, 10); } catch (_) { return iso; }
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
async function describeDrift(git) {
|
|
15
|
-
//
|
|
14
|
+
async function describeDrift(git, status) {
|
|
15
|
+
// Uncommitted local edits to .framework/ (working tree, NOT commits)
|
|
16
|
+
let uncommittedFiles = 0;
|
|
16
17
|
try {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const [a, b] = out.trim().split('\t').map(Number);
|
|
21
|
-
hasFetchHead = !Number.isNaN(a) && !Number.isNaN(b);
|
|
22
|
-
var ahead = a, behind = b;
|
|
23
|
-
} catch (_) { /* no FETCH_HEAD — needs fetch first */ }
|
|
18
|
+
const dirty = await git.git.raw(['diff', '--name-only', 'HEAD', '--', FRAMEWORK_DIR]);
|
|
19
|
+
uncommittedFiles = dirty.trim() ? dirty.trim().split('\n').length : 0;
|
|
20
|
+
} catch (_) { /* repo without HEAD yet */ }
|
|
24
21
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
22
|
+
return {
|
|
23
|
+
isAligned: status.isAligned,
|
|
24
|
+
installedVersion: status.installedVersion,
|
|
25
|
+
remoteVersion: status.remoteVersion,
|
|
26
|
+
behind: status.commitCount.behind,
|
|
27
|
+
ahead: status.commitCount.ahead,
|
|
28
|
+
uncommittedFiles,
|
|
29
|
+
hasFetchHead: status.hasFetchHead,
|
|
30
|
+
};
|
|
36
31
|
}
|
|
37
32
|
|
|
38
33
|
async function version(opts = {}) {
|
|
@@ -50,29 +45,42 @@ async function version(opts = {}) {
|
|
|
50
45
|
const state = State.load();
|
|
51
46
|
const cliVersion = require('../../package.json').version;
|
|
52
47
|
const cliUpdate = UpdateNotifier.hint({ currentVersion: cliVersion });
|
|
48
|
+
const verbose = opts.verbose === true;
|
|
53
49
|
|
|
54
|
-
//
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
// SSOT (v3.25.0+): one query, version-compare authority.
|
|
51
|
+
const repo = state.framework_repo || REPO_DEFAULT;
|
|
52
|
+
let status;
|
|
53
|
+
if (opts.offline === true) {
|
|
54
|
+
status = {
|
|
55
|
+
installedVersion: frameworkVersion,
|
|
56
|
+
remoteVersion: 'unknown',
|
|
57
|
+
isAligned: false,
|
|
58
|
+
commitCount: { behind: 0, ahead: 0, note: '' },
|
|
59
|
+
aheadScoped: 0, hasDivergence: false, hasFetchHead: false, fetched: false,
|
|
60
|
+
};
|
|
61
|
+
} else {
|
|
62
|
+
status = await git.getUpdateStatus(repo);
|
|
60
63
|
}
|
|
61
|
-
const drift = await describeDrift(git);
|
|
64
|
+
const drift = await describeDrift(git, status);
|
|
62
65
|
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
// Messaging based on `isAligned` (not commit count, which is subtree noise).
|
|
67
|
+
let remoteLine;
|
|
68
|
+
if (!drift.hasFetchHead) {
|
|
69
|
+
remoteLine = 'Remote: (offline / not fetched)';
|
|
70
|
+
} else if (drift.isAligned) {
|
|
71
|
+
remoteLine = `Remote: v${drift.remoteVersion} (aligned)`;
|
|
72
|
+
} else if (drift.remoteVersion !== 'unknown') {
|
|
73
|
+
remoteLine = `Remote: v${drift.remoteVersion} (installed v${drift.installedVersion} — \`npx baldart update\`)`;
|
|
74
|
+
} else {
|
|
75
|
+
remoteLine = 'Remote: (upstream VERSION unreadable)';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const lines = [
|
|
65
79
|
`Installed: v${frameworkVersion} ${state.install_date ? `(since ${fmtDate(state.install_date)})` : ''}`.trim(),
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
: 'Remote: (offline / not fetched)',
|
|
71
|
-
drift
|
|
72
|
-
? (drift.uncommittedFiles > 0
|
|
73
|
-
? `Local: v${frameworkVersion} + ${drift.uncommittedFiles} uncommitted file(s) in .framework/`
|
|
74
|
-
: `Local: v${frameworkVersion} (clean)`)
|
|
75
|
-
: '',
|
|
80
|
+
remoteLine,
|
|
81
|
+
drift.uncommittedFiles > 0
|
|
82
|
+
? `Local: v${frameworkVersion} + ${drift.uncommittedFiles} uncommitted file(s) in .framework/`
|
|
83
|
+
: `Local: v${frameworkVersion} (clean)`,
|
|
76
84
|
'',
|
|
77
85
|
`Last update: ${fmtDate(state.last_update_date)}`,
|
|
78
86
|
`Last push: v${state.last_pushed_version || '—'} ${state.last_push_date ? `(${fmtDate(state.last_push_date)})` : ''}`.trim(),
|
|
@@ -80,8 +88,19 @@ async function version(opts = {}) {
|
|
|
80
88
|
cliUpdate
|
|
81
89
|
? `CLI: v${cliVersion} → v${cliUpdate.latest} available (npm i -g baldart@latest)`
|
|
82
90
|
: `CLI: v${cliVersion}`,
|
|
83
|
-
`Repository: https://github.com/${
|
|
84
|
-
]
|
|
91
|
+
`Repository: https://github.com/${repo}`,
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
if (verbose && drift.hasFetchHead) {
|
|
95
|
+
lines.push('');
|
|
96
|
+
lines.push('Diagnostics (subtree commit history — includes auto-generated merges):');
|
|
97
|
+
lines.push(` commits ahead: ${drift.ahead}`);
|
|
98
|
+
lines.push(` commits behind: ${drift.behind}`);
|
|
99
|
+
lines.push(' (these counts grow with every `git subtree pull --squash`; ignore unless investigating)');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
UI.newline();
|
|
103
|
+
UI.box('BALDART VERSION', lines.filter((l) => l !== ''));
|
|
85
104
|
UI.newline();
|
|
86
105
|
} catch (error) {
|
|
87
106
|
UI.error(`Failed to get version: ${error.message}`);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Fixture-driven test for classifyDivergence pattern matching.
|
|
4
|
+
*
|
|
5
|
+
* Run: node src/utils/__tests__/classify-divergence.test.js
|
|
6
|
+
* Exits 0 on success, 1 on any mismatch (CI-friendly).
|
|
7
|
+
*
|
|
8
|
+
* Fixtures cover the exact commit subjects observed during the
|
|
9
|
+
* `mayo` consumer debug (v3.25.0 release). Adversarial-review enforced.
|
|
10
|
+
*/
|
|
11
|
+
const GitUtils = require('../git');
|
|
12
|
+
const g = new GitUtils();
|
|
13
|
+
|
|
14
|
+
const SUBJECT_FIXTURES = [
|
|
15
|
+
// ─── subtree-merge (branch-agnostic) ─────────────────────────────────
|
|
16
|
+
{ subject: "Merge commit 'abc123' into develop", expected: 'subtree-merge' },
|
|
17
|
+
{ subject: "Merge commit 'abc123' into main", expected: 'subtree-merge' },
|
|
18
|
+
{ subject: "Merge commit 'deadbeef' into feature/x", expected: 'subtree-merge' },
|
|
19
|
+
{ subject: "Merge branch 'feature-x' into main", expected: 'subtree-merge' },
|
|
20
|
+
|
|
21
|
+
// ─── subtree-squash ──────────────────────────────────────────────────
|
|
22
|
+
{ subject: "Squashed '.framework/' changes from abc..def", expected: 'subtree-squash' },
|
|
23
|
+
{ subject: "Squashed '.framework/' content from 1234..5678", expected: 'subtree-squash' },
|
|
24
|
+
|
|
25
|
+
// ─── chore-wrapper (real commits from mayo) ──────────────────────────
|
|
26
|
+
{ subject: '[CHORE] reconcile baldart state ledger v3.22.1 -> v3.25.0', expected: 'chore-wrapper' },
|
|
27
|
+
{ subject: '[CHORE] register baldart agent-discovery hooks', expected: 'chore-wrapper' },
|
|
28
|
+
{ subject: 'chore(baldart): post-update reconcile to v3.25.0', expected: 'chore-wrapper' },
|
|
29
|
+
{ subject: 'chore: bump baldart version', expected: 'chore-wrapper' },
|
|
30
|
+
{ subject: '[CHORE] baldart update → v3.12.0', expected: 'chore-wrapper' },
|
|
31
|
+
|
|
32
|
+
// ─── non-matches (should return null → fall through to path classifier) ──
|
|
33
|
+
{ subject: 'feat: add new login screen', expected: null },
|
|
34
|
+
{ subject: 'fix: ui-expert agent for mayo design system', expected: null },
|
|
35
|
+
{ subject: 'docs: update README', expected: null },
|
|
36
|
+
|
|
37
|
+
// ─── adversarial false-positive guards ───────────────────────────────
|
|
38
|
+
// "Merge pull request" is NOT a subtree merge — must NOT match
|
|
39
|
+
{ subject: 'Merge pull request #42 from feature/x', expected: null },
|
|
40
|
+
// "Squashed" without `.framework/` path — NOT subtree-squash
|
|
41
|
+
{ subject: "Squashed 'docs/' content from abc..def", expected: null },
|
|
42
|
+
// Chore without 'baldart' keyword — NOT chore-wrapper
|
|
43
|
+
{ subject: 'chore: update dependencies', expected: null },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const PATH_FIXTURES = [
|
|
47
|
+
{
|
|
48
|
+
name: 'all touched in .framework/.claude/agents → overlay-able',
|
|
49
|
+
touched: ['.framework/framework/.claude/agents/ui-expert.md'],
|
|
50
|
+
expected: 'custom-overlay-able',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'all touched in .framework/.claude/skills → overlay-able',
|
|
54
|
+
touched: [
|
|
55
|
+
'.framework/framework/.claude/skills/ui-design/SKILL.md',
|
|
56
|
+
'.framework/framework/.claude/skills/ui-design/scripts/foo.sh',
|
|
57
|
+
],
|
|
58
|
+
expected: 'custom-overlay-able',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'mixed agents + skills → still overlay-able',
|
|
62
|
+
touched: [
|
|
63
|
+
'.framework/framework/.claude/agents/ui-expert.md',
|
|
64
|
+
'.framework/framework/.claude/commands/check.md',
|
|
65
|
+
],
|
|
66
|
+
expected: 'custom-overlay-able',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'touches .framework/src → custom-other',
|
|
70
|
+
touched: ['.framework/src/utils/git.js'],
|
|
71
|
+
expected: 'custom-other',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'mix overlay-able + src → custom-other',
|
|
75
|
+
touched: [
|
|
76
|
+
'.framework/framework/.claude/agents/ui-expert.md',
|
|
77
|
+
'.framework/src/utils/git.js',
|
|
78
|
+
],
|
|
79
|
+
expected: 'custom-other',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'empty paths → custom-other (conservative default)',
|
|
83
|
+
touched: [],
|
|
84
|
+
expected: 'custom-other',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'touches CHANGELOG → custom-other',
|
|
88
|
+
touched: ['.framework/CHANGELOG.md'],
|
|
89
|
+
expected: 'custom-other',
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
let pass = 0, fail = 0;
|
|
94
|
+
|
|
95
|
+
console.log('─── Subject classifier ───');
|
|
96
|
+
for (const f of SUBJECT_FIXTURES) {
|
|
97
|
+
const actual = g.classifyCommitSubject(f.subject);
|
|
98
|
+
if (actual === f.expected) {
|
|
99
|
+
pass++;
|
|
100
|
+
console.log(` ✓ "${f.subject}" → ${actual}`);
|
|
101
|
+
} else {
|
|
102
|
+
fail++;
|
|
103
|
+
console.log(` ✗ "${f.subject}" → ${actual} (expected ${f.expected})`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log('\n─── Path classifier ───');
|
|
108
|
+
for (const f of PATH_FIXTURES) {
|
|
109
|
+
const actual = g.classifyCommitByPaths(f.touched);
|
|
110
|
+
if (actual === f.expected) {
|
|
111
|
+
pass++;
|
|
112
|
+
console.log(` ✓ ${f.name} → ${actual}`);
|
|
113
|
+
} else {
|
|
114
|
+
fail++;
|
|
115
|
+
console.log(` ✗ ${f.name} → ${actual} (expected ${f.expected})`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(`\nResult: ${pass} pass, ${fail} fail`);
|
|
120
|
+
process.exit(fail > 0 ? 1 : 0);
|