@multiplatform.one/cli 6.4.3 → 6.7.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 (101) hide show
  1. package/README.md +23 -1
  2. package/lib/bin/multiplatformOne.mjs +5 -3
  3. package/lib/commands/initApp.mjs +209 -8
  4. package/lib/commands/updateApp.mjs +170 -16
  5. package/package.json +3 -3
  6. package/src/bin/multiplatformOne.ts +14 -1
  7. package/src/commands/initApp.spec.ts +369 -3
  8. package/src/commands/initApp.ts +324 -4
  9. package/src/commands/updateApp.spec.ts +81 -0
  10. package/src/commands/updateApp.ts +212 -6
  11. package/templates/app/apps/__NAME__/package.json +2 -1
  12. package/templates/app/features/__NAME__/package.json +1 -1
  13. package/templates/app/packages/config/package.json +1 -1
  14. package/templates/pieces/frappe/universal/README.md.partial +32 -0
  15. package/templates/pieces/frappe/universal/docker/compose.frappe.yaml +52 -0
  16. package/templates/pieces/frappe/universal/env.example.partial +8 -0
  17. package/templates/pieces/frappe/universal/packages/config/config.json.partial +3 -0
  18. package/templates/pieces/gnome/universal/README.md.partial +68 -0
  19. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/main.tsx +165 -0
  20. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/polyfills.ts +10 -0
  21. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/components.ts +36 -0
  22. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/empty-module.ts +28 -0
  23. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/forms.ts +18 -0
  24. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/one.ts +89 -0
  25. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/theme.ts +25 -0
  26. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/tamagui-barrel.ts +77 -0
  27. package/templates/pieces/gnome/universal/apps/__NAME__/package.json.partial +18 -0
  28. package/templates/pieces/gnome/universal/apps/__NAME__/vite.config.gnome.ts +124 -0
  29. package/templates/pieces/gnome/universal/gitignore.partial +3 -0
  30. package/templates/pieces/gnome/universal/package.json.partial +6 -0
  31. package/templates/pieces/keycloak/universal/README.md.partial +19 -0
  32. package/templates/pieces/keycloak/universal/docker/compose.keycloak.yaml +27 -0
  33. package/templates/pieces/keycloak/universal/docker/keycloak/realm.json +43 -0
  34. package/templates/pieces/keycloak/universal/env.example.partial +9 -0
  35. package/templates/pieces/keycloak/universal/packages/config/config.json.partial +9 -0
  36. package/templates/pieces/tauri/universal/README.md.partial +18 -0
  37. package/templates/pieces/tauri/universal/apps/__NAME__/package.json.partial +10 -0
  38. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/Cargo.toml +26 -0
  39. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/build.rs +3 -0
  40. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/capabilities/default.json +7 -0
  41. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/128x128.png +0 -0
  42. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/128x128@2x.png +0 -0
  43. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/32x32.png +0 -0
  44. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/icon.icns +0 -0
  45. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/icon.ico +0 -0
  46. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +14 -0
  47. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/main.rs +9 -0
  48. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +43 -0
  49. package/templates/pieces/tauri/universal/gitignore.partial +3 -0
  50. package/templates/pieces/tauri/universal/package.json.partial +6 -0
  51. package/templates/pieces/vscode/universal/README.md.partial +17 -0
  52. package/templates/pieces/vscode/universal/apps/__NAME__/package.json.partial +19 -0
  53. package/templates/pieces/vscode/universal/apps/__NAME__/vite.config.vscode.ts +17 -0
  54. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/env.d.ts +1 -0
  55. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/index.ts +13 -0
  56. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/views/helper.ts +14 -0
  57. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/views/panel.ts +38 -0
  58. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/index.html +49 -0
  59. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/manifest.ts +33 -0
  60. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/package.json +7 -0
  61. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/tamagui.css +3 -0
  62. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/tsconfig.json +9 -0
  63. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/app.tsx +24 -0
  64. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/components/ErrorBoundary.tsx +41 -0
  65. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts/inter-400.woff2 +0 -0
  66. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts/inter-700.woff2 +0 -0
  67. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts.css +21 -0
  68. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/main.tsx +35 -0
  69. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/router.tsx +58 -0
  70. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/useVSCodeTheme.ts +56 -0
  71. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/utils/vscode.ts +16 -0
  72. package/templates/pieces/vscode/universal/gitignore.partial +2 -0
  73. package/templates/pieces/vscode/universal/package.json.partial +6 -0
  74. package/templates/pieces/webext/universal/README.md.partial +16 -0
  75. package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +22 -0
  76. package/templates/pieces/webext/universal/apps/__NAME__/tsconfig.json.partial +5 -0
  77. package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext-background.ts +19 -0
  78. package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +35 -0
  79. package/templates/pieces/webext/universal/apps/__NAME__/webext/assets/fonts/inter-400.woff2 +0 -0
  80. package/templates/pieces/webext/universal/apps/__NAME__/webext/assets/fonts/inter-700.woff2 +0 -0
  81. package/templates/pieces/webext/universal/apps/__NAME__/webext/background/main.ts +10 -0
  82. package/templates/pieces/webext/universal/apps/__NAME__/webext/components/ErrorBoundary.tsx +41 -0
  83. package/templates/pieces/webext/universal/apps/__NAME__/webext/env.ts +2 -0
  84. package/templates/pieces/webext/universal/apps/__NAME__/webext/manifest.ts +45 -0
  85. package/templates/pieces/webext/universal/apps/__NAME__/webext/prepare.ts +21 -0
  86. package/templates/pieces/webext/universal/apps/__NAME__/webext/static/assets/icon-512.png +0 -0
  87. package/templates/pieces/webext/universal/apps/__NAME__/webext/tamagui.css +3 -0
  88. package/templates/pieces/webext/universal/apps/__NAME__/webext/tsconfig.json +9 -0
  89. package/templates/pieces/webext/universal/apps/__NAME__/webext/views/popup/index.html +77 -0
  90. package/templates/pieces/webext/universal/apps/__NAME__/webext/views/popup/main.tsx +73 -0
  91. package/templates/pieces/webext/universal/gitignore.partial +2 -0
  92. package/templates/pieces/webext/universal/package.json.partial +6 -0
  93. package/templates/universal/apps/__NAME__/package.json +2 -1
  94. package/templates/universal/features/__NAME__/package.json +1 -1
  95. package/templates/universal/packages/config/package.json +1 -1
  96. package/templates/universal/packages/i18n/package.json +1 -1
  97. package/templates/universal/packages/themes/package.json +1 -1
  98. package/types/commands/initApp.d.ts +43 -0
  99. package/types/commands/initApp.d.ts.map +1 -1
  100. package/types/commands/updateApp.d.ts +4 -0
  101. package/types/commands/updateApp.d.ts.map +1 -1
