@amsterdamdatalabs/enact-extensions 0.1.55 → 0.1.57

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/README.md CHANGED
@@ -58,11 +58,11 @@ node scripts/enact-extensions.mjs sync extensions/plugin-dev
58
58
  node scripts/enact-extensions.mjs list
59
59
  ```
60
60
 
61
- `install` / `uninstall` / `doctor` only accept plugin names listed in
62
- [`bundled-extensions.json`](bundled-extensions.json) — today that is **`enact-repo`**
63
- only — and only in repo-local `--repo <path>` form. Any other name is refused with a
64
- clear error before anything is touched. `scripts/lib/allowed-plugins.mjs` is the CLI's
65
- allow-list gate; the repo-local install library stays name-agnostic.
61
+ `install` / `uninstall` / `doctor` accept any bundle resolvable by bare name or
62
+ explicit plugin path, and only in repo-local `--repo <path>` form.
63
+ [`bundled-extensions.json`](bundled-extensions.json) names the default bundle set;
64
+ it is not an install or doctor allow-list. An unknown or unresolvable bundle fails
65
+ clearly before anything is touched.
66
66
 
67
67
  ### Repo-local install (`--repo`)
68
68
 
@@ -71,41 +71,50 @@ Install a bundle **into a git repository** as committed, relative files, followi
71
71
  ```bash
72
72
  enact-extensions install enact-repo --repo <path> [--host claude,codex,cursor,kimi,opencode] [--force]
73
73
  enact-extensions install enact-repo --repo <path> --dry-run # preview; writes nothing
74
- enact-extensions uninstall enact-repo --repo <path> [--purge] [--force]
74
+ enact-extensions uninstall enact-repo --repo <path> [--purge]
75
75
  enact-extensions uninstall enact-repo --repo <path> --dry-run [--purge] # preview; writes nothing
76
76
  enact-extensions doctor enact-repo --repo <path> [--json] # read-only health check
