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/registry.js
CHANGED
|
@@ -26,7 +26,17 @@ import { createRequire } from 'node:module';
|
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
28
|
|
|
29
|
+
import {
|
|
30
|
+
REQUIRED_NODE_CEILING_MAJOR,
|
|
31
|
+
REQUIRED_NODE_FLOOR,
|
|
32
|
+
satisfiesNodeEngine,
|
|
33
|
+
} from '../../.agents/scripts/lib/bootstrap/project-bootstrap.js';
|
|
34
|
+
import {
|
|
35
|
+
defaultResolvePackageRoot,
|
|
36
|
+
listFiles as listPayloadFiles,
|
|
37
|
+
} from './sync.js';
|
|
29
38
|
import { readCache } from './version-check.js';
|
|
39
|
+
import { compareVersions as compareSemver } from './version-helpers.js';
|
|
30
40
|
|
|
31
41
|
// ---------------------------------------------------------------------------
|
|
32
42
|
// Internal helpers
|
|
@@ -53,32 +63,6 @@ function spawn(cmd, args) {
|
|
|
53
63
|
// check: node-version
|
|
54
64
|
// ---------------------------------------------------------------------------
|
|
55
65
|
|
|
56
|
-
/**
|
|
57
|
-
* The minimum Node version the framework requires (`engines.node`).
|
|
58
|
-
* Mirrors `lib/bootstrap/project-bootstrap.js#REQUIRED_NODE_FLOOR`.
|
|
59
|
-
*/
|
|
60
|
-
const REQUIRED_NODE_FLOOR = '22.22.1';
|
|
61
|
-
const REQUIRED_NODE_CEILING_MAJOR = 25;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Return true when `version` satisfies `>=22.22.1 <25`.
|
|
65
|
-
*
|
|
66
|
-
* @param {string} version
|
|
67
|
-
* @returns {boolean}
|
|
68
|
-
*/
|
|
69
|
-
function satisfiesNodeEngine(version) {
|
|
70
|
-
const [majorRaw, minorRaw, patchRaw] = String(version).split('.');
|
|
71
|
-
const major = Number.parseInt(majorRaw, 10) || 0;
|
|
72
|
-
const minor = Number.parseInt(minorRaw, 10) || 0;
|
|
73
|
-
const patch = Number.parseInt(patchRaw, 10) || 0;
|
|
74
|
-
if (major >= REQUIRED_NODE_CEILING_MAJOR) return false;
|
|
75
|
-
if (major > 22) return true;
|
|
76
|
-
if (major < 22) return false;
|
|
77
|
-
if (minor > 22) return true;
|
|
78
|
-
if (minor < 22) return false;
|
|
79
|
-
return patch >= 1;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
66
|
/**
|
|
83
67
|
* @param {{ nodeVersion?: string }} [opts]
|
|
84
68
|
* @returns {{ ok: boolean, detail: string, remedy?: string }}
|
|
@@ -235,19 +219,6 @@ function runGhAuth({ runner = spawn, env = process.env } = {}) {
|
|
|
235
219
|
// check: commands-in-sync
|
|
236
220
|
// ---------------------------------------------------------------------------
|
|
237
221
|
|
|
238
|
-
/**
|
|
239
|
-
* Resolve the project root — the directory that contains `.agents/` and
|
|
240
|
-
* `.claude/`. Walks up from this file's own location.
|
|
241
|
-
*
|
|
242
|
-
* @returns {string}
|
|
243
|
-
*/
|
|
244
|
-
function resolveProjectRoot() {
|
|
245
|
-
// lib/cli/registry.js lives at <root>/lib/cli/registry.js, so walk up two
|
|
246
|
-
// levels from __dirname to reach the project root.
|
|
247
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
248
|
-
return path.resolve(here, '..', '..');
|
|
249
|
-
}
|
|
250
|
-
|
|
251
222
|
/**
|
|
252
223
|
* Dry-run the sync-claude-commands logic: compare `.agents/workflows/*.md`
|
|
253
224
|
* sources to the generated flat command tree `.claude/commands/*.md`
|
|
@@ -324,6 +295,14 @@ function runCommandsInSync({ projectRoot, cwd, readDir } = {}) {
|
|
|
324
295
|
* Verify that the framework's required runtime dependencies are resolvable
|
|
325
296
|
* from the project's node_modules.
|
|
326
297
|
*
|
|
298
|
+
* `projectRoot` defaults to `process.cwd()` (the consumer project root) so
|
|
299
|
+
* the manifest path and the require-resolution context mirror the context in
|
|
300
|
+
* which the framework scripts actually run (Story #4046 A2). Using
|
|
301
|
+
* `resolveProjectRoot()` (the package root inside `node_modules/mandrel/`)
|
|
302
|
+
* breaks the check under pnpm isolated-mode layouts where the consumer's
|
|
303
|
+
* node_modules are hoisted above `node_modules/mandrel/` and are invisible
|
|
304
|
+
* from the package root's resolver.
|
|
305
|
+
*
|
|
327
306
|
* Injectable seams:
|
|
328
307
|
* - `resolve(dep)` — replaces the real `require.resolve`; throws when a dep
|
|
329
308
|
* is missing.
|
|
@@ -338,10 +317,15 @@ function runRuntimeDeps({
|
|
|
338
317
|
resolve: resolveSeam,
|
|
339
318
|
manifestRequired,
|
|
340
319
|
} = {}) {
|
|
320
|
+
// Anchor at process.cwd() (the consumer root), not resolveProjectRoot() (the
|
|
321
|
+
// package root). Under pnpm isolated-mode the consumer's node_modules are not
|
|
322
|
+
// visible from inside node_modules/mandrel/, so the old anchor produced false
|
|
323
|
+
// "missing" reports on a clean install (Story #4046 A2).
|
|
324
|
+
const root = projectRoot ?? process.cwd();
|
|
325
|
+
|
|
341
326
|
let required = manifestRequired;
|
|
342
327
|
if (!required) {
|
|
343
328
|
try {
|
|
344
|
-
const root = projectRoot ?? resolveProjectRoot();
|
|
345
329
|
const manifestPath = path.join(root, '.agents', 'runtime-deps.json');
|
|
346
330
|
const raw = fs.readFileSync(manifestPath, 'utf8');
|
|
347
331
|
const parsed = JSON.parse(raw);
|
|
@@ -366,9 +350,11 @@ function runRuntimeDeps({
|
|
|
366
350
|
}
|
|
367
351
|
}
|
|
368
352
|
} else {
|
|
369
|
-
// Anchor resolution to the project root so it mirrors the context
|
|
370
|
-
// the framework scripts run (they free-ride on the consumer's
|
|
371
|
-
|
|
353
|
+
// Anchor resolution to the consumer project root so it mirrors the context
|
|
354
|
+
// in which the framework scripts run (they free-ride on the consumer's
|
|
355
|
+
// node_modules). Under pnpm isolated-mode the consumer's node_modules are
|
|
356
|
+
// not reachable from inside node_modules/mandrel/; anchoring at process.cwd()
|
|
357
|
+
// finds them correctly.
|
|
372
358
|
const req = createRequire(path.join(root, 'package.json'));
|
|
373
359
|
for (const dep of required) {
|
|
374
360
|
try {
|
|
@@ -467,68 +453,9 @@ function runAgentsMaterialized({ cwd, existsSync, resolvePackage } = {}) {
|
|
|
467
453
|
// check: agents-drift
|
|
468
454
|
// ---------------------------------------------------------------------------
|
|
469
455
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
*/
|
|
474
|
-
const PACKAGE_NAME = 'mandrel';
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* Top-level directory name (relative to `.agents/`) reserved as the
|
|
478
|
-
* sync-exempt local-additions zone (Story #3498, f-drift-local-zone).
|
|
479
|
-
*
|
|
480
|
-
* `.agents/local/` is consumer-owned space that `mandrel sync` never
|
|
481
|
-
* materializes nor prunes, so it is excluded from the drift comparison —
|
|
482
|
-
* a hand-authored file under `.agents/local/` is sanctioned, not drift.
|
|
483
|
-
* Mirrors `lib/cli/sync.js#LOCAL_ZONE_DIR`.
|
|
484
|
-
*/
|
|
485
|
-
const LOCAL_ZONE_DIR = 'local';
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Default resolver: locate the installed `mandrel` package root by
|
|
489
|
-
* resolving its `package.json` and returning the directory that contains it.
|
|
490
|
-
* Mirrors `lib/cli/sync.js#defaultResolvePackageRoot` so the drift baseline
|
|
491
|
-
* is exactly the payload that `mandrel sync` would copy.
|
|
492
|
-
*
|
|
493
|
-
* @param {string} fromDir - Directory to resolve from (the consumer project).
|
|
494
|
-
* @returns {string} Absolute path to the package root.
|
|
495
|
-
*/
|
|
496
|
-
function defaultResolvePackageRoot(fromDir) {
|
|
497
|
-
const requireFrom = createRequire(path.join(fromDir, 'noop.js'));
|
|
498
|
-
const pkgJsonPath = requireFrom.resolve(`${PACKAGE_NAME}/package.json`);
|
|
499
|
-
return path.dirname(pkgJsonPath);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/**
|
|
503
|
-
* Recursively enumerate every regular file under `dir`, returning paths
|
|
504
|
-
* relative to `dir` using OS separators. The top-level `local/` subtree is
|
|
505
|
-
* skipped entirely — it is the consumer-owned local-additions zone and is
|
|
506
|
-
* never part of the package payload (Story #3498). Mirrors
|
|
507
|
-
* `lib/cli/sync.js#listFiles` so source enumeration matches the materializer.
|
|
508
|
-
*
|
|
509
|
-
* @param {string} dir - Absolute directory to walk.
|
|
510
|
-
* @param {typeof fs} fsImpl
|
|
511
|
-
* @param {string} [prefix] - Accumulated relative prefix (internal).
|
|
512
|
-
* @returns {string[]} Relative file paths.
|
|
513
|
-
*/
|
|
514
|
-
function listPayloadFiles(dir, fsImpl, prefix = '') {
|
|
515
|
-
const out = [];
|
|
516
|
-
for (const ent of fsImpl.readdirSync(dir, { withFileTypes: true })) {
|
|
517
|
-
// Scope the local-zone skip to the top-level `.agents/local/` only; a
|
|
518
|
-
// deeper directory that happens to be named `local` stays in scope.
|
|
519
|
-
if (prefix === '' && ent.name === LOCAL_ZONE_DIR && ent.isDirectory()) {
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
const rel = prefix ? path.join(prefix, ent.name) : ent.name;
|
|
523
|
-
const abs = path.join(dir, ent.name);
|
|
524
|
-
if (ent.isDirectory()) {
|
|
525
|
-
out.push(...listPayloadFiles(abs, fsImpl, rel));
|
|
526
|
-
} else {
|
|
527
|
-
out.push(rel);
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
return out;
|
|
531
|
-
}
|
|
456
|
+
// defaultResolvePackageRoot and listPayloadFiles (re-exported as `listFiles`
|
|
457
|
+
// from sync.js) are imported from `lib/cli/sync.js` at the top of this file
|
|
458
|
+
// (Story #4048 B3 — no mirror copies).
|
|
532
459
|
|
|
533
460
|
/**
|
|
534
461
|
* Compare the consumer's materialized `./.agents/<f>` bytes against the
|
|
@@ -630,39 +557,9 @@ function runAgentsDrift({ cwd, fsImpl = fs, resolvePackageRoot } = {}) {
|
|
|
630
557
|
// check: version-current
|
|
631
558
|
// ---------------------------------------------------------------------------
|
|
632
559
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
* Mirrors `lib/cli/update.js#parseVersion`.
|
|
637
|
-
*
|
|
638
|
-
* @param {string} version
|
|
639
|
-
* @returns {[number, number, number]}
|
|
640
|
-
*/
|
|
641
|
-
function parseVersionTuple(version) {
|
|
642
|
-
const [major, minor, patch] = String(version).split('.');
|
|
643
|
-
return [
|
|
644
|
-
Number.parseInt(major, 10) || 0,
|
|
645
|
-
Number.parseInt(minor, 10) || 0,
|
|
646
|
-
Number.parseInt(patch, 10) || 0,
|
|
647
|
-
];
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
/**
|
|
651
|
-
* Compare two version strings. Negative when `a < b`, zero when equal,
|
|
652
|
-
* positive when `a > b`. Mirrors `lib/cli/update.js#compareVersions`.
|
|
653
|
-
*
|
|
654
|
-
* @param {string} a
|
|
655
|
-
* @param {string} b
|
|
656
|
-
* @returns {number}
|
|
657
|
-
*/
|
|
658
|
-
function compareSemver(a, b) {
|
|
659
|
-
const pa = parseVersionTuple(a);
|
|
660
|
-
const pb = parseVersionTuple(b);
|
|
661
|
-
for (let i = 0; i < 3; i += 1) {
|
|
662
|
-
if (pa[i] !== pb[i]) return pa[i] - pb[i];
|
|
663
|
-
}
|
|
664
|
-
return 0;
|
|
665
|
-
}
|
|
560
|
+
// parseVersion and compareVersions are imported from `lib/cli/version-helpers.js`
|
|
561
|
+
// at the top of this file (Story #4048 B3 — no mirror copies). `compareSemver`
|
|
562
|
+
// is the registry-local alias for the imported `compareVersions`.
|
|
666
563
|
|
|
667
564
|
/**
|
|
668
565
|
* Resolve the installed `mandrel` version from this package's own
|
|
@@ -685,17 +582,16 @@ function defaultInstalledVersion(fsImpl) {
|
|
|
685
582
|
const DEFAULT_VERSION_CACHE_FILENAME = 'version-check.json';
|
|
686
583
|
|
|
687
584
|
/**
|
|
688
|
-
* Default cache path: `<
|
|
689
|
-
*
|
|
585
|
+
* Default cache path: `<consumerRoot>/temp/version-check.json`, anchored at
|
|
586
|
+
* `process.cwd()` (the consumer project root) so the cache file survives
|
|
587
|
+
* npm/pnpm reinstalls that may replace `node_modules/` entirely (Story #4046
|
|
588
|
+
* A2). Mirrors `commands-in-sync` / `agents-materialized` / `agents-drift`
|
|
589
|
+
* which all anchor at `process.cwd()`.
|
|
690
590
|
*
|
|
691
591
|
* @returns {string}
|
|
692
592
|
*/
|
|
693
593
|
function defaultVersionCachePath() {
|
|
694
|
-
return path.join(
|
|
695
|
-
resolveProjectRoot(),
|
|
696
|
-
'temp',
|
|
697
|
-
DEFAULT_VERSION_CACHE_FILENAME,
|
|
698
|
-
);
|
|
594
|
+
return path.join(process.cwd(), 'temp', DEFAULT_VERSION_CACHE_FILENAME);
|
|
699
595
|
}
|
|
700
596
|
|
|
701
597
|
/**
|
package/lib/cli/sync.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* `./.agents/` directory by **plain file copy** — never a symlink — so
|
|
8
8
|
* Windows and POSIX behave identically (Tech Spec #3459, Feature #3461).
|
|
9
9
|
*
|
|
10
|
-
* Design contract (per Story #3467 AC
|
|
10
|
+
* Design contract (per Story #3467 AC, Tech Spec #3459 "API Changes", and
|
|
11
|
+
* Story #4046 sync-prune):
|
|
11
12
|
* - Copy, not symlink. The materialized tree is plain regular files.
|
|
12
13
|
* - Idempotent. A second run overwrites in place and leaves ./.agents/
|
|
13
14
|
* byte-identical to the package payload.
|
|
@@ -17,12 +18,14 @@
|
|
|
17
18
|
* - Exits non-zero with an actionable message when `mandrel` is
|
|
18
19
|
* not resolvable in node_modules.
|
|
19
20
|
* - `--dry-run` reports the planned copies and writes nothing.
|
|
20
|
-
* - `--force` is accepted; the copy is overwrite-in-place either way, so
|
|
21
|
-
* it exists for explicitness/forward-compat and changes no behaviour.
|
|
22
21
|
* - Local-additions zone. The `.agents/local/` subtree is never copied
|
|
23
22
|
* into nor pruned from the destination (Story #3498). It is the
|
|
24
23
|
* consumer-owned space for hand-authored additions that must survive
|
|
25
24
|
* every re-materialization.
|
|
25
|
+
* - Sync prune (Story #4046). After the copy pass, any file inside the
|
|
26
|
+
* managed `.agents/` zone (everything except `.agents/local/`) that has
|
|
27
|
+
* no counterpart in the package payload is deleted. Consumer additions
|
|
28
|
+
* under `.agents/local/` are never touched.
|
|
26
29
|
*
|
|
27
30
|
* Security (Tech Spec #3459 "Postinstall safety"):
|
|
28
31
|
* - Does nothing beyond a local file copy: no network, no shell, no writes
|
|
@@ -42,7 +45,7 @@ import nodeFs from 'node:fs';
|
|
|
42
45
|
import { createRequire } from 'node:module';
|
|
43
46
|
import path from 'node:path';
|
|
44
47
|
|
|
45
|
-
const PACKAGE_NAME = 'mandrel';
|
|
48
|
+
export const PACKAGE_NAME = 'mandrel';
|
|
46
49
|
|
|
47
50
|
/**
|
|
48
51
|
* Top-level directory name (relative to `.agents/`) reserved as the
|
|
@@ -56,7 +59,16 @@ const PACKAGE_NAME = 'mandrel';
|
|
|
56
59
|
* which keeps both the dry-run plan and the real copy free of any
|
|
57
60
|
* `.agents/local/**` path even if a future payload were to ship one.
|
|
58
61
|
*/
|
|
59
|
-
const LOCAL_ZONE_DIR = 'local';
|
|
62
|
+
export const LOCAL_ZONE_DIR = 'local';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Basename pattern for consumer local-override files (`*.local.<ext>`,
|
|
66
|
+
* e.g. `instructions.local.md`, `foo.local.json`). Matches the gitignore
|
|
67
|
+
* convention (`.agents/*.local.md`, `.agentrc.local.json`) and the override
|
|
68
|
+
* mechanism documented in `.agents/instructions.md` § 1.E. These files never
|
|
69
|
+
* ship in the payload and are exempt from the sync prune pass.
|
|
70
|
+
*/
|
|
71
|
+
export const LOCAL_OVERRIDE_RE = /\.local\.[^.]+$/;
|
|
60
72
|
|
|
61
73
|
/**
|
|
62
74
|
* Default resolver: locate the installed `mandrel` package root by
|
|
@@ -68,7 +80,7 @@ const LOCAL_ZONE_DIR = 'local';
|
|
|
68
80
|
* @param {string} fromDir - Directory to resolve from (the consumer project).
|
|
69
81
|
* @returns {string} Absolute path to the package root.
|
|
70
82
|
*/
|
|
71
|
-
function defaultResolvePackageRoot(fromDir) {
|
|
83
|
+
export function defaultResolvePackageRoot(fromDir) {
|
|
72
84
|
// Resolve relative to the consumer project so we find *their* install,
|
|
73
85
|
// not a copy hoisted next to this CLI module.
|
|
74
86
|
const requireFrom = createRequire(path.join(fromDir, 'noop.js'));
|
|
@@ -88,13 +100,13 @@ function defaultResolvePackageRoot(fromDir) {
|
|
|
88
100
|
* from the package payload (Story #3498). See {@link LOCAL_ZONE_DIR}.
|
|
89
101
|
*
|
|
90
102
|
* @param {string} dir - Absolute directory to walk.
|
|
91
|
-
* @param {typeof nodeFs}
|
|
103
|
+
* @param {typeof nodeFs} fsImpl
|
|
92
104
|
* @param {string} [prefix] - Accumulated relative prefix (internal).
|
|
93
105
|
* @returns {string[]} Relative file paths.
|
|
94
106
|
*/
|
|
95
|
-
function listFiles(dir,
|
|
107
|
+
export function listFiles(dir, fsImpl, prefix = '') {
|
|
96
108
|
const out = [];
|
|
97
|
-
for (const ent of
|
|
109
|
+
for (const ent of fsImpl.readdirSync(dir, { withFileTypes: true })) {
|
|
98
110
|
// Never enumerate the sync-exempt local-additions zone. Matching on the
|
|
99
111
|
// empty prefix scopes the skip to the top-level `.agents/local/` only,
|
|
100
112
|
// leaving any deeper directory that happens to be named `local` intact.
|
|
@@ -104,7 +116,55 @@ function listFiles(dir, fs, prefix = '') {
|
|
|
104
116
|
const rel = prefix ? path.join(prefix, ent.name) : ent.name;
|
|
105
117
|
const abs = path.join(dir, ent.name);
|
|
106
118
|
if (ent.isDirectory()) {
|
|
107
|
-
out.push(...listFiles(abs,
|
|
119
|
+
out.push(...listFiles(abs, fsImpl, rel));
|
|
120
|
+
} else {
|
|
121
|
+
out.push(rel);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Recursively enumerate every regular file under `dir`, returning paths
|
|
129
|
+
* relative to `dir` using OS separators. The top-level `local/` subtree is
|
|
130
|
+
* skipped so consumer additions inside `.agents/local/` are never pruned
|
|
131
|
+
* (Story #3498). Files following the `*.local.*` naming convention (e.g.
|
|
132
|
+
* `.agents/instructions.local.md` — the documented consumer override
|
|
133
|
+
* mechanism, instructions.md § 1.E) are also skipped: they never ship in
|
|
134
|
+
* the payload and must survive every sync.
|
|
135
|
+
*
|
|
136
|
+
* Mirrors `listFiles` but operates on the destination tree so we can
|
|
137
|
+
* identify stale files that have no payload counterpart (Story #4046 A3).
|
|
138
|
+
*
|
|
139
|
+
* @param {string} dir - Absolute directory to walk.
|
|
140
|
+
* @param {typeof nodeFs} fsImpl
|
|
141
|
+
* @param {string} [prefix] - Accumulated relative prefix (internal).
|
|
142
|
+
* @returns {string[]} Relative file paths.
|
|
143
|
+
*/
|
|
144
|
+
function listDestFiles(dir, fsImpl, prefix = '') {
|
|
145
|
+
const out = [];
|
|
146
|
+
let entries;
|
|
147
|
+
try {
|
|
148
|
+
entries = fsImpl.readdirSync(dir, { withFileTypes: true });
|
|
149
|
+
} catch {
|
|
150
|
+
// Directory absent — nothing to prune.
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
for (const ent of entries) {
|
|
154
|
+
// The local-additions zone is never pruned; skip it at the top level.
|
|
155
|
+
if (prefix === '' && ent.name === LOCAL_ZONE_DIR && ent.isDirectory()) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// Consumer local-override files (`*.local.*`, e.g. instructions.local.md,
|
|
159
|
+
// foo.local.json) are gitignored, never shipped in the payload, and a
|
|
160
|
+
// documented override mechanism (instructions.md § 1.E) — never prune.
|
|
161
|
+
if (!ent.isDirectory() && LOCAL_OVERRIDE_RE.test(ent.name)) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const rel = prefix ? path.join(prefix, ent.name) : ent.name;
|
|
165
|
+
const abs = path.join(dir, ent.name);
|
|
166
|
+
if (ent.isDirectory()) {
|
|
167
|
+
out.push(...listDestFiles(abs, fsImpl, rel));
|
|
108
168
|
} else {
|
|
109
169
|
out.push(rel);
|
|
110
170
|
}
|
|
@@ -115,6 +175,11 @@ function listFiles(dir, fs, prefix = '') {
|
|
|
115
175
|
/**
|
|
116
176
|
* Materialize the package's `.agents/` tree into `./.agents/`.
|
|
117
177
|
*
|
|
178
|
+
* After the copy pass, any file inside the managed zone of the destination
|
|
179
|
+
* `.agents/` tree (everything outside `.agents/local/`) that has no
|
|
180
|
+
* counterpart in the package payload is deleted (sync-prune, Story #4046 A3).
|
|
181
|
+
* Consumer additions placed under `.agents/local/` are never touched.
|
|
182
|
+
*
|
|
118
183
|
* @param {{
|
|
119
184
|
* argv?: string[],
|
|
120
185
|
* resolvePackageRoot?: (fromDir: string) => string,
|
|
@@ -124,8 +189,8 @@ function listFiles(dir, fs, prefix = '') {
|
|
|
124
189
|
* writeErr?: (s: string) => void,
|
|
125
190
|
* exit?: (code: number) => void,
|
|
126
191
|
* }} [opts]
|
|
127
|
-
* @returns {{ copied: number, planned: number, dryRun: boolean }}
|
|
128
|
-
* (also returned in dry-run / error paths for testability).
|
|
192
|
+
* @returns {{ copied: number, planned: number, pruned: number, dryRun: boolean }}
|
|
193
|
+
* Summary (also returned in dry-run / error paths for testability).
|
|
129
194
|
*/
|
|
130
195
|
export function runSync({
|
|
131
196
|
argv = [],
|
|
@@ -137,8 +202,6 @@ export function runSync({
|
|
|
137
202
|
exit = (code) => process.exit(code),
|
|
138
203
|
} = {}) {
|
|
139
204
|
const dryRun = argv.includes('--dry-run');
|
|
140
|
-
// `--force` overwrites local edits. The copy is overwrite-in-place
|
|
141
|
-
// regardless, so the flag is accepted but does not branch behaviour.
|
|
142
205
|
const projectRoot = cwd();
|
|
143
206
|
|
|
144
207
|
let packageRoot;
|
|
@@ -150,7 +213,7 @@ export function runSync({
|
|
|
150
213
|
` → Install it first: npm install ${PACKAGE_NAME}\n`,
|
|
151
214
|
);
|
|
152
215
|
exit(1);
|
|
153
|
-
return { copied: 0, planned: 0, dryRun };
|
|
216
|
+
return { copied: 0, planned: 0, pruned: 0, dryRun };
|
|
154
217
|
}
|
|
155
218
|
|
|
156
219
|
const sourceRoot = path.join(packageRoot, '.agents');
|
|
@@ -160,23 +223,36 @@ export function runSync({
|
|
|
160
223
|
` → Reinstall the package: npm install ${PACKAGE_NAME}\n`,
|
|
161
224
|
);
|
|
162
225
|
exit(1);
|
|
163
|
-
return { copied: 0, planned: 0, dryRun };
|
|
226
|
+
return { copied: 0, planned: 0, pruned: 0, dryRun };
|
|
164
227
|
}
|
|
165
228
|
|
|
166
229
|
const destRoot = path.join(projectRoot, '.agents');
|
|
167
|
-
const
|
|
230
|
+
const payloadFiles = listFiles(sourceRoot, fs);
|
|
168
231
|
|
|
169
232
|
if (dryRun) {
|
|
170
|
-
for (const rel of
|
|
233
|
+
for (const rel of payloadFiles) {
|
|
171
234
|
write(`would copy ${path.join('.agents', rel)}\n`);
|
|
172
235
|
}
|
|
236
|
+
// Compute stale files (managed-zone destination files with no payload
|
|
237
|
+
// counterpart) for the dry-run plan so operators can preview pruning.
|
|
238
|
+
const payloadSet = new Set(payloadFiles);
|
|
239
|
+
const destFiles = listDestFiles(destRoot, fs);
|
|
240
|
+
const stale = destFiles.filter((f) => !payloadSet.has(f));
|
|
241
|
+
for (const rel of stale) {
|
|
242
|
+
write(`would prune ${path.join('.agents', rel)}\n`);
|
|
243
|
+
}
|
|
173
244
|
write(
|
|
174
|
-
`✅ Dry run: ${
|
|
245
|
+
`✅ Dry run: ${payloadFiles.length} file(s) would be materialized, ${stale.length} stale file(s) would be pruned from ./.agents/\n`,
|
|
175
246
|
);
|
|
176
|
-
return {
|
|
247
|
+
return {
|
|
248
|
+
copied: 0,
|
|
249
|
+
planned: payloadFiles.length,
|
|
250
|
+
pruned: 0,
|
|
251
|
+
dryRun: true,
|
|
252
|
+
};
|
|
177
253
|
}
|
|
178
254
|
|
|
179
|
-
for (const rel of
|
|
255
|
+
for (const rel of payloadFiles) {
|
|
180
256
|
const src = path.join(sourceRoot, rel);
|
|
181
257
|
const dest = path.join(destRoot, rel);
|
|
182
258
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
@@ -185,8 +261,31 @@ export function runSync({
|
|
|
185
261
|
fs.copyFileSync(src, dest);
|
|
186
262
|
}
|
|
187
263
|
|
|
188
|
-
|
|
189
|
-
|
|
264
|
+
// Prune pass (Story #4046 A3): remove managed-zone destination files that
|
|
265
|
+
// have no counterpart in the payload. The local-additions zone
|
|
266
|
+
// (.agents/local/) is never enumerated by listDestFiles and is therefore
|
|
267
|
+
// never pruned — consumer additions there are sanctioned, not stale.
|
|
268
|
+
const payloadSet = new Set(payloadFiles);
|
|
269
|
+
const destFiles = listDestFiles(destRoot, fs);
|
|
270
|
+
const staleFiles = destFiles.filter((f) => !payloadSet.has(f));
|
|
271
|
+
for (const rel of staleFiles) {
|
|
272
|
+
const dest = path.join(destRoot, rel);
|
|
273
|
+
fs.unlinkSync(dest);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (staleFiles.length > 0) {
|
|
277
|
+
write(
|
|
278
|
+
`✅ Materialized ${payloadFiles.length} file(s) into ./.agents/ (pruned ${staleFiles.length} stale file(s))\n`,
|
|
279
|
+
);
|
|
280
|
+
} else {
|
|
281
|
+
write(`✅ Materialized ${payloadFiles.length} file(s) into ./.agents/\n`);
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
copied: payloadFiles.length,
|
|
285
|
+
planned: payloadFiles.length,
|
|
286
|
+
pruned: staleFiles.length,
|
|
287
|
+
dryRun: false,
|
|
288
|
+
};
|
|
190
289
|
}
|
|
191
290
|
|
|
192
291
|
/**
|
package/lib/cli/uninstall.js
CHANGED
|
@@ -54,7 +54,6 @@
|
|
|
54
54
|
|
|
55
55
|
import fs from 'node:fs';
|
|
56
56
|
import path from 'node:path';
|
|
57
|
-
import { fileURLToPath } from 'node:url';
|
|
58
57
|
|
|
59
58
|
import {
|
|
60
59
|
LEDGER_SCHEMA_VERSION,
|
|
@@ -640,6 +639,7 @@ function formatOutcome(outcome) {
|
|
|
640
639
|
* write?: (s: string) => void,
|
|
641
640
|
* exit?: (code: number) => void,
|
|
642
641
|
* includeGithub?: boolean,
|
|
642
|
+
* dryRun?: boolean,
|
|
643
643
|
* }} [opts]
|
|
644
644
|
* @returns {{ revertedCount: number, manualCount: number, ledgerFound: boolean, parseErrorCount: number }}
|
|
645
645
|
*/
|
|
@@ -650,6 +650,7 @@ export function runUninstall({
|
|
|
650
650
|
write = (s) => process.stdout.write(s),
|
|
651
651
|
exit = (code) => process.exit(code),
|
|
652
652
|
includeGithub = false,
|
|
653
|
+
dryRun = false,
|
|
653
654
|
} = {}) {
|
|
654
655
|
const root = projectRoot ?? resolveProjectRoot(cwd);
|
|
655
656
|
|
|
@@ -695,6 +696,39 @@ export function runUninstall({
|
|
|
695
696
|
|
|
696
697
|
const { fileTargets, manual, executedActionByTarget } = planUninstall(ledger);
|
|
697
698
|
|
|
699
|
+
// --- Dry run — show what would be reversed without touching anything. ---
|
|
700
|
+
if (dryRun) {
|
|
701
|
+
write('mandrel uninstall — planned reversal (dry run)\n');
|
|
702
|
+
for (const target of fileTargets) {
|
|
703
|
+
write(
|
|
704
|
+
formatOutcome({
|
|
705
|
+
kind: 'reverted',
|
|
706
|
+
target,
|
|
707
|
+
detail: '(would be reverted)',
|
|
708
|
+
}),
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
for (const entry of manual) {
|
|
712
|
+
const note = includeGithub
|
|
713
|
+
? `${entry.detail} (acknowledged — reverse manually via the GitHub UI/API)`
|
|
714
|
+
: `${entry.detail} (left untouched — pass --include-github to acknowledge)`;
|
|
715
|
+
write(
|
|
716
|
+
formatOutcome({ kind: 'manual', target: entry.target, detail: note }),
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
write(
|
|
720
|
+
`Dry run: ${fileTargets.length} file target(s) would be reverted, ` +
|
|
721
|
+
`${manual.length} manual follow-up(s).\n`,
|
|
722
|
+
);
|
|
723
|
+
exit(0);
|
|
724
|
+
return {
|
|
725
|
+
revertedCount: 0,
|
|
726
|
+
manualCount: manual.length,
|
|
727
|
+
ledgerFound: true,
|
|
728
|
+
parseErrorCount: 0,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
698
732
|
let revertedCount = 0;
|
|
699
733
|
let parseErrorCount = 0;
|
|
700
734
|
for (const target of fileTargets) {
|
|
@@ -782,14 +816,15 @@ export function runUninstall({
|
|
|
782
816
|
/**
|
|
783
817
|
* Default export consumed by `bin/mandrel.js`.
|
|
784
818
|
*
|
|
785
|
-
*
|
|
819
|
+
* Supported flags:
|
|
820
|
+
* --include-github Acknowledge GitHub-side manual follow-ups.
|
|
821
|
+
* --dry-run Show what would be reversed without touching anything.
|
|
822
|
+
*
|
|
823
|
+
* @param {string[]} argv
|
|
786
824
|
* @returns {Promise<void>}
|
|
787
825
|
*/
|
|
788
826
|
export default async function run(argv = []) {
|
|
789
827
|
const includeGithub = argv.includes('--include-github');
|
|
790
|
-
|
|
828
|
+
const dryRun = argv.includes('--dry-run');
|
|
829
|
+
runUninstall({ includeGithub, dryRun });
|
|
791
830
|
}
|
|
792
|
-
|
|
793
|
-
// Re-export so tests and callers can reference the resolved module path
|
|
794
|
-
// without re-deriving it.
|
|
795
|
-
export const __filenameForTests = fileURLToPath(import.meta.url);
|