create-cmp-cli 0.11.0 → 0.13.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 (51) hide show
  1. package/README.md +11 -9
  2. package/bin/create-cmp.mjs +3 -0
  3. package/package.json +1 -1
  4. package/src/commands/upgrade.mjs +287 -0
  5. package/src/lib/harness-upgrade.mjs +364 -0
  6. package/src/lib/package-name.mjs +72 -0
  7. package/src/scaffold.mjs +7 -2
  8. package/template/.claude/settings.json +30 -0
  9. package/template/CLAUDE.md +51 -6
  10. package/template/README.md +4 -0
  11. package/template/composeApp/build.gradle.kts +44 -0
  12. package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
  13. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
  14. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
  15. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
  16. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
  17. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
  18. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
  19. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
  20. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
  21. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
  22. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
  23. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
  24. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
  29. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
  30. package/template/docs/ARCHITECTURE.md +41 -2
  31. package/template/docs/TESTING.md +165 -0
  32. package/template/gradle/libs.versions.toml +15 -0
  33. package/template/manifest.json +1 -0
  34. package/template/qa/evidence/schema.json +20 -2
  35. package/template/qa/lib/affected-tests.mjs +147 -0
  36. package/template/qa/lib/audit-cadence.mjs +290 -0
  37. package/template/qa/lib/determinism.mjs +179 -0
  38. package/template/qa/lib/device-lease.mjs +249 -0
  39. package/template/qa/lib/evidence-badge.mjs +158 -0
  40. package/template/qa/lib/evidence-level.mjs +117 -0
  41. package/template/qa/lib/flight-recorder.mjs +332 -0
  42. package/template/qa/lib/inputs-hash.mjs +16 -1
  43. package/template/qa/lib/spec-coverage.mjs +54 -3
  44. package/template/qa/lib/step-cache.mjs +221 -0
  45. package/template/qa/receipt-check.mjs +22 -2
  46. package/template/qa/record-audit.mjs +83 -0
  47. package/template/qa/retrospective.mjs +51 -0
  48. package/template/qa/scaffold-feature.mjs +20 -1
  49. package/template/qa/verify.mjs +934 -57
  50. package/template/qa/watch.mjs +622 -0
  51. package/template/specs/app-base.spec.md +11 -0
package/README.md CHANGED
@@ -76,7 +76,7 @@ http://127.0.0.1:9500/inspect/remote
76
76
  A self-contained page mirrors the running app (~700ms refresh) and **click-to-tap drives the
77
77
  real thing** — clicks scale to device pixels and dispatch as taps. This is the "two audiences,
78
78
  one app" split at its purest: the human watches and drives real pixels in a browser while the
79
- agent asserts on the semantics tree (`navigate_and_inspect`, `prove_change`, `db_query`). Use
79
+ agent asserts on the semantics tree (`navigate_and_inspect`, `inspect_tree`, `db_query`). Use
80
80
  it to watch an e2e run, demo a feature, or poke at the app without touching the device. No
81
81
  install, no CORS, debug builds only — release builds contain none of this code.
82
82
 
@@ -161,11 +161,11 @@ Same engine as the CLI, conversational front door. Each skill is a guided flow,
161
161
  | `cmp-test` | "Write tests for my app." *Observes* the running app's semantics tree — what's actually on screen, what's tappable, where navigation goes — and derives the regression suite from that. Tests come from rendered reality, not guesses. |
162
162
  | `cmp-qa-prep` | "Get my test environment up." Emulator + app install + E2E smoke run, with the gotchas handled. |
163
163
 
164
- Plus the **`cmp-inspector` MCP server** (26 tools) — the machine-readable window into a running
164
+ Plus the **`cmp-inspector` MCP server** (15 tools — deliberately few; two production apps proved a lean surface gets used and a wide one gets ignored) — the machine-readable window into a running
165
165
  Compose UI that `cmp-inspect`, `cmp-test`, and the verified dev loop are built on. One tree
166
166
  contract, three sources: render a screen headlessly, connect to the live app, or read a device
167
167
  via UIAutomator. It also carries the runtime half of the agent's eyes (crashes, logs, DB state —
