@multiplatform.one/cli 6.7.0 → 7.0.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 (43) hide show
  1. package/README.md +83 -8
  2. package/lib/bin/multiplatformOne.mjs +21 -7
  3. package/lib/commands/adoptApp.mjs +127 -0
  4. package/lib/commands/init.mjs +1 -1
  5. package/lib/commands/initApp.mjs +43 -15
  6. package/lib/commands/updateApp.mjs +84 -15
  7. package/package.json +2 -2
  8. package/scripts/frappe-app-name.py +125 -0
  9. package/scripts/frappe-app-name.spec.ts +191 -0
  10. package/scripts/frappe-bootstrap.sh +8 -30
  11. package/src/bin/multiplatformOne.ts +78 -13
  12. package/src/commands/adoptApp.spec.ts +208 -0
  13. package/src/commands/adoptApp.ts +185 -0
  14. package/src/commands/initApp.spec.ts +152 -12
  15. package/src/commands/initApp.ts +95 -21
  16. package/src/commands/updateApp.spec.ts +314 -2
  17. package/src/commands/updateApp.ts +164 -33
  18. package/templates/app/apps/__NAME__/package.json +1 -1
  19. package/templates/app/features/__NAME__/package.json +1 -1
  20. package/templates/pieces/gnome/universal/README.md.partial +5 -5
  21. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/anchor.tsx +39 -0
  22. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/main.tsx +4 -2
  23. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/components.ts +26 -0
  24. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/forms.ts +26 -0
  25. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/frappe-ui.ts +17 -0
  26. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/one.ts +3 -0
  27. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/theme.ts +21 -0
  28. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/tamagui-barrel.ts +21 -0
  29. package/templates/pieces/gnome/universal/apps/__NAME__/vite.config.gnome.ts +49 -7
  30. package/templates/pieces/keycloak/universal/README.md.partial +13 -0
  31. package/templates/pieces/keycloak/universal/docker/compose.keycloak.yaml +19 -3
  32. package/templates/pieces/keycloak/universal/env.example.partial +4 -1
  33. package/templates/pieces/vscode/universal/apps/__NAME__/package.json.partial +1 -1
  34. package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +2 -2
  35. package/templates/universal/apps/__NAME__/package.json +2 -2
  36. package/templates/universal/packages/themes/package.json +2 -2
  37. package/types/bin/multiplatformOne.d.ts.map +1 -1
  38. package/types/commands/adoptApp.d.ts +25 -0
  39. package/types/commands/adoptApp.d.ts.map +1 -0
  40. package/types/commands/initApp.d.ts +33 -5
  41. package/types/commands/initApp.d.ts.map +1 -1
  42. package/types/commands/updateApp.d.ts +12 -2
  43. package/types/commands/updateApp.d.ts.map +1 -1
@@ -20,6 +20,15 @@ import spawn from "nano-spawn";
20
20
  */
21
21
  export type InitAppTemplate = "universal" | "app";
22
22
 
23
+ /**
24
+ * The provenance template domain: the scaffold templates plus "none" for
25
+ * ADOPTED projects (`mpo adopt`) — existing repos that were never scaffolded.
26
+ * A "none" scaffold is the selected piece fragments overlaid on an EMPTY
27
+ * tree (no universal/app base), which is exactly the tool-owned surface of
28
+ * an adopted project.
29
+ */
30
+ export type ProvenanceTemplate = InitAppTemplate | "none";
31
+
23
32
  /**
24
33
  * Optional composable pieces overlaid onto the base template (additive
25
34
  * fragments under templates/pieces/<piece>/ — the inverse of the old
@@ -40,7 +49,7 @@ const PIECE_DESCRIPTIONS: Record<InitAppPiece, string> = {
40
49
  frappe: "Frappe backend wiring (env keys, provider, docker compose bench)",
41
50
  gnome: "GNOME desktop target (GTK4/GJS via react-gnome — native widgets, no webview)",
42
51
  keycloak: "Keycloak auth wiring (env keys, provider, docker compose + realm)",
43
- tauri: "Desktop target (src-tauri skeleton, tauri scripts)",
52
+ tauri: "Tauri desktop target (src-tauri skeleton — webview shell, Rust toolchain)",
44
53
  vscode: "VS Code extension target (extension host + Tamagui webview)",
45
54
  webext: "Browser extension target (MV3 popup + background)",
46
55
  };
@@ -52,8 +61,13 @@ export interface InitAppOptions {
52
61
  skipGit?: boolean;
53
62
  /** Pin @multiplatform.one/* to this semver range. Default: ^<cli version>. */
