@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
@@ -1,573 +1,256 @@
1
- import { generateVscodeConfig } from "../generateVscode.mjs";
2
- import { PROVENANCE_FILE, cliVersion, initApp, packageJsonName, readProvenance, validateName, writeProvenance } from "./initApp.mjs";
3
- import { basename, join } from "node:path";
1
+ import { PROVENANCE_FILE, cliVersion, mpoRange, packageJsonName, readProvenance, writeProvenance } from "./provenance.mjs";
2
+ import { isGitUrl, monorepoCheckout, monorepoUrl, resolveSource } from "../source.mjs";
3
+ import { finishMerge, git, gitOut, landTree, mergeScaffolds, projectRootOf, requireCleanTree } from "./merge.mjs";
4
+ import { archiveSubtree, scaffoldToTemp } from "./scaffold.mjs";
5
+ import { scaffoldPieces, withSplitPieces } from "./pieces.mjs";
4
6
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
- import spawn from "nano-spawn";
7
+ import { basename, join } from "node:path";
6
8
  import { tmpdir } from "node:os";
7
9
 
8
10
  //#region src/commands/updateApp.ts
9
11
  /**
10
- * `mpo update` for scaffolded consumer projects — the modern successor of
11
- * the old cookiecutter flow (init a fresh project, swap your .git in, merge
12
- * your changes on top), rebuilt with a TRUE three-way merge:
12
+ * `mpo update`: bring the monorepo's changes into a project as a three-way
13
+ * merge that is NEVER committed.
13
14
  *
14
- * base = the ORIGINAL scaffold, regenerated from the CLI version recorded
15
- * in .mpo.json at init time (published CLIs ship their templates,
16
- * so `pnpm dlx @multiplatform.one/cli@<old> init` reproduces it)
17
- * ours = the project's HEAD (all your custom changes)
18
- * theirs= a fresh scaffold from the CURRENT CLI version
15
+ * base = the scaffold the project started from: the monorepo at the
16
+ * commit recorded in .mpo.json, pruned with the recorded apps
17
+ * and services
18
+ * ours = the project's HEAD (every commit the project made)
19
+ * theirs = the same scaffold at the commit the running CLI was built from
20
+ * (or --source/--checkout)
19
21
  *
20
- * `git merge-tree --write-tree --merge-base=<base>` merges template
21
- * evolution with your customizations; conflicts land in the worktree with
22
- * normal conflict markers. An `.updateignore` file (one exact file or
23
- * directory prefix per line — not globs; `dir/**` is normalized, other
24
- * glob shapes are refused) pins matching paths to your HEAD version.
22
+ * Every product app is a renamed apps/one on both sides, so a change to
23
+ * the One app lands in each of them. The merged tree goes into the index
24
+ * and worktree; the commit line is printed; a dirty tree is refused up
25
+ * front. Adopted projects (`mpo adopt`) merge their piece fragments the
26
+ * same way; projects from the retired template scaffolder reconcile from an
27
+ * empty base once and are clone-derived afterwards.
25
28
  */
