@multiplatform.one/cli 6.4.2 → 6.6.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 (100) hide show
  1. package/README.md +23 -1
  2. package/lib/bin/multiplatformOne.mjs +6 -4
  3. package/lib/commands/init.mjs +1 -1
  4. package/lib/commands/initApp.mjs +209 -8
  5. package/lib/commands/updateApp.mjs +170 -16
  6. package/package.json +2 -2
  7. package/scripts/clone.sh +1 -1
  8. package/scripts/update.sh +1 -1
  9. package/src/bin/multiplatformOne.ts +127 -108
  10. package/src/commands/init.ts +1 -1
  11. package/src/commands/initApp.spec.ts +369 -3
  12. package/src/commands/initApp.ts +325 -7
  13. package/src/commands/updateApp.spec.ts +81 -0
  14. package/src/commands/updateApp.ts +213 -12
  15. package/templates/app/package.json +19 -1
  16. package/templates/pieces/frappe/universal/README.md.partial +32 -0
  17. package/templates/pieces/frappe/universal/docker/compose.frappe.yaml +52 -0
  18. package/templates/pieces/frappe/universal/env.example.partial +8 -0
  19. package/templates/pieces/frappe/universal/packages/config/config.json.partial +3 -0
  20. package/templates/pieces/gnome/universal/README.md.partial +68 -0
  21. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/main.tsx +165 -0
  22. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/polyfills.ts +10 -0
  23. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/components.ts +36 -0
  24. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/empty-module.ts +28 -0
  25. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/forms.ts +18 -0
  26. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/one.ts +89 -0
  27. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/theme.ts +25 -0
  28. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/tamagui-barrel.ts +77 -0
  29. package/templates/pieces/gnome/universal/apps/__NAME__/package.json.partial +18 -0
  30. package/templates/pieces/gnome/universal/apps/__NAME__/vite.config.gnome.ts +124 -0
  31. package/templates/pieces/gnome/universal/gitignore.partial +3 -0
  32. package/templates/pieces/gnome/universal/package.json.partial +6 -0
  33. package/templates/pieces/keycloak/universal/README.md.partial +19 -0
  34. package/templates/pieces/keycloak/universal/docker/compose.keycloak.yaml +27 -0
  35. package/templates/pieces/keycloak/universal/docker/keycloak/realm.json +43 -0
  36. package/templates/pieces/keycloak/universal/env.example.partial +9 -0
  37. package/templates/pieces/keycloak/universal/packages/config/config.json.partial +9 -0
  38. package/templates/pieces/tauri/universal/README.md.partial +18 -0
  39. package/templates/pieces/tauri/universal/apps/__NAME__/package.json.partial +10 -0
  40. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/Cargo.toml +26 -0
  41. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/build.rs +3 -0
  42. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/capabilities/default.json +7 -0
  43. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/128x128.png +0 -0
  44. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/128x128@2x.png +0 -0
  45. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/32x32.png +0 -0
  46. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/icon.icns +0 -0
  47. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/icon.ico +0 -0
  48. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +14 -0
  49. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/main.rs +9 -0
  50. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +43 -0
  51. package/templates/pieces/tauri/universal/gitignore.partial +3 -0
  52. package/templates/pieces/tauri/universal/package.json.partial +6 -0
  53. package/templates/pieces/vscode/universal/README.md.partial +17 -0
  54. package/templates/pieces/vscode/universal/apps/__NAME__/package.json.partial +19 -0
  55. package/templates/pieces/vscode/universal/apps/__NAME__/vite.config.vscode.ts +17 -0
  56. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/env.d.ts +1 -0
  57. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/index.ts +13 -0
  58. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/views/helper.ts +14 -0
  59. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/views/panel.ts +38 -0
  60. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/index.html +49 -0
  61. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/manifest.ts +33 -0
  62. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/package.json +7 -0
  63. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/tamagui.css +3 -0
  64. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/tsconfig.json +9 -0
  65. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/app.tsx +24 -0
  66. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/components/ErrorBoundary.tsx +41 -0
  67. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts/inter-400.woff2 +0 -0
  68. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts/inter-700.woff2 +0 -0
  69. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts.css +21 -0
  70. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/main.tsx +35 -0
  71. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/router.tsx +58 -0
  72. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/useVSCodeTheme.ts +56 -0
  73. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/utils/vscode.ts +16 -0
  74. package/templates/pieces/vscode/universal/gitignore.partial +2 -0
  75. package/templates/pieces/vscode/universal/package.json.partial +6 -0
  76. package/templates/pieces/webext/universal/README.md.partial +16 -0
  77. package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +22 -0
  78. package/templates/pieces/webext/universal/apps/__NAME__/tsconfig.json.partial +5 -0
  79. package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext-background.ts +19 -0
  80. package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +35 -0
  81. package/templates/pieces/webext/universal/apps/__NAME__/webext/assets/fonts/inter-400.woff2 +0 -0
  82. package/templates/pieces/webext/universal/apps/__NAME__/webext/assets/fonts/inter-700.woff2 +0 -0
  83. package/templates/pieces/webext/universal/apps/__NAME__/webext/background/main.ts +10 -0
  84. package/templates/pieces/webext/universal/apps/__NAME__/webext/components/ErrorBoundary.tsx +41 -0
  85. package/templates/pieces/webext/universal/apps/__NAME__/webext/env.ts +2 -0
  86. package/templates/pieces/webext/universal/apps/__NAME__/webext/manifest.ts +45 -0
  87. package/templates/pieces/webext/universal/apps/__NAME__/webext/prepare.ts +21 -0
  88. package/templates/pieces/webext/universal/apps/__NAME__/webext/static/assets/icon-512.png +0 -0
  89. package/templates/pieces/webext/universal/apps/__NAME__/webext/tamagui.css +3 -0
  90. package/templates/pieces/webext/universal/apps/__NAME__/webext/tsconfig.json +9 -0
  91. package/templates/pieces/webext/universal/apps/__NAME__/webext/views/popup/index.html +77 -0
  92. package/templates/pieces/webext/universal/apps/__NAME__/webext/views/popup/main.tsx +73 -0
  93. package/templates/pieces/webext/universal/gitignore.partial +2 -0
  94. package/templates/pieces/webext/universal/package.json.partial +6 -0
  95. package/templates/universal/apps/__NAME__/package.json +1 -1
  96. package/templates/universal/package.json +25 -1
  97. package/types/commands/initApp.d.ts +43 -0
  98. package/types/commands/initApp.d.ts.map +1 -1
  99. package/types/commands/updateApp.d.ts +4 -0
  100. package/types/commands/updateApp.d.ts.map +1 -1
@@ -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
  });