@@ -19,7 +19,7 @@ import YAML from "yaml";
19
19
  import yoctoSpinner from "yocto-spinner";
20
20
  import { discoverE2EApp, runE2ESession } from "../commands/e2e";
21
21
  import { init, runModifyStep } from "../commands/init";
22
- import { initApp, readProvenance } from "../commands/initApp";
22
+ import { INIT_APP_PIECES, initApp, readProvenance } from "../commands/initApp";
23
23
  import { updateApp } from "../commands/updateApp";
24
24
 
25
25
  const projectRoot = lookupProjectRoot();
@@ -221,6 +221,15 @@ program
221
221
  .option("--universal", "scaffold the universal template (web + iOS + Android; default)")
222
222
  .option("--web", "scaffold the web-only template (Vite + React)")
223
223
  .option("--app", "alias of --web (back-compat)")
224
+ .option("--frappe", "add the Frappe backend piece (env keys, provider, docker compose bench)")
225
+ .option("--keycloak", "add the Keycloak auth piece (env keys, provider, docker compose + realm)")
226
+ .option(
227
+ "--gnome",
228
+ "add the GNOME desktop target piece (GTK4/GJS via react-gnome — native widgets, no webview)",
229
+ )
230
+ .option("--tauri", "add the desktop target piece (src-tauri skeleton, webview)")
231
+ .option("--vscode", "add the VS Code extension target piece")
232
+ .option("--webext", "add the browser extension target piece (MV3 popup + background)")
224
233
  .option("--skip-install", "skip pnpm install after scaffolding")
