@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
@@ -0,0 +1,359 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import spawn from "nano-spawn";
4
+ import { tmpdir } from "node:os";
5
+
6
+ //#region src/commands/merge.ts
7
+ /**
8
+ * The three-way merge behind `mpo update` and `mpo add-app`:
9
+ *
10
+ * base = the scaffold the project started from (recorded in .mpo.json)
11
+ * ours = the project's HEAD (every commit the project made)
12
+ * theirs = the scaffold it is moving to
13
+ *
14
+ * `git merge-tree --write-tree --merge-base=<base>` merges the scaffold's
15
+ * evolution with the project's history; the result lands in the index and
16
+ * worktree, never in a commit, so the project commits when it is happy.
17
+ * `.updateignore` entries (exact files or directory prefixes, not globs) and
18
+ * pnpm-lock.yaml stay pinned to HEAD.
19
+ */
20
+ async function git(projectDir, args, env) {
21
+ return spawn("git", args, {
22
+ cwd: projectDir,
23
+ env: env ? {
24
+ ...process.env,
25
+ ...env
26
+ } : void 0
27
+ });
28
+ }
29
+ async function gitOut(projectDir, args, env) {
30
+ return (await git(projectDir, args, env)).stdout.trim();
31
+ }
32
+ async function projectRootOf(command) {
33
+ return gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
34
+ throw new Error(`${command} must run inside a git repository`);
35
+ });
36
+ }
37
+ /** The merge lands in the worktree, so nothing else may be in flight there. */
38
+ async function requireCleanTree(projectDir, command) {
39
+ await git(projectDir, ["rev-parse", "HEAD"]).catch(() => {
40
+ throw new Error(`${command} requires at least one commit`);
41
+ });
42
+ await git(projectDir, ["diff", "--quiet"]).catch(() => {
43
+ throw new Error(`${command} requires a clean working tree (unstaged changes present)`);
44
+ });
45
+ await git(projectDir, [
46
+ "diff",
47
+ "--cached",
48
+ "--quiet"
49
+ ]).catch(() => {
50
+ throw new Error(`${command} requires a clean working tree (staged changes present)`);
51
+ });
52
+ }
53
+ /** Import a directory tree into the project's object database as a
54
+ * commit (temp index; the worktree is never touched). The directory's own
55
+ * .gitignore applies, so a generated .env never enters the tree. */
56
+ async function commitTreeFromDir(projectDir, dir, message, parent) {
57
+ const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-index-")), "index");
58
+ const env = {
59
+ GIT_INDEX_FILE: indexFile,
60
+ GIT_WORK_TREE: dir
61
+ };
62
+ await git(projectDir, [
63
+ "add",
64
+ "-A",
65
+ "--",
66
+ "."
67
+ ], env);
68
+ const tree = await gitOut(projectDir, ["write-tree"], env);
69
+ const commit = await gitOut(projectDir, parent ? [
70
+ "commit-tree",
71
+ tree,
72
+ "-p",
73
+ parent,
74
+ "-m",
75
+ message
76
+ ] : [
77
+ "commit-tree",
78
+ tree,
79
+ "-m",
80
+ message
81
+ ]);
82
+ rmSync(indexFile, { force: true });
83
+ return commit;
84
+ }
85
+ async function emptyCommit(projectDir, message) {
86
+ const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-index-")), "index");
87
+ const env = { GIT_INDEX_FILE: indexFile };
88
+ await git(projectDir, ["read-tree", "--empty"], env);
89
+ const tree = await gitOut(projectDir, ["write-tree"], env);
90
+ rmSync(indexFile, { force: true });
91
+ return gitOut(projectDir, [
92
+ "commit-tree",
93
+ tree,
94
+ "-m",
95
+ message
96
+ ]);
97
+ }
98
+ /** A tree derived from `tree` where every path under `pins` is replaced by
99
+ * its content in `headCommit` (removed when absent there). */
100
+ async function pinPathsToHead(projectDir, tree, headCommit, pins) {
101
+ const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-pin-")), "index");
102
+ const env = { GIT_INDEX_FILE: indexFile };
103
+ await git(projectDir, ["read-tree", tree], env);
104
+ for (const pin of pins) {
105
+ const inTree = await gitOut(projectDir, [
106
+ "ls-tree",
107
+ "-r",
108
+ "--name-only",
109
+ tree,
110
+ "--",
111
+ pin
112
+ ], env).then((out) => out.split("\n").filter(Boolean)).catch(() => []);
113
+ for (const path of inTree) await git(projectDir, [
114
+ "update-index",
115
+ "--force-remove",
116
+ "--",
117
+ path
118
+ ], env).catch(() => {});
119
+ const headEntries = await gitOut(projectDir, [
120
+ "ls-tree",
121
+ "-r",
122
+ headCommit,
123
+ "--",
124
+ pin
125
+ ], env).catch(() => "");
126
+ for (const line of headEntries.split("\n").filter(Boolean)) {
127
+ const match = line.match(/^(\d+) \S+ ([0-9a-f]+)\t(.+)$/);
128
+ if (!match) continue;
129
+ await git(projectDir, [
130
+ "update-index",
131
+ "--add",
132
+ "--cacheinfo",
133
+ `${match[1]},${match[2]},${match[3]}`
134
+ ], env).catch(() => {});
135
+ }
136
+ }
137
+ const pinned = await gitOut(projectDir, ["write-tree"], env);
138
+ rmSync(indexFile, { force: true });
139
+ return pinned;
140
+ }
141
+ /**
142
+ * Pins: pnpm-lock.yaml plus every .updateignore entry. Entries are exact
143
+ * files or directory prefixes (`git ls-tree -- <path>` semantics); the one
144
+ * natural glob spelling (`dir/**`, `dir/*`) normalizes to its directory and
145
+ * any other glob shape is refused, because a silently dead ignore rule is
146
+ * the worst failure mode.
147
+ */
148
+ function readPins(projectDir) {
149
+ const pins = ["pnpm-lock.yaml"];
150
+ const updateignore = join(projectDir, ".updateignore");
151
+ if (!existsSync(updateignore)) return pins;
152
+ for (const line of readFileSync(updateignore, "utf-8").split("\n")) {
153
+ const pattern = line.trim();
154
+ if (!pattern || pattern.startsWith("#")) continue;
155
+ const normalized = pattern.replace(/\/\*{1,2}$/, "");
156
+ if (/[*?[\]]/.test(normalized)) throw new Error(`.updateignore: "${pattern}" looks like a glob, but entries are exact files or directory prefixes (a glob here would silently pin nothing). List the file or directory instead.`);
157
+ pins.push(normalized.replace(/\/+$/, ""));
158
+ }
159
+ return pins;
160
+ }
161
+ const UNRESOLVED = Symbol("unresolved");
162
+ const ABSENT = Symbol("absent");
163
+ const DEP_MAP_KEYS = new Set([
164
+ "dependencies",
165
+ "devDependencies",
166
+ "peerDependencies",
167
+ "optionalDependencies",
168
+ "overrides",
169
+ "resolutions"
170
+ ]);
171
+ function rangeMinimum(range) {
172
+ if (typeof range !== "string") return void 0;
173
+ const match = range.match(/^[\^~]?(\d+)\.(\d+)\.(\d+)(?:-[\w.]+)?$/);
174
+ if (!match) return void 0;
175
+ return [
176
+ Number(match[1]),
177
+ Number(match[2]),
178
+ Number(match[3])
179
+ ];
180
+ }
181
+ function isPlainObject(value) {
182
+ return typeof value === "object" && value !== null && !Array.isArray(value);
183
+ }
184
+ function jsonEqual(a, b) {
185
+ return JSON.stringify(a) === JSON.stringify(b);
186
+ }
187
+ function mergeNode(base, ours, theirs, inDepMap) {
188
+ if (jsonEqual(ours, theirs)) return ours === void 0 ? ABSENT : ours;
189
+ if (jsonEqual(theirs, base)) return ours === void 0 ? ABSENT : ours;
190
+ if (jsonEqual(ours, base)) return theirs === void 0 ? ABSENT : theirs;
191
+ if (isPlainObject(ours) && isPlainObject(theirs)) {
192
+ const baseObject = isPlainObject(base) ? base : {};
193
+ const merged = {};
194
+ const keys = [...Object.keys(ours), ...Object.keys(theirs).filter((key) => !(key in ours))];
195
+ for (const key of keys) {
196
+ const child = mergeNode(baseObject[key], ours[key], theirs[key], inDepMap || DEP_MAP_KEYS.has(key));
197
+ if (child === UNRESOLVED) return UNRESOLVED;
198
+ if (child !== ABSENT) merged[key] = child;
199
+ }
200
+ return merged;
201
+ }
202
+ if (inDepMap) {
203
+ const oursMin = rangeMinimum(ours);
204
+ const theirsMin = rangeMinimum(theirs);
205
+ if (oursMin && theirsMin) {
206
+ for (let i = 0; i < 3; i++) {
207
+ if (oursMin[i] > theirsMin[i]) return ours;
208
+ if (oursMin[i] < theirsMin[i]) return theirs;
209
+ }
210
+ return theirs;
211
+ }
212
+ }
213
+ return UNRESOLVED;
214
+ }
215
+ /** Structural three-way merge of package.json content, or undefined when a
216
+ * divergence cannot be auto-resolved (the textual markers then stay). */
217
+ function threeWayMergePackageJson(base, ours, theirs) {
218
+ let baseJson;
219
+ let oursJson;
220
+ let theirsJson;
221
+ try {
222
+ baseJson = base === void 0 ? {} : JSON.parse(base);
223
+ oursJson = JSON.parse(ours);
224
+ theirsJson = JSON.parse(theirs);
225
+ } catch {
226
+ return;
227
+ }
228
+ if (!isPlainObject(oursJson) || !isPlainObject(theirsJson)) return void 0;
229
+ const merged = mergeNode(baseJson, oursJson, theirsJson, false);
230
+ if (merged === UNRESOLVED || merged === ABSENT || !isPlainObject(merged)) return void 0;
231
+ return merged;
232
+ }
233
+ async function autoResolveDependencyConflicts(projectDir, mergedTree, conflicts, baseCommit, headCommit, nextCommit) {
234
+ const candidates = conflicts.filter((file) => basename(file) === "package.json");
235
+ if (!candidates.length) return {
236
+ tree: mergedTree,
237
+ conflicts,
238
+ resolved: []
239
+ };
240
+ const scratch = mkdtempSync(join(tmpdir(), "mpo-resolve-"));
241
+ const resolved = [];
242
+ try {
243
+ for (const file of candidates) {
244
+ const show = (commit) => gitOut(projectDir, ["show", `${commit}:${file}`]).catch(() => void 0);
245
+ const [base, ours, theirs] = await Promise.all([
246
+ show(baseCommit),
247
+ show(headCommit),
248
+ show(nextCommit)
249
+ ]);
250
+ if (ours === void 0 || theirs === void 0) continue;
251
+ const merged = threeWayMergePackageJson(base, ours, theirs);
252
+ if (!merged) continue;
253
+ const blobFile = join(scratch, "blob.json");
254
+ writeFileSync(blobFile, `${JSON.stringify(merged, null, 2)}\n`);
255
+ const oid = await gitOut(projectDir, [
256
+ "hash-object",
257
+ "-w",
258
+ "--",
259
+ blobFile
260
+ ]);
261
+ resolved.push({
262
+ path: file,
263
+ oid
264
+ });
265
+ }
266
+ if (!resolved.length) return {
267
+ tree: mergedTree,
268
+ conflicts,
269
+ resolved: []
270
+ };
271
+ const env = { GIT_INDEX_FILE: join(scratch, "index") };
272
+ await git(projectDir, ["read-tree", mergedTree], env);
273
+ for (const entry of resolved) await git(projectDir, [
274
+ "update-index",
275
+ "--add",
276
+ "--cacheinfo",
277
+ `100644,${entry.oid},${entry.path}`
278
+ ], env);
279
+ const tree = await gitOut(projectDir, ["write-tree"], env);
280
+ const resolvedPaths = new Set(resolved.map((entry) => entry.path));
281
+ return {
282
+ tree,
283
+ conflicts: conflicts.filter((file) => !resolvedPaths.has(file)),
284
+ resolved: [...resolvedPaths]
285
+ };
286
+ } finally {
287
+ rmSync(scratch, {
288
+ recursive: true,
289
+ force: true
290
+ });
291
+ }
292
+ }
293
+ /** Compute the merged tree. Pure object-database work; nothing is written to the worktree. */
294
+ async function mergeScaffolds(request) {
295
+ const { projectDir } = request;
296
+ const head = await gitOut(projectDir, ["rev-parse", "HEAD"]);
297
+ const baseCommit = request.baseDir ? await commitTreeFromDir(projectDir, request.baseDir, request.baseLabel) : await emptyCommit(projectDir, request.baseLabel);
298
+ const nextCommit = await commitTreeFromDir(projectDir, request.theirsDir, request.theirsLabel, baseCommit);
299
+ let mergedTree;
300
+ let conflicts = [];
301
+ try {
302
+ mergedTree = (await gitOut(projectDir, [
303
+ "merge-tree",
304
+ "--write-tree",
305
+ "--name-only",
306
+ `--merge-base=${baseCommit}`,
307
+ head,
308
+ nextCommit
309
+ ])).split("\n")[0].trim();
310
+ } catch (err) {
311
+ const lines = (err.stdout ?? "").split("\n");
312
+ if (!lines[0]?.trim()) throw err;
313
+ mergedTree = lines[0].trim();
314
+ const blank = lines.indexOf("", 1);
315
+ conflicts = lines.slice(1, blank === -1 ? void 0 : blank).map((line) => line.trim()).filter(Boolean);
316
+ }
317
+ const pins = readPins(projectDir);
318
+ mergedTree = await pinPathsToHead(projectDir, mergedTree, head, pins);
319
+ conflicts = conflicts.filter((file) => !pins.some((pin) => file === pin || file.startsWith(`${pin}/`)));
320
+ const autoResolved = await autoResolveDependencyConflicts(projectDir, mergedTree, conflicts, baseCommit, head, nextCommit);
321
+ const headTree = await gitOut(projectDir, ["rev-parse", `${head}^{tree}`]);
322
+ return {
323
+ mergedTree: autoResolved.tree,
324
+ changed: autoResolved.tree !== headTree,
325
+ conflicts: autoResolved.conflicts,
326
+ resolved: autoResolved.resolved
327
+ };
328
+ }
329
+ /** Land a tree in the index and worktree. The tree was clean before, so
330
+ * every difference is the merge; nothing is committed. */
331
+ async function landTree(projectDir, tree) {
332
+ await git(projectDir, [
333
+ "read-tree",
334
+ "-u",
335
+ "--reset",
336
+ tree
337
+ ]);
338
+ }
339
+ /** Print the outcome and the commit line; run pnpm install on a clean merge. */
340
+ async function finishMerge(projectDir, outcome, commitMessage, options = {}) {
341
+ for (const file of outcome.resolved) console.log(` auto-resolved: ${file} (dependency ranges)`);
342
+ if (outcome.conflicts.length) {
343
+ console.log("\n⚠️ merge conflicts — resolve the markers, then commit:");
344
+ for (const file of outcome.conflicts) console.log(` ${file}`);
345
+ } else console.log("\n✅ merged cleanly into the index and worktree (nothing committed)");
346
+ if (outcome.conflicts.length) {
347
+ if (!options.skipInstall) console.log("\nSkipping pnpm install until the conflicts are resolved (then run: pnpm install).");
348
+ } else if (!options.skipInstall) {
349
+ console.log("\nInstalling dependencies...");
350
+ await spawn("pnpm", ["install"], {
351
+ cwd: projectDir,
352
+ stdio: "inherit"
353
+ });
354
+ }
355
+ console.log(`\nReview with git status / git diff, then:\n git add -A && git commit -m "${commitMessage}"`);
356
+ }
357
+
358
+ //#endregion
359
+ export { finishMerge, git, gitOut, landTree, mergeScaffolds, projectRootOf, readPins, requireCleanTree, threeWayMergePackageJson };
@@ -0,0 +1,236 @@
1
+ import { cliRoot } from "./provenance.mjs";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { extname, join } from "node:path";
4
+
5
+ //#region src/commands/pieces.ts
6
+ /**
7
+ * Pieces: additive fragment trees under templates/pieces/<piece>/ that an
8
+ * ADOPTED project (`mpo adopt`) hand-copied and wants `mpo update` to keep
9
+ * reconciling. A project from `mpo init` never needs them: apps/one already
10
+ * ships gnome/, tauri/, vscode/ and webext/, and frappe / keycloak are
11
+ * `--service` selections of the real apps. Keep sorted: pieces are recorded
12
+ * sorted in provenance and overlaid in this order.
13
+ */
14
+ const INIT_APP_PIECES = [
15
+ "frappe",
16
+ "frappe-bench",
17
+ "gnome",
18
+ "keycloak",
19
+ "tauri",
20
+ "vscode",
21
+ "webext"
22
+ ];
23
+ const PIECE_DESCRIPTIONS = {
24
+ frappe: "Frappe client wiring (env keys, provider)",
25
+ "frappe-bench": "Frappe dev bench (docker compose: bench, MariaDB, Redis)",
26
+ gnome: "GNOME desktop target (GTK4/GJS via react-gnome — native widgets, no webview)",
27
+ keycloak: "Keycloak auth wiring (env keys, provider, docker compose + realm)",
28
+ tauri: "Tauri desktop target (src-tauri skeleton — webview shell, Rust toolchain)",
29
+ vscode: "VS Code extension target (extension host + Tamagui webview)",
30
+ webext: "Browser extension target (MV3 popup + background)"
31
+ };
32
+ function piecesRoot() {
33
+ return join(cliRoot(), "templates/pieces");
34
+ }
35
+ function toPascalCase(name) {
36
+ return name.split(/[-_\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
37
+ }
38
+ /**
39
+ * Fragment source dirs for a piece: shared/ plus universal/ (the layout
40
+ * adopted repos follow: apps/<name>/...). A piece is available when at least
41
+ * one of the two exists.
42
+ */
43
+ function pieceFragmentDirs(piece, root) {
44
+ return [join(root, piece, "shared"), join(root, piece, "universal")].filter((dir) => existsSync(dir));
45
+ }
46
+ /**
47
+ * npm always strips .gitignore / .npmrc (and newer npms .env*) from published
48
+ * tarballs, so the template stores them without the leading dot; restore it
49
+ * at scaffold time.
50
+ */
51
+ const DOTFILE_NAMES = {
52
+ gitignore: ".gitignore",
53
+ npmrc: ".npmrc",
54
+ "env.example": ".env.example"
55
+ };
56
+ function render(content, vars) {
57
+ 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);
58
+ }
59
+ /** Assets copied byte-for-byte; the utf-8 render pass would corrupt them. */
60
+ const BINARY_EXTENSIONS = new Set([
61
+ ".woff2",
62
+ ".woff",
63
+ ".ttf",
64
+ ".otf",
65
+ ".png",
66
+ ".jpg",
67
+ ".ico",
68
+ ".icns"
69
+ ]);
70
+ function isJsonObject(value) {
71
+ return typeof value === "object" && value !== null && !Array.isArray(value);
72
+ }
73
+ /**
74
+ * Deep-merge for JSON partials: objects merge recursively (fragment keys
75
+ * append after base keys, so e.g. package.json script order stays stable),
76
+ * arrays union (base order first), scalars/type-mismatches take the
77
+ * fragment value.
78
+ */
79
+ function mergeJsonValues(base, fragment) {
80
+ if (Array.isArray(base) && Array.isArray(fragment)) {
81
+ const merged = [...base];
82
+ const seen = new Set(base.map((item) => JSON.stringify(item)));
83
+ for (const item of fragment) {
84
+ const key = JSON.stringify(item);
85
+ if (seen.has(key)) continue;
86
+ seen.add(key);
87
+ merged.push(item);
88
+ }
89
+ return merged;
90
+ }
91
+ if (isJsonObject(base) && isJsonObject(fragment)) {
92
+ const merged = { ...base };
93
+ for (const [key, value] of Object.entries(fragment)) merged[key] = key in merged ? mergeJsonValues(merged[key], value) : value;
94
+ return merged;
95
+ }
96
+ return fragment;
97
+ }
98
+ const SORTED_DEPENDENCY_FIELDS = [
99
+ "dependencies",
100
+ "devDependencies",
101
+ "peerDependencies",
102
+ "optionalDependencies"
103
+ ];
104
+ /** Dependency maps stay sorted after fragment merges so overlay order never changes the bytes. */
105
+ function sortDependencyFields(value) {
106
+ if (!isJsonObject(value)) return value;
107
+ const result = { ...value };
108
+ for (const field of SORTED_DEPENDENCY_FIELDS) {
109
+ const deps = result[field];
110
+ if (!isJsonObject(deps)) continue;
111
+ result[field] = Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
112
+ }
113
+ return result;
114
+ }
115
+ /**
116
+ * Apply a rendered `<target>.partial` fragment onto the target file:
117
+ * - `.json` targets deep-merge (see mergeJsonValues)
118
+ * - everything else appends as a text block separated by a blank line
119
+ * Missing targets are created from the fragment alone.
120
+ */
121
+ function applyPartial(targetPath, renderedFragment) {
122
+ if (targetPath.endsWith(".json")) {
123
+ const fragment = JSON.parse(renderedFragment);
124
+ const base = existsSync(targetPath) ? JSON.parse(readFileSync(targetPath, "utf-8")) : void 0;
125
+ const merged = sortDependencyFields(base === void 0 ? fragment : mergeJsonValues(base, fragment));
126
+ writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
127
+ return;
128
+ }
129
+ const block = `${renderedFragment.replace(/^\n+/u, "").replace(/\n+$/u, "")}\n`;
130
+ if (!existsSync(targetPath)) {
131
+ writeFileSync(targetPath, block);
132
+ return;
133
+ }
134
+ writeFileSync(targetPath, `${readFileSync(targetPath, "utf-8").replace(/\n+$/u, "")}\n\n${block}`);
135
+ }
136
+ const PARTIAL_SUFFIX = ".partial";
137
+ /**
138
+ * Overlay a piece fragment tree: regular files render and overwrite,
139
+ * `<target>.partial` files merge into (or create) their target.
140
+ */
141
+ function overlayTree(srcDir, destDir, vars) {
142
+ mkdirSync(destDir, { recursive: true });
143
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
144
+ const from = join(srcDir, entry.name);
145
+ if (entry.isDirectory()) {
146
+ overlayTree(from, join(destDir, render(DOTFILE_NAMES[entry.name] ?? entry.name, vars)), vars);
147
+ continue;
148
+ }
149
+ if (entry.name.endsWith(PARTIAL_SUFFIX)) {
150
+ const targetName = entry.name.slice(0, -8);
151
+ applyPartial(join(destDir, render(DOTFILE_NAMES[targetName] ?? targetName, vars)), render(readFileSync(from, "utf-8"), vars));
152
+ continue;
153
+ }
154
+ const to = join(destDir, render(DOTFILE_NAMES[entry.name] ?? entry.name, vars));
155
+ if (BINARY_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
156
+ copyFileSync(from, to);
157
+ continue;
158
+ }
159
+ writeFileSync(to, render(readFileSync(from, "utf-8"), vars));
160
+ }
161
+ }
162
+ /**
163
+ * Validate + canonicalize a piece selection: unknown names throw, duplicates
164
+ * collapse, order is always sorted, and every piece must ship a fragment.
165
+ */
166
+ function normalizePieces(pieces, root = piecesRoot()) {
167
+ const known = new Set(INIT_APP_PIECES);
168
+ const normalized = [...new Set(pieces)].sort();
169
+ for (const piece of normalized) {
170
+ if (!known.has(piece)) throw new Error(`Unknown piece "${piece}". Available pieces: ${INIT_APP_PIECES.join(", ")}`);
171
+ if (pieceFragmentDirs(piece, root).length === 0) throw new Error(`Piece "${piece}" ships no fragments under ${root}`);
172
+ }
173
+ return normalized;
174
+ }
175
+ /**
176
+ * The frappe piece carried the dev bench until the bench became its own piece
177
+ * (MPO-241). An adoption recorded against pieces that still bundle it keeps
178
+ * the bench, now as frappe-bench, so an update does not delete it.
179
+ */
180
+ function withSplitPieces(pieces, recordedRoot) {
181
+ if (!recordedRoot || !pieces.includes("frappe") || pieces.includes("frappe-bench")) return pieces;
182
+ if (!existsSync(join(recordedRoot, "frappe/universal/docker/compose.frappe.yaml"))) return pieces;
183
+ return [...pieces, "frappe-bench"].sort();
184
+ }
185
+ function availablePieces(root = piecesRoot()) {
186
+ return INIT_APP_PIECES.filter((piece) => pieceFragmentDirs(piece, root).length > 0);
187
+ }
188
+ function pieceDescription(piece) {
189
+ return PIECE_DESCRIPTIONS[piece];
190
+ }
191
+ /**
192
+ * apps/<name>/src/app.ts for the selected backend pieces: each provider
193
+ * adds one createApp key. Only written when a provider piece is selected.
194
+ */
195
+ function composeAppTs(pieces) {
196
+ const frappe = pieces.includes("frappe");
197
+ const keycloak = pieces.includes("keycloak");
198
+ const lines = ["import { createApp } from \"@multiplatform.one/core\";"];
199
+ if (frappe || keycloak) lines.push("import { config } from \"@multiplatform.one/platform\";");
200
+ lines.push("import { themeConfig } from \"@package/themes\";");
201
+ lines.push("", "/**", " * App-wide provider stack. Providers below were enabled by the pieces", " * this project adopted — 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,");
202
+ if (frappe) {
203
+ lines.push(" // Frappe client: FrappeProvider mounts when FRAPPE_ENABLED=1 (a", " // packages/config public env key, loaded from .env — see", " // .env.example). FRAPPE_URL names the Frappe server (default:", " // BASE_URL, then http://localhost:8000).");
204
+ if (pieces.includes("frappe-bench")) lines.push(" // docker/compose.frappe.yaml runs a dev bench.");
205
+ lines.push(" frappe: { baseURL: config.get(\"FRAPPE_URL\") },");
206
+ }
207
+ if (keycloak) lines.push(" // Keycloak auth: 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\") },");
208
+ lines.push("});", "");
209
+ return lines.join("\n");
210
+ }
211
+ /**
212
+ * The tool-owned surface of an adopted project: the selected piece
213
+ * fragments overlaid, in sorted order, on an EMPTY tree. `mpo update`
214
+ * builds both merge baselines of an adopted project with this.
215
+ */
216
+ function scaffoldPieces(targetDir, request) {
217
+ const root = request.root ?? piecesRoot();
218
+ const pieces = normalizePieces(request.pieces, root);
219
+ if (!pieces.length) throw new Error(`A pieces scaffold needs at least one piece (${INIT_APP_PIECES.map((piece) => `--${piece}`).join(", ")})`);
220
+ const vars = {
221
+ NAME: request.name,
222
+ NAME_PASCAL: toPascalCase(request.name),
223
+ NAME_APPID: request.name.replaceAll("-", ""),
224
+ MPO_VERSION: request.mpoVersion,
225
+ YEAR: String((/* @__PURE__ */ new Date()).getFullYear())
226
+ };
227
+ mkdirSync(targetDir, { recursive: true });
228
+ for (const piece of pieces) for (const fragmentDir of pieceFragmentDirs(piece, root)) overlayTree(fragmentDir, targetDir, vars);
229
+ if (pieces.includes("frappe") || pieces.includes("keycloak")) {
230
+ mkdirSync(join(targetDir, "apps", request.name, "src"), { recursive: true });
231
+ writeFileSync(join(targetDir, "apps", request.name, "src/app.ts"), composeAppTs(pieces));
232
+ }
233
+ }
234
+
235
+ //#endregion
236
+ export { INIT_APP_PIECES, availablePieces, composeAppTs, mergeJsonValues, normalizePieces, pieceDescription, piecesRoot, scaffoldPieces, withSplitPieces };