create-cmp-cli 0.14.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +54 -353
  2. package/bin/create-cmp.mjs +19 -3
  3. package/llms.txt +3 -3
  4. package/options.schema.json +4 -0
  5. package/package.json +2 -2
  6. package/packages/harness/package.json +9 -1
  7. package/packages/harness/src/lib/harness-lock.mjs +2 -2
  8. package/packages/harness/src/lib/inputs-hash.mjs +1 -1
  9. package/packages/harness/src/lib/receipt-validate.mjs +1 -1
  10. package/packages/harness/src/receipt-check.mjs +1 -1
  11. package/packages/receipts/package.json +11 -3
  12. package/packages/receipts/src/index.mjs +1 -1
  13. package/packages/receipts/src/inputs-hash.mjs +1 -1
  14. package/packages/receipts/src/receipt-validate.mjs +1 -1
  15. package/src/commands/attach.mjs +250 -0
  16. package/src/commands/create.mjs +13 -2
  17. package/src/commands/harden.mjs +263 -0
  18. package/src/lib/adr-seed.mjs +27 -0
  19. package/src/lib/harness-upgrade.mjs +4 -0
  20. package/src/lib/hooks.mjs +140 -0
  21. package/src/lib/minimal.mjs +130 -0
  22. package/src/lib/toggle.mjs +4 -1
  23. package/src/lib/verify.mjs +6 -1
  24. package/src/scaffold.mjs +18 -1
  25. package/template/.github/workflows/verify.yml +15 -0
  26. package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
  27. package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
  28. package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
  29. package/template/.gradle/8.11.1/gc.properties +0 -0
  30. package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  31. package/template/.gradle/buildOutputCleanup/cache.properties +2 -0
  32. package/template/.gradle/vcs-1/gc.properties +0 -0
  33. package/template/AGENTS.md +57 -8
  34. package/template/CLAUDE.md +49 -0
  35. package/template/CONTRIBUTING.md +13 -0
  36. package/template/README.md +33 -0
  37. package/template/docs/ARCHITECTURE.md +18 -1
  38. package/template/docs/TESTING.md +10 -0
  39. package/template/manifest.json +13 -0
  40. package/template/qa/lib/harness-lock.mjs +2 -2
  41. package/template/qa/lib/inputs-hash.mjs +1 -1
  42. package/template/qa/lib/receipt-validate.mjs +1 -1
  43. package/template/qa/receipt-check.mjs +1 -1
@@ -5,7 +5,7 @@
5
5
  // there is exactly one definition of the surface and the algorithm.
6
6
  //
7
7
  // SINGLE SOURCE OF TRUTH: packages/receipts/src/inputs-hash.mjs in the
8
- // create-cmp repo (the `cmp-receipts` package). The copy in a generated
8
+ // create-cmp repo (the `@create-cmp/receipts` package). The copy in a generated
9
9
  // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
10
10
  // test/receipts-parity.test.mjs — edit the package source, then run
11
11
  // `node scripts/sync-harness.mjs`.
@@ -6,7 +6,7 @@
6
6
  // tarball rather than the working tree.
7
7
  //
8
8
  // SINGLE SOURCE OF TRUTH: packages/receipts/src/receipt-validate.mjs in the
9
- // create-cmp repo (the `cmp-receipts` package). The copy in a generated
9
+ // create-cmp repo (the `@create-cmp/receipts` package). The copy in a generated
10
10
  // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
11
11
  // test/receipts-parity.test.mjs — edit the package source, then run
12
12
  // `node scripts/sync-harness.mjs`.
@@ -39,7 +39,7 @@ function readStdinJson() {
39
39
  }
40
40
 
41
41
  // The predicate itself lives in qa/lib/receipt-validate.mjs (vendored from the
42
- // cmp-receipts package — one definition everywhere a receipt is judged); this
42
+ // @create-cmp/receipts package — one definition everywhere a receipt is judged); this
43
43
  // CLI only reads the receipt and frames the exit codes.
