@multiplatform.one/cli 7.11.0 → 7.16.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 (163) hide show
  1. package/README.md +121 -111
  2. package/bin/createMultiplatformApp.mjs +39 -37
  3. package/lib/bin/multiplatformOne.mjs +65 -75
  4. package/lib/commands/addApp.mjs +78 -0
  5. package/lib/commands/adoptApp.mjs +11 -7
  6. package/lib/commands/doctypeTypes.mjs +148 -0
  7. package/lib/commands/e2e.mjs +2 -2
  8. package/lib/commands/init.mjs +142 -459
  9. package/lib/commands/merge.mjs +359 -0
  10. package/lib/commands/pieces.mjs +236 -0
  11. package/lib/commands/provenance.mjs +108 -0
  12. package/lib/commands/scaffold.mjs +105 -0
  13. package/lib/commands/updateApp.mjs +211 -528
  14. package/lib/source.json +5 -0
  15. package/lib/source.mjs +63 -0
  16. package/package.json +6 -6
  17. package/scripts/frappe-bootstrap.sh +29 -3
  18. package/scripts/frappe-dev.sh +1 -2
  19. package/scripts/frappe-helpers.sh +50 -2
  20. package/scripts/frappe.sh +5 -0
  21. package/scripts/scaffold.sh +937 -0
  22. package/scripts/stamp-source.mjs +51 -0
  23. package/scripts/stamp-source.spec.ts +86 -0
  24. package/src/bin/multiplatformOne.ts +116 -226
  25. package/src/commands/addApp.ts +102 -0
  26. package/src/commands/adoptApp.spec.ts +5 -1
  27. package/src/commands/adoptApp.ts +15 -10
  28. package/src/commands/doctypeTypes.spec.ts +155 -0
  29. package/src/commands/doctypeTypes.ts +214 -0
  30. package/src/commands/init.spec.ts +66 -0
  31. package/src/commands/init.ts +201 -643
  32. package/src/commands/merge.spec.ts +81 -0
  33. package/src/commands/merge.ts +411 -0
  34. package/src/commands/pieces.spec.ts +62 -0
  35. package/src/commands/pieces.ts +340 -0
  36. package/src/commands/provenance.spec.ts +92 -0
  37. package/src/commands/provenance.ts +169 -0
  38. package/src/commands/scaffold.ts +131 -0
  39. package/src/commands/updateApp.spec.ts +540 -331
  40. package/src/commands/updateApp.ts +255 -668
  41. package/src/source.spec.ts +45 -0
  42. package/src/source.ts +88 -0
  43. package/templates/AGENTS.md +161 -0
  44. package/templates/README.md +62 -0
  45. package/templates/pieces/frappe/universal/README.md.partial +7 -32
  46. package/templates/pieces/frappe/universal/env.example.partial +3 -3
  47. package/templates/pieces/frappe-bench/universal/README.md.partial +28 -0
  48. package/templates/pieces/{frappe → frappe-bench}/universal/docker/compose.frappe.yaml +2 -2
  49. package/templates/pieces/tauri/universal/README.md.partial +7 -5
  50. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/dev.rs +463 -0
  51. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +6 -0
  52. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +2 -1
  53. package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +1 -0
  54. package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +6 -2
  55. package/types/bin/multiplatformOne.d.ts.map +1 -1
  56. package/types/commands/addApp.d.ts +20 -0
  57. package/types/commands/addApp.d.ts.map +1 -0
  58. package/types/commands/adoptApp.d.ts +4 -3
  59. package/types/commands/adoptApp.d.ts.map +1 -1
  60. package/types/commands/doctypeTypes.d.ts +40 -0
  61. package/types/commands/doctypeTypes.d.ts.map +1 -0
  62. package/types/commands/init.d.ts +30 -13
  63. package/types/commands/init.d.ts.map +1 -1
  64. package/types/commands/merge.d.ts +54 -0
  65. package/types/commands/merge.d.ts.map +1 -0
  66. package/types/commands/pieces.d.ts +54 -0
  67. package/types/commands/pieces.d.ts.map +1 -0
  68. package/types/commands/provenance.d.ts +52 -0
  69. package/types/commands/provenance.d.ts.map +1 -0
  70. package/types/commands/scaffold.d.ts +43 -0
  71. package/types/commands/scaffold.d.ts.map +1 -0
  72. package/types/commands/updateApp.d.ts +19 -27
  73. package/types/commands/updateApp.d.ts.map +1 -1
  74. package/types/source.d.ts +35 -0
  75. package/types/source.d.ts.map +1 -0
  76. package/lib/commands/initApp.mjs +0 -431
  77. package/lib/generateVscode.mjs +0 -184
  78. package/scripts/clone.sh +0 -17
  79. package/scripts/update.sh +0 -46
  80. package/src/commands/initApp.spec.ts +0 -720
  81. package/src/commands/initApp.ts +0 -706
  82. package/src/generateVscode.ts +0 -238
  83. package/templates/app/AGENTS.md +0 -142
  84. package/templates/app/README.md +0 -62
  85. package/templates/app/apps/__NAME__/index.html +0 -36
  86. package/templates/app/apps/__NAME__/package.json +0 -31
  87. package/templates/app/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  88. package/templates/app/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  89. package/templates/app/apps/__NAME__/src/App.tsx +0 -9
  90. package/templates/app/apps/__NAME__/src/main.tsx +0 -14
  91. package/templates/app/apps/__NAME__/tsconfig.json +0 -8
  92. package/templates/app/apps/__NAME__/vite.config.ts +0 -17
  93. package/templates/app/features/__NAME__/package.json +0 -34
  94. package/templates/app/features/__NAME__/src/HomeScreen.tsx +0 -70
  95. package/templates/app/features/__NAME__/src/index.ts +0 -1
  96. package/templates/app/features/__NAME__/tsconfig.json +0 -5
  97. package/templates/app/gitignore +0 -9
  98. package/templates/app/npmrc +0 -2
  99. package/templates/app/oxlintrc.json +0 -39
  100. package/templates/app/package.json +0 -38
  101. package/templates/app/packages/config/config.json +0 -4
  102. package/templates/app/packages/config/index.ts +0 -5
  103. package/templates/app/packages/config/package.json +0 -17
  104. package/templates/app/packages/config/tsconfig.json +0 -5
  105. package/templates/app/pnpm-workspace.yaml +0 -4
  106. package/templates/app/tsconfig.base.json +0 -16
  107. package/templates/universal/AGENTS.md +0 -142
  108. package/templates/universal/README.md +0 -97
  109. package/templates/universal/apps/__NAME__/app.config.js +0 -31
  110. package/templates/universal/apps/__NAME__/config/tamagui.config.ts +0 -4
  111. package/templates/universal/apps/__NAME__/package.json +0 -62
  112. package/templates/universal/apps/__NAME__/polyfills.native.ts +0 -23
  113. package/templates/universal/apps/__NAME__/polyfills.ts +0 -3
  114. package/templates/universal/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  115. package/templates/universal/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  116. package/templates/universal/apps/__NAME__/routes/_layout.tsx +0 -22
  117. package/templates/universal/apps/__NAME__/routes/_layout.web.tsx +0 -21
  118. package/templates/universal/apps/__NAME__/routes/about.tsx +0 -3
  119. package/templates/universal/apps/__NAME__/routes/index.tsx +0 -3
  120. package/templates/universal/apps/__NAME__/src/app.ts +0 -10
  121. package/templates/universal/apps/__NAME__/tamagui.css +0 -3
  122. package/templates/universal/apps/__NAME__/tsconfig.json +0 -8
  123. package/templates/universal/apps/__NAME__/types.d.ts +0 -9
  124. package/templates/universal/apps/__NAME__/vite.config.ts +0 -20
  125. package/templates/universal/features/__NAME__/package.json +0 -35
  126. package/templates/universal/features/__NAME__/src/AboutScreen.tsx +0 -27
  127. package/templates/universal/features/__NAME__/src/AppLayout.tsx +0 -50
  128. package/templates/universal/features/__NAME__/src/ContactForm.spec.tsx +0 -22
  129. package/templates/universal/features/__NAME__/src/ContactForm.tsx +0 -55
  130. package/templates/universal/features/__NAME__/src/HomeScreen.tsx +0 -40
  131. package/templates/universal/features/__NAME__/src/index.ts +0 -4
  132. package/templates/universal/features/__NAME__/src/screens.spec.ts +0 -12
  133. package/templates/universal/features/__NAME__/tsconfig.json +0 -5
  134. package/templates/universal/features/__NAME__/vitest.config.mjs +0 -7
  135. package/templates/universal/gitignore +0 -14
  136. package/templates/universal/npmrc +0 -3
  137. package/templates/universal/oxlintrc.json +0 -39
  138. package/templates/universal/package.json +0 -51
  139. package/templates/universal/packages/config/config.json +0 -4
  140. package/templates/universal/packages/config/index.ts +0 -5
  141. package/templates/universal/packages/config/package.json +0 -17
  142. package/templates/universal/packages/config/tsconfig.json +0 -5
  143. package/templates/universal/packages/i18n/en/common.json +0 -30
  144. package/templates/universal/packages/i18n/es/common.json +0 -30
  145. package/templates/universal/packages/i18n/i18n.d.ts +0 -16
  146. package/templates/universal/packages/i18n/index.ts +0 -26
  147. package/templates/universal/packages/i18n/package.json +0 -20
  148. package/templates/universal/packages/i18n/resources.ts +0 -13
  149. package/templates/universal/packages/i18n/tsconfig.json +0 -5
  150. package/templates/universal/packages/themes/accent.ts +0 -32
  151. package/templates/universal/packages/themes/base.ts +0 -50
  152. package/templates/universal/packages/themes/fonts.ts +0 -8
  153. package/templates/universal/packages/themes/index.ts +0 -33
  154. package/templates/universal/packages/themes/package.json +0 -23
  155. package/templates/universal/packages/themes/tamagui.config.ts +0 -14
  156. package/templates/universal/packages/themes/tsconfig.json +0 -5
  157. package/templates/universal/pnpm-workspace.yaml +0 -4
  158. package/templates/universal/tsconfig.base.json +0 -21
  159. package/templates/universal/types/env.d.ts +0 -9
  160. package/types/commands/initApp.d.ts +0 -114
  161. package/types/commands/initApp.d.ts.map +0 -1
  162. package/types/generateVscode.d.ts +0 -7
  163. package/types/generateVscode.d.ts.map +0 -1
