@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
package/README.md CHANGED
@@ -38,9 +38,31 @@ Want web-only? `mpo init --web my-app` scaffolds the Vite + React variant
38
38
  (`--app` is a back-compat alias; `create-multiplatform-app` without flags also
39
39
  produces it — pass `--universal` there for the full project).
40
40
 
41
- Options: `--universal`, `--web`/`--app`, `--skip-install`,
41
+ Options: `--universal`, `--web`/`--app`, `--skip-install`, `--skip-git`,
42
42
  `--mpo-version <range>` (defaults to `^<cli version>`), `-y/--yes`.
43
43
 
44
+ ### Pieces
45
+
46
+ Composable fragments overlaid onto the base template. Pass any of them to skip
47
+ the interactive prompt; they are recorded in `.mpo.json` and reproduced by
48
+ `mpo update`, so pick them at init time.
49
+
50
+ | Flag | Adds | Needs |
51
+ | ------------ | ---------------------------------------------------------------- | --------------------- |
52
+ | `--frappe` | Frappe backend wiring — env keys, provider, dev bench compose | Docker |
53
+ | `--keycloak` | Keycloak auth — env keys, provider, compose + realm import | Docker |
54
+ | `--gnome` | GNOME desktop: **real GTK4 widgets** via react-gnome, no webview | gjs, gtk4, libadwaita |
55
+ | `--tauri` | Desktop: the web build in a system webview | Rust toolchain |
56
+ | `--vscode` | VS Code extension — extension host + Tamagui webview | — |
57
+ | `--webext` | Browser extension — MV3 popup + background | — |
58
+
59
+ The two desktop pieces are different things, not alternatives to benchmark
60
+ against each other. `--tauri` wraps the same web output you already ship and
61
+ gives you Linux, macOS and Windows bundles. `--gnome` renders your Tamagui
62
+ components as native GTK widgets laid out by Yoga — a genuinely different
63
+ runtime, Linux-first, and the one to pick when the app should look and feel
64
+ like part of the desktop rather than like a web page in a frame.
65
+
44
66
  ## Other commands
45
67
 
46
68
  | Command | Purpose |
@@ -1,7 +1,7 @@
1
1
  import { generateVscodeConfig } from "../generateVscode.mjs";
2
2
  import { discoverE2EApp, runE2ESession } from "../commands/e2e.mjs";
3
3
  import { init, runModifyStep } from "../commands/init.mjs";
4
- import { initApp, readProvenance } from "../commands/initApp.mjs";
4
+ import { INIT_APP_PIECES, initApp, readProvenance } from "../commands/initApp.mjs";
5
5
  import { updateApp } from "../commands/updateApp.mjs";
6
6
  import { createRequire } from "node:module";
7
7
  import fs from "node:fs/promises";