44
44
  function evaluate() {
45
45
  const receipt = readReceipt(ROOT);
@@ -1,7 +1,7 @@
1
1
  {
2
- "name": "cmp-receipts",
2
+ "name": "@create-cmp/receipts",
3
3
  "version": "0.1.0",
4
- "description": "Validate create-cmp evidence receipts: inputs-hash recompute, receipt-vs-tree binding, freshness, and execution-plausibility. Dependency-free ESM the single source of truth vendored into every generated project's qa/lib/ and consumed by the hosted receipt check.",
4
+ "description": "Validate create-cmp evidence receipts: inputs-hash recompute, receipt-vs-tree binding, freshness, and execution-plausibility. Dependency-free ESM \u2014 the single source of truth vendored into every generated project's qa/lib/ and consumed by the hosted receipt check.",
5
5
  "type": "module",
6
6
  "main": "src/index.mjs",
7
7
  "exports": {
@@ -14,7 +14,8 @@
14
14
  "README.md"
15
15
  ],
16
16
  "scripts": {
17
- "test": "node --test"
17
+ "test": "node --test",
18
+ "prepublishOnly": "npm test"
18
19
  },
19
20
  "engines": {
20
21
  "node": ">=18"
@@ -32,5 +33,12 @@
32
33
  "type": "git",
33
34
  "url": "git+https://github.com/kvdm-co-pilot/create-cmp.git",
34
35
  "directory": "packages/receipts"
36
+ },
37
+ "homepage": "https://github.com/kvdm-co-pilot/create-cmp/tree/main/packages/receipts#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/kvdm-co-pilot/create-cmp/issues"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
35
43
  }
36
44
  }
@@ -1,4 +1,4 @@
1
- // cmp-receipts — validate create-cmp evidence receipts.
1
+ // @create-cmp/receipts — validate create-cmp evidence receipts.
2
2
  // Single source of truth for the inputs-hash algorithm and the receipt
3
3
  // predicate; vendored byte-identical into every generated project's qa/lib/
4
4
  // and consumed by hosted validators. Dependency-free ESM.
@@ -5,7 +5,7 @@
5
5
  // there is exactly one definition of the surface and the algorithm.
6
6
  //
7
7
  // SINGLE SOURCE OF TRUTH: packages/receipts/src/inputs-hash.mjs in the
8
- // create-cmp repo (the `cmp-receipts` package). The copy in a generated
8
+ // create-cmp repo (the `@create-cmp/receipts` package). The copy in a generated
9
9
  // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
10
10
  // test/receipts-parity.test.mjs — edit the package source, then run
11
11
  // `node scripts/sync-harness.mjs`.
@@ -6,7 +6,7 @@
6
6
  // tarball rather than the working tree.
7
7
  //
8
8
  // SINGLE SOURCE OF TRUTH: packages/receipts/src/receipt-validate.mjs in the
9
- // create-cmp repo (the `cmp-receipts` package). The copy in a generated
9
+ // create-cmp repo (the `@create-cmp/receipts` package). The copy in a generated
10
10
  // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
11
11
  // test/receipts-parity.test.mjs — edit the package source, then run
12
12
  // `node scripts/sync-harness.mjs`.
