create-cmp-cli 0.12.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.
- package/bin/create-cmp.mjs +3 -0
- package/package.json +1 -1
- package/src/commands/upgrade.mjs +287 -0
- package/src/lib/harness-upgrade.mjs +364 -0
- package/template/CLAUDE.md +4 -1
- package/template/README.md +4 -0
- package/template/qa/lib/audit-cadence.mjs +290 -0
- package/template/qa/lib/determinism.mjs +179 -0
- package/template/qa/lib/evidence-badge.mjs +158 -0
- package/template/qa/lib/flight-recorder.mjs +332 -0
- package/template/qa/lib/inputs-hash.mjs +16 -1
- package/template/qa/record-audit.mjs +83 -0
- package/template/qa/retrospective.mjs +51 -0
- package/template/qa/verify.mjs +305 -9
- package/template/qa/watch.mjs +2 -2
package/bin/create-cmp.mjs
CHANGED
|
@@ -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.
|
|
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": {
|
package/src/commands/upgrade.mjs
CHANGED
|
@@ -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);
|