@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
@@ -20,6 +20,31 @@ import spawn from "nano-spawn";
20
20
  */
21
21
  export type InitAppTemplate = "universal" | "app";
22
22
 
23
+ /**
24
+ * Optional composable pieces overlaid onto the base template (additive
25
+ * fragments under templates/pieces/<piece>/ — the inverse of the old
26
+ * cookiecutter KEEP_SERVICES/KEEP_PLATFORMS removal hook). Keep sorted:
27
+ * pieces are recorded sorted in provenance and overlaid in this order.
28
+ */
29
+ export const INIT_APP_PIECES = [
30
+ "frappe",
31
+ "gnome",
32
+ "keycloak",
33
+ "tauri",
34
+ "vscode",
35
+ "webext",
36
+ ] as const;
37
+ export type InitAppPiece = (typeof INIT_APP_PIECES)[number];
38
+
39
+ const PIECE_DESCRIPTIONS: Record<InitAppPiece, string> = {
40
+ frappe: "Frappe backend wiring (env keys, provider, docker compose bench)",
41
+ gnome: "GNOME desktop target (GTK4/GJS via react-gnome — native widgets, no webview)",
42
+ keycloak: "Keycloak auth wiring (env keys, provider, docker compose + realm)",
43
+ tauri: "Desktop target (src-tauri skeleton, tauri scripts)",
44
+ vscode: "VS Code extension target (extension host + Tamagui webview)",
45
+ webext: "Browser extension target (MV3 popup + background)",
46
+ };
47
+
23
48
  export interface InitAppOptions {
24
49
  /** Skip pnpm install (useful for CI / dry scaffolding). */
25
50
  skipInstall?: boolean;
@@ -31,6 +56,11 @@ export interface InitAppOptions {
31
56
  template?: InitAppTemplate;
32
57
  /** Accept defaults instead of prompting (non-interactive / CI). */
33
58
  yes?: boolean;
59
+ /**
60
+ * Composable pieces overlaid onto the base template. When omitted:
61
+ * checkbox prompt (interactive) or none (--yes / non-TTY).
62
+ */
63
+ pieces?: InitAppPiece[];
34
64
  }
35
65
 
36
66
  type TemplateVars = {
@@ -94,12 +124,17 @@ export interface MpoProvenance {
94
124
  cliVersion: string;
95
125
  name: string;
96
126
  mpoVersion: string;
127
+ /** Composable pieces overlaid at init time (sorted). */
128
+ pieces: InitAppPiece[];
97
129
  }
98
130
 
99
131
  export const PROVENANCE_FILE = ".mpo.json";
100
132
 
101
133
  export function writeProvenance(targetDir: string, provenance: MpoProvenance): void {
102
- writeFileSync(join(targetDir, PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`);
134
+ writeFileSync(
135
+ join(targetDir, PROVENANCE_FILE),
136
+ `${JSON.stringify({ ...provenance, pieces: [...provenance.pieces].sort() }, null, 2)}\n`,
137
+ );
103
138
  }
104
139
 
105
140
  export function readProvenance(projectDir: string): MpoProvenance | undefined {
@@ -113,6 +148,8 @@ export function readProvenance(projectDir: string): MpoProvenance | undefined {
113
148
  cliVersion: raw.cliVersion,
114
149
  name: raw.name,
115
150
  mpoVersion: raw.mpoVersion ?? `^${raw.cliVersion}`,
151
+ // Scaffolds from pre-piece CLIs (≤6.4.x) have no pieces field.
152
+ pieces: Array.isArray(raw.pieces) ? [...(raw.pieces as InitAppPiece[])].sort() : [],
116
153
  };
117
154
  } catch {
118
155
  return undefined;
@@ -123,13 +160,30 @@ function templatesRoot(template: InitAppTemplate): string {
123
160
  return resolve(dirname(fileURLToPath(import.meta.url)), "../../templates", template);
124
161
  }
125
162
 
163
+ function piecesRoot(piece: InitAppPiece): string {
164
+ return resolve(dirname(fileURLToPath(import.meta.url)), "../../templates/pieces", piece);
165
+ }
166
+
167
+ /**
168
+ * Fragment source dirs for a piece + template: shared/ applies to every
169
+ * template, <template>/ only to that one. A piece supports a template when
170
+ * at least one of the two exists.
171
+ */
172
+ function pieceFragmentDirs(piece: InitAppPiece, template: InitAppTemplate): string[] {
173
+ return [join(piecesRoot(piece), "shared"), join(piecesRoot(piece), template)].filter((dir) =>
174
+ existsSync(dir),
175
+ );
176
+ }
177
+
126
178
  /**
127
- * npm always strips .gitignore / .npmrc from published tarballs, so the
128
- * template stores them without the leading dot; restore it at scaffold time.
179
+ * npm always strips .gitignore / .npmrc (and newer npms .env*) from published
180
+ * tarballs, so the template stores them without the leading dot; restore it
181
+ * at scaffold time.
129
182
  */
130
183
  const DOTFILE_NAMES: Record<string, string> = {
131
184
  gitignore: ".gitignore",
132
185
  npmrc: ".npmrc",
186
+ "env.example": ".env.example",
133
187
  };
134
188
 
135
189
  function render(content: string, vars: TemplateVars): string {
@@ -145,7 +199,16 @@ function render(content: string, vars: TemplateVars): string {
145
199
  * Assets copied byte-for-byte — running them through the utf-8 render pass
146
200
  * would corrupt them (fonts ship in the templates for LC-33 stable mount).
147
201
  */
148
- const BINARY_EXTENSIONS = new Set([".woff2", ".woff", ".ttf", ".otf", ".png", ".jpg", ".ico"]);
202
+ const BINARY_EXTENSIONS = new Set([
203
+ ".woff2",
204
+ ".woff",
205
+ ".ttf",
206
+ ".otf",
207
+ ".png",
208
+ ".jpg",
209
+ ".ico",
210
+ ".icns",
211
+ ]);
149
212
 
150
213
  function writeTree(srcDir: string, destDir: string, vars: TemplateVars): void {
151
214
  if (!existsSync(srcDir)) {
@@ -170,6 +233,227 @@ function writeTree(srcDir: string, destDir: string, vars: TemplateVars): void {
170
233
  }
171
234
  }
172
235
 
236
+ // ── piece fragment overlay ─────────────────────────────────────────
237
+
238
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
239
+
240
+ function isJsonObject(value: JsonValue | undefined): value is { [key: string]: JsonValue } {
241
+ return typeof value === "object" && value !== null && !Array.isArray(value);
242
+ }
243
+
244
+ /**
245
+ * Deep-merge for JSON partials: objects merge recursively (fragment keys
246
+ * append after base keys, so e.g. package.json script order stays stable),
247
+ * arrays union (base order first — config.json public keys accumulate
248
+ * across pieces), scalars/type-mismatches take the fragment value.
249
+ */
250
+ export function mergeJsonValues(base: JsonValue, fragment: JsonValue): JsonValue {
251
+ if (Array.isArray(base) && Array.isArray(fragment)) {
252
+ const merged = [...base];
253
+ const seen = new Set(base.map((item) => JSON.stringify(item)));
254
+ for (const item of fragment) {
255
+ const key = JSON.stringify(item);
256
+ if (seen.has(key)) continue;
257
+ seen.add(key);
258
+ merged.push(item);
259
+ }
260
+ return merged;
261
+ }
262
+ if (isJsonObject(base) && isJsonObject(fragment)) {
263
+ const merged: { [key: string]: JsonValue } = { ...base };
264
+ for (const [key, value] of Object.entries(fragment)) {
265
+ merged[key] = key in merged ? mergeJsonValues(merged[key]!, value) : value;
266
+ }
267
+ return merged;
268
+ }
269
+ return fragment;
270
+ }
271
+
272
+ /** Dependency maps stay alphabetically sorted after fragment merges (the
273
+ * base templates are sorted; multiple pieces appending must not depend on
274
+ * overlay order for the final byte content). */
275
+ const SORTED_DEPENDENCY_FIELDS = [
276
+ "dependencies",
277
+ "devDependencies",
278
+ "peerDependencies",
279
+ "optionalDependencies",
280
+ ];
281
+
282
+ function sortDependencyFields(value: JsonValue): JsonValue {
283
+ if (!isJsonObject(value)) return value;
284
+ const result = { ...value };
285
+ for (const field of SORTED_DEPENDENCY_FIELDS) {
286
+ const deps = result[field];
287
+ if (!isJsonObject(deps)) continue;
288
+ result[field] = Object.fromEntries(
289
+ Object.entries(deps).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
290
+ );
291
+ }
292
+ return result;
293
+ }
294
+
295
+ /**
296
+ * Apply a rendered `<target>.partial` fragment onto the target file:
297
+ * - `.json` targets deep-merge (see mergeJsonValues); package.json-style
298
+ * dependency maps are re-sorted for deterministic output
299
+ * - everything else appends as a text block separated by a blank line
300
+ * Missing targets are created from the fragment alone.
301
+ */
302
+ function applyPartial(targetPath: string, renderedFragment: string): void {
303
+ if (targetPath.endsWith(".json")) {
304
+ const fragment = JSON.parse(renderedFragment) as JsonValue;
305
+ const base = existsSync(targetPath)
306
+ ? (JSON.parse(readFileSync(targetPath, "utf-8")) as JsonValue)
307
+ : undefined;
308
+ const merged = sortDependencyFields(
309
+ base === undefined ? fragment : mergeJsonValues(base, fragment),
310
+ );
311
+ writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
312
+ return;
313
+ }
314
+ const block = `${renderedFragment.replace(/^\n+/u, "").replace(/\n+$/u, "")}\n`;
315
+ if (!existsSync(targetPath)) {
316
+ writeFileSync(targetPath, block);
317
+ return;
318
+ }
319
+ const existing = readFileSync(targetPath, "utf-8").replace(/\n+$/u, "");
320
+ writeFileSync(targetPath, `${existing}\n\n${block}`);
321
+ }
322
+
323
+ const PARTIAL_SUFFIX = ".partial";
324
+
325
+ /**
326
+ * Overlay a piece fragment tree onto the scaffold: regular files render and
327
+ * overwrite, `<target>.partial` files merge into (or create) their target
328
+ * (deep-merge for .json, append for text — see applyPartial).
329
+ */
330
+ function overlayTree(srcDir: string, destDir: string, vars: TemplateVars): void {
331
+ mkdirSync(destDir, { recursive: true });
332
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
333
+ const from = join(srcDir, entry.name);
334
+ if (entry.isDirectory()) {
335
+ overlayTree(from, join(destDir, render(DOTFILE_NAMES[entry.name] ?? entry.name, vars)), vars);
336
+ continue;
337
+ }
338
+ if (entry.name.endsWith(PARTIAL_SUFFIX)) {
339
+ const targetName = entry.name.slice(0, -PARTIAL_SUFFIX.length);
340
+ const toName = render(DOTFILE_NAMES[targetName] ?? targetName, vars);
341
+ applyPartial(join(destDir, toName), render(readFileSync(from, "utf-8"), vars));
342
+ continue;
343
+ }
344
+ const toName = render(DOTFILE_NAMES[entry.name] ?? entry.name, vars);
345
+ const to = join(destDir, toName);
346
+ if (BINARY_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
347
+ copyFileSync(from, to);
348
+ continue;
349
+ }
350
+ writeFileSync(to, render(readFileSync(from, "utf-8"), vars));
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Validate + canonicalize a piece selection for a template: unknown names
356
+ * throw, duplicates collapse, order is always sorted (provenance and overlay
357
+ * order must be deterministic), and every piece must ship a fragment for the
358
+ * chosen template.
359
+ */
360
+ export function normalizePieces(pieces: string[], template: InitAppTemplate): InitAppPiece[] {
361
+ const known = new Set<string>(INIT_APP_PIECES);
362
+ const normalized = [...new Set(pieces)].sort() as InitAppPiece[];
363
+ for (const piece of normalized) {
364
+ if (!known.has(piece)) {
365
+ throw new Error(`Unknown piece "${piece}". Available pieces: ${INIT_APP_PIECES.join(", ")}`);
366
+ }
367
+ if (pieceFragmentDirs(piece, template).length === 0) {
368
+ throw new Error(
369
+ `Piece "${piece}" is not available for the ${template} template ` +
370
+ "(pieces currently target the universal template)",
371
+ );
372
+ }
373
+ }
374
+ return normalized;
375
+ }
376
+
377
+ /** Pieces that can be offered for a template (fragment exists for it). */
378
+ export function availablePieces(template: InitAppTemplate): InitAppPiece[] {
379
+ return INIT_APP_PIECES.filter((piece) => pieceFragmentDirs(piece, template).length > 0);
380
+ }
381
+
382
+ async function resolvePieces(
383
+ options: InitAppOptions,
384
+ template: InitAppTemplate,
385
+ ): Promise<InitAppPiece[]> {
386
+ if (options.pieces) return normalizePieces(options.pieces, template);
387
+ if (options.yes || !process.stdout.isTTY) return [];
388
+ const choices = availablePieces(template);
389
+ if (choices.length === 0) return [];
390
+ const result = await inquirer.prompt([
391
+ {
392
+ message: "Add optional pieces? (space to toggle, enter to confirm)",
393
+ name: "pieces",
394
+ type: "checkbox",
395
+ choices: choices.map((piece) => ({
396
+ name: `${piece} — ${PIECE_DESCRIPTIONS[piece]}`,
397
+ value: piece,
398
+ checked: false,
399
+ })),
400
+ },
401
+ ]);
402
+ return normalizePieces(result.pieces as string[], template);
403
+ }
404
+
405
+ // ── app.ts composition (frappe / keycloak provider wiring) ─────────
406
+
407
+ /**
408
+ * Generate apps/<name>/src/app.ts for the selected backend pieces instead of
409
+ * storing 2^n template variants: each selected provider adds one createApp
410
+ * key (frappe/keycloak read their runtime settings from the packages/config
411
+ * public env keys their fragments register). Only called when at least one
412
+ * provider piece is selected — the base template file stays otherwise.
413
+ */
414
+ export function composeAppTs(pieces: InitAppPiece[]): string {
415
+ const frappe = pieces.includes("frappe");
416
+ const keycloak = pieces.includes("keycloak");
417
+ const lines: string[] = ['import { createApp } from "@multiplatform.one/core";'];
418
+ if (frappe || keycloak) {
419
+ lines.push('import { config } from "@multiplatform.one/platform";');
420
+ }
421
+ lines.push('import { themeConfig } from "@package/themes";');
422
+ lines.push(
423
+ "",
424
+ "/**",
425
+ " * App-wide provider stack. Providers below were enabled by the pieces",
426
+ " * selected at `mpo init` time — see CreateAppConfig in",
427
+ " * @multiplatform.one/core for everything else that can be wired here",
428
+ " * (tanstack, web3, custom providers).",
429
+ " */",
430
+ "export const { AppProvider, useAppConfig, withApp } = createApp({",
431
+ " theme: themeConfig,",
432
+ );
433
+ if (frappe) {
434
+ lines.push(
435
+ " // Frappe backend (mpo init --frappe): FrappeProvider mounts when",
436
+ " // FRAPPE_ENABLED=1 (a packages/config public env key, loaded from",
437
+ " // .env — see .env.example). FRAPPE_URL overrides the bench URL",
438
+ " // (default: BASE_URL, then http://localhost:8000);",
439
+ " // docker/compose.frappe.yaml runs a dev bench.",
440
+ ' frappe: { baseURL: config.get("FRAPPE_URL") },',
441
+ );
442
+ }
443
+ if (keycloak) {
444
+ lines.push(
445
+ " // Keycloak auth (mpo init --keycloak): KeycloakProvider mounts when",
446
+ " // KEYCLOAK_ENABLED=1. Realm/client settings come from the KEYCLOAK_*",
447
+ " // env keys (packages/config, .env.example);",
448
+ " // docker/compose.keycloak.yaml runs a dev server with an imported",
449
+ " // realm.",
450
+ ' keycloak: { baseUrl: config.get("KEYCLOAK_BASE_URL") },',
451
+ );
452
+ }
453
+ lines.push("});", "");
454
+ return lines.join("\n");
455
+ }
456
+
173
457
  async function resolveTemplate(options: InitAppOptions): Promise<InitAppTemplate> {
174
458
  if (options.template) return options.template;
175
459
  if (options.yes || !process.stdout.isTTY) return "universal";
@@ -216,6 +500,7 @@ export async function initApp(
216
500
 
217
501
  const name = validateName(projectName);
218
502
  const template = await resolveTemplate(options);
503
+ const pieces = await resolvePieces(options, template);
219
504
  const targetDir = resolve(name);
220
505
 
221
506
  if (existsSync(targetDir)) {
@@ -242,14 +527,31 @@ export async function initApp(
242
527
  console.log(` packages/i18n`);
243
528
  console.log(` packages/themes`);
244
529
  }
530
+ if (pieces.length) {
531
+ console.log(` pieces: ${pieces.join(", ")}`);
532
+ }
245
533
  console.log(` @multiplatform.one/* → ${vars.MPO_VERSION}\n`);
246
534
 
247
535
  writeTree(templatesRoot(template), targetDir, vars);
536
+ // Pieces overlay AFTER the base template, in sorted order (normalizePieces)
537
+ // so repeated scaffolds are byte-identical — mpo update regenerates these
538
+ // exact trees as merge baselines.
539
+ for (const piece of pieces) {
540
+ for (const fragmentDir of pieceFragmentDirs(piece, template)) {
541
+ overlayTree(fragmentDir, targetDir, vars);
542
+ }
543
+ }
544
+ // Backend providers are wired by generating src/app.ts from the selection
545
+ // (not by stacking file overwrites per combination).
546
+ if (pieces.includes("frappe") || pieces.includes("keycloak")) {
547
+ writeFileSync(join(targetDir, "apps", name, "src/app.ts"), composeAppTs(pieces));
548
+ }
248
549
  writeProvenance(targetDir, {
249
550
  template,
250
551
  cliVersion: cliVersion() ?? "0.0.0",
251
552
  name,
252
553
  mpoVersion: vars.MPO_VERSION,
554
+ pieces,
253
555
  });
254
556
 
255
557
  // Initialize git with a scaffold commit (unless already inside a repo —
@@ -291,9 +593,7 @@ export async function initApp(
291
593
  // commit so a fresh init is immediately clean.
292
594
  if (scaffoldCommitted) {
293
595
  await spawn("git", ["add", "--", "pnpm-lock.yaml"], { cwd: targetDir })
294
- .then(() =>
295
- spawn("git", ["commit", "--amend", "--no-edit", "--quiet"], { cwd: targetDir }),
296
- )
596
+ .then(() => spawn("git", ["commit", "--amend", "--no-edit", "--quiet"], { cwd: targetDir }))
297
597
  .catch(() => {});
298
598
  }
299
599
  } else {
@@ -309,6 +609,24 @@ export async function initApp(
309
609
  console.log(" pnpm ios # iOS (requires pnpm dev in another terminal)");
310
610
  console.log(" pnpm android # Android");
311
611
  }
612
+ if (pieces.includes("frappe")) {
613
+ console.log(" docker compose -f docker/compose.frappe.yaml up -d # dev bench (see README)");
614
+ }
615
+ if (pieces.includes("keycloak")) {
616
+ console.log(" docker compose -f docker/compose.keycloak.yaml up -d # dev keycloak");
617
+ }
618
+ if (pieces.includes("gnome")) {
619
+ console.log(" pnpm gnome # GTK4 desktop app (requires gjs + gtk4 — see README)");
620
+ }
621
+ if (pieces.includes("tauri")) {
622
+ console.log(" pnpm tauri:dev # desktop app (requires the Rust toolchain)");
623
+ }
624
+ if (pieces.includes("vscode")) {
625
+ console.log(" pnpm build:vscode # stage the VS Code extension into apps dist-vscode/");
626
+ }
627
+ if (pieces.includes("webext")) {
628
+ console.log(" pnpm build:webext # stage the browser extension into apps dist-webext/");
629
+ }
312
630
  console.log("\nThis project consumes published @multiplatform.one/* packages.");
313
631
  console.log("It is not a fork of the multiplatform.one monorepo.");
314
632
  }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { threeWayMergePackageJson } from "./updateApp";
3
+
4
+ const json = (value: unknown) => JSON.stringify(value, null, 2);
5
+
6
+ describe("threeWayMergePackageJson", () => {
7
+ it("resolves dependency-range drift to the newer minimum", () => {
8
+ const base = json({ dependencies: { "@multiplatform.one/components": "^6.3.0" } });
9
+ const ours = json({ dependencies: { "@multiplatform.one/components": "^6.4.2" } });
10
+ const theirs = json({ dependencies: { "@multiplatform.one/components": "^6.4.3" } });
11
+ expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
12
+ dependencies: { "@multiplatform.one/components": "^6.4.3" },
13
+ });
14
+ // Symmetric: the project may be AHEAD of the template.
15
+ expect(threeWayMergePackageJson(base, theirs, ours)).toEqual({
16
+ dependencies: { "@multiplatform.one/components": "^6.4.3" },
17
+ });
18
+ });
19
+
20
+ it("prefers the template spelling on equal minimums", () => {
21
+ const base = json({ dependencies: { react: "^19.0.0" } });
22
+ const ours = json({ dependencies: { react: "19.2.0" } });
23
+ const theirs = json({ dependencies: { react: "~19.2.0" } });
24
+ expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
25
+ dependencies: { react: "~19.2.0" },
26
+ });
27
+ });
28
+
29
+ it("keeps project-added dependencies alongside template bumps", () => {
30
+ const base = json({ dependencies: { one: "^1.0.0" } });
31
+ const ours = json({ dependencies: { one: "^1.0.0", "@multiplatform.one/keycloak": "^6.4.2" } });
32
+ const theirs = json({ dependencies: { one: "^1.1.0" } });
33
+ expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
34
+ dependencies: { one: "^1.1.0", "@multiplatform.one/keycloak": "^6.4.2" },
35
+ });
36
+ });
37
+
38
+ it("applies template deletions the project did not touch", () => {
39
+ const base = json({ scripts: { postinstall: "patch-package", build: "vite build" } });
40
+ const ours = json({ scripts: { postinstall: "patch-package", build: "vite build" } });
41
+ const theirs = json({ scripts: { build: "vite build" } });
42
+ expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
43
+ scripts: { build: "vite build" },
44
+ });
45
+ });
46
+
47
+ it("resolves nested override maps", () => {
48
+ const base = json({ pnpm: { overrides: { "react-dom": "19.2.4" } } });
49
+ const ours = json({ pnpm: { overrides: { "react-dom": "19.2.5" } } });
50
+ const theirs = json({ pnpm: { overrides: { "react-dom": "19.2.6" } } });
51
+ expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
52
+ pnpm: { overrides: { "react-dom": "19.2.6" } },
53
+ });
54
+ });
55
+
56
+ it("returns undefined for genuine two-sided conflicts", () => {
57
+ const base = json({ scripts: { build: "vite build" } });
58
+ const ours = json({ scripts: { build: "vite build --mode ours" } });
59
+ const theirs = json({ scripts: { build: "vite build --mode theirs" } });
60
+ expect(threeWayMergePackageJson(base, ours, theirs)).toBeUndefined();
61
+ });
62
+
63
+ it("returns undefined for non-range dependency divergence", () => {
64
+ const base = json({ dependencies: { local: "^1.0.0" } });
65
+ const ours = json({ dependencies: { local: "workspace:*" } });
66
+ const theirs = json({ dependencies: { local: "^2.0.0" } });
67
+ expect(threeWayMergePackageJson(base, ours, theirs)).toBeUndefined();
68
+ });
69
+
70
+ it("returns undefined on unparseable input", () => {
71
+ expect(threeWayMergePackageJson("{}", "not json", "{}")).toBeUndefined();
72
+ });
73
+
74
+ it("treats a missing base as empty (add/add merges)", () => {
75
+ const ours = json({ dependencies: { a: "^1.0.0" } });
76
+ const theirs = json({ dependencies: { b: "^2.0.0" } });
77
+ expect(threeWayMergePackageJson(undefined, ours, theirs)).toEqual({
78
+ dependencies: { a: "^1.0.0", b: "^2.0.0" },
79
+ });
80
+ });
81
+ });