create-cmp-cli 0.12.0 → 0.14.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 (63) hide show
  1. package/bin/create-cmp.mjs +3 -0
  2. package/package.json +6 -2
  3. package/packages/harness/package.json +38 -0
  4. package/packages/harness/src/approve.mjs +247 -0
  5. package/packages/harness/src/arch-doc.mjs +69 -0
  6. package/packages/harness/src/comment.mjs +76 -0
  7. package/packages/harness/src/lib/a11y.mjs +113 -0
  8. package/packages/harness/src/lib/affected-tests.mjs +147 -0
  9. package/packages/harness/src/lib/approvals.mjs +1403 -0
  10. package/packages/harness/src/lib/arch-doc.mjs +451 -0
  11. package/packages/harness/src/lib/audit-cadence.mjs +290 -0
  12. package/packages/harness/src/lib/comments.mjs +252 -0
  13. package/packages/harness/src/lib/component-stories.mjs +183 -0
  14. package/packages/harness/src/lib/determinism.mjs +179 -0
  15. package/packages/harness/src/lib/device-lease.mjs +249 -0
  16. package/packages/harness/src/lib/evidence-badge.mjs +158 -0
  17. package/packages/harness/src/lib/evidence-level.mjs +117 -0
  18. package/packages/harness/src/lib/feature-brief.mjs +324 -0
  19. package/packages/harness/src/lib/flight-recorder.mjs +332 -0
  20. package/packages/harness/src/lib/harness-lock.mjs +147 -0
  21. package/packages/harness/src/lib/harness-region.mjs +159 -0
  22. package/packages/harness/src/lib/inputs-hash.mjs +194 -0
  23. package/packages/harness/src/lib/reachability.mjs +211 -0
  24. package/packages/harness/src/lib/receipt-validate.mjs +234 -0
  25. package/packages/harness/src/lib/render.mjs +254 -0
  26. package/packages/harness/src/lib/spec-coverage.mjs +131 -0
  27. package/packages/harness/src/lib/step-cache.mjs +221 -0
  28. package/packages/harness/src/lib/token-drift.mjs +94 -0
  29. package/packages/harness/src/lib/tree.mjs +108 -0
  30. package/packages/harness/src/preview-gallery.mjs +122 -0
  31. package/packages/harness/src/receipt-check.mjs +96 -0
  32. package/packages/harness/src/record-audit.mjs +83 -0
  33. package/packages/harness/src/refusal-demo.mjs +498 -0
  34. package/packages/harness/src/retrospective.mjs +51 -0
  35. package/packages/harness/src/scaffold-feature.mjs +723 -0
  36. package/packages/harness/src/setup-hooks.mjs +33 -0
  37. package/packages/harness/src/verify.mjs +1709 -0
  38. package/packages/harness/src/walkthrough.mjs +499 -0
  39. package/packages/harness/src/watch.mjs +622 -0
  40. package/packages/receipts/package.json +36 -0
  41. package/packages/receipts/src/index.mjs +16 -0
  42. package/packages/receipts/src/inputs-hash.mjs +194 -0
  43. package/packages/receipts/src/receipt-validate.mjs +234 -0
  44. package/src/commands/upgrade.mjs +383 -0
  45. package/src/lib/harness-upgrade.mjs +521 -0
  46. package/src/scaffold.mjs +60 -1
  47. package/template/AGENTS.md +5 -0
  48. package/template/CLAUDE.md +34 -1
  49. package/template/README.md +4 -0
  50. package/template/gitignore +8 -0
  51. package/template/qa/lib/audit-cadence.mjs +290 -0
  52. package/template/qa/lib/determinism.mjs +179 -0
  53. package/template/qa/lib/evidence-badge.mjs +158 -0
  54. package/template/qa/lib/flight-recorder.mjs +332 -0
  55. package/template/qa/lib/harness-lock.mjs +147 -0
  56. package/template/qa/lib/harness-region.mjs +159 -0
  57. package/template/qa/lib/inputs-hash.mjs +17 -2
  58. package/template/qa/lib/receipt-validate.mjs +1 -1
  59. package/template/qa/preview-gallery.mjs +17 -2
  60. package/template/qa/record-audit.mjs +83 -0
  61. package/template/qa/retrospective.mjs +51 -0
  62. package/template/qa/verify.mjs +400 -10
  63. package/template/qa/watch.mjs +2 -2
