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.
- package/README.md +54 -353
- package/bin/create-cmp.mjs +19 -3
- package/llms.txt +3 -3
- package/options.schema.json +4 -0
- package/package.json +2 -2
- package/packages/harness/package.json +9 -1
- package/packages/harness/src/lib/harness-lock.mjs +2 -2
- package/packages/harness/src/lib/inputs-hash.mjs +1 -1
- package/packages/harness/src/lib/receipt-validate.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +1 -1
- package/packages/receipts/package.json +11 -3
- package/packages/receipts/src/index.mjs +1 -1
- package/packages/receipts/src/inputs-hash.mjs +1 -1
- package/packages/receipts/src/receipt-validate.mjs +1 -1
- package/src/commands/attach.mjs +250 -0
- package/src/commands/create.mjs +13 -2
- package/src/commands/harden.mjs +263 -0
- package/src/lib/adr-seed.mjs +27 -0
- package/src/lib/harness-upgrade.mjs +4 -0
- package/src/lib/hooks.mjs +140 -0
- package/src/lib/minimal.mjs +130 -0
- package/src/lib/toggle.mjs +4 -1
- package/src/lib/verify.mjs +6 -1
- package/src/scaffold.mjs +18 -1
- package/template/.github/workflows/verify.yml +15 -0
- package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
- package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
- package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
- package/template/.gradle/8.11.1/gc.properties +0 -0
- package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/template/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/template/.gradle/vcs-1/gc.properties +0 -0
- package/template/AGENTS.md +57 -8
- package/template/CLAUDE.md +49 -0
- package/template/CONTRIBUTING.md +13 -0
- package/template/README.md +33 -0
- package/template/docs/ARCHITECTURE.md +18 -1
- package/template/docs/TESTING.md +10 -0
- package/template/manifest.json +13 -0
- package/template/qa/lib/harness-lock.mjs +2 -2
- package/template/qa/lib/inputs-hash.mjs +1 -1
- package/template/qa/lib/receipt-validate.mjs +1 -1
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/commands/create.mjs
CHANGED
|
@@ -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
|
+
}
|