54
63
  version?: string;
55
- /** Project template. When omitted: prompt (interactive) or "universal". */
56
- template?: InitAppTemplate;
64
+ /**
65
+ * Project template. When omitted: prompt (interactive) or "universal".
66
+ * "none" scaffolds ONLY the selected pieces onto an empty tree (the
67
+ * `--pieces-only` flag) — `mpo update` uses it to rebuild the merge
68
+ * baselines of adopted projects.
69
+ */
70
+ template?: ProvenanceTemplate;
57
71
  /** Accept defaults instead of prompting (non-interactive / CI). */
58
72
  yes?: boolean;
59
73
  /**
@@ -80,7 +94,7 @@ function toPascalCase(name: string): string {
80
94
  .join("");
81
95
  }
82
96
 
83
- function validateName(name: string): string {
97
+ export function validateName(name: string): string {
84
98
  const trimmed = name.trim();
85
99
  if (!trimmed) {
86
100
  throw new Error("Project name is required");
@@ -93,6 +107,19 @@ function validateName(name: string): string {
93
107
  return trimmed;
94
108
  }
95
109
 
110
+ /** Root package.json name with any @scope/ prefix stripped (adopt +
111
+ * update-bootstrap provenance name derivation). */
112
+ export function packageJsonName(projectDir: string): string | undefined {
113
+ try {
114
+ const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf-8")) as {
115
+ name?: string;
116
+ };
117
+ return pkg.name?.replace(/^@[^/]+\//u, "");
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ }
122
+
96
123
  function resolveMpoVersion(explicit?: string): string {
97
124
  if (explicit) {
98
125
  return explicit.startsWith("^") || explicit.startsWith("~") || explicit === "*"
@@ -120,12 +147,21 @@ export function cliVersion(): string | undefined {
120
147
  * three-way template update.
121
148
  */
122
149
  export interface MpoProvenance {
123
- template: InitAppTemplate;
150
+ /** "none" = adopted project (`mpo adopt`): pieces only, no base template. */
151
+ template: ProvenanceTemplate;
124
152
  cliVersion: string;
125
153
  name: string;
126
154
  mpoVersion: string;
127
155
  /** Composable pieces overlaid at init time (sorted). */
128
156
  pieces: InitAppPiece[];
157
+ /**
158
+ * false = adopted but never reconciled: `mpo adopt` wrote provenance
159
+ * without touching any project file, so no tool-owned content exists yet.
160
+ * The first `mpo update` merges the piece baseline from an EMPTY base
161
+ * (surfacing real diffs against hand-copied files) and then clears the
162
+ * flag. Absent/true = the tree carries the recorded scaffold.
163
+ */
164
+ reconciled?: boolean;
129
165
  }
130
166
 
131
167
  export const PROVENANCE_FILE = ".mpo.json";
@@ -150,6 +186,8 @@ export function readProvenance(projectDir: string): MpoProvenance | undefined {
150
186
  mpoVersion: raw.mpoVersion ?? `^${raw.cliVersion}`,
151
187
  // Scaffolds from pre-piece CLIs (≤6.4.x) have no pieces field.
152
188
  pieces: Array.isArray(raw.pieces) ? [...(raw.pieces as InitAppPiece[])].sort() : [],
189
+ // Only adopted-but-unreconciled provenance carries the flag.
190
+ ...(raw.reconciled === false ? { reconciled: false } : {}),
153
191
  };
154
192
  } catch {
155
193
  return undefined;
@@ -167,10 +205,13 @@ function piecesRoot(piece: InitAppPiece): string {
167
205
  /**
168
206
  * Fragment source dirs for a piece + template: shared/ applies to every
169
207
  * template, <template>/ only to that one. A piece supports a template when
170
- * at least one of the two exists.
208
+ * at least one of the two exists. Adopted projects (template "none") use the
209
+ * universal fragment set — pieces ship universal-layout fragments
210
+ * (apps/<name>/…), which is the layout adopted repos follow.
171
211
  */
172
- function pieceFragmentDirs(piece: InitAppPiece, template: InitAppTemplate): string[] {
173
- return [join(piecesRoot(piece), "shared"), join(piecesRoot(piece), template)].filter((dir) =>
212
+ function pieceFragmentDirs(piece: InitAppPiece, template: ProvenanceTemplate): string[] {
213
+ const effective: InitAppTemplate = template === "none" ? "universal" : template;
214
+ return [join(piecesRoot(piece), "shared"), join(piecesRoot(piece), effective)].filter((dir) =>
174
215
  existsSync(dir),
175
216
  );
176
217
  }
@@ -357,7 +398,7 @@ function overlayTree(srcDir: string, destDir: string, vars: TemplateVars): void
357
398
  * order must be deterministic), and every piece must ship a fragment for the
358
399
  * chosen template.
359
400
  */
360
- export function normalizePieces(pieces: string[], template: InitAppTemplate): InitAppPiece[] {
401
+ export function normalizePieces(pieces: string[], template: ProvenanceTemplate): InitAppPiece[] {
361
402
  const known = new Set<string>(INIT_APP_PIECES);
362
403
  const normalized = [...new Set(pieces)].sort() as InitAppPiece[];
363
404
  for (const piece of normalized) {
@@ -375,13 +416,18 @@ export function normalizePieces(pieces: string[], template: InitAppTemplate): In
375
416
  }
376
417
 
377
418
  /** Pieces that can be offered for a template (fragment exists for it). */
378
- export function availablePieces(template: InitAppTemplate): InitAppPiece[] {
419
+ export function availablePieces(template: ProvenanceTemplate): InitAppPiece[] {
379
420
  return INIT_APP_PIECES.filter((piece) => pieceFragmentDirs(piece, template).length > 0);
380
421
  }
381
422
 
423
+ /** Human-readable piece summaries (adopt + init prompts). */
424
+ export function pieceDescription(piece: InitAppPiece): string {
425
+ return PIECE_DESCRIPTIONS[piece];
426
+ }
427
+
382
428
  async function resolvePieces(
383
429
  options: InitAppOptions,
384
- template: InitAppTemplate,
430
+ template: ProvenanceTemplate,
385
431
  ): Promise<InitAppPiece[]> {
386
432
  if (options.pieces) return normalizePieces(options.pieces, template);
387
433
  if (options.yes || !process.stdout.isTTY) return [];
@@ -454,7 +500,7 @@ export function composeAppTs(pieces: InitAppPiece[]): string {
454
500
  return lines.join("\n");
455
501
  }
456
502
 
457
- async function resolveTemplate(options: InitAppOptions): Promise<InitAppTemplate> {
503
+ async function resolveTemplate(options: InitAppOptions): Promise<ProvenanceTemplate> {
458
504
  if (options.template) return options.template;
459
505
  if (options.yes || !process.stdout.isTTY) return "universal";
460
506
  const result = await inquirer.prompt([
@@ -501,6 +547,12 @@ export async function initApp(
501
547
  const name = validateName(projectName);
502
548
  const template = await resolveTemplate(options);
503
549
  const pieces = await resolvePieces(options, template);
550
+ if (template === "none" && pieces.length === 0) {
551
+ throw new Error(
552
+ "A pieces-only scaffold needs at least one piece " +
553
+ `(${INIT_APP_PIECES.map((piece) => `--${piece}`).join(", ")})`,
554
+ );
555
+ }
504
556
  const targetDir = resolve(name);
505
557
 
506
558
  if (existsSync(targetDir)) {
@@ -518,11 +570,18 @@ export async function initApp(
518
570
  YEAR: String(new Date().getFullYear()),
519
571
  };
520
572
 
521
- const label = template === "universal" ? "universal (web + iOS + Android)" : "web-only";
573
+ const label =
574
+ template === "universal"
575
+ ? "universal (web + iOS + Android)"
576
+ : template === "app"
577
+ ? "web-only"
578
+ : "pieces-only (adopted-project baseline)";
522
579
  console.log(`\nScaffolding ${label} project at ${targetDir}`);
523
- console.log(` apps/${name}`);
524
- console.log(` features/${name}`);
525
- console.log(` packages/config`);
580
+ if (template !== "none") {
581
+ console.log(` apps/${name}`);
582
+ console.log(` features/${name}`);
583
+ console.log(` packages/config`);
584
+ }
526
585
  if (template === "universal") {
527
586
  console.log(` packages/i18n`);
528
587
  console.log(` packages/themes`);
@@ -532,7 +591,13 @@ export async function initApp(
532
591
  }
533
592
  console.log(` @multiplatform.one/* → ${vars.MPO_VERSION}\n`);
534
593
 
535
- writeTree(templatesRoot(template), targetDir, vars);
594
+ // "none" (adopted-project baselines) overlays pieces on an EMPTY tree —
595
+ // there is no base template to write.
596
+ if (template === "none") {
597
+ mkdirSync(targetDir, { recursive: true });
598
+ } else {
599
+ writeTree(templatesRoot(template), targetDir, vars);
600
+ }
536
601
  // Pieces overlay AFTER the base template, in sorted order (normalizePieces)
537
602
  // so repeated scaffolds are byte-identical — mpo update regenerates these
538
603
  // exact trees as merge baselines.
@@ -544,6 +609,8 @@ export async function initApp(
544
609
  // Backend providers are wired by generating src/app.ts from the selection
545
610
  // (not by stacking file overwrites per combination).
546
611
  if (pieces.includes("frappe") || pieces.includes("keycloak")) {
612
+ // Pieces-only trees have no base template dirs — create the path.
613
+ mkdirSync(join(targetDir, "apps", name, "src"), { recursive: true });
547
614
  writeFileSync(join(targetDir, "apps", name, "src/app.ts"), composeAppTs(pieces));
548
615
  }
549
616
  writeProvenance(targetDir, {
@@ -566,7 +633,8 @@ export async function initApp(
566
633
  () => false,
567
634
  );
568
635
  if (!insideRepo) {
569
- const label = template === "universal" ? "universal" : "web-only";
636
+ const label =
637
+ template === "universal" ? "universal" : template === "app" ? "web-only" : "pieces-only";
570
638
  await spawn("git", ["init", "--quiet"], { cwd: targetDir });
571
639
  await spawn("git", ["add", "-A"], { cwd: targetDir });
572
640
  scaffoldCommitted = await spawn(
@@ -583,7 +651,9 @@ export async function initApp(
583
651
  }
584
652
  }
585
653
 
586
- if (!options.skipInstall) {
654
+ // Pieces-only trees are merge baselines, not runnable projects — never
655
+ // install into them.
656
+ if (!options.skipInstall && template !== "none") {
587
657
  console.log("Installing dependencies...");
588
658
  await spawn("pnpm", ["install"], {
589
659
  cwd: targetDir,
@@ -596,11 +666,15 @@ export async function initApp(
596
666
  .then(() => spawn("git", ["commit", "--amend", "--no-edit", "--quiet"], { cwd: targetDir }))
597
667
  .catch(() => {});
598
668
  }
599
- } else {
669
+ } else if (options.skipInstall) {
600
670
  console.log("Skipped pnpm install (--skip-install).");
601
671
  }
602
672
 
603
673
  console.log(`\n✅ Project created at ${targetDir}`);
674
+ if (template === "none") {
675
+ console.log("\nPieces-only tree (adopted-project baseline) — not a runnable project.");
676
+ return;
677
+ }
604
678
  console.log("\nNext steps:");
605
679
  console.log(` cd ${name}`);
606
680
  if (options.skipInstall) console.log(" pnpm install");
@@ -619,7 +693,7 @@ export async function initApp(
619
693
  console.log(" pnpm gnome # GTK4 desktop app (requires gjs + gtk4 — see README)");
620
694
  }
621
695
  if (pieces.includes("tauri")) {
622
- console.log(" pnpm tauri:dev # desktop app (requires the Rust toolchain)");
696
+ console.log(" pnpm tauri:dev # Tauri desktop app (requires the Rust toolchain)");
623
697
  }
624
698
  if (pieces.includes("vscode")) {
625
699
  console.log(" pnpm build:vscode # stage the VS Code extension into apps dist-vscode/");
@@ -1,5 +1,11 @@
1
- import { describe, expect, it } from "vitest";
2
- import { threeWayMergePackageJson } from "./updateApp";
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import spawn from "nano-spawn";
5
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
6
+ import { adoptApp } from "./adoptApp";
7
+ import { cliVersion, initApp, readProvenance } from "./initApp";
8
+ import { threeWayMergePackageJson, updateApp } from "./updateApp";
3
9
 
4
10
  const json = (value: unknown) => JSON.stringify(value, null, 2);
5
11
 
@@ -79,3 +85,309 @@ describe("threeWayMergePackageJson", () => {
79
85
  });
80
86
  });
81
87
  });
88
+
89
+ // ── integration: adopt → update, bootstrap, idempotency ───────────
90
+ //
91
+ // Everything below runs at the CURRENT cli version, so both merge
92
+ // baselines come from the local generator (never pnpm dlx / network).
93
+
94
+ const readJson = (path: string) => JSON.parse(readFileSync(path, "utf-8"));
95
+
96
+ async function gitOut(cwd: string, args: string[]): Promise<string> {
97
+ const result = await spawn("git", args, { cwd });
98
+ return result.stdout.trim();
99
+ }
100
+
101
+ async function initRepo(dir: string): Promise<void> {
102
+ await spawn("git", ["init", "--quiet"], { cwd: dir });
103
+ await spawn("git", ["config", "user.email", "spec@example.com"], { cwd: dir });
104
+ await spawn("git", ["config", "user.name", "Spec"], { cwd: dir });
105
+ await spawn("git", ["add", "-A"], { cwd: dir });
106
+ await spawn("git", ["commit", "--quiet", "-m", "chore: fixture"], { cwd: dir });
107
+ }
108
+
109
+ function writeFiles(dir: string, files: Record<string, string>): void {
110
+ mkdirSync(dir, { recursive: true });
111
+ for (const [file, content] of Object.entries(files)) {
112
+ mkdirSync(dirname(join(dir, file)), { recursive: true });
113
+ writeFileSync(join(dir, file), content);
114
+ }
115
+ }
116
+
117
+ describe("updateApp (integration)", () => {
118
+ const currentVersion = cliVersion()!;
119
+ const currentRange = `^${currentVersion}`;
120
+ let sandbox: string;
121
+ let previousCwd: string;
122
+ let canonical: string;
123
+
124
+ /** Scaffold the canonical pieces-only tree (what adopted-update baselines
125
+ * look like) once, to source hand-copied fixtures and compare against. */
126
+ beforeAll(async () => {
127
+ sandbox = mkdtempSync(join(tmpdir(), "mpo-updateapp-spec-"));
128
+ previousCwd = process.cwd();
129
+ const parent = join(sandbox, "canonical");
130
+ mkdirSync(parent, { recursive: true });
131
+ process.chdir(parent);
132
+ await initApp("fake-ext", {
133
+ template: "none",
134
+ pieces: ["webext"],
135
+ version: currentRange,
136
+ skipInstall: true,
137
+ skipGit: true,
138
+ yes: true,
139
+ });
140
+ process.chdir(previousCwd);
141
+ canonical = join(parent, "fake-ext");
142
+ });
143
+
144
+ afterEach(() => {
145
+ process.chdir(previousCwd);
146
+ });
147
+
148
+ afterAll(() => {
149
+ rmSync(sandbox, { recursive: true, force: true });
150
+ });
151
+
152
+ it("adopted project: first update reconciles from an empty base, second run is a no-op", async () => {
153
+ // A never-scaffolded project that hand-copied ONE webext piece file
154
+ // verbatim and has its own unrelated files.
155
+ const dir = join(sandbox, "adopted-clean", "fake-ext");
156
+ writeFiles(dir, {
157
+ "package.json": json({
158
+ name: "@fake/fake-ext",
159
+ private: true,
160
+ scripts: { custom: "echo custom" },
161
+ }),
162
+ "src/index.ts": "export const custom = true;\n",
163
+ "apps/fake-ext/webext/manifest.ts": readFileSync(
164
+ join(canonical, "apps/fake-ext/webext/manifest.ts"),
165
+ "utf-8",
166
+ ),
167
+ });
168
+ await initRepo(dir);
169
+ process.chdir(dir);
170
+
171
+ await adoptApp({ pieces: ["webext"], yes: true });
172
+ expect(readProvenance(dir)?.reconciled).toBe(false);
173
+
174
+ await updateApp({ skipInstall: true });
175
+
176
+ // Missing piece files landed…
177
+ for (const file of [
178
+ "apps/fake-ext/vite.config.webext.ts",
179
+ "apps/fake-ext/vite.config.webext-background.ts",
180
+ "apps/fake-ext/webext/background/main.ts",
181
+ "apps/fake-ext/webext/views/popup/main.tsx",
182
+ "apps/fake-ext/package.json",
183
+ ]) {
184
+ expect(existsSync(join(dir, file)), `missing ${file}`).toBe(true);
185
+ }
186
+ // …the identical hand-copied file stayed identical…
187
+ expect(readFileSync(join(dir, "apps/fake-ext/webext/manifest.ts"), "utf-8")).toBe(
188
+ readFileSync(join(canonical, "apps/fake-ext/webext/manifest.ts"), "utf-8"),
189
+ );
190
+ // …the project's own files survived…
191
+ expect(readFileSync(join(dir, "src/index.ts"), "utf-8")).toBe("export const custom = true;\n");
192
+ // …and package.json merged structurally (custom + piece scripts).
193
+ const rootPkg = readJson(join(dir, "package.json"));
194
+ expect(rootPkg.scripts.custom).toBe("echo custom");
195
+ expect(rootPkg.scripts["build:webext"]).toBe("pnpm --filter @app/fake-ext build:webext");
196
+
197
+ // Reconciliation committed cleanly and cleared the flag.
198
+ expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
199
+ expect(await gitOut(dir, ["log", "-1", "--format=%s"])).toContain("mpo update");
200
+ const provenance = readProvenance(dir);
201
+ expect(provenance?.reconciled).toBeUndefined();
202
+ expect(provenance?.cliVersion).toBe(currentVersion);
203
+
204
+ // Idempotency: an immediate second update changes NOTHING.
205
+ const headBefore = await gitOut(dir, ["rev-parse", "HEAD"]);
206
+ await updateApp({ skipInstall: true });
207
+ expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(headBefore);
208
+ expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
209
+ });
210
+
211
+ it("adopted project: divergent hand-copied files surface conflict markers, then updates go quiet", async () => {
212
+ const dir = join(sandbox, "adopted-divergent", "fake-ext");
213
+ writeFiles(dir, {
214
+ "package.json": json({ name: "fake-ext", private: true }),
215
+ "apps/fake-ext/webext/manifest.ts": "// hand-rolled manifest, diverged\n",
216
+ });
217
+ await initRepo(dir);
218
+ process.chdir(dir);
219
+
220
+ await adoptApp({ pieces: ["webext"], yes: true });
221
+ await updateApp({ skipInstall: true });
222
+
223
+ // The divergent file carries conflict markers in the worktree — the
224
+ // documented (expected) first-update behavior for hand-adopted files.
225
+ const manifest = readFileSync(join(dir, "apps/fake-ext/webext/manifest.ts"), "utf-8");
226
+ expect(manifest).toContain("<<<<<<<");
227
+ expect(manifest).toContain(">>>>>>>");
228
+ expect(await gitOut(dir, ["status", "--porcelain"])).not.toBe("");
229
+ // Provenance was still rewritten (flag cleared) for the resolution commit.
230
+ expect(readProvenance(dir)?.reconciled).toBeUndefined();
231
+
232
+ // Resolve (keep the template side) and commit like the CLI instructs.
233
+ writeFileSync(
234
+ join(dir, "apps/fake-ext/webext/manifest.ts"),
235
+ readFileSync(join(canonical, "apps/fake-ext/webext/manifest.ts"), "utf-8"),
236
+ );
237
+ await spawn("git", ["add", "-A"], { cwd: dir });
238
+ await spawn("git", ["commit", "--quiet", "-m", "chore: mpo update (resolved)"], { cwd: dir });
239
+
240
+ // Idempotency after resolution.
241
+ const headBefore = await gitOut(dir, ["rev-parse", "HEAD"]);
242
+ await updateApp({ skipInstall: true });
243
+ expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(headBefore);
244
+ expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
245
+ });
246
+
247
+ it(".updateignore: dir/** normalizes to a directory pin; HEAD wins including absence", async () => {
248
+ const dir = join(sandbox, "adopted-pinned", "fake-ext");
249
+ writeFiles(dir, {
250
+ "package.json": json({ name: "fake-ext", private: true }),
251
+ // Deliberately diverged piece file the pin must protect verbatim.
252
+ "apps/fake-ext/webext/manifest.ts": "// bespoke manifest — never reconcile\n",
253
+ // The natural (glob) spelling users reach for first.
254
+ ".updateignore": "# bespoke surface\napps/fake-ext/webext/**\n",
255
+ });
256
+ await initRepo(dir);
257
+ process.chdir(dir);
258
+
259
+ await adoptApp({ pieces: ["webext"], yes: true });
260
+ await updateApp({ skipInstall: true });
261
+
262
+ // Pinned file byte-identical, no conflict markers anywhere under the pin.
263
+ expect(readFileSync(join(dir, "apps/fake-ext/webext/manifest.ts"), "utf-8")).toBe(
264
+ "// bespoke manifest — never reconcile\n",
265
+ );
266
+ // Pinning means HEAD wins INCLUDING absence: template-only files under
267
+ // the pinned dir must not land…
268
+ expect(existsSync(join(dir, "apps/fake-ext/webext/views/popup/main.tsx"))).toBe(false);
269
+ // …while piece files OUTSIDE the pin still flow.
270
+ expect(existsSync(join(dir, "apps/fake-ext/vite.config.webext.ts"))).toBe(true);
271
+
272
+ // Idempotency with pins active.
273
+ const headBefore = await gitOut(dir, ["rev-parse", "HEAD"]);
274
+ await updateApp({ skipInstall: true });
275
+ expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(headBefore);
276
+ expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
277
+ });
278
+
279
+ it(".updateignore: a genuinely glob-shaped entry is refused loudly, not silently dead", async () => {
280
+ const dir = join(sandbox, "adopted-badglob", "fake-ext");
281
+ writeFiles(dir, {
282
+ "package.json": json({ name: "fake-ext", private: true }),
283
+ ".updateignore": "apps/*.config.ts\n",
284
+ });
285
+ await initRepo(dir);
286
+ process.chdir(dir);
287
+
288
+ await adoptApp({ pieces: ["webext"], yes: true });
289
+ await expect(updateApp({ skipInstall: true })).rejects.toThrow(/looks like a glob/);
290
+ });
291
+
292
+ it("scaffolded project: immediate re-update is a no-op, customizations survive", async () => {
293
+ const parent = join(sandbox, "scaffolded");
294
+ mkdirSync(parent, { recursive: true });
295
+ process.chdir(parent);
296
+ await initApp("uni-idem", {
297
+ template: "universal",
298
+ pieces: ["webext"],
299
+ version: currentRange,
300
+ skipInstall: true,
301
+ skipGit: true,
302
+ yes: true,
303
+ });
304
+ const dir = join(parent, "uni-idem");
305
+ await initRepo(dir);
306
+ process.chdir(dir);
307
+
308
+ // Fresh scaffold at the current version: update must be a pure no-op.
309
+ const headBefore = await gitOut(dir, ["rev-parse", "HEAD"]);
310
+ await updateApp({ skipInstall: true });
311
+ expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(headBefore);
312
+ expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
313
+
314
+ // User customizations don't destabilize it.
315
+ writeFileSync(join(dir, "custom.md"), "# mine\n");
316
+ await spawn("git", ["add", "-A"], { cwd: dir });
317
+ await spawn("git", ["commit", "--quiet", "-m", "feat: customize"], { cwd: dir });
318
+ const headCustom = await gitOut(dir, ["rev-parse", "HEAD"]);
319
+ await updateApp({ skipInstall: true });
320
+ expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(headCustom);
321
+ expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
322
+ expect(readFileSync(join(dir, "custom.md"), "utf-8")).toBe("# mine\n");
323
+ });
324
+
325
+ it("bootstraps missing provenance with --assume-version and stays idempotent", async () => {
326
+ const parent = join(sandbox, "bootstrap");
327
+ mkdirSync(parent, { recursive: true });
328
+ process.chdir(parent);
329
+ await initApp("pre-prov", {
330
+ template: "universal",
331
+ version: currentRange,
332
+ skipInstall: true,
333
+ skipGit: true,
334
+ yes: true,
335
+ });
336
+ const dir = join(parent, "pre-prov");
337
+ // Simulate a pre-provenance scaffold: no .mpo.json ever existed.
338
+ rmSync(join(dir, ".mpo.json"));
339
+ await initRepo(dir);
340
+ process.chdir(dir);
341
+
342
+ // Without provenance the update refuses, pointing at both escape
343
+ // hatches (bootstrap + adopt).
344
+ await expect(updateApp({ skipInstall: true })).rejects.toThrow(/--assume-version/u);
345
+ await expect(updateApp({ skipInstall: true })).rejects.toThrow(/mpo adopt/u);
346
+ // Bad version strings are rejected before anything is written.
347
+ await expect(updateApp({ skipInstall: true, assumeVersion: "not-a-version" })).rejects.toThrow(
348
+ /exact CLI version/u,
349
+ );
350
+ expect(existsSync(join(dir, ".mpo.json"))).toBe(false);
351
+
352
+ await updateApp({ skipInstall: true, assumeVersion: currentVersion });
353
+ const provenance = readProvenance(dir);
354
+ expect(provenance?.template).toBe("universal");
355
+ expect(provenance?.cliVersion).toBe(currentVersion);
356
+ expect(provenance?.pieces).toEqual([]);
357
+ expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
358
+ // The bootstrap provenance is committed, not floating.
359
+ expect(await gitOut(dir, ["show", "HEAD:.mpo.json"])).toContain(currentVersion);
360
+
361
+ // Re-asserting a version over existing provenance is refused.
362
+ await expect(updateApp({ skipInstall: true, assumeVersion: currentVersion })).rejects.toThrow(
363
+ /already has provenance/u,
364
+ );
365
+
366
+ // Plain updates are now no-ops.
367
+ const headBefore = await gitOut(dir, ["rev-parse", "HEAD"]);
368
+ await updateApp({ skipInstall: true });
369
+ expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(headBefore);
370
+ expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
371
+ });
372
+
373
+ it("bootstraps the app template when told to", async () => {
374
+ const parent = join(sandbox, "bootstrap-app");
375
+ mkdirSync(parent, { recursive: true });
376
+ process.chdir(parent);
377
+ await initApp("pre-prov-app", {
378
+ template: "app",
379
+ version: currentRange,
380
+ skipInstall: true,
381
+ skipGit: true,
382
+ yes: true,
383
+ });
384
+ const dir = join(parent, "pre-prov-app");
385
+ rmSync(join(dir, ".mpo.json"));
386
+ await initRepo(dir);
387
+ process.chdir(dir);
388
+
389
+ await updateApp({ skipInstall: true, assumeVersion: currentVersion, assumeTemplate: "app" });
390
+ expect(readProvenance(dir)?.template).toBe("app");
391
+ expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
392
+ });
393
+ });