168
- `runtime_crashes`, `runtime_logs`, `db_schema`, `db_query`), the human-approval console
168
+ `runtime_crashes`, `runtime_logs`, `db_query`), the human-approval console
169
169
  (`approval_status`, §8 below), the console's talk-back channel (`review_comments`,
170
170
  `resolve_comment`, §9 below), and the genesis walk's design-language workbench
171
171
  (`snapshot_variant`, §8 below).
@@ -313,7 +313,7 @@ scaffolded app carries the whole loop):
313
313
  `./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs`.
314
314
 
315
315
  **The verified dev loop (the flagship).** For any UI change: snapshot the live tree → make the
316
- edit → reload → `prove_change` compares before/after structure, token drift, and a11y, and returns
316
+ edit → reload → `preview_diff` compares before/after structure, token drift, and a11y, and returns
317
317
  a verdict. The agent doesn't say "I centered the title" — it shows *"title bounds moved, tokens
318
318
  unchanged, no a11y regressions: proven clean."*
319
319
 
@@ -338,11 +338,13 @@ standalone gate. All of it works on any KMP project.
338
338
  generation and mechanical work to sub-agents with self-contained briefs, then **gates every
339
339
  hand-off through the verify lane** before accepting it. Nothing is reported done on prose —
340
340
  only on a receipt.
341
- - **The MCP tools** are how any agent *sees*: `inspect_tree`, `get_node`, `assert_token`,
342
- `layout_gaps`, `diff_against_design_system`, `find_drift`, `snapshot_save`, `snapshot_diff`,
343
- `audit_a11y`, `connect_live`, `navigate_and_inspect`, `render_tree`, `render_screen`,
344
- `prove_change`. Structure in, structure out never pixels in model context. The same eyes
345
- extend to runtime behavior (`runtime_crashes`, `runtime_logs`, `db_schema`, `db_query`), to
341
+ - **The MCP tools** are how any agent *sees*: `inspect_tree` (one tree contract — subtree by
342
+ `testTag`, wireframe rendering, layout-gap reporting as options), `connect_live`
343
+ (self-healing: device forward → health → launch → transport reset), `navigate_and_inspect`,
344
+ `render_screen`, and the preview loop (`preview`, `preview_status`, `preview_diff`).
345
+ Structure in, structure out never pixels in model context. Token drift, a11y, and golden
346
+ regressions are the verify lane's job, not interactive tools. The same eyes
347
+ extend to runtime behavior (`runtime_crashes`, `runtime_logs`, `db_query`), to
346
348
  the human side of the loop (`approval_status`, blocking on a console decision the same way
347
349
  `preview_status` blocks on a render), and to the console's talk-back channel
