@multiplatform.one/cli 6.4.3 → 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 (93) 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 +2 -2
  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/pieces/frappe/universal/README.md.partial +32 -0
  12. package/templates/pieces/frappe/universal/docker/compose.frappe.yaml +52 -0
  13. package/templates/pieces/frappe/universal/env.example.partial +8 -0
  14. package/templates/pieces/frappe/universal/packages/config/config.json.partial +3 -0
  15. package/templates/pieces/gnome/universal/README.md.partial +68 -0
  16. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/main.tsx +165 -0
  17. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/polyfills.ts +10 -0
  18. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/components.ts +36 -0
  19. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/empty-module.ts +28 -0
  20. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/forms.ts +18 -0
  21. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/one.ts +89 -0
  22. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/theme.ts +25 -0
  23. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/tamagui-barrel.ts +77 -0
  24. package/templates/pieces/gnome/universal/apps/__NAME__/package.json.partial +18 -0
  25. package/templates/pieces/gnome/universal/apps/__NAME__/vite.config.gnome.ts +124 -0
  26. package/templates/pieces/gnome/universal/gitignore.partial +3 -0
  27. package/templates/pieces/gnome/universal/package.json.partial +6 -0
  28. package/templates/pieces/keycloak/universal/README.md.partial +19 -0
  29. package/templates/pieces/keycloak/universal/docker/compose.keycloak.yaml +27 -0
  30. package/templates/pieces/keycloak/universal/docker/keycloak/realm.json +43 -0
  31. package/templates/pieces/keycloak/universal/env.example.partial +9 -0
  32. package/templates/pieces/keycloak/universal/packages/config/config.json.partial +9 -0
  33. package/templates/pieces/tauri/universal/README.md.partial +18 -0
  34. package/templates/pieces/tauri/universal/apps/__NAME__/package.json.partial +10 -0
  35. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/Cargo.toml +26 -0
  36. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/build.rs +3 -0
  37. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/capabilities/default.json +7 -0
  38. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/128x128.png +0 -0
  39. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/128x128@2x.png +0 -0
  40. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/32x32.png +0 -0
  41. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/icon.icns +0 -0
  42. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/icon.ico +0 -0
  43. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +14 -0
  44. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/main.rs +9 -0
  45. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +43 -0
  46. package/templates/pieces/tauri/universal/gitignore.partial +3 -0
  47. package/templates/pieces/tauri/universal/package.json.partial +6 -0
  48. package/templates/pieces/vscode/universal/README.md.partial +17 -0
  49. package/templates/pieces/vscode/universal/apps/__NAME__/package.json.partial +19 -0
  50. package/templates/pieces/vscode/universal/apps/__NAME__/vite.config.vscode.ts +17 -0
  51. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/env.d.ts +1 -0
  52. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/index.ts +13 -0
  53. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/views/helper.ts +14 -0
  54. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/views/panel.ts +38 -0
  55. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/index.html +49 -0
  56. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/manifest.ts +33 -0
  57. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/package.json +7 -0
  58. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/tamagui.css +3 -0
  59. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/tsconfig.json +9 -0
  60. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/app.tsx +24 -0
  61. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/components/ErrorBoundary.tsx +41 -0
  62. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts/inter-400.woff2 +0 -0
  63. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts/inter-700.woff2 +0 -0
  64. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts.css +21 -0
  65. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/main.tsx +35 -0
  66. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/router.tsx +58 -0
  67. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/useVSCodeTheme.ts +56 -0
  68. package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/utils/vscode.ts +16 -0
  69. package/templates/pieces/vscode/universal/gitignore.partial +2 -0
  70. package/templates/pieces/vscode/universal/package.json.partial +6 -0
  71. package/templates/pieces/webext/universal/README.md.partial +16 -0
  72. package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +22 -0
  73. package/templates/pieces/webext/universal/apps/__NAME__/tsconfig.json.partial +5 -0
  74. package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext-background.ts +19 -0
  75. package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +35 -0
  76. package/templates/pieces/webext/universal/apps/__NAME__/webext/assets/fonts/inter-400.woff2 +0 -0
  77. package/templates/pieces/webext/universal/apps/__NAME__/webext/assets/fonts/inter-700.woff2 +0 -0
  78. package/templates/pieces/webext/universal/apps/__NAME__/webext/background/main.ts +10 -0
  79. package/templates/pieces/webext/universal/apps/__NAME__/webext/components/ErrorBoundary.tsx +41 -0
  80. package/templates/pieces/webext/universal/apps/__NAME__/webext/env.ts +2 -0
  81. package/templates/pieces/webext/universal/apps/__NAME__/webext/manifest.ts +45 -0
  82. package/templates/pieces/webext/universal/apps/__NAME__/webext/prepare.ts +21 -0
  83. package/templates/pieces/webext/universal/apps/__NAME__/webext/static/assets/icon-512.png +0 -0
  84. package/templates/pieces/webext/universal/apps/__NAME__/webext/tamagui.css +3 -0
  85. package/templates/pieces/webext/universal/apps/__NAME__/webext/tsconfig.json +9 -0
  86. package/templates/pieces/webext/universal/apps/__NAME__/webext/views/popup/index.html +77 -0
  87. package/templates/pieces/webext/universal/apps/__NAME__/webext/views/popup/main.tsx +73 -0
  88. package/templates/pieces/webext/universal/gitignore.partial +2 -0
  89. package/templates/pieces/webext/universal/package.json.partial +6 -0
  90. package/types/commands/initApp.d.ts +43 -0
  91. package/types/commands/initApp.d.ts.map +1 -1
  92. package/types/commands/updateApp.d.ts +4 -0
  93. package/types/commands/updateApp.d.ts.map +1 -1
