@multiplatform.one/cli 7.11.0 → 7.16.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 (163) hide show
  1. package/README.md +121 -111
  2. package/bin/createMultiplatformApp.mjs +39 -37
  3. package/lib/bin/multiplatformOne.mjs +65 -75
  4. package/lib/commands/addApp.mjs +78 -0
  5. package/lib/commands/adoptApp.mjs +11 -7
  6. package/lib/commands/doctypeTypes.mjs +148 -0
  7. package/lib/commands/e2e.mjs +2 -2
  8. package/lib/commands/init.mjs +142 -459
  9. package/lib/commands/merge.mjs +359 -0
  10. package/lib/commands/pieces.mjs +236 -0
  11. package/lib/commands/provenance.mjs +108 -0
  12. package/lib/commands/scaffold.mjs +105 -0
  13. package/lib/commands/updateApp.mjs +211 -528
  14. package/lib/source.json +5 -0
  15. package/lib/source.mjs +63 -0
  16. package/package.json +6 -6
  17. package/scripts/frappe-bootstrap.sh +29 -3
  18. package/scripts/frappe-dev.sh +1 -2
  19. package/scripts/frappe-helpers.sh +50 -2
  20. package/scripts/frappe.sh +5 -0
  21. package/scripts/scaffold.sh +937 -0
  22. package/scripts/stamp-source.mjs +51 -0
  23. package/scripts/stamp-source.spec.ts +86 -0
  24. package/src/bin/multiplatformOne.ts +116 -226
  25. package/src/commands/addApp.ts +102 -0
  26. package/src/commands/adoptApp.spec.ts +5 -1
  27. package/src/commands/adoptApp.ts +15 -10
  28. package/src/commands/doctypeTypes.spec.ts +155 -0
  29. package/src/commands/doctypeTypes.ts +214 -0
  30. package/src/commands/init.spec.ts +66 -0
  31. package/src/commands/init.ts +201 -643
  32. package/src/commands/merge.spec.ts +81 -0
  33. package/src/commands/merge.ts +411 -0
  34. package/src/commands/pieces.spec.ts +62 -0
  35. package/src/commands/pieces.ts +340 -0
  36. package/src/commands/provenance.spec.ts +92 -0
  37. package/src/commands/provenance.ts +169 -0
  38. package/src/commands/scaffold.ts +131 -0
  39. package/src/commands/updateApp.spec.ts +540 -331
  40. package/src/commands/updateApp.ts +255 -668
  41. package/src/source.spec.ts +45 -0
  42. package/src/source.ts +88 -0
  43. package/templates/AGENTS.md +161 -0
  44. package/templates/README.md +62 -0
  45. package/templates/pieces/frappe/universal/README.md.partial +7 -32
  46. package/templates/pieces/frappe/universal/env.example.partial +3 -3
  47. package/templates/pieces/frappe-bench/universal/README.md.partial +28 -0
  48. package/templates/pieces/{frappe → frappe-bench}/universal/docker/compose.frappe.yaml +2 -2
  49. package/templates/pieces/tauri/universal/README.md.partial +7 -5
  50. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/dev.rs +463 -0
  51. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +6 -0
  52. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +2 -1
  53. package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +1 -0
  54. package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +6 -2
  55. package/types/bin/multiplatformOne.d.ts.map +1 -1
  56. package/types/commands/addApp.d.ts +20 -0
  57. package/types/commands/addApp.d.ts.map +1 -0
  58. package/types/commands/adoptApp.d.ts +4 -3
  59. package/types/commands/adoptApp.d.ts.map +1 -1
  60. package/types/commands/doctypeTypes.d.ts +40 -0
  61. package/types/commands/doctypeTypes.d.ts.map +1 -0
  62. package/types/commands/init.d.ts +30 -13
  63. package/types/commands/init.d.ts.map +1 -1
  64. package/types/commands/merge.d.ts +54 -0
  65. package/types/commands/merge.d.ts.map +1 -0
  66. package/types/commands/pieces.d.ts +54 -0
  67. package/types/commands/pieces.d.ts.map +1 -0
  68. package/types/commands/provenance.d.ts +52 -0
  69. package/types/commands/provenance.d.ts.map +1 -0
  70. package/types/commands/scaffold.d.ts +43 -0
  71. package/types/commands/scaffold.d.ts.map +1 -0
  72. package/types/commands/updateApp.d.ts +19 -27
  73. package/types/commands/updateApp.d.ts.map +1 -1
  74. package/types/source.d.ts +35 -0
  75. package/types/source.d.ts.map +1 -0
  76. package/lib/commands/initApp.mjs +0 -431
  77. package/lib/generateVscode.mjs +0 -184
  78. package/scripts/clone.sh +0 -17
  79. package/scripts/update.sh +0 -46
  80. package/src/commands/initApp.spec.ts +0 -720
  81. package/src/commands/initApp.ts +0 -706
  82. package/src/generateVscode.ts +0 -238
  83. package/templates/app/AGENTS.md +0 -142
  84. package/templates/app/README.md +0 -62
  85. package/templates/app/apps/__NAME__/index.html +0 -36
  86. package/templates/app/apps/__NAME__/package.json +0 -31
  87. package/templates/app/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  88. package/templates/app/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  89. package/templates/app/apps/__NAME__/src/App.tsx +0 -9
  90. package/templates/app/apps/__NAME__/src/main.tsx +0 -14
  91. package/templates/app/apps/__NAME__/tsconfig.json +0 -8
  92. package/templates/app/apps/__NAME__/vite.config.ts +0 -17
  93. package/templates/app/features/__NAME__/package.json +0 -34
  94. package/templates/app/features/__NAME__/src/HomeScreen.tsx +0 -70
  95. package/templates/app/features/__NAME__/src/index.ts +0 -1
  96. package/templates/app/features/__NAME__/tsconfig.json +0 -5
  97. package/templates/app/gitignore +0 -9
  98. package/templates/app/npmrc +0 -2
  99. package/templates/app/oxlintrc.json +0 -39
  100. package/templates/app/package.json +0 -38
  101. package/templates/app/packages/config/config.json +0 -4
  102. package/templates/app/packages/config/index.ts +0 -5
  103. package/templates/app/packages/config/package.json +0 -17
  104. package/templates/app/packages/config/tsconfig.json +0 -5
  105. package/templates/app/pnpm-workspace.yaml +0 -4
  106. package/templates/app/tsconfig.base.json +0 -16
  107. package/templates/universal/AGENTS.md +0 -142
  108. package/templates/universal/README.md +0 -97
  109. package/templates/universal/apps/__NAME__/app.config.js +0 -31
  110. package/templates/universal/apps/__NAME__/config/tamagui.config.ts +0 -4
  111. package/templates/universal/apps/__NAME__/package.json +0 -62
  112. package/templates/universal/apps/__NAME__/polyfills.native.ts +0 -23
  113. package/templates/universal/apps/__NAME__/polyfills.ts +0 -3
  114. package/templates/universal/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  115. package/templates/universal/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  116. package/templates/universal/apps/__NAME__/routes/_layout.tsx +0 -22
  117. package/templates/universal/apps/__NAME__/routes/_layout.web.tsx +0 -21
  118. package/templates/universal/apps/__NAME__/routes/about.tsx +0 -3
  119. package/templates/universal/apps/__NAME__/routes/index.tsx +0 -3
  120. package/templates/universal/apps/__NAME__/src/app.ts +0 -10
  121. package/templates/universal/apps/__NAME__/tamagui.css +0 -3
  122. package/templates/universal/apps/__NAME__/tsconfig.json +0 -8
  123. package/templates/universal/apps/__NAME__/types.d.ts +0 -9
  124. package/templates/universal/apps/__NAME__/vite.config.ts +0 -20
  125. package/templates/universal/features/__NAME__/package.json +0 -35
  126. package/templates/universal/features/__NAME__/src/AboutScreen.tsx +0 -27
  127. package/templates/universal/features/__NAME__/src/AppLayout.tsx +0 -50
  128. package/templates/universal/features/__NAME__/src/ContactForm.spec.tsx +0 -22
  129. package/templates/universal/features/__NAME__/src/ContactForm.tsx +0 -55
  130. package/templates/universal/features/__NAME__/src/HomeScreen.tsx +0 -40
  131. package/templates/universal/features/__NAME__/src/index.ts +0 -4
  132. package/templates/universal/features/__NAME__/src/screens.spec.ts +0 -12
  133. package/templates/universal/features/__NAME__/tsconfig.json +0 -5
  134. package/templates/universal/features/__NAME__/vitest.config.mjs +0 -7
  135. package/templates/universal/gitignore +0 -14
  136. package/templates/universal/npmrc +0 -3
  137. package/templates/universal/oxlintrc.json +0 -39
  138. package/templates/universal/package.json +0 -51
  139. package/templates/universal/packages/config/config.json +0 -4
  140. package/templates/universal/packages/config/index.ts +0 -5
  141. package/templates/universal/packages/config/package.json +0 -17
  142. package/templates/universal/packages/config/tsconfig.json +0 -5
  143. package/templates/universal/packages/i18n/en/common.json +0 -30
  144. package/templates/universal/packages/i18n/es/common.json +0 -30
  145. package/templates/universal/packages/i18n/i18n.d.ts +0 -16
  146. package/templates/universal/packages/i18n/index.ts +0 -26
  147. package/templates/universal/packages/i18n/package.json +0 -20
  148. package/templates/universal/packages/i18n/resources.ts +0 -13
  149. package/templates/universal/packages/i18n/tsconfig.json +0 -5
  150. package/templates/universal/packages/themes/accent.ts +0 -32
  151. package/templates/universal/packages/themes/base.ts +0 -50
  152. package/templates/universal/packages/themes/fonts.ts +0 -8
  153. package/templates/universal/packages/themes/index.ts +0 -33
  154. package/templates/universal/packages/themes/package.json +0 -23
  155. package/templates/universal/packages/themes/tamagui.config.ts +0 -14
  156. package/templates/universal/packages/themes/tsconfig.json +0 -5
  157. package/templates/universal/pnpm-workspace.yaml +0 -4
  158. package/templates/universal/tsconfig.base.json +0 -21
  159. package/templates/universal/types/env.d.ts +0 -9
  160. package/types/commands/initApp.d.ts +0 -114
  161. package/types/commands/initApp.d.ts.map +0 -1
  162. package/types/generateVscode.d.ts +0 -7
  163. package/types/generateVscode.d.ts.map +0 -1
