@monte3l/groundwork 1.0.0-rc.2 → 1.0.0-rc.3

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 (170) hide show
  1. package/README.md +13 -5
  2. package/bin/m3l-groundwork.mjs +36 -2
  3. package/dist/assets.d.ts +10 -0
  4. package/dist/assets.js +14 -0
  5. package/dist/baseline-stage.d.ts +173 -0
  6. package/dist/baseline-stage.js +215 -0
  7. package/dist/caps.d.ts +4 -1
  8. package/dist/caps.js +17 -3
  9. package/dist/conflicts.d.ts +23 -2
  10. package/dist/conflicts.js +89 -11
  11. package/dist/customize-paths.d.ts +92 -0
  12. package/dist/customize-paths.js +115 -0
  13. package/dist/emit.d.ts +50 -1
  14. package/dist/emit.js +121 -13
  15. package/dist/fatal.d.ts +70 -0
  16. package/dist/fatal.js +132 -0
  17. package/dist/format-error.d.ts +113 -0
  18. package/dist/format-error.js +553 -0
  19. package/dist/fs-guard.d.ts +142 -0
  20. package/dist/fs-guard.js +222 -0
  21. package/dist/git.js +10 -1
  22. package/dist/harness/conformance.js +2 -0
  23. package/dist/harness/frontmatter.js +2 -13
  24. package/dist/harness/grade.js +37 -8
  25. package/dist/harness/rules.d.ts +20 -3
  26. package/dist/harness/rules.js +108 -6
  27. package/dist/harness/types.d.ts +13 -0
  28. package/dist/harness/types.js +3 -0
  29. package/dist/inventory.d.ts +77 -3
  30. package/dist/inventory.js +103 -12
  31. package/dist/jsonc.d.ts +49 -2
  32. package/dist/jsonc.js +140 -7
  33. package/dist/main.d.ts +62 -3
  34. package/dist/main.js +538 -67
  35. package/dist/merge-json.d.ts +47 -4
  36. package/dist/merge-json.js +120 -8
  37. package/dist/mode.js +12 -2
  38. package/dist/pack-stage.d.ts +210 -0
  39. package/dist/pack-stage.js +287 -0
  40. package/dist/packs.d.ts +19 -13
  41. package/dist/packs.js +231 -28
  42. package/dist/palette.d.ts +23 -0
  43. package/dist/palette.js +22 -0
  44. package/dist/plugin.d.ts +122 -6
  45. package/dist/plugin.js +687 -47
  46. package/dist/report.d.ts +21 -2
  47. package/dist/report.js +93 -5
  48. package/dist/staging.d.ts +176 -0
  49. package/dist/staging.js +375 -0
  50. package/dist/survey/fs-walk.d.ts +34 -2
  51. package/dist/survey/fs-walk.js +70 -5
  52. package/dist/survey/internal/blocked-path.d.ts +27 -0
  53. package/dist/survey/internal/blocked-path.js +129 -0
  54. package/dist/survey/internal/package-json.d.ts +13 -0
  55. package/dist/survey/internal/package-json.js +37 -0
  56. package/dist/survey/internal/read-guard.d.ts +178 -0
  57. package/dist/survey/internal/read-guard.js +276 -0
  58. package/dist/survey/survey-docs.d.ts +17 -2
  59. package/dist/survey/survey-docs.js +61 -21
  60. package/dist/survey/survey-harness.d.ts +20 -2
  61. package/dist/survey/survey-harness.js +87 -46
  62. package/dist/survey/survey-shape.d.ts +17 -2
  63. package/dist/survey/survey-shape.js +60 -46
  64. package/dist/survey/survey-toolchain.d.ts +16 -1
  65. package/dist/survey/survey-toolchain.js +69 -66
  66. package/dist/survey/survey.js +6 -4
  67. package/dist/survey/types.d.ts +79 -0
  68. package/dist/survey/types.js +2 -6
  69. package/dist/term.d.ts +80 -0
  70. package/dist/term.js +145 -0
  71. package/dist/tokens.js +2 -0
  72. package/dist/toolchain/conformance.js +2 -0
  73. package/dist/toolchain/grade.js +26 -8
  74. package/dist/toolchain/rules.d.ts +13 -4
  75. package/dist/toolchain/rules.js +16 -0
  76. package/dist/toolchain/tsconfig-chain.d.ts +2 -0
  77. package/dist/toolchain/tsconfig-chain.js +32 -8
  78. package/dist/toolchain/types.d.ts +16 -4
  79. package/dist/toolchain/types.js +3 -0
  80. package/package.json +4 -3
  81. package/plugin/skills/customize/SKILL.md +292 -18
  82. package/plugin/src/domain-map.ts +39 -14
  83. package/plugin/src/index.ts +5 -1
  84. package/plugin/src/kind-facet-map.ts +25 -8
  85. package/plugin/src/pack-map.ts +147 -25
  86. package/plugin/src/plugin-map.ts +236 -0
  87. package/templates/core/.claude/agents/Explore.md +0 -1
  88. package/templates/core/.claude/agents/code-implementer.md +4 -4
  89. package/templates/core/.claude/agents/code-reviewer.md +4 -4
  90. package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
  91. package/templates/core/.claude/agents/test-author.md +7 -5
  92. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
  93. package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
  94. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
  95. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
  96. package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
  97. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
  98. package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
  99. package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
  100. package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
  101. package/templates/core/.claude/rules/agent-dispatch.md +7 -0
  102. package/templates/core/.claude/rules/tests.md +2 -2
  103. package/templates/core/.claude/settings.json +5 -0
  104. package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
  105. package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
  106. package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
  107. package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
  108. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
  109. package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
  110. package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
  111. package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
  112. package/templates/core/.github/dependabot.yml +18 -0
  113. package/templates/core/.github/workflows/ci.yml +15 -15
  114. package/templates/core/.github/workflows/dependency-review.yml +2 -2
  115. package/templates/core/.github/workflows/security-audit.yml +10 -4
  116. package/templates/core/.prettierignore +4 -0
  117. package/templates/core/CLAUDE.md +53 -3
  118. package/templates/core/README.md +30 -10
  119. package/templates/core/_gitignore +17 -0
  120. package/templates/core/bin/check-exports.mjs +11 -5
  121. package/templates/core/bin/lib/agent-roster.mjs +1 -1
  122. package/templates/core/bin/lib/frontmatter.mjs +4 -2
  123. package/templates/core/bin/lib/harness-rules.mjs +169 -20
  124. package/templates/core/bin/lib/protected-paths.mjs +93 -5
  125. package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
  126. package/templates/core/bin/lib/verify-steps.mjs +29 -11
  127. package/templates/core/bin/verify.mjs +12 -0
  128. package/templates/core/eslint.config.js +5 -0
  129. package/templates/core/package.json +7 -7
  130. package/templates/core/vitest.config.ts +8 -1
  131. package/templates/packs/README.md +35 -24
  132. package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
  133. package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
  134. package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
  135. package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
  136. package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
  137. package/templates/packs/github/pack.json +19 -0
  138. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
  139. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
  140. package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
  141. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
  142. package/templates/packs/harness-extras/pack.json +18 -13
  143. package/templates/packs/publishing/files/.changeset/README.md +25 -0
  144. package/templates/packs/publishing/files/.changeset/config.json +7 -0
  145. package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
  146. package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
  147. package/templates/packs/publishing/files/REUSE.toml +28 -0
  148. package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
  149. package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
  150. package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
  151. package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
  152. package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
  153. package/templates/packs/publishing/pack.json +35 -0
  154. package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
  155. package/templates/packs/quality/pack.json +29 -0
  156. package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
  157. package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
  158. package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
  159. package/templates/packs/supply-chain/pack.json +19 -0
  160. package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
  161. package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
  162. package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
  163. package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
  164. package/templates/packs/worktrees/files/.worktreeinclude +11 -0
  165. package/templates/packs/worktrees/pack.json +64 -0
  166. package/templates/packs/statusline/pack.json +0 -31
  167. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
  168. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
  169. /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
  170. /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