@@ -15,12 +15,12 @@
15
15
  * pins matching paths to your HEAD version, exactly like the old script.
16
16
  */
17
17
 
18
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
18
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
19
  import { tmpdir } from "node:os";
20
- import { join } from "node:path";
20
+ import { basename, join } from "node:path";
21
21
  import spawn from "nano-spawn";
22
22
  import { generateVscodeConfig } from "../generateVscode";
23
- import { cliVersion, initApp, readProvenance, writeProvenance } from "./initApp";
23
+ import { cliVersion, initApp, readProvenance, writeProvenance, type InitAppPiece } from "./initApp";
24
24
 
25
25
  export interface UpdateAppOptions {
26
26
  /** Skip pnpm install after the merge. */
@@ -69,6 +69,7 @@ async function scaffoldBaseline(
69
69
  name: string,
70
70
  template: "universal" | "app",
71
71
  mpoVersion: string,
72
+ pieces: InitAppPiece[],
72
73
  ): Promise<{ dir: string; cleanup: () => void }> {
73
74
  const parent = mkdtempSync(join(tmpdir(), "mpo-update-"));
74
75
  const cleanup = () => rmSync(parent, { recursive: true, force: true });
@@ -79,6 +80,19 @@ async function scaffoldBaseline(
79
80
  // scaffold baseline.
80
81
  const [major = 0, minor = 0] = version.split(".").map(Number);
81
82
  const scaffoldCapable = major > 6 || (major === 6 && minor >= 3);
83
+ // Composable pieces (--frappe/--keycloak/--tauri/--vscode/--webext)
84
+ // shipped in 6.5.0 — older published CLIs reject the flags. A 6.3/6.4
85
+ // baseline is scaffolded WITHOUT pieces: piece files then appear as
86
+ // ours-only additions relative to that base, which three-way merge keeps
87
+ // cleanly (they can't have come from those CLIs in the first place).
88
+ const piecesCapable = major > 6 || (major === 6 && minor >= 5);
89
+ const pieceFlags = piecesCapable ? pieces.map((piece) => `--${piece}`) : [];
90
+ if (pieces.length && scaffoldCapable && !piecesCapable && version !== current) {
91
+ console.warn(
92
+ `⚠️ @multiplatform.one/cli@${version} predates composable pieces (6.5.0); ` +
93
+ "the merge base is scaffolded without them.",
94
+ );
95
+ }
82
96
  if (!scaffoldCapable) {
83
97
  console.warn(
84
98
  `⚠️ @multiplatform.one/cli@${version} predates the consumer scaffolder (6.3.0); ` +
@@ -104,6 +118,7 @@ async function scaffoldBaseline(
104
118
  "--mpo-version",
105
119
  mpoVersion,
106
120
  ...templateFlag,
121
+ ...pieceFlags,
107
122
  ],
108
123
  { cwd: parent, stdio: "inherit" },
109
124
  );
@@ -131,6 +146,7 @@ async function scaffoldBaseline(
131
146
  yes: true,
132
147
  template,
133
148
  version: mpoVersion,
149
+ pieces,
134
150
  });
135
151
  } finally {
136
152
  process.chdir(previousCwd);
@@ -180,6 +196,167 @@ async function pinPathsToHead(
180
196
  return pinned;
181
197
  }
182
198
 
199
+ // ── package.json conflict auto-resolution ─────────────────────────
200
+ //
201
+ // The dominant conflict class in real updates is dependency-range drift:
202
+ // the project bumped a range between updates (ours ^6.4.2) while the new
203
+ // template carries its own (theirs ^6.4.3). Both sides changed the same
204
+ // JSON entry, so textual merge punts — but the resolution is mechanical:
205
+ // take the newer minimum version. A structural three-way merge handles it.
206
+
207
+ const UNRESOLVED = Symbol("unresolved");
208
+ /** Deleted-on-one-side sentinel (distinct from a key that never existed). */
209
+ const ABSENT = Symbol("absent");
210
+
211
+ const DEP_MAP_KEYS = new Set([
212
+ "dependencies",
213
+ "devDependencies",
214
+ "peerDependencies",
215
+ "optionalDependencies",
216
+ "overrides",
217
+ "resolutions",
218
+ ]);
219
+
220
+ /** Minimum concrete version of a simple range (`^x.y.z`, `~x.y.z`,
221
+ * `x.y.z`, optional prerelease). Anything fancier is not auto-resolved. */
222
+ function rangeMinimum(range: unknown): [number, number, number] | undefined {
223
+ if (typeof range !== "string") return undefined;
224
+ const match = range.match(/^[\^~]?(\d+)\.(\d+)\.(\d+)(?:-[\w.]+)?$/);
225
+ if (!match) return undefined;
226
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
227
+ }
228
+
229
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
230
+ return typeof value === "object" && value !== null && !Array.isArray(value);
231
+ }
232
+
233
+ function jsonEqual(a: unknown, b: unknown): boolean {
234
+ return JSON.stringify(a) === JSON.stringify(b);
235
+ }
236
+
237
+ function mergeNode(
238
+ base: unknown,
239
+ ours: unknown,
240
+ theirs: unknown,
241
+ inDepMap: boolean,
242
+ ): unknown | typeof UNRESOLVED | typeof ABSENT {
243
+ if (jsonEqual(ours, theirs)) return ours === undefined ? ABSENT : ours;
244
+ // Only one side diverged from base → that side wins (undefined = deletion).
245
+ if (jsonEqual(theirs, base)) return ours === undefined ? ABSENT : ours;
246
+ if (jsonEqual(ours, base)) return theirs === undefined ? ABSENT : theirs;
247
+ // Both diverged. Objects merge key-by-key; dep ranges take the newer
248
+ // minimum; everything else is a genuine conflict.
249
+ if (isPlainObject(ours) && isPlainObject(theirs)) {
250
+ const baseObject = isPlainObject(base) ? base : {};
251
+ const merged: Record<string, unknown> = {};
252
+ const keys = [...Object.keys(ours), ...Object.keys(theirs).filter((key) => !(key in ours))];
253
+ for (const key of keys) {
254
+ const child = mergeNode(
255
+ baseObject[key],
256
+ ours[key],
257
+ theirs[key],
258
+ inDepMap || DEP_MAP_KEYS.has(key),
259
+ );
260
+ if (child === UNRESOLVED) return UNRESOLVED;
261
+ if (child !== ABSENT) merged[key] = child;
262
+ }
263
+ return merged;
264
+ }
265
+ if (inDepMap) {
266
+ const oursMin = rangeMinimum(ours);
267
+ const theirsMin = rangeMinimum(theirs);
268
+ if (oursMin && theirsMin) {
269
+ for (let i = 0; i < 3; i++) {
270
+ if (oursMin[i]! > theirsMin[i]!) return ours;
271
+ if (oursMin[i]! < theirsMin[i]!) return theirs;
272
+ }
273
+ return theirs; // equal minimums, different spelling — template wins
274
+ }
275
+ }
276
+ return UNRESOLVED;
277
+ }
278
+
279
+ /** Structural three-way merge of package.json content. Returns the merged
280
+ * object, or undefined when a divergence can't be auto-resolved (the file
281
+ * then keeps its textual conflict markers for manual resolution). */
282
+ export function threeWayMergePackageJson(
283
+ base: string | undefined,
284
+ ours: string,
285
+ theirs: string,
286
+ ): Record<string, unknown> | undefined {
287
+ let baseJson: unknown;
288
+ let oursJson: unknown;
289
+ let theirsJson: unknown;
290
+ try {
291
+ baseJson = base === undefined ? {} : JSON.parse(base);
292
+ oursJson = JSON.parse(ours);
293
+ theirsJson = JSON.parse(theirs);
294
+ } catch {
295
+ return undefined;
296
+ }
297
+ if (!isPlainObject(oursJson) || !isPlainObject(theirsJson)) return undefined;
298
+ const merged = mergeNode(baseJson, oursJson, theirsJson, false);
299
+ if (merged === UNRESOLVED || merged === ABSENT || !isPlainObject(merged)) return undefined;
300
+ return merged;
301
+ }
302
+
303
+ /** Replace auto-resolvable conflicted package.json files in `mergedTree`
304
+ * with their structural merge. Pure index plumbing, worktree untouched. */
305
+ async function autoResolveDependencyConflicts(
306
+ projectDir: string,
307
+ mergedTree: string,
308
+ conflicts: string[],
309
+ baseCommit: string,
310
+ headCommit: string,
311
+ nextCommit: string,
312
+ ): Promise<{ tree: string; conflicts: string[]; resolved: string[] }> {
313
+ const candidates = conflicts.filter((file) => basename(file) === "package.json");
314
+ if (!candidates.length) return { tree: mergedTree, conflicts, resolved: [] };
315
+
316
+ const scratch = mkdtempSync(join(tmpdir(), "mpo-resolve-"));
317
+ const resolved: { path: string; oid: string }[] = [];
318
+ try {
319
+ for (const file of candidates) {
320
+ const show = (commit: string) =>
321
+ gitOut(projectDir, ["show", `${commit}:${file}`]).catch(() => undefined);
322
+ const [base, ours, theirs] = await Promise.all([
323
+ show(baseCommit),
324
+ show(headCommit),
325
+ show(nextCommit),
326
+ ]);
327
+ // Add/delete conflicts stay manual — only both-sides-present merges.
328
+ if (ours === undefined || theirs === undefined) continue;
329
+ const merged = threeWayMergePackageJson(base, ours, theirs);
330
+ if (!merged) continue;
331
+ const blobFile = join(scratch, "blob.json");
332
+ writeFileSync(blobFile, `${JSON.stringify(merged, null, 2)}\n`);
333
+ const oid = await gitOut(projectDir, ["hash-object", "-w", "--", blobFile]);
334
+ resolved.push({ path: file, oid });
335
+ }
336
+ if (!resolved.length) return { tree: mergedTree, conflicts, resolved: [] };
337
+
338
+ const indexFile = join(scratch, "index");
339
+ const env = { GIT_INDEX_FILE: indexFile };
340
+ await git(projectDir, ["read-tree", mergedTree], env);
341
+ for (const entry of resolved) {
342
+ await git(
343
+ projectDir,
344
+ ["update-index", "--add", "--cacheinfo", `100644,${entry.oid},${entry.path}`],
345
+ env,
346
+ );
347
+ }
348
+ const tree = await gitOut(projectDir, ["write-tree"], env);
349
+ const resolvedPaths = new Set(resolved.map((entry) => entry.path));
350
+ return {
351
+ tree,
352
+ conflicts: conflicts.filter((file) => !resolvedPaths.has(file)),
353
+ resolved: [...resolvedPaths],
354
+ };
355
+ } finally {
356
+ rmSync(scratch, { recursive: true, force: true });
357
+ }
358
+ }
359
+
183
360
  export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
184
361
  const projectDir = await gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
185
362
  throw new Error("mpo update must run inside a git repository");
@@ -212,15 +389,19 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
212
389
 
213
390
  console.log(`\nmpo update: ${provenance.cliVersion} → ${currentVersion}`);
214
391
  console.log(` template: ${provenance.template}`);
392
+ if (provenance.pieces.length) console.log(` pieces: ${provenance.pieces.join(", ")}`);
215
393
  console.log(` project: ${provenance.name}\n`);
216
394
 
217
- // 1. Regenerate the ORIGINAL scaffold (merge base).
395
+ // 1. Regenerate the ORIGINAL scaffold (merge base). Pieces recorded in
396
+ // provenance are reproduced in BOTH baselines so piece files merge as
397
+ // template content, not as phantom diffs.
218
398
  console.log(`Scaffolding merge base (cli@${provenance.cliVersion})...`);
219
399
  const base = await scaffoldBaseline(
220
400
  provenance.cliVersion,
221
401
  provenance.name,
222
402
  provenance.template,
223
403
  provenance.mpoVersion,
404
+ provenance.pieces,
224
405
  );
225
406
 
226
407
  // 2. Generate the CURRENT scaffold (theirs).
@@ -230,6 +411,7 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
230
411
  provenance.name,
231
412
  provenance.template,
232
413
  targetMpoRange,
414
+ provenance.pieces,
233
415
  );
234
416
 
235
417
  // .mpo.json is TOOL-owned, never merged: pre-provenance CLIs (≤6.3.0)
@@ -289,6 +471,7 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
289
471
  cliVersion: currentVersion,
290
472
  name: provenance.name,
291
473
  mpoVersion: targetMpoRange,
474
+ pieces: provenance.pieces,
292
475
  };
