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.
Files changed (46) hide show
  1. package/.agents/README.md +74 -32
  2. package/.agents/docs/SDLC.md +8 -9
  3. package/.agents/docs/configuration.md +61 -4
  4. package/.agents/docs/quality-gates.md +796 -0
  5. package/.agents/docs/workflows.md +2 -3
  6. package/.agents/runtime-deps.json +2 -2
  7. package/.agents/scripts/README.md +1 -1
  8. package/.agents/scripts/agents-bootstrap-github.js +23 -119
  9. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +46 -0
  10. package/.agents/scripts/lib/bootstrap/gh-preflight.js +7 -9
  11. package/.agents/scripts/lib/bootstrap/manifest.js +21 -1
  12. package/.agents/scripts/lib/bootstrap/merge-methods.js +31 -16
  13. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +32 -11
  14. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  15. package/.agents/scripts/lib/detect-package-manager.js +72 -0
  16. package/.agents/scripts/lib/errors/index.js +4 -4
  17. package/.agents/scripts/lib/label-taxonomy.js +2 -2
  18. package/.agents/scripts/lib/onboard/detect-stack.js +10 -10
  19. package/.agents/scripts/lib/onboard/init-tail.js +218 -0
  20. package/.agents/scripts/lib/onboard/scaffold-docs.js +18 -3
  21. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -6
  22. package/.agents/scripts/lib/worktree/node-modules-strategy.js +5 -2
  23. package/.agents/workflows/agents-update.md +14 -29
  24. package/.agents/workflows/helpers/agents-sync-config.md +3 -2
  25. package/.agents/workflows/plan.md +45 -3
  26. package/README.md +18 -30
  27. package/bin/mandrel.js +235 -16
  28. package/docs/CHANGELOG.md +24 -0
  29. package/lib/cli/doctor.js +45 -3
  30. package/lib/cli/init.js +66 -7
  31. package/lib/cli/registry.js +41 -145
  32. package/lib/cli/sync.js +122 -23
  33. package/lib/cli/uninstall.js +42 -7
  34. package/lib/cli/update.js +145 -192
  35. package/lib/cli/version-helpers.js +59 -0
  36. package/package.json +6 -6
  37. package/.agents/workflows/onboard.md +0 -208
  38. package/lib/cli/__tests__/migrate.test.js +0 -268
  39. package/lib/cli/__tests__/sync-local-zone.test.js +0 -247
  40. package/lib/cli/__tests__/sync.test.js +0 -372
  41. package/lib/cli/__tests__/update-changelog-surface.test.js +0 -357
  42. package/lib/cli/__tests__/update-major.test.js +0 -217
  43. package/lib/cli/__tests__/update-reexec.test.js +0 -513
  44. package/lib/cli/__tests__/update.test.js +0 -696
  45. package/lib/cli/__tests__/version-check.test.js +0 -398
  46. 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 **non-major** published version,
7
- * re-materializes `.agents/`, runs applicable version-keyed migrations,
8
- * surfaces the target changelog, and verifies the result via the doctor
9
- * registry. A **major** crossing (e.g. `1.x 2.0`) is gated: the orchestrator
10
- * refuses to apply it without `--major`, prints a pointer to
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, non-major bump)
12
+ * ## Ordered cycle (happy path)
14
13
  *
15
14
  * 1. resolve target version (newest published) and the current version
16
- * 2. **major gate**decline + non-zero exit when the target crosses a
17
- * major boundary and `--major` is absent
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-circuitalready 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
- * 5. runSync — re-materialize ./.agents/ **from the newly-installed
25
+ * 4. runSync — re-materialize ./.agents/ **from the newly-installed
29
26
  * binary** so the materialized payload is always the target version's.
30
- * 6. runMigrations — apply version-keyed steps for the crossed range,
27
+ * 5. runMigrations — apply version-keyed steps for the crossed range,
31
28
  * **from the newly-installed binary**.
32
- * 7. doctor — run the check registry **from the newly-installed
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
- * 8. surface the changelog for the target version
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 57 execute as **child processes spawned from the newly-installed
35
+ * Steps 46 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, step names, and the runbook path. No tokens, credentials, or env
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
- * This delegates to `isStale`, which honours the 24h-cache semantics: a fresh
259
- * cache returns the cached version with **zero** network I/O, while a missing,
260
- * corrupt, or stale cache triggers exactly one network probe (`npm view
261
- * mandrel version`) and refreshes `temp/version-check.json`. Wiring the
262
- * production update path through `isStale` is precisely what populates that
263
- * daily cache, which the `version-current` doctor advisory reads.
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
- const result = await isStale({ cachePath, now, runner, fs, log });
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 has = (file) => {
322
+ const exists = (p) => {
361
323
  try {
362
- return fs.existsSync(path.join(cwd, file));
324
+ return fs.existsSync(p);
363
325
  } catch {
364
326
  return false;
365
327
  }
366
328
  };
367
- if (has('pnpm-lock.yaml')) {
368
- return {
369
- packageManager: 'pnpm',
370
- workspaceRoot: has('pnpm-workspace.yaml'),
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 a non-major bump. Shared
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 = ['npm-update', 'runSync', 'runMigrations', 'doctor'];
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' | 'declined-major' | 'dry-run' | 'up-to-date' | 'doctor-failed',
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
- // --- Major runbook (inline, when --major applies) -------------------------
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. runMigrations from new bin — apply version-keyed steps for the
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
- // 4. doctor from new bin — verify the resulting install. Running from the
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
- // 5. surface the target changelog (best-effort; optional seam).
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 newest published `mandrel`
1086
- * version through the daily freshness cache (`version-check.js#isStale`),
1087
- * which ALSO populates `temp/version-check.json` the cache the
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 newly-installed
1095
- * binary (`node_modules/.bin/mandrel`). This is the Story #4034 fix:
1096
- * the new bin loads the new package's module code and resolves paths
1097
- * against the new install dir, so sync/migrate/doctor can never observe
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
- * `--major` / `--dry-run` / `--install-cmd` are parsed from `argv` by
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
- ...(cachePath ? { cachePath } : {}),
1140
+ cachePath:
1141
+ cachePath ?? path.join(process.cwd(), 'temp', DEFAULT_CACHE_FILENAME),
1179
1142
  fs,
1180
- ...(versionRunner ? { runner: versionRunner } : {}),
1181
- ...(now ? { now } : {}),
1182
- ...(log ? { log } : {}),
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
- ...(runInstall ? { runInstall } : {}),
1151
+ runInstall: runInstall ?? runInstallCommand,
1188
1152
  fs,
1189
1153
  }),
1190
- // In the production default export, always wire spawnPhase unless the
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
- ...(fetchChangelog ? { fetchChangelog } : {}),
1208
- ...(write ? { write } : {}),
1209
- ...(writeErr ? { writeErr } : {}),
1160
+ fetchChangelog: fetchChangelog ?? fetchChangelogFromGitHub,
1161
+ write,
1162
+ writeErr,
1210
1163
  }),
1211
- ...(write ? { write } : {}),
1212
- ...(writeErr ? { writeErr } : {}),
1213
- ...(exit ? { exit } : {}),
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
+ }