package/dist/conflicts.js CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  /**
2
4
  * Compares `templates/core` (after token substitution) against a target
3
5
  * directory, file by file, without writing anything. `package.json` and any
@@ -5,10 +7,13 @@
5
7
  * a whole-file conflict on `package.json` is a useless finding, since the
6
8
  * answer is always a merge, never "pick one file wholesale".
7
9
  */
8
- import { existsSync, readFileSync, readdirSync } from "node:fs";
10
+ import { readFileSync, readdirSync } from "node:fs";
9
11
  import { basename, join, relative } from "node:path";
10
12
  import { restoreDotfilePath } from "./assets.js";
11
13
  import { parseJsonc } from "./jsonc.js";
14
+ import { isRecord } from "./merge-json.js";
15
+ import { blockedAbsentNote } from "./survey/internal/blocked-path.js";
16
+ import { probePath, probeSubject, readFailure, recordedReadCode, unreadableNote, } from "./survey/internal/read-guard.js";
12
17
  import { applyTokens } from "./tokens.js";
13
18
  function isKeyLevelJsonFile(relPath) {
14
19
  const name = basename(relPath);
@@ -20,8 +25,14 @@ function compareJsonKeys(baselineContent, targetContent) {
20
25
  if (!baseline.ok || !target.ok) {
21
26
  return undefined;
22
27
  }
28
+ // Valid JSON need not be an object (`null`, a number, an array): a key-level
29
+ // compare is meaningless there, so take the same whole-file fallback as an
30
+ // unparseable file.
23
31
  const baselineValue = baseline.value;
24
32
  const targetValue = target.value;
33
+ if (!isRecord(baselineValue) || !isRecord(targetValue)) {
34
+ return undefined;
35
+ }
25
36
  const keys = new Set([
26
37
  ...Object.keys(baselineValue),
27
38
  ...Object.keys(targetValue),
@@ -34,11 +45,57 @@ function compareJsonKeys(baselineContent, targetContent) {
34
45
  }
35
46
  return diffs;
36
47
  }
37
- function compareFile(relPath, baselineContent, targetPath) {
38
- if (!existsSync(targetPath)) {
39
- return { relPath, status: "absent", keyDiffs: undefined };
48
+ /**
49
+ * Appends `note` unless already present: adopt mode passes the survey's own
50
+ * list, and a pack and the baseline can name the same path.
51
+ */
52
+ function recordOnce(undetermined, note) {
53
+ if (!undetermined.includes(note))
54
+ undetermined.push(note);
55
+ }
56
+ function compareFile(relPath, baselineContent, targetPath, targetDir, undetermined) {
57
+ // A real `stat`, never `existsSync`: a file under a directory this process
58
+ // cannot search must not read as "absent" (a clean add `/customize` would
59
+ // then write straight over). Something there this process cannot reach is
60
+ // divergent -- it cannot be shown identical, and adopt mode never
61
+ // overwrites -- with the errno recorded. Any other errno throws.
62
+ const probe = probePath(targetPath);
63
+ if (probe.kind === "absent") {
64
+ // `absent` also covers a dangling symlink at the path or an ancestor
65
+ // (`ENOENT`) and a regular file where an ancestor directory should be
66
+ // (`ENOTDIR`): both are something in the project's tree a write would
67
+ // have to go through, so they are divergent, recorded once, never a
68
+ // silent clean add.
69
+ const note = blockedAbsentNote(targetPath, targetDir);
70
+ if (note === undefined) {
71
+ return { relPath, status: "absent", keyDiffs: undefined };
72
+ }
73
+ recordOnce(undetermined, note);
74
+ return { relPath, status: "divergent", keyDiffs: undefined };
75
+ }
76
+ if (probe.kind === "unresolvable") {
77
+ // A permission failure names the enclosing directory (the `stat` needed
78
+ // search permission there, not on the file), so every baseline file
79
+ // under one locked directory shares a single note; a symlink loop names
80
+ // the path itself.
81
+ recordOnce(undetermined, unreadableNote(probeSubject(targetPath, probe.code), probe.code));
82
+ return { relPath, status: "divergent", keyDiffs: undefined };
83
+ }
84
+ // A read failure that is a property of the project's tree (a permission
85
+ // failure, a directory where the baseline has a file) is divergent too --
86
+ // it cannot be shown identical -- and is recorded naming the path, so the
87
+ // report never shows "divergent" with no reason behind it.
88
+ let targetContent;
89
+ try {
90
+ targetContent = readFileSync(targetPath, "utf8");
91
+ }
92
+ catch (error) {
93
+ const code = recordedReadCode(error);
94
+ if (code === undefined)
95
+ throw readFailure(targetPath, error);
96
+ recordOnce(undetermined, unreadableNote(targetPath, code));
97
+ return { relPath, status: "divergent", keyDiffs: undefined };
40
98
  }
41
- const targetContent = readFileSync(targetPath, "utf8");
42
99
  if (isKeyLevelJsonFile(relPath)) {
43
100
  const keyDiffs = compareJsonKeys(baselineContent, targetContent);
44
101
  if (keyDiffs !== undefined) {
@@ -56,22 +113,43 @@ function compareFile(relPath, baselineContent, targetPath) {
56
113
  keyDiffs: undefined,
57
114
  };
58
115
  }
59
- function walkTemplate(root, currentDir, targetDir, tokens, results) {
116
+ function walkTemplate(root, currentDir, targetDir, tokens, results, undetermined) {
60
117
  for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
61
118
  const sourcePath = join(currentDir, entry.name);
62
119
  const relPath = restoreDotfilePath(applyTokens(relative(root, sourcePath), tokens));
63
120
  if (entry.isDirectory()) {
64
- walkTemplate(root, sourcePath, targetDir, tokens, results);
121
+ walkTemplate(root, sourcePath, targetDir, tokens, results, undetermined);
65
122
  continue;
66
123
  }
67
124
  const baselineContent = applyTokens(readFileSync(sourcePath, "utf8"), tokens);
68
- results.push(compareFile(relPath, baselineContent, join(targetDir, relPath)));
125
+ results.push(compareFile(relPath, baselineContent, join(targetDir, relPath), targetDir, undetermined));
69
126
  }
70
127
  }
71
- /** Compares every file `templates/core` would emit against what `targetDir` already has. */
72
- export function planConflicts(templateRoot, targetDir, tokens) {
128
+ /**
129
+ * Compares every file `templates/core` (or a pack's `files/`) would emit
130
+ * against what `targetDir` already has. A target path this process cannot
131
+ * reach (`EACCES`/`EPERM`/`ELOOP`) is never reported `absent`: it is
132
+ * `divergent`, and a note naming the errno is appended to `undetermined`
133
+ * once (adopt mode passes the survey's own list, so the report shows it) --
134
+ * naming the enclosing directory for a permission failure on the `stat`, the
135
+ * path itself for a symlink loop, or for a permission failure or a
136
+ * directory-where-a-file-was-expected (`EISDIR`) on the read. A dangling
137
+ * symlink at the target path is `divergent` too, recorded naming the path;
138
+ * so is a dangling symlink at one of its ancestors below `targetDir`,
139
+ * recorded once naming that ancestor. A regular file where an enclosing
140
+ * directory should be (`ENOTDIR`) is `divergent`, recorded once naming that
141
+ * blocking ancestor. A genuinely missing path is `absent`; any other errno
142
+ * throws.
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * const undetermined: string[] = [];
147
+ * const conflicts = planConflicts(templateRoot, targetDir, tokens, undetermined);
148
+ * ```
149
+ */
150
+ export function planConflicts(templateRoot, targetDir, tokens, undetermined = []) {
73
151
  const results = [];
74
- walkTemplate(templateRoot, templateRoot, targetDir, tokens, results);
152
+ walkTemplate(templateRoot, templateRoot, targetDir, tokens, results, undetermined);
75
153
  return results;
76
154
  }
77
155
  //# sourceMappingURL=conflicts.js.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The skill's entry file: the one payload file that lives under the plugin
3
+ * source's `skills/customize/` (every other one lives under its `src/`), and
4
+ * the one whose presence makes Claude Code load the skill -- which is why
5
+ * {@link CUSTOMIZE_SKILL_WRITE_ORDER} writes it last.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { CUSTOMIZE_SKILL_ENTRY_FILE } from "./customize-paths.js";
10
+ *
11
+ * CUSTOMIZE_SKILL_ENTRY_FILE; // "SKILL.md"
12
+ * ```
13
+ */
14
+ export declare const CUSTOMIZE_SKILL_ENTRY_FILE = "SKILL.md";
15
+ /**
16
+ * Every payload file's name inside an installed copy of the skill.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import { CUSTOMIZE_SKILL_FILE_NAMES } from "./customize-paths.js";
21
+ *
22
+ * CUSTOMIZE_SKILL_FILE_NAMES.includes("SKILL.md"); // true
23
+ * ```
24
+ */
25
+ export declare const CUSTOMIZE_SKILL_FILE_NAMES: readonly ["SKILL.md", "kind-facet-map.ts", "domain-map.ts", "pack-map.ts", "plugin-map.ts"];
26
+ /**
27
+ * The order an install writes the payload in: every backing-data file
28
+ * first, {@link CUSTOMIZE_SKILL_ENTRY_FILE} last. A run that fails part-way
29
+ * (and whose rollback cannot remove everything) therefore never leaves a
30
+ * loadable `SKILL.md` beside missing or stale data files -- given the
31
+ * precondition that no `SKILL.md` already sits at the destination. Wherever
32
+ * an install replaces existing entries -- fresh mode's
33
+ * `.claude/skills/customize/` (a `--force` re-run over an earlier install)
34
+ * and the CLI-owned `.groundwork/customize/` -- `plugin.ts` establishes that
35
+ * precondition by removing any existing `SKILL.md` entry before the first
36
+ * data file is rewritten. Adopt mode's additive `.claude/skills/customize/`
37
+ * install only ever writes there when no `SKILL.md` entry exists.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * import { CUSTOMIZE_SKILL_WRITE_ORDER } from "./customize-paths.js";
42
+ *
43
+ * CUSTOMIZE_SKILL_WRITE_ORDER.at(-1); // "SKILL.md"
44
+ * ```
45
+ */
46
+ export declare const CUSTOMIZE_SKILL_WRITE_ORDER: readonly ["kind-facet-map.ts", "domain-map.ts", "pack-map.ts", "plugin-map.ts", "SKILL.md"];
47
+ /**
48
+ * The fresh-mode destination -- and adopt mode's, when nothing is there yet
49
+ * or an earlier install was interrupted before its `SKILL.md` -- as path
50
+ * segments relative to the project root.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * import { join } from "node:path";
55
+ * import { CLAUDE_DEST_SEGMENTS } from "./customize-paths.js";
56
+ *
57
+ * join(...CLAUDE_DEST_SEGMENTS); // ".claude/skills/customize"
58
+ * ```
59
+ */
60
+ export declare const CLAUDE_DEST_SEGMENTS: readonly [".claude", "skills", "customize"];
61
+ /**
62
+ * The adopt-mode fallback destination, as path segments relative to the
63
+ * project root. Used whenever {@link CLAUDE_DEST_SEGMENTS} cannot be written
64
+ * additively: a project-owned entry under any of the skill's payload names
65
+ * that is not this CLI's current copy (a differing file, a symlink, a
66
+ * directory, a `SKILL.md` without its data), or a component of
67
+ * `.claude/skills/customize` that is a symlink or not a directory.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * import { join } from "node:path";
72
+ * import { GROUNDWORK_DEST_SEGMENTS } from "./customize-paths.js";
73
+ *
74
+ * join(...GROUNDWORK_DEST_SEGMENTS); // ".groundwork/customize"
75
+ * ```
76
+ */
77
+ export declare const GROUNDWORK_DEST_SEGMENTS: readonly [".groundwork", "customize"];
78
+ /**
79
+ * Every path, relative to the project root, that either install location
80
+ * could write -- so a caller can scope-check them before anything is
81
+ * written.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * import { plannedCustomizeSkillPaths } from "./customize-paths.js";
86
+ *
87
+ * plannedCustomizeSkillPaths();
88
+ * // [".claude/skills/customize/SKILL.md", ..., ".groundwork/customize/plugin-map.ts"]
89
+ * ```
90
+ */
91
+ export declare function plannedCustomizeSkillPaths(): readonly string[];
92
+ //# sourceMappingURL=customize-paths.d.ts.map
@@ -0,0 +1,115 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Where the `/customize` skill install (`plugin.ts`) writes, owned in one
5
+ * place so `main.ts`'s adopt-mode write-scope check can validate the planned
6
+ * destinations before `plugin.ts` writes anything. Pure data, no I/O.
7
+ */
8
+ import { join } from "node:path";
9
+ /**
10
+ * The skill's entry file: the one payload file that lives under the plugin
11
+ * source's `skills/customize/` (every other one lives under its `src/`), and
12
+ * the one whose presence makes Claude Code load the skill -- which is why
13
+ * {@link CUSTOMIZE_SKILL_WRITE_ORDER} writes it last.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { CUSTOMIZE_SKILL_ENTRY_FILE } from "./customize-paths.js";
18
+ *
19
+ * CUSTOMIZE_SKILL_ENTRY_FILE; // "SKILL.md"
20
+ * ```
21
+ */
22
+ export const CUSTOMIZE_SKILL_ENTRY_FILE = "SKILL.md";
23
+ /** The backing-data payload files, all sourced from the plugin's `src/`. */
24
+ const CUSTOMIZE_SKILL_DATA_FILE_NAMES = [
25
+ "kind-facet-map.ts",
26
+ "domain-map.ts",
27
+ "pack-map.ts",
28
+ "plugin-map.ts",
29
+ ];
30
+ /**
31
+ * Every payload file's name inside an installed copy of the skill.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * import { CUSTOMIZE_SKILL_FILE_NAMES } from "./customize-paths.js";
36
+ *
37
+ * CUSTOMIZE_SKILL_FILE_NAMES.includes("SKILL.md"); // true
38
+ * ```
39
+ */
40
+ export const CUSTOMIZE_SKILL_FILE_NAMES = [
41
+ CUSTOMIZE_SKILL_ENTRY_FILE,
42
+ ...CUSTOMIZE_SKILL_DATA_FILE_NAMES,
43
+ ];
44
+ /**
45
+ * The order an install writes the payload in: every backing-data file
46
+ * first, {@link CUSTOMIZE_SKILL_ENTRY_FILE} last. A run that fails part-way
47
+ * (and whose rollback cannot remove everything) therefore never leaves a
48
+ * loadable `SKILL.md` beside missing or stale data files -- given the
49
+ * precondition that no `SKILL.md` already sits at the destination. Wherever
50
+ * an install replaces existing entries -- fresh mode's
51
+ * `.claude/skills/customize/` (a `--force` re-run over an earlier install)
52
+ * and the CLI-owned `.groundwork/customize/` -- `plugin.ts` establishes that
53
+ * precondition by removing any existing `SKILL.md` entry before the first
54
+ * data file is rewritten. Adopt mode's additive `.claude/skills/customize/`
55
+ * install only ever writes there when no `SKILL.md` entry exists.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * import { CUSTOMIZE_SKILL_WRITE_ORDER } from "./customize-paths.js";
60
+ *
61
+ * CUSTOMIZE_SKILL_WRITE_ORDER.at(-1); // "SKILL.md"
62
+ * ```
63
+ */
64
+ export const CUSTOMIZE_SKILL_WRITE_ORDER = [
65
+ ...CUSTOMIZE_SKILL_DATA_FILE_NAMES,
66
+ CUSTOMIZE_SKILL_ENTRY_FILE,
67
+ ];
68
+ /**
69
+ * The fresh-mode destination -- and adopt mode's, when nothing is there yet
70
+ * or an earlier install was interrupted before its `SKILL.md` -- as path
71
+ * segments relative to the project root.
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * import { join } from "node:path";
76
+ * import { CLAUDE_DEST_SEGMENTS } from "./customize-paths.js";
77
+ *
78
+ * join(...CLAUDE_DEST_SEGMENTS); // ".claude/skills/customize"
79
+ * ```
80
+ */
81
+ export const CLAUDE_DEST_SEGMENTS = [".claude", "skills", "customize"];
82
+ /**
83
+ * The adopt-mode fallback destination, as path segments relative to the
84
+ * project root. Used whenever {@link CLAUDE_DEST_SEGMENTS} cannot be written
85
+ * additively: a project-owned entry under any of the skill's payload names
86
+ * that is not this CLI's current copy (a differing file, a symlink, a
87
+ * directory, a `SKILL.md` without its data), or a component of
88
+ * `.claude/skills/customize` that is a symlink or not a directory.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * import { join } from "node:path";
93
+ * import { GROUNDWORK_DEST_SEGMENTS } from "./customize-paths.js";
94
+ *
95
+ * join(...GROUNDWORK_DEST_SEGMENTS); // ".groundwork/customize"
96
+ * ```
97
+ */
98
+ export const GROUNDWORK_DEST_SEGMENTS = [".groundwork", "customize"];
99
+ /**
100
+ * Every path, relative to the project root, that either install location
101
+ * could write -- so a caller can scope-check them before anything is
102
+ * written.
103
+ *
104
+ * @example
105
+ * ```ts
106
+ * import { plannedCustomizeSkillPaths } from "./customize-paths.js";
107
+ *
108
+ * plannedCustomizeSkillPaths();
109
+ * // [".claude/skills/customize/SKILL.md", ..., ".groundwork/customize/plugin-map.ts"]
110
+ * ```
111
+ */
112
+ export function plannedCustomizeSkillPaths() {
113
+ return [CLAUDE_DEST_SEGMENTS, GROUNDWORK_DEST_SEGMENTS].flatMap((segments) => CUSTOMIZE_SKILL_FILE_NAMES.map((name) => join(...segments, name)));
114
+ }
115
+ //# sourceMappingURL=customize-paths.js.map
package/dist/emit.d.ts CHANGED
@@ -1,7 +1,56 @@
1
1
  import type { TokenTable } from "./tokens.js";
2
+ /**
3
+ * True when `target`, once resolved, is `root` itself or lies inside it.
4
+ * Exact match or proper prefix plus a separator -- never a bare
5
+ * `startsWith`, which would wrongly accept a sibling path that merely shares
6
+ * `root`'s name as a prefix (e.g. `/foo/bar-evil` against `/foo/bar`).
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * isPathContained("/foo/bar/x", "/foo/bar"); // true
11
+ * isPathContained("/foo/bar", "/foo/bar"); // true
12
+ * isPathContained("/foo/bar-evil/x", "/foo/bar"); // false
13
+ * ```
14
+ */
15
+ export declare function isPathContained(target: string, root: string): boolean;
2
16
  export interface EmitResult {
3
17
  filesWritten: string[];
4
18
  }
5
- /** Recursively copies `sourceDir` into `targetDir`, applying `tokens`. */
19
+ /**
20
+ * Refuses, before anything is written, when any destination that emitting
21
+ * each of `sourceDirs` into `targetDir` would touch is a symlink (dangling
22
+ * or not) or a non-directory where a directory is needed. `targetDir`
23
+ * itself and its ancestors are not checked -- only paths strictly below it.
24
+ * The error names the offending path and ends with fresh mode's single
25
+ * `--fresh --force` retry instruction; nothing is written either way.
26
+ *
27
+ * @throws `Error` naming the first offending destination path.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * import { assertSafeEmitDestinations, emitTemplate } from "./emit.js";
32
+ *
33
+ * // Validate the baseline AND a pack's payload before writing either.
34
+ * assertSafeEmitDestinations(["/tpl/core", "/tpl/packs/x/files"], "/work/app", {});
35
+ * emitTemplate("/tpl/core", "/work/app", {});
36
+ * ```
37
+ */
38
+ export declare function assertSafeEmitDestinations(sourceDirs: readonly string[], targetDir: string, tokens: TokenTable): void;
39
+ /**
40
+ * Recursively copies `sourceDir` into `targetDir`, applying `tokens`. Every
41
+ * destination is validated first (see {@link assertSafeEmitDestinations}),
42
+ * so a refusal writes nothing.
43
+ *
44
+ * @throws `Error` naming a symlinked or non-directory destination component.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * import { emitTemplate } from "./emit.js";
49
+ *
50
+ * const { filesWritten } = emitTemplate("/tpl/core", "/work/app", {
51
+ * PROJECT_NAME: "app",
52
+ * });
53
+ * ```
54
+ */
6
55
  export declare function emitTemplate(sourceDir: string, targetDir: string, tokens: TokenTable): EmitResult;
7
56
  //# sourceMappingURL=emit.d.ts.map
package/dist/emit.js CHANGED
@@ -1,35 +1,142 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  /**
2
4
  * Copies `templates/core` into a target directory, applying token
3
5
  * substitution to both file contents and path segments (so a token in a
4
6
  * directory or file NAME, not just its content, is honored). A published
5
7
  * tarball stores `.gitignore`/`.npmrc` as `_gitignore`/`_npmrc` (npm strips
6
8
  * the real names); they are restored here -- see `assets.ts`.
9
+ *
10
+ * Every destination path is planned and `lstat`-checked before the first
11
+ * write ({@link assertSafeEmitDestinations}), so a symlink or a misplaced
12
+ * file below the target refuses the whole emit rather than redirecting a
13
+ * write outside it.
7
14
  */
8
15
  import { readdirSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, } from "node:fs";
9
- import { join, relative, dirname, extname } from "node:path";
16
+ import assert from "node:assert/strict";
17
+ import { join, relative, dirname, extname, resolve, sep } from "node:path";
10
18
  import { restoreDotfilePath } from "./assets.js";
19
+ import { assertDirectoryComponent, assertFileDestination, FRESH_SYMLINK_ADVICE, } from "./fs-guard.js";
11
20
  import { applyTokens } from "./tokens.js";
12
21
  // Extensions copied byte-for-byte, never text-decoded -- token substitution
13
22
  // only makes sense for text content.
14
23
  const BINARY_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".ico"]);
15
- /** Recursively copies `sourceDir` into `targetDir`, applying `tokens`. */
24
+ /**
25
+ * True when `target`, once resolved, is `root` itself or lies inside it.
26
+ * Exact match or proper prefix plus a separator -- never a bare
27
+ * `startsWith`, which would wrongly accept a sibling path that merely shares
28
+ * `root`'s name as a prefix (e.g. `/foo/bar-evil` against `/foo/bar`).
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * isPathContained("/foo/bar/x", "/foo/bar"); // true
33
+ * isPathContained("/foo/bar", "/foo/bar"); // true
34
+ * isPathContained("/foo/bar-evil/x", "/foo/bar"); // false
35
+ * ```
36
+ */
37
+ export function isPathContained(target, root) {
38
+ const resolvedTarget = resolve(target);
39
+ const resolvedRoot = resolve(root);
40
+ return (resolvedTarget === resolvedRoot ||
41
+ resolvedTarget.startsWith(resolvedRoot + sep));
42
+ }
43
+ /** Every destination `sourceDir` would emit under `targetRoot`, in walk order (a directory before its contents). */
44
+ function planEmit(sourceDir, targetRoot, tokens) {
45
+ const entries = [];
46
+ const visit = (currentSourceDir) => {
47
+ for (const entry of readdirSync(currentSourceDir, {
48
+ withFileTypes: true,
49
+ })) {
50
+ const sourcePath = join(currentSourceDir, entry.name);
51
+ const relPath = restoreDotfilePath(applyTokens(relative(sourceDir, sourcePath), tokens));
52
+ const targetPath = join(targetRoot, relPath);
53
+ // CWE-22 invariant (docs/assurance-case.md): a token value substituted
54
+ // into a path segment must never walk the write out of `targetRoot`.
55
+ assert.ok(isPathContained(targetPath, targetRoot), `emitTemplate: target path ${resolve(targetPath)} escapes ${resolve(targetRoot)}`);
56
+ const isDirectory = entry.isDirectory();
57
+ entries.push({ sourcePath, relPath, isDirectory });
58
+ if (isDirectory)
59
+ visit(sourcePath);
60
+ }
61
+ };
62
+ visit(sourceDir);
63
+ return entries;
64
+ }
65
+ /**
66
+ * `lstat`-checks every destination of `entries` under `targetRoot`: each
67
+ * directory component strictly below `targetRoot` must be missing or a real
68
+ * directory, and each destination file must not be a symlink. Ancestors are
69
+ * checked before descendants, so the topmost offending path is the one named.
70
+ */
71
+ function assertPlanSafe(targetRoot, entries) {
72
+ const checked = new Set();
73
+ for (const { relPath, isDirectory } of entries) {
74
+ const segments = relPath.split(sep);
75
+ const dirCount = isDirectory ? segments.length : segments.length - 1;
76
+ for (let depth = 1; depth <= dirCount; depth++) {
77
+ const dirPath = join(targetRoot, ...segments.slice(0, depth));
78
+ if (checked.has(dirPath))
79
+ continue;
80
+ checked.add(dirPath);
81
+ assertDirectoryComponent(dirPath, FRESH_SYMLINK_ADVICE);
82
+ }
83
+ if (!isDirectory) {
84
+ assertFileDestination(join(targetRoot, relPath), FRESH_SYMLINK_ADVICE);
85
+ }
86
+ }
87
+ }
88
+ /**
89
+ * Refuses, before anything is written, when any destination that emitting
90
+ * each of `sourceDirs` into `targetDir` would touch is a symlink (dangling
91
+ * or not) or a non-directory where a directory is needed. `targetDir`
92
+ * itself and its ancestors are not checked -- only paths strictly below it.
93
+ * The error names the offending path and ends with fresh mode's single
94
+ * `--fresh --force` retry instruction; nothing is written either way.
95
+ *
96
+ * @throws `Error` naming the first offending destination path.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * import { assertSafeEmitDestinations, emitTemplate } from "./emit.js";
101
+ *
102
+ * // Validate the baseline AND a pack's payload before writing either.
103
+ * assertSafeEmitDestinations(["/tpl/core", "/tpl/packs/x/files"], "/work/app", {});
104
+ * emitTemplate("/tpl/core", "/work/app", {});
105
+ * ```
106
+ */
107
+ export function assertSafeEmitDestinations(sourceDirs, targetDir, tokens) {
108
+ for (const sourceDir of sourceDirs) {
109
+ assertPlanSafe(targetDir, planEmit(sourceDir, targetDir, tokens));
110
+ }
111
+ }
112
+ /**
113
+ * Recursively copies `sourceDir` into `targetDir`, applying `tokens`. Every
114
+ * destination is validated first (see {@link assertSafeEmitDestinations}),
115
+ * so a refusal writes nothing.
116
+ *
117
+ * @throws `Error` naming a symlinked or non-directory destination component.
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * import { emitTemplate } from "./emit.js";
122
+ *
123
+ * const { filesWritten } = emitTemplate("/tpl/core", "/work/app", {
124
+ * PROJECT_NAME: "app",
125
+ * });
126
+ * ```
127
+ */
16
128
  export function emitTemplate(sourceDir, targetDir, tokens) {
129
+ const entries = planEmit(sourceDir, targetDir, tokens);
130
+ assertPlanSafe(targetDir, entries);
17
131
  const filesWritten = [];
18
- walk(sourceDir, sourceDir, targetDir, tokens, filesWritten);
19
- return { filesWritten };
20
- }
21
- function walk(root, currentSourceDir, targetRoot, tokens, filesWritten) {
22
- for (const entry of readdirSync(currentSourceDir, { withFileTypes: true })) {
23
- const sourcePath = join(currentSourceDir, entry.name);
24
- const relPath = restoreDotfilePath(applyTokens(relative(root, sourcePath), tokens));
25
- const targetPath = join(targetRoot, relPath);
26
- if (entry.isDirectory()) {
132
+ for (const { sourcePath, relPath, isDirectory } of entries) {
133
+ const targetPath = join(targetDir, relPath);
134
+ if (isDirectory) {
27
135
  mkdirSync(targetPath, { recursive: true });
28
- walk(root, sourcePath, targetRoot, tokens, filesWritten);
29
136
  continue;
30
137
  }
31
138
  mkdirSync(dirname(targetPath), { recursive: true });
32
- if (BINARY_EXTENSIONS.has(extname(entry.name))) {
139
+ if (BINARY_EXTENSIONS.has(extname(sourcePath))) {
33
140
  copyFileSync(sourcePath, targetPath);
34
141
  }
35
142
  else {
@@ -38,5 +145,6 @@ function walk(root, currentSourceDir, targetRoot, tokens, filesWritten) {
38
145
  }
39
146
  filesWritten.push(relPath);
40
147
  }
148
+ return { filesWritten };
41
149
  }
42
150
  //# sourceMappingURL=emit.js.map
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Reports a fatal `error`: sets the exit code first -- 2 when
3
+ * `isUsageError(error)` says it is a usage error, 1 otherwise, including
4
+ * when `isUsageError` itself throws -- then prints the report through
5
+ * `io.print`. For a usage error the report is exactly
6
+ * `formatErrorChain(error)`. For any other failure it is
7
+ * `formatFatalError(error, { withName: true, withStack: debug })` (both in
8
+ * `./format-error.ts`): the same chain, its top line prefixed `<name>: `
9
+ * when `error` is an `Error` with a readable, non-blank `name` other than
10
+ * `Error` and a non-blank message (`TypeError: boom`), and, when `debug` is
11
+ * set, the stack frames appended as `formatFatalError` describes
12
+ * (control-escaped, header dropped, capped at its `MAX_STACK_LINES`). Only
13
+ * the top error's own stack is printed: a `cause`'s stack is deliberately
14
+ * omitted, since the chain already names every cause. If that print
15
+ * throws, the fallbacks never touch `io.print` again (it is the channel
16
+ * that just failed -- in the CLI, the one that paints colour), so `print`
17
+ * is called exactly once: `io.printRaw` prints the same report, so the
18
+ * cause chain (and any name prefix and stack) survives a failing `print`;
19
+ * if that throws, `io.printRaw` prints `String(error.stack ?? error)`,
20
+ * control-escaped the same way the chain is (`escapeControls` in
21
+ * `./format-error.ts`); if that throws, `io.printRaw` prints
22
+ * `[unprintable error]`; if even that throws, gives up silently, the exit
23
+ * code already set. Never throws. A `setExitCode` that throws is swallowed
24
+ * and not retried -- the report is still attempted -- but it is not
25
+ * otherwise handled: assigning Node's `process.exitCode` cannot throw, so
26
+ * the CLI never hits that case.
27
+ *
28
+ * @param error - The value the CLI's `main()` threw.
29
+ * @param io - Where the exit code and the report go: `print` for the
30
+ * formatted chain, `printRaw` -- a plain write with nothing in it that can
31
+ * fail the way `print` did -- for all three fallbacks.
32
+ * @param isUsageError - Whether `error` is a bad invocation rather than a
33
+ * runtime failure.
34
+ * @param debug - Append the top error's own stack (never a cause's) to a
35
+ * runtime failure's report (never to a usage error's); defaults to `false`.
36
+ * The CLI sets it when `M3L_DEBUG` is non-empty.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * import { CliUsageError, main } from "./main.js";
41
+ * import { handleFatal } from "./fatal.js";
42
+ *
43
+ * try {
44
+ * main(process.argv.slice(2));
45
+ * } catch (error) {
46
+ * handleFatal(
47
+ * error,
48
+ * {
49
+ * setExitCode: (code) => {
50
+ * process.exitCode = code;
51
+ * },
52
+ * print: (text) => {
53
+ * console.error(text);
54
+ * },
55
+ * printRaw: (text) => {
56
+ * console.error(text);
57
+ * },
58
+ * },
59
+ * (e) => e instanceof CliUsageError,
60
+ * (process.env["M3L_DEBUG"] ?? "") !== "",
61
+ * );
62
+ * }
63
+ * ```
64
+ */
65
+ export declare function handleFatal(error: unknown, io: {
66
+ setExitCode(code: number): void;
67
+ print(text: string): void;
68
+ printRaw(text: string): void;
69
+ }, isUsageError: (error: unknown) => boolean, debug?: boolean): void;
70
+ //# sourceMappingURL=fatal.d.ts.map