225
234
  .option("--skip-git", "skip git init + scaffold commit")
226
235
  .option(
@@ -269,12 +278,16 @@ program
269
278
  if (webOnly && options.universal) {
270
279
  throw new Error("Pass either --web/--app or --universal, not both");
271
280
  }
281
+ // Piece flags are multi-select; passing any of them skips the interactive
282
+ // pieces prompt. No flags + --yes (or non-TTY) → no pieces.
283
+ const pieces = INIT_APP_PIECES.filter((piece) => Boolean(options[piece]));
272
284
  await initApp(name, {
273
285
  skipInstall: Boolean(options.skipInstall),
274
286
  skipGit: Boolean(options.skipGit),
275
287
  version: options.mpoVersion,
276
288
  template: webOnly ? "app" : options.universal ? "universal" : undefined,
277
289
  yes: Boolean(options.yes),
290
+ pieces: pieces.length ? pieces : undefined,
278
291
  });
279
292
  });
280
293
 
@@ -1,8 +1,16 @@
1
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
1
+ import {
2
+ existsSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ rmSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from "node:fs";
2
10
  import { tmpdir } from "node:os";
3
- import { join } from "node:path";
11
+ import { extname, join } from "node:path";
4
12
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
- import { initApp } from "./initApp";
13
+ import { initApp, mergeJsonValues, readProvenance } from "./initApp";
6
14
 
7
15
  const readJson = (path: string) => JSON.parse(readFileSync(path, "utf-8"));
8
16
 
@@ -207,4 +215,362 @@ describe("initApp generator", () => {
207
215
  initApp("occupied", { template: "app", skipInstall: true, yes: true }),
208
216
  ).rejects.toThrow(/already exists and is not empty/u);
209
217
  });
