baldart 3.25.0 → 3.27.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 +95 -0
- package/VERSION +1 -1
- package/bin/baldart.js +14 -0
- package/framework/.claude/hooks/agent-discovery-info.sh +30 -8
- package/framework/.claude/hooks/framework-edit-gate.js +13 -7
- package/framework/.claude/skills/baldart-update/SKILL.md +123 -73
- 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/utils/overlay-merger.js +36 -12
- package/src/utils/symlinks.js +58 -11
package/src/commands/doctor.js
CHANGED
|
@@ -117,15 +117,22 @@ function overlayDrift(cwd, frameworkVersion) {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
async function describeRemote(git, repo, offline) {
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
120
|
+
// SSOT (v3.25.0+): version-compare authority + diagnostic counts.
|
|
121
|
+
// The legacy { ahead, behind, fetched } shape is preserved for downstream
|
|
122
|
+
// callers that read it; the new authoritative fields are `isAligned`,
|
|
123
|
+
// `installedVersion`, `remoteVersion`.
|
|
124
|
+
if (offline) return { fetched: false, isAligned: false, installedVersion: 'unknown', remoteVersion: 'unknown', aheadScoped: 0 };
|
|
125
|
+
const status = await git.getUpdateStatus(repo);
|
|
126
|
+
return {
|
|
127
|
+
fetched: status.fetched,
|
|
128
|
+
ahead: status.commitCount.ahead,
|
|
129
|
+
behind: status.commitCount.behind,
|
|
130
|
+
isAligned: status.isAligned,
|
|
131
|
+
installedVersion: status.installedVersion,
|
|
132
|
+
remoteVersion: status.remoteVersion,
|
|
133
|
+
aheadScoped: status.aheadScoped,
|
|
134
|
+
hasFetchHead: status.hasFetchHead,
|
|
135
|
+
};
|
|
129
136
|
}
|
|
130
137
|
|
|
131
138
|
async function localFrameworkChanges(git) {
|
|
@@ -137,11 +144,12 @@ async function localFrameworkChanges(git) {
|
|
|
137
144
|
} catch (_) { return 0; }
|
|
138
145
|
}
|
|
139
146
|
|
|
140
|
-
async function commitsAheadOfRemote(git) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
147
|
+
async function commitsAheadOfRemote(git, remote) {
|
|
148
|
+
// Pre-v3.25.0 this re-ran the git query; now we read from the SSOT
|
|
149
|
+
// result populated by describeRemote(). Kept as a helper for readability
|
|
150
|
+
// in detectState().
|
|
151
|
+
if (remote && typeof remote.aheadScoped === 'number') return remote.aheadScoped;
|
|
152
|
+
return 0;
|
|
145
153
|
}
|
|
146
154
|
|
|
147
155
|
async function detectState(cwd, opts = {}) {
|
|
@@ -206,7 +214,7 @@ async function detectState(cwd, opts = {}) {
|
|
|
206
214
|
}
|
|
207
215
|
|
|
208
216
|
state.remote = await describeRemote(git, ledger.framework_repo || REPO_DEFAULT, !!opts.offline);
|
|
209
|
-
state.commitsAhead = await commitsAheadOfRemote(git);
|
|
217
|
+
state.commitsAhead = await commitsAheadOfRemote(git, state.remote);
|
|
210
218
|
state.workingTreeDirty = await localFrameworkChanges(git);
|
|
211
219
|
|
|
212
220
|
state.overlays = countOverlays(cwd);
|
|
@@ -577,14 +585,24 @@ function planActions(state) {
|
|
|
577
585
|
});
|
|
578
586
|
}
|
|
579
587
|
|
|
580
|
-
|
|
581
|
-
|
|
588
|
+
// v3.25.0+: drift detection is authoritative via VERSION compare (isAligned).
|
|
589
|
+
// The HEAD...FETCH_HEAD commit count is subtree-merge noise and never reaches
|
|
590
|
+
// 0, so we MUST NOT use it as the "needs update" signal.
|
|
591
|
+
const remoteAhead = state.remote.fetched && state.remote.isAligned === false;
|
|
592
|
+
// Local work detection: working-tree dirty is real; commitsAhead (aheadScoped)
|
|
593
|
+
// includes subtree-merge noise — only count it when NOT aligned (i.e. there's
|
|
594
|
+
// a real divergence to investigate). C2 adds classifier to filter noise.
|
|
595
|
+
const hasLocalWork = state.workingTreeDirty > 0 ||
|
|
596
|
+
(state.commitsAhead > 0 && state.remote.fetched && state.remote.isAligned === false);
|
|
582
597
|
|
|
583
598
|
if (remoteAhead) {
|
|
599
|
+
const fromTo = (state.remote.installedVersion && state.remote.remoteVersion)
|
|
600
|
+
? `v${state.remote.installedVersion} → v${state.remote.remoteVersion}`
|
|
601
|
+
: 'update available';
|
|
584
602
|
actions.push({
|
|
585
603
|
key: 'update',
|
|
586
|
-
label: `
|
|
587
|
-
why: 'Remote framework
|
|
604
|
+
label: `Update framework (${fromTo})`,
|
|
605
|
+
why: 'Remote framework version differs from local. Updating first keeps your contributions on a fresh base.',
|
|
588
606
|
autoOk: true, // update has its own internal "Proceed?" prompt + pre-flight stash
|
|
589
607
|
run: () => require('./update')(),
|
|
590
608
|
});
|
|
@@ -596,7 +614,7 @@ function planActions(state) {
|
|
|
596
614
|
label: 'Push local framework improvements upstream',
|
|
597
615
|
why: state.workingTreeDirty
|
|
598
616
|
? `${state.workingTreeDirty} uncommitted file(s) in .framework/ — commit them through \`baldart push\`.`
|
|
599
|
-
: `${state.commitsAhead} commit(s) on .framework/ not yet pushed.`,
|
|
617
|
+
: `${state.commitsAhead} commit(s) on .framework/ not yet pushed (may include subtree-merge noise; \`baldart push\` will classify).`,
|
|
600
618
|
autoOk: false, // push writes to remote and asks for version classification — keep explicit intent
|
|
601
619
|
run: () => require('./push')(),
|
|
602
620
|
});
|
|
@@ -685,25 +703,39 @@ function renderDiagnostic(state) {
|
|
|
685
703
|
}
|
|
686
704
|
|
|
687
705
|
if (state.remote.fetched) {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
706
|
+
// v3.25.0+: messaging based on isAligned (version-compare), not on
|
|
707
|
+
// commit count which is structurally noisy on subtree pulls.
|
|
708
|
+
if (state.remote.isAligned) {
|
|
709
|
+
console.log(statusLine(
|
|
710
|
+
'Remote',
|
|
711
|
+
`v${state.remote.remoteVersion} (aligned)`,
|
|
712
|
+
'ok'
|
|
713
|
+
));
|
|
714
|
+
} else if (state.remote.remoteVersion && state.remote.remoteVersion !== 'unknown') {
|
|
715
|
+
console.log(statusLine(
|
|
716
|
+
'Remote',
|
|
717
|
+
`v${state.remote.remoteVersion} — update available (installed v${state.remote.installedVersion})`,
|
|
718
|
+
'warn'
|
|
719
|
+
));
|
|
720
|
+
} else {
|
|
721
|
+
console.log(statusLine('Remote', 'upstream VERSION unreadable', 'warn'));
|
|
722
|
+
}
|
|
694
723
|
} else {
|
|
695
724
|
console.log(statusLine('Remote', 'offline / not fetched', 'warn'));
|
|
696
725
|
}
|
|
697
726
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
727
|
+
// Local changes: working-tree dirty is real signal. Commit count is shown
|
|
728
|
+
// ONLY if not aligned (otherwise it's subtree-merge noise — see Fix 2 for
|
|
729
|
+
// the classifier that further filters this).
|
|
730
|
+
const showCommitsAhead = state.commitsAhead > 0 &&
|
|
731
|
+
state.remote.fetched && state.remote.isAligned === false;
|
|
732
|
+
const localStatusText = state.workingTreeDirty > 0
|
|
733
|
+
? `${state.workingTreeDirty} file(s) in .framework/`
|
|
734
|
+
: showCommitsAhead
|
|
735
|
+
? `${state.commitsAhead} unpushed commit(s)`
|
|
736
|
+
: 'clean';
|
|
737
|
+
const localSeverity = (state.workingTreeDirty > 0 || showCommitsAhead) ? 'warn' : 'ok';
|
|
738
|
+
console.log(statusLine('Local changes', localStatusText, localSeverity));
|
|
707
739
|
|
|
708
740
|
console.log(statusLine(
|
|
709
741
|
'Overlays',
|
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}`);
|