@@ -1,96 +1,30 @@
1
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import {
2
+ cpSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
2
10
  import { tmpdir } from "node:os";
3
11
  import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
4
13
  import spawn from "nano-spawn";
5
14
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
15
+ import { addApp } from "./addApp";
6
16
  import { adoptApp } from "./adoptApp";
7
- import { cliVersion, initApp, readProvenance } from "./initApp";
8
- import { threeWayMergePackageJson, updateApp } from "./updateApp";
9
-
17
+ import { init } from "./init";
18
+ import { piecesRoot, scaffoldPieces } from "./pieces";
19
+ import { cliVersion, readProvenance } from "./provenance";
20
+ import { updateApp } from "./updateApp";
21
+
22
+ // Every scaffold below reads THIS checkout at HEAD (resolveSource finds the
23
+ // monorepo around public/cli), so the suite exercises the real scaffold.sh
24
+ // against the real tree. Scaffolds take a few seconds each.
25
+ const monorepo = fileURLToPath(new URL("../../../..", import.meta.url));
26
+ const heavy = 300_000;
10
27
  const json = (value: unknown) => JSON.stringify(value, null, 2);
11
-
12
- describe("threeWayMergePackageJson", () => {
13
- it("resolves dependency-range drift to the newer minimum", () => {
14
- const base = json({ dependencies: { "@multiplatform.one/components": "^6.3.0" } });
15
- const ours = json({ dependencies: { "@multiplatform.one/components": "^6.4.2" } });
16
- const theirs = json({ dependencies: { "@multiplatform.one/components": "^6.4.3" } });
17
- expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
18
- dependencies: { "@multiplatform.one/components": "^6.4.3" },
19
- });
20
- // Symmetric: the project may be AHEAD of the template.
21
- expect(threeWayMergePackageJson(base, theirs, ours)).toEqual({
22
- dependencies: { "@multiplatform.one/components": "^6.4.3" },
23
- });
24
- });
25
-
26
- it("prefers the template spelling on equal minimums", () => {
27
- const base = json({ dependencies: { react: "^19.0.0" } });
28
- const ours = json({ dependencies: { react: "19.2.0" } });
29
- const theirs = json({ dependencies: { react: "~19.2.0" } });
30
- expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
31
- dependencies: { react: "~19.2.0" },
32
- });
33
- });
34
-
35
- it("keeps project-added dependencies alongside template bumps", () => {
36
- const base = json({ dependencies: { one: "^1.0.0" } });
37
- const ours = json({ dependencies: { one: "^1.0.0", "@multiplatform.one/keycloak": "^6.4.2" } });
38
- const theirs = json({ dependencies: { one: "^1.1.0" } });
39
- expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
40
- dependencies: { one: "^1.1.0", "@multiplatform.one/keycloak": "^6.4.2" },
41
- });
42
- });
43
-
44
- it("applies template deletions the project did not touch", () => {
45
- const base = json({ scripts: { postinstall: "patch-package", build: "vite build" } });
46
- const ours = json({ scripts: { postinstall: "patch-package", build: "vite build" } });
47
- const theirs = json({ scripts: { build: "vite build" } });
48
- expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
49
- scripts: { build: "vite build" },
50
- });
51
- });
52
-
53
- it("resolves nested override maps", () => {
54
- const base = json({ pnpm: { overrides: { "react-dom": "19.2.4" } } });
55
- const ours = json({ pnpm: { overrides: { "react-dom": "19.2.5" } } });
56
- const theirs = json({ pnpm: { overrides: { "react-dom": "19.2.6" } } });
57
- expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
58
- pnpm: { overrides: { "react-dom": "19.2.6" } },
59
- });
60
- });
61
-
62
- it("returns undefined for genuine two-sided conflicts", () => {
63
- const base = json({ scripts: { build: "vite build" } });
64
- const ours = json({ scripts: { build: "vite build --mode ours" } });
65
- const theirs = json({ scripts: { build: "vite build --mode theirs" } });
66
- expect(threeWayMergePackageJson(base, ours, theirs)).toBeUndefined();
67
- });
68
-
69
- it("returns undefined for non-range dependency divergence", () => {
70
- const base = json({ dependencies: { local: "^1.0.0" } });
71
- const ours = json({ dependencies: { local: "workspace:*" } });
72
- const theirs = json({ dependencies: { local: "^2.0.0" } });
73
- expect(threeWayMergePackageJson(base, ours, theirs)).toBeUndefined();
74
- });
75
-
76
- it("returns undefined on unparseable input", () => {
77
- expect(threeWayMergePackageJson("{}", "not json", "{}")).toBeUndefined();
78
- });
79
-
80
- it("treats a missing base as empty (add/add merges)", () => {
81
- const ours = json({ dependencies: { a: "^1.0.0" } });
82
- const theirs = json({ dependencies: { b: "^2.0.0" } });
83
- expect(threeWayMergePackageJson(undefined, ours, theirs)).toEqual({
84
- dependencies: { a: "^1.0.0", b: "^2.0.0" },
85
- });
86
- });
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
28
  const readJson = (path: string) => JSON.parse(readFileSync(path, "utf-8"));