@@ -1,706 +0,0 @@
1
- import {
2
- copyFileSync,
3
- existsSync,
4
- mkdirSync,
5
- readFileSync,
6
- readdirSync,
7
- writeFileSync,
8
- } from "node:fs";
9
- import { dirname, extname, join, resolve } from "node:path";
10
- import { fileURLToPath } from "node:url";
11
- import inquirer from "inquirer";
12
- import spawn from "nano-spawn";
13
-
14
- /**
15
- * Consumer project templates:
16
- * - "universal": one-based app serving web + iOS + Android from one codebase
17
- * (apps/<name> + features/<name> + packages/{config,i18n,themes})
18
- * - "app": web-only Vite + React shell
19
- * (apps/<name> + features/<name> + packages/config)
20
- */
21
- export type InitAppTemplate = "universal" | "app";
22
-
23
- /**
24
- * The provenance template domain: the scaffold templates plus "none" for
25
- * ADOPTED projects (`mpo adopt`) — existing repos that were never scaffolded.
26
- * A "none" scaffold is the selected piece fragments overlaid on an EMPTY
27
- * tree (no universal/app base), which is exactly the tool-owned surface of
28
- * an adopted project.
29
- */
30
- export type ProvenanceTemplate = InitAppTemplate | "none";
31
-
32
- /**
33
- * Optional composable pieces overlaid onto the base template (additive
34
- * fragments under templates/pieces/<piece>/ — the inverse of the old
35
- * cookiecutter KEEP_SERVICES/KEEP_PLATFORMS removal hook). Keep sorted:
36
- * pieces are recorded sorted in provenance and overlaid in this order.
37
- */
38
- export const INIT_APP_PIECES = [
39
- "frappe",
40
- "gnome",
41
- "keycloak",
42
- "tauri",
43
- "vscode",
44
- "webext",
45
- ] as const;
46
- export type InitAppPiece = (typeof INIT_APP_PIECES)[number];
47
-
48
- const PIECE_DESCRIPTIONS: Record<InitAppPiece, string> = {
49
- frappe: "Frappe backend wiring (env keys, provider, docker compose bench)",
50
- gnome: "GNOME desktop target (GTK4/GJS via react-gnome — native widgets, no webview)",
51
- keycloak: "Keycloak auth wiring (env keys, provider, docker compose + realm)",
52
- tauri: "Tauri desktop target (src-tauri skeleton — webview shell, Rust toolchain)",
53
- vscode: "VS Code extension target (extension host + Tamagui webview)",
54
- webext: "Browser extension target (MV3 popup + background)",
55
- };
56
-
57
- export interface InitAppOptions {
58
- /** Skip pnpm install (useful for CI / dry scaffolding). */
59
- skipInstall?: boolean;
60
- /** Skip git init + scaffold commit (mpo update baselines, nested dirs). */
61
- skipGit?: boolean;
62
- /** Pin @multiplatform.one/* to this semver range. Default: ^<cli version>. */
63
- version?: string;
64
- /**
65
- * Project template. When omitted: prompt (interactive) or "universal".
66
- * "none" scaffolds ONLY the selected pieces onto an empty tree (the
67
- * `--pieces-only` flag) — `mpo update` uses it to rebuild the merge
68
- * baselines of adopted projects.
69
- */
70
- template?: ProvenanceTemplate;
71
- /** Accept defaults instead of prompting (non-interactive / CI). */
72
- yes?: boolean;
73
- /**
74
- * Composable pieces overlaid onto the base template. When omitted:
75
- * checkbox prompt (interactive) or none (--yes / non-TTY).
76
- */
77
- pieces?: InitAppPiece[];
78
- }
79
-
80
- type TemplateVars = {
81
- NAME: string;
82
- NAME_PASCAL: string;
83
- /** Lowercase alphanumeric form for iOS bundle ids / Android packages. */
84
- NAME_APPID: string;
85
- MPO_VERSION: string;
86
- YEAR: string;
87
- };
88
-
89
- function toPascalCase(name: string): string {
90
- return name
91
- .split(/[-_\s]+/)
92
- .filter(Boolean)
93
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
94
- .join("");
95
- }
96
-
97
- export function validateName(name: string): string {
98
- const trimmed = name.trim();
99
- if (!trimmed) {
100
- throw new Error("Project name is required");
101
- }
102
- if (!/^[a-z][a-z0-9-]*$/.test(trimmed)) {
103
- throw new Error(
104
- `Invalid project name "${trimmed}". Use lowercase letters, numbers, and hyphens (e.g. my-app).`,
105
- );
106
- }
107
- return trimmed;
108
- }
109
-
110
- /** Root package.json name with any @scope/ prefix stripped (adopt +
111
- * update-bootstrap provenance name derivation). */
112
- export function packageJsonName(projectDir: string): string | undefined {
113
- try {
114
- const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf-8")) as {
115
- name?: string;
116
- };
117
- return pkg.name?.replace(/^@[^/]+\//u, "");
118
- } catch {
119
- return undefined;
120
- }
121
- }
122
-
123
- function resolveMpoVersion(explicit?: string): string {
124
- if (explicit) {
125
- return explicit.startsWith("^") || explicit.startsWith("~") || explicit === "*"
126
- ? explicit
127
- : `^${explicit}`;
128
- }
129
- const version = cliVersion();
130
- return version ? `^${version}` : "^6.1.0";
131
- }
132
-
133
- export function cliVersion(): string | undefined {
134
- try {
135
- const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../package.json");
136
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string };
137
- return pkg.version;
138
- } catch {
139
- return undefined;
140
- }
141
- }
142
-
143
- /**
144
- * Scaffold provenance (the copier-answers equivalent): which template, which
145
- * CLI version and which vars produced this project. `mpo update` regenerates
146
- * the ORIGINAL scaffold from this record to use as the merge base for a
147
- * three-way template update.
148
- */
149
- export interface MpoProvenance {
150
- /** "none" = adopted project (`mpo adopt`): pieces only, no base template. */
151
- template: ProvenanceTemplate;
152
- cliVersion: string;
153
- name: string;
154
- mpoVersion: string;
155
- /** Composable pieces overlaid at init time (sorted). */
156
- pieces: InitAppPiece[];
157
- /**
158
- * false = adopted but never reconciled: `mpo adopt` wrote provenance
159
- * without touching any project file, so no tool-owned content exists yet.
160
- * The first `mpo update` merges the piece baseline from an EMPTY base
161
- * (surfacing real diffs against hand-copied files) and then clears the
162
- * flag. Absent/true = the tree carries the recorded scaffold.
163
- */
164
- reconciled?: boolean;
165
- }
166
-
167
- export const PROVENANCE_FILE = ".mpo.json";
168
-
169
- export function writeProvenance(targetDir: string, provenance: MpoProvenance): void {
170
- writeFileSync(
171
- join(targetDir, PROVENANCE_FILE),
172
- `${JSON.stringify({ ...provenance, pieces: [...provenance.pieces].sort() }, null, 2)}\n`,
173
- );
174
- }
175
-
176
- export function readProvenance(projectDir: string): MpoProvenance | undefined {
177
- const file = join(projectDir, PROVENANCE_FILE);
178
- if (!existsSync(file)) return undefined;
179
- try {
180
- const raw = JSON.parse(readFileSync(file, "utf-8")) as Partial<MpoProvenance>;
181
- if (!raw.template || !raw.cliVersion || !raw.name) return undefined;
182
- return {
183
- template: raw.template,
184
- cliVersion: raw.cliVersion,
185
- name: raw.name,
186
- mpoVersion: raw.mpoVersion ?? `^${raw.cliVersion}`,
187
- // Scaffolds from pre-piece CLIs (≤6.4.x) have no pieces field.
188
- pieces: Array.isArray(raw.pieces) ? [...(raw.pieces as InitAppPiece[])].sort() : [],
189
- // Only adopted-but-unreconciled provenance carries the flag.
190
- ...(raw.reconciled === false ? { reconciled: false } : {}),
191
- };
192
- } catch {
193
- return undefined;
194
- }
195
- }
196
-
197
- function templatesRoot(template: InitAppTemplate): string {
198
- return resolve(dirname(fileURLToPath(import.meta.url)), "../../templates", template);
199
- }
200
-
201
- function piecesRoot(piece: InitAppPiece): string {
202
- return resolve(dirname(fileURLToPath(import.meta.url)), "../../templates/pieces", piece);
203
- }
204
-
205
- /**
206
- * Fragment source dirs for a piece + template: shared/ applies to every
207
- * template, <template>/ only to that one. A piece supports a template when
208
- * at least one of the two exists. Adopted projects (template "none") use the
209
- * universal fragment set — pieces ship universal-layout fragments
210
- * (apps/<name>/…), which is the layout adopted repos follow.
211
- */
212
- function pieceFragmentDirs(piece: InitAppPiece, template: ProvenanceTemplate): string[] {
213
- const effective: InitAppTemplate = template === "none" ? "universal" : template;
214
- return [join(piecesRoot(piece), "shared"), join(piecesRoot(piece), effective)].filter((dir) =>
215
- existsSync(dir),
216
- );
217
- }
218
-
219
- /**
220
- * npm always strips .gitignore / .npmrc (and newer npms .env*) from published
221
- * tarballs, so the template stores them without the leading dot; restore it
222
- * at scaffold time.
223
- */
224
- const DOTFILE_NAMES: Record<string, string> = {
225
- gitignore: ".gitignore",
226
- npmrc: ".npmrc",
227
- "env.example": ".env.example",
228
- };
229
-
230
- function render(content: string, vars: TemplateVars): string {
231
- return content
232
- .replaceAll("__NAME_PASCAL__", vars.NAME_PASCAL)
233
- .replaceAll("__NAME_APPID__", vars.NAME_APPID)
234
- .replaceAll("__NAME__", vars.NAME)
235
- .replaceAll("__MPO_VERSION__", vars.MPO_VERSION)
236
- .replaceAll("__YEAR__", vars.YEAR);
237
- }
238
-
239
- /**
240
- * Assets copied byte-for-byte — running them through the utf-8 render pass
241
- * would corrupt them (fonts ship in the templates for LC-33 stable mount).
242
- */
243
- const BINARY_EXTENSIONS = new Set([
244
- ".woff2",
245
- ".woff",
246
- ".ttf",
247
- ".otf",
248
- ".png",
249
- ".jpg",
250
- ".ico",
251
- ".icns",
252
- ]);
253
-
254
- function writeTree(srcDir: string, destDir: string, vars: TemplateVars): void {
255
- if (!existsSync(srcDir)) {
256
- throw new Error(`Project template missing at ${srcDir}`);
257
- }
258
- mkdirSync(destDir, { recursive: true });
259
- for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
260
- const from = join(srcDir, entry.name);
261
- // Template files may use __NAME__ in path segments.
262
- const toName = render(DOTFILE_NAMES[entry.name] ?? entry.name, vars);
263
- const to = join(destDir, toName);
264
- if (entry.isDirectory()) {
265
- writeTree(from, to, vars);
266
- continue;
267
- }
268
- if (BINARY_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
269
- copyFileSync(from, to);
270
- continue;
271
- }
272
- const raw = readFileSync(from, "utf-8");
273
- writeFileSync(to, render(raw, vars));
274
- }
275
- }
276
-
277
- // ── piece fragment overlay ─────────────────────────────────────────
278
-
279
- type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
280
-
281
- function isJsonObject(value: JsonValue | undefined): value is { [key: string]: JsonValue } {
282
- return typeof value === "object" && value !== null && !Array.isArray(value);
283
- }
284
-
285
- /**
286
- * Deep-merge for JSON partials: objects merge recursively (fragment keys
287
- * append after base keys, so e.g. package.json script order stays stable),
288
- * arrays union (base order first — config.json public keys accumulate
289
- * across pieces), scalars/type-mismatches take the fragment value.
290
- */
291
- export function mergeJsonValues(base: JsonValue, fragment: JsonValue): JsonValue {
292
- if (Array.isArray(base) && Array.isArray(fragment)) {
293
- const merged = [...base];
294
- const seen = new Set(base.map((item) => JSON.stringify(item)));
295
- for (const item of fragment) {
296
- const key = JSON.stringify(item);
297
- if (seen.has(key)) continue;
298
- seen.add(key);
299
- merged.push(item);
300
- }
301
- return merged;
302
- }
303
- if (isJsonObject(base) && isJsonObject(fragment)) {
304
- const merged: { [key: string]: JsonValue } = { ...base };
305
- for (const [key, value] of Object.entries(fragment)) {
306
- merged[key] = key in merged ? mergeJsonValues(merged[key]!, value) : value;
307
- }
308
- return merged;
309
- }
310
- return fragment;
311
- }
312
-
313
- /** Dependency maps stay alphabetically sorted after fragment merges (the
314
- * base templates are sorted; multiple pieces appending must not depend on
315
- * overlay order for the final byte content). */
316
- const SORTED_DEPENDENCY_FIELDS = [
317
- "dependencies",
318
- "devDependencies",
319
- "peerDependencies",
320
- "optionalDependencies",
321
- ];
322
-
323
- function sortDependencyFields(value: JsonValue): JsonValue {
324
- if (!isJsonObject(value)) return value;
325
- const result = { ...value };
326
- for (const field of SORTED_DEPENDENCY_FIELDS) {
327
- const deps = result[field];
328
- if (!isJsonObject(deps)) continue;
329
- result[field] = Object.fromEntries(
330
- Object.entries(deps).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
331
- );
332
- }
333
- return result;
334
- }
335
-
336
- /**
337
- * Apply a rendered `<target>.partial` fragment onto the target file:
338
- * - `.json` targets deep-merge (see mergeJsonValues); package.json-style
339
- * dependency maps are re-sorted for deterministic output
340
- * - everything else appends as a text block separated by a blank line
341
- * Missing targets are created from the fragment alone.
342
- */
343
- function applyPartial(targetPath: string, renderedFragment: string): void {
344
- if (targetPath.endsWith(".json")) {
345
- const fragment = JSON.parse(renderedFragment) as JsonValue;
346
- const base = existsSync(targetPath)
347
- ? (JSON.parse(readFileSync(targetPath, "utf-8")) as JsonValue)
348
- : undefined;
349
- const merged = sortDependencyFields(
350
- base === undefined ? fragment : mergeJsonValues(base, fragment),
351
- );
352
- writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
353
- return;
354
- }
355
- const block = `${renderedFragment.replace(/^\n+/u, "").replace(/\n+$/u, "")}\n`;
356
- if (!existsSync(targetPath)) {
357
- writeFileSync(targetPath, block);
358
- return;
359
- }
360
- const existing = readFileSync(targetPath, "utf-8").replace(/\n+$/u, "");
361
- writeFileSync(targetPath, `${existing}\n\n${block}`);
362
- }
363
-
364
- const PARTIAL_SUFFIX = ".partial";
365
-
366
- /**
367
- * Overlay a piece fragment tree onto the scaffold: regular files render and
368
- * overwrite, `<target>.partial` files merge into (or create) their target
369
- * (deep-merge for .json, append for text — see applyPartial).
370
- */
371
- function overlayTree(srcDir: string, destDir: string, vars: TemplateVars): void {
372
- mkdirSync(destDir, { recursive: true });
373
- for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
374
- const from = join(srcDir, entry.name);
375
- if (entry.isDirectory()) {
376
- overlayTree(from, join(destDir, render(DOTFILE_NAMES[entry.name] ?? entry.name, vars)), vars);
377
- continue;
378
- }
379
- if (entry.name.endsWith(PARTIAL_SUFFIX)) {
380
- const targetName = entry.name.slice(0, -PARTIAL_SUFFIX.length);
381
- const toName = render(DOTFILE_NAMES[targetName] ?? targetName, vars);
382
- applyPartial(join(destDir, toName), render(readFileSync(from, "utf-8"), vars));
383
- continue;
384
- }
385
- const toName = render(DOTFILE_NAMES[entry.name] ?? entry.name, vars);
386
- const to = join(destDir, toName);
387
- if (BINARY_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
388
- copyFileSync(from, to);
389
- continue;
390
- }
391
- writeFileSync(to, render(readFileSync(from, "utf-8"), vars));
392
- }
393
- }
394
-
395
- /**
396
- * Validate + canonicalize a piece selection for a template: unknown names
397
- * throw, duplicates collapse, order is always sorted (provenance and overlay
398
- * order must be deterministic), and every piece must ship a fragment for the
399
- * chosen template.
400
- */
401
- export function normalizePieces(pieces: string[], template: ProvenanceTemplate): InitAppPiece[] {
402
- const known = new Set<string>(INIT_APP_PIECES);
403
- const normalized = [...new Set(pieces)].sort() as InitAppPiece[];
404
- for (const piece of normalized) {
405
- if (!known.has(piece)) {
406
- throw new Error(`Unknown piece "${piece}". Available pieces: ${INIT_APP_PIECES.join(", ")}`);
407
- }
408
- if (pieceFragmentDirs(piece, template).length === 0) {
409
- throw new Error(
410
- `Piece "${piece}" is not available for the ${template} template ` +
411
- "(pieces currently target the universal template)",
412
- );
413
- }
414
- }
415
- return normalized;
416
- }
417
-
418
- /** Pieces that can be offered for a template (fragment exists for it). */
419
- export function availablePieces(template: ProvenanceTemplate): InitAppPiece[] {
420
- return INIT_APP_PIECES.filter((piece) => pieceFragmentDirs(piece, template).length > 0);
421
- }
422
-
423
- /** Human-readable piece summaries (adopt + init prompts). */
424
- export function pieceDescription(piece: InitAppPiece): string {
425
- return PIECE_DESCRIPTIONS[piece];
426
- }
427
-
428
- async function resolvePieces(
429
- options: InitAppOptions,
430
- template: ProvenanceTemplate,
431
- ): Promise<InitAppPiece[]> {
432
- if (options.pieces) return normalizePieces(options.pieces, template);
433
- if (options.yes || !process.stdout.isTTY) return [];
434
- const choices = availablePieces(template);
435
- if (choices.length === 0) return [];
436
- const result = await inquirer.prompt([
437
- {
438
- message: "Add optional pieces? (space to toggle, enter to confirm)",
439
- name: "pieces",
440
- type: "checkbox",
441
- choices: choices.map((piece) => ({
442
- name: `${piece} — ${PIECE_DESCRIPTIONS[piece]}`,
443
- value: piece,
444
- checked: false,
445
- })),
446
- },
447
- ]);
448
- return normalizePieces(result.pieces as string[], template);
449
- }
450
-
451
- // ── app.ts composition (frappe / keycloak provider wiring) ─────────
452
-
453
- /**
454
- * Generate apps/<name>/src/app.ts for the selected backend pieces instead of
455
- * storing 2^n template variants: each selected provider adds one createApp
456
- * key (frappe/keycloak read their runtime settings from the packages/config
457
- * public env keys their fragments register). Only called when at least one
458
- * provider piece is selected — the base template file stays otherwise.
459
- */
460
- export function composeAppTs(pieces: InitAppPiece[]): string {
461
- const frappe = pieces.includes("frappe");
462
- const keycloak = pieces.includes("keycloak");
463
- const lines: string[] = ['import { createApp } from "@multiplatform.one/core";'];
464
- if (frappe || keycloak) {
465
- lines.push('import { config } from "@multiplatform.one/platform";');
466
- }
467
- lines.push('import { themeConfig } from "@package/themes";');
468
- lines.push(
469
- "",
470
- "/**",
471
- " * App-wide provider stack. Providers below were enabled by the pieces",
472
- " * selected at `mpo init` time — see CreateAppConfig in",
473
- " * @multiplatform.one/core for everything else that can be wired here",
474
- " * (tanstack, web3, custom providers).",
475
- " */",
476
- "export const { AppProvider, useAppConfig, withApp } = createApp({",
477
- " theme: themeConfig,",
478
- );
479
- if (frappe) {
480
- lines.push(
481
- " // Frappe backend (mpo init --frappe): FrappeProvider mounts when",
482
- " // FRAPPE_ENABLED=1 (a packages/config public env key, loaded from",
483
- " // .env — see .env.example). FRAPPE_URL overrides the bench URL",
484
- " // (default: BASE_URL, then http://localhost:8000);",
485
- " // docker/compose.frappe.yaml runs a dev bench.",
486
- ' frappe: { baseURL: config.get("FRAPPE_URL") },',
487
- );
488
- }
489
- if (keycloak) {
490
- lines.push(
491
- " // Keycloak auth (mpo init --keycloak): KeycloakProvider mounts when",
492
- " // KEYCLOAK_ENABLED=1. Realm/client settings come from the KEYCLOAK_*",
493
- " // env keys (packages/config, .env.example);",
494
- " // docker/compose.keycloak.yaml runs a dev server with an imported",
495
- " // realm.",
496
- ' keycloak: { baseUrl: config.get("KEYCLOAK_BASE_URL") },',
497
- );
498
- }
499
- lines.push("});", "");
500
- return lines.join("\n");
501
- }
502
-
503
- async function resolveTemplate(options: InitAppOptions): Promise<ProvenanceTemplate> {
504
- if (options.template) return options.template;
505
- if (options.yes || !process.stdout.isTTY) return "universal";
506
- const result = await inquirer.prompt([
507
- {
508
- message: "Which platforms should this project target?",
509
- name: "template",
510
- type: "list",
511
- choices: [
512
- { name: "Universal — web + iOS + Android (one)", value: "universal" },
513
- { name: "Web only — Vite + React", value: "app" },
514
- ],
515
- default: "universal",
516
- },
517
- ]);
518
- return result.template as InitAppTemplate;
519
- }
520
-
521
- /**
522
- * Scaffold a consumer project (universal by default, web-only with
523
- * template: "app") that depends on @multiplatform.one/* at semver ranges
524
- * (never workspace:*). This is NOT a monorepo fork.
525
- */
526
- export async function initApp(
527
- nameArg: string | undefined,
528
- options: InitAppOptions = {},
529
- ): Promise<void> {
530
- let projectName = nameArg;
531
- if (!projectName) {
532
- if (options.yes || !process.stdout.isTTY) {
533
- projectName = "my-app";
534
- } else {
535
- const result = await inquirer.prompt([
536
- {
537
- message: "What is the app name?",
538
- name: "name",
539
- type: "input",
540
- default: "my-app",
541
- },
542
- ]);
543
- projectName = result.name as string;
544
- }
545
- }
546
-
547
- const name = validateName(projectName);
548
- const template = await resolveTemplate(options);
549
- const pieces = await resolvePieces(options, template);
550
- if (template === "none" && pieces.length === 0) {
551
- throw new Error(
552
- "A pieces-only scaffold needs at least one piece " +
553
- `(${INIT_APP_PIECES.map((piece) => `--${piece}`).join(", ")})`,
554
- );
555
- }
556
- const targetDir = resolve(name);
557
-
558
- if (existsSync(targetDir)) {
559
- const entries = readdirSync(targetDir);
560
- if (entries.length > 0) {
561
- throw new Error(`Directory "${targetDir}" already exists and is not empty`);
562
- }
563
- }
564
-
565
- const vars: TemplateVars = {
566
- NAME: name,
567
- NAME_PASCAL: toPascalCase(name),
568
- NAME_APPID: name.replaceAll("-", ""),
569
- MPO_VERSION: resolveMpoVersion(options.version),
570
- YEAR: String(new Date().getFullYear()),
571
- };
572
-
573
- const label =
574
- template === "universal"
575
- ? "universal (web + iOS + Android)"
576
- : template === "app"
577
- ? "web-only"
578
- : "pieces-only (adopted-project baseline)";
579
- console.log(`\nScaffolding ${label} project at ${targetDir}`);
580
- if (template !== "none") {
581
- console.log(` apps/${name}`);
582
- console.log(` features/${name}`);
583
- console.log(` packages/config`);
584
- }
585
- if (template === "universal") {
586
- console.log(` packages/i18n`);
587
- console.log(` packages/themes`);
588
- }
589
- if (pieces.length) {
590
- console.log(` pieces: ${pieces.join(", ")}`);
591
- }
592
- console.log(` @multiplatform.one/* → ${vars.MPO_VERSION}\n`);
593
-
594
- // "none" (adopted-project baselines) overlays pieces on an EMPTY tree —
595
- // there is no base template to write.
596
- if (template === "none") {
597
- mkdirSync(targetDir, { recursive: true });
598
- } else {
599
- writeTree(templatesRoot(template), targetDir, vars);
600
- }
601
- // Pieces overlay AFTER the base template, in sorted order (normalizePieces)
602
- // so repeated scaffolds are byte-identical — mpo update regenerates these
603
- // exact trees as merge baselines.
604
- for (const piece of pieces) {
605
- for (const fragmentDir of pieceFragmentDirs(piece, template)) {
606
- overlayTree(fragmentDir, targetDir, vars);
607
- }
608
- }
609
- // Backend providers are wired by generating src/app.ts from the selection
610
- // (not by stacking file overwrites per combination).
611
- if (pieces.includes("frappe") || pieces.includes("keycloak")) {
612
- // Pieces-only trees have no base template dirs — create the path.
613
- mkdirSync(join(targetDir, "apps", name, "src"), { recursive: true });
614
- writeFileSync(join(targetDir, "apps", name, "src/app.ts"), composeAppTs(pieces));
615
- }
616
- writeProvenance(targetDir, {
617
- template,
618
- cliVersion: cliVersion() ?? "0.0.0",
619
- name,
620
- mpoVersion: vars.MPO_VERSION,
621
- pieces,
622
- });
623
-
624
- // Initialize git with a scaffold commit (unless already inside a repo —
625
- // e.g. scaffolding into an existing project dir). `mpo update` needs the
626
- // scaffold as a committed baseline to merge on top of.
627
- let scaffoldCommitted = false;
628
- if (!options.skipGit) {
629
- const insideRepo = await spawn("git", ["rev-parse", "--is-inside-work-tree"], {
630
- cwd: targetDir,
631
- }).then(
632
- () => true,
633
- () => false,
634
- );
635
- if (!insideRepo) {
636
- const label =
637
- template === "universal" ? "universal" : template === "app" ? "web-only" : "pieces-only";
638
- await spawn("git", ["init", "--quiet"], { cwd: targetDir });
639
- await spawn("git", ["add", "-A"], { cwd: targetDir });
640
- scaffoldCommitted = await spawn(
641
- "git",
642
- ["commit", "--quiet", "-m", `feat: scaffold ${label} multiplatform.one app (mpo init)`],
643
- { cwd: targetDir },
644
- ).then(
645
- () => true,
646
- () => {
647
- console.warn("⚠️ git commit failed (missing git identity?) — repo left staged.");
648
- return false;
649
- },
650
- );
651
- }
652
- }
653
-
654
- // Pieces-only trees are merge baselines, not runnable projects — never
655
- // install into them.
656
- if (!options.skipInstall && template !== "none") {
657
- console.log("Installing dependencies...");
658
- await spawn("pnpm", ["install"], {
659
- cwd: targetDir,
660
- stdio: "inherit",
661
- });
662
- // The lockfile belongs to the scaffold — fold it into the scaffold
663
- // commit so a fresh init is immediately clean.
664
- if (scaffoldCommitted) {
665
- await spawn("git", ["add", "--", "pnpm-lock.yaml"], { cwd: targetDir })
666
- .then(() => spawn("git", ["commit", "--amend", "--no-edit", "--quiet"], { cwd: targetDir }))
667
- .catch(() => {});
668
- }
669
- } else if (options.skipInstall) {
670
- console.log("Skipped pnpm install (--skip-install).");
671
- }
672
-
673
- console.log(`\n✅ Project created at ${targetDir}`);
674
- if (template === "none") {
675
- console.log("\nPieces-only tree (adopted-project baseline) — not a runnable project.");
676
- return;
677
- }
678
- console.log("\nNext steps:");
679
- console.log(` cd ${name}`);
680
- if (options.skipInstall) console.log(" pnpm install");
681
- console.log(" pnpm dev # web dev server");
682
- if (template === "universal") {
683
- console.log(" pnpm ios # iOS (requires pnpm dev in another terminal)");
684
- console.log(" pnpm android # Android");
685
- }
686
- if (pieces.includes("frappe")) {
687
- console.log(" docker compose -f docker/compose.frappe.yaml up -d # dev bench (see README)");
688
- }
689
- if (pieces.includes("keycloak")) {
690
- console.log(" docker compose -f docker/compose.keycloak.yaml up -d # dev keycloak");
691
- }
692
- if (pieces.includes("gnome")) {
693
- console.log(" pnpm gnome # GTK4 desktop app (requires gjs + gtk4 — see README)");
694
- }
695
- if (pieces.includes("tauri")) {
696
- console.log(" pnpm tauri:dev # Tauri desktop app (requires the Rust toolchain)");
697
- }
698
- if (pieces.includes("vscode")) {
699
- console.log(" pnpm build:vscode # stage the VS Code extension into apps dist-vscode/");
700
- }
701
- if (pieces.includes("webext")) {
702
- console.log(" pnpm build:webext # stage the browser extension into apps dist-webext/");
703
- }
704
- console.log("\nThis project consumes published @multiplatform.one/* packages.");
705
- console.log("It is not a fork of the multiplatform.one monorepo.");
706
- }