@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,720 +0,0 @@
1
- import {
2
- existsSync,
3
- mkdtempSync,
4
- readFileSync,
5
- readdirSync,
6
- rmSync,
7
- statSync,
8
- writeFileSync,
9
- } from "node:fs";
10
- import { tmpdir } from "node:os";
11
- import { extname, join } from "node:path";
12
- import { afterAll, beforeAll, describe, expect, it } from "vitest";
13
- import { initApp, mergeJsonValues, readProvenance } from "./initApp";
14
-
15
- const readJson = (path: string) => JSON.parse(readFileSync(path, "utf-8"));
16
-
17
- /** Collect dependency ranges by scope for leakage assertions. */
18
- function depRanges(pkg: {
19
- dependencies?: Record<string, string>;
20
- devDependencies?: Record<string, string>;
21
- }): Record<string, string> {
22
- return { ...pkg.dependencies, ...pkg.devDependencies };
23
- }
24
-
25
- describe("initApp generator", () => {
26
- let sandbox: string;
27
- let previousCwd: string;
28
-
29
- beforeAll(() => {
30
- sandbox = mkdtempSync(join(tmpdir(), "mpo-initapp-spec-"));
31
- previousCwd = process.cwd();
32
- process.chdir(sandbox);
33
- });
34
-
35
- afterAll(() => {
36
- process.chdir(previousCwd);
37
- rmSync(sandbox, { recursive: true, force: true });
38
- });
39
-
40
- describe("universal template", () => {
41
- const name = "uni-demo";
42
- let root: string;
43
-
44
- beforeAll(async () => {
45
- await initApp(name, {
46
- template: "universal",
47
- skipInstall: true,
48
- version: "6.1.0",
49
- yes: true,
50
- });
51
- root = join(sandbox, name);
52
- });
53
-
54
- it("writes the workspace tree", () => {
55
- for (const file of [
56
- "package.json",
57
- "pnpm-workspace.yaml",
58
- "tsconfig.base.json",
59
- "oxlintrc.json",
60
- "AGENTS.md",
61
- "README.md",
62
- ".gitignore",
63
- ".npmrc",
64
- `apps/${name}/package.json`,
65
- `apps/${name}/vite.config.ts`,
66
- `apps/${name}/app.config.js`,
67
- `apps/${name}/tamagui.css`,
68
- `apps/${name}/types.d.ts`,
69
- `apps/${name}/config/tamagui.config.ts`,
70
- `apps/${name}/routes/_layout.tsx`,
71
- `apps/${name}/routes/_layout.web.tsx`,
72
- `apps/${name}/routes/index.tsx`,
73
- `apps/${name}/routes/about.tsx`,
74
- `apps/${name}/src/app.ts`,
75
- `apps/${name}/polyfills.ts`,
76
- `apps/${name}/polyfills.native.ts`,
77
- `features/${name}/package.json`,
78
- `features/${name}/vitest.config.mjs`,
79
- `features/${name}/src/HomeScreen.tsx`,
80
- `features/${name}/src/AboutScreen.tsx`,
81
- `features/${name}/src/AppLayout.tsx`,
82
- `features/${name}/src/ContactForm.tsx`,
83
- `features/${name}/src/ContactForm.spec.tsx`,
84
- "packages/config/package.json",
85
- "packages/i18n/en/common.json",
86
- "packages/i18n/es/common.json",
87
- "packages/i18n/resources.ts",
88
- "packages/themes/tamagui.config.ts",
89
- "packages/themes/fonts.ts",
90
- "packages/themes/base.ts",
91
- "packages/themes/accent.ts",
92
- "types/env.d.ts",
93
- ]) {
94
- expect(existsSync(join(root, file)), `missing ${file}`).toBe(true);
95
- }
96
- });
97
-
98
- it("restores dotfile names stripped by npm pack", () => {
99
- expect(readFileSync(join(root, ".npmrc"), "utf-8")).toContain("node-linker=hoisted");
100
- expect(readFileSync(join(root, ".gitignore"), "utf-8")).toContain("node_modules");
101
- expect(existsSync(join(root, "npmrc"))).toBe(false);
102
- expect(existsSync(join(root, "gitignore"))).toBe(false);
103
- });
104
-
105
- it("pins @multiplatform.one/* to semver and locals to workspace:*", () => {
106
- for (const pkgPath of [
107
- `apps/${name}/package.json`,
108
- `features/${name}/package.json`,
109
- "packages/i18n/package.json",
110
- "packages/themes/package.json",
111
- ]) {
112
- const ranges = depRanges(readJson(join(root, pkgPath)));
113
- for (const [dep, range] of Object.entries(ranges)) {
114
- if (dep.startsWith("@multiplatform.one/")) {
115
- expect(range, `${pkgPath} ${dep}`).toBe("^6.1.0");
116
- } else if (dep.startsWith("@features/") || dep.startsWith("@package/")) {
117
- expect(range, `${pkgPath} ${dep}`).toBe("workspace:*");
118
- } else {
119
- expect(range, `${pkgPath} ${dep} leaked a workspace range`).not.toMatch(/^workspace:/u);
120
- }
121
- }
122
- }
123
- });
124
-
125
- it("renders template vars (no placeholders left)", () => {
126
- const appPkg = readJson(join(root, `apps/${name}/package.json`));
127
- expect(appPkg.name).toBe(`@app/${name}`);
128
- const rootPkg = readJson(join(root, "package.json"));
129
- expect(rootPkg.name).toBe(name);
130
- const appConfig = readFileSync(join(root, `apps/${name}/app.config.js`), "utf-8");
131
- expect(appConfig).toContain('name: "UniDemo"');
132
- expect(appConfig).toContain("com.unidemo.app");
133
- const viteConfig = readFileSync(join(root, `apps/${name}/vite.config.ts`), "utf-8");
134
- expect(viteConfig).toContain('key: "UniDemo"');
135
- for (const file of [
136
- "package.json",
137
- "README.md",
138
- `apps/${name}/app.config.js`,
139
- `features/${name}/src/HomeScreen.tsx`,
140
- "packages/i18n/en/common.json",
141
- ]) {
142
- expect(readFileSync(join(root, file), "utf-8")).not.toMatch(/__NAME(_PASCAL|_APPID)?__/u);
143
- }
144
- });
145
-
146
- it("wires universal scripts", () => {
147
- const rootPkg = readJson(join(root, "package.json"));
148
- for (const script of [
149
- "dev",
150
- "build",
151
- "native",
152
- "ios",
153
- "android",
154
- "test",
155
- "lint",
156
- "typecheck",
157
- ]) {
158
- expect(rootPkg.scripts[script], `root script ${script}`).toBeDefined();
159
- }
160
- const appPkg = readJson(join(root, `apps/${name}/package.json`));
161
- expect(appPkg.scripts.dev).toContain("one dev");
162
- expect(appPkg.scripts.build).toBe("one build");
163
- expect(appPkg.scripts["prebuild:native"]).toBe("one prebuild");
164
- });
165
- });
166
-
167
- describe("app (web-only) template regression", () => {
168
- const name = "web-demo";
169
- let root: string;
170
-
171
- beforeAll(async () => {
172
- await initApp(name, {
173
- template: "app",
174
- skipInstall: true,
175
- version: "6.1.0",
176
- yes: true,
177
- });
178
- root = join(sandbox, name);
179
- });
180
-
181
- it("keeps the web-only tree (no universal extras)", () => {
182
- for (const file of [
183
- "package.json",
184
- ".npmrc",
185
- ".gitignore",
186
- `apps/${name}/vite.config.ts`,
187
- `apps/${name}/src/main.tsx`,
188
- `features/${name}/src/HomeScreen.tsx`,
189
- "packages/config/package.json",
190
- ]) {
191
- expect(existsSync(join(root, file)), `missing ${file}`).toBe(true);
192
- }
193
- expect(existsSync(join(root, "packages/i18n"))).toBe(false);
194
- expect(existsSync(join(root, "packages/themes"))).toBe(false);
195
- expect(existsSync(join(root, `apps/${name}/routes`))).toBe(false);
196
- });
197
-
198
- it("keeps semver deps and web scripts", () => {
199
- const appPkg = readJson(join(root, `apps/${name}/package.json`));
200
- expect(appPkg.dependencies["@multiplatform.one/components"]).toBe("^6.1.0");
201
- expect(appPkg.dependencies[`@features/${name}`]).toBe("workspace:*");
202
- expect(appPkg.scripts.dev).toBe("vite");
203
- });
204
- });
205
-
206
- describe("pieces-only template (adopted-project baselines)", () => {
207
- const name = "adopt-base";
208
- let root: string;
209
-
210
- beforeAll(async () => {
211
- await initApp(name, {
212
- template: "none",
213
- skipInstall: true,
214
- skipGit: true,
215
- version: "6.6.0",
216
- yes: true,
217
- pieces: ["webext"],
218
- });
219
- root = join(sandbox, name);
220
- });
221
-
222
- it("overlays ONLY the piece fragments (no base template)", () => {
223
- for (const file of [
224
- `apps/${name}/vite.config.webext.ts`,
225
- `apps/${name}/webext/manifest.ts`,
226
- `apps/${name}/webext/background/main.ts`,
227
- `apps/${name}/package.json`,
228
- ".gitignore",
229
- "README.md",
230
- "package.json",
231
- ]) {
232
- expect(existsSync(join(root, file)), `missing ${file}`).toBe(true);
233
- }
234
- // Base-template content must NOT leak in.
235
- for (const file of [
236
- "pnpm-workspace.yaml",
237
- "tsconfig.base.json",
238
- `features/${name}`,
239
- "packages/config",
240
- `apps/${name}/routes`,
241
- `apps/${name}/vite.config.ts`,
242
- ]) {
243
- expect(existsSync(join(root, file)), `unexpected ${file}`).toBe(false);
244
- }
245
- });
246
-
247
- it("creates partial targets from the fragments alone", () => {
248
- // Root package.json = the webext fragment only — no template fields.
249
- const rootPkg = readJson(join(root, "package.json"));
250
- expect(Object.keys(rootPkg)).toEqual(["scripts"]);
251
- expect(rootPkg.scripts["build:webext"]).toBe(`pnpm --filter @app/${name} build:webext`);
252
- const manifest = readFileSync(join(root, `apps/${name}/webext/manifest.ts`), "utf-8");
253
- expect(manifest).toContain('const DISPLAY_NAME = "AdoptBase"');
254
- expect(manifest).not.toMatch(/__NAME(_PASCAL|_APPID)?__/u);
255
- });
256
-
257
- it("records template none in provenance", () => {
258
- const provenance = readJson(join(root, ".mpo.json"));
259
- expect(provenance.template).toBe("none");
260
- expect(provenance.pieces).toEqual(["webext"]);
261
- expect("reconciled" in provenance).toBe(false);
262
- expect(readProvenance(root)?.template).toBe("none");
263
- });
264
-
265
- it("refuses a pieces-only scaffold without pieces", async () => {
266
- await expect(
267
- initApp("no-pieces-base", {
268
- template: "none",
269
- skipInstall: true,
270
- skipGit: true,
271
- yes: true,
272
- }),
273
- ).rejects.toThrow(/at least one piece/u);
274
- });
275
- });
276
-
277
- it("rejects invalid names", async () => {
278
- await expect(
279
- initApp("Bad Name", { template: "app", skipInstall: true, yes: true }),
280
- ).rejects.toThrow(/Invalid project name/u);
281
- });
282
-
283
- it("refuses to scaffold into a non-empty directory", async () => {
284
- await initApp("occupied", { template: "app", skipInstall: true, yes: true });
285
- await expect(
286
- initApp("occupied", { template: "app", skipInstall: true, yes: true }),
287
- ).rejects.toThrow(/already exists and is not empty/u);
288
- });
289
-
290
- describe("composable pieces", () => {
291
- describe("webext piece", () => {
292
- const name = "ext-demo";
293
- let root: string;
294
-
295
- beforeAll(async () => {
296
- await initApp(name, {
297
- template: "universal",
298
- skipInstall: true,
299
- skipGit: true,
300
- version: "6.1.0",
301
- yes: true,
302
- pieces: ["webext"],
303
- });
304
- root = join(sandbox, name);
305
- });
306
-
307
- it("overlays the webext target files", () => {
308
- for (const file of [
309
- `apps/${name}/vite.config.webext.ts`,
310
- `apps/${name}/vite.config.webext-background.ts`,
311
- `apps/${name}/webext/env.ts`,
312
- `apps/${name}/webext/manifest.ts`,
313
- `apps/${name}/webext/prepare.ts`,
314
- `apps/${name}/webext/tamagui.css`,
315
- `apps/${name}/webext/tsconfig.json`,
316
- `apps/${name}/webext/background/main.ts`,
317
- `apps/${name}/webext/components/ErrorBoundary.tsx`,
318
- `apps/${name}/webext/views/popup/index.html`,
319
- `apps/${name}/webext/views/popup/main.tsx`,
320
- `apps/${name}/webext/assets/fonts/inter-400.woff2`,
321
- `apps/${name}/webext/assets/fonts/inter-700.woff2`,
322
- `apps/${name}/webext/static/assets/icon-512.png`,
323
- ]) {
324
- expect(existsSync(join(root, file)), `missing ${file}`).toBe(true);
325
- }
326
- });
327
-
328
- it("renders template vars in fragment files", () => {
329
- const manifest = readFileSync(join(root, `apps/${name}/webext/manifest.ts`), "utf-8");
330
- expect(manifest).toContain('const DISPLAY_NAME = "ExtDemo"');
331
- for (const file of [
332
- `apps/${name}/vite.config.webext.ts`,
333
- `apps/${name}/webext/manifest.ts`,
334
- `apps/${name}/webext/views/popup/main.tsx`,
335
- ]) {
336
- expect(readFileSync(join(root, file), "utf-8")).not.toMatch(/__NAME(_PASCAL|_APPID)?__/u);
337
- }
338
- const popup = readFileSync(join(root, `apps/${name}/webext/views/popup/main.tsx`), "utf-8");
339
- expect(popup).toContain(`from "@features/${name}"`);
340
- });
341
-
342
- it("deep-merges package.json.partial into the app manifest", () => {
343
- const appPkg = readJson(join(root, `apps/${name}/package.json`));
344
- // base scripts survive, fragment scripts append
345
- expect(appPkg.scripts.dev).toContain("one dev");
346
- expect(appPkg.scripts["build:webext"]).toContain("vite.config.webext.ts");
347
- expect(appPkg.scripts["typecheck:webext"]).toBe("tsc --noEmit -p webext/tsconfig.json");
348
- // fragment deps merge in with rendered versions; base deps survive
349
- expect(appPkg.dependencies["webextension-polyfill"]).toBe("^0.12.0");
350
- expect(appPkg.dependencies["@multiplatform.one/router"]).toBe("^6.1.0");
351
- expect(appPkg.dependencies[`@features/${name}`]).toBe("workspace:*");
352
- expect(appPkg.devDependencies["@multiplatform.one/vite-plugin-webext"]).toBe("^6.1.0");
353
- // dependency maps stay sorted after the merge
354
- for (const field of ["dependencies", "devDependencies"]) {
355
- const keys = Object.keys(appPkg[field]);
356
- expect(keys, `${field} sorted`).toEqual([...keys].sort());
357
- }
358
- });
359
-
360
- it("unions tsconfig types and root scripts, appends gitignore", () => {
361
- const tsconfig = readJson(join(root, `apps/${name}/tsconfig.json`));
362
- expect(tsconfig.compilerOptions.types).toEqual(["vite/client", "node"]);
363
- const rootPkg = readJson(join(root, "package.json"));
364
- expect(rootPkg.scripts["build:webext"]).toBe(`pnpm --filter @app/${name} build:webext`);
365
- const gitignore = readFileSync(join(root, ".gitignore"), "utf-8");
366
- expect(gitignore).toContain("node_modules");
367
- expect(gitignore).toContain("dist-webext");
368
- });
369
-
370
- it("records pieces in provenance", () => {
371
- const provenance = readJson(join(root, ".mpo.json"));
372
- expect(provenance.pieces).toEqual(["webext"]);
373
- expect(readProvenance(root)?.pieces).toEqual(["webext"]);
374
- });
375
- });
376
-
377
- describe("frappe + keycloak pieces", () => {
378
- const name = "backend-demo";
379
- let root: string;
380
-
381
- beforeAll(async () => {
382
- await initApp(name, {
383
- template: "universal",
384
- skipInstall: true,
385
- skipGit: true,
386
- version: "6.1.0",
387
- yes: true,
388
- // intentionally unsorted — provenance must sort
389
- pieces: ["keycloak", "frappe"],
390
- });
391
- root = join(sandbox, name);
392
- });
393
-
394
- it("generates src/app.ts from the selected providers", () => {
395
- const appTs = readFileSync(join(root, `apps/${name}/src/app.ts`), "utf-8");
396
- expect(appTs).toContain('import { config } from "@multiplatform.one/platform"');
397
- expect(appTs).toContain('frappe: { baseURL: config.get("FRAPPE_URL") }');
398
- expect(appTs).toContain('keycloak: { baseUrl: config.get("KEYCLOAK_BASE_URL") }');
399
- expect(appTs).toContain("theme: themeConfig");
400
- });
401
-
402
- it("accumulates public config keys across pieces", () => {
403
- const config = readJson(join(root, "packages/config/config.json"));
404
- expect(config.public).toEqual([
405
- "BASE_URL",
406
- "DEBUG",
407
- "I18N_DEFAULT_LANGUAGE",
408
- "FRAPPE_ENABLED",
409
- "FRAPPE_URL",
410
- "KEYCLOAK_ENABLED",
411
- "KEYCLOAK_BASE_URL",
412
- "KEYCLOAK_REALM",
413
- "KEYCLOAK_CLIENT_ID",
414
- "KEYCLOAK_PUBLIC_CLIENT_ID",
415
- ]);
416
- expect(config.private).toEqual(["SECRET"]);
417
- });
418
-
419
- it("writes env, docker, and README fragments", () => {
420
- const env = readFileSync(join(root, ".env.example"), "utf-8");
421
- expect(env).toContain("FRAPPE_ENABLED=1");
422
- expect(env).toContain("KEYCLOAK_ENABLED=1");
423
- expect(env).toContain(`KEYCLOAK_REALM=${name}`);
424
- expect(existsSync(join(root, "docker/compose.frappe.yaml"))).toBe(true);
425
- expect(existsSync(join(root, "docker/compose.keycloak.yaml"))).toBe(true);
426
- const realm = readJson(join(root, "docker/keycloak/realm.json"));
427
- expect(realm.realm).toBe(name);
428
- expect(realm.clients[1].clientId).toBe(`${name}-public`);
429
- const readme = readFileSync(join(root, "README.md"), "utf-8");
430
- expect(readme).toContain("## Frappe backend");
431
- expect(readme).toContain("## Keycloak auth");
432
- // Fixtures are a supported way to run the generated app (MPO-228),
433
- // so the README has to say how.
434
- expect(readme).toContain("### Running against fixtures, with no bench");
435
- expect(readme).toContain("frappe: { fixtures }");
436
- });
437
-
438
- // Two measured first-boot failures, both cheap to reintroduce. The
439
- // keycloak image ships /opt/keycloak/data (keycloak-owned) and no
440
- // h2/ subdirectory: a named volume mounted at data/h2 materializes
441
- // root-owned, and Keycloak (uid 1000) dies with H2 90028 on a FRESH
442
- // volume — it reads like corruption and survives `down -v`, because
443
- // the wipe recreates the same root-owned mount point. And a
444
- // hardcoded 8080 collides with whatever already owns the most
445
- // contested dev port; a yaml !override in a second compose file is
446
- // not a safe alternative (compose versions without the tag drop the
447
- // whole ports list silently — container healthy, unreachable).
448
- it("keeps the dev keycloak bootable: data/ volume, parameterized port", () => {
449
- const compose = readFileSync(join(root, "docker/compose.keycloak.yaml"), "utf-8");
450
- expect(compose).toContain('"${KEYCLOAK_HTTP_PORT:-8080}:8080"');
451
- expect(
452
- compose,
453
- "volume mounts data/, which the image ships with keycloak ownership",
454
- ).toMatch(/^\s*- keycloak:\/opt\/keycloak\/data$/mu);
455
- expect(compose).not.toMatch(/^\s*- keycloak:\/opt\/keycloak\/data\/h2$/mu);
456
- });
457
-
458
- it("records pieces sorted in provenance", () => {
459
- expect(readJson(join(root, ".mpo.json")).pieces).toEqual(["frappe", "keycloak"]);
460
- });
461
- });
462
-
463
- describe("kitchen sink (all pieces)", () => {
464
- const name = "full-demo";
465
- let root: string;
466
-
467
- beforeAll(async () => {
468
- await initApp(name, {
469
- template: "universal",
470
- skipInstall: true,
471
- skipGit: true,
472
- version: "6.1.0",
473
- yes: true,
474
- pieces: ["frappe", "gnome", "keycloak", "tauri", "vscode", "webext"],
475
- });
476
- root = join(sandbox, name);
477
- });
478
-
479
- it("overlays every target", () => {
480
- for (const file of [
481
- `apps/${name}/vite.config.gnome.ts`,
482
- `apps/${name}/gnome/main.tsx`,
483
- `apps/${name}/gnome/polyfills.ts`,
484
- `apps/${name}/gnome/anchor.tsx`,
485
- `apps/${name}/gnome/tamagui-barrel.ts`,
486
- `apps/${name}/gnome/shims/components.ts`,
487
- `apps/${name}/gnome/shims/theme.ts`,
488
- `apps/${name}/gnome/shims/forms.ts`,
489
- `apps/${name}/gnome/shims/frappe-ui.ts`,
490
- `apps/${name}/gnome/shims/one.ts`,
491
- `apps/${name}/gnome/shims/empty-module.ts`,
492
- `apps/${name}/vite.config.vscode.ts`,
493
- `apps/${name}/vscode/extension/index.ts`,
494
- `apps/${name}/vscode/webview/main.tsx`,
495
- `apps/${name}/vscode/webview/router.tsx`,
496
- `apps/${name}/vscode/package.json`,
497
- `apps/${name}/vscode/manifest.ts`,
498
- `apps/${name}/vite.config.webext.ts`,
499
- `apps/${name}/webext/manifest.ts`,
500
- `apps/${name}/src-tauri/tauri.conf.json`,
501
- `apps/${name}/src-tauri/Cargo.toml`,
502
- `apps/${name}/src-tauri/build.rs`,
503
- `apps/${name}/src-tauri/src/main.rs`,
504
- `apps/${name}/src-tauri/src/lib.rs`,
505
- `apps/${name}/src-tauri/capabilities/default.json`,
506
- `apps/${name}/src-tauri/icons/icon.icns`,
507
- "docker/compose.frappe.yaml",
508
- "docker/compose.keycloak.yaml",
509
- ]) {
510
- expect(existsSync(join(root, file)), `missing ${file}`).toBe(true);
511
- }
512
- const rootPkg = readJson(join(root, "package.json"));
513
- for (const script of [
514
- "gnome",
515
- "gnome:capture",
516
- "build:vscode",
517
- "build:webext",
518
- "tauri:dev",
519
- "tauri:build",
520
- ]) {
521
- expect(rootPkg.scripts[script], `root script ${script}`).toBeDefined();
522
- }
523
- });
524
-
525
- // Tauri and GNOME are peer desktop targets — the kitchen sink proves
526
- // they co-select: one scaffold carries both the src-tauri webview
527
- // shell and the GTK/GJS gnome target side by side.
528
- it("keeps tauri and gnome as co-selected desktop targets", () => {
529
- expect(existsSync(join(root, `apps/${name}/src-tauri/tauri.conf.json`))).toBe(true);
530
- expect(existsSync(join(root, `apps/${name}/vite.config.gnome.ts`))).toBe(true);
531
- const appPkg = readJson(join(root, `apps/${name}/package.json`));
532
- expect(appPkg.scripts["tauri:dev"]).toBe("tauri dev");
533
- expect(appPkg.devDependencies["@tauri-apps/cli"]).toBeDefined();
534
- expect(appPkg.scripts.gnome).toBeDefined();
535
- });
536
-
537
- // The GNOME target is the only piece that rewires module resolution
538
- // rather than just adding files, and both halves of that rewiring
539
- // fail silently if they drift. A shim that imports the bare barrel
540
- // it is aliased FROM resolves back to itself — an infinite alias
541
- // loop that surfaces as an out-of-memory bundler, not an import
542
- // error. And the react-gnome packages are the runtime: without them
543
- // the entry resolves nothing at all.
544
- it("wires the gnome target without aliasing a shim back to itself", () => {
545
- const aliased = ["components", "theme", "forms", "frappe-ui"] as const;
546
- for (const barrel of aliased) {
547
- const shim = readFileSync(join(root, `apps/${name}/gnome/shims/${barrel}.ts`), "utf-8");
548
- expect(shim, `${barrel} shim must deep-import, never the bare barrel`).not.toMatch(
549
- new RegExp(String.raw`from "@multiplatform\.one/${barrel}"`, "u"),
550
- );
551
- expect(shim, `${barrel} shim reaches its subgraph`).toMatch(
552
- new RegExp(String.raw`@multiplatform\.one/${barrel}/src/`, "u"),
553
- );
554
- }
555
- const appPkg = readJson(join(root, `apps/${name}/package.json`));
556
- for (const dep of [
557
- "@react-gnome/renderer",
558
- "@react-gnome/react-native",
559
- "@react-gnome/animation",
560
- "@react-gnome/tamagui",
561
- ]) {
562
- expect(appPkg.dependencies[dep], `runtime dep ${dep}`).toBeDefined();
563
- }
564
- expect(appPkg.devDependencies["@react-gnome/vite-plugin"]).toBeDefined();
565
- expect(appPkg.devDependencies["@multiplatform.one/vite-plugin-gnome"]).toBeDefined();
566
- // polyfills must be the FIRST import: the globals have to exist
567
- // before any hoisted module initializer probes for them.
568
- const entry = readFileSync(join(root, `apps/${name}/gnome/main.tsx`), "utf-8");
569
- const firstImport = entry.split("\n").find((line) => line.startsWith("import "));
570
- expect(firstImport).toBe(`import "./polyfills";`);
571
- });
572
-
573
- // The other half of that rewiring, and the half that fails most
574
- // quietly. `@multiplatform.one/platform` ships its GNOME flags as
575
- // `index.gnome.ts`, and the ONLY thing that selects that file is
576
- // `.gnome.*` sitting ahead of react-gnome's `.native.*` in
577
- // resolve.extensions. Drop the registration and a scaffolded project
578
- // still builds, still runs, and reports `isGnome: false` forever.
579
- it("registers .gnome.* ahead of .native.* so isGnome resolves", () => {
580
- const config = readFileSync(join(root, `apps/${name}/vite.config.gnome.ts`), "utf-8");
581
- expect(config, "gnomePlatformExtensions must be imported").toMatch(
582
- /import\s*\{[^}]*\bgnomePlatformExtensions\b[^}]*\}\s*from\s*"@multiplatform\.one\/vite-plugin-gnome"/u,
583
- );
584
- expect(config, ".gnome.* must be registered ahead of .native.*").toMatch(
585
- /extensions:\s*\[\s*\.\.\.gnomePlatformExtensions\s*,\s*\.\.\.gnomeExtensions\s*,?\s*\]/u,
586
- );
587
- });
588
-
589
- // The @react-gnome aliases must not assume the scope sits in the
590
- // app's OWN node_modules: the scaffold's .npmrc sets
591
- // node-linker=hoisted, so every dependency hoists to the workspace
592
- // root, the app-local dir never exists, and every alias points at a
593
- // nonexistent path (measured — the build fails resolving the
594
- // renderer). require.resolve cannot do the lookup either, because
595
- // the @react-gnome export maps hide package.json; the config walks
596
- // up to whichever node_modules holds the scope. And the registry
597
- // tarballs ship dist ONLY (files: ["dist", …]), so the renderer
598
- // alias needs a dist fallback when there is no src checkout.
599
- it("finds @react-gnome by walking up, with a dist fallback for registry tarballs", () => {
600
- const config = readFileSync(join(root, `apps/${name}/vite.config.gnome.ts`), "utf-8");
601
- expect(config, "walk-up helper must exist").toContain("function reactGnomeScopeDir()");
602
- expect(config, "aliases must use the walk-up, not the app-local dir").toMatch(
603
- /gnomeLinkedAliases\(reactGnomeScopeDir\(\)\)/u,
604
- );
605
- expect(config, "renderer alias needs the dist fallback").toContain(
606
- "renderer/dist/index.js",
607
- );
608
- });
609
-
610
- it("leaves no template placeholders anywhere", () => {
611
- const binary = new Set([".woff2", ".png", ".ico", ".icns"]);
612
- const walk = (dir: string): string[] =>
613
- readdirSync(dir).flatMap((entry) => {
614
- const path = join(dir, entry);
615
- if (statSync(path).isDirectory()) return walk(path);
616
- return [path];
617
- });
618
- for (const file of walk(root)) {
619
- if (binary.has(extname(file).toLowerCase())) continue;
620
- expect(readFileSync(file, "utf-8"), `placeholder left in ${file}`).not.toMatch(
621
- /__NAME(_PASCAL|_APPID)?__|__MPO_VERSION__|__YEAR__/u,
622
- );
623
- }
624
- });
625
-
626
- it("scaffolds deterministically (update baselines depend on it)", async () => {
627
- // Same name in a different parent dir (paths and rendered content
628
- // embed the project name); pieces passed in a different order.
629
- const parent = mkdtempSync(join(tmpdir(), "mpo-again-"));
630
- const cwd = process.cwd();
631
- process.chdir(parent);
632
- try {
633
- await initApp(name, {
634
- template: "universal",
635
- skipInstall: true,
636
- skipGit: true,
637
- version: "6.1.0",
638
- yes: true,
639
- pieces: ["webext", "vscode", "tauri", "keycloak", "gnome", "frappe"],
640
- });
641
- } finally {
642
- process.chdir(cwd);
643
- }
644
- const other = join(parent, name);
645
- const rel = (dir: string, base: string): string[] =>
646
- readdirSync(dir).flatMap((entry) => {
647
- const path = join(dir, entry);
648
- if (statSync(path).isDirectory()) return rel(path, base);
649
- return [path.slice(base.length + 1)];
650
- });
651
- const ours = rel(root, root).sort();
652
- expect(rel(other, other).sort()).toEqual(ours);
653
- for (const file of ours) {
654
- const a = readFileSync(join(root, file));
655
- const b = readFileSync(join(other, file));
656
- expect(b.equals(a), `content differs for ${file}`).toBe(true);
657
- }
658
- rmSync(parent, { recursive: true, force: true });
659
- });
660
- });
661
-
662
- it("rejects pieces on the web-only template", async () => {
663
- await expect(
664
- initApp("web-pieces", {
665
- template: "app",
666
- skipInstall: true,
667
- skipGit: true,
668
- yes: true,
669
- pieces: ["webext"],
670
- }),
671
- ).rejects.toThrow(/not available for the app template/u);
672
- });
673
-
674
- it("rejects unknown pieces", async () => {
675
- await expect(
676
- initApp("bogus-pieces", {
677
- template: "universal",
678
- skipInstall: true,
679
- skipGit: true,
680
- yes: true,
681
- pieces: ["solana" as never],
682
- }),
683
- ).rejects.toThrow(/Unknown piece "solana"/u);
684
- });
685
-
686
- it("defaults provenance pieces to [] for pre-piece scaffolds", () => {
687
- const legacy = mkdtempSync(join(tmpdir(), "mpo-legacy-prov-"));
688
- writeFileSync(
689
- join(legacy, ".mpo.json"),
690
- JSON.stringify({
691
- template: "universal",
692
- cliVersion: "6.4.0",
693
- name: "legacy",
694
- mpoVersion: "^6.4.0",
695
- }),
696
- );
697
- expect(readProvenance(legacy)?.pieces).toEqual([]);
698
- rmSync(legacy, { recursive: true, force: true });
699
- });
700
- });
701
-
702
- describe("mergeJsonValues", () => {
703
- it("unions arrays preserving base order", () => {
704
- expect(mergeJsonValues(["a", "b"], ["b", "c"])).toEqual(["a", "b", "c"]);
705
- });
706
-
707
- it("merges nested objects with fragment scalars winning", () => {
708
- expect(
709
- mergeJsonValues(
710
- { scripts: { dev: "vite" }, private: true },
711
- { scripts: { build: "vite build", dev: "vite --host" } },
712
- ),
713
- ).toEqual({ scripts: { dev: "vite --host", build: "vite build" }, private: true });
714
- });
715
-
716
- it("replaces on type mismatch", () => {
717
- expect(mergeJsonValues({ a: 1 }, ["x"])).toEqual(["x"]);
718
- });
719
- });
720
- });