95
29
 
96
30
  async function gitOut(cwd: string, args: string[]): Promise<string> {
@@ -98,12 +32,21 @@ async function gitOut(cwd: string, args: string[]): Promise<string> {
98
32
  return result.stdout.trim();
99
33
  }
100
34
 
101
- async function initRepo(dir: string): Promise<void> {
102
- await spawn("git", ["init", "--quiet"], { cwd: dir });
35
+ async function identity(dir: string): Promise<void> {
103
36
  await spawn("git", ["config", "user.email", "spec@example.com"], { cwd: dir });
104
37
  await spawn("git", ["config", "user.name", "Spec"], { cwd: dir });
38
+ }
39
+
40
+ async function commitAll(dir: string, message: string): Promise<string> {
105
41
  await spawn("git", ["add", "-A"], { cwd: dir });
106
- await spawn("git", ["commit", "--quiet", "-m", "chore: fixture"], { cwd: dir });
42
+ await spawn("git", ["commit", "--quiet", "-m", message], { cwd: dir });
43
+ return gitOut(dir, ["rev-parse", "HEAD"]);
44
+ }
45
+
46
+ async function initRepo(dir: string): Promise<string> {
47
+ await spawn("git", ["init", "--quiet"], { cwd: dir });
48
+ await identity(dir);
49
+ return commitAll(dir, "chore: fixture");
107
50
  }
108
51
 
109
52
  function writeFiles(dir: string, files: Record<string, string>): void {
@@ -114,280 +57,546 @@ function writeFiles(dir: string, files: Record<string, string>): void {
114
57
  }
115
58
  }
116
59
 
117
- describe("updateApp (integration)", () => {
118
- const currentVersion = cliVersion()!;
119
- const currentRange = `^${currentVersion}`;
60
+ function replaceFirstLine(file: string, line: string): void {
61
+ const rest = readFileSync(file, "utf-8").split("\n").slice(1).join("\n");
62
+ writeFileSync(file, `${line}\n${rest}`);
63
+ }
64
+
65
+ describe("clone-derived project: init → update → add-app", () => {
120
66
  let sandbox: string;
121
67
  let previousCwd: string;
122
- let canonical: string;
68
+ let project: string;
69
+ /** A second clone of the monorepo that moves ahead of the recorded commit. */
70
+ let source: string;
123
71
 
124
- /** Scaffold the canonical pieces-only tree (what adopted-update baselines
125
- * look like) once, to source hand-copied fixtures and compare against. */
126
72
  beforeAll(async () => {
127
- sandbox = mkdtempSync(join(tmpdir(), "mpo-updateapp-spec-"));
73
+ sandbox = mkdtempSync(join(tmpdir(), "mpo-update-spec-"));
128
74
  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,
75
+ process.chdir(sandbox);
76
+ await init("shop", {
77
+ apps: ["admin"],
78
+ services: [],
136
79
  skipInstall: true,
137
80
  skipGit: true,
138
81
  yes: true,
139
82
  });
140
- process.chdir(previousCwd);
141
- canonical = join(parent, "fake-ext");
142
- });
83
+ project = join(sandbox, "shop");
84
+ await initRepo(project);
85
+
86
+ source = join(sandbox, "source");
87
+ const head = await gitOut(monorepo, ["rev-parse", "HEAD"]);
88
+ await spawn("git", ["clone", "--quiet", "--shared", "--no-checkout", monorepo, source]);
89
+ await spawn("git", ["checkout", "--quiet", head], { cwd: source });
90
+ await identity(source);
91
+ }, heavy);
143
92
 
144
93
  afterEach(() => {
145
94
  process.chdir(previousCwd);
146
95
  });
147
96
 
148
97
  afterAll(() => {
98
+ process.chdir(previousCwd);
149
99
  rmSync(sandbox, { recursive: true, force: true });
150
100
  });
151
101
 
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"),
102
+ it(
103
+ "merges a One-app change into every product app, commits nothing, then goes quiet",
104
+ async () => {
105
+ // The monorepo moves: apps/one gains a file and a README line.
106
+ writeFileSync(join(source, "apps/one/hello.txt"), "hello from one\n");
107
+ writeFileSync(
108
+ join(source, "apps/one/README.md"),
109
+ `${readFileSync(join(source, "apps/one/README.md"), "utf-8")}\nAdded upstream.\n`,
110
+ );
111
+ const upstream = await commitAll(source, "feat: hello");
112
+
113
+ process.chdir(project);
114
+ const headBefore = await gitOut(project, ["rev-parse", "HEAD"]);
115
+ await updateApp({ source, skipInstall: true });
116
+
117
+ // Nothing committed: HEAD stands, the merge sits in index + worktree.
118
+ expect(await gitOut(project, ["rev-parse", "HEAD"])).toBe(headBefore);
119
+ expect(await gitOut(project, ["status", "--porcelain"])).not.toBe("");
120
+ const staged = await gitOut(project, ["diff", "--cached", "--name-only"]);
121
+ expect(staged).toContain("apps/shop/hello.txt");
122
+ expect(staged).toContain("apps/admin/hello.txt");
123
+ for (const app of ["shop", "admin"]) {
124
+ expect(readFileSync(join(project, `apps/${app}/hello.txt`), "utf-8")).toBe(
125
+ "hello from one\n",
126
+ );
127
+ expect(readFileSync(join(project, `apps/${app}/README.md`), "utf-8")).toContain(
128
+ "Added upstream.",
129
+ );
130
+ }
131
+ expect(existsSync(join(project, "public"))).toBe(false);
132
+ const provenance = readProvenance(project);
133
+ expect(provenance?.source?.sha).toBe(upstream);
134
+ expect(provenance?.apps).toEqual(["shop", "admin"]);
135
+
136
+ // Commit as instructed; an immediate re-run changes nothing.
137
+ const committed = await commitAll(project, "chore: mpo update");
138
+ await updateApp({ source, skipInstall: true });
139
+ expect(await gitOut(project, ["rev-parse", "HEAD"])).toBe(committed);
140
+ expect(await gitOut(project, ["status", "--porcelain"])).toBe("");
141
+ },
142
+ heavy,
143
+ );
144
+
145
+ it("refuses a dirty tree before touching anything", async () => {
146
+ process.chdir(project);
147
+ const readme = join(project, "README.md");
148
+ const original = readFileSync(readme, "utf-8");
149
+ writeFileSync(readme, `${original}dirty\n`);
150
+ await expect(updateApp({ source, skipInstall: true })).rejects.toThrow(
151
+ /clean working tree \(unstaged changes present\)/,
189
152
  );
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"),
153
+ writeFileSync(readme, original);
154
+ await spawn("git", ["add", "-A"], { cwd: project });
155
+ writeFileSync(readme, `${original}staged\n`);
156
+ await spawn("git", ["add", "--", "README.md"], { cwd: project });
157
+ await expect(updateApp({ source, skipInstall: true })).rejects.toThrow(
158
+ /staged changes present/,
236
159
  );
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("");
160
+ writeFileSync(readme, original);
161
+ await spawn("git", ["add", "--", "README.md"], { cwd: project });
162
+ expect(await gitOut(project, ["status", "--porcelain"])).toBe("");
245
163
  });
246
164
 
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);
165
+ it(
166
+ "a two-sided change shows conflict markers; .updateignore pins hold",
167
+ async () => {
168
+ process.chdir(project);
169
+ // The project rewrites the first README line of one app and pins the other.
170
+ replaceFirstLine(join(project, "apps/shop/README.md"), "# shop, customized");
171
+ writeFileSync(join(project, ".updateignore"), "apps/admin/README.md\n");
172
+ await commitAll(project, "feat: customize shop");
173
+ const pinned = readFileSync(join(project, "apps/admin/README.md"), "utf-8");
174
+ // The monorepo rewrites the same line.
175
+ replaceFirstLine(join(source, "apps/one/README.md"), "# one, changed upstream");
176
+ const upstream = await commitAll(source, "docs: retitle");
177
+
178
+ const headBefore = await gitOut(project, ["rev-parse", "HEAD"]);
179
+ await updateApp({ source, skipInstall: true });
180
+ expect(await gitOut(project, ["rev-parse", "HEAD"])).toBe(headBefore);
181
+ const conflicted = readFileSync(join(project, "apps/shop/README.md"), "utf-8");
182
+ expect(conflicted).toContain("<<<<<<<");
183
+ expect(conflicted).toContain("# shop, customized");
184
+ expect(conflicted).toContain("# one, changed upstream");
185
+ expect(conflicted).toContain(">>>>>>>");
186
+ // The pinned app took nothing: HEAD's bytes, no markers.
187
+ expect(readFileSync(join(project, "apps/admin/README.md"), "utf-8")).toBe(pinned);
188
+ expect(readProvenance(project)?.source?.sha).toBe(upstream);
189
+
190
+ // Resolve, commit, and the next run is a no-op.
191
+ replaceFirstLine(join(project, "apps/shop/README.md"), "# shop, resolved");
192
+ writeFileSync(
193
+ join(project, "apps/shop/README.md"),
194
+ readFileSync(join(project, "apps/shop/README.md"), "utf-8").replace(
195
+ /<<<<<<<[^\n]*\n[\s\S]*?>>>>>>>[^\n]*\n/,
196
+ "",
197
+ ),
198
+ );
199
+ const committed = await commitAll(project, "chore: mpo update (resolved)");
200
+ await updateApp({ source, skipInstall: true });
201
+ expect(await gitOut(project, ["rev-parse", "HEAD"])).toBe(committed);
202
+ expect(await gitOut(project, ["status", "--porcelain"])).toBe("");
203
+ },
204
+ heavy,
205
+ );
206
+
207
+ it(
208
+ "add-app lands one more product app at the recorded commit, uncommitted",
209
+ async () => {
210
+ process.chdir(project);
211
+ const headBefore = await gitOut(project, ["rev-parse", "HEAD"]);
212
+ const shopReadme = readFileSync(join(project, "apps/shop/README.md"), "utf-8");
213
+ await addApp("kiosk", { skipInstall: true });
214
+
215
+ expect(await gitOut(project, ["rev-parse", "HEAD"])).toBe(headBefore);
216
+ expect(await gitOut(project, ["status", "--porcelain"])).not.toBe("");
217
+ expect(readJson(join(project, "apps/kiosk/package.json")).name).toBe("@app/kiosk");
218
+ expect(existsSync(join(project, "features/kiosk/screen.tsx"))).toBe(true);
219
+ expect(readFileSync(join(project, "Makefile"), "utf-8")).toContain(
220
+ "kiosk/%: configure FORCE",
221
+ );
222
+ expect(readJson(join(project, "package.json")).scripts["dev:kiosk"]).toBe(
223
+ "pnpm --filter @app/kiosk dev",
224
+ );
225
+ // The project's own edits are not part of the diff.
226
+ expect(readFileSync(join(project, "apps/shop/README.md"), "utf-8")).toBe(shopReadme);
227
+ expect(readProvenance(project)?.apps).toEqual(["shop", "admin", "kiosk"]);
228
+
229
+ await commitAll(project, "feat: add app kiosk");
230
+ await expect(addApp("kiosk", { skipInstall: true })).rejects.toThrow(/already a product app/);
231
+ },
232
+ heavy,
233
+ );
234
+ });
258
235
 
259
- await adoptApp({ pieces: ["webext"], yes: true });
260
- await updateApp({ skipInstall: true });
236
+ describe("template-era project", () => {
237
+ let sandbox: string;
238
+ let previousCwd: string;
261
239
 
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("");
240
+ beforeAll(() => {
241
+ sandbox = mkdtempSync(join(tmpdir(), "mpo-update-legacy-spec-"));
242
+ previousCwd = process.cwd();
277
243
  });
278
244
 
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);
245
+ afterEach(() => {
246
+ process.chdir(previousCwd);
247
+ });
287
248
 
288
- await adoptApp({ pieces: ["webext"], yes: true });
289
- await expect(updateApp({ skipInstall: true })).rejects.toThrow(/looks like a glob/);
249
+ afterAll(() => {
250
+ rmSync(sandbox, { recursive: true, force: true });
290
251
  });
291
252
 
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");
253
+ it(
254
+ "reconciles from an empty base and becomes clone-derived",
255
+ async () => {
256
+ const dir = join(sandbox, "legacy");
257
+ writeFiles(dir, {
258
+ "package.json": json({ name: "legacy", private: true, scripts: { custom: "echo custom" } }),
259
+ "README.md": "# legacy\n",
260
+ "apps/legacy/package.json": json({ name: "@app/legacy", private: true }),
261
+ ".mpo.json": json({
262
+ template: "universal",
263
+ cliVersion: "7.7.1",
264
+ name: "legacy",
265
+ mpoVersion: "^7.7.1",
266
+ pieces: [],
267
+ }),
268
+ });
269
+ const head = await initRepo(dir);
270
+ process.chdir(dir);
271
+
272
+ await updateApp({ skipInstall: true });
273
+ expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(head);
274
+ // The One app landed under the project's name…
275
+ expect(existsSync(join(dir, "apps/legacy/app.config.js"))).toBe(true);
276
+ expect(existsSync(join(dir, "features/legacy/screen.tsx"))).toBe(true);
277
+ // …shared files that both sides wrote carry markers…
278
+ expect(readFileSync(join(dir, "README.md"), "utf-8")).toContain("<<<<<<<");
279
+ // …package.json resolved structurally (custom script kept, scaffold scripts added).
280
+ const pkg = readJson(join(dir, "package.json"));
281
+ expect(pkg.scripts.custom).toBe("echo custom");
282
+ expect(pkg.scripts.dev).toBe("pnpm --filter @app/legacy dev");
283
+ const provenance = readProvenance(dir);
284
+ expect(provenance?.template).toBeUndefined();
285
+ expect(provenance?.source?.sha).toBe(await gitOut(monorepo, ["rev-parse", "HEAD"]));
286
+ expect(provenance?.apps).toEqual(["legacy"]);
287
+ },
288
+ heavy,
289
+ );
290
+ });
291
+
292
+ describe("project scaffolded while the scaffold mapped @multiplatform.one to Pulp (MPO-289)", () => {
293
+ let sandbox: string;
294
+ let previousCwd: string;
295
+ // The .pnpmfile.cjs that MPO-343 shipped, byte for byte.
296
+ const pulpPnpmfile = `// A project's .pnpmfile.cjs. @multiplatform.one/* is published to Pulp, not
297
+ // npmjs. The scope is mapped here because pnpm drops the whole .npmrc when a
298
+ // variable it names (NPM_AUTH_TOKEN) is unset, and pnpm-workspace.yaml cannot
299
+ // map a scope. A mapping an .npmrc does carry wins.
300
+ module.exports = {
301
+ hooks: {
302
+ updateConfig(config) {
303
+ config.registries["@multiplatform.one"] ??=
304
+ "https://artifacts.bitspur.com/pulp/content/multiplatform.one/";
305
+ return config;
306
+ },
307
+ },
308
+ };\n`;
309
+
310
+ beforeAll(() => {
311
+ sandbox = mkdtempSync(join(tmpdir(), "mpo-update-pnpmfile-spec-"));
312
+ previousCwd = process.cwd();
323
313
  });
324
314
 
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
- );
315
+ afterEach(() => {
316
+ process.chdir(previousCwd);
317
+ });
365
318
 
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("");
319
+ afterAll(() => {
320
+ process.chdir(previousCwd);
321
+ rmSync(sandbox, { recursive: true, force: true });
371
322
  });
372
323
 
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("");
324
+ it(
325
+ "loses .pnpmfile.cjs and its lockfile checksum on update to this checkout, both staged",
326
+ async () => {
327
+ const source = join(sandbox, "source");
328
+ const head = await gitOut(monorepo, ["rev-parse", "HEAD"]);
329
+ await spawn("git", ["clone", "--quiet", "--shared", "--no-checkout", monorepo, source]);
330
+ await spawn("git", ["checkout", "--quiet", head], { cwd: source });
331
+ await identity(source);
332
+ writeFileSync(join(source, "public/cli/templates/pnpmfile.cjs"), pulpPnpmfile);
333
+ await spawn("git", ["add", "-A"], { cwd: source });
334
+ await spawn("git", ["commit", "--quiet", "--allow-empty", "-m", "chore: the Pulp pnpmfile"], {
335
+ cwd: source,
336
+ });
337
+ const pulpEra = await gitOut(source, ["rev-parse", "HEAD"]);
338
+
339
+ process.chdir(sandbox);
340
+ await init("pulp", {
341
+ source,
342
+ checkout: pulpEra,
343
+ skipInstall: true,
344
+ skipGit: true,
345
+ yes: true,
346
+ });
347
+ const project = join(sandbox, "pulp");
348
+ await initRepo(project);
349
+ expect(readFileSync(join(project, ".pnpmfile.cjs"), "utf-8")).toBe(pulpPnpmfile);
350
+ expect(readFileSync(join(project, "pnpm-lock.yaml"), "utf-8")).toMatch(
351
+ /^pnpmfileChecksum: /m,
352
+ );
353
+
354
+ // The monorepo moves on to exactly this checkout's tree.
355
+ const tree = await gitOut(source, ["rev-parse", `${head}^{tree}`]);
356
+ const next = await gitOut(source, ["commit-tree", tree, "-p", pulpEra, "-m", "chore: npmjs"]);
357
+ await spawn("git", ["checkout", "--quiet", next], { cwd: source });
358
+ process.chdir(project);
359
+ await updateApp({ source, skipInstall: true });
360
+ expect(existsSync(join(project, ".pnpmfile.cjs"))).toBe(false);
361
+ expect(readFileSync(join(project, "pnpm-lock.yaml"), "utf-8")).not.toMatch(
362
+ /^pnpmfileChecksum:/m,
363
+ );
364
+ const staged = (await gitOut(project, ["diff", "--cached", "--name-status"])).split("\n");
365
+ expect(staged).toContain("D\t.pnpmfile.cjs");
366
+ expect(staged).toContain("M\tpnpm-lock.yaml");
367
+ },
368
+ heavy,
369
+ );
370
+ });
371
+
372
+ describe("adopted project (pieces)", () => {
373
+ const currentVersion = cliVersion()!;
374
+ const currentRange = `^${currentVersion}`;
375
+ let sandbox: string;
376
+ let previousCwd: string;
377
+ let canonical: string;
378
+
379
+ /** The pieces-only tree an adopted webext project is reconciled against. */
380
+ beforeAll(() => {
381
+ sandbox = mkdtempSync(join(tmpdir(), "mpo-update-adopted-spec-"));
382
+ previousCwd = process.cwd();
383
+ canonical = join(sandbox, "canonical");
384
+ scaffoldPieces(canonical, { name: "fake-ext", mpoVersion: currentRange, pieces: ["webext"] });
385
+ });
386
+
387
+ afterEach(() => {
388
+ process.chdir(previousCwd);
389
+ });
390
+
391
+ afterAll(() => {
392
+ rmSync(sandbox, { recursive: true, force: true });
392
393
  });
394
+
395
+ it(
396
+ "first update reconciles from an empty base without committing; the next run is a no-op",
397
+ async () => {
398
+ const dir = join(sandbox, "adopted-clean", "fake-ext");
399
+ writeFiles(dir, {
400
+ "package.json": json({
401
+ name: "@fake/fake-ext",
402
+ private: true,
403
+ scripts: { custom: "echo custom" },
404
+ }),
405
+ "src/index.ts": "export const custom = true;\n",
406
+ "apps/fake-ext/webext/manifest.ts": readFileSync(
407
+ join(canonical, "apps/fake-ext/webext/manifest.ts"),
408
+ "utf-8",
409
+ ),
410
+ });
411
+ await initRepo(dir);
412
+ process.chdir(dir);
413
+
414
+ await adoptApp({ pieces: ["webext"], yes: true });
415
+ expect(readProvenance(dir)?.reconciled).toBe(false);
416
+ const head = await gitOut(dir, ["rev-parse", "HEAD"]);
417
+
418
+ await updateApp({ skipInstall: true });
419
+
420
+ for (const file of [
421
+ "apps/fake-ext/vite.config.webext.ts",
422
+ "apps/fake-ext/vite.config.webext-background.ts",
423
+ "apps/fake-ext/webext/background/main.ts",
424
+ "apps/fake-ext/webext/views/popup/main.tsx",
425
+ "apps/fake-ext/package.json",
426
+ ]) {
427
+ expect(existsSync(join(dir, file)), `missing ${file}`).toBe(true);
428
+ }
429
+ expect(readFileSync(join(dir, "apps/fake-ext/webext/manifest.ts"), "utf-8")).toBe(
430
+ readFileSync(join(canonical, "apps/fake-ext/webext/manifest.ts"), "utf-8"),
431
+ );
432
+ expect(readFileSync(join(dir, "src/index.ts"), "utf-8")).toBe(
433
+ "export const custom = true;\n",
434
+ );
435
+ const rootPkg = readJson(join(dir, "package.json"));
436
+ expect(rootPkg.scripts.custom).toBe("echo custom");
437
+ expect(rootPkg.scripts["build:webext"]).toBe("pnpm --filter @app/fake-ext build:webext");
438
+
439
+ // Landed, not committed; the flag is cleared for the commit the user makes.
440
+ expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(head);
441
+ expect(await gitOut(dir, ["status", "--porcelain"])).not.toBe("");
442
+ expect(readProvenance(dir)?.reconciled).toBeUndefined();
443
+ expect(readProvenance(dir)?.cliVersion).toBe(currentVersion);
444
+
445
+ const committed = await commitAll(dir, "chore: mpo update");
446
+ await updateApp({ skipInstall: true });
447
+ expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(committed);
448
+ expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
449
+ },
450
+ heavy,
451
+ );
452
+
453
+ it(
454
+ "divergent hand-copied files surface conflict markers, then updates go quiet",
455
+ async () => {
456
+ const dir = join(sandbox, "adopted-divergent", "fake-ext");
457
+ writeFiles(dir, {
458
+ "package.json": json({ name: "fake-ext", private: true }),
459
+ "apps/fake-ext/webext/manifest.ts": "// hand-rolled manifest, diverged\n",
460
+ });
461
+ await initRepo(dir);
462
+ process.chdir(dir);
463
+
464
+ await adoptApp({ pieces: ["webext"], yes: true });
465
+ await updateApp({ skipInstall: true });
466
+
467
+ const manifest = readFileSync(join(dir, "apps/fake-ext/webext/manifest.ts"), "utf-8");
468
+ expect(manifest).toContain("<<<<<<<");
469
+ expect(manifest).toContain(">>>>>>>");
470
+ expect(readProvenance(dir)?.reconciled).toBeUndefined();
471
+
472
+ writeFileSync(
473
+ join(dir, "apps/fake-ext/webext/manifest.ts"),
474
+ readFileSync(join(canonical, "apps/fake-ext/webext/manifest.ts"), "utf-8"),
475
+ );
476
+ const committed = await commitAll(dir, "chore: mpo update (resolved)");
477
+ await updateApp({ skipInstall: true });
478
+ expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(committed);
479
+ expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
480
+ },
481
+ heavy,
482
+ );
483
+
484
+ it(
485
+ ".updateignore: dir/** normalizes to a directory pin; HEAD wins including absence",
486
+ async () => {
487
+ const dir = join(sandbox, "adopted-pinned", "fake-ext");
488
+ writeFiles(dir, {
489
+ "package.json": json({ name: "fake-ext", private: true }),
490
+ "apps/fake-ext/webext/manifest.ts": "// bespoke manifest — never reconcile\n",
491
+ ".updateignore": "# bespoke surface\napps/fake-ext/webext/**\n",
492
+ });
493
+ await initRepo(dir);
494
+ process.chdir(dir);
495
+
496
+ await adoptApp({ pieces: ["webext"], yes: true });
497
+ await updateApp({ skipInstall: true });
498
+
499
+ expect(readFileSync(join(dir, "apps/fake-ext/webext/manifest.ts"), "utf-8")).toBe(
500
+ "// bespoke manifest — never reconcile\n",
501
+ );
502
+ expect(existsSync(join(dir, "apps/fake-ext/webext/views/popup/main.tsx"))).toBe(false);
503
+ expect(existsSync(join(dir, "apps/fake-ext/vite.config.webext.ts"))).toBe(true);
504
+
505
+ const committed = await commitAll(dir, "chore: mpo update");
506
+ await updateApp({ skipInstall: true });
507
+ expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(committed);
508
+ expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
509
+ },
510
+ heavy,
511
+ );
512
+
513
+ it(
514
+ ".updateignore: a genuinely glob-shaped entry is refused loudly",
515
+ async () => {
516
+ const dir = join(sandbox, "adopted-badglob", "fake-ext");
517
+ writeFiles(dir, {
518
+ "package.json": json({ name: "fake-ext", private: true }),
519
+ ".updateignore": "apps/*.config.ts\n",
520
+ });
521
+ await initRepo(dir);
522
+ process.chdir(dir);
523
+
524
+ await adoptApp({ pieces: ["webext"], yes: true });
525
+ await expect(updateApp({ skipInstall: true })).rejects.toThrow(/looks like a glob/);
526
+ },
527
+ heavy,
528
+ );
529
+
530
+ it(
531
+ "a Frappe client adoption updates cleanly and never gains a bench (MPO-241)",
532
+ async () => {
533
+ const dir = join(sandbox, "adopted-frappe-client", "shop");
534
+ writeFiles(dir, { "package.json": json({ name: "shop", private: true }) });
535
+ await initRepo(dir);
536
+ process.chdir(dir);
537
+
538
+ await adoptApp({ pieces: ["frappe"], yes: true });
539
+ await updateApp({ skipInstall: true });
540
+ expect(readFileSync(join(dir, "apps/shop/src/app.ts"), "utf-8")).toContain("FRAPPE_URL");
541
+ expect(existsSync(join(dir, "docker"))).toBe(false);
542
+
543
+ const committed = await commitAll(dir, "chore: mpo update");
544
+ await updateApp({ skipInstall: true });
545
+ expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(committed);
546
+ expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
547
+ expect(readProvenance(dir)?.pieces).toEqual(["frappe"]);
548
+ },
549
+ heavy,
550
+ );
551
+
552
+ it(
553
+ "an adoption recorded when frappe carried the bench keeps the bench (MPO-241)",
554
+ async () => {
555
+ // v0.0.1 of a stand-in monorepo, whose frappe piece still bundles the bench.
556
+ const oldSource = join(sandbox, "pre-split-source");
557
+ const oldPieces = join(oldSource, "public/cli/templates/pieces");
558
+ cpSync(join(piecesRoot(), "frappe"), join(oldPieces, "frappe"), { recursive: true });
559
+ cpSync(
560
+ join(piecesRoot(), "frappe-bench/universal/docker"),
561
+ join(oldPieces, "frappe/universal/docker"),
562
+ { recursive: true },
563
+ );
564
+ await initRepo(oldSource);
565
+ await spawn("git", ["tag", "v0.0.1"], { cwd: oldSource });
566
+
567
+ const dir = join(sandbox, "adopted-pre-split", "shop");
568
+ scaffoldPieces(dir, {
569
+ name: "shop",
570
+ mpoVersion: "^0.0.1",
571
+ pieces: ["frappe"],
572
+ root: oldPieces,
573
+ });
574
+ writeFiles(dir, {
575
+ "package.json": json({ name: "shop", private: true }),
576
+ ".mpo.json": json({
577
+ template: "none",
578
+ cliVersion: "0.0.1",
579
+ name: "shop",
580
+ mpoVersion: "^0.0.1",
581
+ apps: [],
582
+ services: [],
583
+ pieces: ["frappe"],
584
+ }),
585
+ });
586
+ const compose = readFileSync(join(dir, "docker/compose.frappe.yaml"), "utf-8");
587
+ await initRepo(dir);
588
+ process.chdir(dir);
589
+
590
+ await updateApp({ source: oldSource, skipInstall: true });
591
+ expect(readFileSync(join(dir, "docker/compose.frappe.yaml"), "utf-8")).toBe(compose);
592
+ expect(readFileSync(join(dir, "README.md"), "utf-8")).toContain("bench start");
593
+ expect(readProvenance(dir)?.pieces).toEqual(["frappe", "frappe-bench"]);
594
+
595
+ const committed = await commitAll(dir, "chore: mpo update");
596
+ await updateApp({ skipInstall: true });
597
+ expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(committed);
598
+ expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
599
+ },
600
+ heavy,
601
+ );
393
602
  });