218
+
219
+ describe("composable pieces", () => {
220
+ describe("webext piece", () => {
221
+ const name = "ext-demo";
222
+ let root: string;
223
+
224
+ beforeAll(async () => {
225
+ await initApp(name, {
226
+ template: "universal",
227
+ skipInstall: true,
228
+ skipGit: true,
229
+ version: "6.1.0",
230
+ yes: true,
231
+ pieces: ["webext"],
232
+ });
233
+ root = join(sandbox, name);
234
+ });
235
+
236
+ it("overlays the webext target files", () => {
237
+ for (const file of [
238
+ `apps/${name}/vite.config.webext.ts`,
239
+ `apps/${name}/vite.config.webext-background.ts`,
240
+ `apps/${name}/webext/env.ts`,
241
+ `apps/${name}/webext/manifest.ts`,
242
+ `apps/${name}/webext/prepare.ts`,
243
+ `apps/${name}/webext/tamagui.css`,
244
+ `apps/${name}/webext/tsconfig.json`,
245
+ `apps/${name}/webext/background/main.ts`,
246
+ `apps/${name}/webext/components/ErrorBoundary.tsx`,
247
+ `apps/${name}/webext/views/popup/index.html`,
248
+ `apps/${name}/webext/views/popup/main.tsx`,
249
+ `apps/${name}/webext/assets/fonts/inter-400.woff2`,
250
+ `apps/${name}/webext/assets/fonts/inter-700.woff2`,
251
+ `apps/${name}/webext/static/assets/icon-512.png`,
252
+ ]) {
253
+ expect(existsSync(join(root, file)), `missing ${file}`).toBe(true);
254
+ }
255
+ });
256
+
257
+ it("renders template vars in fragment files", () => {
258
+ const manifest = readFileSync(join(root, `apps/${name}/webext/manifest.ts`), "utf-8");
259
+ expect(manifest).toContain('const DISPLAY_NAME = "ExtDemo"');
260
+ for (const file of [
261
+ `apps/${name}/vite.config.webext.ts`,
262
+ `apps/${name}/webext/manifest.ts`,
263
+ `apps/${name}/webext/views/popup/main.tsx`,
264
+ ]) {
265
+ expect(readFileSync(join(root, file), "utf-8")).not.toMatch(/__NAME(_PASCAL|_APPID)?__/u);
266
+ }
267
+ const popup = readFileSync(join(root, `apps/${name}/webext/views/popup/main.tsx`), "utf-8");
268
+ expect(popup).toContain(`from "@features/${name}"`);
269
+ });
270
+
271
+ it("deep-merges package.json.partial into the app manifest", () => {
272
+ const appPkg = readJson(join(root, `apps/${name}/package.json`));
273
+ // base scripts survive, fragment scripts append
274
+ expect(appPkg.scripts.dev).toContain("one dev");
275
+ expect(appPkg.scripts["build:webext"]).toContain("vite.config.webext.ts");
276
+ expect(appPkg.scripts["typecheck:webext"]).toBe("tsc --noEmit -p webext/tsconfig.json");
277
+ // fragment deps merge in with rendered versions; base deps survive
278
+ expect(appPkg.dependencies["webextension-polyfill"]).toBe("^0.12.0");
279
+ expect(appPkg.dependencies["@multiplatform.one/router"]).toBe("^6.1.0");
280
+ expect(appPkg.dependencies[`@features/${name}`]).toBe("workspace:*");
281
+ expect(appPkg.devDependencies["@multiplatform.one/vite-plugin-webext"]).toBe("^6.1.0");
282
+ // dependency maps stay sorted after the merge
283
+ for (const field of ["dependencies", "devDependencies"]) {
284
+ const keys = Object.keys(appPkg[field]);
285
+ expect(keys, `${field} sorted`).toEqual([...keys].sort());
286
+ }
287
+ });
288
+
289
+ it("unions tsconfig types and root scripts, appends gitignore", () => {
290
+ const tsconfig = readJson(join(root, `apps/${name}/tsconfig.json`));
291
+ expect(tsconfig.compilerOptions.types).toEqual(["vite/client", "node"]);
292
+ const rootPkg = readJson(join(root, "package.json"));
293
+ expect(rootPkg.scripts["build:webext"]).toBe(`pnpm --filter @app/${name} build:webext`);
294
+ const gitignore = readFileSync(join(root, ".gitignore"), "utf-8");
295
+ expect(gitignore).toContain("node_modules");
296
+ expect(gitignore).toContain("dist-webext");
297
+ });
298
+
299
+ it("records pieces in provenance", () => {
300
+ const provenance = readJson(join(root, ".mpo.json"));
301
+ expect(provenance.pieces).toEqual(["webext"]);
302
+ expect(readProvenance(root)?.pieces).toEqual(["webext"]);
303
+ });
304
+ });
305
+
306
+ describe("frappe + keycloak pieces", () => {
307
+ const name = "backend-demo";
308
+ let root: string;
309
+
310
+ beforeAll(async () => {
311
+ await initApp(name, {
312
+ template: "universal",
313
+ skipInstall: true,
314
+ skipGit: true,
315
+ version: "6.1.0",
316
+ yes: true,
317
+ // intentionally unsorted — provenance must sort
318
+ pieces: ["keycloak", "frappe"],
319
+ });
320
+ root = join(sandbox, name);
321
+ });
322
+
323
+ it("generates src/app.ts from the selected providers", () => {
324
+ const appTs = readFileSync(join(root, `apps/${name}/src/app.ts`), "utf-8");
325
+ expect(appTs).toContain('import { config } from "@multiplatform.one/platform"');
326
+ expect(appTs).toContain('frappe: { baseURL: config.get("FRAPPE_URL") }');
327
+ expect(appTs).toContain('keycloak: { baseUrl: config.get("KEYCLOAK_BASE_URL") }');
328
+ expect(appTs).toContain("theme: themeConfig");
329
+ });
330
+
331
+ it("accumulates public config keys across pieces", () => {
332
+ const config = readJson(join(root, "packages/config/config.json"));
333
+ expect(config.public).toEqual([
334
+ "BASE_URL",
335
+ "DEBUG",
336
+ "I18N_DEFAULT_LANGUAGE",
337
+ "FRAPPE_ENABLED",
338
+ "FRAPPE_URL",
339
+ "KEYCLOAK_ENABLED",
340
+ "KEYCLOAK_BASE_URL",
341
+ "KEYCLOAK_REALM",
342
+ "KEYCLOAK_CLIENT_ID",
343
+ "KEYCLOAK_PUBLIC_CLIENT_ID",
344
+ ]);
345
+ expect(config.private).toEqual(["SECRET"]);
346
+ });
347
+
348
+ it("writes env, docker, and README fragments", () => {
349
+ const env = readFileSync(join(root, ".env.example"), "utf-8");
350
+ expect(env).toContain("FRAPPE_ENABLED=1");
351
+ expect(env).toContain("KEYCLOAK_ENABLED=1");
352
+ expect(env).toContain(`KEYCLOAK_REALM=${name}`);
353
+ expect(existsSync(join(root, "docker/compose.frappe.yaml"))).toBe(true);
354
+ expect(existsSync(join(root, "docker/compose.keycloak.yaml"))).toBe(true);
355
+ const realm = readJson(join(root, "docker/keycloak/realm.json"));
356
+ expect(realm.realm).toBe(name);
357
+ expect(realm.clients[1].clientId).toBe(`${name}-public`);
358
+ const readme = readFileSync(join(root, "README.md"), "utf-8");
359
+ expect(readme).toContain("## Frappe backend");
360
+ expect(readme).toContain("## Keycloak auth");
361
+ });
362
+
363
+ it("records pieces sorted in provenance", () => {
364
+ expect(readJson(join(root, ".mpo.json")).pieces).toEqual(["frappe", "keycloak"]);
365
+ });
366
+ });
367
+
368
+ describe("kitchen sink (all pieces)", () => {
369
+ const name = "full-demo";
370
+ let root: string;
371
+
372
+ beforeAll(async () => {
373
+ await initApp(name, {
374
+ template: "universal",
375
+ skipInstall: true,
376
+ skipGit: true,
377
+ version: "6.1.0",
378
+ yes: true,
379
+ pieces: ["frappe", "gnome", "keycloak", "tauri", "vscode", "webext"],
380
+ });
381
+ root = join(sandbox, name);
382
+ });
383
+
384
+ it("overlays every target", () => {
385
+ for (const file of [
386
+ `apps/${name}/vite.config.gnome.ts`,
387
+ `apps/${name}/gnome/main.tsx`,
388
+ `apps/${name}/gnome/polyfills.ts`,
389
+ `apps/${name}/gnome/tamagui-barrel.ts`,
390
+ `apps/${name}/gnome/shims/components.ts`,
391
+ `apps/${name}/gnome/shims/theme.ts`,
392
+ `apps/${name}/gnome/shims/forms.ts`,
393
+ `apps/${name}/gnome/shims/one.ts`,
394
+ `apps/${name}/gnome/shims/empty-module.ts`,
395
+ `apps/${name}/src-tauri/tauri.conf.json`,
396
+ `apps/${name}/src-tauri/Cargo.toml`,
397
+ `apps/${name}/src-tauri/src/main.rs`,
398
+ `apps/${name}/src-tauri/src/lib.rs`,
399
+ `apps/${name}/src-tauri/capabilities/default.json`,
400
+ `apps/${name}/src-tauri/icons/icon.icns`,
401
+ `apps/${name}/vite.config.vscode.ts`,
402
+ `apps/${name}/vscode/extension/index.ts`,
403
+ `apps/${name}/vscode/webview/main.tsx`,
404
+ `apps/${name}/vscode/webview/router.tsx`,
405
+ `apps/${name}/vscode/package.json`,
406
+ `apps/${name}/vscode/manifest.ts`,
407
+ `apps/${name}/vite.config.webext.ts`,
408
+ `apps/${name}/webext/manifest.ts`,
409
+ "docker/compose.frappe.yaml",
410
+ "docker/compose.keycloak.yaml",
411
+ ]) {
412
+ expect(existsSync(join(root, file)), `missing ${file}`).toBe(true);
413
+ }
414
+ const tauriConf = readJson(join(root, `apps/${name}/src-tauri/tauri.conf.json`));
415
+ expect(tauriConf.productName).toBe(name);
416
+ expect(tauriConf.identifier).toBe("com.fulldemo.app");
417
+ const rootPkg = readJson(join(root, "package.json"));
418
+ for (const script of [
419
+ "gnome",
420
+ "gnome:capture",
421
+ "tauri:dev",
422
+ "tauri:build",
423
+ "build:vscode",
424
+ "build:webext",
425
+ ]) {
426
+ expect(rootPkg.scripts[script], `root script ${script}`).toBeDefined();
427
+ }
428
+ });
429
+
430
+ // The GNOME target is the only piece that rewires module resolution
431
+ // rather than just adding files, and both halves of that rewiring
432
+ // fail silently if they drift. A shim that imports the bare barrel
433
+ // it is aliased FROM resolves back to itself — an infinite alias
434
+ // loop that surfaces as an out-of-memory bundler, not an import
435
+ // error. And the react-gnome packages are the runtime: without them
436
+ // the entry resolves nothing at all.
437
+ it("wires the gnome target without aliasing a shim back to itself", () => {
438
+ const aliased = ["components", "theme", "forms"] as const;
439
+ for (const barrel of aliased) {
440
+ const shim = readFileSync(join(root, `apps/${name}/gnome/shims/${barrel}.ts`), "utf-8");
441
+ expect(shim, `${barrel} shim must deep-import, never the bare barrel`).not.toMatch(
442
+ new RegExp(String.raw`from "@multiplatform\.one/${barrel}"`, "u"),
443
+ );
444
+ expect(shim, `${barrel} shim reaches its subgraph`).toMatch(
445
+ new RegExp(String.raw`@multiplatform\.one/${barrel}/src/`, "u"),
446
+ );
447
+ }
448
+ const appPkg = readJson(join(root, `apps/${name}/package.json`));
449
+ for (const dep of [
450
+ "@react-gnome/renderer",
451
+ "@react-gnome/react-native",
452
+ "@react-gnome/animation",
453
+ "@react-gnome/tamagui",
454
+ ]) {
455
+ expect(appPkg.dependencies[dep], `runtime dep ${dep}`).toBeDefined();
456
+ }
457
+ expect(appPkg.devDependencies["@react-gnome/vite-plugin"]).toBeDefined();
458
+ expect(appPkg.devDependencies["@multiplatform.one/vite-plugin-gnome"]).toBeDefined();
459
+ // polyfills must be the FIRST import: the globals have to exist
460
+ // before any hoisted module initializer probes for them.
461
+ const entry = readFileSync(join(root, `apps/${name}/gnome/main.tsx`), "utf-8");
462
+ const firstImport = entry.split("\n").find((line) => line.startsWith("import "));
463
+ expect(firstImport).toBe(`import "./polyfills";`);
464
+ });
465
+
466
+ it("leaves no template placeholders anywhere", () => {
467
+ const binary = new Set([".woff2", ".png", ".ico", ".icns"]);
468
+ const walk = (dir: string): string[] =>
469
+ readdirSync(dir).flatMap((entry) => {
470
+ const path = join(dir, entry);
471
+ if (statSync(path).isDirectory()) return walk(path);
472
+ return [path];
473
+ });
474
+ for (const file of walk(root)) {
475
+ if (binary.has(extname(file).toLowerCase())) continue;
476
+ expect(readFileSync(file, "utf-8"), `placeholder left in ${file}`).not.toMatch(
477
+ /__NAME(_PASCAL|_APPID)?__|__MPO_VERSION__|__YEAR__/u,
478
+ );
479
+ }
480
+ });
481
+
482
+ it("scaffolds deterministically (update baselines depend on it)", async () => {
483
+ // Same name in a different parent dir (paths and rendered content
484
+ // embed the project name); pieces passed in a different order.
485
+ const parent = mkdtempSync(join(tmpdir(), "mpo-again-"));
486
+ const cwd = process.cwd();
487
+ process.chdir(parent);
488
+ try {
489
+ await initApp(name, {
490
+ template: "universal",
491
+ skipInstall: true,
492
+ skipGit: true,
493
+ version: "6.1.0",
494
+ yes: true,
495
+ pieces: ["webext", "vscode", "tauri", "keycloak", "gnome", "frappe"],
496
+ });
497
+ } finally {
498
+ process.chdir(cwd);
499
+ }
500
+ const other = join(parent, name);
501
+ const rel = (dir: string, base: string): string[] =>
502
+ readdirSync(dir).flatMap((entry) => {
503
+ const path = join(dir, entry);
504
+ if (statSync(path).isDirectory()) return rel(path, base);
505
+ return [path.slice(base.length + 1)];
506
+ });
507
+ const ours = rel(root, root).sort();
508
+ expect(rel(other, other).sort()).toEqual(ours);
509
+ for (const file of ours) {
510
+ const a = readFileSync(join(root, file));
511
+ const b = readFileSync(join(other, file));
512
+ expect(b.equals(a), `content differs for ${file}`).toBe(true);
513
+ }
514
+ rmSync(parent, { recursive: true, force: true });
515
+ });
516
+ });
517
+
518
+ it("rejects pieces on the web-only template", async () => {
519
+ await expect(
520
+ initApp("web-pieces", {
521
+ template: "app",
522
+ skipInstall: true,
523
+ skipGit: true,
524
+ yes: true,
525
+ pieces: ["webext"],
526
+ }),
527
+ ).rejects.toThrow(/not available for the app template/u);
528
+ });
529
+
530
+ it("rejects unknown pieces", async () => {
531
+ await expect(
532
+ initApp("bogus-pieces", {
533
+ template: "universal",
534
+ skipInstall: true,
535
+ skipGit: true,
536
+ yes: true,
537
+ pieces: ["solana" as never],
538
+ }),
539
+ ).rejects.toThrow(/Unknown piece "solana"/u);
540
+ });
541
+
542
+ it("defaults provenance pieces to [] for pre-piece scaffolds", () => {
543
+ const legacy = mkdtempSync(join(tmpdir(), "mpo-legacy-prov-"));
544
+ writeFileSync(
545
+ join(legacy, ".mpo.json"),
546
+ JSON.stringify({
547
+ template: "universal",
548
+ cliVersion: "6.4.0",
549
+ name: "legacy",
550
+ mpoVersion: "^6.4.0",
551
+ }),
552
+ );
553
+ expect(readProvenance(legacy)?.pieces).toEqual([]);
554
+ rmSync(legacy, { recursive: true, force: true });
555
+ });
556
+ });
557
+
558
+ describe("mergeJsonValues", () => {
559
+ it("unions arrays preserving base order", () => {
560
+ expect(mergeJsonValues(["a", "b"], ["b", "c"])).toEqual(["a", "b", "c"]);
561
+ });
562
+
563
+ it("merges nested objects with fragment scalars winning", () => {
564
+ expect(
565
+ mergeJsonValues(
566
+ { scripts: { dev: "vite" }, private: true },
567
+ { scripts: { build: "vite build", dev: "vite --host" } },
568
+ ),
569
+ ).toEqual({ scripts: { dev: "vite --host", build: "vite build" }, private: true });
570
+ });
571
+
572
+ it("replaces on type mismatch", () => {
573
+ expect(mergeJsonValues({ a: 1 }, ["x"])).toEqual(["x"]);
574
+ });
575
+ });
210
576
  });