26
- async function git(projectDir, args, env) {
27
- return spawn("git", args, {
28
- cwd: projectDir,
29
- env: env ? {
30
- ...process.env,
31
- ...env
32
- } : void 0
33
- });
29
+ function isSha(ref) {
30
+ return /^[0-9a-f]{40}$/.test(ref);
34
31
  }
35
- async function gitOut(projectDir, args, env) {
36
- return (await git(projectDir, args, env)).stdout.trim();
32
+ /** The commit a source spec names, when it can be known without scaffolding. */
33
+ async function resolveSha(spec) {
34
+ if (isSha(spec.ref)) return spec.ref;
35
+ if (isGitUrl(spec.source)) return void 0;
36
+ return gitOut(spec.source, [
37
+ "rev-parse",
38
+ "--verify",
39
+ `${spec.ref}^{commit}`
40
+ ]).catch(() => void 0);
37
41
  }
38
- /** Import a directory tree into the project's object database and return a
39
- * root-less commit for it (temp index; the worktree is never touched). */
40
- async function commitTreeFromDir(projectDir, dir, message, parent) {
41
- const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-index-")), "index");
42
- const env = {
43
- GIT_INDEX_FILE: indexFile,
44
- GIT_WORK_TREE: dir
45
- };
42
+ async function dropStalePnpmfileChecksum(projectDir) {
43
+ const lockfile = join(projectDir, "pnpm-lock.yaml");
44
+ if (existsSync(join(projectDir, ".pnpmfile.cjs")) || !existsSync(lockfile)) return;
45
+ const lock = readFileSync(lockfile, "utf-8");
46
+ const stripped = lock.replace(/^pnpmfileChecksum: .*\n\n?/m, "");
47
+ if (stripped === lock) return;
48
+ writeFileSync(lockfile, stripped);
46
49
  await git(projectDir, [
47
50
  "add",
48
- "-A",
49
- "--force",
50
51
  "--",
51
- "."
52
- ], env);
53
- const tree = await gitOut(projectDir, ["write-tree"], env);
54
- const commit = await gitOut(projectDir, parent ? [
55
- "commit-tree",
56
- tree,
57
- "-p",
58
- parent,
59
- "-m",
60
- message
61
- ] : [
62
- "commit-tree",
63
- tree,
64
- "-m",
65
- message
52
+ "pnpm-lock.yaml"
66
53
  ]);
67
- rmSync(indexFile, { force: true });
68
- return commit;
69
54
  }
70
- /** Scaffold a project with a specific published CLI version into tmp and
71
- * return the generated project dir. Falls back to the local generator when
72
- * the requested version matches the running CLI (or dlx fails). */
73
- async function scaffoldBaseline(version, name, template, mpoVersion, pieces) {
74
- const parent = mkdtempSync(join(tmpdir(), "mpo-update-"));
75
- const cleanup = () => rmSync(parent, {
76
- recursive: true,
77
- force: true
78
- });
79
- const current = cliVersion();
80
- const templateFlag = template === "none" ? ["--pieces-only"] : template === "app" ? ["--web"] : ["--universal"];
81
- const [major = 0, minor = 0] = version.split(".").map(Number);
82
- const scaffoldCapable = template === "none" ? major > 6 || major === 6 && minor >= 6 : major > 6 || major === 6 && minor >= 3;
83
- const piecesCapable = major > 6 || major === 6 && minor >= 5;
84
- const pieceFlags = piecesCapable ? pieces.map((piece) => `--${piece}`) : [];
85
- if (pieces.length && scaffoldCapable && !piecesCapable && version !== current) console.warn(`⚠️ @multiplatform.one/cli@${version} predates composable pieces (6.5.0); the merge base is scaffolded without them.`);
86
- if (!scaffoldCapable) {
87
- if (version !== current) console.warn(template === "none" ? `⚠️ @multiplatform.one/cli@${version} predates pieces-only baselines (6.6.0); using the current CLI's piece templates as the merge base (the merge degrades to two-way: template changes since that version won't land).` : `⚠️ @multiplatform.one/cli@${version} predates the consumer scaffolder (6.3.0); using the current CLI's template as the merge base.`);
88
- } else if (version !== current) {
89
- for (let attempt = 1; attempt <= 2; attempt++) try {
90
- await spawn("pnpm", [
91
- "--package",
92
- `@multiplatform.one/cli@${version}`,
93
- "dlx",
94
- "mpo",
95
- "init",
96
- name,
97
- "--yes",
98
- "--skip-install",
99
- "--mpo-version",
100
- mpoVersion,
101
- ...templateFlag,
102
- ...pieceFlags
103
- ], {
104
- cwd: parent,
105
- stdio: "inherit"
106
- });
107
- const dir = join(parent, name);
108
- if (existsSync(join(dir, template === "none" ? ".mpo.json" : "package.json"))) return {
109
- dir,
110
- cleanup
111
- };
112
- console.warn(`⚠️ dlx scaffold for @multiplatform.one/cli@${version} produced no project`);
113
- break;
114
- } catch {
115
- rmSync(join(parent, name), {
116
- recursive: true,
117
- force: true
118
- });
119
- if (attempt === 2) console.warn(`⚠️ could not scaffold baseline with @multiplatform.one/cli@${version} (network?);`);
120
- }
121
- console.warn(" falling back to the current CLI's template as the merge base.");
122
- }
123
- const previousCwd = process.cwd();
124
- process.chdir(parent);
125
- try {
126
- await initApp(name, {
127
- skipInstall: true,
128
- skipGit: true,
129
- yes: true,
130
- template,
131
- version: mpoVersion,
132
- pieces
133
- });
134
- } finally {
135
- process.chdir(previousCwd);
136
- }
55
+ function tempDir() {
56
+ const dir = mkdtempSync(join(tmpdir(), "mpo-update-"));
137
57
  return {
138
- dir: join(parent, name),
139
- cleanup
58
+ dir,
59
+ cleanup: () => rmSync(dir, {
60
+ recursive: true,
61
+ force: true
62
+ })
140
63
  };
141
64
  }
142
- /** Return a tree derived from `tree` where every path matching `pins` is
143
- * replaced by its content in `headCommit` (removed when absent there).
144
- * Pure index plumbing — the worktree is untouched. */
145
- async function pinPathsToHead(projectDir, tree, headCommit, pins) {
146
- const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-pin-")), "index");
147
- const env = { GIT_INDEX_FILE: indexFile };
148
- await git(projectDir, ["read-tree", tree], env);
149
- for (const pin of pins) {
150
- const inTree = await gitOut(projectDir, [
151
- "ls-tree",
152
- "-r",
153
- "--name-only",
154
- tree,
155
- "--",
156
- pin
157
- ], env).then((out) => out.split("\n").filter(Boolean)).catch(() => []);
158
- for (const path of inTree) await git(projectDir, [
159
- "update-index",
160
- "--force-remove",
161
- "--",
162
- path
163
- ], env).catch(() => {});
164
- const headEntries = await gitOut(projectDir, [
165
- "ls-tree",
166
- "-r",
167
- headCommit,
168
- "--",
169
- pin
170
- ], env).catch(() => "");
171
- for (const line of headEntries.split("\n").filter(Boolean)) {
172
- const match = line.match(/^(\d+) \S+ ([0-9a-f]+)\t(.+)$/);
173
- if (!match) continue;
174
- await git(projectDir, [
175
- "update-index",
176
- "--add",
177
- "--cacheinfo",
178
- `${match[1]},${match[2]},${match[3]}`
179
- ], env).catch(() => {});
180
- }
181
- }
182
- const pinned = await gitOut(projectDir, ["write-tree"], env);
183
- rmSync(indexFile, { force: true });
184
- return pinned;
185
- }
186
- const UNRESOLVED = Symbol("unresolved");
187
- /** Deleted-on-one-side sentinel (distinct from a key that never existed). */
188
- const ABSENT = Symbol("absent");
189
- const DEP_MAP_KEYS = new Set([
190
- "dependencies",
191
- "devDependencies",
192
- "peerDependencies",
193
- "optionalDependencies",
194
- "overrides",
195
- "resolutions"
196
- ]);
197
- /** Minimum concrete version of a simple range (`^x.y.z`, `~x.y.z`,
198
- * `x.y.z`, optional prerelease). Anything fancier is not auto-resolved. */
199
- function rangeMinimum(range) {
200
- if (typeof range !== "string") return void 0;
201
- const match = range.match(/^[\^~]?(\d+)\.(\d+)\.(\d+)(?:-[\w.]+)?$/);
202
- if (!match) return void 0;
203
- return [
204
- Number(match[1]),
205
- Number(match[2]),
206
- Number(match[3])
207
- ];
208
- }
209
- function isPlainObject(value) {
210
- return typeof value === "object" && value !== null && !Array.isArray(value);
211
- }
212
- function jsonEqual(a, b) {
213
- return JSON.stringify(a) === JSON.stringify(b);
214
- }
215
- function mergeNode(base, ours, theirs, inDepMap) {
216
- if (jsonEqual(ours, theirs)) return ours === void 0 ? ABSENT : ours;
217
- if (jsonEqual(theirs, base)) return ours === void 0 ? ABSENT : ours;
218
- if (jsonEqual(ours, base)) return theirs === void 0 ? ABSENT : theirs;
219
- if (isPlainObject(ours) && isPlainObject(theirs)) {
220
- const baseObject = isPlainObject(base) ? base : {};
221
- const merged = {};
222
- const keys = [...Object.keys(ours), ...Object.keys(theirs).filter((key) => !(key in ours))];
223
- for (const key of keys) {
224
- const child = mergeNode(baseObject[key], ours[key], theirs[key], inDepMap || DEP_MAP_KEYS.has(key));
225
- if (child === UNRESOLVED) return UNRESOLVED;
226
- if (child !== ABSENT) merged[key] = child;
227
- }
228
- return merged;
229
- }
230
- if (inDepMap) {
231
- const oursMin = rangeMinimum(ours);
232
- const theirsMin = rangeMinimum(theirs);
233
- if (oursMin && theirsMin) {
234
- for (let i = 0; i < 3; i++) {
235
- if (oursMin[i] > theirsMin[i]) return ours;
236
- if (oursMin[i] < theirsMin[i]) return theirs;
237
- }
238
- return theirs;
239
- }
240
- }
241
- return UNRESOLVED;
242
- }
243
- /** Structural three-way merge of package.json content. Returns the merged
244
- * object, or undefined when a divergence can't be auto-resolved (the file
245
- * then keeps its textual conflict markers for manual resolution). */
246
- function threeWayMergePackageJson(base, ours, theirs) {
247
- let baseJson;
248
- let oursJson;
249
- let theirsJson;
250
- try {
251
- baseJson = base === void 0 ? {} : JSON.parse(base);
252
- oursJson = JSON.parse(ours);
253
- theirsJson = JSON.parse(theirs);
254
- } catch {
255
- return;
256
- }
257
- if (!isPlainObject(oursJson) || !isPlainObject(theirsJson)) return void 0;
258
- const merged = mergeNode(baseJson, oursJson, theirsJson, false);
259
- if (merged === UNRESOLVED || merged === ABSENT || !isPlainObject(merged)) return void 0;
260
- return merged;
261
- }
262
- /** Replace auto-resolvable conflicted package.json files in `mergedTree`
263
- * with their structural merge. Pure index plumbing, worktree untouched. */
264
- async function autoResolveDependencyConflicts(projectDir, mergedTree, conflicts, baseCommit, headCommit, nextCommit) {
265
- const candidates = conflicts.filter((file) => basename(file) === "package.json");
266
- if (!candidates.length) return {
267
- tree: mergedTree,
268
- conflicts,
269
- resolved: []
270
- };
271
- const scratch = mkdtempSync(join(tmpdir(), "mpo-resolve-"));
272
- const resolved = [];
65
+ /**
66
+ * templates/pieces as they were at another CLI version: the monorepo tag
67
+ * v<version>, from the CLI's source (or --source). Falls back to the
68
+ * current templates with a warning, which degrades that merge to two-way.
69
+ */
70
+ async function piecesRootAt(version, options) {
71
+ if (version === cliVersion()) return { cleanup: () => {} };
72
+ const scratch = tempDir();
73
+ const source = options.source ?? monorepoCheckout() ?? "https://git.corp.bitspur.com/multiplatform.one/mpo.git";
273
74
  try {
274
- for (const file of candidates) {
275
- const show = (commit) => gitOut(projectDir, ["show", `${commit}:${file}`]).catch(() => void 0);
276
- const [base, ours, theirs] = await Promise.all([
277
- show(baseCommit),
278
- show(headCommit),
279
- show(nextCommit)
280
- ]);
281
- if (ours === void 0 || theirs === void 0) continue;
282
- const merged = threeWayMergePackageJson(base, ours, theirs);
283
- if (!merged) continue;
284
- const blobFile = join(scratch, "blob.json");
285
- writeFileSync(blobFile, `${JSON.stringify(merged, null, 2)}\n`);
286
- const oid = await gitOut(projectDir, [
287
- "hash-object",
288
- "-w",
289
- "--",
290
- blobFile
291
- ]);
292
- resolved.push({
293
- path: file,
294
- oid
295
- });
296
- }
297
- if (!resolved.length) return {
298
- tree: mergedTree,
299
- conflicts,
300
- resolved: []
301
- };
302
- const env = { GIT_INDEX_FILE: join(scratch, "index") };
303
- await git(projectDir, ["read-tree", mergedTree], env);
304
- for (const entry of resolved) await git(projectDir, [
305
- "update-index",
306
- "--add",
307
- "--cacheinfo",
308
- `100644,${entry.oid},${entry.path}`
309
- ], env);
310
- const tree = await gitOut(projectDir, ["write-tree"], env);
311
- const resolvedPaths = new Set(resolved.map((entry) => entry.path));
75
+ await archiveSubtree(source, `v${version}`, "public/cli/templates/pieces", scratch.dir);
312
76
  return {
313
- tree,
314
- conflicts: conflicts.filter((file) => !resolvedPaths.has(file)),
315
- resolved: [...resolvedPaths]
77
+ root: join(scratch.dir, "public/cli/templates/pieces"),
78
+ cleanup: scratch.cleanup
316
79
  };
317
- } finally {
318
- rmSync(scratch, {
319
- recursive: true,
320
- force: true
321
- });
80
+ } catch {
81
+ scratch.cleanup();
82
+ console.warn(`⚠️ could not read templates/pieces at v${version} from ${source}; using the current pieces as the merge base (changes since that version won't land).`);
83
+ return { cleanup: () => {} };
322
84
  }
323
85
  }
324
- /**
325
- * Pre-provenance bootstrap (`mpo update --assume-version <cliVersion>`):
326
- * projects scaffolded by CLIs older than the provenance file (≤6.3.0) have
327
- * no .mpo.json. The user asserts which CLI version (and template) produced
328
- * the project; provenance is written and committed, then the normal update
329
- * proceeds against that baseline.
330
- */
331
- async function bootstrapProvenance(projectDir, options) {
332
- const version = options.assumeVersion;
333
- if (!/^\d+\.\d+\.\d+(?:-[\w.]+)?$/u.test(version)) throw new Error(`--assume-version must be an exact CLI version (e.g. 6.3.0), got "${version}"`);
334
- const rawName = packageJsonName(projectDir);
335
- if (!rawName) throw new Error(`Could not derive the project name from package.json — create ${PROVENANCE_FILE} by hand with { template, cliVersion, name } instead`);
336
- const provenance = {
337
- template: options.assumeTemplate ?? "universal",
338
- cliVersion: version,
339
- name: validateName(rawName),
340
- mpoVersion: `^${version}`,
341
- pieces: []
342
- };
343
- console.log(`\nBootstrapping provenance: assuming this project was scaffolded by cli@${version} (${provenance.template} template).`);
344
- writeProvenance(projectDir, provenance);
345
- await git(projectDir, [
346
- "add",
347
- "--",
348
- PROVENANCE_FILE
349
- ]);
350
- await git(projectDir, [
351
- "commit",
352
- "--quiet",
353
- "-m",
354
- `chore: record assumed mpo scaffold provenance (cli@${version})`
355
- ]).catch(() => {
356
- console.warn("⚠️ git commit failed (missing git identity?) — provenance left staged.");
357
- });
358
- return provenance;
359
- }
360
- async function updateApp(options = {}) {
361
- const projectDir = await gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
362
- throw new Error("mpo update must run inside a git repository");
363
- });
364
- await git(projectDir, ["diff", "--quiet"]).catch(() => {
365
- throw new Error("mpo update requires a clean working tree (unstaged changes present)");
366
- });
367
- await git(projectDir, [
368
- "diff",
369
- "--cached",
370
- "--quiet"
371
- ]).catch(() => {
372
- throw new Error("mpo update requires a clean working tree (staged changes present)");
373
- });
374
- let provenance = readProvenance(projectDir);
375
- if (options.assumeVersion) {
376
- if (provenance) throw new Error(`--assume-version is for projects without ${PROVENANCE_FILE} — this project already has provenance (cli@${provenance.cliVersion})`);
377
- provenance = await bootstrapProvenance(projectDir, options);
378
- }
379
- if (!provenance) throw new Error(`${PROVENANCE_FILE} not found — this project predates scaffold provenance.\n - scaffolded by an old CLI (pre-6.3)? bootstrap it:
380
- mpo update --assume-version <cliVersion> [--template universal|app]
381
- - never scaffolded (hand-adopted pieces)? bring it under management:
382
- mpo adopt --<piece> [...]`);
86
+ async function updateAdopted(projectDir, provenance, options) {
383
87
  const currentVersion = cliVersion() ?? "0.0.0";
384
- const targetMpoRange = options.version ? options.version.startsWith("^") || options.version.startsWith("~") ? options.version : `^${options.version}` : `^${currentVersion}`;
385
- const adopted = provenance.template === "none";
386
- const firstReconcile = adopted && provenance.reconciled === false;
387
- console.log(`\nmpo update: ${provenance.cliVersion} → ${currentVersion}`);
388
- console.log(` template: ${provenance.template}${adopted ? " (adopted)" : ""}`);
389
- if (provenance.pieces.length) console.log(` pieces: ${provenance.pieces.join(", ")}`);
390
- console.log(` project: ${provenance.name}\n`);
88
+ const targetRange = mpoRange(options.version, currentVersion);
89
+ const name = provenance.name;
90
+ const firstReconcile = provenance.reconciled === false;
91
+ console.log(`\nmpo update (adopted): cli ${provenance.cliVersion} → ${currentVersion}`);
92
+ console.log(` pieces ${provenance.pieces.join(", ")}`);
93
+ console.log(` project ${name}\n`);
391
94
  if (firstReconcile) {
392
95
  console.log("⚠️ FIRST UPDATE AFTER ADOPTION: piece files are reconciled against the");
393
96
  console.log(" piece templates from an empty merge base. Diffs and conflicts on");
394
97
  console.log(" hand-copied piece files are EXPECTED — resolve the markers and");
395
98
  console.log(" commit. Later updates merge three-way and stay quiet.\n");
396
99
  }
397
- let base;
398
- if (firstReconcile) {
399
- console.log("Using an empty merge base (first update after adoption)...");
400
- const dir = mkdtempSync(join(tmpdir(), "mpo-update-"));
401
- base = {
402
- dir,
403
- cleanup: () => rmSync(dir, {
404
- recursive: true,
405
- force: true
406
- })
407
- };
408
- } else {
409
- console.log(`Scaffolding merge base (cli@${provenance.cliVersion})...`);
410
- base = await scaffoldBaseline(provenance.cliVersion, provenance.name, provenance.template, provenance.mpoVersion, provenance.pieces);
411
- }
412
- console.log(`Scaffolding update target (cli@${currentVersion})...`);
413
- const next = await scaffoldBaseline(currentVersion, provenance.name, provenance.template, targetMpoRange, provenance.pieces);
414
- rmSync(join(base.dir, ".mpo.json"), { force: true });
415
- rmSync(join(next.dir, ".mpo.json"), { force: true });
100
+ const base = tempDir();
101
+ const theirs = tempDir();
102
+ let oldPieces = { cleanup: () => {} };
103
+ let pieces = provenance.pieces;
416
104
  try {
417
- const head = await gitOut(projectDir, ["rev-parse", "HEAD"]);
418
- const baseCommit = await commitTreeFromDir(projectDir, base.dir, `mpo scaffold ${provenance.template}@${provenance.cliVersion}`);
419
- const nextCommit = await commitTreeFromDir(projectDir, next.dir, `mpo scaffold ${provenance.template}@${currentVersion}`, baseCommit);
420
- let mergedTree;
421
- let conflicts = [];
422
- try {
423
- mergedTree = (await gitOut(projectDir, [
424
- "merge-tree",
425
- "--write-tree",
426
- "--name-only",
427
- `--merge-base=${baseCommit}`,
428
- head,
429
- nextCommit
430
- ])).split("\n")[0].trim();
431
- } catch (err) {
432
- const lines = (err.stdout ?? "").split("\n");
433
- if (!lines[0]?.trim()) throw err;
434
- mergedTree = lines[0].trim();
435
- const blank = lines.indexOf("", 1);
436
- conflicts = lines.slice(1, blank === -1 ? void 0 : blank).map((line) => line.trim()).filter(Boolean);
105
+ if (!firstReconcile) {
106
+ oldPieces = await piecesRootAt(provenance.cliVersion, options);
107
+ scaffoldPieces(base.dir, {
108
+ name,
109
+ mpoVersion: provenance.mpoVersion ?? `^${provenance.cliVersion}`,
110
+ pieces: provenance.pieces,
111
+ root: oldPieces.root
112
+ });
113
+ pieces = withSplitPieces(provenance.pieces, oldPieces.root);
114
+ if (pieces !== provenance.pieces) console.log(" frappe-bench added: the frappe piece no longer carries the dev bench\n");
437
115
  }
438
- const finalProvenance = {
439
- template: provenance.template,
116
+ scaffoldPieces(theirs.dir, {
117
+ name,
118
+ mpoVersion: targetRange,
119
+ pieces
120
+ });
121
+ const outcome = await mergeScaffolds({
122
+ projectDir,
123
+ baseDir: firstReconcile ? void 0 : base.dir,
124
+ theirsDir: theirs.dir,
125
+ baseLabel: `mpo pieces ${provenance.cliVersion}`,
126
+ theirsLabel: `mpo pieces ${currentVersion}`
127
+ });
128
+ const next = {
129
+ ...provenance,
440
130
  cliVersion: currentVersion,
441
- name: provenance.name,
442
- mpoVersion: targetMpoRange,
443
- pieces: provenance.pieces
131
+ mpoVersion: targetRange,
132
+ pieces,
133
+ reconciled: void 0
444
134
  };
445
- const pins = ["pnpm-lock.yaml"];
446
- const updateignore = join(projectDir, ".updateignore");
447
- if (existsSync(updateignore)) for (const line of readFileSync(updateignore, "utf-8").split("\n")) {
448
- const pattern = line.trim();
449
- if (!pattern || pattern.startsWith("#")) continue;
450
- const normalized = pattern.replace(/\/\*{1,2}$/, "");
451
- 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.`);
452
- pins.push(normalized.replace(/\/+$/, ""));
453
- }
454
- mergedTree = await pinPathsToHead(projectDir, mergedTree, head, pins);
455
- conflicts = conflicts.filter((file) => !pins.some((pin) => file === pin || file.startsWith(`${pin}/`)));
456
- const autoResolved = await autoResolveDependencyConflicts(projectDir, mergedTree, conflicts, baseCommit, head, nextCommit);
457
- mergedTree = autoResolved.tree;
458
- conflicts = autoResolved.conflicts;
459
- for (const file of autoResolved.resolved) console.log(` auto-resolved: ${file} (dependency ranges)`);
460
- const headTree = await gitOut(projectDir, ["rev-parse", `${head}^{tree}`]);
461
- if (!conflicts.length && mergedTree === headTree) {
462
- if (provenance.cliVersion !== finalProvenance.cliVersion || provenance.mpoVersion !== finalProvenance.mpoVersion || provenance.reconciled === false) {
463
- writeProvenance(projectDir, finalProvenance);
464
- await git(projectDir, [
465
- "add",
466
- "--",
467
- ".mpo.json"
468
- ]);
469
- await git(projectDir, [
470
- "commit",
471
- "--quiet",
472
- "-m",
473
- `chore: record mpo template provenance ${currentVersion}`
474
- ]);
475
- console.log("\n✅ already up to date (provenance refreshed)");
135
+ if (!outcome.changed && !outcome.conflicts.length) {
136
+ if (provenance.cliVersion !== currentVersion || provenance.mpoVersion !== targetRange || pieces !== provenance.pieces || firstReconcile) {
137
+ writeProvenance(projectDir, next);
138
+ console.log(`\n✅ already up to date; ${PROVENANCE_FILE} refreshed — commit it`);
476
139
  } else console.log("\n✅ already up to date");
477
140
  return;
478
141
  }
479
- let cleanMergeMessage;
480
- if (!conflicts.length) {
481
- const message = `chore: mpo update ${provenance.cliVersion} → ${currentVersion}`;
482
- await git(projectDir, [
483
- "update-ref",
484
- "HEAD",
485
- await gitOut(projectDir, [
486
- "commit-tree",
487
- mergedTree,
488
- "-p",
489
- head,
490
- "-p",
491
- nextCommit,
492
- "-m",
493
- message
494
- ])
495
- ]);
496
- await git(projectDir, [
497
- "reset",
498
- "--hard",
499
- "HEAD"
500
- ]);
501
- writeProvenance(projectDir, finalProvenance);
502
- try {
503
- await generateVscodeConfig(projectDir);
504
- } catch {}
505
- await git(projectDir, [
506
- "add",
507
- "--",
508
- ".mpo.json",
509
- ".vscode"
510
- ]);
511
- await git(projectDir, [
512
- "commit",
513
- "--amend",
514
- "--no-edit",
515
- "--quiet"
516
- ]);
517
- cleanMergeMessage = message;
518
- } else {
519
- await git(projectDir, [
520
- "read-tree",
521
- "-u",
522
- "--reset",
523
- mergedTree
524
- ]);
525
- writeProvenance(projectDir, finalProvenance);
526
- console.log("\n⚠️ merge conflicts — resolve the markers, then commit:");
527
- for (const file of conflicts) console.log(` ${file}`);
528
- console.log(`\n git add -A && git commit -m "chore: mpo update ${provenance.cliVersion} → ${currentVersion}"`);
142
+ await landTree(projectDir, outcome.mergedTree);
143
+ writeProvenance(projectDir, next);
144
+ await finishMerge(projectDir, outcome, `chore: mpo update ${provenance.cliVersion} → ${currentVersion}`, options);
145
+ } finally {
146
+ base.cleanup();
147
+ theirs.cleanup();
148
+ oldPieces.cleanup();
149
+ }
150
+ }
151
+ async function updateClone(projectDir, provenance, options) {
152
+ const currentVersion = cliVersion() ?? "0.0.0";
153
+ const recorded = provenance.source;
154
+ const templateEra = !recorded;
155
+ const apps = provenance.apps;
156
+ if (!apps.length) throw new Error(`${PROVENANCE_FILE} lists no product apps — nothing to update`);
157
+ const nextSpec = resolveSource({
158
+ source: options.source,
159
+ checkout: options.checkout
160
+ });
161
+ if (nextSpec.warning) console.warn(`⚠️ ${nextSpec.warning}`);
162
+ const baseSource = options.source ?? recorded?.url ?? nextSpec.source;
163
+ const targetRange = options.version ? mpoRange(options.version) : void 0;
164
+ console.log(`\nmpo update: ${recorded ? recorded.sha.slice(0, 12) : "(template-era project)"} → ${nextSpec.source} @ ${nextSpec.ref}`);
165
+ console.log(` apps ${apps.join(", ")}`);
166
+ console.log(` services ${provenance.services.length ? provenance.services.join(", ") : "(none)"}`);
167
+ if (templateEra) {
168
+ console.log("\n⚠️ This project came from the retired template scaffolder, so there is no");
169
+ console.log(" scaffold to use as a merge base. The monorepo's One app is merged in");
170
+ console.log(" from an empty base once: expect additions and conflicts on shared files.");
171
+ console.log(" Pin what is yours in .updateignore first if needed. After this update the");
172
+ console.log(` project is clone-derived and later updates merge three-way.\n`);
173
+ }
174
+ if (recorded && !options.version) {
175
+ if (await resolveSha(nextSpec) === recorded.sha) {
176
+ console.log("\n✅ already up to date");
177
+ return;
529
178
  }
530
- if (conflicts.length && !options.skipInstall) console.log("\nSkipping pnpm install until conflicts are resolved (then run: pnpm install).");
531
- else if (!options.skipInstall) {
532
- console.log("\nInstalling dependencies...");
533
- await spawn("pnpm", ["install"], {
534
- cwd: projectDir,
535
- stdio: "inherit"
536
- });
537
- if (!conflicts.length) {
538
- if (await git(projectDir, [
539
- "diff",
540
- "--quiet",
541
- "--",
542
- "pnpm-lock.yaml"
543
- ]).then(() => false, () => true)) {
544
- await git(projectDir, [
545
- "add",
546
- "--",
547
- "pnpm-lock.yaml"
548
- ]);
549
- await git(projectDir, [
550
- "commit",
551
- "--amend",
552
- "--no-edit",
553
- "--quiet"
554
- ]);
555
- }
556
- }
179
+ }
180
+ const name = packageJsonName(projectDir) ?? basename(projectDir);
181
+ const theirs = await scaffoldToTemp({
182
+ source: nextSpec.source,
183
+ ref: nextSpec.ref,
184
+ apps,
185
+ services: provenance.services,
186
+ mpoVersion: targetRange,
187
+ name,
188
+ verbose: options.verbose
189
+ });
190
+ let base;
191
+ try {
192
+ const nextSha = theirs.provenance.source.sha;
193
+ if (recorded && nextSha === recorded.sha && !options.version) {
194
+ console.log("\n✅ already up to date");
195
+ return;
557
196
  }
558
- if (cleanMergeMessage) {
559
- const finalHash = await gitOut(projectDir, [
560
- "rev-parse",
561
- "--short",
562
- "HEAD"
563
- ]);
564
- console.log(`\n✅ merged cleanly → ${finalHash} ("${cleanMergeMessage}")`);
197
+ if (recorded) base = await scaffoldToTemp({
198
+ source: baseSource,
199
+ ref: recorded.sha,
200
+ apps,
201
+ services: provenance.services,
202
+ mpoVersion: `^${provenance.cliVersion}`,
203
+ name,
204
+ verbose: options.verbose
205
+ });
206
+ for (const dir of [theirs.dir, base?.dir]) if (dir) rmSync(join(dir, PROVENANCE_FILE), { force: true });
207
+ const outcome = await mergeScaffolds({
208
+ projectDir,
209
+ baseDir: base?.dir,
210
+ theirsDir: theirs.dir,
211
+ baseLabel: recorded ? `mpo scaffold ${recorded.sha}` : "mpo scaffold (empty base)",
212
+ theirsLabel: `mpo scaffold ${nextSha}`
213
+ });
214
+ const next = {
215
+ cliVersion: theirs.provenance.cliVersion || currentVersion,
216
+ source: {
217
+ sha: nextSha,
218
+ url: options.source ?? recorded?.url ?? nextSpec.source
219
+ },
220
+ apps,
221
+ services: provenance.services,
222
+ pieces: []
223
+ };
224
+ const label = `${recorded ? recorded.sha.slice(0, 12) : "template"} → ${nextSha.slice(0, 12)}`;
225
+ if (!outcome.changed && !outcome.conflicts.length) {
226
+ writeProvenance(projectDir, next);
227
+ console.log(`\n✅ already up to date; ${PROVENANCE_FILE} refreshed — commit it`);
228
+ return;
565
229
  }
230
+ await landTree(projectDir, outcome.mergedTree);
231
+ await dropStalePnpmfileChecksum(projectDir);
232
+ writeProvenance(projectDir, next);
233
+ await finishMerge(projectDir, outcome, `chore: mpo update ${label}`, options);
566
234
  } finally {
567
- base.cleanup();
568
- next.cleanup();
235
+ theirs.cleanup();
236
+ base?.cleanup();
237
+ }
238
+ }
239
+ async function updateApp(options = {}) {
240
+ const projectDir = await projectRootOf("mpo update");
241
+ await requireCleanTree(projectDir, "mpo update");
242
+ const provenance = readProvenance(projectDir);
243
+ if (!provenance) throw new Error(`${PROVENANCE_FILE} not found — this project has no scaffold provenance.\n - hand-copied piece files from the templates? bring it under management:
244
+ mpo adopt --<piece> [...]
245
+ - scaffolded from the monorepo before provenance existed? write .mpo.json:
246
+ { "cliVersion": "<cli>", "source": { "sha": "<commit>", "url": "<repo>" },
247
+ "apps": ["<name>"], "services": [], "pieces": [] }`);
248
+ if (provenance.template === "none") {
249
+ await updateAdopted(projectDir, provenance, options);
250
+ return;
569
251
  }
252
+ await updateClone(projectDir, provenance, options);
570
253
  }
571
254
 
572
255
  //#endregion
573
- export { threeWayMergePackageJson, updateApp };
256
+ export { updateApp };