293
476
 
294
477
  // .updateignore pathspecs (+ pnpm-lock.yaml) pin to the pre-update HEAD.
@@ -309,6 +492,22 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
309
492
  (file) => !pins.some((pin) => file === pin || file.startsWith(`${pin}/`)),
310
493
  );
311
494
 
495
+ // Dependency-range drift in package.json files resolves mechanically
496
+ // (newer minimum version wins) — don't punt those to the user.
497
+ const autoResolved = await autoResolveDependencyConflicts(
498
+ projectDir,
499
+ mergedTree,
500
+ conflicts,
501
+ baseCommit,
502
+ head,
503
+ nextCommit,
504
+ );
505
+ mergedTree = autoResolved.tree;
506
+ conflicts = autoResolved.conflicts;
507
+ for (const file of autoResolved.resolved) {
508
+ console.log(` auto-resolved: ${file} (dependency ranges)`);
509
+ }
510
+
312
511
  // No-op detection: identical trees mean the project already carries the
313
512
  // current template — don't stack empty merge commits.
314
513
  const headTree = await gitOut(projectDir, ["rev-parse", `${head}^{tree}`]);
@@ -332,6 +531,7 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
332
531
  return;
333
532
  }
334
533
 
534
+ let cleanMergeMessage: string | undefined;
335
535
  if (!conflicts.length) {
336
536
  const message = `chore: mpo update ${provenance.cliVersion} → ${currentVersion}`;
337
537
  const mergeCommit = await gitOut(projectDir, [
@@ -356,8 +556,7 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
356
556
  }
357
557
  await git(projectDir, ["add", "--", ".mpo.json", ".vscode"]);
358
558
  await git(projectDir, ["commit", "--amend", "--no-edit", "--quiet"]);
359
- const amended = await gitOut(projectDir, ["rev-parse", "--short", "HEAD"]);
360
- console.log(`\n✅ merged cleanly → ${amended} ("${message}")`);
559
+ cleanMergeMessage = message;
361
560
  } else {
362
561
  // Land the merged tree (with conflict markers) in index + worktree for
363
562
  // manual resolution; pins are already part of the tree.
@@ -389,6 +588,13 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
389
588
  }
390
589
  }