@@ -17,15 +17,87 @@
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 { writeHarnessLock, checkHarnessIntegrity, describeIntegrity } from "../../packages/harness/src/lib/harness-lock.mjs";
46
+ import { LOCAL_PATCH_PATH } from "../lib/harness-upgrade.mjs";
47
+ import {
48
+ planHarnessUpgrade,
49
+ applyHarnessPlan,
50
+ configFromSpecRecord,
51
+ SIDECAR_SUFFIX,
52
+ } from "../lib/harness-upgrade.mjs";
53
+
54
+ const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
55
+
56
+ /**
57
+ * The harness version this engine ships. Read from the package that owns the
58
+ * lane, NOT the engine's own package.json — they version independently, which
59
+ * is the point: the lane changes far more often than the template's app shape,
60
+ * and fusing them forced an app-shape merge every time a lane fix shipped.
61
+ * @returns {string|null} semver, or null when unreadable
62
+ */
63
+ function shippedHarnessVersion() {
64
+ try {
65
+ return JSON.parse(
66
+ fs.readFileSync(path.join(REPO_ROOT, "packages/harness/package.json"), "utf8")
67
+ ).version;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Advance create-cmp.json's engineVersion to what this engine just applied.
75
+ *
76
+ * Nothing used to do this, and the omission compounded: the next
77
+ * `upgrade --harness` re-fetched the STALE version as its merge base and
78
+ * re-presented every conflict already resolved by hand. Fuelled still read
79
+ * 0.9.0 after being upgraded to 0.13.0.
80
+ *
81
+ * Only called when the sweep landed COMPLETELY. With conflicts outstanding the
82
+ * app is genuinely part-way between two engine versions, and claiming the new
83
+ * one would be the same lie in the other direction.
84
+ * @param {string} projectDir
85
+ * @param {string} version
86
+ * @returns {boolean} whether the record was updated
87
+ */
88
+ function writeBackEngineVersion(projectDir, version) {
89
+ const specPath = path.join(projectDir, "create-cmp.json");
90
+ try {
91
+ const record = JSON.parse(fs.readFileSync(specPath, "utf8"));
92
+ if (record.engineVersion === version) return false;
93
+ record.engineVersion = version;
94
+ record.upgradedAt = new Date().toISOString();
95
+ fs.writeFileSync(specPath, `${JSON.stringify(record, null, 2)}\n`);
96
+ return true;
97
+ } catch {
98
+ return false; // best-effort — never fail an applied upgrade over metadata
99
+ }
100
+ }
29
101
 
30
102
  function readIfExists(p) {
31
103
  try {
@@ -48,11 +120,322 @@ function printDiffTable(changes) {
48
120
  }
49
121
  }
50
122
 
123
+ // --- harness mode helpers ----------------------------------------------------
124
+
125
+ /** Current engine version, from this checkout's package.json. */
126
+ function currentEngineVersion() {
127
+ try {
128
+ return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")).version;
129
+ } catch {
130
+ return "unknown";
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Resolve a --base-dir argument to a template root (the dir holding
136
+ * manifest.json). Accepts the template root itself, a repo/package checkout
137
+ * containing `template/`, or an extracted npm tarball (`package/template/`).
138
+ * Throws (never exits) so the caller's temp-dir cleanup always runs.
139
+ * @param {string} p
140
+ * @returns {string} absolute template root
141
+ */
142
+ function resolveBaseTemplateDir(p) {
143
+ const abs = path.resolve(p);
144
+ for (const candidate of [abs, path.join(abs, "template"), path.join(abs, "package", "template")]) {
145
+ if (fs.existsSync(path.join(candidate, "manifest.json"))) return candidate;
146
+ }
147
+ throw new Error(
148
+ `--base-dir ${abs} does not look like a create-cmp template (no manifest.json at ` +
149
+ `<dir>, <dir>/template, or <dir>/package/template).`
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Fetch the OLD engine's template by version: `npm pack create-cmp-cli@<v>`
155
+ * into a temp dir, then extract `package/template/` from the tarball.
156
+ * Fails loudly (throws — never exits, so the caller's temp-dir cleanup always
157
+ * runs), naming the exact command that failed and the --base-dir escape hatch
158
+ * (offline / local checkout / testing).
159
+ * @param {string} engineVersion version recorded in create-cmp.json
160
+ * @param {string} tmpRoot session temp dir (cleaned up by the caller)
161
+ * @returns {string} absolute path of the extracted template root
162
+ */
163
+ function fetchBaseTemplate(engineVersion, tmpRoot) {
164
+ if (!engineVersion || engineVersion === "unknown" || !/^[0-9A-Za-z.+-]+$/.test(engineVersion)) {
165
+ throw new Error(
166
+ `create-cmp.json does not record a usable engineVersion (got ${JSON.stringify(engineVersion)}). ` +
167
+ `Pass --base-dir <path> pointing at the template this app was stamped from.`
168
+ );
169
+ }
170
+ const packDir = path.join(tmpRoot, "pack");
171
+ fs.mkdirSync(packDir, { recursive: true });
172
+ const packCmd = `npm pack create-cmp-cli@${engineVersion}`;
173
+ const r = spawnSync("npm", ["pack", `create-cmp-cli@${engineVersion}`], {
174
+ cwd: packDir,
175
+ encoding: "utf8",
176
+ timeout: 120000,
177
+ });
178
+ if (r.error || r.status !== 0) {
179
+ throw new Error(
180
+ `Could not fetch the base engine template: \`${packCmd}\` failed` +
181
+ (r.stderr ? ` — ${r.stderr.trim().split("\n").pop()}` : "") +
182
+ `.\n Offline or unpublished version? Pass --base-dir <path> to an already-extracted template.`
183
+ );
184
+ }
185
+ // npm pack prints the tarball filename as the last stdout line.
186
+ const tgz = (r.stdout || "").trim().split("\n").pop().trim();
187
+ const tgzPath = path.join(packDir, tgz);
188
+ const tarCmd = `tar -xzf ${tgzPath} -C ${packDir}`;
189
+ const rt = spawnSync("tar", ["-xzf", tgzPath, "-C", packDir], { timeout: 120000 });
190
+ if (rt.error || rt.status !== 0 || !fs.existsSync(path.join(packDir, "package", "template", "manifest.json"))) {
191
+ throw new Error(
192
+ `Could not extract the base engine template: \`${tarCmd}\` failed or the tarball ` +
193
+ `carries no package/template/. Pass --base-dir <path> to an already-extracted template.`
194
+ );
195
+ }
196
+ return path.join(packDir, "package", "template");
197
+ }
198
+
199
+ /** Print the grouped harness report; returns whether anything is actionable. */
200
+ function printHarnessReport(plan) {
201
+ const c = plan.counts;
202
+ const list = (bucket) => plan.entries.filter((e) => e.bucket === bucket).map((e) => e.relPath);
203
+
204
+ process.stdout.write(
205
+ `\n${colors.bold("Engine-owned files")} — ` +
206
+ `${colors.dim(`unchanged ${c.unchanged} · already current ${c.current} · excluded state/secrets ${c.excluded}`)}\n`
207
+ );
208
+ const groups = [
209
+ // The machine-owned lane first: it is the bulk of the sweep and the part
210
+ // that needs no human judgement at all.
211
+ ["region-clean", "lane refreshed (machine-owned, untouched since install)", ok],
212
+ ["region-absorbed", "lane refreshed (local edit already carried by the new engine)", ok],
213
+ ["region-restored", "lane restored (files the app had deleted)", ok],
214
+ ["region-removed", "lane files retired by the engine", ok],
215
+ [
216
+ "region-patched",
217
+ `lane refreshed, LOCAL FORK preserved (not re-applied — see ${LOCAL_PATCH_PATH})`,
218
+ warn,
219
+ ],
220
+ ["applied", "applied (engine changed, app never touched — will take the new content)", ok],
221
+ ["merged", "merged (both changed different regions — both edits survive)", ok],
222
+ ["added", "added (new engine files absent from the app)", ok],
223
+ ["removed", "removed (engine deleted, app never touched — will be deleted)", warn],
224
+ ["orphaned", "orphaned (engine deleted these but the app modified them — left in place)", warn],
225
+ ["conflicted", `conflicted (NEVER clobbered — new engine content lands beside as *${SIDECAR_SUFFIX})`, fail],
226
+ ];
227
+ let actionable = 0;
228
+ for (const [bucket, label, log] of groups) {
229
+ const files = list(bucket);
230
+ if (files.length === 0) continue;
231
+ // `orphaned` needs no action (the app's file is kept as-is), and neither do
232
+ // the clean lane buckets — they apply themselves. Only a preserved local
233
+ // fork earns a human's attention among the region buckets.
234
+ if (bucket !== "orphaned") actionable += files.length;
235
+ log(`${colors.bold(String(files.length))} ${label}`);
236
+ for (const f of files) process.stdout.write(` ${f}\n`);
237
+ }
238
+ process.stdout.write(
239
+ colors.dim(
240
+ `\n Not considered: ${c.excluded} excluded app-state/secret files ` +
241
+ `(evidence, approvals, goldens, keystores, Firebase configs), and any app-authored ` +
242
+ `files the engine never stamped.\n`
243
+ )
244
+ );
245
+ return actionable > 0;
246
+ }
247
+
248
+ /**
249
+ * `create-cmp upgrade --harness` — refresh engine-owned files of a stamped app.
250
+ * @param {Record<string,string|boolean>} flags
251
+ * @param {string|undefined} positional optional target dir positional
252
+ */
253
+ async function runHarnessUpgrade(flags, positional) {
254
+ const targetDir =
255
+ (typeof flags["target-dir"] === "string" && flags["target-dir"]) || positional || ".";
256
+ const projectDir = path.resolve(targetDir);
257
+
258
+ const specPath = path.join(projectDir, "create-cmp.json");
259
+ const specRaw = readIfExists(specPath);
260
+ if (specRaw === null) {
261
+ process.stderr.write(
262
+ `Error: no create-cmp.json under ${projectDir}.\n` +
263
+ `\`create-cmp upgrade --harness\` refreshes the engine-owned files of a create-cmp-stamped ` +
264
+ `project, and needs the spec-of-record the stamp wrote. Run it from the project root or ` +
265
+ `pass --target-dir. (For a plain version-catalog upgrade, drop --harness.)\n`
266
+ );
267
+ process.exit(1);
268
+ }
269
+ let record;
270
+ try {
271
+ record = JSON.parse(specRaw);
272
+ } catch (e) {
273
+ process.stderr.write(`Error: ${specPath} is not valid JSON (${e.message}).\n`);
274
+ process.exit(1);
275
+ }
276
+
277
+ const baseVersion = record.engineVersion;
278
+ const currentVersion = currentEngineVersion();
279
+ process.stdout.write(
280
+ `\n${colors.bold("create-cmp upgrade --harness")} — refresh engine-owned files\n` +
281
+ ` project: ${colors.cyan(projectDir)}\n` +
282
+ ` stamped by engine ${colors.yellow(String(baseVersion))} ${colors.dim("→")} current engine ${colors.green(currentVersion)}\n\n`
283
+ );
284
+
285
+ // NOTE: `process.exit` skips `finally`, so the body below RETURNS an exit
286
+ // code and the temp trees are cleaned up before the process actually exits.
287
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "create-cmp-harness-"));
288
+ let code = 1;
289
+ try {
290
+ code = await harnessPlanAndApply({
291
+ flags,
292
+ record,
293
+ projectDir,
294
+ targetDir,
295
+ tmpRoot,
296
+ baseVersion,
297
+ currentVersion,
298
+ });
299
+ } catch (e) {
300
+ fail(e.message);
301
+ code = 1;
302
+ } finally {
303
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
304
+ }
305
+ process.exit(code);
306
+ }
307
+
308
+ /**
309
+ * The harness mode's plan/report/apply body. Returns the process exit code
310
+ * (0 = clean apply or dry run, 1 = conflicts produced) and throws on
311
+ * environment failures — it never calls process.exit itself, so the caller's
312
+ * temp-dir cleanup always runs.
313
+ * @returns {Promise<number>}
314
+ */
315
+ async function harnessPlanAndApply({ flags, record, projectDir, targetDir, tmpRoot, baseVersion, currentVersion }) {
316
+ // Base template: --base-dir wins (offline / local checkout / testing);
317
+ // same-version needs no fetch (the local template IS the base); otherwise
318
+ // npm pack the recorded version.
319
+ let baseTemplateDir;
320
+ if (typeof flags["base-dir"] === "string") {
321
+ baseTemplateDir = resolveBaseTemplateDir(flags["base-dir"]);
322
+ step(`Base template: ${colors.cyan(baseTemplateDir)} (--base-dir)`);
323
+ } else if (baseVersion === currentVersion) {
324
+ baseTemplateDir = path.join(REPO_ROOT, "template");
325
+ step(`App was stamped by THIS engine version — base is the local template.`);
326
+ } else {
327
+ step(`Fetching base engine ${baseVersion} via npm pack…`);
328
+ baseTemplateDir = fetchBaseTemplate(baseVersion, tmpRoot);
329
+ }
330
+
331
+ // Stamp both sides with the app's OWN recorded config, so tokens resolve
332
+ // identically and base→new diffs are pure engine change. `verify:false`
333
+ // keeps this filesystem-only — no Gradle, no device.
334
+ const { scaffold } = await import("../scaffold.mjs");
335
+ const newDir = path.join(tmpRoot, "new");
336
+ const baseDir = path.join(tmpRoot, "base");
337
+ step("Stamping the CURRENT engine with the app's recorded config…");
338
+ await scaffold(configFromSpecRecord(record, newDir), { verify: false });
339
+ step(`Stamping the BASE engine (${baseVersion}) with the same config…`);
340
+ await scaffold(configFromSpecRecord(record, baseDir), {
341
+ templateDir: baseTemplateDir,
342
+ verify: false,
343
+ });
344
+ const plan = planHarnessUpgrade({ baseDir, newDir, projectDir });
345
+
346
+ const anythingToDo = printHarnessReport(plan);
347
+ if (!anythingToDo) {
348
+ ok("Engine-owned files are fully up to date — nothing to apply.");
349
+ return 0;
350
+ }
351
+
352
+ if (flags["dry-run"] === true) {
353
+ process.stdout.write(`\n${colors.yellow("Dry run")} — nothing written. Re-run with --yes to apply.\n`);
354
+ return 0;
355
+ }
356
+ const approved = await consent(
357
+ `\nApply these changes (backups written as *${BACKUP_SUFFIX}; conflicts only get *${SIDECAR_SUFFIX} sidecars)?`,
358
+ { assumeYes: flags.yes === true }
359
+ );
360
+ if (!approved) {
361
+ process.stdout.write(`${colors.yellow("Not applied")} — dry run only. Re-run with --yes to apply.\n`);
362
+ return 0;
363
+ }
364
+
365
+ const actionable = plan.entries.filter(
366
+ (e) => e.write !== null || e.sidecar !== null || e.remove
367
+ );
368
+ const result = applyHarnessPlan(projectDir, actionable);
369
+ for (const f of result.written) ok(`wrote ${f} ${colors.dim(`(backup: ${f}${BACKUP_SUFFIX})`)}`);
370
+ for (const f of result.created) ok(`created ${f}`);
371
+ for (const f of result.deleted) ok(`deleted ${f} ${colors.dim(`(backup: ${f}${BACKUP_SUFFIX})`)}`);
372
+ for (const f of result.sidecars) warn(`conflict sidecar ${f} — resolve by hand, then delete it`);
373
+
374
+ // ── Re-lock the lane ──────────────────────────────────────────────────────
375
+ // The machine-owned region always lands on the new engine's content, whether
376
+ // or not app-shaped files conflicted — that is what the two independent
377
+ // version numbers buy. So the lock is rewritten on its own schedule.
378
+ const harnessVersion = shippedHarnessVersion();
379
+ if (harnessVersion) {
380
+ writeHarnessLock(projectDir, { version: harnessVersion });
381
+ ok(`lane locked at ${colors.bold(harnessVersion)} — ${describeIntegrity(checkHarnessIntegrity(projectDir))}`);
382
+ }
383
+
384
+ if (result.patched.length > 0) {
385
+ warn(
386
+ `${result.patched.length} lane file(s) carried a LOCAL change that the new engine does not. ` +
387
+ `The lane was refreshed and your edits were preserved — NOT re-applied — in ${LOCAL_PATCH_PATH}.`
388
+ );
389
+ for (const f of result.patched) process.stdout.write(` ${f}\n`);
390
+ process.stdout.write(
391
+ colors.dim(
392
+ ` The diff is against the lane you WERE on, so it may not apply cleanly to the new one —\n` +
393
+ ` that is the merge this tool declined to do behind your back, not a broken patch.\n` +
394
+ ` Attempt it with: git apply --reject ${LOCAL_PATCH_PATH} (or upstream the change)\n`
395
+ )
396
+ );
397
+ }
398
+
399
+ if (result.backups.length > 0 || result.created.length > 0) {
400
+ process.stdout.write(`\n${colors.bold("To revert")}\n`);
401
+ for (const f of result.backups) {
402
+ process.stdout.write(` mv "${path.join(projectDir, f)}${BACKUP_SUFFIX}" "${path.join(projectDir, f)}"\n`);
403
+ }
404
+ for (const f of result.created) {
405
+ process.stdout.write(` rm "${path.join(projectDir, f)}"\n`);
406
+ }
407
+ }
408
+
409
+ if (result.sidecars.length > 0) {
410
+ fail(
411
+ `${result.sidecars.length} conflict(s) need a human: the app's files were left untouched; ` +
412
+ `each *${SIDECAR_SUFFIX} sidecar carries the new engine content.`
413
+ );
414
+ return 1;
415
+ }
416
+ // ── Advance the recorded engine version ───────────────────────────────────
417
+ // Only now, with nothing conflicted: the sweep landed completely, so the
418
+ // next upgrade's merge base is genuinely this version. Skipping this is what
419
+ // made repeat upgrades compound — the base stayed stale and every
420
+ // already-resolved conflict came back.
421
+ if (writeBackEngineVersion(projectDir, currentVersion)) {
422
+ ok(`create-cmp.json engineVersion → ${colors.bold(currentVersion)}`);
423
+ }
424
+
425
+ process.stdout.write(
426
+ `\n${colors.green("Applied.")} Prove the build: ${colors.cyan(`create-cmp verify --target-dir ${targetDir}`)}\n`
427
+ );
428
+ return 0;
429
+ }
430
+
51
431
  /**
52
432
  * @param {Record<string,string|boolean>} flags
53
433
  * @param {string|undefined} positional optional target dir positional
54
434
  */
55
435
  export async function runUpgrade(flags, positional) {
436
+ if (flags.harness === true) {
437
+ return runHarnessUpgrade(flags, positional);
438
+ }
56
439
  const targetDir =
57
440
  (typeof flags["target-dir"] === "string" && flags["target-dir"]) || positional || ".";
58
441
  const projectDir = path.resolve(targetDir);