mandrel 1.60.0 → 1.61.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/.agents/README.md +74 -32
- package/.agents/docs/SDLC.md +8 -9
- package/.agents/docs/configuration.md +61 -4
- package/.agents/docs/quality-gates.md +796 -0
- package/.agents/docs/workflows.md +2 -3
- package/.agents/runtime-deps.json +2 -2
- package/.agents/scripts/README.md +1 -1
- package/.agents/scripts/agents-bootstrap-github.js +23 -119
- package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +46 -0
- package/.agents/scripts/lib/bootstrap/gh-preflight.js +7 -9
- package/.agents/scripts/lib/bootstrap/manifest.js +21 -1
- package/.agents/scripts/lib/bootstrap/merge-methods.js +31 -16
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +32 -11
- package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
- package/.agents/scripts/lib/detect-package-manager.js +72 -0
- package/.agents/scripts/lib/errors/index.js +4 -4
- package/.agents/scripts/lib/label-taxonomy.js +2 -2
- package/.agents/scripts/lib/onboard/detect-stack.js +10 -10
- package/.agents/scripts/lib/onboard/init-tail.js +218 -0
- package/.agents/scripts/lib/onboard/scaffold-docs.js +18 -3
- package/.agents/scripts/lib/runtime-deps/preflight.js +6 -6
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +5 -2
- package/.agents/workflows/agents-update.md +14 -29
- package/.agents/workflows/helpers/agents-sync-config.md +3 -2
- package/.agents/workflows/plan.md +45 -3
- package/README.md +18 -30
- package/bin/mandrel.js +235 -16
- package/docs/CHANGELOG.md +24 -0
- package/lib/cli/doctor.js +45 -3
- package/lib/cli/init.js +66 -7
- package/lib/cli/registry.js +41 -145
- package/lib/cli/sync.js +122 -23
- package/lib/cli/uninstall.js +42 -7
- package/lib/cli/update.js +145 -192
- package/lib/cli/version-helpers.js +59 -0
- package/package.json +6 -6
- package/.agents/workflows/onboard.md +0 -208
- package/lib/cli/__tests__/migrate.test.js +0 -268
- package/lib/cli/__tests__/sync-local-zone.test.js +0 -247
- package/lib/cli/__tests__/sync.test.js +0 -372
- package/lib/cli/__tests__/update-changelog-surface.test.js +0 -357
- package/lib/cli/__tests__/update-major.test.js +0 -217
- package/lib/cli/__tests__/update-reexec.test.js +0 -513
- package/lib/cli/__tests__/update.test.js +0 -696
- package/lib/cli/__tests__/version-check.test.js +0 -398
- package/lib/migrations/__tests__/index.test.js +0 -216
package/lib/cli/update.js
CHANGED
|
@@ -3,20 +3,17 @@
|
|
|
3
3
|
* `mandrel update` subcommand — the auto-update orchestrator (f-update-command,
|
|
4
4
|
* Story #3503, Epic #3437 — Auto-Update & Version Lifecycle).
|
|
5
5
|
*
|
|
6
|
-
* Advances `mandrel` to the newest
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `docs/upgrade-major.md`, and exits non-zero without touching anything.
|
|
6
|
+
* Advances `mandrel` to the newest published version, re-materializes
|
|
7
|
+
* `.agents/`, runs applicable version-keyed migrations, surfaces the target
|
|
8
|
+
* changelog, and verifies the result via the doctor registry. Major crossings
|
|
9
|
+
* are applied like any other bump (hard-cutover doctrine —
|
|
10
|
+
* `.agents/rules/git-conventions.md` § Contract Cutovers).
|
|
12
11
|
*
|
|
13
|
-
* ## Ordered cycle (happy path
|
|
12
|
+
* ## Ordered cycle (happy path)
|
|
14
13
|
*
|
|
15
14
|
* 1. resolve target version (newest published) and the current version
|
|
16
|
-
* 2.
|
|
17
|
-
*
|
|
18
|
-
* 3. no-op short-circuit — already on the newest version ⇒ nothing to do
|
|
19
|
-
* 4. install — bump the dependency (lockfile bump left STAGED).
|
|
15
|
+
* 2. no-op short-circuit — already on the newest version ⇒ nothing to do
|
|
16
|
+
* 3. install — bump the dependency (lockfile bump left STAGED).
|
|
20
17
|
* The package manager is auto-detected from the lockfile in the project
|
|
21
18
|
* root: `pnpm-lock.yaml` ⇒ `pnpm add -D …` (with `-w` at a
|
|
22
19
|
* `pnpm-workspace.yaml` root), `yarn.lock` ⇒ `yarn add -D …`, otherwise
|
|
@@ -25,17 +22,17 @@
|
|
|
25
22
|
* the resolved version so an override can still consume the auto-probed
|
|
26
23
|
* newest. The registry probe in step 1 always stays on `npm view` (a
|
|
27
24
|
* PM-agnostic registry query).
|
|
28
|
-
*
|
|
25
|
+
* 4. runSync — re-materialize ./.agents/ **from the newly-installed
|
|
29
26
|
* binary** so the materialized payload is always the target version's.
|
|
30
|
-
*
|
|
27
|
+
* 5. runMigrations — apply version-keyed steps for the crossed range,
|
|
31
28
|
* **from the newly-installed binary**.
|
|
32
|
-
*
|
|
29
|
+
* 6. doctor — run the check registry **from the newly-installed
|
|
33
30
|
* binary** so `agents-drift` is never a false-green against stale payload.
|
|
34
|
-
*
|
|
31
|
+
* 7. surface the changelog for the target version
|
|
35
32
|
*
|
|
36
33
|
* ## Re-exec of post-install phases (Story #4034)
|
|
37
34
|
*
|
|
38
|
-
* Steps
|
|
35
|
+
* Steps 4–6 execute as **child processes spawned from the newly-installed
|
|
39
36
|
* binary** (`<cwd>/node_modules/.bin/mandrel`) rather than in the running
|
|
40
37
|
* process. Node cannot hot-swap a `require`d module mid-process, so without
|
|
41
38
|
* re-exec, the still-running old binary's `runSync`/`runMigrations`/`runDoctor`
|
|
@@ -69,18 +66,6 @@
|
|
|
69
66
|
* without invoking any effectful seam (no npm update, no sync, no migrations,
|
|
70
67
|
* no doctor) and writing nothing.
|
|
71
68
|
*
|
|
72
|
-
* ## Major gate
|
|
73
|
-
*
|
|
74
|
-
* The project sits on the **1.x** line under release-please
|
|
75
|
-
* `always-bump-minor` ([AGENTS.md § Major-version policy]); a major release is
|
|
76
|
-
* a deliberate manual operator decision, so adopting one must be equally
|
|
77
|
-
* deliberate. When the newest version's major exceeds the current major:
|
|
78
|
-
* - **without `--major`**: print the available version + the
|
|
79
|
-
* `docs/upgrade-major.md` runbook pointer, exit non-zero, and invoke
|
|
80
|
-
* **no** npm-update / sync / migration / doctor seam.
|
|
81
|
-
* - **with `--major`**: apply the major target and print the runbook inline.
|
|
82
|
-
* Routine minor/patch bumps within the 1.x line are never gated.
|
|
83
|
-
*
|
|
84
69
|
* ## Changelog surface
|
|
85
70
|
*
|
|
86
71
|
* `defaultSurfaceChangelog` prints the `docs/CHANGELOG.md` section(s) for the
|
|
@@ -122,7 +107,7 @@
|
|
|
122
107
|
* new binary path when `spawnPhase` is absent)
|
|
123
108
|
*
|
|
124
109
|
* Security (security-baseline § 5 — Data Leakage & Logging): logs only version
|
|
125
|
-
* strings
|
|
110
|
+
* strings and step names. No tokens, credentials, or env
|
|
126
111
|
* values are read or logged; no shell-string interpolation occurs here (the
|
|
127
112
|
* npm bump is delegated to the injected `npmUpdate` seam, which owns transport).
|
|
128
113
|
*
|
|
@@ -152,14 +137,13 @@ import nodeHttps from 'node:https';
|
|
|
152
137
|
import path from 'node:path';
|
|
153
138
|
import { fileURLToPath } from 'node:url';
|
|
154
139
|
|
|
140
|
+
import { detectPackageManagerWithWorkspace } from '../../.agents/scripts/lib/detect-package-manager.js';
|
|
155
141
|
import { runInstallCommand } from '../../.agents/scripts/lib/install-cmd-parser.js';
|
|
156
142
|
import { runMigrations as defaultRunMigrations } from '../migrations/index.js';
|
|
157
143
|
import { registry } from './registry.js';
|
|
158
144
|
import { runSync as defaultRunSync } from './sync.js';
|
|
159
145
|
import { isStale } from './version-check.js';
|
|
160
|
-
|
|
161
|
-
/** Path (relative to project root) of the major-upgrade runbook. */
|
|
162
|
-
const RUNBOOK_PATH = 'docs/upgrade-major.md';
|
|
146
|
+
import { compareVersions } from './version-helpers.js';
|
|
163
147
|
|
|
164
148
|
/** The published package whose newest version `mandrel update` advances to. */
|
|
165
149
|
const PACKAGE_NAME = 'mandrel';
|
|
@@ -180,51 +164,6 @@ const GITHUB_RELEASES_URL = 'https://github.com/dsj1984/mandrel/releases';
|
|
|
180
164
|
/** Default freshness-cache filename — mirrors version-check.js. */
|
|
181
165
|
const DEFAULT_CACHE_FILENAME = 'version-check.json';
|
|
182
166
|
|
|
183
|
-
/**
|
|
184
|
-
* Parse a dotted semver-ish string into a numeric tuple. Non-numeric or
|
|
185
|
-
* missing segments coerce to 0 so a partial version still compares sanely.
|
|
186
|
-
*
|
|
187
|
-
* @param {string} version
|
|
188
|
-
* @returns {[number, number, number]}
|
|
189
|
-
*/
|
|
190
|
-
function parseVersion(version) {
|
|
191
|
-
const [major, minor, patch] = String(version).split('.');
|
|
192
|
-
return [
|
|
193
|
-
Number.parseInt(major, 10) || 0,
|
|
194
|
-
Number.parseInt(minor, 10) || 0,
|
|
195
|
-
Number.parseInt(patch, 10) || 0,
|
|
196
|
-
];
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Compare two version strings. Negative when `a < b`, zero when equal,
|
|
201
|
-
* positive when `a > b` (the standard `Array.sort` comparator contract).
|
|
202
|
-
*
|
|
203
|
-
* @param {string} a
|
|
204
|
-
* @param {string} b
|
|
205
|
-
* @returns {number}
|
|
206
|
-
*/
|
|
207
|
-
function compareVersions(a, b) {
|
|
208
|
-
const pa = parseVersion(a);
|
|
209
|
-
const pb = parseVersion(b);
|
|
210
|
-
for (let i = 0; i < 3; i += 1) {
|
|
211
|
-
if (pa[i] !== pb[i]) return pa[i] - pb[i];
|
|
212
|
-
}
|
|
213
|
-
return 0;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* True when `target`'s major axis is strictly greater than `current`'s — the
|
|
218
|
-
* gated "crosses a major boundary" condition.
|
|
219
|
-
*
|
|
220
|
-
* @param {string} current
|
|
221
|
-
* @param {string} target
|
|
222
|
-
* @returns {boolean}
|
|
223
|
-
*/
|
|
224
|
-
function crossesMajor(current, target) {
|
|
225
|
-
return parseVersion(target)[0] > parseVersion(current)[0];
|
|
226
|
-
}
|
|
227
|
-
|
|
228
167
|
/**
|
|
229
168
|
* Resolve the installed `mandrel` version from this package's own
|
|
230
169
|
* `package.json`. The module lives at `<root>/lib/cli/update.js`, so the
|
|
@@ -255,12 +194,15 @@ function resolveProjectRoot() {
|
|
|
255
194
|
* Default `resolveTargetVersion` seam: determine the newest published
|
|
256
195
|
* `mandrel` version via the daily freshness cache (`version-check.js`).
|
|
257
196
|
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
197
|
+
* When `bypassCache` is `true` (the default for an explicit `mandrel update`
|
|
198
|
+
* call — Story #4046 A1b), the cache freshness window is effectively zeroed by
|
|
199
|
+
* passing `now` as far in the future, which makes `isStale` treat any existing
|
|
200
|
+
* cache as stale and always issue exactly one network probe. The cache is still
|
|
201
|
+
* written so the post-update `version-current` advisory has a fresh baseline.
|
|
202
|
+
*
|
|
203
|
+
* When `bypassCache` is `false` (passive staleness checks only), the normal
|
|
204
|
+
* 24h-cache semantics apply: a fresh cache returns the cached version with
|
|
205
|
+
* zero network I/O.
|
|
264
206
|
*
|
|
265
207
|
* The network probe shells `npm view` through `spawnSync` with a fixed argument
|
|
266
208
|
* vector (no shell-string interpolation; the package name is a constant). On
|
|
@@ -274,6 +216,7 @@ function resolveProjectRoot() {
|
|
|
274
216
|
* fs?: typeof nodeFs,
|
|
275
217
|
* runner?: () => string,
|
|
276
218
|
* now?: Date,
|
|
219
|
+
* bypassCache?: boolean,
|
|
277
220
|
* log?: (msg: string) => void,
|
|
278
221
|
* }} [opts]
|
|
279
222
|
* @returns {Promise<string>} The newest published version string.
|
|
@@ -283,9 +226,22 @@ async function defaultResolveTargetVersion({
|
|
|
283
226
|
fs = nodeFs,
|
|
284
227
|
runner = defaultVersionRunner,
|
|
285
228
|
now = new Date(),
|
|
229
|
+
bypassCache = false,
|
|
286
230
|
log = () => {},
|
|
287
231
|
} = {}) {
|
|
288
|
-
|
|
232
|
+
// When bypassCache is true, push `now` far enough into the future that any
|
|
233
|
+
// cached checkedAt value is guaranteed to be older than the STALE_AFTER_MS
|
|
234
|
+
// window, forcing a fresh network probe (Story #4046 A1b).
|
|
235
|
+
const effectiveNow = bypassCache
|
|
236
|
+
? new Date(now.getTime() + 48 * 60 * 60 * 1000)
|
|
237
|
+
: now;
|
|
238
|
+
const result = await isStale({
|
|
239
|
+
cachePath,
|
|
240
|
+
now: effectiveNow,
|
|
241
|
+
runner,
|
|
242
|
+
fs,
|
|
243
|
+
log,
|
|
244
|
+
});
|
|
289
245
|
return String(result.latestVersion);
|
|
290
246
|
}
|
|
291
247
|
|
|
@@ -352,28 +308,30 @@ function repairInstallCommand(packageManager) {
|
|
|
352
308
|
* Detecting the lockfile keeps the bump on the operator's real package manager
|
|
353
309
|
* so the change lands in the matching lockfile.
|
|
354
310
|
*
|
|
311
|
+
* Delegates to the shared `detectPackageManagerWithWorkspace` helper
|
|
312
|
+
* (Story #4048 B3 — one implementation per concept). The `fs` seam is adapted
|
|
313
|
+
* to the shared module's `exists` contract; the shared module's `bun` return
|
|
314
|
+
* value coerces to `npm` here because `bun add` is not yet a first-class update
|
|
315
|
+
* path for this orchestrator.
|
|
316
|
+
*
|
|
355
317
|
* @param {string} [cwd] - Project root to probe (default `process.cwd()`).
|
|
356
318
|
* @param {typeof nodeFs} [fs]
|
|
357
319
|
* @returns {{ packageManager: 'pnpm' | 'yarn' | 'npm', workspaceRoot: boolean }}
|
|
358
320
|
*/
|
|
359
321
|
export function detectPackageManager(cwd = process.cwd(), fs = nodeFs) {
|
|
360
|
-
const
|
|
322
|
+
const exists = (p) => {
|
|
361
323
|
try {
|
|
362
|
-
return fs.existsSync(
|
|
324
|
+
return fs.existsSync(p);
|
|
363
325
|
} catch {
|
|
364
326
|
return false;
|
|
365
327
|
}
|
|
366
328
|
};
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
if (has('yarn.lock')) {
|
|
374
|
-
return { packageManager: 'yarn', workspaceRoot: false };
|
|
375
|
-
}
|
|
376
|
-
return { packageManager: 'npm', workspaceRoot: false };
|
|
329
|
+
const result = detectPackageManagerWithWorkspace(cwd, exists);
|
|
330
|
+
// Coerce `bun` → `npm` because this orchestrator's install-command builder
|
|
331
|
+
// only handles pnpm / yarn / npm today.
|
|
332
|
+
const packageManager =
|
|
333
|
+
result.packageManager === 'bun' ? 'npm' : result.packageManager;
|
|
334
|
+
return { packageManager, workspaceRoot: result.workspaceRoot };
|
|
377
335
|
}
|
|
378
336
|
|
|
379
337
|
/**
|
|
@@ -749,10 +707,25 @@ export function defaultSpawnPhase(
|
|
|
749
707
|
}
|
|
750
708
|
|
|
751
709
|
/**
|
|
752
|
-
* The ordered step names the orchestrator drives on
|
|
710
|
+
* The ordered step names the orchestrator drives on an update. Shared
|
|
753
711
|
* by the live path and the `--dry-run` plan printout so the two never drift.
|
|
712
|
+
*
|
|
713
|
+
* Step ordering (Story #4046 A1c):
|
|
714
|
+
* 1. npm-update — install the new version
|
|
715
|
+
* 2. runSync — re-materialize .agents/ from the new payload
|
|
716
|
+
* 3. sync-commands — regenerate .claude/commands/ from the new payload
|
|
717
|
+
* 4. runMigrations — apply version-keyed migrations
|
|
718
|
+
* 5. doctor — validate the post-upgrade state
|
|
719
|
+
* 6. surface changelog — print the changelog (always last, best-effort)
|
|
754
720
|
*/
|
|
755
|
-
const STEP_PLAN = [
|
|
721
|
+
const STEP_PLAN = [
|
|
722
|
+
'npm-update',
|
|
723
|
+
'runSync',
|
|
724
|
+
'sync-commands',
|
|
725
|
+
'runMigrations',
|
|
726
|
+
'doctor',
|
|
727
|
+
'surface changelog',
|
|
728
|
+
];
|
|
756
729
|
|
|
757
730
|
/**
|
|
758
731
|
* Extract the `--install-cmd "<cmd>"` value from the subcommand argv. Accepts
|
|
@@ -780,21 +753,6 @@ function parseInstallCmdFlag(argv) {
|
|
|
780
753
|
return undefined;
|
|
781
754
|
}
|
|
782
755
|
|
|
783
|
-
/**
|
|
784
|
-
* Print the major-gate refusal: the available version, the runbook pointer,
|
|
785
|
-
* and the re-run hint. No effectful seam runs after this.
|
|
786
|
-
*
|
|
787
|
-
* @param {string} target
|
|
788
|
-
* @param {(s: string) => void} writeErr
|
|
789
|
-
*/
|
|
790
|
-
function emitMajorRefusal(target, writeErr) {
|
|
791
|
-
writeErr(
|
|
792
|
-
`mandrel update: a newer MAJOR version (${target}) is available; ` +
|
|
793
|
-
'this is a breaking upgrade.\n' +
|
|
794
|
-
` → Review ${RUNBOOK_PATH}, then re-run with --major to apply it.\n`,
|
|
795
|
-
);
|
|
796
|
-
}
|
|
797
|
-
|
|
798
756
|
/**
|
|
799
757
|
* Run the `mandrel update` orchestration cycle.
|
|
800
758
|
*
|
|
@@ -815,10 +773,9 @@ function emitMajorRefusal(target, writeErr) {
|
|
|
815
773
|
* }} [opts]
|
|
816
774
|
* @returns {Promise<{
|
|
817
775
|
* ok: boolean,
|
|
818
|
-
* action: 'updated' | '
|
|
776
|
+
* action: 'updated' | 'dry-run' | 'up-to-date' | 'doctor-failed',
|
|
819
777
|
* currentVersion: string,
|
|
820
778
|
* targetVersion: string | null,
|
|
821
|
-
* major: boolean,
|
|
822
779
|
* stepsRun: string[],
|
|
823
780
|
* dryRun: boolean,
|
|
824
781
|
* }>}
|
|
@@ -839,7 +796,6 @@ export async function runUpdate({
|
|
|
839
796
|
cwd = () => process.cwd(),
|
|
840
797
|
} = {}) {
|
|
841
798
|
const dryRun = argv.includes('--dry-run');
|
|
842
|
-
const allowMajor = argv.includes('--major');
|
|
843
799
|
const installCmd = parseInstallCmdFlag(argv);
|
|
844
800
|
|
|
845
801
|
const current =
|
|
@@ -854,25 +810,6 @@ export async function runUpdate({
|
|
|
854
810
|
}
|
|
855
811
|
const target = String(await resolveTargetVersion());
|
|
856
812
|
|
|
857
|
-
const major = crossesMajor(current, target);
|
|
858
|
-
|
|
859
|
-
// --- Major gate -----------------------------------------------------------
|
|
860
|
-
// A major crossing without --major is refused outright: no npm-update, no
|
|
861
|
-
// sync, no migration, no doctor — print the runbook pointer and exit non-zero.
|
|
862
|
-
if (major && !allowMajor) {
|
|
863
|
-
emitMajorRefusal(target, writeErr);
|
|
864
|
-
exit(1);
|
|
865
|
-
return {
|
|
866
|
-
ok: false,
|
|
867
|
-
action: 'declined-major',
|
|
868
|
-
currentVersion: current,
|
|
869
|
-
targetVersion: target,
|
|
870
|
-
major: true,
|
|
871
|
-
stepsRun: [],
|
|
872
|
-
dryRun,
|
|
873
|
-
};
|
|
874
|
-
}
|
|
875
|
-
|
|
876
813
|
// --- No-op short-circuit --------------------------------------------------
|
|
877
814
|
// Already on (or ahead of) the newest version: nothing to apply.
|
|
878
815
|
if (compareVersions(target, current) <= 0) {
|
|
@@ -882,7 +819,6 @@ export async function runUpdate({
|
|
|
882
819
|
action: 'up-to-date',
|
|
883
820
|
currentVersion: current,
|
|
884
821
|
targetVersion: target,
|
|
885
|
-
major,
|
|
886
822
|
stepsRun: [],
|
|
887
823
|
dryRun,
|
|
888
824
|
};
|
|
@@ -893,34 +829,21 @@ export async function runUpdate({
|
|
|
893
829
|
// write nothing to disk.
|
|
894
830
|
if (dryRun) {
|
|
895
831
|
write(`mandrel update — planned upgrade v${current} → v${target}\n`);
|
|
896
|
-
if (major) {
|
|
897
|
-
write(' (major upgrade — --major supplied)\n');
|
|
898
|
-
}
|
|
899
832
|
STEP_PLAN.forEach((step, i) => {
|
|
900
833
|
write(` ${i + 1}. ${step}\n`);
|
|
901
834
|
});
|
|
902
|
-
write(' 5. surface changelog\n');
|
|
903
835
|
write('Dry run: no files written, no dependency bumped.\n');
|
|
904
836
|
return {
|
|
905
837
|
ok: true,
|
|
906
838
|
action: 'dry-run',
|
|
907
839
|
currentVersion: current,
|
|
908
840
|
targetVersion: target,
|
|
909
|
-
major,
|
|
910
841
|
stepsRun: [],
|
|
911
842
|
dryRun: true,
|
|
912
843
|
};
|
|
913
844
|
}
|
|
914
845
|
|
|
915
|
-
|
|
916
|
-
if (major) {
|
|
917
|
-
write(
|
|
918
|
-
`Applying MAJOR upgrade v${current} → v${target} (--major). ` +
|
|
919
|
-
`Review the runbook: ${RUNBOOK_PATH}\n`,
|
|
920
|
-
);
|
|
921
|
-
} else {
|
|
922
|
-
write(`Updating v${current} → v${target}…\n`);
|
|
923
|
-
}
|
|
846
|
+
write(`Updating v${current} → v${target}…\n`);
|
|
924
847
|
|
|
925
848
|
const stepsRun = [];
|
|
926
849
|
|
|
@@ -970,7 +893,29 @@ export async function runUpdate({
|
|
|
970
893
|
}
|
|
971
894
|
stepsRun.push('runSync');
|
|
972
895
|
|
|
973
|
-
// 3.
|
|
896
|
+
// 3. sync-commands from new bin — regenerate .claude/commands/ from the
|
|
897
|
+
// freshly-materialized .agents/workflows/. Running from the new bin
|
|
898
|
+
// ensures the command tree is consistent with the new payload; an
|
|
899
|
+
// upstream-renamed workflow will be projected correctly and the old
|
|
900
|
+
// command file will be reaped. This step must follow runSync so the
|
|
901
|
+
// workflow sources are up to date before the command tree is rebuilt
|
|
902
|
+
// (Story #4046 A1c — `commands-in-sync` validates the post-sync state).
|
|
903
|
+
const syncCommandsResult = await spawnPhase('sync-commands', [], {
|
|
904
|
+
binPath,
|
|
905
|
+
cwd: projectRoot,
|
|
906
|
+
write,
|
|
907
|
+
writeErr,
|
|
908
|
+
});
|
|
909
|
+
if (!syncCommandsResult.ok) {
|
|
910
|
+
throw new Error(
|
|
911
|
+
`mandrel update: \`mandrel sync-commands\` from new binary exited non-zero — ` +
|
|
912
|
+
'the .claude/commands/ tree may be out of sync. ' +
|
|
913
|
+
'Run `npm run sync:commands` manually to restore.',
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
stepsRun.push('sync-commands');
|
|
917
|
+
|
|
918
|
+
// 4. runMigrations from new bin — apply version-keyed steps for the
|
|
974
919
|
// crossed range. The new binary's migration registry contains any steps
|
|
975
920
|
// added in the target version; the old process's registry does not.
|
|
976
921
|
const migrateResult = await spawnPhase(
|
|
@@ -987,7 +932,7 @@ export async function runUpdate({
|
|
|
987
932
|
}
|
|
988
933
|
stepsRun.push('runMigrations');
|
|
989
934
|
|
|
990
|
-
//
|
|
935
|
+
// 5. doctor from new bin — verify the resulting install. Running from the
|
|
991
936
|
// new bin is critical: the agents-drift check compares the materialized
|
|
992
937
|
// .agents/ against the installed package payload. When the old process
|
|
993
938
|
// runs this check, it resolves the package root to its own (old) install
|
|
@@ -1002,7 +947,7 @@ export async function runUpdate({
|
|
|
1002
947
|
});
|
|
1003
948
|
stepsRun.push('doctor');
|
|
1004
949
|
|
|
1005
|
-
//
|
|
950
|
+
// 6. surface the target changelog (best-effort; optional seam).
|
|
1006
951
|
if (typeof surfaceChangelog === 'function') {
|
|
1007
952
|
await surfaceChangelog(target);
|
|
1008
953
|
}
|
|
@@ -1018,7 +963,6 @@ export async function runUpdate({
|
|
|
1018
963
|
action: 'doctor-failed',
|
|
1019
964
|
currentVersion: current,
|
|
1020
965
|
targetVersion: target,
|
|
1021
|
-
major,
|
|
1022
966
|
stepsRun,
|
|
1023
967
|
dryRun: false,
|
|
1024
968
|
};
|
|
@@ -1034,6 +978,9 @@ export async function runUpdate({
|
|
|
1034
978
|
stepsRun.push('runSync');
|
|
1035
979
|
|
|
1036
980
|
// 3. runMigrations — apply version-keyed steps for the crossed range.
|
|
981
|
+
// Note: the in-process path (pre-Story-#4034) does not run sync-commands
|
|
982
|
+
// here because sync-commands runs as a child process and there is no
|
|
983
|
+
// in-process seam for it. The re-exec path (spawnPhase) handles it.
|
|
1037
984
|
runMigrations({ fromVersion: current, toVersion: target, ctx: {} });
|
|
1038
985
|
stepsRun.push('runMigrations');
|
|
1039
986
|
|
|
@@ -1059,7 +1006,6 @@ export async function runUpdate({
|
|
|
1059
1006
|
action: 'doctor-failed',
|
|
1060
1007
|
currentVersion: current,
|
|
1061
1008
|
targetVersion: target,
|
|
1062
|
-
major,
|
|
1063
1009
|
stepsRun,
|
|
1064
1010
|
dryRun: false,
|
|
1065
1011
|
};
|
|
@@ -1072,7 +1018,6 @@ export async function runUpdate({
|
|
|
1072
1018
|
action: 'updated',
|
|
1073
1019
|
currentVersion: current,
|
|
1074
1020
|
targetVersion: target,
|
|
1075
|
-
major,
|
|
1076
1021
|
stepsRun,
|
|
1077
1022
|
dryRun: false,
|
|
1078
1023
|
};
|
|
@@ -1082,20 +1027,21 @@ export async function runUpdate({
|
|
|
1082
1027
|
* Default export consumed by `bin/mandrel.js`.
|
|
1083
1028
|
*
|
|
1084
1029
|
* Wires the production-default seams that `runUpdate` leaves injectable:
|
|
1085
|
-
* - `resolveTargetVersion` probes the
|
|
1086
|
-
*
|
|
1087
|
-
*
|
|
1088
|
-
* `version-current` doctor advisory reads
|
|
1030
|
+
* - `resolveTargetVersion` always probes the registry via `isStale` with
|
|
1031
|
+
* `bypassCache: true` — the 24h cache is overridden for explicit update
|
|
1032
|
+
* calls so the resolved version is always fresh (Story #4046 A1b). The
|
|
1033
|
+
* cache is still written so the `version-current` doctor advisory reads
|
|
1034
|
+
* a current baseline after the upgrade.
|
|
1089
1035
|
* - `npmUpdate` runs the install command — auto-detected from the project
|
|
1090
1036
|
* lockfile (`pnpm`/`yarn`/`npm`), or the `--install-cmd` override —
|
|
1091
1037
|
* through the shared `runInstallCommand` helper — no git mutation;
|
|
1092
1038
|
* lockfile left staged.
|
|
1093
1039
|
* - `spawnPhase` is wired to `defaultSpawnPhase`, which spawns each
|
|
1094
|
-
* post-install phase (sync, migrate, doctor) from the
|
|
1095
|
-
* binary (`node_modules/.bin/mandrel`). This is the
|
|
1096
|
-
* the new bin loads the new package's module code and
|
|
1097
|
-
* against the new install dir, so
|
|
1098
|
-
* the old payload.
|
|
1040
|
+
* post-install phase (sync, sync-commands, migrate, doctor) from the
|
|
1041
|
+
* newly-installed binary (`node_modules/.bin/mandrel`). This is the
|
|
1042
|
+
* Story #4034 fix: the new bin loads the new package's module code and
|
|
1043
|
+
* resolves paths against the new install dir, so these phases can never
|
|
1044
|
+
* observe the old payload.
|
|
1099
1045
|
* - `surfaceChangelog` prints the relevant `docs/CHANGELOG.md` section(s)
|
|
1100
1046
|
* for the applied range. Reads from the packaged file first; falls back to
|
|
1101
1047
|
* a GitHub raw-content fetch via the injectable `fetchChangelog` seam when
|
|
@@ -1104,7 +1050,7 @@ export async function runUpdate({
|
|
|
1104
1050
|
*
|
|
1105
1051
|
* Every seam stays injectable on `runUpdate`; these are merely the
|
|
1106
1052
|
* no-seam-provided fallbacks, so the existing seam-driven tests stay green.
|
|
1107
|
-
* `--
|
|
1053
|
+
* `--dry-run` / `--install-cmd` are parsed from `argv` by
|
|
1108
1054
|
* `runUpdate` itself.
|
|
1109
1055
|
*
|
|
1110
1056
|
* The second `deps` argument exposes the **process boundaries** the production
|
|
@@ -1152,9 +1098,9 @@ export default async function run(argv = [], deps = {}) {
|
|
|
1152
1098
|
runSync,
|
|
1153
1099
|
runMigrations,
|
|
1154
1100
|
runDoctor,
|
|
1155
|
-
write,
|
|
1156
|
-
writeErr,
|
|
1157
|
-
exit,
|
|
1101
|
+
write = (s) => process.stdout.write(s),
|
|
1102
|
+
writeErr = (s) => process.stderr.write(s),
|
|
1103
|
+
exit = (code) => process.exit(code),
|
|
1158
1104
|
log,
|
|
1159
1105
|
} = deps;
|
|
1160
1106
|
|
|
@@ -1170,46 +1116,53 @@ export default async function run(argv = [], deps = {}) {
|
|
|
1170
1116
|
...(spawnFn ? { spawnFn } : {}),
|
|
1171
1117
|
});
|
|
1172
1118
|
|
|
1119
|
+
// Resolve which seam set to use for post-install phases. If any old-style
|
|
1120
|
+
// in-process seam (runSync/runMigrations/runDoctor) is injected, fall back
|
|
1121
|
+
// to the pre-Story-#4034 in-process path so the entrypoint test stays green.
|
|
1122
|
+
// Otherwise use the re-exec path (spawnPhase). This is a single ternary
|
|
1123
|
+
// rather than stacked optional spreads (tidy, Story #4046).
|
|
1124
|
+
const phaseSeams =
|
|
1125
|
+
runSync || runMigrations || runDoctor
|
|
1126
|
+
? {
|
|
1127
|
+
...(runSync ? { runSync } : {}),
|
|
1128
|
+
...(runMigrations ? { runMigrations } : {}),
|
|
1129
|
+
...(runDoctor ? { runDoctor } : {}),
|
|
1130
|
+
}
|
|
1131
|
+
: { spawnPhase: productionSpawnPhase };
|
|
1132
|
+
|
|
1173
1133
|
await runUpdateImpl({
|
|
1174
1134
|
argv,
|
|
1175
1135
|
currentVersion: current,
|
|
1136
|
+
// Always bypass the 24h cache on an explicit `mandrel update` so the
|
|
1137
|
+
// resolved target is fresh from the registry (Story #4046 A1b).
|
|
1176
1138
|
resolveTargetVersion: () =>
|
|
1177
1139
|
defaultResolveTargetVersion({
|
|
1178
|
-
|
|
1140
|
+
cachePath:
|
|
1141
|
+
cachePath ?? path.join(process.cwd(), 'temp', DEFAULT_CACHE_FILENAME),
|
|
1179
1142
|
fs,
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1143
|
+
runner: versionRunner ?? defaultVersionRunner,
|
|
1144
|
+
now: now ?? new Date(),
|
|
1145
|
+
bypassCache: true,
|
|
1146
|
+
log: log ?? (() => {}),
|
|
1183
1147
|
}),
|
|
1184
1148
|
npmUpdate: (target, { installCmd } = {}) =>
|
|
1185
1149
|
defaultNpmUpdate(target, {
|
|
1186
1150
|
...(installCmd ? { installCmd } : {}),
|
|
1187
|
-
|
|
1151
|
+
runInstall: runInstall ?? runInstallCommand,
|
|
1188
1152
|
fs,
|
|
1189
1153
|
}),
|
|
1190
|
-
|
|
1191
|
-
// caller injects the old-style in-process seams (runSync/runMigrations/
|
|
1192
|
-
// runDoctor). When any old-style seam is present, fall back to in-process
|
|
1193
|
-
// behavior to preserve the full backward-compat surface that the
|
|
1194
|
-
// entrypoint test (update-entrypoint.test.js) relies on.
|
|
1195
|
-
...(runSync || runMigrations || runDoctor
|
|
1196
|
-
? {
|
|
1197
|
-
...(runSync ? { runSync } : {}),
|
|
1198
|
-
...(runMigrations ? { runMigrations } : {}),
|
|
1199
|
-
...(runDoctor ? { runDoctor } : {}),
|
|
1200
|
-
}
|
|
1201
|
-
: { spawnPhase: productionSpawnPhase }),
|
|
1154
|
+
...phaseSeams,
|
|
1202
1155
|
surfaceChangelog: (target) =>
|
|
1203
1156
|
defaultSurfaceChangelog(target, {
|
|
1204
1157
|
current,
|
|
1205
1158
|
fs,
|
|
1206
1159
|
...(changelogPath ? { changelogPath } : {}),
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1160
|
+
fetchChangelog: fetchChangelog ?? fetchChangelogFromGitHub,
|
|
1161
|
+
write,
|
|
1162
|
+
writeErr,
|
|
1210
1163
|
}),
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1164
|
+
write,
|
|
1165
|
+
writeErr,
|
|
1166
|
+
exit,
|
|
1214
1167
|
});
|
|
1215
1168
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// lib/cli/version-helpers.js
|
|
2
|
+
/**
|
|
3
|
+
* Shared semver-ish parse and compare helpers used by both
|
|
4
|
+
* `lib/cli/update.js` and `lib/cli/registry.js`.
|
|
5
|
+
*
|
|
6
|
+
* Both files previously defined local copies of `parseVersion` and
|
|
7
|
+
* `compareVersions`; this module is the single authoritative
|
|
8
|
+
* implementation (Story #4048 B3 — multiplied helpers).
|
|
9
|
+
*
|
|
10
|
+
* Builtins only — this module is imported from both the CLI surface
|
|
11
|
+
* (`lib/cli/`) and the doctor registry which runs before third-party
|
|
12
|
+
* packages are guaranteed to be present.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse a dotted semver-ish string into a numeric tuple. Non-numeric or
|
|
17
|
+
* missing segments coerce to 0 so a partial version still compares sanely.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} version
|
|
20
|
+
* @returns {[number, number, number]}
|
|
21
|
+
*/
|
|
22
|
+
export function parseVersion(version) {
|
|
23
|
+
const [major, minor, patch] = String(version).split('.');
|
|
24
|
+
return [
|
|
25
|
+
Number.parseInt(major, 10) || 0,
|
|
26
|
+
Number.parseInt(minor, 10) || 0,
|
|
27
|
+
Number.parseInt(patch, 10) || 0,
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compare two version strings. Negative when `a < b`, zero when equal,
|
|
33
|
+
* positive when `a > b` (the standard `Array.sort` comparator contract).
|
|
34
|
+
*
|
|
35
|
+
* @param {string} a
|
|
36
|
+
* @param {string} b
|
|
37
|
+
* @returns {number}
|
|
38
|
+
*/
|
|
39
|
+
export function compareVersions(a, b) {
|
|
40
|
+
const pa = parseVersion(a);
|
|
41
|
+
const pb = parseVersion(b);
|
|
42
|
+
for (let i = 0; i < 3; i += 1) {
|
|
43
|
+
if (pa[i] !== pb[i]) return pa[i] - pb[i];
|
|
44
|
+
}
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True when `target`'s major axis is strictly greater than `current`'s —
|
|
50
|
+
* the gated "crosses a major boundary" condition used by the update
|
|
51
|
+
* orchestrator.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} current
|
|
54
|
+
* @param {string} target
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function crossesMajor(current, target) {
|
|
58
|
+
return parseVersion(target)[0] > parseVersion(current)[0];
|
|
59
|
+
}
|