391
590
  }
591
+
592
+ // Reported last — the lockfile amend above rewrites the commit, so an
593
+ // earlier print would show a hash that no longer exists.
594
+ if (cleanMergeMessage) {
595
+ const finalHash = await gitOut(projectDir, ["rev-parse", "--short", "HEAD"]);
596
+ console.log(`\n✅ merged cleanly → ${finalHash} ("${cleanMergeMessage}")`);
597
+ }
392
598
  } finally {
393
599
  base.cleanup();
394
600
  next.cleanup();
@@ -0,0 +1,32 @@
1
+ ## Frappe backend (`mpo init --frappe`)
2
+
3
+ `apps/__NAME__/src/app.ts` wires `createApp({ frappe: … })`: the
4
+ `FrappeProvider` mounts when `FRAPPE_ENABLED=1` and talks to `FRAPPE_URL`
5
+ (both are `public` env keys in `packages/config/config.json`, loaded from
6
+ `.env` — start from `.env.example`).
7
+
8
+ Run a dev bench with docker:
9
+
10
+ ```bash
11
+ docker compose -f docker/compose.frappe.yaml up -d
12
+ docker compose -f docker/compose.frappe.yaml exec bench bash
13
+
14
+ # inside the container (first time only)
15
+ bench init --skip-redis-config-generation frappe-bench
16
+ cd frappe-bench
17
+ bench set-config -g db_host mariadb
18
+ bench set-config -g redis_cache redis://redis-cache:6379
19
+ bench set-config -g redis_queue redis://redis-queue:6379
20
+ bench set-config -g redis_socketio redis://redis-queue:6379
21
+ bench new-site __NAME__.localhost \
22
+ --mariadb-user-host-login-scope=% \
23
+ --db-root-password frappe --admin-password admin
24
+ bench --site __NAME__.localhost set-config developer_mode 1
25
+
26
+ # every dev session
27
+ bench start
28
+ ```
29
+
30
+ Then set `FRAPPE_URL=http://__NAME__.localhost:8000` (or keep
31
+ `http://localhost:8000` and make the site the default with
32
+ `bench use __NAME__.localhost`) and restart `pnpm dev`.
@@ -0,0 +1,52 @@
1
+ # Dev Frappe bench for __NAME__ (mpo init --frappe).
2
+ #
3
+ # docker compose -f docker/compose.frappe.yaml up -d
4
+ # docker compose -f docker/compose.frappe.yaml exec bench bash
5
+ #
6
+ # The frappe/bench image is a development sandbox (frappe user + bench
7
+ # tooling); the bench itself is created ONCE inside the container — see the
8
+ # "Frappe backend" section of the README for the init commands. State
9
+ # persists in the named volumes.
10
+ services:
11
+ bench:
12
+ image: frappe/bench:latest
13
+ command: sleep infinity
14
+ working_dir: /home/frappe
15
+ environment:
16
+ SHELL: /bin/bash
17
+ volumes:
18
+ - bench:/home/frappe
19
+ ports:
20
+ - "8000:8000" # bench serve (FRAPPE_URL)
21
+ - "9000:9000" # socket.io realtime
22
+ depends_on:
23
+ - mariadb
24
+ - redis-cache
25
+ - redis-queue
26
+
27
+ mariadb:
28
+ image: mariadb:10.6
29
+ command:
30
+ - --character-set-server=utf8mb4
31
+ - --collation-server=utf8mb4_unicode_ci
32
+ - --skip-character-set-client-handshake
33
+ - --skip-innodb-read-only-compressed
34
+ environment:
35
+ MARIADB_ROOT_PASSWORD: frappe
36
+ volumes:
37
+ - mariadb:/var/lib/mysql
38
+ healthcheck:
39
+ test: ["CMD", "mariadb-admin", "ping", "-h", "127.0.0.1", "-uroot", "-pfrappe"]
40
+ interval: 5s
41
+ timeout: 5s
42
+ retries: 10
43
+
44
+ redis-cache:
45
+ image: redis:alpine
46
+
47
+ redis-queue:
48
+ image: redis:alpine
49
+
50
+ volumes:
51
+ bench: {}
52
+ mariadb: {}
@@ -0,0 +1,8 @@
1
+ # Frappe backend (mpo init --frappe) — copy to .env. Keys listed as `public`
2
+ # in packages/config/config.json are baked into the client bundle at build
3
+ # time (VITE_MP_CONFIG); FRAPPE_ENABLED=1 mounts the FrappeProvider wired in
4
+ # apps/__NAME__/src/app.ts.
5
+ FRAPPE_ENABLED=1
6
+ # Bench URL the client talks to (docker/compose.frappe.yaml serves :8000).
7
+ # Falls back to BASE_URL, then http://localhost:8000.
8
+ FRAPPE_URL=http://localhost:8000
@@ -0,0 +1,3 @@
1
+ {
2
+ "public": ["FRAPPE_ENABLED", "FRAPPE_URL"]
3
+ }
@@ -0,0 +1,68 @@
1
+ ## GNOME desktop (GTK4 / GJS)
2
+
3
+ `mpo init --gnome` added a native GNOME target. Unlike the Tauri target,
4
+ which ships the web build inside a system webview, this renders **real
5
+ GTK4 widgets** through [react-gnome](https://github.com/react-gjs) — your
6
+ Tamagui components become `Gtk.Widget`s laid out by Yoga, with no DOM
7
+ anywhere in the process.
8
+
9
+ ### Prerequisites
10
+
11
+ GJS and the GTK stack are system packages, not npm ones:
12
+
13
+ ```bash
14
+ # Fedora / RHEL
15
+ sudo dnf install gjs gtk4-devel libadwaita-devel gobject-introspection-devel
16
+
17
+ # Debian / Ubuntu
18
+ sudo apt install gjs libgtk-4-dev libadwaita-1-dev libgirepository1.0-dev
19
+
20
+ # macOS (development only — GTK apps ship on Linux)
21
+ brew install gjs gtk4 libadwaita gobject-introspection
22
+ ```
23
+
24
+ On macOS, `gjs` needs the Homebrew libraries on its loader path:
25
+
26
+ ```bash
27
+ export DYLD_FALLBACK_LIBRARY_PATH="$(brew --prefix gjs)/lib:$(brew --prefix)/lib"
28
+ ```
29
+
30
+ ### Run it
31
+
32
+ ```bash
33
+ pnpm gnome # build and open an interactive Adw.ApplicationWindow
34
+ pnpm gnome:capture # build, render, save a PNG, quit — works headless
35
+ ```
36
+
37
+ `gnome:capture` writes `/tmp/__NAME__-gnome.png`. It polls for a render
38
+ node across frames rather than waiting a fixed delay, because offscreen
39
+ surfaces return null transiently after an invalidation — which is how you
40
+ get a blank screenshot that looks like a broken app.
41
+
42
+ ### How it is wired
43
+
44
+ | Path | Role |
45
+ | --- | --- |
46
+ | `apps/__NAME__/gnome/main.tsx` | Entry — Gtk init, Yoga, providers, window |
47
+ | `apps/__NAME__/gnome/polyfills.ts` | Browser-API globals. **Must be the first import** |
48
+ | `apps/__NAME__/gnome/tamagui-barrel.ts` | The granular `@tamagui/*` set this app reaches |
49
+ | `apps/__NAME__/gnome/shims/` | Barrels narrowed to their GJS-safe subgraph |
50
+ | `apps/__NAME__/vite.config.gnome.ts` | Separate library build → `dist/gnome/main.js` |
51
+
52
+ Two things surprise people:
53
+
54
+ **One's router is not mounted.** Its native path needs the React Native
55
+ fabric renderer, which does not exist here. `gnome/shims/one.ts` backs
56
+ `useRouter`, `usePathname` and `Link` with the framework's navigator seam
57
+ instead. With no `Adw.NavigationView` attached it degrades — pushes are
58
+ ignored, `Link` renders its children. Attach a navigation host and the
59
+ same calls become real push/pop with the back button, Escape and
60
+ swipe-back. Your screens do not change.
61
+
62
+ **Barrels are aliased to shims.** The full `@multiplatform.one/components`
63
+ barrel pulls expo-asset, flash-list and phosphor; `theme` pulls
64
+ react-dom/server and node:fs. Those fail at module init, before a widget
65
+ exists. The shims re-export only the GJS-safe subgraph, so adding a
66
+ component is usually one line in a shim plus one granular `@tamagui/*`
67
+ export in the barrel. A missing export is a build error naming the
68
+ symbol.
@@ -0,0 +1,165 @@
1
+ // GNOME (GTK4 / GJS / libadwaita) entry for __NAME__.
2
+ //
3
+ // Renders the home screen inside a real Adw.ApplicationWindow via
4
+ // react-gnome. Every box you see is an actual GTK widget laid out by Yoga
5
+ // — there is no webview and no DOM.
6
+ //
7
+ // pnpm gnome interactive window
8
+ // pnpm gnome:capture render, save a PNG, quit (works headless)
9
+ // gjs -m dist/gnome/main.js [outPath|interactive]
10
+ //
11
+ // ONE'S ROUTER IS NOT MOUNTED. Its native path goes through
12
+ // react-native-screens, which needs the RN fabric renderer that does not
13
+ // exist here. The index route's component is imported directly, and
14
+ // gnome/shims/one.ts backs the router API the screens call. Give this
15
+ // entry an Adw.NavigationView host when you want real push/pop.
16
+ //
17
+ // PROVIDERS. Only what the home screen consumes: Tamagui with the app's
18
+ // real config, and i18next with the app's real resources. Add others as
19
+ // screens need them — a provider whose graph reaches the DOM will fail at
20
+ // module init, not at render.
21
+
22
+ // Browser-API polyfills FIRST. Tamagui and RN-shaped dependencies probe
23
+ // window/document/matchMedia at module-init time, and imports are hoisted
24
+ // — first-import order is what guarantees the globals exist by then.
25
+ import "./polyfills";
26
+
27
+ import "gi://Gtk?version=4.0";
28
+ import "gi://Adw?version=1";
29
+ import Adw from "gi://Adw";
30
+ import GLib from "gi://GLib";
31
+ import Graphene from "gi://Graphene";
32
+ import Gtk from "gi://Gtk";
33
+ import {
34
+ AdwApplicationWindowElement,
35
+ AdwHeaderBar,
36
+ ToolbarView,
37
+ loadYogaForGjs,
38
+ render,
39
+ setYoga,
40
+ } from "@react-gnome/renderer";
41
+ import { HomeScreen } from "@features/__NAME__";
42
+ import { resources } from "@package/i18n/resources";
43
+ import i18n from "i18next";
44
+ import React from "react";
45
+ import { initReactI18next } from "react-i18next";
46
+ import { config } from "../config/tamagui.config";
47
+ import { TamaguiProvider } from "./tamagui-barrel";
48
+
49
+ declare const ARGV: string[];
50
+ declare const imports: { system: { exit(code: number): void } };
51
+
52
+ // initImmediate:false makes init synchronous — resources are bundled, so
53
+ // useTranslation must not suspend on first render.
54
+ i18n.use(initReactI18next).init({
55
+ compatibilityJSON: "v4",
56
+ defaultNS: "common",
57
+ ns: ["common"],
58
+ fallbackLng: "en",
59
+ lng: "en",
60
+ resources,
61
+ interpolation: { escapeValue: false },
62
+ initImmediate: false,
63
+ });
64
+
65
+ const arg0 = typeof ARGV !== "undefined" ? ARGV[0] : undefined;
66
+ const interactive = arg0 === "interactive" || arg0 === "i";
67
+ const outPath = !interactive && arg0 ? arg0 : "/tmp/__NAME__-gnome.png";
68
+
69
+ Gtk.init();
70
+ setYoga(await loadYogaForGjs());
71
+
72
+ function GnomeApp(): React.ReactElement {
73
+ return (
74
+ <TamaguiProvider config={config} defaultTheme="light">
75
+ <React.Suspense fallback={null}>
76
+ <HomeScreen />
77
+ </React.Suspense>
78
+ </TamaguiProvider>
79
+ );
80
+ }
81
+
82
+ const app = new Adw.Application({ applicationId: "one.multiplatform.__NAME_APPID__", flags: 0 });
83
+
84
+ app.connect("activate", () => {
85
+ const window = new AdwApplicationWindowElement({
86
+ title: "__NAME_PASCAL__",
87
+ application: app,
88
+ defaultWidth: 900,
89
+ defaultHeight: 640,
90
+ });
91
+
92
+ render(
93
+ <ToolbarView>
94
+ <AdwHeaderBar toolbarSlot="top" />
95
+ <GnomeApp />
96
+ </ToolbarView>,
97
+ window,
98
+ );
99
+ window.widget.present();
100
+
101
+ if (interactive) return;
102
+
103
+ // Headless capture. A render node is transiently null on offscreen
104
+ // surfaces right after an invalidation, so poll across frames rather
105
+ // than trusting one fixed delay — a single timeout yields a blank PNG
106
+ // often enough to be useless as evidence.
107
+ const dir = outPath.substring(0, outPath.lastIndexOf("/")) || ".";
108
+ GLib.mkdir_with_parents(dir, 0o755);
109
+ const wait = Number.parseInt(GLib.getenv("RGN_WAIT") ?? "800", 10);
110
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, wait, () => {
111
+ const widget = window.widget as Gtk.Widget & {
112
+ get_width(): number;
113
+ get_height(): number;
114
+ get_native(): Gtk.Native | null;
115
+ queue_draw(): void;
116
+ };
117
+ const WidgetPaintable = (
118
+ Gtk as unknown as { WidgetPaintable: { new: (w: Gtk.Widget) => unknown } }
119
+ ).WidgetPaintable;
120
+ let tries = 0;
121
+ const poll = (): boolean => {
122
+ try {
123
+ const width = widget.get_width();
124
+ const height = widget.get_height();
125
+ if (width > 0 && height > 0) {
126
+ const paintable = WidgetPaintable.new(widget) as {
127
+ snapshot(s: Gtk.Snapshot, w: number, h: number): void;
128
+ };
129
+ const snapshot = new Gtk.Snapshot();
130
+ paintable.snapshot(snapshot, width, height);
131
+ const node = snapshot.to_node();
132
+ if (node) {
133
+ const renderer = widget.get_native()?.get_renderer();
134
+ if (!renderer) throw new Error("no Gsk.Renderer attached to the window");
135
+ const viewport = new Graphene.Rect();
136
+ viewport.init(0, 0, width, height);
137
+ renderer.render_texture(node, viewport).save_to_png(outPath);
138
+ console.log(`captured ${width}x${height} -> ${outPath}`);
139
+ app.quit();
140
+ return GLib.SOURCE_REMOVE;
141
+ }
142
+ }
143
+ if (++tries >= 80) {
144
+ console.error(`snapshot produced no render node after ${tries} frames`);
145
+ app.quit();
146
+ imports.system.exit(4);
147
+ return GLib.SOURCE_REMOVE;
148
+ }
149
+ widget.queue_draw();
150
+ return GLib.SOURCE_CONTINUE;
151
+ } catch (e) {
152
+ console.error(`snapshot failed: ${String(e)}`);
153
+ app.quit();
154
+ imports.system.exit(5);
155
+ return GLib.SOURCE_REMOVE;
156
+ }
157
+ };
158
+ if (poll() !== GLib.SOURCE_REMOVE) {
159
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, poll);
160
+ }
161
+ return GLib.SOURCE_REMOVE;
162
+ });
163
+ });
164
+
165
+ app.run([]);
@@ -0,0 +1,10 @@
1
+ // Browser-API polyfills for the GNOME target. MUST be the first import of
2
+ // gnome/main.tsx: JS imports are hoisted, so first-import order is what
3
+ // guarantees window/document/matchMedia/URL exist before Tamagui and the
4
+ // RN-shaped dependencies probe for them at module-init time.
5
+ //
6
+ // The implementation is packaged — @react-gnome/react-native/polyfills
7
+ // installs each global conditionally, so anything you define before this
8
+ // import wins. Keep this file as the app's stable local import point.
9
+
10
+ import "@react-gnome/react-native/polyfills";