348
350
  (`review_comments`, `resolve_comment` — the agent observes feedback and closes the loop with a
@@ -71,6 +71,7 @@ function printHelp() {
71
71
  ` npx create-cmp create [target-dir] same, explicit\n` +
72
72
  ` npx create-cmp doctor toolchain doctor + project diagnosis (any KMP project)\n` +
73
73
  ` npx create-cmp upgrade migrate to the next proven-green version set\n` +
74
+ ` npx create-cmp upgrade --harness refresh engine-owned files of a stamped app (3-way merge)\n` +
74
75
  ` npx create-cmp clean ~/.konan + Gradle build-output hygiene (consent-gated)\n` +
75
76
  ` npx create-cmp verify run the green-build gate on an existing project\n\n` +
76
77
  `create (scaffold) flags:\n` +
@@ -83,6 +84,8 @@ function printHelp() {
83
84
  ` --target-dir --verify/--no-verify --yes --force --dry-run-verify\n\n` +
84
85
  `doctor flags: --yes --dry-run --no-ios --no-install --target-dir <dir> --fix\n` +
85
86
  `upgrade flags: --target-dir <dir> --set <id> --dry-run --yes --verify\n` +
87
+ ` --harness mode flags: --target-dir <dir> --base-dir <extracted-template> --dry-run --yes\n` +
88
+ ` (--harness dry-runs by default; conflicts never clobber — they land as *.cmp-new sidecars)\n` +
86
89
  `clean flags: --target-dir <dir> --dry-run --yes\n` +
87
90
  `verify flags: --target-dir <dir> --no-ios --dry-run\n`
88
91
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cmp-cli",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI — the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,15 +17,39 @@
17
17
  // `<kotlin>-…`.
18
18
  // - Works on ANY project with a libs.versions.toml — template markers only
19
19
  // soften/strengthen messaging, never refuse.
20
+ //
21
+ // Second mode — `create-cmp upgrade --harness`:
22
+ //
23
+ // create-cmp upgrade --harness [--target-dir .] [--base-dir <path>] [--dry-run] [--yes]
24
+ //
25
+ // Refreshes the ENGINE-OWNED files of a stamped app via a three-way merge
26
+ // (base = old engine's stamp, new = current engine's stamp, theirs = the
27
+ // app's tree — both stamps use the app's own recorded config from
28
+ // create-cmp.json so every diff is pure engine change). Conflicts never
29
+ // clobber: the app's file stays put and a `.cmp-new` sidecar carries the
30
+ // new engine content. Decision logic lives in src/lib/harness-upgrade.mjs;
31
+ // this file does the filesystem/CLI orchestration (npm pack of the base
32
+ // version, temp-dir stamps, consent, backups, report, exit code).
20
33
 
21
34
  import fs from "node:fs";
35
+ import os from "node:os";
22
36
  import path from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+ import { spawnSync } from "node:child_process";
23
39
 
24
40
  import { flagBool } from "../lib/args.mjs";
25
41
  import { colors, ok, warn, fail, step } from "../lib/log.mjs";
26
42
  import { consent } from "../bootstrap/exec.mjs";
27
43
  import { loadRegistry, latestSet, getSet } from "../lib/registry.mjs";
28
44
  import { planUpgrade, BACKUP_SUFFIX } from "../lib/upgrade.mjs";
45
+ import {
46
+ planHarnessUpgrade,
47
+ applyHarnessPlan,
48
+ configFromSpecRecord,
49
+ SIDECAR_SUFFIX,
50
+ } from "../lib/harness-upgrade.mjs";
51
+
52
+ const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
29
53
 
30
54
  function readIfExists(p) {
31
55
  try {
@@ -48,11 +72,274 @@ function printDiffTable(changes) {
48
72
  }
49
73
  }
50
74
 
75
+ // --- harness mode helpers ----------------------------------------------------
76
+
77
+ /** Current engine version, from this checkout's package.json. */
78
+ function currentEngineVersion() {
79
+ try {
80
+ return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")).version;
81
+ } catch {
82
+ return "unknown";
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Resolve a --base-dir argument to a template root (the dir holding
88
+ * manifest.json). Accepts the template root itself, a repo/package checkout
89
+ * containing `template/`, or an extracted npm tarball (`package/template/`).
90
+ * Throws (never exits) so the caller's temp-dir cleanup always runs.
91
+ * @param {string} p
92
+ * @returns {string} absolute template root
93
+ */
94
+ function resolveBaseTemplateDir(p) {
95
+ const abs = path.resolve(p);
96
+ for (const candidate of [abs, path.join(abs, "template"), path.join(abs, "package", "template")]) {
97
+ if (fs.existsSync(path.join(candidate, "manifest.json"))) return candidate;
98
+ }
99
+ throw new Error(
100
+ `--base-dir ${abs} does not look like a create-cmp template (no manifest.json at ` +
101
+ `<dir>, <dir>/template, or <dir>/package/template).`
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Fetch the OLD engine's template by version: `npm pack create-cmp-cli@<v>`
107
+ * into a temp dir, then extract `package/template/` from the tarball.
108
+ * Fails loudly (throws — never exits, so the caller's temp-dir cleanup always
109
+ * runs), naming the exact command that failed and the --base-dir escape hatch
110
+ * (offline / local checkout / testing).
111
+ * @param {string} engineVersion version recorded in create-cmp.json
112
+ * @param {string} tmpRoot session temp dir (cleaned up by the caller)
113
+ * @returns {string} absolute path of the extracted template root
114
+ */
115
+ function fetchBaseTemplate(engineVersion, tmpRoot) {
116
+ if (!engineVersion || engineVersion === "unknown" || !/^[0-9A-Za-z.+-]+$/.test(engineVersion)) {
117
+ throw new Error(
118
+ `create-cmp.json does not record a usable engineVersion (got ${JSON.stringify(engineVersion)}). ` +
119
+ `Pass --base-dir <path> pointing at the template this app was stamped from.`
120
+ );
121
+ }
122
+ const packDir = path.join(tmpRoot, "pack");
123
+ fs.mkdirSync(packDir, { recursive: true });
124
+ const packCmd = `npm pack create-cmp-cli@${engineVersion}`;
125
+ const r = spawnSync("npm", ["pack", `create-cmp-cli@${engineVersion}`], {
126
+ cwd: packDir,
127
+ encoding: "utf8",
128
+ timeout: 120000,
129
+ });
130
+ if (r.error || r.status !== 0) {
131
+ throw new Error(
132
+ `Could not fetch the base engine template: \`${packCmd}\` failed` +
133
+ (r.stderr ? ` — ${r.stderr.trim().split("\n").pop()}` : "") +
134
+ `.\n Offline or unpublished version? Pass --base-dir <path> to an already-extracted template.`
135
+ );
136
+ }
137
+ // npm pack prints the tarball filename as the last stdout line.
138
+ const tgz = (r.stdout || "").trim().split("\n").pop().trim();
139
+ const tgzPath = path.join(packDir, tgz);
140
+ const tarCmd = `tar -xzf ${tgzPath} -C ${packDir}`;
141
+ const rt = spawnSync("tar", ["-xzf", tgzPath, "-C", packDir], { timeout: 120000 });
142
+ if (rt.error || rt.status !== 0 || !fs.existsSync(path.join(packDir, "package", "template", "manifest.json"))) {
143
+ throw new Error(
144
+ `Could not extract the base engine template: \`${tarCmd}\` failed or the tarball ` +
145
+ `carries no package/template/. Pass --base-dir <path> to an already-extracted template.`
146
+ );
147
+ }
148
+ return path.join(packDir, "package", "template");
149
+ }
150
+
151
+ /** Print the grouped harness report; returns whether anything is actionable. */
152
+ function printHarnessReport(plan) {
153
+ const c = plan.counts;
154
+ const list = (bucket) => plan.entries.filter((e) => e.bucket === bucket).map((e) => e.relPath);
155
+
156
+ process.stdout.write(
157
+ `\n${colors.bold("Engine-owned files")} — ` +
158
+ `${colors.dim(`unchanged ${c.unchanged} · already current ${c.current} · excluded state/secrets ${c.excluded}`)}\n`
159
+ );
160
+ const groups = [
161
+ ["applied", "applied (engine changed, app never touched — will take the new content)", ok],
162
+ ["merged", "merged (both changed different regions — both edits survive)", ok],
163
+ ["added", "added (new engine files absent from the app)", ok],
164
+ ["removed", "removed (engine deleted, app never touched — will be deleted)", warn],
165
+ ["orphaned", "orphaned (engine deleted these but the app modified them — left in place)", warn],
166
+ ["conflicted", `conflicted (NEVER clobbered — new engine content lands beside as *${SIDECAR_SUFFIX})`, fail],
167
+ ];
168
+ let actionable = 0;
169
+ for (const [bucket, label, log] of groups) {
170
+ const files = list(bucket);
171
+ if (files.length === 0) continue;
172
+ if (bucket !== "orphaned") actionable += files.length;
173
+ log(`${colors.bold(String(files.length))} ${label}`);
174
+ for (const f of files) process.stdout.write(` ${f}\n`);
175
+ }
176
+ process.stdout.write(
177
+ colors.dim(
178
+ `\n Not considered: ${c.excluded} excluded app-state/secret files ` +
179
+ `(evidence, approvals, goldens, keystores, Firebase configs), and any app-authored ` +
180
+ `files the engine never stamped.\n`
181
+ )
182
+ );
183
+ return actionable > 0;
184
+ }
185
+
186
+ /**
187
+ * `create-cmp upgrade --harness` — refresh engine-owned files of a stamped app.
188
+ * @param {Record<string,string|boolean>} flags
189
+ * @param {string|undefined} positional optional target dir positional
190
+ */
191
+ async function runHarnessUpgrade(flags, positional) {
192
+ const targetDir =
193
+ (typeof flags["target-dir"] === "string" && flags["target-dir"]) || positional || ".";
194
+ const projectDir = path.resolve(targetDir);
195
+
196
+ const specPath = path.join(projectDir, "create-cmp.json");
197
+ const specRaw = readIfExists(specPath);
198
+ if (specRaw === null) {
199
+ process.stderr.write(
200
+ `Error: no create-cmp.json under ${projectDir}.\n` +
201
+ `\`create-cmp upgrade --harness\` refreshes the engine-owned files of a create-cmp-stamped ` +
202
+ `project, and needs the spec-of-record the stamp wrote. Run it from the project root or ` +
203
+ `pass --target-dir. (For a plain version-catalog upgrade, drop --harness.)\n`
204
+ );
205
+ process.exit(1);
206
+ }
207
+ let record;
208
+ try {
209
+ record = JSON.parse(specRaw);
210
+ } catch (e) {
211
+ process.stderr.write(`Error: ${specPath} is not valid JSON (${e.message}).\n`);
212
+ process.exit(1);
213
+ }
214
+
215
+ const baseVersion = record.engineVersion;
216
+ const currentVersion = currentEngineVersion();
217
+ process.stdout.write(
218
+ `\n${colors.bold("create-cmp upgrade --harness")} — refresh engine-owned files\n` +
219
+ ` project: ${colors.cyan(projectDir)}\n` +
220
+ ` stamped by engine ${colors.yellow(String(baseVersion))} ${colors.dim("→")} current engine ${colors.green(currentVersion)}\n\n`
221
+ );
222
+
223
+ // NOTE: `process.exit` skips `finally`, so the body below RETURNS an exit
224
+ // code and the temp trees are cleaned up before the process actually exits.
225
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "create-cmp-harness-"));
226
+ let code = 1;
227
+ try {
228
+ code = await harnessPlanAndApply({
229
+ flags,
230
+ record,
231
+ projectDir,
232
+ targetDir,
233
+ tmpRoot,
234
+ baseVersion,
235
+ currentVersion,
236
+ });
237
+ } catch (e) {
238
+ fail(e.message);
239
+ code = 1;
240
+ } finally {
241
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
242
+ }
243
+ process.exit(code);
244
+ }
245
+
246
+ /**
247
+ * The harness mode's plan/report/apply body. Returns the process exit code
248
+ * (0 = clean apply or dry run, 1 = conflicts produced) and throws on
249
+ * environment failures — it never calls process.exit itself, so the caller's
250
+ * temp-dir cleanup always runs.
251
+ * @returns {Promise<number>}
252
+ */
253
+ async function harnessPlanAndApply({ flags, record, projectDir, targetDir, tmpRoot, baseVersion, currentVersion }) {
254
+ // Base template: --base-dir wins (offline / local checkout / testing);
255
+ // same-version needs no fetch (the local template IS the base); otherwise
256
+ // npm pack the recorded version.
257
+ let baseTemplateDir;
258
+ if (typeof flags["base-dir"] === "string") {
259
+ baseTemplateDir = resolveBaseTemplateDir(flags["base-dir"]);
260
+ step(`Base template: ${colors.cyan(baseTemplateDir)} (--base-dir)`);
261
+ } else if (baseVersion === currentVersion) {
262
+ baseTemplateDir = path.join(REPO_ROOT, "template");
263
+ step(`App was stamped by THIS engine version — base is the local template.`);
264
+ } else {
265
+ step(`Fetching base engine ${baseVersion} via npm pack…`);
266
+ baseTemplateDir = fetchBaseTemplate(baseVersion, tmpRoot);
267
+ }
268
+
269
+ // Stamp both sides with the app's OWN recorded config, so tokens resolve
270
+ // identically and base→new diffs are pure engine change. `verify:false`
271
+ // keeps this filesystem-only — no Gradle, no device.
272
+ const { scaffold } = await import("../scaffold.mjs");
273
+ const newDir = path.join(tmpRoot, "new");
274
+ const baseDir = path.join(tmpRoot, "base");
275
+ step("Stamping the CURRENT engine with the app's recorded config…");
276
+ await scaffold(configFromSpecRecord(record, newDir), { verify: false });
277
+ step(`Stamping the BASE engine (${baseVersion}) with the same config…`);
278
+ await scaffold(configFromSpecRecord(record, baseDir), {
279
+ templateDir: baseTemplateDir,
280
+ verify: false,
281
+ });
282
+ const plan = planHarnessUpgrade({ baseDir, newDir, projectDir });
283
+
284
+ const anythingToDo = printHarnessReport(plan);
285
+ if (!anythingToDo) {
286
+ ok("Engine-owned files are fully up to date — nothing to apply.");
287
+ return 0;
288
+ }
289
+
290
+ if (flags["dry-run"] === true) {
291
+ process.stdout.write(`\n${colors.yellow("Dry run")} — nothing written. Re-run with --yes to apply.\n`);
292
+ return 0;
293
+ }
294
+ const approved = await consent(
295
+ `\nApply these changes (backups written as *${BACKUP_SUFFIX}; conflicts only get *${SIDECAR_SUFFIX} sidecars)?`,
296
+ { assumeYes: flags.yes === true }
297
+ );
298
+ if (!approved) {
299
+ process.stdout.write(`${colors.yellow("Not applied")} — dry run only. Re-run with --yes to apply.\n`);
300
+ return 0;
301
+ }
302
+
303
+ const actionable = plan.entries.filter(
304
+ (e) => e.write !== null || e.sidecar !== null || e.remove
305
+ );
306
+ const result = applyHarnessPlan(projectDir, actionable);
307
+ for (const f of result.written) ok(`wrote ${f} ${colors.dim(`(backup: ${f}${BACKUP_SUFFIX})`)}`);
308
+ for (const f of result.created) ok(`created ${f}`);
309
+ for (const f of result.deleted) ok(`deleted ${f} ${colors.dim(`(backup: ${f}${BACKUP_SUFFIX})`)}`);
310
+ for (const f of result.sidecars) warn(`conflict sidecar ${f} — resolve by hand, then delete it`);
311
+
312
+ if (result.backups.length > 0 || result.created.length > 0) {
313
+ process.stdout.write(`\n${colors.bold("To revert")}\n`);
314
+ for (const f of result.backups) {
315
+ process.stdout.write(` mv "${path.join(projectDir, f)}${BACKUP_SUFFIX}" "${path.join(projectDir, f)}"\n`);
316
+ }
317
+ for (const f of result.created) {
318
+ process.stdout.write(` rm "${path.join(projectDir, f)}"\n`);
319
+ }
320
+ }
321
+
322
+ if (result.sidecars.length > 0) {
323
+ fail(
324
+ `${result.sidecars.length} conflict(s) need a human: the app's files were left untouched; ` +
325
+ `each *${SIDECAR_SUFFIX} sidecar carries the new engine content.`
326
+ );
327
+ return 1;
328
+ }
329
+ process.stdout.write(
330
+ `\n${colors.green("Applied.")} Prove the build: ${colors.cyan(`create-cmp verify --target-dir ${targetDir}`)}\n`
331
+ );
332
+ return 0;
333
+ }
334
+
51
335
  /**
52
336
  * @param {Record<string,string|boolean>} flags
53
337
  * @param {string|undefined} positional optional target dir positional
54
338
  */
55
339
  export async function runUpgrade(flags, positional) {
340
+ if (flags.harness === true) {
341
+ return runHarnessUpgrade(flags, positional);
342
+ }
56
343
  const targetDir =
57
344
  (typeof flags["target-dir"] === "string" && flags["target-dir"]) || positional || ".";
58
345
  const projectDir = path.resolve(targetDir);