@@ -471,7 +471,7 @@ async function getFrappeApps(root) {
471
471
  const program = new Command();
472
472
  program.name("mpo");
473
473
  program.version(JSON.parse(fsSync.readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../package.json"), "utf8"))?.version);
474
- program.command("init").option("--universal", "scaffold the universal template (web + iOS + Android; default)").option("--web", "scaffold the web-only template (Vite + React)").option("--app", "alias of --web (back-compat)").option("--skip-install", "skip pnpm install after scaffolding").option("--skip-git", "skip git init + scaffold commit").option("--mpo-version <range>", "semver range for @multiplatform.one/* (default: ^<cli version>)").option("-y, --yes", "non-interactive: accept defaults instead of prompting").option("--monorepo", "DEPRECATED: clone/modify the full multiplatform.one monorepo instead of scaffolding a consumer project").option("-c, --checkout <branch>", "branch, tag or commit to checkout (--monorepo only)", "main").option("-a, --apps <apps>", "comma-separated apps to include (--monorepo only)").option("-s, --services <services>", "comma-separated services to include (--monorepo only)").argument("[name]", "the name of the project").description("scaffold a new multiplatform project (universal web + native by default; --web for web-only; semver @multiplatform.one/* deps)").action(async (name, options) => {
474
+ program.command("init").option("--universal", "scaffold the universal template (web + iOS + Android; default)").option("--web", "scaffold the web-only template (Vite + React)").option("--app", "alias of --web (back-compat)").option("--frappe", "add the Frappe backend piece (env keys, provider, docker compose bench)").option("--keycloak", "add the Keycloak auth piece (env keys, provider, docker compose + realm)").option("--gnome", "add the GNOME desktop target piece (GTK4/GJS via react-gnome — native widgets, no webview)").option("--tauri", "add the desktop target piece (src-tauri skeleton, webview)").option("--vscode", "add the VS Code extension target piece").option("--webext", "add the browser extension target piece (MV3 popup + background)").option("--skip-install", "skip pnpm install after scaffolding").option("--skip-git", "skip git init + scaffold commit").option("--mpo-version <range>", "semver range for @multiplatform.one/* (default: ^<cli version>)").option("-y, --yes", "non-interactive: accept defaults instead of prompting").option("--monorepo", "DEPRECATED: clone/modify the full multiplatform.one monorepo instead of scaffolding a consumer project").option("-c, --checkout <branch>", "branch, tag or commit to checkout (--monorepo only)", "main").option("-a, --apps <apps>", "comma-separated apps to include (--monorepo only)").option("-s, --services <services>", "comma-separated services to include (--monorepo only)").argument("[name]", "the name of the project").description("scaffold a new multiplatform project (universal web + native by default; --web for web-only; semver @multiplatform.one/* deps)").action(async (name, options) => {
475
475
  if (options.monorepo) {
476
476
  console.warn("⚠️ `mpo init --monorepo` clones the whole multiplatform.one monorepo (legacy). New projects should use the default consumer scaffold instead.");
477
477
  if (await spawn("git", ["rev-parse", "--is-inside-work-tree"]).then(() => true, () => false)) throw new Error("mpo cannot be initialized inside a git repository");
@@ -485,12 +485,14 @@ program.command("init").option("--universal", "scaffold the universal template (
485
485
  if (options.apps || options.services) throw new Error("--apps/--services select monorepo pieces and require --monorepo (legacy clone mode)");
486
486
  const webOnly = Boolean(options.web || options.app);
487
487
  if (webOnly && options.universal) throw new Error("Pass either --web/--app or --universal, not both");
488
+ const pieces = INIT_APP_PIECES.filter((piece) => Boolean(options[piece]));
488
489
  await initApp(name, {
489
490
  skipInstall: Boolean(options.skipInstall),
490
491
  skipGit: Boolean(options.skipGit),
491
492
  version: options.mpoVersion,
492
493
  template: webOnly ? "app" : options.universal ? "universal" : void 0,
493
- yes: Boolean(options.yes)
494
+ yes: Boolean(options.yes),
495
+ pieces: pieces.length ? pieces : void 0
494
496
  });
495
497
  });
496
498
  program.command("update").option("-c, --checkout <branch>", "branch, tag or commit to merge from upstream (monorepo forks)", "main").option("-r, --remote <url>", "upstream remote URL (monorepo forks)", "https://gitlab.com/bitspur/frappe/multiplatform.one.git").option("--skip-install", "skip pnpm install after the update").option("--mpo-version <range>", "semver range for @multiplatform.one/* (default: ^<cli version>)").description("update a scaffolded project to the current template (three-way merge via .mpo.json provenance); monorepo forks fall back to the upstream merge flow").action(async (options) => {
@@ -5,6 +5,28 @@ import spawn from "nano-spawn";
5
5
  import inquirer from "inquirer";
6
6
 
7
7
  //#region src/commands/initApp.ts
8
+ /**
9
+ * Optional composable pieces overlaid onto the base template (additive
10
+ * fragments under templates/pieces/<piece>/ — the inverse of the old
11
+ * cookiecutter KEEP_SERVICES/KEEP_PLATFORMS removal hook). Keep sorted:
12
+ * pieces are recorded sorted in provenance and overlaid in this order.
13
+ */
14
+ const INIT_APP_PIECES = [
15
+ "frappe",
16
+ "gnome",
17
+ "keycloak",
18
+ "tauri",
19
+ "vscode",
20
+ "webext"
21
+ ];
22
+ const PIECE_DESCRIPTIONS = {
23
+ frappe: "Frappe backend wiring (env keys, provider, docker compose bench)",
24
+ gnome: "GNOME desktop target (GTK4/GJS via react-gnome — native widgets, no webview)",
25
+ keycloak: "Keycloak auth wiring (env keys, provider, docker compose + realm)",
26
+ tauri: "Desktop target (src-tauri skeleton, tauri scripts)",
27
+ vscode: "VS Code extension target (extension host + Tamagui webview)",
28
+ webext: "Browser extension target (MV3 popup + background)"
29
+ };
8
30
  function toPascalCase(name) {
9
31
  return name.split(/[-_\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
10
32
  }
@@ -29,7 +51,10 @@ function cliVersion() {
29
51
  }
30
52
  const PROVENANCE_FILE = ".mpo.json";
31
53
  function writeProvenance(targetDir, provenance) {
32
- writeFileSync(join(targetDir, PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`);
54
+ writeFileSync(join(targetDir, PROVENANCE_FILE), `${JSON.stringify({
55
+ ...provenance,
56
+ pieces: [...provenance.pieces].sort()
57
+ }, null, 2)}\n`);
33
58
  }
34
59
  function readProvenance(projectDir) {
35
60
  const file = join(projectDir, PROVENANCE_FILE);
@@ -41,7 +66,8 @@ function readProvenance(projectDir) {
41
66
  template: raw.template,
42
67
  cliVersion: raw.cliVersion,
43
68
  name: raw.name,
44
- mpoVersion: raw.mpoVersion ?? `^${raw.cliVersion}`
69
+ mpoVersion: raw.mpoVersion ?? `^${raw.cliVersion}`,
70
+ pieces: Array.isArray(raw.pieces) ? [...raw.pieces].sort() : []
45
71
  };
46
72
  } catch {
47
73
  return;
@@ -50,13 +76,26 @@ function readProvenance(projectDir) {
50
76
  function templatesRoot(template) {
51
77
  return resolve(dirname(fileURLToPath(import.meta.url)), "../../templates", template);
52
78
  }
79
+ function piecesRoot(piece) {
80
+ return resolve(dirname(fileURLToPath(import.meta.url)), "../../templates/pieces", piece);
81
+ }
82
+ /**
83
+ * Fragment source dirs for a piece + template: shared/ applies to every
84
+ * template, <template>/ only to that one. A piece supports a template when
85
+ * at least one of the two exists.
86
+ */
87
+ function pieceFragmentDirs(piece, template) {
88
+ return [join(piecesRoot(piece), "shared"), join(piecesRoot(piece), template)].filter((dir) => existsSync(dir));
89
+ }
53
90
  /**
54
- * npm always strips .gitignore / .npmrc from published tarballs, so the
55
- * template stores them without the leading dot; restore it at scaffold time.
91
+ * npm always strips .gitignore / .npmrc (and newer npms .env*) from published
92
+ * tarballs, so the template stores them without the leading dot; restore it
93
+ * at scaffold time.
56
94
  */
57
95
  const DOTFILE_NAMES = {
58
96
  gitignore: ".gitignore",
59
- npmrc: ".npmrc"
97
+ npmrc: ".npmrc",
98
+ "env.example": ".env.example"
60
99
  };
61
100
  function render(content, vars) {
62
101
  return content.replaceAll("__NAME_PASCAL__", vars.NAME_PASCAL).replaceAll("__NAME_APPID__", vars.NAME_APPID).replaceAll("__NAME__", vars.NAME).replaceAll("__MPO_VERSION__", vars.MPO_VERSION).replaceAll("__YEAR__", vars.YEAR);
@@ -72,7 +111,8 @@ const BINARY_EXTENSIONS = new Set([
72
111
  ".otf",
73
112
  ".png",
74
113
  ".jpg",
75
- ".ico"
114
+ ".ico",
115
+ ".icns"
76
116
  ]);
77
117
  function writeTree(srcDir, destDir, vars) {
78
118
  if (!existsSync(srcDir)) throw new Error(`Project template missing at ${srcDir}`);
@@ -91,6 +131,156 @@ function writeTree(srcDir, destDir, vars) {
91
131
  writeFileSync(to, render(readFileSync(from, "utf-8"), vars));
92
132
  }
93
133
  }
134
+ function isJsonObject(value) {
135
+ return typeof value === "object" && value !== null && !Array.isArray(value);
136
+ }
137
+ /**
138
+ * Deep-merge for JSON partials: objects merge recursively (fragment keys
139
+ * append after base keys, so e.g. package.json script order stays stable),
140
+ * arrays union (base order first — config.json public keys accumulate
141
+ * across pieces), scalars/type-mismatches take the fragment value.
142
+ */
143
+ function mergeJsonValues(base, fragment) {
144
+ if (Array.isArray(base) && Array.isArray(fragment)) {
145
+ const merged = [...base];
146
+ const seen = new Set(base.map((item) => JSON.stringify(item)));
147
+ for (const item of fragment) {
148
+ const key = JSON.stringify(item);
149
+ if (seen.has(key)) continue;
150
+ seen.add(key);
151
+ merged.push(item);
152
+ }
153
+ return merged;
154
+ }
155
+ if (isJsonObject(base) && isJsonObject(fragment)) {
156
+ const merged = { ...base };
157
+ for (const [key, value] of Object.entries(fragment)) merged[key] = key in merged ? mergeJsonValues(merged[key], value) : value;
158
+ return merged;
159
+ }
160
+ return fragment;
161
+ }
162
+ /** Dependency maps stay alphabetically sorted after fragment merges (the
163
+ * base templates are sorted; multiple pieces appending must not depend on
164
+ * overlay order for the final byte content). */
165
+ const SORTED_DEPENDENCY_FIELDS = [
166
+ "dependencies",
167
+ "devDependencies",
168
+ "peerDependencies",
169
+ "optionalDependencies"
170
+ ];
171
+ function sortDependencyFields(value) {
172
+ if (!isJsonObject(value)) return value;
173
+ const result = { ...value };
174
+ for (const field of SORTED_DEPENDENCY_FIELDS) {
175
+ const deps = result[field];
176
+ if (!isJsonObject(deps)) continue;
177
+ result[field] = Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
178
+ }
179
+ return result;
180
+ }
181
+ /**
182
+ * Apply a rendered `<target>.partial` fragment onto the target file:
183
+ * - `.json` targets deep-merge (see mergeJsonValues); package.json-style
184
+ * dependency maps are re-sorted for deterministic output
185
+ * - everything else appends as a text block separated by a blank line
186
+ * Missing targets are created from the fragment alone.
187
+ */
188
+ function applyPartial(targetPath, renderedFragment) {
189
+ if (targetPath.endsWith(".json")) {
190
+ const fragment = JSON.parse(renderedFragment);
191
+ const base = existsSync(targetPath) ? JSON.parse(readFileSync(targetPath, "utf-8")) : void 0;
192
+ const merged = sortDependencyFields(base === void 0 ? fragment : mergeJsonValues(base, fragment));
193
+ writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
194
+ return;
195
+ }
196
+ const block = `${renderedFragment.replace(/^\n+/u, "").replace(/\n+$/u, "")}\n`;
197
+ if (!existsSync(targetPath)) {
198
+ writeFileSync(targetPath, block);
199
+ return;
200
+ }
201
+ writeFileSync(targetPath, `${readFileSync(targetPath, "utf-8").replace(/\n+$/u, "")}\n\n${block}`);
202
+ }
203
+ const PARTIAL_SUFFIX = ".partial";
204
+ /**
205
+ * Overlay a piece fragment tree onto the scaffold: regular files render and
206
+ * overwrite, `<target>.partial` files merge into (or create) their target
207
+ * (deep-merge for .json, append for text — see applyPartial).
208
+ */
209
+ function overlayTree(srcDir, destDir, vars) {
210
+ mkdirSync(destDir, { recursive: true });
211
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
212
+ const from = join(srcDir, entry.name);
213
+ if (entry.isDirectory()) {
214
+ overlayTree(from, join(destDir, render(DOTFILE_NAMES[entry.name] ?? entry.name, vars)), vars);
215
+ continue;
216
+ }
217
+ if (entry.name.endsWith(PARTIAL_SUFFIX)) {
218
+ const targetName = entry.name.slice(0, -8);
219
+ applyPartial(join(destDir, render(DOTFILE_NAMES[targetName] ?? targetName, vars)), render(readFileSync(from, "utf-8"), vars));
220
+ continue;
221
+ }
222
+ const to = join(destDir, render(DOTFILE_NAMES[entry.name] ?? entry.name, vars));
223
+ if (BINARY_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
224
+ copyFileSync(from, to);
225
+ continue;
226
+ }
227
+ writeFileSync(to, render(readFileSync(from, "utf-8"), vars));
228
+ }
229
+ }
230
+ /**
231
+ * Validate + canonicalize a piece selection for a template: unknown names
232
+ * throw, duplicates collapse, order is always sorted (provenance and overlay
233
+ * order must be deterministic), and every piece must ship a fragment for the
234
+ * chosen template.
235
+ */
236
+ function normalizePieces(pieces, template) {
237
+ const known = new Set(INIT_APP_PIECES);
238
+ const normalized = [...new Set(pieces)].sort();
239
+ for (const piece of normalized) {
240
+ if (!known.has(piece)) throw new Error(`Unknown piece "${piece}". Available pieces: ${INIT_APP_PIECES.join(", ")}`);
241
+ if (pieceFragmentDirs(piece, template).length === 0) throw new Error(`Piece "${piece}" is not available for the ${template} template (pieces currently target the universal template)`);
242
+ }
243
+ return normalized;
244
+ }
245
+ /** Pieces that can be offered for a template (fragment exists for it). */
246
+ function availablePieces(template) {
247
+ return INIT_APP_PIECES.filter((piece) => pieceFragmentDirs(piece, template).length > 0);
248
+ }
249
+ async function resolvePieces(options, template) {
250
+ if (options.pieces) return normalizePieces(options.pieces, template);
251
+ if (options.yes || !process.stdout.isTTY) return [];
252
+ const choices = availablePieces(template);
253
+ if (choices.length === 0) return [];
254
+ return normalizePieces((await inquirer.prompt([{
255
+ message: "Add optional pieces? (space to toggle, enter to confirm)",
256
+ name: "pieces",
257
+ type: "checkbox",
258
+ choices: choices.map((piece) => ({
259
+ name: `${piece} — ${PIECE_DESCRIPTIONS[piece]}`,
260
+ value: piece,
261
+ checked: false
262
+ }))
263
+ }])).pieces, template);
264
+ }
265
+ /**
266
+ * Generate apps/<name>/src/app.ts for the selected backend pieces instead of
267
+ * storing 2^n template variants: each selected provider adds one createApp
268
+ * key (frappe/keycloak read their runtime settings from the packages/config
269
+ * public env keys their fragments register). Only called when at least one
270
+ * provider piece is selected — the base template file stays otherwise.
271
+ */
272
+ function composeAppTs(pieces) {
273
+ const frappe = pieces.includes("frappe");
274
+ const keycloak = pieces.includes("keycloak");
275
+ const lines = ["import { createApp } from \"@multiplatform.one/core\";"];
276
+ if (frappe || keycloak) lines.push("import { config } from \"@multiplatform.one/platform\";");
277
+ lines.push("import { themeConfig } from \"@package/themes\";");
278
+ lines.push("", "/**", " * App-wide provider stack. Providers below were enabled by the pieces", " * selected at `mpo init` time — see CreateAppConfig in", " * @multiplatform.one/core for everything else that can be wired here", " * (tanstack, web3, custom providers).", " */", "export const { AppProvider, useAppConfig, withApp } = createApp({", " theme: themeConfig,");
279
+ if (frappe) lines.push(" // Frappe backend (mpo init --frappe): FrappeProvider mounts when", " // FRAPPE_ENABLED=1 (a packages/config public env key, loaded from", " // .env — see .env.example). FRAPPE_URL overrides the bench URL", " // (default: BASE_URL, then http://localhost:8000);", " // docker/compose.frappe.yaml runs a dev bench.", " frappe: { baseURL: config.get(\"FRAPPE_URL\") },");
280
+ if (keycloak) lines.push(" // Keycloak auth (mpo init --keycloak): KeycloakProvider mounts when", " // KEYCLOAK_ENABLED=1. Realm/client settings come from the KEYCLOAK_*", " // env keys (packages/config, .env.example);", " // docker/compose.keycloak.yaml runs a dev server with an imported", " // realm.", " keycloak: { baseUrl: config.get(\"KEYCLOAK_BASE_URL\") },");
281
+ lines.push("});", "");
282
+ return lines.join("\n");
283
+ }
94
284
  async function resolveTemplate(options) {
95
285
  if (options.template) return options.template;
96
286
  if (options.yes || !process.stdout.isTTY) return "universal";
@@ -124,6 +314,7 @@ async function initApp(nameArg, options = {}) {
124
314
  }])).name;
125
315
  const name = validateName(projectName);
126
316
  const template = await resolveTemplate(options);
317
+ const pieces = await resolvePieces(options, template);
127
318
  const targetDir = resolve(name);
128
319
  if (existsSync(targetDir)) {
129
320
  if (readdirSync(targetDir).length > 0) throw new Error(`Directory "${targetDir}" already exists and is not empty`);
@@ -143,13 +334,17 @@ async function initApp(nameArg, options = {}) {
143
334
  console.log(` packages/i18n`);
144
335
  console.log(` packages/themes`);
145
336
  }
337
+ if (pieces.length) console.log(` pieces: ${pieces.join(", ")}`);
146
338
  console.log(` @multiplatform.one/* → ${vars.MPO_VERSION}\n`);
147
339
  writeTree(templatesRoot(template), targetDir, vars);
340
+ for (const piece of pieces) for (const fragmentDir of pieceFragmentDirs(piece, template)) overlayTree(fragmentDir, targetDir, vars);
341
+ if (pieces.includes("frappe") || pieces.includes("keycloak")) writeFileSync(join(targetDir, "apps", name, "src/app.ts"), composeAppTs(pieces));
148
342
  writeProvenance(targetDir, {
149
343
  template,
150
344
  cliVersion: cliVersion() ?? "0.0.0",
151
345
  name,
152
- mpoVersion: vars.MPO_VERSION
346
+ mpoVersion: vars.MPO_VERSION,
347
+ pieces
153
348
  });
154
349
  let scaffoldCommitted = false;
155
350
  if (!options.skipGit) {
@@ -194,9 +389,15 @@ async function initApp(nameArg, options = {}) {
194
389
  console.log(" pnpm ios # iOS (requires pnpm dev in another terminal)");
195
390
  console.log(" pnpm android # Android");
196
391
  }
392
+ if (pieces.includes("frappe")) console.log(" docker compose -f docker/compose.frappe.yaml up -d # dev bench (see README)");
393
+ if (pieces.includes("keycloak")) console.log(" docker compose -f docker/compose.keycloak.yaml up -d # dev keycloak");
394
+ if (pieces.includes("gnome")) console.log(" pnpm gnome # GTK4 desktop app (requires gjs + gtk4 — see README)");
395
+ if (pieces.includes("tauri")) console.log(" pnpm tauri:dev # desktop app (requires the Rust toolchain)");
396
+ if (pieces.includes("vscode")) console.log(" pnpm build:vscode # stage the VS Code extension into apps dist-vscode/");
397
+ if (pieces.includes("webext")) console.log(" pnpm build:webext # stage the browser extension into apps dist-webext/");
197
398
  console.log("\nThis project consumes published @multiplatform.one/* packages.");
198
399
  console.log("It is not a fork of the multiplatform.one monorepo.");
199
400
  }
200
401
 
201
402
  //#endregion
202
- export { PROVENANCE_FILE, cliVersion, initApp, readProvenance, writeProvenance };
403
+ export { INIT_APP_PIECES, PROVENANCE_FILE, availablePieces, cliVersion, composeAppTs, initApp, mergeJsonValues, normalizePieces, readProvenance, writeProvenance };
@@ -1,7 +1,7 @@
1
1
  import { generateVscodeConfig } from "../generateVscode.mjs";
2
2
  import { cliVersion, initApp, readProvenance, writeProvenance } from "./initApp.mjs";
3
- import { join } from "node:path";
4
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import spawn from "nano-spawn";
6
6
  import { tmpdir } from "node:os";
7
7
 
@@ -69,7 +69,7 @@ async function commitTreeFromDir(projectDir, dir, message, parent) {
69
69
  /** Scaffold a project with a specific published CLI version into tmp and
70
70
  * return the generated project dir. Falls back to the local generator when
71
71
  * the requested version matches the running CLI (or dlx fails). */
72
- async function scaffoldBaseline(version, name, template, mpoVersion) {
72
+ async function scaffoldBaseline(version, name, template, mpoVersion, pieces) {
73
73
  const parent = mkdtempSync(join(tmpdir(), "mpo-update-"));
74
74
  const cleanup = () => rmSync(parent, {
75
75
  recursive: true,
@@ -78,7 +78,11 @@ async function scaffoldBaseline(version, name, template, mpoVersion) {
78
78
  const current = cliVersion();
79
79
  const templateFlag = template === "app" ? ["--web"] : ["--universal"];
80
80
  const [major = 0, minor = 0] = version.split(".").map(Number);
81
- if (!(major > 6 || major === 6 && minor >= 3)) console.warn(`⚠️ @multiplatform.one/cli@${version} predates the consumer scaffolder (6.3.0); using the current CLI's template as the merge base.`);
81
+ const scaffoldCapable = major > 6 || major === 6 && minor >= 3;
82
+ const piecesCapable = major > 6 || major === 6 && minor >= 5;
83
+ const pieceFlags = piecesCapable ? pieces.map((piece) => `--${piece}`) : [];
84
+ if (pieces.length && scaffoldCapable && !piecesCapable && version !== current) console.warn(`⚠️ @multiplatform.one/cli@${version} predates composable pieces (6.5.0); the merge base is scaffolded without them.`);
85
+ if (!scaffoldCapable) console.warn(`⚠️ @multiplatform.one/cli@${version} predates the consumer scaffolder (6.3.0); using the current CLI's template as the merge base.`);
82
86
  else if (version !== current) {
83
87
  for (let attempt = 1; attempt <= 2; attempt++) try {
84
88
  await spawn("pnpm", [
@@ -92,7 +96,8 @@ async function scaffoldBaseline(version, name, template, mpoVersion) {
92
96
  "--skip-install",
93
97
  "--mpo-version",
94
98
  mpoVersion,
95
- ...templateFlag
99
+ ...templateFlag,
100
+ ...pieceFlags
96
101
  ], {
97
102
  cwd: parent,
98
103
  stdio: "inherit"
@@ -121,7 +126,8 @@ async function scaffoldBaseline(version, name, template, mpoVersion) {
121
126
  skipGit: true,
122
127
  yes: true,
123
128
  template,
124
- version: mpoVersion
129
+ version: mpoVersion,
130
+ pieces
125
131
  });
126
132
  } finally {
127
133
  process.chdir(previousCwd);
@@ -175,6 +181,144 @@ async function pinPathsToHead(projectDir, tree, headCommit, pins) {
175
181
  rmSync(indexFile, { force: true });
176
182
  return pinned;
177
183
  }
184
+ const UNRESOLVED = Symbol("unresolved");
185
+ /** Deleted-on-one-side sentinel (distinct from a key that never existed). */
186
+ const ABSENT = Symbol("absent");
187
+ const DEP_MAP_KEYS = new Set([
188
+ "dependencies",
189
+ "devDependencies",
190
+ "peerDependencies",
191
+ "optionalDependencies",
192
+ "overrides",
193
+ "resolutions"
194
+ ]);
195
+ /** Minimum concrete version of a simple range (`^x.y.z`, `~x.y.z`,
196
+ * `x.y.z`, optional prerelease). Anything fancier is not auto-resolved. */
197
+ function rangeMinimum(range) {
198
+ if (typeof range !== "string") return void 0;
199
+ const match = range.match(/^[\^~]?(\d+)\.(\d+)\.(\d+)(?:-[\w.]+)?$/);
200
+ if (!match) return void 0;
201
+ return [
202
+ Number(match[1]),
203
+ Number(match[2]),
204
+ Number(match[3])
205
+ ];
206
+ }
207
+ function isPlainObject(value) {
208
+ return typeof value === "object" && value !== null && !Array.isArray(value);
209
+ }
210
+ function jsonEqual(a, b) {
211
+ return JSON.stringify(a) === JSON.stringify(b);
212
+ }
213
+ function mergeNode(base, ours, theirs, inDepMap) {
214
+ if (jsonEqual(ours, theirs)) return ours === void 0 ? ABSENT : ours;
215
+ if (jsonEqual(theirs, base)) return ours === void 0 ? ABSENT : ours;
216
+ if (jsonEqual(ours, base)) return theirs === void 0 ? ABSENT : theirs;
217
+ if (isPlainObject(ours) && isPlainObject(theirs)) {
218
+ const baseObject = isPlainObject(base) ? base : {};
219
+ const merged = {};
220
+ const keys = [...Object.keys(ours), ...Object.keys(theirs).filter((key) => !(key in ours))];
221
+ for (const key of keys) {
222
+ const child = mergeNode(baseObject[key], ours[key], theirs[key], inDepMap || DEP_MAP_KEYS.has(key));
223
+ if (child === UNRESOLVED) return UNRESOLVED;
224
+ if (child !== ABSENT) merged[key] = child;
225
+ }
226
+ return merged;
227
+ }
228
+ if (inDepMap) {
229
+ const oursMin = rangeMinimum(ours);
230
+ const theirsMin = rangeMinimum(theirs);
231
+ if (oursMin && theirsMin) {
232
+ for (let i = 0; i < 3; i++) {
233
+ if (oursMin[i] > theirsMin[i]) return ours;
234
+ if (oursMin[i] < theirsMin[i]) return theirs;
235
+ }
236
+ return theirs;
237
+ }
238
+ }
239
+ return UNRESOLVED;
240
+ }
241
+ /** Structural three-way merge of package.json content. Returns the merged
242
+ * object, or undefined when a divergence can't be auto-resolved (the file
243
+ * then keeps its textual conflict markers for manual resolution). */
244
+ function threeWayMergePackageJson(base, ours, theirs) {
245
+ let baseJson;
246
+ let oursJson;
247
+ let theirsJson;
248
+ try {
249
+ baseJson = base === void 0 ? {} : JSON.parse(base);
250
+ oursJson = JSON.parse(ours);
251
+ theirsJson = JSON.parse(theirs);
252
+ } catch {
253
+ return;
254
+ }
255
+ if (!isPlainObject(oursJson) || !isPlainObject(theirsJson)) return void 0;
256
+ const merged = mergeNode(baseJson, oursJson, theirsJson, false);
257
+ if (merged === UNRESOLVED || merged === ABSENT || !isPlainObject(merged)) return void 0;
258
+ return merged;
259
+ }
260
+ /** Replace auto-resolvable conflicted package.json files in `mergedTree`
261
+ * with their structural merge. Pure index plumbing, worktree untouched. */
262
+ async function autoResolveDependencyConflicts(projectDir, mergedTree, conflicts, baseCommit, headCommit, nextCommit) {
263
+ const candidates = conflicts.filter((file) => basename(file) === "package.json");
264
+ if (!candidates.length) return {
265
+ tree: mergedTree,
266
+ conflicts,
267
+ resolved: []
268
+ };
269
+ const scratch = mkdtempSync(join(tmpdir(), "mpo-resolve-"));
270
+ const resolved = [];
271
+ try {
272
+ for (const file of candidates) {
273
+ const show = (commit) => gitOut(projectDir, ["show", `${commit}:${file}`]).catch(() => void 0);
274
+ const [base, ours, theirs] = await Promise.all([
275
+ show(baseCommit),
276
+ show(headCommit),
277
+ show(nextCommit)
278
+ ]);
279
+ if (ours === void 0 || theirs === void 0) continue;
280
+ const merged = threeWayMergePackageJson(base, ours, theirs);
281
+ if (!merged) continue;
282
+ const blobFile = join(scratch, "blob.json");
283
+ writeFileSync(blobFile, `${JSON.stringify(merged, null, 2)}\n`);
284
+ const oid = await gitOut(projectDir, [
285
+ "hash-object",
286
+ "-w",
287
+ "--",
288
+ blobFile
289
+ ]);
290
+ resolved.push({
291
+ path: file,
292
+ oid
293
+ });
294
+ }
295
+ if (!resolved.length) return {
296
+ tree: mergedTree,
297
+ conflicts,
298
+ resolved: []
299
+ };
300
+ const env = { GIT_INDEX_FILE: join(scratch, "index") };
301
+ await git(projectDir, ["read-tree", mergedTree], env);
302
+ for (const entry of resolved) await git(projectDir, [
303
+ "update-index",
304
+ "--add",
305
+ "--cacheinfo",
306
+ `100644,${entry.oid},${entry.path}`
307
+ ], env);
308
+ const tree = await gitOut(projectDir, ["write-tree"], env);
309
+ const resolvedPaths = new Set(resolved.map((entry) => entry.path));
310
+ return {
311
+ tree,
312
+ conflicts: conflicts.filter((file) => !resolvedPaths.has(file)),
313
+ resolved: [...resolvedPaths]
314
+ };
315
+ } finally {
316
+ rmSync(scratch, {
317
+ recursive: true,
318
+ force: true
319
+ });
320
+ }
321
+ }
178
322
  async function updateApp(options = {}) {
179
323
  const projectDir = await gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
180
324
  throw new Error("mpo update must run inside a git repository");
@@ -195,11 +339,12 @@ async function updateApp(options = {}) {
195
339
  const targetMpoRange = options.version ? options.version.startsWith("^") || options.version.startsWith("~") ? options.version : `^${options.version}` : `^${currentVersion}`;
196
340
  console.log(`\nmpo update: ${provenance.cliVersion} → ${currentVersion}`);
197
341
  console.log(` template: ${provenance.template}`);
342
+ if (provenance.pieces.length) console.log(` pieces: ${provenance.pieces.join(", ")}`);
198
343
  console.log(` project: ${provenance.name}\n`);
199
344
  console.log(`Scaffolding merge base (cli@${provenance.cliVersion})...`);
200
- const base = await scaffoldBaseline(provenance.cliVersion, provenance.name, provenance.template, provenance.mpoVersion);
345
+ const base = await scaffoldBaseline(provenance.cliVersion, provenance.name, provenance.template, provenance.mpoVersion, provenance.pieces);
201
346
  console.log(`Scaffolding update target (cli@${currentVersion})...`);
202
- const next = await scaffoldBaseline(currentVersion, provenance.name, provenance.template, targetMpoRange);
347
+ const next = await scaffoldBaseline(currentVersion, provenance.name, provenance.template, targetMpoRange, provenance.pieces);
203
348
  rmSync(join(base.dir, ".mpo.json"), { force: true });
204
349
  rmSync(join(next.dir, ".mpo.json"), { force: true });
205
350
  try {
@@ -228,7 +373,8 @@ async function updateApp(options = {}) {
228
373
  template: provenance.template,
229
374
  cliVersion: currentVersion,
230
375
  name: provenance.name,
231
- mpoVersion: targetMpoRange
376
+ mpoVersion: targetMpoRange,
377
+ pieces: provenance.pieces
232
378
  };
233
379
  const pins = ["pnpm-lock.yaml"];
234
380
  const updateignore = join(projectDir, ".updateignore");
@@ -238,6 +384,10 @@ async function updateApp(options = {}) {
238
384
  }
239
385
  mergedTree = await pinPathsToHead(projectDir, mergedTree, head, pins);
240
386
  conflicts = conflicts.filter((file) => !pins.some((pin) => file === pin || file.startsWith(`${pin}/`)));
387
+ const autoResolved = await autoResolveDependencyConflicts(projectDir, mergedTree, conflicts, baseCommit, head, nextCommit);
388
+ mergedTree = autoResolved.tree;
389
+ conflicts = autoResolved.conflicts;
390
+ for (const file of autoResolved.resolved) console.log(` auto-resolved: ${file} (dependency ranges)`);
241
391
  const headTree = await gitOut(projectDir, ["rev-parse", `${head}^{tree}`]);
242
392
  if (!conflicts.length && mergedTree === headTree) {
243
393
  if (provenance.cliVersion !== finalProvenance.cliVersion || provenance.mpoVersion !== finalProvenance.mpoVersion) {
@@ -257,6 +407,7 @@ async function updateApp(options = {}) {
257
407
  } else console.log("\n✅ already up to date");
258
408
  return;
259
409
  }
410
+ let cleanMergeMessage;
260
411
  if (!conflicts.length) {
261
412
  const message = `chore: mpo update ${provenance.cliVersion} → ${currentVersion}`;
262
413
  await git(projectDir, [
@@ -294,12 +445,7 @@ async function updateApp(options = {}) {
294
445
  "--no-edit",
295
446
  "--quiet"
296
447
  ]);
297
- const amended = await gitOut(projectDir, [
298
- "rev-parse",
299
- "--short",
300
- "HEAD"
301
- ]);
302
- console.log(`\n✅ merged cleanly → ${amended} ("${message}")`);
448
+ cleanMergeMessage = message;
303
449
  } else {
304
450
  await git(projectDir, [
305
451
  "read-tree",
@@ -340,6 +486,14 @@ async function updateApp(options = {}) {
340
486
  }
341
487
  }
342
488
  }
489
+ if (cleanMergeMessage) {
490
+ const finalHash = await gitOut(projectDir, [
491
+ "rev-parse",
492
+ "--short",
493
+ "HEAD"
494
+ ]);
495
+ console.log(`\n✅ merged cleanly → ${finalHash} ("${cleanMergeMessage}")`);
496
+ }
343
497
  } finally {
344
498
  base.cleanup();
345
499
  next.cleanup();
@@ -347,4 +501,4 @@ async function updateApp(options = {}) {
347
501
  }
348
502
 
349
503
  //#endregion
350
- export { updateApp };
504
+ export { threeWayMergePackageJson, updateApp };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/cli",
3
- "version": "6.4.3",
3
+ "version": "6.7.0",
4
4
  "description": "multiplatform.one cli — mpo init / create-multiplatform-app",
5
5
  "keywords": [
6
6
  "create-multiplatform-app",
@@ -61,7 +61,7 @@
61
61
  "nano-spawn": "^2.1.0",
62
62
  "yaml": "^2.8.3",
63
63
  "yocto-spinner": "^1.1.0",
64
- "@multiplatform.one/utils": "6.4.3"
64
+ "@multiplatform.one/utils": "6.7.0"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@types/inquirer": "^9.0.9",
@@ -75,6 +75,6 @@
75
75
  "scripts": {
76
76
  "build": "rm -rf lib types 2>/dev/null && tsc -p tsconfig.build.json && tsdown",
77
77
  "test": "vitest run",
78
- "typecheck": "tsc --noEmit"
78
+ "typecheck": "tsgo --noEmit"
79
79
  }
80
80
  }