@monte3l/groundwork 1.0.0-rc.2 → 1.0.0-rc.4
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 +13 -5
- 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 +574 -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 +21 -13
- package/dist/packs.js +241 -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 +44 -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 +295 -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 +41 -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
package/dist/main.js
CHANGED
|
@@ -1,32 +1,43 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
1
3
|
/**
|
|
2
4
|
* Entry point: `m3l-groundwork <target-dir> [options]`. No prompts, no
|
|
3
5
|
* interactivity, no network call beyond the package install -- this must
|
|
4
6
|
* work on a plane against a warm pnpm store.
|
|
5
7
|
*
|
|
6
8
|
* Two modes, auto-detected from the target directory (`--adopt`/`--fresh`
|
|
7
|
-
* force either): **fresh** writes the baseline into an empty
|
|
8
|
-
*
|
|
9
|
+
* force either): **fresh** writes the baseline template tree into an empty
|
|
10
|
+
* or missing target directory, then installs any `--pack` requested.
|
|
11
|
+
* **adopt** surveys an
|
|
9
12
|
* already-established project and writes only a report -- see `mode.ts`,
|
|
10
13
|
* `survey/survey.ts`, `conflicts.ts`, and `report.ts`. Adopt mode never
|
|
11
14
|
* touches a project file, including a pack's: it surveys every pack under
|
|
12
|
-
* `templates/packs/` into the report
|
|
13
|
-
* `/customize`; see
|
|
15
|
+
* `templates/packs/` into the report, stages each as inert `.staged` copies
|
|
16
|
+
* under `.groundwork/packs/`, and defers installation to `/customize`; see
|
|
17
|
+
* `runAdopt`.
|
|
14
18
|
*/
|
|
15
|
-
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync, } from "node:fs";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
16
21
|
import { join, relative, resolve, basename } from "node:path";
|
|
22
|
+
import process from "node:process";
|
|
17
23
|
import { resolveAsset } from "./assets.js";
|
|
18
24
|
import { CAP_LIMITS, countBaselineCaps } from "./caps.js";
|
|
19
|
-
import { emitTemplate } from "./emit.js";
|
|
25
|
+
import { assertSafeEmitDestinations, emitTemplate, isPathContained, } from "./emit.js";
|
|
20
26
|
import { installCustomizeSkill, installCustomizeSkillGuarded, } from "./plugin.js";
|
|
27
|
+
import { CLAUDE_DEST_SEGMENTS, CUSTOMIZE_SKILL_FILE_NAMES, GROUNDWORK_DEST_SEGMENTS, plannedCustomizeSkillPaths, } from "./customize-paths.js";
|
|
21
28
|
import { gitInit, runInstall } from "./git.js";
|
|
22
29
|
import { gradeHarness } from "./harness/grade.js";
|
|
23
30
|
import { detectMode, resolveMode } from "./mode.js";
|
|
24
31
|
import { surveyProject } from "./survey/survey.js";
|
|
25
32
|
import { planConflicts } from "./conflicts.js";
|
|
33
|
+
import { STAGED_BASELINE_DIR, STAGED_SUFFIX, planBaselineStaging, stageBaselineAdditions, } from "./baseline-stage.js";
|
|
34
|
+
import { assertDirectoryComponent, assertNotDirectory, assertNotSymlink, endsWithRerunAdvice, FIX_AND_RERUN_ADVICE, FRESH_SYMLINK_ADVICE, } from "./fs-guard.js";
|
|
26
35
|
import { buildInventory, resolveCliVersion, writeInventory, } from "./inventory.js";
|
|
27
|
-
import { listPackNames, loadPack, installPack, observeWiring,
|
|
36
|
+
import { listPackNames, loadPack, installPack, observeWiring, } from "./packs.js";
|
|
37
|
+
import { STAGED_PACKS_DIR, planPackStaging, stagePacks } from "./pack-stage.js";
|
|
28
38
|
import { renderReport } from "./report.js";
|
|
29
39
|
import { gradeToolchain } from "./toolchain/grade.js";
|
|
40
|
+
import { paint } from "./term.js";
|
|
30
41
|
const USAGE = [
|
|
31
42
|
"usage: m3l-groundwork <target-dir> [options]",
|
|
32
43
|
"",
|
|
@@ -45,9 +56,9 @@ const USAGE = [
|
|
|
45
56
|
"looks like a project (package.json, .git, or loose source files) is",
|
|
46
57
|
"surveyed and adopted instead -- see .groundwork/adoption-report.md.",
|
|
47
58
|
"",
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
59
|
+
"--pack and --force are rejected in adopt mode -- adopt mode never writes",
|
|
60
|
+
"project files. Every available pack is surveyed into the report",
|
|
61
|
+
"automatically; run /customize to install one.",
|
|
51
62
|
].join("\n");
|
|
52
63
|
// --name takes a single value; --pack is repeatable. Every other
|
|
53
64
|
// recognized flag is a bare boolean.
|
|
@@ -56,6 +67,15 @@ const REPEATABLE_VALUE_FLAGS = new Set(["--pack"]);
|
|
|
56
67
|
const HELP_FLAGS = new Set(["--help", "-h"]);
|
|
57
68
|
const VERSION_FLAGS = new Set(["--version", "-v"]);
|
|
58
69
|
const LIST_PACKS_FLAGS = new Set(["--list-packs"]);
|
|
70
|
+
// A --pack value is a bare directory name under templates/packs/ -- this
|
|
71
|
+
// allowlist keeps it from ever reaching packs.ts's join() as a path.
|
|
72
|
+
const PACK_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
73
|
+
// A --name value is substituted verbatim into the emitted package.json, so it
|
|
74
|
+
// must fit a conservative, ASCII-lowercase subset of npm's naming rules
|
|
75
|
+
// (optional @scope/). Reserved names like `node_modules` are out of scope.
|
|
76
|
+
const NPM_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
77
|
+
// npm's own ceiling on a package name's total length, scope included.
|
|
78
|
+
const NPM_NAME_MAX_LENGTH = 214;
|
|
59
79
|
const BOOLEAN_FLAGS = new Set([
|
|
60
80
|
"--skip-install",
|
|
61
81
|
"--force",
|
|
@@ -110,6 +130,9 @@ function tokenizeArgv(argv) {
|
|
|
110
130
|
if (value === undefined || value.startsWith("-")) {
|
|
111
131
|
throw new CliUsageError(`${arg} requires a value\n\n${USAGE}`);
|
|
112
132
|
}
|
|
133
|
+
if (values.has(arg)) {
|
|
134
|
+
throw new CliUsageError(`${arg} given more than once\n\n${USAGE}`);
|
|
135
|
+
}
|
|
113
136
|
values.set(arg, value);
|
|
114
137
|
i++;
|
|
115
138
|
continue;
|
|
@@ -153,7 +176,17 @@ export function parseArgs(argv) {
|
|
|
153
176
|
}
|
|
154
177
|
const targetDir = resolve(targetArg);
|
|
155
178
|
const explicitName = values.get("--name");
|
|
156
|
-
|
|
179
|
+
if (explicitName !== undefined &&
|
|
180
|
+
(explicitName.length > NPM_NAME_MAX_LENGTH ||
|
|
181
|
+
!NPM_NAME_PATTERN.test(explicitName))) {
|
|
182
|
+
throw new CliUsageError(`--name must be a valid npm package name (lowercase, optional @scope/, at most ${String(NPM_NAME_MAX_LENGTH)} characters): ${JSON.stringify(explicitName)}\n\n${USAGE}`);
|
|
183
|
+
}
|
|
184
|
+
const packValues = repeatableValues.get("--pack") ?? [];
|
|
185
|
+
const badPack = packValues.find((name) => !PACK_NAME_PATTERN.test(name));
|
|
186
|
+
if (badPack !== undefined) {
|
|
187
|
+
throw new CliUsageError(`--pack must be a pack name matching ${PACK_NAME_PATTERN.source}: ${JSON.stringify(badPack)}\n\n${USAGE}`);
|
|
188
|
+
}
|
|
189
|
+
const packs = [...new Set(packValues)].sort();
|
|
157
190
|
return {
|
|
158
191
|
targetDir,
|
|
159
192
|
projectName: explicitName ?? basename(targetDir),
|
|
@@ -180,7 +213,18 @@ function buildTokens(projectName) {
|
|
|
180
213
|
function formatCounts(counts) {
|
|
181
214
|
return `${counts.agents} agents, ${counts.skills} skills, ${counts.hooks} hooks, ${counts.workflows} workflows, ${counts.scripts} scripts`;
|
|
182
215
|
}
|
|
183
|
-
/**
|
|
216
|
+
/**
|
|
217
|
+
* The post-`--pack`-install caps summary line(s) printed to fresh-mode's
|
|
218
|
+
* console output, plus whether any cap is exceeded.
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* ```ts
|
|
222
|
+
* const summary = formatCapsSummary(baselineCounts, [
|
|
223
|
+
* { name: "harness-extras", budget: packBudget },
|
|
224
|
+
* ]);
|
|
225
|
+
* console.log(summary.text);
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
184
228
|
export function formatCapsSummary(baseline, installed) {
|
|
185
229
|
const total = { ...baseline };
|
|
186
230
|
const lines = [
|
|
@@ -196,38 +240,328 @@ export function formatCapsSummary(baseline, installed) {
|
|
|
196
240
|
}
|
|
197
241
|
const overCap = ["agents", "skills", "hooks", "workflows", "scripts"].filter((key) => total[key] > CAP_LIMITS[key]);
|
|
198
242
|
lines.push(`= ${formatCounts(total)}${overCap.length > 0 ? ` ⚠ over cap: ${overCap.join(", ")}` : ""}`);
|
|
199
|
-
return lines.join("\n");
|
|
243
|
+
return { text: lines.join("\n"), overCap: overCap.length > 0 };
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Fresh mode's closing "setup required" block: one heading, then each pack
|
|
247
|
+
* that declares `setupSteps` (in `packs` order) followed by its commands,
|
|
248
|
+
* each indented two spaces. `undefined` when no pack declares any, so the
|
|
249
|
+
* caller prints nothing extra.
|
|
250
|
+
*/
|
|
251
|
+
function formatSetupBlock(targetDir, packs) {
|
|
252
|
+
const lines = [];
|
|
253
|
+
for (const { manifest } of packs) {
|
|
254
|
+
const steps = manifest.setupSteps ?? [];
|
|
255
|
+
if (steps.length === 0)
|
|
256
|
+
continue;
|
|
257
|
+
lines.push(`pack "${manifest.name}":`, ...steps.map((step) => ` ${step}`));
|
|
258
|
+
}
|
|
259
|
+
if (lines.length === 0)
|
|
260
|
+
return undefined;
|
|
261
|
+
return [
|
|
262
|
+
`\nthe installed pack(s) need setup before the first \`pnpm verify\` -- run these in ${targetDir}:`,
|
|
263
|
+
...lines,
|
|
264
|
+
].join("\n");
|
|
200
265
|
}
|
|
201
|
-
|
|
266
|
+
/**
|
|
267
|
+
* Fresh mode's pre-flight over the `/customize` skill's own destination
|
|
268
|
+
* (`.claude/skills/customize/`): each directory component must be missing
|
|
269
|
+
* or a real directory, and no payload file name may be a directory. A
|
|
270
|
+
* symlink AT a payload name passes -- the install replaces it rather than
|
|
271
|
+
* writing through it. Nothing is written either way.
|
|
272
|
+
*/
|
|
273
|
+
function assertSafeSkillDestination(targetDir) {
|
|
274
|
+
for (let depth = 1; depth <= CLAUDE_DEST_SEGMENTS.length; depth++) {
|
|
275
|
+
assertDirectoryComponent(join(targetDir, ...CLAUDE_DEST_SEGMENTS.slice(0, depth)), FRESH_SYMLINK_ADVICE);
|
|
276
|
+
}
|
|
277
|
+
const destDir = join(targetDir, ...CLAUDE_DEST_SEGMENTS);
|
|
278
|
+
for (const name of CUSTOMIZE_SKILL_FILE_NAMES) {
|
|
279
|
+
assertNotDirectory(join(destDir, name), FRESH_SYMLINK_ADVICE);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function runFresh(options, platform) {
|
|
283
|
+
if (platform === "win32") {
|
|
284
|
+
throw new Error("Windows is not supported yet (Linux and macOS only)");
|
|
285
|
+
}
|
|
202
286
|
if (!isEmptyOrMissing(options.targetDir) && !options.force) {
|
|
203
287
|
throw new Error(`${options.targetDir} already exists and is not empty (pass --force to overwrite, or --adopt to survey it instead)`);
|
|
204
288
|
}
|
|
205
|
-
|
|
289
|
+
// Resolve every --pack before the first write: a bad name must leave the
|
|
290
|
+
// target exactly as it was found.
|
|
291
|
+
const packs = options.packs.map((name) => resolveFreshPack(name));
|
|
206
292
|
const tokens = buildTokens(options.projectName);
|
|
293
|
+
// Validate every destination the baseline AND each pack would write
|
|
294
|
+
// before the first write: a symlinked or non-directory component refuses
|
|
295
|
+
// the whole run with nothing written.
|
|
296
|
+
assertSafeEmitDestinations([templatesCoreDir(), ...packs.map((pack) => pack.filesDir)], options.targetDir, tokens);
|
|
297
|
+
// The /customize skill's destination too: its own install refuses a
|
|
298
|
+
// symlinked directory component or a directory at a payload name, but it
|
|
299
|
+
// runs only after the baseline is written -- too late to leave the target
|
|
300
|
+
// untouched.
|
|
301
|
+
assertSafeSkillDestination(options.targetDir);
|
|
302
|
+
mkdirSync(options.targetDir, { recursive: true });
|
|
207
303
|
const result = emitTemplate(templatesCoreDir(), options.targetDir, tokens);
|
|
208
304
|
console.log(`wrote ${result.filesWritten.length} files to ${options.targetDir}`);
|
|
209
305
|
const installedPacks = [];
|
|
210
|
-
for (const
|
|
211
|
-
const
|
|
212
|
-
if (!pack.manifest.modes.includes("fresh")) {
|
|
213
|
-
throw new Error(`pack "${name}" does not support fresh mode (modes: ${pack.manifest.modes.join(", ")})`);
|
|
214
|
-
}
|
|
306
|
+
for (const pack of packs) {
|
|
307
|
+
const name = pack.manifest.name;
|
|
215
308
|
const packResult = installPack(pack, options.targetDir, tokens);
|
|
216
309
|
console.log(`installed pack "${name}" (${packResult.filesWritten.length} files)`);
|
|
217
310
|
installedPacks.push({ name, budget: packResult.budget });
|
|
218
311
|
}
|
|
219
312
|
if (installedPacks.length > 0) {
|
|
220
|
-
|
|
313
|
+
const summary = formatCapsSummary(countBaselineCaps(templatesCoreDir()), installedPacks);
|
|
314
|
+
console.log(summary.overCap
|
|
315
|
+
? paint(process.stdout, "warning", summary.text)
|
|
316
|
+
: summary.text);
|
|
317
|
+
}
|
|
318
|
+
const { targetDir, skipInstall, projectName } = options;
|
|
319
|
+
// Built once, now that the project and its packs are on disk, and printed
|
|
320
|
+
// exactly once per run: at the very end on success, or just before the
|
|
321
|
+
// throw when git init / pnpm install fails -- a plain re-run of a
|
|
322
|
+
// non-empty target adopts it, and adopt mode never prints setupSteps. The
|
|
323
|
+
// /customize-skill failure below deliberately does not print it: its
|
|
324
|
+
// message says to re-run with --fresh --force, which prints it on success.
|
|
325
|
+
const setupBlock = formatSetupBlock(targetDir, packs);
|
|
326
|
+
const printSetupBlock = () => {
|
|
327
|
+
if (setupBlock !== undefined) {
|
|
328
|
+
console.log(paint(process.stdout, "warning", setupBlock));
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
let pluginResult;
|
|
332
|
+
try {
|
|
333
|
+
pluginResult = installCustomizeSkill(targetDir);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
// The target is no longer empty, so a plain re-run would auto-detect
|
|
337
|
+
// adopt mode; only --fresh --force repeats this run. With
|
|
338
|
+
// --skip-install, pnpm install was never going to run, so don't claim
|
|
339
|
+
// the failure stopped it (same split as the git-init branch below).
|
|
340
|
+
const notRun = skipInstall ? "git init" : "git init / pnpm install";
|
|
341
|
+
throw new Error(`the project was written to ${targetDir}, but the /customize skill install failed and ${notRun} did not run -- fix the cause, then re-run with --fresh --force (plus your original --name/--pack/--skip-install); a plain re-run adopts it`, { cause: error });
|
|
342
|
+
}
|
|
343
|
+
console.log(pluginResult.filesWritten.length === 0
|
|
344
|
+
? "the /customize skill was already up to date"
|
|
345
|
+
: `installed the /customize skill (${pluginResult.filesWritten.length} files)`);
|
|
346
|
+
try {
|
|
347
|
+
gitInit(targetDir);
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
printSetupBlock();
|
|
351
|
+
throw new Error(`git init failed, but the project was written to ${targetDir}; run \`git init\`${skipInstall ? "" : " and `pnpm install`"} there yourself`, { cause: error });
|
|
221
352
|
}
|
|
222
|
-
const pluginResult = installCustomizeSkill(options.targetDir);
|
|
223
|
-
console.log(`installed the /customize skill (${pluginResult.filesWritten.length} files)`);
|
|
224
|
-
gitInit(options.targetDir);
|
|
225
353
|
console.log("initialized git repository");
|
|
226
|
-
if (!
|
|
227
|
-
|
|
354
|
+
if (!skipInstall) {
|
|
355
|
+
try {
|
|
356
|
+
runInstall(targetDir);
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
console.log(paint(process.stdout, "warning", `\n${projectName} written to ${targetDir}, but dependencies are not installed`));
|
|
360
|
+
printSetupBlock();
|
|
361
|
+
throw new Error(`${describeInstallFailure(error)}; the project was written to ${targetDir} -- run \`pnpm install\` there yourself to finish`, { cause: error });
|
|
362
|
+
}
|
|
228
363
|
console.log("installed dependencies");
|
|
229
364
|
}
|
|
230
|
-
console.log(`\n✓ ${
|
|
365
|
+
console.log(paint(process.stdout, "success", `\n✓ ${projectName} is ready at ${targetDir}`));
|
|
366
|
+
printSetupBlock();
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Explains why the post-emission `pnpm install` failed: a missing binary
|
|
370
|
+
* specifically, otherwise the exit status, killing signal, or error code
|
|
371
|
+
* when the thrown value carries one. Each property is read exactly once.
|
|
372
|
+
*/
|
|
373
|
+
function describeInstallFailure(error) {
|
|
374
|
+
if (typeof error !== "object" || error === null) {
|
|
375
|
+
return "`pnpm install` failed";
|
|
376
|
+
}
|
|
377
|
+
const code = "code" in error ? error.code : undefined;
|
|
378
|
+
const status = "status" in error ? error.status : undefined;
|
|
379
|
+
const signal = "signal" in error ? error.signal : undefined;
|
|
380
|
+
if (code === "ENOENT")
|
|
381
|
+
return "pnpm was not found on PATH";
|
|
382
|
+
if (typeof status === "number") {
|
|
383
|
+
return `\`pnpm install\` failed (exit status ${String(status)})`;
|
|
384
|
+
}
|
|
385
|
+
if (typeof signal === "string") {
|
|
386
|
+
return `\`pnpm install\` failed (killed by signal ${signal})`;
|
|
387
|
+
}
|
|
388
|
+
if (typeof code === "string")
|
|
389
|
+
return `\`pnpm install\` failed (${code})`;
|
|
390
|
+
return "`pnpm install` failed";
|
|
391
|
+
}
|
|
392
|
+
// A pack that was renamed or folded into another, mapped to its successor so
|
|
393
|
+
// an old `--pack` name gets a pointed hint rather than a bare "unknown pack".
|
|
394
|
+
const RENAMED_PACKS = new Map([
|
|
395
|
+
["statusline", "harness-extras"],
|
|
396
|
+
]);
|
|
397
|
+
/**
|
|
398
|
+
* Loads one `--pack` for fresh mode -- called for every pack before any file
|
|
399
|
+
* is written. An unknown name (with a rename hint when {@link RENAMED_PACKS}
|
|
400
|
+
* knows its successor) or a fresh-incompatible pack is a usage error (exit
|
|
401
|
+
* 2); a pack that exists but whose manifest fails to load propagates
|
|
402
|
+
* `loadPack`'s own error unchanged (exit 1), since that is a broken install,
|
|
403
|
+
* not a bad invocation.
|
|
404
|
+
*/
|
|
405
|
+
function resolveFreshPack(name) {
|
|
406
|
+
const available = listPackNames();
|
|
407
|
+
if (!available.includes(name)) {
|
|
408
|
+
const successor = RENAMED_PACKS.get(name);
|
|
409
|
+
const hint = successor === undefined
|
|
410
|
+
? ""
|
|
411
|
+
: ` -- it was renamed to "${successor}"; use --pack ${successor}`;
|
|
412
|
+
const list = available.length > 0 ? available.join(", ") : "none";
|
|
413
|
+
throw new CliUsageError(`unknown pack "${name}"${hint} (available: ${list})\n\n${USAGE}`);
|
|
414
|
+
}
|
|
415
|
+
const pack = loadPack(name);
|
|
416
|
+
if (!pack.manifest.modes.includes("fresh")) {
|
|
417
|
+
throw new CliUsageError(`pack "${name}" does not support fresh mode (modes: ${pack.manifest.modes.join(", ")})\n\n${USAGE}`);
|
|
418
|
+
}
|
|
419
|
+
return pack;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Rejects flags and targets adopt mode cannot honor, as usage errors (exit
|
|
423
|
+
* 2) rather than silently ignoring them: a missing target directory (there
|
|
424
|
+
* is nothing to survey), `--force` (adopt mode never writes project files),
|
|
425
|
+
* and `--pack` (every pack is surveyed automatically).
|
|
426
|
+
*/
|
|
427
|
+
function assertAdoptUsage(options) {
|
|
428
|
+
if (!existsSync(options.targetDir)) {
|
|
429
|
+
throw new CliUsageError(`${options.targetDir} does not exist -- --adopt needs an existing project to survey (use fresh mode, or omit --adopt, to bootstrap a new one)\n\n${USAGE}`);
|
|
430
|
+
}
|
|
431
|
+
if (options.force) {
|
|
432
|
+
throw new CliUsageError(`--force has no effect in adopt mode -- adopt mode never writes project files; run /customize to reconcile instead.\n\n${USAGE}`);
|
|
433
|
+
}
|
|
434
|
+
if (options.packs.length > 0) {
|
|
435
|
+
throw new CliUsageError(`--pack has no effect in adopt mode -- every pack is surveyed automatically; run /customize to install one.\n\n${USAGE}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Adopt mode's write-scope invariant (docs/assurance-case.md's trust
|
|
440
|
+
* boundary around the adopted project): every path it writes resolves under
|
|
441
|
+
* `<targetDir>/.groundwork/` or the guarded `/customize` install at
|
|
442
|
+
* `<targetDir>/.claude/skills/customize/` -- never an existing project file.
|
|
443
|
+
* `paths` may be absolute or relative to `targetDir`. Containment is
|
|
444
|
+
* {@link isPathContained}'s, so a sibling that merely shares a root's name
|
|
445
|
+
* as a prefix (`.groundwork-evil/`) is rejected. Both roots are derived from
|
|
446
|
+
* `customize-paths.ts`'s segment constants, so they cannot drift from where
|
|
447
|
+
* the install actually writes. The check is lexical -- it never touches the
|
|
448
|
+
* filesystem, so it does not detect a symlinked directory component; that
|
|
449
|
+
* refusal lives in the writers themselves (`fs-guard.ts`).
|
|
450
|
+
*
|
|
451
|
+
* @throws `AssertionError` (from `node:assert/strict`) naming the first path
|
|
452
|
+
* that escapes both allowed roots.
|
|
453
|
+
*
|
|
454
|
+
* @example
|
|
455
|
+
* ```ts
|
|
456
|
+
* import { assertAdoptWriteScope } from "./main.js";
|
|
457
|
+
*
|
|
458
|
+
* assertAdoptWriteScope("/work/app", [".groundwork/inventory.json"]); // ok
|
|
459
|
+
* assertAdoptWriteScope("/work/app", ["/work/app/package.json"]); // throws
|
|
460
|
+
* ```
|
|
461
|
+
*/
|
|
462
|
+
export function assertAdoptWriteScope(targetDir, paths) {
|
|
463
|
+
const allowedRoots = [
|
|
464
|
+
resolve(targetDir, GROUNDWORK_DEST_SEGMENTS[0]),
|
|
465
|
+
resolve(targetDir, ...CLAUDE_DEST_SEGMENTS),
|
|
466
|
+
];
|
|
467
|
+
for (const path of paths) {
|
|
468
|
+
const resolved = resolve(targetDir, path);
|
|
469
|
+
assert.ok(allowedRoots.some((root) => isPathContained(resolved, root)), `adopt mode wrote outside its scope: ${resolved}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/** The previous run's files `runAdopt` deletes at its point of no return, in deletion order. */
|
|
473
|
+
const STALE_FILE_NAMES = [
|
|
474
|
+
"inventory.json",
|
|
475
|
+
"adoption-report.md",
|
|
476
|
+
"adoption-decisions.json",
|
|
477
|
+
];
|
|
478
|
+
/**
|
|
479
|
+
* Which previous `.groundwork/` files a failed run actually removed, worded
|
|
480
|
+
* for a message: only those in `removed` are named, and the decisions file
|
|
481
|
+
* carries its "a re-run does not recreate it" caveat only when it is one of
|
|
482
|
+
* them.
|
|
483
|
+
*/
|
|
484
|
+
function describeRemoved(removed) {
|
|
485
|
+
if (removed.length === 0) {
|
|
486
|
+
return "no previous .groundwork/ inventory, report or decisions file was removed";
|
|
487
|
+
}
|
|
488
|
+
const names = removed.length === 1
|
|
489
|
+
? removed.join("")
|
|
490
|
+
: `${removed.slice(0, -1).join(", ")} and ${removed.slice(-1).join("")}`;
|
|
491
|
+
const verb = removed.length === 1 ? "was" : "were";
|
|
492
|
+
const decisionsCaveat = removed.includes("adoption-decisions.json")
|
|
493
|
+
? " -- adoption-decisions.json held the decisions /customize recorded, which a re-run does not recreate"
|
|
494
|
+
: "";
|
|
495
|
+
return `the previous .groundwork/${names} ${verb} removed${decisionsCaveat}`;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* A failure after `runAdopt`'s point of no return. The message embeds the
|
|
499
|
+
* cause's own message so it stands alone (`formatErrorChain` then skips the
|
|
500
|
+
* redundant `caused by:` line), names only the previous files actually
|
|
501
|
+
* removed, and appends the re-run advice unless the cause already ends
|
|
502
|
+
* with equivalent advice in any wording ({@link endsWithRerunAdvice}).
|
|
503
|
+
*/
|
|
504
|
+
function removedStaleFilesError(cause, removed) {
|
|
505
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
506
|
+
const advice = endsWithRerunAdvice(reason) ? "" : `; ${FIX_AND_RERUN_ADVICE}`;
|
|
507
|
+
return new Error(`adopt mode failed (${reason}); ${describeRemoved(removed)}${advice}`, { cause });
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Rethrows a failure after `runAdopt`'s point of no return. An
|
|
511
|
+
* `AssertionError` (a broken invariant: a bug, not something a re-run
|
|
512
|
+
* fixes) keeps its identity, after one warning naming the previous files it
|
|
513
|
+
* removed, so that loss is not silent; anything else becomes
|
|
514
|
+
* {@link removedStaleFilesError}.
|
|
515
|
+
*/
|
|
516
|
+
function rethrowAfterPointOfNoReturn(cause, removed) {
|
|
517
|
+
if (cause instanceof assert.AssertionError) {
|
|
518
|
+
if (removed.length > 0) {
|
|
519
|
+
console.warn(`warning: adopt mode stopped on a broken invariant after ${describeRemoved(removed)}`);
|
|
520
|
+
}
|
|
521
|
+
throw cause;
|
|
522
|
+
}
|
|
523
|
+
throw removedStaleFilesError(cause, removed);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* The adopt-mode next step after a `.groundwork/customize/` fallback, chosen
|
|
527
|
+
* by why it was taken: only an `"entry"` fallback has a project-local copy
|
|
528
|
+
* of the skill to replace. Returned without a `Next: ` prefix and starting
|
|
529
|
+
* lower-case; each caller adds its own framing. `undefined` -- which the installer never returns
|
|
530
|
+
* alongside a `"groundwork"` location -- is a contract violation and throws
|
|
531
|
+
* rather than guessing.
|
|
532
|
+
*/
|
|
533
|
+
function groundworkNextStep(cause) {
|
|
534
|
+
const staged = "the current /customize skill is staged at .groundwork/customize/, but Claude Code does not load skills from there";
|
|
535
|
+
switch (cause) {
|
|
536
|
+
case "component":
|
|
537
|
+
return `${staged}, and no project-local .claude/skills/customize/ copy exists -- fix or replace the .claude path named above so .claude/skills/customize/ is a real directory, copy the staged skill there and then run /customize, or run the m3l-groundwork plugin's own /customize.`;
|
|
538
|
+
case "entry":
|
|
539
|
+
return `${staged} -- run the m3l-groundwork plugin's own /customize, or replace the project-local .claude/skills/customize/ copy with the staged one and then run /customize.`;
|
|
540
|
+
case undefined:
|
|
541
|
+
throw new Error('unhandled fallback cause: undefined (a "groundwork" install result must carry fallbackCause)');
|
|
542
|
+
default: {
|
|
543
|
+
const exhaustive = cause;
|
|
544
|
+
throw new Error(`unhandled fallback cause: ${String(exhaustive)}`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* The report's `## Next step` text for a `"groundwork"` install result: the
|
|
550
|
+
* fallback reason first, then {@link groundworkNextStep}'s sentence -- the
|
|
551
|
+
* same order the console prints them in, which the `"component"` text's
|
|
552
|
+
* "the .claude path named above" depends on. `renderReport` strips one
|
|
553
|
+
* leading `Next: ` and upper-cases only the text's first character, so the
|
|
554
|
+
* reason carries that prefix and the sentence after it is capitalized here.
|
|
555
|
+
* A missing reason keeps the sentence alone rather than printing `undefined`.
|
|
556
|
+
*/
|
|
557
|
+
function groundworkReportNextStep(result) {
|
|
558
|
+
const sentence = groundworkNextStep(result.fallbackCause);
|
|
559
|
+
const reason = result.fallbackReason;
|
|
560
|
+
if (reason === undefined) {
|
|
561
|
+
return `Next: ${sentence}`;
|
|
562
|
+
}
|
|
563
|
+
const separator = /[.!?]$/.test(reason) ? " " : ". ";
|
|
564
|
+
return `Next: ${reason}${separator}${sentence.charAt(0).toUpperCase()}${sentence.slice(1)}`;
|
|
231
565
|
}
|
|
232
566
|
/**
|
|
233
567
|
* Surveys an already-established project and writes `.groundwork/` --
|
|
@@ -235,53 +569,216 @@ function runFresh(options) {
|
|
|
235
569
|
* the one addition is a purely-additive, collision-guarded copy of the
|
|
236
570
|
* `/customize` skill (see `installCustomizeSkillGuarded`), so the report
|
|
237
571
|
* can point straight at a working next step. Every pack under
|
|
238
|
-
* `templates/packs/` is surveyed (
|
|
239
|
-
*
|
|
572
|
+
* `templates/packs/` is surveyed (`main` rejects `--pack` in this mode, see
|
|
573
|
+
* `assertAdoptUsage`) and staged, unapplied and inert, at
|
|
574
|
+
* `.groundwork/packs/<name>/` -- its manifest as `pack.json.staged`, its
|
|
575
|
+
* files as `files/<path>.staged` -- all packs written to a temporary
|
|
576
|
+
* sibling directory and swapped in by rename, so `packs/` is never
|
|
577
|
+
* half-written (`stagePacks`); absent baseline files are staged the same
|
|
578
|
+
* way as inert `<path>.staged` copies at `.groundwork/baseline/`. With a
|
|
579
|
+
* previous staging in place each swap is two renames (park the old
|
|
580
|
+
* directory, then move the new one in); a kill between them leaves that
|
|
581
|
+
* directory absent with the old copy under its `.packs-*`/`.baseline-*`
|
|
582
|
+
* work directory's `previous/` -- safe, because `inventory.json` was
|
|
583
|
+
* already deleted, so nothing reads the gap as a completed run.
|
|
584
|
+
*
|
|
585
|
+
* Checked before anything under `.groundwork/` is deleted or written, in
|
|
586
|
+
* this order: the three stale-file paths are scope-checked against
|
|
587
|
+
* {@link assertAdoptWriteScope}; every pack is loaded and validated by
|
|
588
|
+
* `loadPack` and surveyed (`planConflicts`, `observeWiring`); the pack
|
|
589
|
+
* staging plan (`planPackStaging`) and the baseline staging plan
|
|
590
|
+
* (`planBaselineStaging`) are each computed once, validated, and every path
|
|
591
|
+
* they would write scope-checked; the `/customize` skill's planned install
|
|
592
|
+
* paths (`plannedCustomizeSkillPaths`, a lexical check of its path
|
|
593
|
+
* constants) are scope-checked; then `.groundwork/`, `.groundwork/packs`
|
|
594
|
+
* and `.groundwork/baseline` are each refused if they are a symlink. So an
|
|
595
|
+
* invalid pack (a malformed manifest, a prototype-sensitive key in its
|
|
596
|
+
* wiring, an unstageable file tree), an invalid baseline plan, an
|
|
597
|
+
* out-of-scope staging or skill-install path, or a symlinked staging
|
|
598
|
+
* directory throws with the previous `.groundwork/` untouched.
|
|
599
|
+
*
|
|
600
|
+
* **The point of no return** is the first deletion of a stale
|
|
601
|
+
* `inventory.json`/`adoption-report.md`/`adoption-decisions.json` (in that
|
|
602
|
+
* order), which follows those checks; which of the three existed is
|
|
603
|
+
* recorded just before. After the deletions, packs and the baseline are
|
|
604
|
+
* staged from the plans already computed (their trees are not walked
|
|
605
|
+
* again), the harness and toolchain are graded, the `/customize` skill is
|
|
606
|
+
* installed and its writes scope-checked, `adoption-report.md` is written
|
|
607
|
+
* (its `## Next step` leading with the same sentence the console prints
|
|
608
|
+
* when the skill fell back to `.groundwork/customize/`),
|
|
609
|
+
* and `inventory.json` is written last (atomically, via a temp file and
|
|
610
|
+
* rename). A failure in any of those steps -- a failed deletion included --
|
|
611
|
+
* is rethrown as an `Error` with the failure as `cause` and its message
|
|
612
|
+
* embedded, naming only the previous files that existed and were actually
|
|
613
|
+
* removed (and, when `adoption-decisions.json` is among them, that it held
|
|
614
|
+
* the decisions `/customize` recorded, which a re-run does not recreate),
|
|
615
|
+
* and saying to fix the cause and re-run the CLI -- once, even when the
|
|
616
|
+
* cause's own message already says so. An `AssertionError` (a broken
|
|
617
|
+
* write-scope or containment invariant, a bug a re-run cannot fix) keeps
|
|
618
|
+
* its identity instead, after one `console.warn` naming the removed files
|
|
619
|
+
* (none when nothing was removed).
|
|
620
|
+
*
|
|
621
|
+
* If `inventory.json` fails to write, the just-written report is first
|
|
622
|
+
* removed (best effort: a failed removal only warns, never masking the
|
|
623
|
+
* write failure). Only console output follows `inventory.json`, so its
|
|
624
|
+
* presence means every step of the run completed.
|
|
240
625
|
*/
|
|
241
626
|
function runAdopt(options, detection) {
|
|
242
|
-
if (options.force) {
|
|
243
|
-
throw new Error("--force has no effect in adopt mode -- adopt mode never writes project files; run /customize to reconcile instead.");
|
|
244
|
-
}
|
|
245
627
|
console.log(`adopt mode: ${detection.signal}`);
|
|
246
628
|
const survey = surveyProject(options.targetDir);
|
|
247
629
|
const templateRoot = templatesCoreDir();
|
|
248
630
|
const tokens = buildTokens(options.projectName);
|
|
249
|
-
|
|
631
|
+
// The survey's own `undetermined` list: a baseline or pack target this
|
|
632
|
+
// process cannot reach is recorded there (and so in the report), never
|
|
633
|
+
// reported as a clean add.
|
|
634
|
+
const conflicts = planConflicts(templateRoot, options.targetDir, tokens, survey.undetermined);
|
|
250
635
|
const groundworkDir = join(options.targetDir, ".groundwork");
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
name: pack.manifest.name,
|
|
258
|
-
modes: pack.manifest.modes,
|
|
259
|
-
budget: pack.manifest.budget,
|
|
260
|
-
fileConflicts,
|
|
261
|
-
wiring: pack.manifest.wiring,
|
|
262
|
-
wiringObservations,
|
|
263
|
-
adoptNotes: pack.manifest.adoptNotes,
|
|
264
|
-
};
|
|
265
|
-
});
|
|
266
|
-
const inventory = buildInventory({
|
|
267
|
-
detection,
|
|
268
|
-
templateRoot,
|
|
269
|
-
targetDir: options.targetDir,
|
|
270
|
-
survey,
|
|
271
|
-
conflicts,
|
|
272
|
-
packs,
|
|
273
|
-
harnessGrade: gradeHarness(options.targetDir),
|
|
274
|
-
toolchainGrade: gradeToolchain(options.targetDir),
|
|
275
|
-
});
|
|
276
|
-
const inventoryPath = writeInventory(inventory, groundworkDir);
|
|
636
|
+
const stagedBaselineDir = `.groundwork/${STAGED_BASELINE_DIR}`;
|
|
637
|
+
const stalePaths = STALE_FILE_NAMES.map((name) => ({
|
|
638
|
+
name,
|
|
639
|
+
path: join(groundworkDir, name),
|
|
640
|
+
}));
|
|
641
|
+
const inventoryPath = join(groundworkDir, "inventory.json");
|
|
277
642
|
const reportPath = join(groundworkDir, "adoption-report.md");
|
|
278
|
-
|
|
643
|
+
assertAdoptWriteScope(options.targetDir, stalePaths.map(({ path }) => path));
|
|
644
|
+
// Every pack must pass loadPack's validation (including its
|
|
645
|
+
// prototype-sensitive wiring-key check), and both staging plans (packs and
|
|
646
|
+
// baseline) must be computed and scope-checked, before anything under
|
|
647
|
+
// .groundwork/ is touched, so an invalid pack or plan fails the run with
|
|
648
|
+
// the previous inventory/report still intact rather than half-cleared.
|
|
649
|
+
const loadedPacks = listPackNames().map((name) => loadPack(name));
|
|
650
|
+
const packs = loadedPacks.map((pack) => ({
|
|
651
|
+
name: pack.manifest.name,
|
|
652
|
+
modes: pack.manifest.modes,
|
|
653
|
+
budget: pack.manifest.budget,
|
|
654
|
+
fileConflicts: planConflicts(pack.filesDir, options.targetDir, tokens, survey.undetermined),
|
|
655
|
+
wiring: pack.manifest.wiring,
|
|
656
|
+
wiringObservations: observeWiring(options.targetDir, pack.manifest, survey.undetermined),
|
|
657
|
+
adoptNotes: pack.manifest.adoptNotes,
|
|
658
|
+
}));
|
|
659
|
+
// Each plan is computed once, here, and handed to its stager below, so
|
|
660
|
+
// what was scope-checked is exactly what gets written.
|
|
661
|
+
const packPlan = planPackStaging(loadedPacks, groundworkDir, tokens);
|
|
662
|
+
assertAdoptWriteScope(options.targetDir, packPlan.paths);
|
|
663
|
+
const baselinePlan = planBaselineStaging(templateRoot, conflicts, groundworkDir, tokens);
|
|
664
|
+
assertAdoptWriteScope(options.targetDir, baselinePlan.paths);
|
|
665
|
+
// A constant drift guard only: it lexically checks paths built from
|
|
666
|
+
// customize-paths.ts's constants, so it can catch one of those constants
|
|
667
|
+
// being edited to escape the project, and nothing else -- it reads no
|
|
668
|
+
// filesystem state, so it cannot detect a symlink or any other runtime
|
|
669
|
+
// condition. The installer lstat-checks every directory component itself
|
|
670
|
+
// (and falls back to .groundwork/customize/ when one under .claude/ is a
|
|
671
|
+
// symlink or not a directory -- see fallbackReason below).
|
|
672
|
+
assertAdoptWriteScope(options.targetDir, plannedCustomizeSkillPaths());
|
|
673
|
+
// A symlinked .groundwork/ or staging directory would redirect a delete
|
|
674
|
+
// or a write outside the project; refuse it before anything is deleted.
|
|
675
|
+
// (Each stager repeats its own check; these make the refusal precede the
|
|
676
|
+
// deletions below.)
|
|
677
|
+
assertNotSymlink(groundworkDir);
|
|
678
|
+
assertNotSymlink(join(options.targetDir, STAGED_PACKS_DIR));
|
|
679
|
+
assertNotSymlink(join(groundworkDir, STAGED_BASELINE_DIR));
|
|
680
|
+
// Recorded before any deletion, so a failure message names only files a
|
|
681
|
+
// previous run actually left (and this run actually removed).
|
|
682
|
+
const preexisting = new Set(stalePaths.filter(({ path }) => existsSync(path)).map(({ name }) => name));
|
|
683
|
+
const removed = [];
|
|
684
|
+
let inventory;
|
|
685
|
+
let pluginResult;
|
|
686
|
+
let nextStep;
|
|
687
|
+
try {
|
|
688
|
+
// The point of no return: the first deletion. A previous run's
|
|
689
|
+
// inventory/report -- and the decisions /customize recorded against
|
|
690
|
+
// them -- must not survive a run that fails part-way: /customize would
|
|
691
|
+
// read them as describing the new staging. All three go before anything
|
|
692
|
+
// is staged; inventory/report are rewritten only at the end, and the
|
|
693
|
+
// decisions file only by /customize. Inside the wrapping, so a failed
|
|
694
|
+
// deletion reports which files are already gone. Every path is removed,
|
|
695
|
+
// not just the pre-existing ones: existsSync follows symlinks, so a
|
|
696
|
+
// dangling one must still go before the report's "wx" write below.
|
|
697
|
+
for (const { name, path } of stalePaths) {
|
|
698
|
+
rmSync(path, { force: true });
|
|
699
|
+
if (preexisting.has(name)) {
|
|
700
|
+
removed.push(name);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
const stagedPacks = stagePacks(loadedPacks, groundworkDir, tokens, packPlan);
|
|
704
|
+
const stagedBaselineFiles = stageBaselineAdditions(templateRoot, conflicts, groundworkDir, tokens, baselinePlan);
|
|
705
|
+
inventory = buildInventory({
|
|
706
|
+
detection,
|
|
707
|
+
templateRoot,
|
|
708
|
+
targetDir: options.targetDir,
|
|
709
|
+
survey,
|
|
710
|
+
conflicts,
|
|
711
|
+
packs,
|
|
712
|
+
harnessGrade: gradeHarness(options.targetDir),
|
|
713
|
+
toolchainGrade: gradeToolchain(options.targetDir),
|
|
714
|
+
stagedBaseline: {
|
|
715
|
+
dir: stagedBaselineDir,
|
|
716
|
+
suffix: STAGED_SUFFIX,
|
|
717
|
+
files: stagedBaselineFiles,
|
|
718
|
+
},
|
|
719
|
+
stagedPacks,
|
|
720
|
+
});
|
|
721
|
+
// The /customize skill install runs before the two .groundwork/ files
|
|
722
|
+
// (the report's "wx" write below can still fail after it), so
|
|
723
|
+
// inventory.json's presence still means the install completed. Its
|
|
724
|
+
// planned paths were scope-checked before the deletions above.
|
|
725
|
+
pluginResult = installCustomizeSkillGuarded(options.targetDir);
|
|
726
|
+
// Deliberately no rollback if this throws: what the installer reports it
|
|
727
|
+
// wrote must sit inside the planned scope, so a failure here means the
|
|
728
|
+
// installer and customize-paths.ts drifted apart -- a programming error
|
|
729
|
+
// to surface loudly, not a runtime condition to recover from.
|
|
730
|
+
assertAdoptWriteScope(options.targetDir, pluginResult.filesWritten);
|
|
731
|
+
// Resolved before the report and inventory are written, so a contract
|
|
732
|
+
// violation in the result fails the run before inventory.json claims it
|
|
733
|
+
// completed.
|
|
734
|
+
// The fresh copy is staged where Claude Code never loads a skill from,
|
|
735
|
+
// so the generic next step would be false there; the report then names
|
|
736
|
+
// the fallback reason and the same sentence the console prints, in the
|
|
737
|
+
// console's order. Otherwise the report keeps its own generic sentence.
|
|
738
|
+
const isGroundwork = pluginResult.location === "groundwork";
|
|
739
|
+
nextStep = isGroundwork
|
|
740
|
+
? `Next: ${groundworkNextStep(pluginResult.fallbackCause)}`
|
|
741
|
+
: "Next: open this project in Claude Code and run /customize.";
|
|
742
|
+
const reportNextStep = isGroundwork
|
|
743
|
+
? groundworkReportNextStep(pluginResult)
|
|
744
|
+
: undefined;
|
|
745
|
+
// The report next, inventory.json last (written atomically): nothing
|
|
746
|
+
// that can fail follows it, so its presence means the run completed.
|
|
747
|
+
mkdirSync(groundworkDir, { recursive: true });
|
|
748
|
+
// "wx": the path was removed above, so anything there now (a symlink
|
|
749
|
+
// raced in mid-run) makes the write fail instead of being followed.
|
|
750
|
+
writeFileSync(reportPath, renderReport(inventory, reportNextStep), {
|
|
751
|
+
flag: "wx",
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
catch (cause) {
|
|
755
|
+
rethrowAfterPointOfNoReturn(cause, removed);
|
|
756
|
+
}
|
|
757
|
+
const { stagedPacks } = inventory;
|
|
758
|
+
const stagedBaselineFiles = inventory.stagedBaseline.files;
|
|
759
|
+
try {
|
|
760
|
+
writeInventory(inventory, groundworkDir);
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
// Without inventory.json the run did not complete; a report left behind
|
|
764
|
+
// would read as if it had. Best effort: a failed removal only warns, so
|
|
765
|
+
// it can never mask the write failure being rethrown.
|
|
766
|
+
try {
|
|
767
|
+
rmSync(reportPath, { force: true });
|
|
768
|
+
}
|
|
769
|
+
catch (cleanupError) {
|
|
770
|
+
console.warn(`warning: could not remove ${reportPath} after inventory.json failed to write -- delete it by hand (${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)})`);
|
|
771
|
+
}
|
|
772
|
+
rethrowAfterPointOfNoReturn(error, removed);
|
|
773
|
+
}
|
|
279
774
|
console.log(`wrote ${relative(options.targetDir, inventoryPath)}`);
|
|
280
775
|
console.log(`wrote ${relative(options.targetDir, reportPath)}`);
|
|
281
|
-
if (
|
|
282
|
-
console.log(`staged ${
|
|
776
|
+
if (stagedBaselineFiles.length > 0) {
|
|
777
|
+
console.log(`staged ${stagedBaselineFiles.length} baseline file(s) at ${stagedBaselineDir}/ for /customize`);
|
|
778
|
+
}
|
|
779
|
+
if (stagedPacks.length > 0) {
|
|
780
|
+
console.log(`staged ${stagedPacks.length} pack(s) at ${STAGED_PACKS_DIR}/ for /customize`);
|
|
283
781
|
}
|
|
284
|
-
const pluginResult = installCustomizeSkillGuarded(options.targetDir);
|
|
285
782
|
if (pluginResult.location === "already-present") {
|
|
286
783
|
console.log("the /customize skill was already up to date");
|
|
287
784
|
}
|
|
@@ -290,11 +787,20 @@ function runAdopt(options, detection) {
|
|
|
290
787
|
? ".claude/skills/customize/"
|
|
291
788
|
: ".groundwork/customize/";
|
|
292
789
|
console.log(`installed the /customize skill into ${where} (${pluginResult.filesWritten.length} files)`);
|
|
790
|
+
if (pluginResult.fallbackReason !== undefined) {
|
|
791
|
+
console.log(` ${pluginResult.fallbackReason}`);
|
|
792
|
+
}
|
|
293
793
|
}
|
|
294
|
-
console.log(`\n✓ adoption report ready at ${reportPath}`);
|
|
295
|
-
console.log(
|
|
794
|
+
console.log(paint(process.stdout, "success", `\n✓ adoption report ready at ${reportPath}`));
|
|
795
|
+
console.log(nextStep);
|
|
296
796
|
}
|
|
297
|
-
|
|
797
|
+
/**
|
|
798
|
+
* Runs the CLI. `platform` is injectable so fresh mode's Windows refusal
|
|
799
|
+
* (a runtime error, exit 1, raised before anything is written) is
|
|
800
|
+
* unit-testable on any OS; it defaults to `process.platform`. Adopt mode and
|
|
801
|
+
* `--help`/`--version`/`--list-packs` run on every platform.
|
|
802
|
+
*/
|
|
803
|
+
export function main(argv, platform = process.platform) {
|
|
298
804
|
const options = parseArgs(argv);
|
|
299
805
|
if (options.help) {
|
|
300
806
|
console.log(USAGE);
|
|
@@ -323,10 +829,11 @@ export function main(argv) {
|
|
|
323
829
|
fresh: options.fresh,
|
|
324
830
|
});
|
|
325
831
|
if (resolved.mode === "adopt") {
|
|
832
|
+
assertAdoptUsage(options);
|
|
326
833
|
runAdopt(options, resolved);
|
|
327
834
|
}
|
|
328
835
|
else {
|
|
329
|
-
runFresh(options);
|
|
836
|
+
runFresh(options, platform);
|
|
330
837
|
}
|
|
331
838
|
}
|
|
332
839
|
//# sourceMappingURL=main.js.map
|