@@ -0,0 +1,250 @@
1
+ // `create-cmp attach` — M0a: the agent contract for an EXISTING Compose/KMP
2
+ // repo that create-cmp never scaffolded (docs/features/attach-mode.md is the
3
+ // design of record; LADDER §R5).
4
+ //
5
+ // The honesty constraint IS the design: a foreign repo carries none of the
6
+ // stamped machinery our discovery surfaces normally point at, so attach
7
+ // writes ONLY surfaces that are true in any Compose/KMP repo — the engine's
8
+ // own doctor/upgrade at the toolchain walls, and an advisory SessionStart
9
+ // context that says plainly what is and is not wired here. Previews, the
10
+ // lane, and enforcement are named as NOT wired (M0b's staged scope), never
11
+ // faked. Files are never clobbered: an existing differing file keeps its
12
+ // bytes and our content lands beside it as `*.cmp-new` (the same sidecar
13
+ // convention as `upgrade --harness` and `harden`).
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ import { colors, ok, warn, fail } from "../lib/log.mjs";
19
+ import { consent } from "../bootstrap/exec.mjs";
20
+ import { sessionStartCommand } from "../lib/hooks.mjs";
21
+ import { SIDECAR_SUFFIX } from "../lib/harness-upgrade.mjs";
22
+
23
+ const COMPOSE_SIGNALS = [
24
+ "org.jetbrains.compose",
25
+ 'kotlin("multiplatform")',
26
+ "org.jetbrains.kotlin.multiplatform",
27
+ "org.jetbrains.kotlin.plugin.compose",
28
+ ];
29
+
30
+ /** SessionStart context for an attached repo. No apostrophes (shell-quoted). */
31
+ export const ATTACH_SESSION_CONTEXT =
32
+ "This Compose/KMP repo carries the create-cmp attach surfaces: AGENTS.md maps " +
33
+ "toolchain walls to zero-consent commands (doctor diagnoses machine AND project, " +
34
+ "upgrade diffs against a proven-green version set). No verify lane, previews, or " +
35
+ "inspector are wired here — do not assume create-cmp scaffold machinery exists in " +
36
+ "this repo. A new app scaffolded with npx create-cmp-cli would carry all of it.";
37
+
38
+ /** The attach rendering of the symptom table — only rows true in ANY Compose/KMP repo. */
39
+ export function attachAgentsMd() {
40
+ return `# Agent instructions
41
+
42
+ This is an existing Compose/KMP repository with the create-cmp **attach surfaces**
43
+ installed: discovery for the walls every KMP build eventually hits. It was NOT
44
+ scaffolded by create-cmp — none of the generated-project machinery (verify lane,
45
+ headless previews, live inspector, generators) is wired here.
46
+
47
+ ## Stuck? Symptom → command
48
+
49
+ Both commands run from the repo root with nothing to install — \`npx\` fetches on demand.
50
+
51
+ | Symptom | Run |
52
+ |---|---|
53
+ | Build broken, toolchain suspect | \`npx create-cmp-cli doctor --fix\` — diagnoses machine AND project (kotlin↔ksp lockstep, catalog drift); asks before any repair |
54
+ | Dependency versions stale or mismatched | \`npx create-cmp-cli upgrade --dry-run\` — diff against the next proven-green set before touching anything |
55
+
56
+ Famous build failures (kotlin↔KSP mismatch, the KSP2/iOS catch-22, \`SDK location not
57
+ found\`, \`No space left on device\`): \`doctor\` diagnoses all of them offline; the worked
58
+ write-ups live upstream at
59
+ <https://github.com/kvdm-co-pilot/create-cmp/tree/main/docs/errors>.
60
+
61
+ ## What is NOT wired here
62
+
63
+ A create-cmp-scaffolded app additionally carries device-free screen previews (structure
64
+ for the agent, pixels for the human), a live on-device inspector, feature generators,
65
+ and a verify lane with evidence receipts and a machine-checked definition of done. Those
66
+ surfaces presuppose scaffold machinery this repo does not have; attach does not fake
67
+ them. To see the full contract, scaffold with \`npx create-cmp-cli@latest\`.
68
+ `;
69
+ }
70
+
71
+ function attachSettings() {
72
+ return (
73
+ JSON.stringify(
74
+ {
75
+ hooks: {
76
+ SessionStart: [
77
+ {
78
+ matcher: "",
79
+ hooks: [{ type: "command", command: sessionStartCommand(ATTACH_SESSION_CONTEXT) }],
80
+ },
81
+ ],
82
+ },
83
+ },
84
+ null,
85
+ 2
86
+ ) + "\n"
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Classify the target directory. Pure; returns {ok:true} or {ok:false, reason}.
92
+ */
93
+ export function classifyTarget(projectDir) {
94
+ if (fs.existsSync(path.join(projectDir, "create-cmp.json"))) {
95
+ return {
96
+ ok: false,
97
+ reason:
98
+ "this is a create-cmp-stamped app (create-cmp.json present). Attach is for repos " +
99
+ "create-cmp never scaffolded — use `create-cmp harden` (minimal → full) or " +
100
+ "`create-cmp upgrade --harness` (refresh) here instead.",
101
+ };
102
+ }
103
+ const hasGradleSettings =
104
+ fs.existsSync(path.join(projectDir, "settings.gradle.kts")) ||
105
+ fs.existsSync(path.join(projectDir, "settings.gradle"));
106
+ if (!hasGradleSettings) {
107
+ return {
108
+ ok: false,
109
+ reason: "no settings.gradle(.kts) — attach targets an existing Gradle Compose/KMP project.",
110
+ };
111
+ }
112
+ // A Compose/KMP signal anywhere in the repo's build files (two levels deep
113
+ // covers the conventional module layout without a full tree walk).
114
+ const buildFiles = [];
115
+ const collect = (dir, depth) => {
116
+ let entries;
117
+ try {
118
+ entries = fs.readdirSync(dir, { withFileTypes: true });
119
+ } catch {
120
+ return;
121
+ }
122
+ for (const e of entries) {
123
+ if (e.name === "build" || e.name.startsWith(".")) continue;
124
+ const abs = path.join(dir, e.name);
125
+ if (e.isDirectory() && depth > 0) collect(abs, depth - 1);
126
+ else if (/^build\.gradle(\.kts)?$|^libs\.versions\.toml$/.test(e.name)) buildFiles.push(abs);
127
+ }
128
+ };
129
+ collect(projectDir, 2);
130
+ collect(path.join(projectDir, "gradle"), 1);
131
+ const signals = buildFiles.some((f) => {
132
+ try {
133
+ const src = fs.readFileSync(f, "utf8");
134
+ return COMPOSE_SIGNALS.some((s) => src.includes(s));
135
+ } catch {
136
+ return false;
137
+ }
138
+ });
139
+ if (!signals) {
140
+ return {
141
+ ok: false,
142
+ reason:
143
+ "no Compose/KMP signal found in the build files (looked for the Compose or Kotlin " +
144
+ "Multiplatform plugins). Attach only claims to help Compose/KMP repos.",
145
+ };
146
+ }
147
+ return { ok: true };
148
+ }
149
+
150
+ /**
151
+ * Plan (and optionally write) the attach surfaces. Testable core: no exit, no
152
+ * prompt. Each unit lands as one of: current | written | sidecar.
153
+ * @param {object} params
154
+ * @param {string} params.projectDir
155
+ * @param {boolean} [params.apply=false]
156
+ * @returns {{units: Array<{relPath:string, action:string}>, notWired: string[]}}
157
+ */
158
+ export function attachProject({ projectDir, apply = false }) {
159
+ const target = classifyTarget(projectDir);
160
+ if (!target.ok) throw new Error(target.reason);
161
+
162
+ const files = [
163
+ { relPath: "AGENTS.md", content: attachAgentsMd() },
164
+ { relPath: ".claude/settings.json", content: attachSettings() },
165
+ ];
166
+ const units = [];
167
+ for (const { relPath, content } of files) {
168
+ const abs = path.join(projectDir, relPath);
169
+ if (fs.existsSync(abs)) {
170
+ const current = fs.readFileSync(abs, "utf8");
171
+ if (current === content) {
172
+ units.push({ relPath, action: "current" });
173
+ continue;
174
+ }
175
+ if (apply) {
176
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
177
+ fs.writeFileSync(abs + SIDECAR_SUFFIX, content);
178
+ }
179
+ units.push({ relPath, action: "sidecar" });
180
+ continue;
181
+ }
182
+ if (apply) {
183
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
184
+ fs.writeFileSync(abs, content);
185
+ }
186
+ units.push({ relPath, action: "written" });
187
+ }
188
+
189
+ return {
190
+ units,
191
+ notWired: [
192
+ "headless screen previews (PreviewRegistry + renderScreens) — staged M0b, needs a per-repo Gradle wiring design",
193
+ "live on-device inspector — ships with scaffolded apps; not injectable into a foreign build yet",
194
+ "verify lane / evidence receipts / enforcement — the lane addresses the stamped layout by name",
195
+ ],
196
+ };
197
+ }
198
+
199
+ /**
200
+ * `create-cmp attach [dir] [--dry-run] [--yes]`
201
+ */
202
+ export async function runAttach(flags, positional) {
203
+ const targetDir =
204
+ (typeof flags["target-dir"] === "string" && flags["target-dir"]) || positional || ".";
205
+ const projectDir = path.resolve(targetDir);
206
+
207
+ process.stdout.write(
208
+ `\n${colors.bold("create-cmp attach")} — agent contract for an existing Compose/KMP repo\n` +
209
+ ` project: ${colors.cyan(projectDir)}\n\n`
210
+ );
211
+
212
+ let plan;
213
+ try {
214
+ plan = attachProject({ projectDir, apply: false });
215
+ } catch (e) {
216
+ fail(e.message);
217
+ process.exit(1);
218
+ }
219
+
220
+ for (const u of plan.units) {
221
+ if (u.action === "current") ok(`${u.relPath} — already current`);
222
+ if (u.action === "written") process.stdout.write(` will write ${u.relPath}\n`);
223
+ if (u.action === "sidecar")
224
+ warn(`${u.relPath} exists and differs — ours will land as ${u.relPath}${SIDECAR_SUFFIX}`);
225
+ }
226
+ process.stdout.write(`\n${colors.bold("Not wired")} (staged, never faked):\n`);
227
+ for (const n of plan.notWired) process.stdout.write(` · ${n}\n`);
228
+
229
+ const actionable = plan.units.filter((u) => u.action !== "current");
230
+ if (actionable.length === 0) {
231
+ ok("\nNothing to do — attach surfaces are current.");
232
+ process.exit(0);
233
+ }
234
+ if (flags["dry-run"] === true) {
235
+ process.stdout.write(`\n${colors.yellow("Dry run")} — nothing written.\n`);
236
+ process.exit(0);
237
+ }
238
+ const approved = await consent("\nWrite the attach surfaces?", { assumeYes: flags.yes === true });
239
+ if (!approved) {
240
+ process.stdout.write(`${colors.yellow("Not applied")} — re-run with --yes to skip the prompt.\n`);
241
+ process.exit(0);
242
+ }
243
+
244
+ const applied = attachProject({ projectDir, apply: true });
245
+ for (const u of applied.units) {
246
+ if (u.action === "written") ok(`wrote ${u.relPath}`);
247
+ if (u.action === "sidecar") warn(`wrote ${u.relPath}${SIDECAR_SUFFIX} (yours untouched)`);
248
+ }
249
+ process.exit(0);
250
+ }
@@ -74,6 +74,10 @@ function buildConfigFromFlags(flags, positional) {
74
74
  region: typeof flags.region === "string" ? flags.region : "us-central1",
75
75
  themePrefix:
76
76
  typeof flags["theme-prefix"] === "string" ? flags["theme-prefix"] : pascalFromName(name),
77
+ // `--minimal` is the Act 1 door (LADDER §2): same app, tests, previews and
78
+ // advisory hooks, without the verify lane / receipts / governance.
79
+ // `create-cmp harden` installs the subtraction back.
80
+ harness: !flagBool(flags, "minimal", false),
77
81
  platforms: { android: true, ios },
78
82
  firebase: {
79
83
  enabled: firebase,
@@ -95,7 +99,7 @@ function buildConfigFromFlags(flags, positional) {
95
99
  };
96
100
  }
97
101
 
98
- async function interactiveConfig(positional) {
102
+ async function interactiveConfig(positional, flags = {}) {
99
103
  let prompts;
100
104
  try {
101
105
  prompts = (await import("prompts")).default;
@@ -157,6 +161,12 @@ async function interactiveConfig(positional) {
157
161
 
158
162
  const extras = await prompts(
159
163
  [
164
+ {
165
+ type: "confirm",
166
+ name: "harness",
167
+ message: "Verification harness (verify lane, evidence receipts, machine-checked done)?",
168
+ initial: flags.minimal !== true, // --minimal pre-answers the interview question
169
+ },
160
170
  { type: "confirm", name: "room", message: "Room local cache?", initial: true },
161
171
  { type: "confirm", name: "e2e", message: "E2E test harness (Maestro)?", initial: true },
162
172
  {
@@ -193,6 +203,7 @@ async function interactiveConfig(positional) {
193
203
  iosBundleId: base.package,
194
204
  region: base.region,
195
205
  themePrefix: pascalFromName(base.appName),
206
+ harness: extras.harness,
196
207
  platforms: { android: true, ios: base.ios },
197
208
  firebase: { enabled: base.firebase, auth, firestore, storage, functions, fcm },
198
209
  room: extras.room,
@@ -222,7 +233,7 @@ export async function runCreate(flags, positional) {
222
233
 
223
234
  const config = nonInteractive
224
235
  ? buildConfigFromFlags(flags, positional)
225
- : await interactiveConfig(positional);
236
+ : await interactiveConfig(positional, flags);
226
237
 
227
238
  const verify = flagBool(flags, "verify", true);
228
239
 
@@ -0,0 +1,263 @@
1
+ // `create-cmp harden` — install the full verification harness into a minimal
2
+ // scaffold. The Act 2 → Act 3 climb (LADDER §R3), in-band, one command.
3
+ //
4
+ // This is deliberately NOT new machinery. `--minimal` subtracts the harness;
5
+ // harden re-derives the subtraction and installs it back through the SAME
6
+ // three-way walk `upgrade --harness` already trusts:
7
+ //
8
+ // base = this app's config stamped MINIMAL (what the app was given)
9
+ // new = this app's config stamped FULL (what full mode gives it)
10
+ // theirs = the app's working tree today
11
+ //
12
+ // Every base→new difference is, by construction, exactly the harness: the
13
+ // lane region (restored wholesale — decideRegionFile), the governance
14
+ // surfaces (specs/, skills, hooks — "added"), and the mode-variant documents
15
+ // (CLAUDE.md, AGENTS.md, README, CI — "applied" when untouched, three-way
16
+ // merged when the app edited them, `.cmp-new` sidecars when both moved the
17
+ // same lines). Nothing is ever clobbered, and a second run finds everything
18
+ // current — idempotent by the walk's own semantics, not by bookkeeping.
19
+ //
20
+ // One seam the walk deliberately refuses: EXCLUDED_PATTERNS keeps app state
21
+ // (qa/approvals.json, qa/evidence/, qa/golden/) out of upgrades, because
22
+ // overwriting a ledger is never an upgrade. But a MINIMAL app has no ledgers
23
+ // to protect — harden must seed them. So after the walk, anything excluded
24
+ // that exists in the full stamp and is MISSING in the app is copied in:
25
+ // seed-if-absent, never overwrite.
26
+ //
27
+ // Both stamps use the CURRENT engine's template. If the app was stamped by an
28
+ // older engine, app-shaped drift from engine evolution surfaces as merges or
29
+ // sidecars — visible, never silent — and the installed lane is the current
30
+ // one (which is what `upgrade --harness` would land anyway).
31
+
32
+ import fs from "node:fs";
33
+ import os from "node:os";
34
+ import path from "node:path";
35
+ import { spawnSync } from "node:child_process";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ import { colors, ok, warn, fail, step } from "../lib/log.mjs";
39
+ import { consent } from "../bootstrap/exec.mjs";
40
+ import { buildTokenMap } from "../lib/tokens.mjs";
41
+ import {
42
+ planHarnessUpgrade,
43
+ applyHarnessPlan,
44
+ configFromSpecRecord,
45
+ stampBaseWith,
46
+ isExcludedPath,
47
+ SIDECAR_SUFFIX,
48
+ } from "../lib/harness-upgrade.mjs";
49
+ import { listFiles } from "../lib/fsutil.mjs";
50
+ import {
51
+ writeHarnessLock,
52
+ checkHarnessIntegrity,
53
+ describeIntegrity,
54
+ } from "../../packages/harness/src/lib/harness-lock.mjs";
55
+
56
+ const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
57
+
58
+ /** The harness version this engine ships (the lane's own package, not the engine's). */
59
+ function shippedHarnessVersion() {
60
+ try {
61
+ return JSON.parse(
62
+ fs.readFileSync(path.join(REPO_ROOT, "packages/harness/package.json"), "utf8")
63
+ ).version;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function currentEngineVersion() {
70
+ try {
71
+ return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")).version;
72
+ } catch {
73
+ return "unknown";
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The testable core: plan (and optionally apply) the harness install.
79
+ * Throws on a hopeless setup; never calls process.exit and never prompts.
80
+ *
81
+ * @param {object} params
82
+ * @param {string} params.projectDir absolute path of the app
83
+ * @param {string} [params.templateDir] template override (tests)
84
+ * @param {boolean} [params.apply=false] write changes (false = plan only)
85
+ * @param {(msg:string)=>void} [params.log]
86
+ * @returns {Promise<{alreadyFull:boolean, plan?:object, result?:object,
87
+ * seeded?:string[], record?:object}>}
88
+ */
89
+ export async function hardenProject({ projectDir, templateDir, apply = false, log = () => {} }) {
90
+ const specPath = path.join(projectDir, "create-cmp.json");
91
+ if (!fs.existsSync(specPath)) {
92
+ throw new Error(
93
+ `no create-cmp.json under ${projectDir}.\n` +
94
+ `\`create-cmp harden\` installs the verification harness into a create-cmp-stamped ` +
95
+ `project and needs the spec-of-record the stamp wrote. Run it from the project root ` +
96
+ `or pass --target-dir. (For an app that was never stamped by create-cmp, harden ` +
97
+ `cannot help yet — that is attach mode's territory.)`
98
+ );
99
+ }
100
+ const record = JSON.parse(fs.readFileSync(specPath, "utf8"));
101
+
102
+ // Full already: record says harness and the lane's front door is present.
103
+ // (A full record with a missing lane is a broken tree harden can heal, so
104
+ // only the conjunction short-circuits.)
105
+ if (record.harness !== false && fs.existsSync(path.join(projectDir, "qa", "verify.mjs"))) {
106
+ return { alreadyFull: true, record };
107
+ }
108
+
109
+ const { scaffold } = await import("../scaffold.mjs");
110
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "create-cmp-harden-"));
111
+ try {
112
+ const baseDir = path.join(tmpRoot, "base");
113
+ const newDir = path.join(tmpRoot, "new");
114
+ const baseConfig = { ...configFromSpecRecord(record, baseDir), harness: false };
115
+ const newConfig = { ...configFromSpecRecord(record, newDir), harness: true };
116
+
117
+ log("Stamping this app's config MINIMAL (the walk's base)…");
118
+ await scaffold(baseConfig, { verify: false, ...(templateDir ? { templateDir } : {}) });
119
+ log("Stamping this app's config FULL (the walk's target)…");
120
+ await scaffold(newConfig, { verify: false, ...(templateDir ? { templateDir } : {}) });
121
+
122
+ const plan = planHarnessUpgrade({
123
+ baseDir,
124
+ newDir,
125
+ projectDir,
126
+ stampBase: stampBaseWith(buildTokenMap(configFromSpecRecord(record, projectDir))),
127
+ });
128
+
129
+ // App-state seeds the walk excludes by design: present in the full stamp,
130
+ // absent in the app → copy. Never touches an existing file.
131
+ const seedPlan = [];
132
+ for (const abs of listFiles(newDir)) {
133
+ const rel = path.relative(newDir, abs).split(path.sep).join("/");
134
+ if (!isExcludedPath(rel)) continue;
135
+ if (rel === "create-cmp.json" || rel === "local.properties") continue; // ours below / host-specific
136
+ if (!fs.existsSync(path.join(projectDir, rel))) seedPlan.push(rel);
137
+ }
138
+
139
+ if (!apply) return { alreadyFull: false, plan, seedPlan, record };
140
+
141
+ const actionable = plan.entries.filter(
142
+ (e) => e.write !== null || e.sidecar !== null || e.remove
143
+ );
144
+ const result = applyHarnessPlan(projectDir, actionable);
145
+
146
+ const seeded = [];
147
+ for (const rel of seedPlan) {
148
+ const target = path.join(projectDir, rel);
149
+ fs.mkdirSync(path.dirname(target), { recursive: true });
150
+ fs.copyFileSync(path.join(newDir, rel), target);
151
+ seeded.push(rel);
152
+ }
153
+
154
+ const harnessVersion = shippedHarnessVersion();
155
+ if (harnessVersion) writeHarnessLock(projectDir, { version: harnessVersion });
156
+
157
+ const updated = { ...record, harness: true, engineVersion: currentEngineVersion() };
158
+ fs.writeFileSync(specPath, JSON.stringify(updated, null, 2) + "\n");
159
+
160
+ return { alreadyFull: false, plan, seedPlan, result, seeded, record: updated };
161
+ } finally {
162
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
163
+ }
164
+ }
165
+
166
+ /**
167
+ * `create-cmp harden [target-dir] [--dry-run] [--yes] [--verify]`
168
+ * @param {Record<string,string|boolean>} flags
169
+ * @param {string|undefined} positional optional target dir
170
+ */
171
+ export async function runHarden(flags, positional) {
172
+ const targetDir =
173
+ (typeof flags["target-dir"] === "string" && flags["target-dir"]) || positional || ".";
174
+ const projectDir = path.resolve(targetDir);
175
+
176
+ process.stdout.write(
177
+ `\n${colors.bold("create-cmp harden")} — install the full verification harness\n` +
178
+ ` project: ${colors.cyan(projectDir)}\n\n`
179
+ );
180
+
181
+ let outcome;
182
+ try {
183
+ // Plan first (side-effect free) so the consent question shows real content.
184
+ outcome = await hardenProject({ projectDir, log: (m) => step(m) });
185
+ } catch (e) {
186
+ fail(e.message);
187
+ process.exit(1);
188
+ }
189
+
190
+ if (outcome.alreadyFull) {
191
+ ok("This app already carries the full harness — nothing to install.");
192
+ process.stdout.write(
193
+ colors.dim(
194
+ ` ${describeIntegrity(checkHarnessIntegrity(projectDir))}\n` +
195
+ ` To refresh the harness to the current engine: npx create-cmp-cli upgrade --harness\n`
196
+ )
197
+ );
198
+ process.exit(0);
199
+ }
200
+
201
+ const { plan, seedPlan } = outcome;
202
+ const counts = plan.counts;
203
+ const installing = plan.entries.filter((e) => e.write !== null || e.remove).length;
204
+ const conflicts = plan.entries.filter((e) => e.sidecar !== null).map((e) => e.relPath);
205
+ process.stdout.write(
206
+ `${colors.bold(String(installing))} file(s) to install/refresh · ` +
207
+ `${colors.bold(String(seedPlan.length))} app-state seed(s) · ` +
208
+ `${colors.dim(`already current ${counts.current + counts.unchanged}`)}\n`
209
+ );
210
+ for (const f of conflicts) {
211
+ warn(`edited since stamp — full-mode content will land beside as ${f}${SIDECAR_SUFFIX}`);
212
+ }
213
+
214
+ if (installing === 0 && seedPlan.length === 0 && conflicts.length === 0) {
215
+ ok("Nothing to do — the tree already matches full mode.");
216
+ process.exit(0);
217
+ }
218
+ if (flags["dry-run"] === true) {
219
+ for (const e of plan.entries) {
220
+ if (e.write !== null || e.remove || e.sidecar !== null) process.stdout.write(` ${e.relPath}\n`);
221
+ }
222
+ for (const rel of seedPlan) process.stdout.write(` ${rel} ${colors.dim("(seed)")}\n`);
223
+ process.stdout.write(`\n${colors.yellow("Dry run")} — nothing written.\n`);
224
+ process.exit(0);
225
+ }
226
+
227
+ const approved = await consent(
228
+ `\nInstall the harness (existing files are backed up; edited files get *${SIDECAR_SUFFIX} sidecars, never clobbered)?`,
229
+ { assumeYes: flags.yes === true }
230
+ );
231
+ if (!approved) {
232
+ process.stdout.write(`${colors.yellow("Not applied")} — re-run with --yes to skip the prompt.\n`);
233
+ process.exit(0);
234
+ }
235
+
236
+ let applied;
237
+ try {
238
+ applied = await hardenProject({ projectDir, apply: true, log: (m) => step(m) });
239
+ } catch (e) {
240
+ fail(e.message);
241
+ process.exit(1);
242
+ }
243
+
244
+ const r = applied.result;
245
+ for (const f of r.created) ok(`installed ${f}`);
246
+ for (const f of r.written) ok(`refreshed ${f}`);
247
+ for (const f of applied.seeded) ok(`seeded ${f}`);
248
+ for (const f of r.sidecars) warn(`conflict sidecar ${f} — resolve by hand, then delete it`);
249
+ ok(`create-cmp.json → harness: true · ${describeIntegrity(checkHarnessIntegrity(projectDir))}`);
250
+
251
+ process.stdout.write(
252
+ `\nProve it: ${colors.bold("node qa/verify.mjs --profile scaffold")}` +
253
+ colors.dim(" (runs now with --verify)\n")
254
+ );
255
+ if (flags.verify === true) {
256
+ const v = spawnSync("node", ["qa/verify.mjs", "--profile", "scaffold"], {
257
+ cwd: projectDir,
258
+ stdio: "inherit",
259
+ });
260
+ process.exit(v.status === 0 && r.sidecars.length === 0 ? 0 : 1);
261
+ }
262
+ process.exit(r.sidecars.length > 0 ? 1 : 0);
263
+ }