@monte3l/groundwork 0.1.0-next.1 → 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.
- package/README.md +16 -8
- package/bin/m3l-groundwork.mjs +36 -2
- package/dist/assets.d.ts +10 -0
- package/dist/assets.js +14 -0
- package/dist/baseline-stage.d.ts +173 -0
- package/dist/baseline-stage.js +215 -0
- package/dist/caps.d.ts +4 -1
- package/dist/caps.js +17 -3
- package/dist/conflicts.d.ts +23 -2
- package/dist/conflicts.js +89 -11
- package/dist/customize-paths.d.ts +92 -0
- package/dist/customize-paths.js +115 -0
- package/dist/emit.d.ts +50 -1
- package/dist/emit.js +121 -13
- package/dist/fatal.d.ts +70 -0
- package/dist/fatal.js +132 -0
- package/dist/format-error.d.ts +113 -0
- package/dist/format-error.js +553 -0
- package/dist/fs-guard.d.ts +142 -0
- package/dist/fs-guard.js +222 -0
- package/dist/git.js +10 -1
- package/dist/harness/conformance.js +2 -0
- package/dist/harness/frontmatter.js +2 -13
- package/dist/harness/grade.js +37 -8
- package/dist/harness/rules.d.ts +20 -3
- package/dist/harness/rules.js +108 -6
- package/dist/harness/types.d.ts +13 -0
- package/dist/harness/types.js +3 -0
- package/dist/inventory.d.ts +77 -3
- package/dist/inventory.js +103 -12
- package/dist/jsonc.d.ts +49 -2
- package/dist/jsonc.js +140 -7
- package/dist/main.d.ts +62 -3
- package/dist/main.js +538 -67
- package/dist/merge-json.d.ts +47 -4
- package/dist/merge-json.js +120 -8
- package/dist/mode.js +12 -2
- package/dist/pack-stage.d.ts +210 -0
- package/dist/pack-stage.js +287 -0
- package/dist/packs.d.ts +19 -13
- package/dist/packs.js +231 -28
- package/dist/palette.d.ts +23 -0
- package/dist/palette.js +22 -0
- package/dist/plugin.d.ts +122 -6
- package/dist/plugin.js +687 -47
- package/dist/report.d.ts +21 -2
- package/dist/report.js +93 -5
- package/dist/staging.d.ts +176 -0
- package/dist/staging.js +375 -0
- package/dist/survey/fs-walk.d.ts +34 -2
- package/dist/survey/fs-walk.js +70 -5
- package/dist/survey/internal/blocked-path.d.ts +27 -0
- package/dist/survey/internal/blocked-path.js +129 -0
- package/dist/survey/internal/package-json.d.ts +13 -0
- package/dist/survey/internal/package-json.js +37 -0
- package/dist/survey/internal/read-guard.d.ts +178 -0
- package/dist/survey/internal/read-guard.js +276 -0
- package/dist/survey/survey-docs.d.ts +17 -2
- package/dist/survey/survey-docs.js +61 -21
- package/dist/survey/survey-harness.d.ts +20 -2
- package/dist/survey/survey-harness.js +87 -46
- package/dist/survey/survey-shape.d.ts +17 -2
- package/dist/survey/survey-shape.js +60 -46
- package/dist/survey/survey-toolchain.d.ts +16 -1
- package/dist/survey/survey-toolchain.js +69 -66
- package/dist/survey/survey.js +6 -4
- package/dist/survey/types.d.ts +79 -0
- package/dist/survey/types.js +2 -6
- package/dist/term.d.ts +80 -0
- package/dist/term.js +145 -0
- package/dist/tokens.js +2 -0
- package/dist/toolchain/conformance.js +2 -0
- package/dist/toolchain/grade.js +26 -8
- package/dist/toolchain/rules.d.ts +13 -4
- package/dist/toolchain/rules.js +16 -0
- package/dist/toolchain/tsconfig-chain.d.ts +2 -0
- package/dist/toolchain/tsconfig-chain.js +32 -8
- package/dist/toolchain/types.d.ts +16 -4
- package/dist/toolchain/types.js +3 -0
- package/package.json +4 -3
- package/plugin/skills/customize/SKILL.md +292 -18
- package/plugin/src/domain-map.ts +39 -14
- package/plugin/src/index.ts +5 -1
- package/plugin/src/kind-facet-map.ts +25 -8
- package/plugin/src/pack-map.ts +147 -25
- package/plugin/src/plugin-map.ts +236 -0
- package/templates/core/.claude/agents/Explore.md +0 -1
- package/templates/core/.claude/agents/code-implementer.md +4 -4
- package/templates/core/.claude/agents/code-reviewer.md +4 -4
- package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
- package/templates/core/.claude/agents/test-author.md +7 -5
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
- package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
- package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
- package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
- package/templates/core/.claude/rules/agent-dispatch.md +7 -0
- package/templates/core/.claude/rules/tests.md +2 -2
- package/templates/core/.claude/settings.json +5 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
- package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
- package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
- package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
- package/templates/core/.github/dependabot.yml +18 -0
- package/templates/core/.github/workflows/ci.yml +15 -15
- package/templates/core/.github/workflows/dependency-review.yml +2 -2
- package/templates/core/.github/workflows/security-audit.yml +10 -4
- package/templates/core/.prettierignore +4 -0
- package/templates/core/CLAUDE.md +53 -3
- package/templates/core/README.md +30 -10
- package/templates/core/_gitignore +17 -0
- package/templates/core/bin/check-exports.mjs +11 -5
- package/templates/core/bin/lib/agent-roster.mjs +1 -1
- package/templates/core/bin/lib/frontmatter.mjs +4 -2
- package/templates/core/bin/lib/harness-rules.mjs +169 -20
- package/templates/core/bin/lib/protected-paths.mjs +93 -5
- package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
- package/templates/core/bin/lib/verify-steps.mjs +29 -11
- package/templates/core/bin/verify.mjs +12 -0
- package/templates/core/eslint.config.js +5 -0
- package/templates/core/package.json +7 -7
- package/templates/core/vitest.config.ts +8 -1
- package/templates/packs/README.md +35 -24
- package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
- package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
- package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
- package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
- package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
- package/templates/packs/github/pack.json +19 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
- package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
- package/templates/packs/harness-extras/pack.json +18 -13
- package/templates/packs/publishing/files/.changeset/README.md +25 -0
- package/templates/packs/publishing/files/.changeset/config.json +7 -0
- package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
- package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
- package/templates/packs/publishing/files/REUSE.toml +28 -0
- package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
- package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
- package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
- package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
- package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
- package/templates/packs/publishing/pack.json +35 -0
- package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
- package/templates/packs/quality/pack.json +29 -0
- package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
- package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
- package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
- package/templates/packs/supply-chain/pack.json +19 -0
- package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
- package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
- package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
- package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
- package/templates/packs/worktrees/files/.worktreeinclude +11 -0
- package/templates/packs/worktrees/pack.json +64 -0
- package/templates/packs/statusline/pack.json +0 -31
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
- /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
- /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* Adopt mode's staging of every pack under `templates/packs/`: each pack's
|
|
5
|
+
* manifest and `files/` tree are copied into `.groundwork/packs/<name>/`,
|
|
6
|
+
* every file under an inert `<path>.staged` name, so `/customize`'s Step 0
|
|
7
|
+
* can install a pack from a self-contained copy -- after confirmation --
|
|
8
|
+
* without any toolchain globbing the project ever picking a staged file up.
|
|
9
|
+
* All packs are written together to a temporary sibling directory and
|
|
10
|
+
* swapped in by rename, so `packs/` is never half-written -- the same
|
|
11
|
+
* lifecycle `baseline-stage.ts` uses (both build on `staging.ts`).
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, statSync } from "node:fs";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { toPosixPath } from "./assets.js";
|
|
16
|
+
import { isPathContained } from "./emit.js";
|
|
17
|
+
import { STAGED_SUFFIX, assertPlanBuiltFor, clearStaging, collectTemplateFiles, findStagedPathCollision, prepareStaging, stageAtomically, stagedNameFor, writeStagedBytes, } from "./staging.js";
|
|
18
|
+
/** The staging directory's name under `.groundwork/`. */
|
|
19
|
+
const PACKS_DIR_NAME = "packs";
|
|
20
|
+
/** Each staged pack's subdirectory holding its `files/` tree. */
|
|
21
|
+
const PACK_FILES_DIR = "files";
|
|
22
|
+
/**
|
|
23
|
+
* The project-relative directory every pack is staged under; a pack's own
|
|
24
|
+
* staging directory is `${STAGED_PACKS_DIR}/<name>`.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* const dir = `${STAGED_PACKS_DIR}/quality`; // ".groundwork/packs/quality"
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export const STAGED_PACKS_DIR = `.groundwork/${PACKS_DIR_NAME}`;
|
|
32
|
+
/**
|
|
33
|
+
* The install name of a pack's manifest; it is staged as
|
|
34
|
+
* `pack.json` + `.staged` beside the pack's `files/` directory.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* const staged = `${STAGED_PACK_MANIFEST}.staged`; // "pack.json.staged"
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export const STAGED_PACK_MANIFEST = "pack.json";
|
|
42
|
+
function assertSingleSegmentName(name) {
|
|
43
|
+
if (name === "" || name === "." || name === ".." || /[\\/]/.test(name)) {
|
|
44
|
+
throw new Error(`stagePacks: pack name ${JSON.stringify(name)} is not a single directory name`);
|
|
45
|
+
}
|
|
46
|
+
if (name.includes(":")) {
|
|
47
|
+
throw new Error(`stagePacks: pack name ${JSON.stringify(name)} contains ":", which Windows reads as a drive letter or an alternate data stream`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether `filesDir` is a directory. A missing path answers `false`; any
|
|
52
|
+
* other `statSync` failure (a permission error, say) becomes a plan-time
|
|
53
|
+
* `Error` naming the pack and the path, with the failure as `cause` -- seen
|
|
54
|
+
* before anything is written, it is a template defect or a permission
|
|
55
|
+
* problem to fix, not an "incomplete, re-run" staging failure.
|
|
56
|
+
*/
|
|
57
|
+
function isFilesDir(name, filesDir) {
|
|
58
|
+
try {
|
|
59
|
+
return (statSync(filesDir, { throwIfNoEntry: false })?.isDirectory() === true);
|
|
60
|
+
}
|
|
61
|
+
catch (cause) {
|
|
62
|
+
throw new Error(`stagePacks: could not inspect pack "${name}"'s files directory ${filesDir}`, { cause });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function serializeManifest(name, manifest) {
|
|
66
|
+
// JSON.stringify is typed string but yields undefined for some inputs
|
|
67
|
+
// (a toJSON returning undefined); never write or hash that as text.
|
|
68
|
+
const text = JSON.stringify(manifest, null, 2);
|
|
69
|
+
if (typeof text !== "string") {
|
|
70
|
+
throw new Error(`stagePacks: pack "${name}"'s manifest is not JSON`);
|
|
71
|
+
}
|
|
72
|
+
return Buffer.from(`${text}\n`);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Validates and projects every pack before anything is touched. These
|
|
76
|
+
* defects are refused, each with its own `Error` (no `cause`): a pack name
|
|
77
|
+
* that is not a single directory name, contains `:`, or is shared by two
|
|
78
|
+
* packs (also when the two differ only by letter case or Unicode
|
|
79
|
+
* normalization); a missing
|
|
80
|
+
* `filesDir`; two files in one pack installing to the same path; an install
|
|
81
|
+
* path containing `:` (a drive letter or alternate data stream on Windows);
|
|
82
|
+
* a tokenized install path whose staged name would land outside the pack's
|
|
83
|
+
* `files/` staging directory (CWE-22, docs/assurance-case.md); and two
|
|
84
|
+
* staged names in one pack that would land on the same file
|
|
85
|
+
* ({@link findStagedPathCollision}). A `filesDir` that cannot be inspected
|
|
86
|
+
* at all is refused with its failure as `cause` (see `isFilesDir`).
|
|
87
|
+
*/
|
|
88
|
+
function planPacks(packs, packsDir, tokens) {
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
const plan = packs.map(({ manifest, filesDir }) => {
|
|
91
|
+
const name = manifest.name;
|
|
92
|
+
assertSingleSegmentName(name);
|
|
93
|
+
if (seen.has(name)) {
|
|
94
|
+
throw new Error(`stagePacks: two packs are named "${name}"; pack names must be unique`);
|
|
95
|
+
}
|
|
96
|
+
seen.add(name);
|
|
97
|
+
if (!isFilesDir(name, filesDir)) {
|
|
98
|
+
throw new Error(`stagePacks: pack "${name}"'s files directory ${filesDir} does not exist`);
|
|
99
|
+
}
|
|
100
|
+
const stagedFilesDir = join(packsDir, name, PACK_FILES_DIR);
|
|
101
|
+
const files = [...collectTemplateFiles(filesDir, tokens)]
|
|
102
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
103
|
+
.map(([path, sourcePath]) => {
|
|
104
|
+
if (path.includes(":")) {
|
|
105
|
+
throw new Error(`stagePacks: pack "${name}"'s file ${toPosixPath(path)} contains ":", which Windows reads as a drive letter or an alternate data stream`);
|
|
106
|
+
}
|
|
107
|
+
const destPath = join(stagedFilesDir, stagedNameFor(path));
|
|
108
|
+
if (!isPathContained(destPath, stagedFilesDir)) {
|
|
109
|
+
throw new Error(`stagePacks: staged path ${resolve(destPath)} for pack "${name}"'s ${path} escapes ${resolve(stagedFilesDir)}`);
|
|
110
|
+
}
|
|
111
|
+
return { path, sourcePath };
|
|
112
|
+
});
|
|
113
|
+
const collision = findStagedPathCollision(files.map(({ path }) => toPosixPath(stagedNameFor(path))));
|
|
114
|
+
if (collision !== undefined) {
|
|
115
|
+
throw new Error(`stagePacks: two of pack "${name}"'s staged files collide: ${collision}; rename one of them under ${filesDir}`);
|
|
116
|
+
}
|
|
117
|
+
return { name, manifestBytes: serializeManifest(name, manifest), files };
|
|
118
|
+
});
|
|
119
|
+
const nameCollision = findStagedPathCollision(plan.map(({ name }) => name));
|
|
120
|
+
if (nameCollision !== undefined) {
|
|
121
|
+
throw new Error(`stagePacks: two packs' staging directories collide: ${nameCollision}; pack names must be unique ignoring case and Unicode normalization`);
|
|
122
|
+
}
|
|
123
|
+
return plan;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Validates `packs` and computes the plan {@link stagePacks} writes from:
|
|
127
|
+
* every path under `<groundworkDir>/packs/` -- each pack's
|
|
128
|
+
* `pack.json.staged` and every `files/<path>.staged` -- and each file's
|
|
129
|
+
* source, so adopt mode can scope-check `paths` before any pack is written
|
|
130
|
+
* and then hand the same plan to {@link stagePacks}. Reads the packs'
|
|
131
|
+
* source trees; writes nothing.
|
|
132
|
+
*
|
|
133
|
+
* @throws The same plan `Error`s as {@link stagePacks}.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```ts
|
|
137
|
+
* import { assertAdoptWriteScope } from "./main.js";
|
|
138
|
+
* const plan = planPackStaging(packs, groundworkDir, tokens);
|
|
139
|
+
* assertAdoptWriteScope(targetDir, plan.paths);
|
|
140
|
+
* stagePacks(packs, groundworkDir, tokens, plan);
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
export function planPackStaging(packs, groundworkDir, tokens) {
|
|
144
|
+
const packsDir = join(groundworkDir, PACKS_DIR_NAME);
|
|
145
|
+
const planned = planPacks(packs, packsDir, tokens);
|
|
146
|
+
const paths = planned.flatMap(({ name, files }) => [
|
|
147
|
+
join(packsDir, name, stagedNameFor(STAGED_PACK_MANIFEST)),
|
|
148
|
+
...files.map(({ path }) => join(packsDir, name, PACK_FILES_DIR, stagedNameFor(path))),
|
|
149
|
+
]);
|
|
150
|
+
return { groundworkDir, paths, packs: planned };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Every path {@link stagePacks} would write for `packs` -- a thin wrapper
|
|
154
|
+
* returning {@link planPackStaging}'s `paths`. Reads the packs' source
|
|
155
|
+
* trees; writes nothing.
|
|
156
|
+
*
|
|
157
|
+
* @throws The same plan `Error`s as {@link stagePacks}.
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```ts
|
|
161
|
+
* import { assertAdoptWriteScope } from "./main.js";
|
|
162
|
+
* assertAdoptWriteScope(targetDir, plannedPackStagingPaths(packs, groundworkDir, tokens));
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
export function plannedPackStagingPaths(packs, groundworkDir, tokens) {
|
|
166
|
+
return [...planPackStaging(packs, groundworkDir, tokens).paths];
|
|
167
|
+
}
|
|
168
|
+
function writePack(plan, newDir) {
|
|
169
|
+
const manifestStaged = stagedNameFor(STAGED_PACK_MANIFEST);
|
|
170
|
+
// Relative to newDir, so the containment assertion also covers the name.
|
|
171
|
+
const manifestSha256 = writeStagedBytes("stagePacks", plan.manifestBytes, newDir, join(plan.name, manifestStaged));
|
|
172
|
+
const filesDir = join(newDir, plan.name, PACK_FILES_DIR);
|
|
173
|
+
const files = plan.files.map(({ path, sourcePath }) => {
|
|
174
|
+
const stagedName = stagedNameFor(path);
|
|
175
|
+
const sha256 = writeStagedBytes("stagePacks", readFileSync(sourcePath), filesDir, stagedName);
|
|
176
|
+
return {
|
|
177
|
+
path: toPosixPath(path),
|
|
178
|
+
staged: toPosixPath(stagedName),
|
|
179
|
+
sha256,
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
return {
|
|
183
|
+
name: plan.name,
|
|
184
|
+
dir: `${STAGED_PACKS_DIR}/${plan.name}`,
|
|
185
|
+
suffix: STAGED_SUFFIX,
|
|
186
|
+
manifest: {
|
|
187
|
+
path: STAGED_PACK_MANIFEST,
|
|
188
|
+
staged: manifestStaged,
|
|
189
|
+
sha256: manifestSha256,
|
|
190
|
+
},
|
|
191
|
+
files,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Stages every pack in `packs` into `<groundworkDir>/packs/`: for each, its
|
|
196
|
+
* manifest as `<name>/pack.json.staged` (`JSON.stringify(manifest, null, 2)`
|
|
197
|
+
* plus a newline) and every file of its `filesDir` tree as
|
|
198
|
+
* `<name>/files/<path>.staged`, copied byte-for-byte -- no token
|
|
199
|
+
* substitution into content, which is `/customize`'s job at install time.
|
|
200
|
+
* `tokens` only substitutes into install paths (and dotfile names are
|
|
201
|
+
* restored), the same derivation `planConflicts` uses. Recorded `path`/
|
|
202
|
+
* `staged` values use forward slashes; `dir` is the literal
|
|
203
|
+
* `.groundwork/packs/<name>`, whatever `groundworkDir` was passed.
|
|
204
|
+
*
|
|
205
|
+
* What is guaranteed:
|
|
206
|
+
* - The plan is validated first, before anything is deleted or written (or,
|
|
207
|
+
* when `plan` is passed, was already validated by {@link planPackStaging}
|
|
208
|
+
* and the packs' trees are not walked again): a pack name that is not a
|
|
209
|
+
* single directory name, contains `:`, or is used by two packs (ignoring
|
|
210
|
+
* case and Unicode normalization), a missing `filesDir`, two files in one
|
|
211
|
+
* pack installing to the same path, an install path containing `:`, a
|
|
212
|
+
* staged name that would escape its pack's staging directory, or two
|
|
213
|
+
* staged names in one pack landing on the same file (equal once
|
|
214
|
+
* NFC-normalized and case-folded, or one a directory prefix of the other)
|
|
215
|
+
* throws its own
|
|
216
|
+
* `Error`, leaving `.groundwork/` exactly as it was. A `filesDir` that
|
|
217
|
+
* cannot be inspected (a permission error) throws a plan `Error` naming
|
|
218
|
+
* the pack and path, with the failure as `cause`.
|
|
219
|
+
* - Then, still before anything is deleted or written, `groundworkDir` and
|
|
220
|
+
* `<groundworkDir>/packs` are checked not to be symlinks, and every
|
|
221
|
+
* `.packs-*` entry of the CLI-owned `groundworkDir` -- meant for work
|
|
222
|
+
* directories a crashed earlier run left, but removed whatever created
|
|
223
|
+
* it, so a concurrent run against the same directory can lose its
|
|
224
|
+
* in-progress work directory and fail with the incomplete/re-run error
|
|
225
|
+
* -- is removed (best effort; a failure to remove one only warns, a
|
|
226
|
+
* failure to list `groundworkDir` throws). `.baseline-*`
|
|
227
|
+
* entries are left alone.
|
|
228
|
+
* - When `packs` is empty, any previous `packs/` is removed and nothing is
|
|
229
|
+
* created.
|
|
230
|
+
* - Otherwise every pack is written into one temporary `.packs-*` sibling
|
|
231
|
+
* directory (each file created exclusively, `wx`) and swapped in by rename
|
|
232
|
+
* only after every copy succeeded, so `packs/` is never half-written and a
|
|
233
|
+
* failure leaves any previous `packs/` intact; a previous `packs/` is
|
|
234
|
+
* replaced wholesale (a pack no longer passed, or a file a pack dropped,
|
|
235
|
+
* does not linger).
|
|
236
|
+
* - With a previous `packs/`, the swap is two renames: the previous one is
|
|
237
|
+
* parked inside the temporary directory, then the new one moved into
|
|
238
|
+
* place. A process killed between the two leaves `packs/` absent and the
|
|
239
|
+
* previous copy at `.packs-XXXXXX/previous`; the next run's sweep removes
|
|
240
|
+
* it and regenerates the staging.
|
|
241
|
+
* - If the final swap rename fails, the previous `packs/` is renamed back.
|
|
242
|
+
* Only if that restore also fails is `packs/` left absent: the previous
|
|
243
|
+
* staging then survives, parked inside the temporary directory, which is
|
|
244
|
+
* deliberately not removed (a later run's stale-dir sweep does).
|
|
245
|
+
* - The temporary directory is otherwise always removed; a failure to remove
|
|
246
|
+
* it only warns, naming its path.
|
|
247
|
+
*
|
|
248
|
+
* @throws `Error` (no re-run advice; no `cause` except for an uninspectable
|
|
249
|
+
* `filesDir`) for an invalid plan, as above; `Error` before any delete or
|
|
250
|
+
* write when `groundworkDir` or
|
|
251
|
+
* `<groundworkDir>/packs` is a symlink; `AggregateError` of the swap and
|
|
252
|
+
* restore failures, naming where the previous `packs/` is parked, when both
|
|
253
|
+
* renames fail; the `AssertionError` itself, unwrapped, if the staged-path
|
|
254
|
+
* containment invariant ever fails while writing; otherwise an `Error` with
|
|
255
|
+
* `cause`, including the cause's message, saying `.groundwork/` is
|
|
256
|
+
* incomplete and the CLI should be re-run.
|
|
257
|
+
*
|
|
258
|
+
* @param plan - The plan {@link planPackStaging} computed for these same
|
|
259
|
+
* `packs`, `groundworkDir` and `tokens`; computed here when omitted. A plan
|
|
260
|
+
* whose `groundworkDir` resolves to a different directory throws a plain
|
|
261
|
+
* `Error` naming both ("the plan was built for …") before anything is
|
|
262
|
+
* deleted or written.
|
|
263
|
+
*
|
|
264
|
+
* @example
|
|
265
|
+
* ```ts
|
|
266
|
+
* import { listPackNames, loadPack } from "./packs.js";
|
|
267
|
+
* const packs = listPackNames().map((name) => loadPack(name));
|
|
268
|
+
* const staged = stagePacks(packs, "/work/app/.groundwork", { PROJECT_NAME: "app" });
|
|
269
|
+
* // staged[0]: { name: "github", dir: ".groundwork/packs/github", suffix: ".staged", … }
|
|
270
|
+
* ```
|
|
271
|
+
*/
|
|
272
|
+
export function stagePacks(packs, groundworkDir, tokens, plan = planPackStaging(packs, groundworkDir, tokens)) {
|
|
273
|
+
assertPlanBuiltFor("stagePacks", plan.groundworkDir, groundworkDir);
|
|
274
|
+
const target = {
|
|
275
|
+
groundworkDir,
|
|
276
|
+
dirName: PACKS_DIR_NAME,
|
|
277
|
+
noun: "packs",
|
|
278
|
+
plural: true,
|
|
279
|
+
};
|
|
280
|
+
prepareStaging(target);
|
|
281
|
+
if (plan.packs.length === 0) {
|
|
282
|
+
clearStaging(target);
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
return stageAtomically(target, (newDir) => plan.packs.map((planned) => writePack(planned, newDir)));
|
|
286
|
+
}
|
|
287
|
+
//# sourceMappingURL=pack-stage.js.map
|
package/dist/packs.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export interface Pack {
|
|
|
28
28
|
export declare function packsRootDir(): string;
|
|
29
29
|
/** Names of every pack directory that has a `pack.json` under `root`, sorted for deterministic install order. `root` defaults to `templates/packs`, overridable for tests. */
|
|
30
30
|
export declare function listPackNames(root?: string): string[];
|
|
31
|
-
/** Loads and validates one pack's manifest from under `root` (default `templates/packs`, overridable for tests). Throws, naming the available packs, if unknown
|
|
31
|
+
/** Loads and validates one pack's manifest from under `root` (default `templates/packs`, overridable for tests). Throws, naming the available packs, if unknown; throws naming the pack and the problem if malformed, including a prototype-sensitive key in its wiring (see {@link assertValidWiringShape}). */
|
|
32
32
|
export declare function loadPack(name: string, root?: string): Pack;
|
|
33
33
|
export interface PackInstallResult {
|
|
34
34
|
filesWritten: string[];
|
|
@@ -41,21 +41,27 @@ export interface PackInstallResult {
|
|
|
41
41
|
* applies the pack's three JSON wiring merges.
|
|
42
42
|
*/
|
|
43
43
|
export declare function installPack(pack: Pack, targetDir: string, tokens: TokenTable): PackInstallResult;
|
|
44
|
-
/**
|
|
45
|
-
* Copies a pack's `pack.json` + `files/` tree, unmodified, into
|
|
46
|
-
* `<groundworkDir>/packs/<name>/` -- adopt mode's staging area. `/customize`
|
|
47
|
-
* installs from this self-contained copy rather than from `templateRoot`
|
|
48
|
-
* (an absolute path that may not exist by the time it runs). Token
|
|
49
|
-
* substitution is a no-op here (`{}`): staging a project's real name into
|
|
50
|
-
* pack content is `/customize`'s job, not this offline copy's.
|
|
51
|
-
*/
|
|
52
|
-
export declare function stagePackFiles(pack: Pack, groundworkDir: string): string[];
|
|
53
44
|
/**
|
|
54
45
|
* Index-level, adopt-mode-only facts about how a pack's wiring would land
|
|
55
46
|
* against a real project's current `.claude/settings.json` and
|
|
56
47
|
* `bin/lib/verify-steps.packs.json` -- never a verdict on whether it will
|
|
57
|
-
* work
|
|
58
|
-
*
|
|
48
|
+
* work. That verdict is a judgment call for `/customize`'s Step 0 (the
|
|
49
|
+
* adopt-mode reconcile step in `/customize`) to make after reading the
|
|
50
|
+
* project's real gate runner and hook config. A path this process cannot
|
|
51
|
+
* reach (`EACCES`/`EPERM`/`ELOOP`) is observed with its errno, never as
|
|
52
|
+
* "not found"; nor is a dangling symlink or a file blocking an ancestor
|
|
53
|
+
* directory, which is also recorded once in `undetermined`. A
|
|
54
|
+
* `.claude/settings.json` that exists but cannot be read for a reason that
|
|
55
|
+
* is a property of the project's tree (`EACCES`/`EPERM`, `EISDIR`, `ENOENT`,
|
|
56
|
+
* `ELOOP`) is observed with its errno and also recorded once in
|
|
57
|
+
* `undetermined` -- adopt mode passes the survey's own list, so the report
|
|
58
|
+
* shows it; any other errno throws.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* const undetermined: string[] = [];
|
|
63
|
+
* const observations = observeWiring(targetDir, pack.manifest, undetermined);
|
|
64
|
+
* ```
|
|
59
65
|
*/
|
|
60
|
-
export declare function observeWiring(targetDir: string, manifest: PackManifest): string[];
|
|
66
|
+
export declare function observeWiring(targetDir: string, manifest: PackManifest, undetermined?: string[]): string[];
|
|
61
67
|
//# sourceMappingURL=packs.d.ts.map
|
package/dist/packs.js
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
1
3
|
/**
|
|
2
4
|
* Loads pack manifests from `templates/packs/<name>/pack.json` and installs
|
|
3
5
|
* a pack's files + JSON wiring into a freshly-bootstrapped project. Adopt
|
|
4
6
|
* mode never calls `installPack` -- it surveys packs into the report
|
|
5
|
-
* (`observeWiring
|
|
6
|
-
* `/customize`, which reads a project's real gate
|
|
7
|
-
* a pack's wiring (see inventory.ts).
|
|
7
|
+
* (`observeWiring`; `pack-stage.ts`'s `stagePacks` stages them, inert) and
|
|
8
|
+
* defers installation to `/customize`, which reads a project's real gate
|
|
9
|
+
* runner before translating a pack's wiring (see inventory.ts). Both modes call `loadPack` for every
|
|
10
|
+
* pack they handle (fresh mode: each `--pack`; adopt mode: every pack)
|
|
11
|
+
* before writing anything, and `loadPack` refuses a prototype-sensitive
|
|
12
|
+
* key in `wiring.settings`, `wiring.settingsTopLevel` or
|
|
13
|
+
* `wiring.packageScripts`, so such a pack is neither installed nor staged.
|
|
8
14
|
*/
|
|
9
15
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
|
|
10
16
|
import { dirname, join } from "node:path";
|
|
11
17
|
import { resolveAsset } from "./assets.js";
|
|
12
18
|
import { emitTemplate } from "./emit.js";
|
|
13
19
|
import { parseJsonc } from "./jsonc.js";
|
|
14
|
-
import {
|
|
20
|
+
import { blockedAbsentNote } from "./survey/internal/blocked-path.js";
|
|
21
|
+
import { probePath, readFailure, recordedReadCode, unreadableNote, } from "./survey/internal/read-guard.js";
|
|
22
|
+
import { isPrototypeSensitiveKey, isRecord, mergePackageScripts, mergeSettingsHooks, mergeSettingsTopLevel, mergeVerifySteps, } from "./merge-json.js";
|
|
15
23
|
/** `templates/packs`, resolved the same way `templatesCoreDir()` resolves `templates/core`. */
|
|
16
24
|
export function packsRootDir() {
|
|
17
25
|
return resolveAsset({ repo: "templates/packs", local: "templates/packs" });
|
|
@@ -26,7 +34,105 @@ export function listPackNames(root = packsRootDir()) {
|
|
|
26
34
|
.map((entry) => entry.name)
|
|
27
35
|
.sort();
|
|
28
36
|
}
|
|
29
|
-
/**
|
|
37
|
+
/** A pack's own `name` becomes a path segment under `.groundwork/packs/`, so it must be a bare lowercase identifier: no separators, no `..`. */
|
|
38
|
+
const PACK_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
39
|
+
const CAP_KEYS = [
|
|
40
|
+
"agents",
|
|
41
|
+
"skills",
|
|
42
|
+
"hooks",
|
|
43
|
+
"workflows",
|
|
44
|
+
"scripts",
|
|
45
|
+
];
|
|
46
|
+
/** True when `budget` has an own non-negative integer for every {@link CapCounts} key (one read per key). */
|
|
47
|
+
function isValidBudget(budget) {
|
|
48
|
+
if (!isRecord(budget))
|
|
49
|
+
return false;
|
|
50
|
+
return CAP_KEYS.every((key) => {
|
|
51
|
+
if (!Object.hasOwn(budget, key))
|
|
52
|
+
return false;
|
|
53
|
+
const value = budget[key];
|
|
54
|
+
return Number.isInteger(value) && value >= 0;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** The two CLI modes a pack's `modes` may name (see mode.ts). */
|
|
58
|
+
const PACK_MODES = ["fresh", "adopt"];
|
|
59
|
+
/** The `bin/lib/verify-steps.mjs` groups a pack's verify step may join. */
|
|
60
|
+
const VERIFY_GROUPS = [
|
|
61
|
+
"format",
|
|
62
|
+
"lint",
|
|
63
|
+
"typecheck",
|
|
64
|
+
"build",
|
|
65
|
+
"test",
|
|
66
|
+
];
|
|
67
|
+
/** Throws, naming the entry's 0-based `index` and the offending field, when one `wiring.verifySteps[]` entry isn't an object, lacks a string `id`/`name`, lacks a non-empty string-array `cmd`, or names an unknown `group`. Each field is read once into a local before it is checked. */
|
|
68
|
+
function assertValidVerifyStep(name, step, index) {
|
|
69
|
+
const where = `pack "${name}": pack.json's wiring.verifySteps[${String(index)}]`;
|
|
70
|
+
if (!isRecord(step)) {
|
|
71
|
+
throw new Error(`${where} must be an object, got ${String(JSON.stringify(step))}`);
|
|
72
|
+
}
|
|
73
|
+
for (const key of ["id", "name"]) {
|
|
74
|
+
const value = Object.hasOwn(step, key) ? step[key] : undefined;
|
|
75
|
+
if (typeof value !== "string") {
|
|
76
|
+
throw new Error(`${where}.${key} must be a string, got ${String(JSON.stringify(value))}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const cmd = Object.hasOwn(step, "cmd") ? step["cmd"] : undefined;
|
|
80
|
+
if (!Array.isArray(cmd) ||
|
|
81
|
+
cmd.length === 0 ||
|
|
82
|
+
!cmd.every((part) => typeof part === "string")) {
|
|
83
|
+
throw new Error(`${where}.cmd must be a non-empty array of strings, got ${String(JSON.stringify(cmd))}`);
|
|
84
|
+
}
|
|
85
|
+
const group = Object.hasOwn(step, "group")
|
|
86
|
+
? step["group"]
|
|
87
|
+
: undefined;
|
|
88
|
+
if (typeof group !== "string" || !VERIFY_GROUPS.includes(group)) {
|
|
89
|
+
throw new Error(`${where} group ${String(JSON.stringify(group))} must be one of ${VERIFY_GROUPS.join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Throws, naming the pack, the field and the key, when one of `fragment`'s own
|
|
94
|
+
* enumerable keys is prototype-sensitive ({@link isPrototypeSensitiveKey}).
|
|
95
|
+
* `Object.keys` sees a `__proto__` key here because `parseJsonc`, like
|
|
96
|
+
* `JSON.parse`, creates it as an ordinary own data property. Only the
|
|
97
|
+
* fragment's own top-level keys are checked: they are the names a merge
|
|
98
|
+
* writes, while everything nested below them is an opaque value.
|
|
99
|
+
*/
|
|
100
|
+
function assertNoPrototypeSensitiveKeys(name, field, fragment) {
|
|
101
|
+
for (const key of Object.keys(fragment)) {
|
|
102
|
+
if (isPrototypeSensitiveKey(key)) {
|
|
103
|
+
throw new Error(`pack "${name}": pack.json's wiring.${field} must not use the prototype-sensitive key ${JSON.stringify(key)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Throws, naming the offending key or value, when `wiring.settings`/`packageScripts` is missing or isn't an object, `verifySteps` is missing or isn't an array, any `verifySteps[]` entry is malformed (see {@link assertValidVerifyStep}), or `settings`, `packageScripts` or (when it is an object) `settingsTopLevel` has a prototype-sensitive own key (see {@link assertNoPrototypeSensitiveKeys}).
|
|
109
|
+
* Those three are the only wiring fields whose keys a merge writes; `verifySteps` ids are values. An absent or non-object `settingsTopLevel` is not rejected here.
|
|
110
|
+
*/
|
|
111
|
+
function assertValidWiringShape(name, wiring) {
|
|
112
|
+
for (const key of ["settings", "packageScripts"]) {
|
|
113
|
+
const value = Object.hasOwn(wiring, key) ? wiring[key] : undefined;
|
|
114
|
+
if (!isRecord(value)) {
|
|
115
|
+
throw new Error(`pack "${name}": pack.json's wiring.${key} must be an object`);
|
|
116
|
+
}
|
|
117
|
+
assertNoPrototypeSensitiveKeys(name, key, value);
|
|
118
|
+
}
|
|
119
|
+
const topLevel = Object.hasOwn(wiring, "settingsTopLevel")
|
|
120
|
+
? wiring["settingsTopLevel"]
|
|
121
|
+
: undefined;
|
|
122
|
+
if (isRecord(topLevel)) {
|
|
123
|
+
assertNoPrototypeSensitiveKeys(name, "settingsTopLevel", topLevel);
|
|
124
|
+
}
|
|
125
|
+
const steps = Object.hasOwn(wiring, "verifySteps")
|
|
126
|
+
? wiring["verifySteps"]
|
|
127
|
+
: undefined;
|
|
128
|
+
if (!Array.isArray(steps)) {
|
|
129
|
+
throw new Error(`pack "${name}": pack.json's wiring.verifySteps must be an array`);
|
|
130
|
+
}
|
|
131
|
+
steps.forEach((step, index) => {
|
|
132
|
+
assertValidVerifyStep(name, step, index);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/** Loads and validates one pack's manifest from under `root` (default `templates/packs`, overridable for tests). Throws, naming the available packs, if unknown; throws naming the pack and the problem if malformed, including a prototype-sensitive key in its wiring (see {@link assertValidWiringShape}). */
|
|
30
136
|
export function loadPack(name, root = packsRootDir()) {
|
|
31
137
|
const packDir = join(root, name);
|
|
32
138
|
const manifestPath = join(packDir, "pack.json");
|
|
@@ -38,10 +144,43 @@ export function loadPack(name, root = packsRootDir()) {
|
|
|
38
144
|
if (!parsed.ok) {
|
|
39
145
|
throw new Error(`pack "${name}": pack.json failed to parse -- ${parsed.error}`);
|
|
40
146
|
}
|
|
41
|
-
const
|
|
147
|
+
const raw = parsed.value;
|
|
148
|
+
if (!isRecord(raw)) {
|
|
149
|
+
throw new Error(`pack "${name}": pack.json must be an object, got ${String(JSON.stringify(raw))}`);
|
|
150
|
+
}
|
|
151
|
+
const manifest = raw;
|
|
42
152
|
if (manifest.schemaVersion !== 1) {
|
|
43
153
|
throw new Error(`pack "${name}": unsupported pack.json schemaVersion ${JSON.stringify(manifest.schemaVersion)}`);
|
|
44
154
|
}
|
|
155
|
+
const manifestName = manifest.name;
|
|
156
|
+
if (typeof manifestName !== "string" ||
|
|
157
|
+
!PACK_NAME_PATTERN.test(manifestName)) {
|
|
158
|
+
throw new Error(`pack "${name}": pack.json's pack name ${JSON.stringify(manifestName)} must match ${String(PACK_NAME_PATTERN)} (a bare lowercase identifier, no path separators)`);
|
|
159
|
+
}
|
|
160
|
+
const modes = manifest.modes;
|
|
161
|
+
if (!Array.isArray(modes) ||
|
|
162
|
+
modes.length === 0 ||
|
|
163
|
+
!modes.every((mode) => typeof mode === "string")) {
|
|
164
|
+
throw new Error(`pack "${name}": pack.json's modes must be a non-empty array of strings`);
|
|
165
|
+
}
|
|
166
|
+
const badMode = modes.find((mode) => !PACK_MODES.includes(mode));
|
|
167
|
+
if (badMode !== undefined) {
|
|
168
|
+
throw new Error(`pack "${name}": pack.json's mode ${JSON.stringify(badMode)} must be one of ${PACK_MODES.join(", ")}`);
|
|
169
|
+
}
|
|
170
|
+
const wiring = manifest.wiring;
|
|
171
|
+
if (!isRecord(wiring)) {
|
|
172
|
+
throw new Error(`pack "${name}": pack.json's wiring must be an object`);
|
|
173
|
+
}
|
|
174
|
+
assertValidWiringShape(name, wiring);
|
|
175
|
+
const budget = manifest.budget;
|
|
176
|
+
if (!isValidBudget(budget)) {
|
|
177
|
+
throw new Error(`pack "${name}": pack.json's budget must set ${CAP_KEYS.join(", ")} to non-negative integers`);
|
|
178
|
+
}
|
|
179
|
+
// pack-stage.ts's stagePacks keys each pack's staging directory on
|
|
180
|
+
// manifest.name, so a mismatch would let two pack directories collide.
|
|
181
|
+
if (manifestName !== name) {
|
|
182
|
+
throw new Error(`pack "${name}": pack.json's name "${manifestName}" does not match its directory name "${name}"`);
|
|
183
|
+
}
|
|
45
184
|
return { manifest, filesDir: join(packDir, "files") };
|
|
46
185
|
}
|
|
47
186
|
function readJsonOrThrow(path) {
|
|
@@ -126,35 +265,96 @@ export function installPack(pack, targetDir, tokens) {
|
|
|
126
265
|
return { filesWritten, budget: manifest.budget };
|
|
127
266
|
}
|
|
128
267
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
268
|
+
* Reads `.claude/settings.json` for {@link observeWiring}. A failure that is
|
|
269
|
+
* a fact about the project's own tree -- a permission failure
|
|
270
|
+
* (`EACCES`/`EPERM`), a directory at the path (`EISDIR`), the file vanishing
|
|
271
|
+
* after the exists probe or a dangling symlink (`ENOENT`), a symlink loop
|
|
272
|
+
* (`ELOOP`) -- is recorded as an observation naming the path and errno, and
|
|
273
|
+
* once in `undetermined` (so the adoption report shows it), and `undefined`
|
|
274
|
+
* is returned; any other errno is about the machine and throws, naming the
|
|
275
|
+
* path with the original failure as `cause`.
|
|
135
276
|
*/
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
277
|
+
function readSettingsOrObserve(settingsPath, observations, undetermined) {
|
|
278
|
+
try {
|
|
279
|
+
return readFileSync(settingsPath, "utf8");
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
const code = recordedReadCode(error);
|
|
283
|
+
if (code === undefined)
|
|
284
|
+
throw readFailure(settingsPath, error);
|
|
285
|
+
observations.push(`.claude/settings.json (${settingsPath}) exists but could not be read (${code})`);
|
|
286
|
+
// Called once per pack against the same file: record the note once.
|
|
287
|
+
const note = unreadableNote(settingsPath, code);
|
|
288
|
+
if (!undetermined.includes(note))
|
|
289
|
+
undetermined.push(note);
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Whether `path` exists, for {@link observeWiring}. A real `stat`, never
|
|
295
|
+
* `existsSync`, so a file under a directory this process cannot search is
|
|
296
|
+
* not observed as missing: an `EACCES`/`EPERM`/`ELOOP` is recorded as an
|
|
297
|
+
* observation naming the path and errno, and `undefined` is returned so the
|
|
298
|
+
* caller states neither "found" nor "not found". `ENOENT`/`ENOTDIR` answers
|
|
299
|
+
* `false` only when nothing is really there: a dangling symlink at the path
|
|
300
|
+
* or an ancestor, or a regular file blocking an ancestor, is observed and
|
|
301
|
+
* recorded once in `undetermined` (the same note `conflicts.ts` records via
|
|
302
|
+
* `blockedAbsentNote`) and answers `undefined`. Any other errno throws (see
|
|
303
|
+
* `probePath`).
|
|
304
|
+
*/
|
|
305
|
+
function existsOrObserve(path, targetDir, observations, undetermined) {
|
|
306
|
+
const probe = probePath(path);
|
|
307
|
+
if (probe.kind === "unresolvable") {
|
|
308
|
+
observations.push(`could not check whether ${path} exists (${probe.code}) -- left undetermined, not reported missing`);
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
if (probe.kind === "present")
|
|
312
|
+
return true;
|
|
313
|
+
const note = blockedAbsentNote(path, targetDir);
|
|
314
|
+
if (note === undefined)
|
|
315
|
+
return false;
|
|
316
|
+
observations.push(`${note} -- left undetermined, not reported missing`);
|
|
317
|
+
// Called once per pack against the same path: record the note once.
|
|
318
|
+
if (!undetermined.includes(note))
|
|
319
|
+
undetermined.push(note);
|
|
320
|
+
return undefined;
|
|
141
321
|
}
|
|
142
322
|
/**
|
|
143
323
|
* Index-level, adopt-mode-only facts about how a pack's wiring would land
|
|
144
324
|
* against a real project's current `.claude/settings.json` and
|
|
145
325
|
* `bin/lib/verify-steps.packs.json` -- never a verdict on whether it will
|
|
146
|
-
* work
|
|
147
|
-
*
|
|
326
|
+
* work. That verdict is a judgment call for `/customize`'s Step 0 (the
|
|
327
|
+
* adopt-mode reconcile step in `/customize`) to make after reading the
|
|
328
|
+
* project's real gate runner and hook config. A path this process cannot
|
|
329
|
+
* reach (`EACCES`/`EPERM`/`ELOOP`) is observed with its errno, never as
|
|
330
|
+
* "not found"; nor is a dangling symlink or a file blocking an ancestor
|
|
331
|
+
* directory, which is also recorded once in `undetermined`. A
|
|
332
|
+
* `.claude/settings.json` that exists but cannot be read for a reason that
|
|
333
|
+
* is a property of the project's tree (`EACCES`/`EPERM`, `EISDIR`, `ENOENT`,
|
|
334
|
+
* `ELOOP`) is observed with its errno and also recorded once in
|
|
335
|
+
* `undetermined` -- adopt mode passes the survey's own list, so the report
|
|
336
|
+
* shows it; any other errno throws.
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```ts
|
|
340
|
+
* const undetermined: string[] = [];
|
|
341
|
+
* const observations = observeWiring(targetDir, pack.manifest, undetermined);
|
|
342
|
+
* ```
|
|
148
343
|
*/
|
|
149
|
-
export function observeWiring(targetDir, manifest) {
|
|
344
|
+
export function observeWiring(targetDir, manifest, undetermined = []) {
|
|
150
345
|
const observations = [];
|
|
151
346
|
const settingsPath = join(targetDir, ".claude", "settings.json");
|
|
152
|
-
|
|
347
|
+
const settingsExists = existsOrObserve(settingsPath, targetDir, observations, undetermined);
|
|
348
|
+
if (settingsExists === false) {
|
|
153
349
|
observations.push("no .claude/settings.json found");
|
|
154
350
|
}
|
|
155
|
-
else {
|
|
156
|
-
const
|
|
157
|
-
|
|
351
|
+
else if (settingsExists) {
|
|
352
|
+
const content = readSettingsOrObserve(settingsPath, observations, undetermined);
|
|
353
|
+
const parsed = content === undefined ? undefined : parseJsonc(content);
|
|
354
|
+
if (parsed === undefined) {
|
|
355
|
+
// Unreadable -- already recorded as an observation.
|
|
356
|
+
}
|
|
357
|
+
else if (!parsed.ok || !isRecord(parsed.value)) {
|
|
158
358
|
observations.push(".claude/settings.json exists but could not be parsed");
|
|
159
359
|
}
|
|
160
360
|
else {
|
|
@@ -172,14 +372,17 @@ export function observeWiring(targetDir, manifest) {
|
|
|
172
372
|
}
|
|
173
373
|
}
|
|
174
374
|
}
|
|
175
|
-
if (
|
|
375
|
+
if (existsOrObserve(join(targetDir, ".claude", "settings.local.json"), targetDir, observations, undetermined) === true) {
|
|
176
376
|
observations.push(".claude/settings.local.json is present and may shadow a merged hook entry");
|
|
177
377
|
}
|
|
178
378
|
if (manifest.wiring.verifySteps.length > 0) {
|
|
179
379
|
const stepsPath = join(targetDir, "bin", "lib", "verify-steps.packs.json");
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
380
|
+
const stepsExist = existsOrObserve(stepsPath, targetDir, observations, undetermined);
|
|
381
|
+
if (stepsExist !== undefined) {
|
|
382
|
+
observations.push(stepsExist
|
|
383
|
+
? "bin/lib/verify-steps.packs.json exists"
|
|
384
|
+
: "no bin/lib/verify-steps.packs.json found -- no bin/verify.mjs-shaped gate runner detected");
|
|
385
|
+
}
|
|
183
386
|
}
|
|
184
387
|
return observations;
|
|
185
388
|
}
|