@multiplatform.one/cli 7.10.0 → 7.15.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 +7 -7
  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
@@ -0,0 +1,340 @@
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { extname, join } from "node:path";
10
+ import { cliRoot } from "./provenance";
11
+
12
+ /**
13
+ * Pieces: additive fragment trees under templates/pieces/<piece>/ that an
14
+ * ADOPTED project (`mpo adopt`) hand-copied and wants `mpo update` to keep
15
+ * reconciling. A project from `mpo init` never needs them: apps/one already
16
+ * ships gnome/, tauri/, vscode/ and webext/, and frappe / keycloak are
17
+ * `--service` selections of the real apps. Keep sorted: pieces are recorded
18
+ * sorted in provenance and overlaid in this order.
19
+ */
20
+ export const INIT_APP_PIECES = [
21
+ "frappe",
22
+ "frappe-bench",
23
+ "gnome",
24
+ "keycloak",
25
+ "tauri",
26
+ "vscode",
27
+ "webext",
28
+ ] as const;
29
+ export type InitAppPiece = (typeof INIT_APP_PIECES)[number];
30
+
31
+ const PIECE_DESCRIPTIONS: Record<InitAppPiece, string> = {
32
+ frappe: "Frappe client wiring (env keys, provider)",
33
+ "frappe-bench": "Frappe dev bench (docker compose: bench, MariaDB, Redis)",
34
+ gnome: "GNOME desktop target (GTK4/GJS via react-gnome — native widgets, no webview)",
35
+ keycloak: "Keycloak auth wiring (env keys, provider, docker compose + realm)",
36
+ tauri: "Tauri desktop target (src-tauri skeleton — webview shell, Rust toolchain)",
37
+ vscode: "VS Code extension target (extension host + Tamagui webview)",
38
+ webext: "Browser extension target (MV3 popup + background)",
39
+ };
40
+
41
+ export function piecesRoot(): string {
42
+ return join(cliRoot(), "templates/pieces");
43
+ }
44
+
45
+ type TemplateVars = {
46
+ NAME: string;
47
+ NAME_PASCAL: string;
48
+ /** Lowercase alphanumeric form for iOS bundle ids / Android packages. */
49
+ NAME_APPID: string;
50
+ MPO_VERSION: string;
51
+ YEAR: string;
52
+ };
53
+
54
+ function toPascalCase(name: string): string {
55
+ return name
56
+ .split(/[-_\s]+/)
57
+ .filter(Boolean)
58
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
59
+ .join("");
60
+ }
61
+
62
+ /**
63
+ * Fragment source dirs for a piece: shared/ plus universal/ (the layout
64
+ * adopted repos follow: apps/<name>/...). A piece is available when at least
65
+ * one of the two exists.
66
+ */
67
+ function pieceFragmentDirs(piece: InitAppPiece, root: string): string[] {
68
+ return [join(root, piece, "shared"), join(root, piece, "universal")].filter((dir) =>
69
+ existsSync(dir),
70
+ );
71
+ }
72
+
73
+ /**
74
+ * npm always strips .gitignore / .npmrc (and newer npms .env*) from published
75
+ * tarballs, so the template stores them without the leading dot; restore it
76
+ * at scaffold time.
77
+ */
78
+ const DOTFILE_NAMES: Record<string, string> = {
79
+ gitignore: ".gitignore",
80
+ npmrc: ".npmrc",
81
+ "env.example": ".env.example",
82
+ };
83
+
84
+ function render(content: string, vars: TemplateVars): string {
85
+ return content
86
+ .replaceAll("__NAME_PASCAL__", vars.NAME_PASCAL)
87
+ .replaceAll("__NAME_APPID__", vars.NAME_APPID)
88
+ .replaceAll("__NAME__", vars.NAME)
89
+ .replaceAll("__MPO_VERSION__", vars.MPO_VERSION)
90
+ .replaceAll("__YEAR__", vars.YEAR);
91
+ }
92
+
93
+ /** Assets copied byte-for-byte; the utf-8 render pass would corrupt them. */
94
+ const BINARY_EXTENSIONS = new Set([
95
+ ".woff2",
96
+ ".woff",
97
+ ".ttf",
98
+ ".otf",
99
+ ".png",
100
+ ".jpg",
101
+ ".ico",
102
+ ".icns",
103
+ ]);
104
+
105
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
106
+
107
+ function isJsonObject(value: JsonValue | undefined): value is { [key: string]: JsonValue } {
108
+ return typeof value === "object" && value !== null && !Array.isArray(value);
109
+ }
110
+
111
+ /**
112
+ * Deep-merge for JSON partials: objects merge recursively (fragment keys
113
+ * append after base keys, so e.g. package.json script order stays stable),
114
+ * arrays union (base order first), scalars/type-mismatches take the
115
+ * fragment value.
116
+ */
117
+ export function mergeJsonValues(base: JsonValue, fragment: JsonValue): JsonValue {
118
+ if (Array.isArray(base) && Array.isArray(fragment)) {
119
+ const merged = [...base];
120
+ const seen = new Set(base.map((item) => JSON.stringify(item)));
121
+ for (const item of fragment) {
122
+ const key = JSON.stringify(item);
123
+ if (seen.has(key)) continue;
124
+ seen.add(key);
125
+ merged.push(item);
126
+ }
127
+ return merged;
128
+ }
129
+ if (isJsonObject(base) && isJsonObject(fragment)) {
130
+ const merged: { [key: string]: JsonValue } = { ...base };
131
+ for (const [key, value] of Object.entries(fragment)) {
132
+ merged[key] = key in merged ? mergeJsonValues(merged[key]!, value) : value;
133
+ }
134
+ return merged;
135
+ }
136
+ return fragment;
137
+ }
138
+
139
+ const SORTED_DEPENDENCY_FIELDS = [
140
+ "dependencies",
141
+ "devDependencies",
142
+ "peerDependencies",
143
+ "optionalDependencies",
144
+ ];
145
+
146
+ /** Dependency maps stay sorted after fragment merges so overlay order never changes the bytes. */
147
+ function sortDependencyFields(value: JsonValue): JsonValue {
148
+ if (!isJsonObject(value)) return value;
149
+ const result = { ...value };
150
+ for (const field of SORTED_DEPENDENCY_FIELDS) {
151
+ const deps = result[field];
152
+ if (!isJsonObject(deps)) continue;
153
+ result[field] = Object.fromEntries(
154
+ Object.entries(deps).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
155
+ );
156
+ }
157
+ return result;
158
+ }
159
+
160
+ /**
161
+ * Apply a rendered `<target>.partial` fragment onto the target file:
162
+ * - `.json` targets deep-merge (see mergeJsonValues)
163
+ * - everything else appends as a text block separated by a blank line
164
+ * Missing targets are created from the fragment alone.
165
+ */
166
+ function applyPartial(targetPath: string, renderedFragment: string): void {
167
+ if (targetPath.endsWith(".json")) {
168
+ const fragment = JSON.parse(renderedFragment) as JsonValue;
169
+ const base = existsSync(targetPath)
170
+ ? (JSON.parse(readFileSync(targetPath, "utf-8")) as JsonValue)
171
+ : undefined;
172
+ const merged = sortDependencyFields(
173
+ base === undefined ? fragment : mergeJsonValues(base, fragment),
174
+ );
175
+ writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
176
+ return;
177
+ }
178
+ const block = `${renderedFragment.replace(/^\n+/u, "").replace(/\n+$/u, "")}\n`;
179
+ if (!existsSync(targetPath)) {
180
+ writeFileSync(targetPath, block);
181
+ return;
182
+ }
183
+ const existing = readFileSync(targetPath, "utf-8").replace(/\n+$/u, "");
184
+ writeFileSync(targetPath, `${existing}\n\n${block}`);
185
+ }
186
+
187
+ const PARTIAL_SUFFIX = ".partial";
188
+
189
+ /**
190
+ * Overlay a piece fragment tree: regular files render and overwrite,
191
+ * `<target>.partial` files merge into (or create) their target.
192
+ */
193
+ function overlayTree(srcDir: string, destDir: string, vars: TemplateVars): void {
194
+ mkdirSync(destDir, { recursive: true });
195
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
196
+ const from = join(srcDir, entry.name);
197
+ if (entry.isDirectory()) {
198
+ overlayTree(from, join(destDir, render(DOTFILE_NAMES[entry.name] ?? entry.name, vars)), vars);
199
+ continue;
200
+ }
201
+ if (entry.name.endsWith(PARTIAL_SUFFIX)) {
202
+ const targetName = entry.name.slice(0, -PARTIAL_SUFFIX.length);
203
+ const toName = render(DOTFILE_NAMES[targetName] ?? targetName, vars);
204
+ applyPartial(join(destDir, toName), render(readFileSync(from, "utf-8"), vars));
205
+ continue;
206
+ }
207
+ const toName = render(DOTFILE_NAMES[entry.name] ?? entry.name, vars);
208
+ const to = join(destDir, toName);
209
+ if (BINARY_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
210
+ copyFileSync(from, to);
211
+ continue;
212
+ }
213
+ writeFileSync(to, render(readFileSync(from, "utf-8"), vars));
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Validate + canonicalize a piece selection: unknown names throw, duplicates
219
+ * collapse, order is always sorted, and every piece must ship a fragment.
220
+ */
221
+ export function normalizePieces(pieces: string[], root = piecesRoot()): InitAppPiece[] {
222
+ const known = new Set<string>(INIT_APP_PIECES);
223
+ const normalized = [...new Set(pieces)].sort() as InitAppPiece[];
224
+ for (const piece of normalized) {
225
+ if (!known.has(piece)) {
226
+ throw new Error(`Unknown piece "${piece}". Available pieces: ${INIT_APP_PIECES.join(", ")}`);
227
+ }
228
+ if (pieceFragmentDirs(piece, root).length === 0) {
229
+ throw new Error(`Piece "${piece}" ships no fragments under ${root}`);
230
+ }
231
+ }
232
+ return normalized;
233
+ }
234
+
235
+ /**
236
+ * The frappe piece carried the dev bench until the bench became its own piece
237
+ * (MPO-241). An adoption recorded against pieces that still bundle it keeps
238
+ * the bench, now as frappe-bench, so an update does not delete it.
239
+ */
240
+ export function withSplitPieces(pieces: InitAppPiece[], recordedRoot?: string): InitAppPiece[] {
241
+ if (!recordedRoot || !pieces.includes("frappe") || pieces.includes("frappe-bench")) return pieces;
242
+ if (!existsSync(join(recordedRoot, "frappe/universal/docker/compose.frappe.yaml"))) return pieces;
243
+ return [...pieces, "frappe-bench" as const].sort();
244
+ }
245
+
246
+ export function availablePieces(root = piecesRoot()): InitAppPiece[] {
247
+ return INIT_APP_PIECES.filter((piece) => pieceFragmentDirs(piece, root).length > 0);
248
+ }
249
+
250
+ export function pieceDescription(piece: InitAppPiece): string {
251
+ return PIECE_DESCRIPTIONS[piece];
252
+ }
253
+
254
+ /**
255
+ * apps/<name>/src/app.ts for the selected backend pieces: each provider
256
+ * adds one createApp key. Only written when a provider piece is selected.
257
+ */
258
+ export function composeAppTs(pieces: InitAppPiece[]): string {
259
+ const frappe = pieces.includes("frappe");
260
+ const keycloak = pieces.includes("keycloak");
261
+ const lines: string[] = ['import { createApp } from "@multiplatform.one/core";'];
262
+ if (frappe || keycloak) {
263
+ lines.push('import { config } from "@multiplatform.one/platform";');
264
+ }
265
+ lines.push('import { themeConfig } from "@package/themes";');
266
+ lines.push(
267
+ "",
268
+ "/**",
269
+ " * App-wide provider stack. Providers below were enabled by the pieces",
270
+ " * this project adopted — see CreateAppConfig in @multiplatform.one/core",
271
+ " * for everything else that can be wired here (tanstack, web3, custom",
272
+ " * providers).",
273
+ " */",
274
+ "export const { AppProvider, useAppConfig, withApp } = createApp({",
275
+ " theme: themeConfig,",
276
+ );
277
+ if (frappe) {
278
+ lines.push(
279
+ " // Frappe client: FrappeProvider mounts when FRAPPE_ENABLED=1 (a",
280
+ " // packages/config public env key, loaded from .env — see",
281
+ " // .env.example). FRAPPE_URL names the Frappe server (default:",
282
+ " // BASE_URL, then http://localhost:8000).",
283
+ );
284
+ if (pieces.includes("frappe-bench")) {
285
+ lines.push(" // docker/compose.frappe.yaml runs a dev bench.");
286
+ }
287
+ lines.push(' frappe: { baseURL: config.get("FRAPPE_URL") },');
288
+ }
289
+ if (keycloak) {
290
+ lines.push(
291
+ " // Keycloak auth: KeycloakProvider mounts when KEYCLOAK_ENABLED=1.",
292
+ " // Realm/client settings come from the KEYCLOAK_* env keys",
293
+ " // (packages/config, .env.example); docker/compose.keycloak.yaml runs",
294
+ " // a dev server with an imported realm.",
295
+ ' keycloak: { baseUrl: config.get("KEYCLOAK_BASE_URL") },',
296
+ );
297
+ }
298
+ lines.push("});", "");
299
+ return lines.join("\n");
300
+ }
301
+
302
+ export interface PiecesScaffold {
303
+ name: string;
304
+ mpoVersion: string;
305
+ pieces: InitAppPiece[];
306
+ /** templates/pieces of another CLI version (the merge base of an older adoption). */
307
+ root?: string;
308
+ }
309
+
310
+ /**
311
+ * The tool-owned surface of an adopted project: the selected piece
312
+ * fragments overlaid, in sorted order, on an EMPTY tree. `mpo update`
313
+ * builds both merge baselines of an adopted project with this.
314
+ */
315
+ export function scaffoldPieces(targetDir: string, request: PiecesScaffold): void {
316
+ const root = request.root ?? piecesRoot();
317
+ const pieces = normalizePieces(request.pieces, root);
318
+ if (!pieces.length) {
319
+ throw new Error(
320
+ `A pieces scaffold needs at least one piece (${INIT_APP_PIECES.map((piece) => `--${piece}`).join(", ")})`,
321
+ );
322
+ }
323
+ const vars: TemplateVars = {
324
+ NAME: request.name,
325
+ NAME_PASCAL: toPascalCase(request.name),
326
+ NAME_APPID: request.name.replaceAll("-", ""),
327
+ MPO_VERSION: request.mpoVersion,
328
+ YEAR: String(new Date().getFullYear()),
329
+ };
330
+ mkdirSync(targetDir, { recursive: true });
331
+ for (const piece of pieces) {
332
+ for (const fragmentDir of pieceFragmentDirs(piece, root)) {
333
+ overlayTree(fragmentDir, targetDir, vars);
334
+ }
335
+ }
336
+ if (pieces.includes("frappe") || pieces.includes("keycloak")) {
337
+ mkdirSync(join(targetDir, "apps", request.name, "src"), { recursive: true });
338
+ writeFileSync(join(targetDir, "apps", request.name, "src/app.ts"), composeAppTs(pieces));
339
+ }
340
+ }
@@ -0,0 +1,92 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
+ import { mpoRange, readProvenance, writeProvenance } from "./provenance";
6
+
7
+ describe("provenance", () => {
8
+ let dir: string;
9
+ beforeAll(() => {
10
+ dir = mkdtempSync(join(tmpdir(), "mpo-provenance-spec-"));
11
+ });
12
+ afterAll(() => {
13
+ rmSync(dir, { recursive: true, force: true });
14
+ });
15
+
16
+ it("round-trips the clone-derived record", () => {
17
+ writeProvenance(dir, {
18
+ cliVersion: "7.11.0",
19
+ source: { sha: "a".repeat(40), url: "https://example.com/mpo.git" },
20
+ apps: ["shop", "admin"],
21
+ services: ["keycloak", "frappe"],
22
+ pieces: [],
23
+ });
24
+ expect(JSON.parse(readFileSync(join(dir, ".mpo.json"), "utf-8"))).toEqual({
25
+ cliVersion: "7.11.0",
26
+ source: { sha: "a".repeat(40), url: "https://example.com/mpo.git" },
27
+ apps: ["shop", "admin"],
28
+ services: ["frappe", "keycloak"],
29
+ pieces: [],
30
+ });
31
+ expect(readProvenance(dir)).toEqual({
32
+ cliVersion: "7.11.0",
33
+ source: { sha: "a".repeat(40), url: "https://example.com/mpo.git" },
34
+ apps: ["shop", "admin"],
35
+ services: ["frappe", "keycloak"],
36
+ pieces: [],
37
+ });
38
+ });
39
+
40
+ it("reads the template-era {template, name} record as one product app with no base", () => {
41
+ writeFileSync(
42
+ join(dir, ".mpo.json"),
43
+ JSON.stringify({ template: "universal", cliVersion: "7.7.1", name: "wealthflow" }),
44
+ );
45
+ expect(readProvenance(dir)).toEqual({
46
+ cliVersion: "7.7.1",
47
+ apps: ["wealthflow"],
48
+ services: [],
49
+ pieces: [],
50
+ template: "universal",
51
+ name: "wealthflow",
52
+ mpoVersion: "^7.7.1",
53
+ });
54
+ });
55
+
56
+ it("reads an adopted record with its pieces sorted and the reconciled flag", () => {
57
+ writeFileSync(
58
+ join(dir, ".mpo.json"),
59
+ JSON.stringify({
60
+ template: "none",
61
+ cliVersion: "7.9.0",
62
+ name: "easel",
63
+ mpoVersion: "^7.9.0",
64
+ pieces: ["webext", "gnome"],
65
+ reconciled: false,
66
+ }),
67
+ );
68
+ expect(readProvenance(dir)).toEqual({
69
+ cliVersion: "7.9.0",
70
+ apps: [],
71
+ services: [],
72
+ pieces: ["gnome", "webext"],
73
+ template: "none",
74
+ name: "easel",
75
+ mpoVersion: "^7.9.0",
76
+ reconciled: false,
77
+ });
78
+ });
79
+
80
+ it("returns undefined for records it cannot use", () => {
81
+ writeFileSync(join(dir, ".mpo.json"), JSON.stringify({ template: "universal" }));
82
+ expect(readProvenance(dir)).toBeUndefined();
83
+ writeFileSync(join(dir, ".mpo.json"), "not json");
84
+ expect(readProvenance(dir)).toBeUndefined();
85
+ });
86
+
87
+ it("mpoRange carets a bare version and passes ranges through", () => {
88
+ expect(mpoRange("7.11.0")).toBe("^7.11.0");
89
+ expect(mpoRange("~7.11.0")).toBe("~7.11.0");
90
+ expect(mpoRange(undefined, "7.11.0")).toBe("^7.11.0");
91
+ });
92
+ });
@@ -0,0 +1,169 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { InitAppPiece } from "./pieces";
5
+
6
+ export const PROVENANCE_FILE = ".mpo.json";
7
+
8
+ /**
9
+ * What `mpo update` needs to rebuild the merge base of a project.
10
+ *
11
+ * A project from `mpo init` records the monorepo commit it was pruned from
12
+ * and the selection made at that time; the scaffold at that commit with
13
+ * that selection IS the merge base. An adopted project (`mpo adopt`,
14
+ * template "none") records only the pieces it hand-copied. A project from
15
+ * the retired template scaffolder (template "universal" / "app") has no
16
+ * reproducible base left; its next update reconciles from an empty tree.
17
+ */
18
+ export interface MpoProvenance {
19
+ cliVersion: string;
20
+ /** The monorepo commit the project was scaffolded from. */
21
+ source?: { sha: string; url?: string };
22
+ /** Product apps derived from apps/one. */
23
+ apps: string[];
24
+ /** Singleton apps kept under apps/ (frappe, keycloak, storybook, ...). */
25
+ services: string[];
26
+ /** Pieces overlaid for adopted projects (sorted). */
27
+ pieces: InitAppPiece[];
28
+ /** "none" = adopted; "universal" / "app" = template-era. Absent on clone-derived projects. */
29
+ template?: "none" | "universal" | "app";
30
+ /** Adopted and template-era projects: the name that renders piece paths (apps/<name>/...). */
31
+ name?: string;
32
+ mpoVersion?: string;
33
+ /**
34
+ * false = adopted but never reconciled: `mpo adopt` wrote provenance
35
+ * without touching a project file, so the first update merges the piece
36
+ * baseline from an EMPTY base and then clears the flag.
37
+ */
38
+ reconciled?: boolean;
39
+ }
40
+
41
+ export function validateName(name: string): string {
42
+ const trimmed = name.trim();
43
+ if (!trimmed) {
44
+ throw new Error("Project name is required");
45
+ }
46
+ if (!/^[a-z][a-z0-9-]*$/.test(trimmed)) {
47
+ throw new Error(
48
+ `Invalid project name "${trimmed}". Use lowercase letters, numbers, and hyphens (e.g. my-app).`,
49
+ );
50
+ }
51
+ return trimmed;
52
+ }
53
+
54
+ /** Root package.json name with any @scope/ prefix stripped (adopt derives the project name from it). */
55
+ export function packageJsonName(projectDir: string): string | undefined {
56
+ try {
57
+ const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf-8")) as {
58
+ name?: string;
59
+ };
60
+ return pkg.name?.replace(/^@[^/]+\//u, "");
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ }
65
+
66
+ export function cliRoot(): string {
67
+ return resolve(dirname(fileURLToPath(import.meta.url)), "../..");
68
+ }
69
+
70
+ export function cliVersion(): string | undefined {
71
+ try {
72
+ const pkg = JSON.parse(readFileSync(join(cliRoot(), "package.json"), "utf-8")) as {
73
+ version?: string;
74
+ };
75
+ return pkg.version;
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ /** `^x.y.z` for a bare version; explicit ranges pass through. */
82
+ export function mpoRange(explicit: string | undefined, fallbackVersion = cliVersion()): string {
83
+ if (explicit) {
84
+ return explicit.startsWith("^") || explicit.startsWith("~") || explicit === "*"
85
+ ? explicit
86
+ : `^${explicit}`;
87
+ }
88
+ return fallbackVersion ? `^${fallbackVersion}` : "^0.0.0";
89
+ }
90
+
91
+ export function writeProvenance(targetDir: string, provenance: MpoProvenance): void {
92
+ const pieces = [...provenance.pieces].sort();
93
+ const record: Record<string, unknown> = provenance.source
94
+ ? {
95
+ cliVersion: provenance.cliVersion,
96
+ source: provenance.source,
97
+ apps: provenance.apps,
98
+ services: [...provenance.services].sort(),
99
+ pieces,
100
+ }
101
+ : {
102
+ template: provenance.template ?? "none",
103
+ cliVersion: provenance.cliVersion,
104
+ name: provenance.name,
105
+ mpoVersion: provenance.mpoVersion ?? `^${provenance.cliVersion}`,
106
+ pieces,
107
+ ...(provenance.reconciled === false ? { reconciled: false } : {}),
108
+ };
109
+ writeFileSync(join(targetDir, PROVENANCE_FILE), `${JSON.stringify(record, null, 2)}\n`);
110
+ }
111
+
112
+ /**
113
+ * Reads both shapes: the clone-derived record ({cliVersion, source, apps,
114
+ * services, pieces}) and the older {template, name} record written by
115
+ * `mpo adopt` and the retired template scaffolder.
116
+ */
117
+ export function readProvenance(projectDir: string): MpoProvenance | undefined {
118
+ const file = join(projectDir, PROVENANCE_FILE);
119
+ if (!existsSync(file)) return undefined;
120
+ let raw: Record<string, unknown>;
121
+ try {
122
+ raw = JSON.parse(readFileSync(file, "utf-8")) as Record<string, unknown>;
123
+ } catch {
124
+ return undefined;
125
+ }
126
+ if (typeof raw.cliVersion !== "string") return undefined;
127
+ const strings = (value: unknown): string[] =>
128
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
129
+ const pieces = strings(raw.pieces).sort() as InitAppPiece[];
130
+ const source = raw.source as { sha?: unknown; url?: unknown } | undefined;
131
+ if (source && typeof source.sha === "string") {
132
+ return {
133
+ cliVersion: raw.cliVersion,
134
+ source: {
135
+ sha: source.sha,
136
+ ...(typeof source.url === "string" ? { url: source.url } : {}),
137
+ },
138
+ apps: strings(raw.apps),
139
+ services: strings(raw.services).sort(),
140
+ pieces,
141
+ };
142
+ }
143
+ const template = raw.template;
144
+ if (template !== "none" && template !== "universal" && template !== "app") {
145
+ // A clone-derived record without a sha cannot be rebuilt.
146
+ if (Array.isArray(raw.apps)) {
147
+ return {
148
+ cliVersion: raw.cliVersion,
149
+ apps: strings(raw.apps),
150
+ services: strings(raw.services).sort(),
151
+ pieces,
152
+ template: "universal",
153
+ name: strings(raw.apps)[0],
154
+ };
155
+ }
156
+ return undefined;
157
+ }
158
+ if (typeof raw.name !== "string") return undefined;
159
+ return {
160
+ cliVersion: raw.cliVersion,
161
+ apps: template === "none" ? [] : [raw.name],
162
+ services: [],
163
+ pieces,
164
+ template,
165
+ name: raw.name,
166
+ mpoVersion: typeof raw.mpoVersion === "string" ? raw.mpoVersion : `^${raw.cliVersion}`,
167
+ ...(raw.reconciled === false ? { reconciled: false } : {}),
168
+ };
169
+ }