77
77
  ```
78
78
 
79
- `<path>` must be the git repository root (not a subdirectory, not `$HOME`). `--host` defaults to every repo host the bundle targets.
79
+ `<path>` must be the git repository root (not a subdirectory, not `$HOME`) and must
80
+ already contain a regular, non-symlink root `enact-config.toml`. Install is
81
+ reconciliation, never enrollment: an unmarked repository is refused before any
82
+ file is read or written. `--host` defaults to every repo host the bundle targets.
80
83
 
81
- `--dry-run` prints exactly what would be written, merged, removed, or restored — one line per file, naming the action and the reason (including a drift/conflict refusal, if the real run would refuse) — and exits with the same success/failure a real run would, without touching the filesystem.
84
+ `--dry-run` prints exactly what would be deleted or rebuilt — one line per file,
85
+ naming the action and reason — and exits with the same success/failure a real run
86
+ would, without touching the filesystem. `install --force` is an explicit,
87
+ announced request for the same authoritative repair performed by a normal install;
88
+ it never enables a merge, preservation, or restore path.
82
89
 
83
90
  `doctor enact-repo --repo <path>` is read-only. It reports the installed and current
84
91
  bundle versions, lock integrity and drift, and each host's lock-owned native paths.
85
92
  It also reports Codex and Kimi trust notes, Cursor MCP approval, Kimi's global-hook
86
- status, required global binaries, the root `enact-config.toml` `[extensions]` marker,
87
- an optional `enact-hook doctor --repo` result, and the per-repo hook logs. It never
88
- writes; it exits non-zero only for a hard requirement such as a missing install,
89
- lock-integrity failure, missing required binary, or invalid/missing `[extensions]`
90
- marker.
91
-
92
- | Host | Files written in the repo |
93
+ status, required global binaries, the root `enact-config.toml` marker and whether its
94
+ `[extensions]` roster is present, an optional `enact-hook doctor --repo` result, and
95
+ the per-repo hook logs. A pre-existing regular marker without `[extensions]` is valid
96
+ for doctor; install adds this extension's roster declaration. Doctor never writes; it
97
+ exits non-zero only for a hard requirement such as a missing install, lock-integrity
98
+ failure, missing required binary, or missing/invalid root marker.
99
+
100
+ | Host | Canonical files rebuilt in the repo |
93
101
  | --- | --- |
94
- | Claude Code | `.claude/agents/*.md`, `.claude/skills/<skill>/`, root `.mcp.json`, and hook entries merged into `.claude/settings.json`. Trust the folder before loading the project configuration. |
102
+ | Claude Code | `.claude/agents/*.md`, `.claude/skills/<skill>/`, root `.mcp.json`, and `.claude/settings.json`. Trust the folder before loading the project configuration. |
95
103
  | Codex | `.codex/agents/*.toml`, `.agents/skills/<skill>/`, `.codex/config.toml` MCP tables, and `.codex/hooks.json`. Trust the exact project path and review hooks with `/hooks`. |
96
104
  | Cursor | `.cursor/agents/*.md`, `.cursor/skills/<skill>/`, `.cursor/mcp.json`, and `.cursor/hooks.json`. |
97
105
  | Kimi Code | `.kimi-code/agents/*.md`, `.kimi-code/skills/<skill>/`, and `.kimi-code/mcp.json`. Kimi hooks remain global-only and are installed by `enact-vps`, never by this repo-local command. |
98
- | OpenCode | A hook-free bundle writes `.opencode/agents/*.md`, `.opencode/skills/<skill>/`, and root `opencode.json` MCP entries. `enact-repo` declares hooks, so its repo-local OpenCode install is refused before any write; install its explicit global OpenCode plugin through `enact-vps`. |
106
+ | OpenCode | `.opencode/agents/*.md`, `.opencode/skills/<skill>/`, and root `opencode.json`. The repo-local installer also removes the retired `.opencode/plugins` root. |
99
107
 
100
108
  Skills are installed at each host's native project discovery path: `.agents/skills/` for
101
109
  Codex and `.claude/skills/`, `.cursor/skills/`, `.kimi-code/skills/`, or
102
- `.opencode/skills/` for the other hosts. An existing same-named skill directory that
103
- the bundle does not manage is never overwritten, even with `--force`.
110
+ `.opencode/skills/` for the other hosts. These paths are components of authoritative
111
+ roots, so stale, foreign, or drifted entries are deleted and regenerated from the
112
+ bundle on every reconciliation.
104
113
 
105
- `lean-ctx` config: regardless of `--host`, the bundle's baseline `lean-ctx/config.toml` and `lean-ctx/layout.toml` are installed as managed whole files at `.agents/lean-ctx/config/lean-ctx/` — every host's `.mcp.json`/hooks equivalent runs `enact-hook lean-ctx ...`, which resolves its config from this repo-local path. Managed like any other whole file: drift is detected, `--force` takes over and records a pre-existing user `config.toml`/`layout.toml` for byte-exact restore on uninstall.
114
+ `lean-ctx` config: regardless of `--host`, the bundle's baseline `lean-ctx/config.toml` and `lean-ctx/layout.toml` are installed as canonical whole files at `.agents/lean-ctx/config/lean-ctx/` — every host's `.mcp.json`/hooks equivalent runs `enact-hook lean-ctx ...`, which resolves its config from this repo-local path. Drift is repaired by replacing the owned file; uninstall does not restore prior content.
106
115
 
107
- Always written: root `enact-config.toml`'s `[extensions]` marker and the bare
108
- `[extensions.<name>]` declaration for the installed bundle. The shared file's tables
116
+ Install text-surgeries only the pre-existing root `enact-config.toml`'s `[extensions]`
117
+ scope and the bare `[extensions.<name>]` declaration for the installed bundle. The shared file's tables
109
118
  have one owner: `version`/`[hooks]`/`[rules]`/`[repo]` are `enact-hook`,
110
119
  `[extensions]` is enact-extensions, and `[controls]` is enact-repo-controls. The
111
120
  installer edits only its own block, removes the legacy marker-only `[skills]` block,
@@ -113,12 +122,13 @@ and never writes `[hooks]`. It also manages the baseline `lean-ctx` config,
113
122
  `.agents/.gitignore` runtime entries, and `.agents/enact-repo.lock.json`. Commit all
114
123
  of it except what `.gitignore` excludes.
115
124
 
116
- The lock records every managed file (sha256), every entry merged into a user file, and the plugin version, all as relative paths:
125
+ The lock records every canonical managed file (sha256), the owned roots, and the
126
+ plugin version, all as relative paths. Its reconciliation contract is:
117
127
 
118
- - **Re-runs are idempotent.** An unchanged bundle produces no diff. Narrowing `--host` removes the dropped hosts' managed content.
119
- - **Merged user files are edited surgically.** `.claude/settings.json`, `.cursor/hooks.json`, `.cursor/mcp.json`, `.kimi-code/mcp.json`, `.codex/hooks.json`, and `.codex/config.toml` only gain or lose the bundle's own entries. Key order and indentation are preserved.
120
- - **Refusals (override with `--force`).** Install stops if a managed file or entry was edited since the last install, or if a same-named native agent, skill, or MCP entry exists that the bundle does not own. The error names each file.
121
- - **Uninstall removes exactly what the lock records.** It deletes a merged file only if install created it and it is now empty, then deletes the lock. `enact-config.toml` stays unless you pass `--purge`. `--purge` cuts only the `[extensions]` block install wrote, at text level; it refuses if that block was edited (with `--force`, it keeps the block). It deletes the file only if install created it and nothing but `version = 1` remains.
128
+ - **Re-runs are idempotent.** An unchanged bundle produces byte-identical owned files.
129
+ - **Roots are rebuilt, never merged.** `.agents`, `.claude`, `.codex`, `.cursor`, `.kimi-code`, and `.opencode` are Enact-owned roots. Reconciliation deletes their contents and writes only the canonical projection; it also deletes retired `.claude-plugin`, `.codex-plugin`, `.cursor-plugin`, `.kimi-plugin`, and `.opencode/plugins` roots.
130
+ - **Runtime boundaries are explicit.** `.agents/hooks/` and `.agents/lean-ctx/{data,cache,state,bin}/` remain outside Enact ownership; their writers retain them through install and both uninstall modes.
131
+ - **Uninstall removes the authoritative deployment.** It removes Enact-owned native roots and the extension declaration, without restoring foreign or prior files. `enact-config.toml` remains a pre-existing central marker; `--purge` additionally cuts an `[extensions]` block this installer appended, using text-level surgery only.
122
132
 
123
133
  Install the CLI itself globally (the CLI binary only — no plugins, no host config):
124
134
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "enact-repo",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Baseline agent setup for any repository: the full Enact skill catalog plus guard, briefing, and code-review-graph hooks and MCP tooling, installed at repo scope.",
5
5
  "author": {
6
6
  "name": "amsterdamdatalabs"
@@ -69,27 +69,38 @@ host, with no `--host` appended.
69
69
 
70
70
  ## Repo-local install
71
71
 
72
- Install into a repository with `enact-extensions install enact-repo --repo <repo-root>` (see the
73
- top-level README, "Repo-local install"). The command writes direct native project files only:
72
+ Install into an already centrally managed repository with `enact-extensions install enact-repo --repo <repo-root>`
73
+ (see the top-level README, "Repo-local install"). Root `enact-config.toml` must already exist;
74
+ install is reconciliation, not enrollment. The command writes direct native project files only:
74
75
  `.claude/agents` and `.claude/skills`; `.codex/agents` and `.agents/skills`;
75
- `.cursor/agents` and `.cursor/skills`; `.kimi-code/agents` and `.kimi-code/skills`; and,
76
- for hook-free bundles, `.opencode/agents` and `.opencode/skills`. It also writes each host's
76
+ `.cursor/agents` and `.cursor/skills`; `.kimi-code/agents` and `.kimi-code/skills`; and
77
+ `.opencode/agents` and `.opencode/skills`. It also writes each host's
77
78
  native MCP and hook configuration where supported. It never writes a plugin root, marketplace,
78
79
  or plugin registration.
79
- Kimi hooks are global-only and are installed by `enact-vps`, not by this bundle. OpenCode has no
80
- repo-local hook carrier: an OpenCode install of a hook-bearing bundle fails before writing anything
81
- and requires the explicit global OpenCode plugin through `enact-vps`. The installer also marks the
82
- repo as managed through root `enact-config.toml` (the file `enact-hook` checks): it creates the
83
- file with `version = 1` when missing, manages the `[extensions]` declaration for this bundle by
84
- text-level surgery only, removes the legacy marker-only `[skills]` block, and never writes
85
- `[hooks]`; installs the baseline `lean-ctx/config.toml` and
86
- `lean-ctx/layout.toml` as managed whole files at `.agents/lean-ctx/config/lean-ctx/` (tracked by
87
- the lock like any other managed file — drift detection, `--force` takeover with originals
88
- restore on uninstall, all reused from the existing managed-file machinery); and ignores
89
- `lean-ctx`'s runtime-generated dirs via `.agents/.gitignore` (`/lean-ctx/data/`,
80
+ Kimi hooks are global-only and are installed by `enact-vps`, not by this bundle. OpenCode's
81
+ process-level hook carrier is separate from this repo-local installer; OpenCode bundles always
82
+ install their native local files and remove legacy plugin roots. The installer rebuilds the owned
83
+ roots `.agents`, `.claude`, `.codex`, `.cursor`, `.kimi-code`, and `.opencode` from the canonical
84
+ bundle on every run: stale, foreign, and drifted content is deleted, never merged, adopted, or
85
+ restored. It removes the retired `.claude-plugin`, `.codex-plugin`, `.cursor-plugin`,
86
+ `.kimi-plugin`, and `.opencode/plugins` roots. It modifies only `[extensions]` in the pre-existing
87
+ marker through text-level surgery, removes the legacy marker-only `[skills]` block, and never
88
+ writes `[hooks]`. The baseline `lean-ctx/config.toml` and `lean-ctx/layout.toml` live at
89
+ `.agents/lean-ctx/config/lean-ctx/`. The explicit unmanaged runtime boundaries are
90
+ `.agents/hooks/` and `.agents/lean-ctx/{data,cache,state,bin}/`; they are written by their own
91
+ runtimes and ignored via `.agents/.gitignore` (`/lean-ctx/data/`,
90
92
  `/lean-ctx/cache/`, `/lean-ctx/state/`, `/lean-ctx/bin/` — the `config/` dir itself stays
91
93
  committed).
92
94
 
95
+ ## Commands
96
+
97
+ The authoritative bundle currently declares no `commands/` source. A 2026-09-18 audit of
98
+ `test-repo-only-for-global-tools-m5` found native agent profiles but no native command artifact.
99
+ The installer therefore does not invent a command projection. Documented future repo-local command
100
+ paths are `.claude/commands/` (frontmatter Markdown), `.cursor/commands/` (plain Markdown), and
101
+ `.opencode/commands/` (frontmatter Markdown); Codex has no documented project-local command path.
102
+ Command projection remains pending a canonical Enact command source.
103
+
93
104
  ## Canonical install path
94
105
 
95
106
  The canonical install entrypoint is `extensions/enact-repo/.agents/plugin.json`. `enact-extensions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amsterdamdatalabs/enact-extensions",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
4
4
  "description": "Create and validate Enact multi-platform plugin manifests",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -37,17 +37,16 @@ Usage:
37
37
  enact-extensions sync [path|name] Sync host manifests from .agents/plugin.json (default: cwd)
38
38
  enact-extensions sync [path|name] --name <id> Create .agents/plugin.json then sync (new plugin)
39
39
  enact-extensions install <name> --repo <path> [--host claude,codex,cursor,kimi,opencode] [--force]
40
- Install a bundle INTO a git repo as committed, relative files
41
- (per-host project standard; state in .agents/<name>.lock.json)
42
- enact-extensions install <name> --repo <path> --dry-run Print exactly what install would write/merge, per
40
+ Rebuild Enact-owned native roots in an already marked git repo
41
+ (state in .agents/<name>.lock.json)
42
+ enact-extensions install <name> --repo <path> --dry-run Print exactly what install would delete/write, per
43
43
  file (action + reason), and exit with the same success/failure
44
44
  the real run would have. Touches nothing.
45
- enact-extensions uninstall <name> --repo <path> [--purge] [--force]
46
- Remove exactly what the repo lock records (--purge also removes
47
- the [extensions] block install wrote to enact-config.toml, and the
48
- file itself if install created it and only version = 1 remains)
45
+ enact-extensions uninstall <name> --repo <path> [--purge]
46
+ Remove the authoritative native roots and this extension declaration
47
+ (--purge also cuts a table this extension appended)
49
48
  enact-extensions uninstall <name> --repo <path> --dry-run [--purge]
50
- Print exactly what uninstall would remove/restore, per file
49
+ Print exactly what uninstall would remove, per file
51
50
  (action + reason), and exit with the same success/failure the
52
51
  real run would have. Touches nothing.
53
52
  enact-extensions doctor enact-repo --repo <path> [--json]
@@ -72,15 +71,15 @@ Options:
72
71
  --stdout (index only) Alias for --out -.
73
72
  --json (list/doctor) Emit JSON to stdout instead of human output.
74
73
  --name <id> (sync only) Plugin name for a new .agents/plugin.json.
75
- --repo <path> (install/uninstall/doctor) Target the git repository root at <path>.
76
- Never writes under $HOME; never writes absolute paths.
74
+ --repo <path> (install/uninstall/doctor) Target an already marked git repository root at <path>.
75
+ Root enact-config.toml is required; never writes under $HOME or absolute paths.
77
76
  --host <list> (install --repo) Hosts to install for: claude,codex,cursor,kimi,opencode
78
77
  (default: every repo host the bundle targets).
79
78
  --dry-run (install/uninstall --repo) Print exactly what would change, per file, and
80
79
  exit with the same success/failure the real run would have. Writes nothing.
81
- --force (install/uninstall --repo) Overwrite user-edited managed content and take over
82
- same-named entries not owned by the bundle. Never overwrites a user skill dir.
83
- --purge (uninstall --repo) Also remove the [extensions] block install wrote to enact-config.toml.
80
+ --force (install --repo) Explicitly request the authoritative repair. It deletes and
81
+ rebuilds the same owned roots as every install; it never preserves or restores foreign content.
82
+ --purge (uninstall --repo) Also cut an [extensions] block this extension appended.
84
83
 
85
84
  Name resolution (bare plugin names):
86
85
  When a bare name (no "/" in it) is given, it is resolved in this order:
@@ -99,7 +98,7 @@ Examples:
99
98
  enact-extensions install enact-repo --repo ~/code/my-service --host claude,cursor
100
99
  enact-extensions install enact-repo --repo . --dry-run Preview the plan; writes nothing
101
100
  enact-extensions uninstall enact-repo --repo . --purge
102
- enact-extensions uninstall enact-repo --repo . --dry-run Preview what would be removed/restored
101
+ enact-extensions uninstall enact-repo --repo . --dry-run Preview what would be removed
103
102
  enact-extensions doctor enact-repo --repo . Read-only health check
104
103
  enact-extensions doctor enact-repo --repo . --json
105
104
  `;
@@ -154,12 +153,12 @@ function parseArgs(argv) {
154
153
  options.repo = argv[++i];
155
154
  } else if (arg === "--host" && argv[i + 1]) {
156
155
  options.host = argv[++i];
157
- } else if (arg === "--force") {
158
- options.force = true;
159
156
  } else if (arg === "--purge") {
160
157
  options.purge = true;
161
158
  } else if (arg === "--dry-run") {
162
159
  options.dryRun = true;
160
+ } else if (arg === "--force") {
161
+ options.force = true;
163
162
  } else if (arg === "--json") {
164
163
  options.json = true;
165
164
  } else if (arg === "-h" || arg === "--help") {
@@ -205,7 +204,10 @@ try {
205
204
  !["install", "uninstall"].includes(command) &&
206
205
  (options.force || options.purge || options.host || options.dryRun)
207
206
  ) {
208
- fail("--host/--force/--purge/--dry-run only apply to install|uninstall <name> --repo <path>");
207
+ fail("--host/--purge/--dry-run/--force only apply to install|uninstall <name> --repo <path>");
208
+ }
209
+ if (options.force && command !== "install") {
210
+ fail("--force applies only to install <name> --repo <path>");
209
211
  }
210
212
 
211
213
  function printRepoDoctorReport(report) {
@@ -530,7 +532,12 @@ try {
530
532
  }
531
533
  const result =
532
534
  command === "install"
533
- ? runRepoInstall(pluginRoot, { repo: options.repo, host: options.host, force: options.force, dryRun })
535
+ ? runRepoInstall(pluginRoot, {
536
+ repo: options.repo,
537
+ host: options.host,
538
+ force: options.force,
539
+ dryRun,
540
+ })
534
541
  : runRepoUninstall(repoPluginName, { repo: options.repo, purge: options.purge, force: options.force, dryRun });
535
542
  if (dryRun) {
536
543
  if (result.actions.length === 0) {
@@ -539,6 +546,7 @@ try {
539
546
  for (const a of result.actions) {
540
547
  console.log(`[dry-run] would ${a.action}: ${a.file} — ${a.reason}`);
541
548
  }
549
+ console.log(`would change ${result.actions.length} file(s)`);
542
550
  }
543
551
  }
544
552
  exit(0);
@@ -10,10 +10,9 @@
10
10
  * - plugin version vs bundle version (outdated?), plus per-file content
11
11
  * drift against what the CURRENT
12
12
  * bundle would generate
13
- * - lock integrity (every managed file present + hash
14
- * match; every merged JSON entry /
15
- * TOML block present; list drifted
16
- * or missing)
13
+ * - lock integrity (every canonical managed file
14
+ * present + hash match; no foreign
15
+ * content inside owned roots)
17
16
  * - per-host status: direct native files recorded in the lock (never a
18
17
  * plugin root or marketplace), codex (files present; read-only
19
18
  * ~/.codex/config.toml projects."<repo>".trust_level; hooks-review-
@@ -48,21 +47,21 @@
48
47
 
49
48
  import { spawnSync } from "node:child_process";
50
49
  import { createHash } from "node:crypto";
51
- import { existsSync, lstatSync, readFileSync, readlinkSync } from "node:fs";
50
+ import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync } from "node:fs";
52
51
  import { homedir } from "node:os";
53
52
  import { basename, join } from "node:path";
54
53
  import * as TOML from "@iarna/toml";
55
- import { entryPresent, findBlock } from "../../dist/index.js";
54
+ import { findBlock } from "../../dist/index.js";
56
55
  import {
57
56
  abs,
58
57
  buildPlan,
59
58
  CONFIG_PATH,
60
59
  fileSha,
61
60
  HOOK_LOG_FILES,
61
+ inspectExtensionsScope,
62
62
  lockRel,
63
63
  REPO_HOSTS,
64
64
  readBundle,
65
- readJsonStrict,
66
65
  readLock,
67
66
  resolveRepoRoot,
68
67
  sha256,
@@ -299,8 +298,8 @@ function runHookDoctor(repo, enactHook) {
299
298
  }
300
299
 
301
300
  // ---------------------------------------------------------------------------
302
- // lock integrity — every managed file present + hash match; every merged
303
- // JSON entry / TOML block present; lists drifted/missing.
301
+ // lock integrity — every canonical managed file present + hash match, plus
302
+ // no foreign content inside an owned root.
304
303
  // ---------------------------------------------------------------------------
305
304
  function checkLockIntegrity(repo, name, lock) {
306
305
  const missingFiles = [];
@@ -309,24 +308,6 @@ function checkLockIntegrity(repo, name, lock) {
309
308
  if (!existsSync(abs(repo, rel))) missingFiles.push(rel);
310
309
  else if (fileSha(repo, rel) !== want) driftedFiles.push(rel);
311
310
  }
312
- const jsonIssues = [];
313
- for (const [rel, record] of Object.entries(lock.merged)) {
314
- const path = abs(repo, rel);
315
- if (!existsSync(path)) {
316
- jsonIssues.push(`${rel} (file missing)`);
317
- continue;
318
- }
319
- let doc;
320
- try {
321
- doc = readJsonStrict(path, rel);
322
- } catch (err) {
323
- jsonIssues.push(`${rel} (${err instanceof Error ? err.message : String(err)})`);
324
- continue;
325
- }
326
- for (const entry of record.entries) {
327
- if (!entryPresent(doc, entry)) jsonIssues.push(`${rel} (${entry.path.join(".")})`);
328
- }
329
- }
330
311
  const tomlIssues = [];
331
312
  for (const [rel, record] of Object.entries(lock.toml)) {
332
313
  const path = abs(repo, rel);
@@ -358,13 +339,41 @@ function checkLockIntegrity(repo, name, lock) {
358
339
  }
359
340
  if (!existsSync(path)) symlinkIssues.push(`${rel} (dangling -> ${target})`);
360
341
  }
342
+ const rootIssues = [];
343
+ for (const root of lock.ownedRoots ?? []) {
344
+ const expectedFiles = new Set(
345
+ [...Object.keys(lock.files ?? {}), lockRel(name)].filter((rel) => rel.startsWith(`${root}/`)),
346
+ );
347
+ const expectedDirs = new Set([root]);
348
+ for (const rel of expectedFiles) {
349
+ const parts = rel.split("/");
350
+ for (let i = 1; i < parts.length; i++) expectedDirs.add(parts.slice(0, i).join("/"));
351
+ }
352
+ const boundaries = new Set((lock.unmanagedBoundaries ?? []).filter((rel) => rel.startsWith(`${root}/`)));
353
+ const visit = (rel) => {
354
+ if ([...boundaries].some((boundary) => rel === boundary || rel.startsWith(`${boundary}/`))) return;
355
+ const path = abs(repo, rel);
356
+ const state = lstatSync(path, { throwIfNoEntry: false });
357
+ if (!state) return;
358
+ if (!state.isDirectory() || state.isSymbolicLink()) {
359
+ if (!expectedFiles.has(rel)) rootIssues.push(`${rel} (foreign file)`);
360
+ return;
361
+ }
362
+ if (!expectedDirs.has(rel)) {
363
+ rootIssues.push(`${rel} (foreign directory)`);
364
+ return;
365
+ }
366
+ for (const entry of readdirSync(path, { withFileTypes: true })) visit(`${rel}/${entry.name}`);
367
+ };
368
+ if (existsSync(abs(repo, root))) visit(root);
369
+ }
361
370
  const valid =
362
371
  missingFiles.length === 0 &&
363
372
  driftedFiles.length === 0 &&
364
- jsonIssues.length === 0 &&
365
373
  tomlIssues.length === 0 &&
366
- symlinkIssues.length === 0;
367
- return { valid, missingFiles, driftedFiles, jsonIssues, tomlIssues, symlinkIssues };
374
+ symlinkIssues.length === 0 &&
375
+ rootIssues.length === 0;
376
+ return { valid, missingFiles, driftedFiles, tomlIssues, symlinkIssues, rootIssues };
368
377
  }
369
378
 
370
379
  // ---------------------------------------------------------------------------
@@ -478,9 +487,9 @@ function checkMarker(repo) {
478
487
  note: "missing -- enact-hook only acts in repos containing this file; re-run install",
479
488
  };
480
489
  }
481
- let doc;
490
+ let extensions;
482
491
  try {
483
- doc = TOML.parse(readFileSync(path, "utf8"));
492
+ extensions = inspectExtensionsScope(readFileSync(path, "utf8"), "checking central-management marker");
484
493
  } catch (err) {
485
494
  return {
486
495
  ...base,
@@ -488,25 +497,20 @@ function checkMarker(repo) {
488
497
  parses: false,
489
498
  extensionsPresent: false,
490
499
  valid: false,
491
- note: `cannot parse: ${err instanceof Error ? err.message : String(err)}`,
500
+ note: err instanceof Error ? err.message : String(err),
492
501
  };
493
502
  }
494
- // `[extensions]` is the marker: its presence is what makes a repo
495
- // enact-hook-managed, and its sub-tables are the declared extension set.
496
- const extensionsPresent = doc.extensions !== undefined;
497
- const declared = extensionsPresent
498
- ? Object.entries(doc.extensions)
499
- .filter(([, v]) => v && typeof v === "object" && !Array.isArray(v))
500
- .map(([k]) => k)
501
- : [];
503
+ // The root file is the central-management marker. `[extensions]` is our
504
+ // optional roster, inspected only through its own textual scope.
505
+ const extensionsPresent = extensions.present;
502
506
  return {
503
507
  ...base,
504
508
  present: true,
505
509
  parses: true,
506
510
  extensionsPresent,
507
- declared,
508
- valid: extensionsPresent,
509
- note: extensionsPresent ? undefined : "present but has no [extensions] table -- re-run install",
511
+ declared: extensions.declarations,
512
+ valid: true,
513
+ note: extensionsPresent ? undefined : "present; [extensions] roster will be added by install",
510
514
  };
511
515
  }
512
516
 
@@ -582,9 +586,9 @@ export function runRepoDoctor(pluginRoot, options = {}) {
582
586
  if (!lockIntegrity.valid) {
583
587
  problems.push(
584
588
  `lock integrity: ${lockIntegrity.missingFiles.length} missing + ${lockIntegrity.driftedFiles.length} drifted managed file(s), ` +
585
- `${lockIntegrity.jsonIssues.length} json + ${lockIntegrity.tomlIssues.length} toml + ` +
586
- `${lockIntegrity.symlinkIssues.length} symlink issue(s) -- ` +
587
- `re-run "install --force" to repair or "uninstall --force" to remove`,
589
+ `${lockIntegrity.tomlIssues.length} toml + ` +
590
+ `${lockIntegrity.symlinkIssues.length} symlink + ${lockIntegrity.rootIssues.length} owned-root issue(s) -- ` +
591
+ `re-run install to repair or uninstall to remove`,
588
592
  );
589
593
  }
590
594