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.
- package/README.md +11 -9
- 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/src/lib/package-name.mjs +72 -0
- package/src/scaffold.mjs +7 -2
- package/template/.claude/settings.json +30 -0
- package/template/CLAUDE.md +51 -6
- package/template/README.md +4 -0
- package/template/composeApp/build.gradle.kts +44 -0
- package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
- package/template/docs/ARCHITECTURE.md +41 -2
- package/template/docs/TESTING.md +165 -0
- package/template/gradle/libs.versions.toml +15 -0
- package/template/manifest.json +1 -0
- package/template/qa/evidence/schema.json +20 -2
- package/template/qa/lib/affected-tests.mjs +147 -0
- package/template/qa/lib/audit-cadence.mjs +290 -0
- package/template/qa/lib/determinism.mjs +179 -0
- package/template/qa/lib/device-lease.mjs +249 -0
- package/template/qa/lib/evidence-badge.mjs +158 -0
- package/template/qa/lib/evidence-level.mjs +117 -0
- package/template/qa/lib/flight-recorder.mjs +332 -0
- package/template/qa/lib/inputs-hash.mjs +16 -1
- package/template/qa/lib/spec-coverage.mjs +54 -3
- package/template/qa/lib/step-cache.mjs +221 -0
- package/template/qa/receipt-check.mjs +22 -2
- package/template/qa/record-audit.mjs +83 -0
- package/template/qa/retrospective.mjs +51 -0
- package/template/qa/scaffold-feature.mjs +20 -1
- package/template/qa/verify.mjs +934 -57
- package/template/qa/watch.mjs +622 -0
- 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`, `
|
|
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** (
|
|
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`, `
|
|
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 → `
|
|
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
|
|
342
|
-
`
|
|
343
|
-
|
|
344
|
-
`
|
|
345
|
-
|
|
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
|
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);
|