@multiplatform.one/cli 7.11.0 → 7.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +121 -111
  2. package/bin/createMultiplatformApp.mjs +39 -37
  3. package/lib/bin/multiplatformOne.mjs +65 -75
  4. package/lib/commands/addApp.mjs +78 -0
  5. package/lib/commands/adoptApp.mjs +11 -7
  6. package/lib/commands/doctypeTypes.mjs +148 -0
  7. package/lib/commands/e2e.mjs +2 -2
  8. package/lib/commands/init.mjs +142 -459
  9. package/lib/commands/merge.mjs +359 -0
  10. package/lib/commands/pieces.mjs +236 -0
  11. package/lib/commands/provenance.mjs +108 -0
  12. package/lib/commands/scaffold.mjs +105 -0
  13. package/lib/commands/updateApp.mjs +211 -528
  14. package/lib/source.json +5 -0
  15. package/lib/source.mjs +63 -0
  16. package/package.json +6 -6
  17. package/scripts/frappe-bootstrap.sh +29 -3
  18. package/scripts/frappe-dev.sh +1 -2
  19. package/scripts/frappe-helpers.sh +50 -2
  20. package/scripts/frappe.sh +5 -0
  21. package/scripts/scaffold.sh +937 -0
  22. package/scripts/stamp-source.mjs +51 -0
  23. package/scripts/stamp-source.spec.ts +86 -0
  24. package/src/bin/multiplatformOne.ts +116 -226
  25. package/src/commands/addApp.ts +102 -0
  26. package/src/commands/adoptApp.spec.ts +5 -1
  27. package/src/commands/adoptApp.ts +15 -10
  28. package/src/commands/doctypeTypes.spec.ts +155 -0
  29. package/src/commands/doctypeTypes.ts +214 -0
  30. package/src/commands/init.spec.ts +66 -0
  31. package/src/commands/init.ts +201 -643
  32. package/src/commands/merge.spec.ts +81 -0
  33. package/src/commands/merge.ts +411 -0
  34. package/src/commands/pieces.spec.ts +62 -0
  35. package/src/commands/pieces.ts +340 -0
  36. package/src/commands/provenance.spec.ts +92 -0
  37. package/src/commands/provenance.ts +169 -0
  38. package/src/commands/scaffold.ts +131 -0
  39. package/src/commands/updateApp.spec.ts +540 -331
  40. package/src/commands/updateApp.ts +255 -668
  41. package/src/source.spec.ts +45 -0
  42. package/src/source.ts +88 -0
  43. package/templates/AGENTS.md +161 -0
  44. package/templates/README.md +62 -0
  45. package/templates/pieces/frappe/universal/README.md.partial +7 -32
  46. package/templates/pieces/frappe/universal/env.example.partial +3 -3
  47. package/templates/pieces/frappe-bench/universal/README.md.partial +28 -0
  48. package/templates/pieces/{frappe → frappe-bench}/universal/docker/compose.frappe.yaml +2 -2
  49. package/templates/pieces/tauri/universal/README.md.partial +7 -5
  50. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/dev.rs +463 -0
  51. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +6 -0
  52. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +2 -1
  53. package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +1 -0
  54. package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +6 -2
  55. package/types/bin/multiplatformOne.d.ts.map +1 -1
  56. package/types/commands/addApp.d.ts +20 -0
  57. package/types/commands/addApp.d.ts.map +1 -0
  58. package/types/commands/adoptApp.d.ts +4 -3
  59. package/types/commands/adoptApp.d.ts.map +1 -1
  60. package/types/commands/doctypeTypes.d.ts +40 -0
  61. package/types/commands/doctypeTypes.d.ts.map +1 -0
  62. package/types/commands/init.d.ts +30 -13
  63. package/types/commands/init.d.ts.map +1 -1
  64. package/types/commands/merge.d.ts +54 -0
  65. package/types/commands/merge.d.ts.map +1 -0
  66. package/types/commands/pieces.d.ts +54 -0
  67. package/types/commands/pieces.d.ts.map +1 -0
  68. package/types/commands/provenance.d.ts +52 -0
  69. package/types/commands/provenance.d.ts.map +1 -0
  70. package/types/commands/scaffold.d.ts +43 -0
  71. package/types/commands/scaffold.d.ts.map +1 -0
  72. package/types/commands/updateApp.d.ts +19 -27
  73. package/types/commands/updateApp.d.ts.map +1 -1
  74. package/types/source.d.ts +35 -0
  75. package/types/source.d.ts.map +1 -0
  76. package/lib/commands/initApp.mjs +0 -431
  77. package/lib/generateVscode.mjs +0 -184
  78. package/scripts/clone.sh +0 -17
  79. package/scripts/update.sh +0 -46
  80. package/src/commands/initApp.spec.ts +0 -720
  81. package/src/commands/initApp.ts +0 -706
  82. package/src/generateVscode.ts +0 -238
  83. package/templates/app/AGENTS.md +0 -142
  84. package/templates/app/README.md +0 -62
  85. package/templates/app/apps/__NAME__/index.html +0 -36
  86. package/templates/app/apps/__NAME__/package.json +0 -31
  87. package/templates/app/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  88. package/templates/app/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  89. package/templates/app/apps/__NAME__/src/App.tsx +0 -9
  90. package/templates/app/apps/__NAME__/src/main.tsx +0 -14
  91. package/templates/app/apps/__NAME__/tsconfig.json +0 -8
  92. package/templates/app/apps/__NAME__/vite.config.ts +0 -17
  93. package/templates/app/features/__NAME__/package.json +0 -34
  94. package/templates/app/features/__NAME__/src/HomeScreen.tsx +0 -70
  95. package/templates/app/features/__NAME__/src/index.ts +0 -1
  96. package/templates/app/features/__NAME__/tsconfig.json +0 -5
  97. package/templates/app/gitignore +0 -9
  98. package/templates/app/npmrc +0 -2
  99. package/templates/app/oxlintrc.json +0 -39
  100. package/templates/app/package.json +0 -38
  101. package/templates/app/packages/config/config.json +0 -4
  102. package/templates/app/packages/config/index.ts +0 -5
  103. package/templates/app/packages/config/package.json +0 -17
  104. package/templates/app/packages/config/tsconfig.json +0 -5
  105. package/templates/app/pnpm-workspace.yaml +0 -4
  106. package/templates/app/tsconfig.base.json +0 -16
  107. package/templates/universal/AGENTS.md +0 -142
  108. package/templates/universal/README.md +0 -97
  109. package/templates/universal/apps/__NAME__/app.config.js +0 -31
  110. package/templates/universal/apps/__NAME__/config/tamagui.config.ts +0 -4
  111. package/templates/universal/apps/__NAME__/package.json +0 -62
  112. package/templates/universal/apps/__NAME__/polyfills.native.ts +0 -23
  113. package/templates/universal/apps/__NAME__/polyfills.ts +0 -3
  114. package/templates/universal/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  115. package/templates/universal/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  116. package/templates/universal/apps/__NAME__/routes/_layout.tsx +0 -22
  117. package/templates/universal/apps/__NAME__/routes/_layout.web.tsx +0 -21
  118. package/templates/universal/apps/__NAME__/routes/about.tsx +0 -3
  119. package/templates/universal/apps/__NAME__/routes/index.tsx +0 -3
  120. package/templates/universal/apps/__NAME__/src/app.ts +0 -10
  121. package/templates/universal/apps/__NAME__/tamagui.css +0 -3
  122. package/templates/universal/apps/__NAME__/tsconfig.json +0 -8
  123. package/templates/universal/apps/__NAME__/types.d.ts +0 -9
  124. package/templates/universal/apps/__NAME__/vite.config.ts +0 -20
  125. package/templates/universal/features/__NAME__/package.json +0 -35
  126. package/templates/universal/features/__NAME__/src/AboutScreen.tsx +0 -27
  127. package/templates/universal/features/__NAME__/src/AppLayout.tsx +0 -50
  128. package/templates/universal/features/__NAME__/src/ContactForm.spec.tsx +0 -22
  129. package/templates/universal/features/__NAME__/src/ContactForm.tsx +0 -55
  130. package/templates/universal/features/__NAME__/src/HomeScreen.tsx +0 -40
  131. package/templates/universal/features/__NAME__/src/index.ts +0 -4
  132. package/templates/universal/features/__NAME__/src/screens.spec.ts +0 -12
  133. package/templates/universal/features/__NAME__/tsconfig.json +0 -5
  134. package/templates/universal/features/__NAME__/vitest.config.mjs +0 -7
  135. package/templates/universal/gitignore +0 -14
  136. package/templates/universal/npmrc +0 -3
  137. package/templates/universal/oxlintrc.json +0 -39
  138. package/templates/universal/package.json +0 -51
  139. package/templates/universal/packages/config/config.json +0 -4
  140. package/templates/universal/packages/config/index.ts +0 -5
  141. package/templates/universal/packages/config/package.json +0 -17
  142. package/templates/universal/packages/config/tsconfig.json +0 -5
  143. package/templates/universal/packages/i18n/en/common.json +0 -30
  144. package/templates/universal/packages/i18n/es/common.json +0 -30
  145. package/templates/universal/packages/i18n/i18n.d.ts +0 -16
  146. package/templates/universal/packages/i18n/index.ts +0 -26
  147. package/templates/universal/packages/i18n/package.json +0 -20
  148. package/templates/universal/packages/i18n/resources.ts +0 -13
  149. package/templates/universal/packages/i18n/tsconfig.json +0 -5
  150. package/templates/universal/packages/themes/accent.ts +0 -32
  151. package/templates/universal/packages/themes/base.ts +0 -50
  152. package/templates/universal/packages/themes/fonts.ts +0 -8
  153. package/templates/universal/packages/themes/index.ts +0 -33
  154. package/templates/universal/packages/themes/package.json +0 -23
  155. package/templates/universal/packages/themes/tamagui.config.ts +0 -14
  156. package/templates/universal/packages/themes/tsconfig.json +0 -5
  157. package/templates/universal/pnpm-workspace.yaml +0 -4
  158. package/templates/universal/tsconfig.base.json +0 -21
  159. package/templates/universal/types/env.d.ts +0 -9
  160. package/types/commands/initApp.d.ts +0 -114
  161. package/types/commands/initApp.d.ts.map +0 -1
  162. package/types/generateVscode.d.ts +0 -7
  163. package/types/generateVscode.d.ts.map +0 -1
@@ -1,494 +1,126 @@
1
1
  /**
2
- * `mpo update` for scaffolded consumer projects — the modern successor of
3
- * the old cookiecutter flow (init a fresh project, swap your .git in, merge
4
- * your changes on top), rebuilt with a TRUE three-way merge:
2
+ * `mpo update`: bring the monorepo's changes into a project as a three-way
3
+ * merge that is NEVER committed.
5
4
  *
6
- * base = the ORIGINAL scaffold, regenerated from the CLI version recorded
7
- * in .mpo.json at init time (published CLIs ship their templates,
8
- * so `pnpm dlx @multiplatform.one/cli@<old> init` reproduces it)
9
- * ours = the project's HEAD (all your custom changes)
10
- * theirs= a fresh scaffold from the CURRENT CLI version
5
+ * base = the scaffold the project started from: the monorepo at the
6
+ * commit recorded in .mpo.json, pruned with the recorded apps
7
+ * and services
8
+ * ours = the project's HEAD (every commit the project made)
9
+ * theirs = the same scaffold at the commit the running CLI was built from
10
+ * (or --source/--checkout)
11
11
  *
12
- * `git merge-tree --write-tree --merge-base=<base>` merges template
13
- * evolution with your customizations; conflicts land in the worktree with
14
- * normal conflict markers. An `.updateignore` file (one exact file or
15
- * directory prefix per line — not globs; `dir/**` is normalized, other
16
- * glob shapes are refused) pins matching paths to your HEAD version.
12
+ * Every product app is a renamed apps/one on both sides, so a change to
13
+ * the One app lands in each of them. The merged tree goes into the index
14
+ * and worktree; the commit line is printed; a dirty tree is refused up
15
+ * front. Adopted projects (`mpo adopt`) merge their piece fragments the
16
+ * same way; projects from the retired template scaffolder reconcile from an
17
+ * empty base once and are clone-derived afterwards.
17
18
  */
18
19
 
19
20
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20
21
  import { tmpdir } from "node:os";
21
22
  import { basename, join } from "node:path";
22
- import spawn from "nano-spawn";
23
- import { generateVscodeConfig } from "../generateVscode";
23
+ import { isGitUrl, monorepoCheckout, monorepoUrl, resolveSource, type SourceSpec } from "../source";
24
+ import {
25
+ finishMerge,
26
+ git,
27
+ gitOut,
28
+ landTree,
29
+ mergeScaffolds,
30
+ projectRootOf,
31
+ requireCleanTree,
32
+ } from "./merge";
33
+ import { scaffoldPieces, withSplitPieces } from "./pieces";
24
34
  import {
25
35
  PROVENANCE_FILE,
26
36
  cliVersion,
27
- initApp,
37
+ mpoRange,
28
38
  packageJsonName,
29
39
  readProvenance,
30
- validateName,
31
40
  writeProvenance,
32
- type InitAppPiece,
33
- type InitAppTemplate,
34
41
  type MpoProvenance,
35
- type ProvenanceTemplate,
36
- } from "./initApp";
42
+ } from "./provenance";
43
+ import { archiveSubtree, scaffoldToTemp, type TempScaffold } from "./scaffold";
37
44
 
38
45
  export interface UpdateAppOptions {
39
- /** Skip pnpm install after the merge. */
40
46
  skipInstall?: boolean;
41
47
  /** Override the semver range written for @multiplatform.one/* deps. */
42
48
  version?: string;
43
- /**
44
- * Pre-provenance bootstrap: when .mpo.json is missing, assume the project
45
- * was scaffolded by this exact CLI version, write provenance first, then
46
- * proceed with the normal update.
47
- */
48
- assumeVersion?: string;
49
- /** Template recorded by the --assume-version bootstrap (default universal). */
50
- assumeTemplate?: InitAppTemplate;
51
- }
52
-
53
- async function git(projectDir: string, args: string[], env?: Record<string, string>) {
54
- return spawn("git", args, {
55
- cwd: projectDir,
56
- env: env ? { ...process.env, ...env } : undefined,
57
- });
58
- }
59
-
60
- async function gitOut(projectDir: string, args: string[], env?: Record<string, string>) {
61
- const result = await git(projectDir, args, env);
62
- return result.stdout.trim();
63
- }
64
-
65
- /** Import a directory tree into the project's object database and return a
66
- * root-less commit for it (temp index; the worktree is never touched). */
67
- async function commitTreeFromDir(
68
- projectDir: string,
69
- dir: string,
70
- message: string,
71
- parent?: string,
72
- ): Promise<string> {
73
- const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-index-")), "index");
74
- const env = { GIT_INDEX_FILE: indexFile, GIT_WORK_TREE: dir };
75
- await git(projectDir, ["add", "-A", "--force", "--", "."], env);
76
- const tree = await gitOut(projectDir, ["write-tree"], env);
77
- const commitArgs = parent
78
- ? ["commit-tree", tree, "-p", parent, "-m", message]
79
- : ["commit-tree", tree, "-m", message];
80
- const commit = await gitOut(projectDir, commitArgs);
81
- rmSync(indexFile, { force: true });
82
- return commit;
83
- }
84
-
85
- /** Scaffold a project with a specific published CLI version into tmp and
86
- * return the generated project dir. Falls back to the local generator when
87
- * the requested version matches the running CLI (or dlx fails). */
88
- async function scaffoldBaseline(
89
- version: string,
90
- name: string,
91
- template: ProvenanceTemplate,
92
- mpoVersion: string,
93
- pieces: InitAppPiece[],
94
- ): Promise<{ dir: string; cleanup: () => void }> {
95
- const parent = mkdtempSync(join(tmpdir(), "mpo-update-"));
96
- const cleanup = () => rmSync(parent, { recursive: true, force: true });
97
- const current = cliVersion();
98
- const templateFlag =
99
- template === "none" ? ["--pieces-only"] : template === "app" ? ["--web"] : ["--universal"];
100
- // The consumer scaffolder (initApp + templates) shipped in 6.3.0 —
101
- // earlier CLIs only had the monorepo-clone init and cannot regenerate a
102
- // scaffold baseline. Pieces-only scaffolds (`mpo init --pieces-only`, the
103
- // baseline of ADOPTED projects) shipped in 6.6.0 alongside `mpo adopt`;
104
- // adopted provenance is always written by ≥6.6 CLIs, so the gate only
105
- // trips on hand-written .mpo.json files.
106
- const [major = 0, minor = 0] = version.split(".").map(Number);
107
- const scaffoldCapable =
108
- template === "none"
109
- ? major > 6 || (major === 6 && minor >= 6)
110
- : major > 6 || (major === 6 && minor >= 3);
111
- // Composable pieces (--frappe/--keycloak/--gnome/--vscode/--webext)
112
- // shipped in 6.5.0 — older published CLIs reject the flags. A 6.3/6.4
113
- // baseline is scaffolded WITHOUT pieces: piece files then appear as
114
- // ours-only additions relative to that base, which three-way merge keeps
115
- // cleanly (they can't have come from those CLIs in the first place).
116
- const piecesCapable = major > 6 || (major === 6 && minor >= 5);
117
- const pieceFlags = piecesCapable ? pieces.map((piece) => `--${piece}`) : [];
118
- if (pieces.length && scaffoldCapable && !piecesCapable && version !== current) {
119
- console.warn(
120
- `⚠️ @multiplatform.one/cli@${version} predates composable pieces (6.5.0); ` +
121
- "the merge base is scaffolded without them.",
122
- );
123
- }
124
- if (!scaffoldCapable) {
125
- // The local generator is exact for the running version — the capability
126
- // gate only matters when an OLDER published CLI must be dlx'd.
127
- if (version !== current) {
128
- console.warn(
129
- template === "none"
130
- ? `⚠️ @multiplatform.one/cli@${version} predates pieces-only baselines (6.6.0); ` +
131
- "using the current CLI's piece templates as the merge base " +
132
- "(the merge degrades to two-way: template changes since that version won't land)."
133
- : `⚠️ @multiplatform.one/cli@${version} predates the consumer scaffolder (6.3.0); ` +
134
- "using the current CLI's template as the merge base.",
135
- );
136
- }
137
- } else if (version !== current) {
138
- // Older published CLIs reject flags they don't know (--skip-git), so
139
- // only the stable flag set is passed; a scaffold-side git repo is
140
- // harmless (index operations never descend into nested .git dirs).
141
- for (let attempt = 1; attempt <= 2; attempt++) {
142
- try {
143
- await spawn(
144
- "pnpm",
145
- [
146
- "--package",
147
- `@multiplatform.one/cli@${version}`,
148
- "dlx",
149
- "mpo",
150
- "init",
151
- name,
152
- "--yes",
153
- "--skip-install",
154
- "--mpo-version",
155
- mpoVersion,
156
- ...templateFlag,
157
- ...pieceFlags,
158
- ],
159
- { cwd: parent, stdio: "inherit" },
160
- );
161
- const dir = join(parent, name);
162
- // Pieces-only trees may not contain a root package.json (not every
163
- // piece ships a root fragment) — provenance is their reliable marker.
164
- const marker = template === "none" ? PROVENANCE_FILE : "package.json";
165
- if (existsSync(join(dir, marker))) return { dir, cleanup };
166
- console.warn(`⚠️ dlx scaffold for @multiplatform.one/cli@${version} produced no project`);
167
- break;
168
- } catch {
169
- rmSync(join(parent, name), { recursive: true, force: true });
170
- if (attempt === 2) {
171
- console.warn(
172
- `⚠️ could not scaffold baseline with @multiplatform.one/cli@${version} (network?);`,
173
- );
174
- }
175
- }
176
- }
177
- console.warn(" falling back to the current CLI's template as the merge base.");
178
- }
179
- const previousCwd = process.cwd();
180
- process.chdir(parent);
181
- try {
182
- await initApp(name, {
183
- skipInstall: true,
184
- skipGit: true,
185
- yes: true,
186
- template,
187
- version: mpoVersion,
188
- pieces,
189
- });
190
- } finally {
191
- process.chdir(previousCwd);
192
- }
193
- return { dir: join(parent, name), cleanup };
194
- }
195
-
196
- /** Return a tree derived from `tree` where every path matching `pins` is
197
- * replaced by its content in `headCommit` (removed when absent there).
198
- * Pure index plumbing — the worktree is untouched. */
199
- async function pinPathsToHead(
200
- projectDir: string,
201
- tree: string,
202
- headCommit: string,
203
- pins: string[],
204
- ): Promise<string> {
205
- const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-pin-")), "index");
206
- const env = { GIT_INDEX_FILE: indexFile };
207
- await git(projectDir, ["read-tree", tree], env);
208
- for (const pin of pins) {
209
- // Paths under the pin in the merged tree (to drop when HEAD lacks them).
210
- const inTree = await gitOut(projectDir, ["ls-tree", "-r", "--name-only", tree, "--", pin], env)
211
- .then((out) => out.split("\n").filter(Boolean))
212
- .catch(() => [] as string[]);
213
- for (const path of inTree) {
214
- await git(projectDir, ["update-index", "--force-remove", "--", path], env).catch(() => {});
215
- }
216
- // HEAD's entries for the pin (restored verbatim).
217
- const headEntries = await gitOut(
218
- projectDir,
219
- ["ls-tree", "-r", headCommit, "--", pin],
220
- env,
221
- ).catch(() => "");
222
- for (const line of headEntries.split("\n").filter(Boolean)) {
223
- // "<mode> blob <oid>\t<path>"
224
- const match = line.match(/^(\d+) \S+ ([0-9a-f]+)\t(.+)$/);
225
- if (!match) continue;
226
- await git(
227
- projectDir,
228
- ["update-index", "--add", "--cacheinfo", `${match[1]},${match[2]},${match[3]}`],
229
- env,
230
- ).catch(() => {});
231
- }
232
- }
233
- const pinned = await gitOut(projectDir, ["write-tree"], env);
234
- rmSync(indexFile, { force: true });
235
- return pinned;
236
- }
237
-
238
- // ── package.json conflict auto-resolution ─────────────────────────
239
- //
240
- // The dominant conflict class in real updates is dependency-range drift:
241
- // the project bumped a range between updates (ours ^6.4.2) while the new
242
- // template carries its own (theirs ^6.4.3). Both sides changed the same
243
- // JSON entry, so textual merge punts — but the resolution is mechanical:
244
- // take the newer minimum version. A structural three-way merge handles it.
245
-
246
- const UNRESOLVED = Symbol("unresolved");
247
- /** Deleted-on-one-side sentinel (distinct from a key that never existed). */
248
- const ABSENT = Symbol("absent");
249
-
250
- const DEP_MAP_KEYS = new Set([
251
- "dependencies",
252
- "devDependencies",
253
- "peerDependencies",
254
- "optionalDependencies",
255
- "overrides",
256
- "resolutions",
257
- ]);
258
-
259
- /** Minimum concrete version of a simple range (`^x.y.z`, `~x.y.z`,
260
- * `x.y.z`, optional prerelease). Anything fancier is not auto-resolved. */
261
- function rangeMinimum(range: unknown): [number, number, number] | undefined {
262
- if (typeof range !== "string") return undefined;
263
- const match = range.match(/^[\^~]?(\d+)\.(\d+)\.(\d+)(?:-[\w.]+)?$/);
264
- if (!match) return undefined;
265
- return [Number(match[1]), Number(match[2]), Number(match[3])];
266
- }
267
-
268
- function isPlainObject(value: unknown): value is Record<string, unknown> {
269
- return typeof value === "object" && value !== null && !Array.isArray(value);
49
+ /** Path or url to update from (default: the CLI's recorded source). */
50
+ source?: string;
51
+ /** Commit, tag or branch at that source (default: the CLI's source commit). */
52
+ checkout?: string;
53
+ verbose?: boolean;
270
54
  }
271
55
 
272
- function jsonEqual(a: unknown, b: unknown): boolean {
273
- return JSON.stringify(a) === JSON.stringify(b);
56
+ function isSha(ref: string): boolean {
57
+ return /^[0-9a-f]{40}$/.test(ref);
274
58
  }
275
59
 
276
- function mergeNode(
277
- base: unknown,
278
- ours: unknown,
279
- theirs: unknown,
280
- inDepMap: boolean,
281
- ): unknown | typeof UNRESOLVED | typeof ABSENT {
282
- if (jsonEqual(ours, theirs)) return ours === undefined ? ABSENT : ours;
283
- // Only one side diverged from base → that side wins (undefined = deletion).
284
- if (jsonEqual(theirs, base)) return ours === undefined ? ABSENT : ours;
285
- if (jsonEqual(ours, base)) return theirs === undefined ? ABSENT : theirs;
286
- // Both diverged. Objects merge key-by-key; dep ranges take the newer
287
- // minimum; everything else is a genuine conflict.
288
- if (isPlainObject(ours) && isPlainObject(theirs)) {
289
- const baseObject = isPlainObject(base) ? base : {};
290
- const merged: Record<string, unknown> = {};
291
- const keys = [...Object.keys(ours), ...Object.keys(theirs).filter((key) => !(key in ours))];
292
- for (const key of keys) {
293
- const child = mergeNode(
294
- baseObject[key],
295
- ours[key],
296
- theirs[key],
297
- inDepMap || DEP_MAP_KEYS.has(key),
298
- );
299
- if (child === UNRESOLVED) return UNRESOLVED;
300
- if (child !== ABSENT) merged[key] = child;
301
- }
302
- return merged;
303
- }
304
- if (inDepMap) {
305
- const oursMin = rangeMinimum(ours);
306
- const theirsMin = rangeMinimum(theirs);
307
- if (oursMin && theirsMin) {
308
- for (let i = 0; i < 3; i++) {
309
- if (oursMin[i]! > theirsMin[i]!) return ours;
310
- if (oursMin[i]! < theirsMin[i]!) return theirs;
311
- }
312
- return theirs; // equal minimums, different spelling — template wins
313
- }
314
- }
315
- return UNRESOLVED;
60
+ /** The commit a source spec names, when it can be known without scaffolding. */
61
+ async function resolveSha(spec: SourceSpec): Promise<string | undefined> {
62
+ if (isSha(spec.ref)) return spec.ref;
63
+ if (isGitUrl(spec.source)) return undefined;
64
+ return gitOut(spec.source, ["rev-parse", "--verify", `${spec.ref}^{commit}`]).catch(
65
+ () => undefined,
66
+ );
316
67
  }
317
68
 
318
- /** Structural three-way merge of package.json content. Returns the merged
319
- * object, or undefined when a divergence can't be auto-resolved (the file
320
- * then keeps its textual conflict markers for manual resolution). */
321
- export function threeWayMergePackageJson(
322
- base: string | undefined,
323
- ours: string,
324
- theirs: string,
325
- ): Record<string, unknown> | undefined {
326
- let baseJson: unknown;
327
- let oursJson: unknown;
328
- let theirsJson: unknown;
329
- try {
330
- baseJson = base === undefined ? {} : JSON.parse(base);
331
- oursJson = JSON.parse(ours);
332
- theirsJson = JSON.parse(theirs);
333
- } catch {
334
- return undefined;
335
- }
336
- if (!isPlainObject(oursJson) || !isPlainObject(theirsJson)) return undefined;
337
- const merged = mergeNode(baseJson, oursJson, theirsJson, false);
338
- if (merged === UNRESOLVED || merged === ABSENT || !isPlainObject(merged)) return undefined;
339
- return merged;
69
+ // pnpm-lock.yaml stays pinned to HEAD through the merge, so a merge that
70
+ // removes .pnpmfile.cjs leaves its checksum behind, and pnpm re-resolves every
71
+ // dependency over the mismatch instead of keeping the lockfile's pins.
72
+ async function dropStalePnpmfileChecksum(projectDir: string): Promise<void> {
73
+ const lockfile = join(projectDir, "pnpm-lock.yaml");
74
+ if (existsSync(join(projectDir, ".pnpmfile.cjs")) || !existsSync(lockfile)) return;
75
+ const lock = readFileSync(lockfile, "utf-8");
76
+ const stripped = lock.replace(/^pnpmfileChecksum: .*\n\n?/m, "");
77
+ if (stripped === lock) return;
78
+ writeFileSync(lockfile, stripped);
79
+ await git(projectDir, ["add", "--", "pnpm-lock.yaml"]);
340
80
  }
341
81
 
342
- /** Replace auto-resolvable conflicted package.json files in `mergedTree`
343
- * with their structural merge. Pure index plumbing, worktree untouched. */
344
- async function autoResolveDependencyConflicts(
345
- projectDir: string,
346
- mergedTree: string,
347
- conflicts: string[],
348
- baseCommit: string,
349
- headCommit: string,
350
- nextCommit: string,
351
- ): Promise<{ tree: string; conflicts: string[]; resolved: string[] }> {
352
- const candidates = conflicts.filter((file) => basename(file) === "package.json");
353
- if (!candidates.length) return { tree: mergedTree, conflicts, resolved: [] };
354
-
355
- const scratch = mkdtempSync(join(tmpdir(), "mpo-resolve-"));
356
- const resolved: { path: string; oid: string }[] = [];
357
- try {
358
- for (const file of candidates) {
359
- const show = (commit: string) =>
360
- gitOut(projectDir, ["show", `${commit}:${file}`]).catch(() => undefined);
361
- const [base, ours, theirs] = await Promise.all([
362
- show(baseCommit),
363
- show(headCommit),
364
- show(nextCommit),
365
- ]);
366
- // Add/delete conflicts stay manual — only both-sides-present merges.
367
- if (ours === undefined || theirs === undefined) continue;
368
- const merged = threeWayMergePackageJson(base, ours, theirs);
369
- if (!merged) continue;
370
- const blobFile = join(scratch, "blob.json");
371
- writeFileSync(blobFile, `${JSON.stringify(merged, null, 2)}\n`);
372
- const oid = await gitOut(projectDir, ["hash-object", "-w", "--", blobFile]);
373
- resolved.push({ path: file, oid });
374
- }
375
- if (!resolved.length) return { tree: mergedTree, conflicts, resolved: [] };
376
-
377
- const indexFile = join(scratch, "index");
378
- const env = { GIT_INDEX_FILE: indexFile };
379
- await git(projectDir, ["read-tree", mergedTree], env);
380
- for (const entry of resolved) {
381
- await git(
382
- projectDir,
383
- ["update-index", "--add", "--cacheinfo", `100644,${entry.oid},${entry.path}`],
384
- env,
385
- );
386
- }
387
- const tree = await gitOut(projectDir, ["write-tree"], env);
388
- const resolvedPaths = new Set(resolved.map((entry) => entry.path));
389
- return {
390
- tree,
391
- conflicts: conflicts.filter((file) => !resolvedPaths.has(file)),
392
- resolved: [...resolvedPaths],
393
- };
394
- } finally {
395
- rmSync(scratch, { recursive: true, force: true });
396
- }
82
+ function tempDir(): { dir: string; cleanup: () => void } {
83
+ const dir = mkdtempSync(join(tmpdir(), "mpo-update-"));
84
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
397
85
  }
398
86
 
399
87
  /**
400
- * Pre-provenance bootstrap (`mpo update --assume-version <cliVersion>`):
401
- * projects scaffolded by CLIs older than the provenance file (≤6.3.0) have
402
- * no .mpo.json. The user asserts which CLI version (and template) produced
403
- * the project; provenance is written and committed, then the normal update
404
- * proceeds against that baseline.
88
+ * templates/pieces as they were at another CLI version: the monorepo tag
89
+ * v<version>, from the CLI's source (or --source). Falls back to the
90
+ * current templates with a warning, which degrades that merge to two-way.
405
91
  */
406
- async function bootstrapProvenance(
407
- projectDir: string,
92
+ async function piecesRootAt(
93
+ version: string,
408
94
  options: UpdateAppOptions,
409
- ): Promise<MpoProvenance> {
410
- const version = options.assumeVersion!;
411
- if (!/^\d+\.\d+\.\d+(?:-[\w.]+)?$/u.test(version)) {
412
- throw new Error(`--assume-version must be an exact CLI version (e.g. 6.3.0), got "${version}"`);
413
- }
414
- const rawName = packageJsonName(projectDir);
415
- if (!rawName) {
416
- throw new Error(
417
- `Could not derive the project name from package.json — create ${PROVENANCE_FILE} by hand ` +
418
- "with { template, cliVersion, name } instead",
95
+ ): Promise<{ root?: string; cleanup: () => void }> {
96
+ if (version === cliVersion()) return { cleanup: () => {} };
97
+ const scratch = tempDir();
98
+ const source = options.source ?? monorepoCheckout() ?? monorepoUrl;
99
+ try {
100
+ await archiveSubtree(source, `v${version}`, "public/cli/templates/pieces", scratch.dir);
101
+ return { root: join(scratch.dir, "public/cli/templates/pieces"), cleanup: scratch.cleanup };
102
+ } catch {
103
+ scratch.cleanup();
104
+ console.warn(
105
+ `⚠️ could not read templates/pieces at v${version} from ${source}; ` +
106
+ "using the current pieces as the merge base (changes since that version won't land).",
419
107
  );
108
+ return { cleanup: () => {} };
420
109
  }
421
- const provenance: MpoProvenance = {
422
- template: options.assumeTemplate ?? "universal",
423
- cliVersion: version,
424
- name: validateName(rawName),
425
- mpoVersion: `^${version}`,
426
- pieces: [],
427
- };
428
- console.log(
429
- `\nBootstrapping provenance: assuming this project was scaffolded by ` +
430
- `cli@${version} (${provenance.template} template).`,
431
- );
432
- writeProvenance(projectDir, provenance);
433
- await git(projectDir, ["add", "--", PROVENANCE_FILE]);
434
- await git(projectDir, [
435
- "commit",
436
- "--quiet",
437
- "-m",
438
- `chore: record assumed mpo scaffold provenance (cli@${version})`,
439
- ]).catch(() => {
440
- console.warn("⚠️ git commit failed (missing git identity?) — provenance left staged.");
441
- });
442
- return provenance;
443
110
  }
444
111
 
445
- export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
446
- const projectDir = await gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
447
- throw new Error("mpo update must run inside a git repository");
448
- });
449
-
450
- // Clean tree required — the merge lands in the worktree.
451
- await git(projectDir, ["diff", "--quiet"]).catch(() => {
452
- throw new Error("mpo update requires a clean working tree (unstaged changes present)");
453
- });
454
- await git(projectDir, ["diff", "--cached", "--quiet"]).catch(() => {
455
- throw new Error("mpo update requires a clean working tree (staged changes present)");
456
- });
457
-
458
- let provenance = readProvenance(projectDir);
459
- if (options.assumeVersion) {
460
- if (provenance) {
461
- throw new Error(
462
- `--assume-version is for projects without ${PROVENANCE_FILE} — ` +
463
- `this project already has provenance (cli@${provenance.cliVersion})`,
464
- );
465
- }
466
- provenance = await bootstrapProvenance(projectDir, options);
467
- }
468
- if (!provenance) {
469
- throw new Error(
470
- `${PROVENANCE_FILE} not found — this project predates scaffold provenance.\n` +
471
- " - scaffolded by an old CLI (pre-6.3)? bootstrap it:\n" +
472
- " mpo update --assume-version <cliVersion> [--template universal|app]\n" +
473
- " - never scaffolded (hand-adopted pieces)? bring it under management:\n" +
474
- " mpo adopt --<piece> [...]",
475
- );
476
- }
477
-
112
+ async function updateAdopted(
113
+ projectDir: string,
114
+ provenance: MpoProvenance,
115
+ options: UpdateAppOptions,
116
+ ): Promise<void> {
478
117
  const currentVersion = cliVersion() ?? "0.0.0";
479
- const targetMpoRange = options.version
480
- ? options.version.startsWith("^") || options.version.startsWith("~")
481
- ? options.version
482
- : `^${options.version}`
483
- : `^${currentVersion}`;
484
-
485
- const adopted = provenance.template === "none";
486
- const firstReconcile = adopted && provenance.reconciled === false;
487
-
488
- console.log(`\nmpo update: ${provenance.cliVersion} → ${currentVersion}`);
489
- console.log(` template: ${provenance.template}${adopted ? " (adopted)" : ""}`);
490
- if (provenance.pieces.length) console.log(` pieces: ${provenance.pieces.join(", ")}`);
491
- console.log(` project: ${provenance.name}\n`);
118
+ const targetRange = mpoRange(options.version, currentVersion);
119
+ const name = provenance.name!;
120
+ const firstReconcile = provenance.reconciled === false;
121
+ console.log(`\nmpo update (adopted): cli ${provenance.cliVersion} → ${currentVersion}`);
122
+ console.log(` pieces ${provenance.pieces.join(", ")}`);
123
+ console.log(` project ${name}\n`);
492
124
  if (firstReconcile) {
493
125
  console.log("⚠️ FIRST UPDATE AFTER ADOPTION: piece files are reconciled against the");
494
126
  console.log(" piece templates from an empty merge base. Diffs and conflicts on");
@@ -496,238 +128,193 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
496
128
  console.log(" commit. Later updates merge three-way and stay quiet.\n");
497
129
  }
498
130
 
499
- // 1. Regenerate the ORIGINAL scaffold (merge base). Pieces recorded in
500
- // provenance are reproduced in BOTH baselines so piece files merge as
501
- // template content, not as phantom diffs. An adopted project's first
502
- // update has NO original scaffold — the tool never wrote a file there —
503
- // so its honest merge base is the empty tree.
504
- let base: { dir: string; cleanup: () => void };
505
- if (firstReconcile) {
506
- console.log("Using an empty merge base (first update after adoption)...");
507
- const dir = mkdtempSync(join(tmpdir(), "mpo-update-"));
508
- base = { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
509
- } else {
510
- console.log(`Scaffolding merge base (cli@${provenance.cliVersion})...`);
511
- base = await scaffoldBaseline(
512
- provenance.cliVersion,
513
- provenance.name,
514
- provenance.template,
515
- provenance.mpoVersion,
516
- provenance.pieces,
517
- );
518
- }
519
-
520
- // 2. Generate the CURRENT scaffold (theirs).
521
- console.log(`Scaffolding update target (cli@${currentVersion})...`);
522
- const next = await scaffoldBaseline(
523
- currentVersion,
524
- provenance.name,
525
- provenance.template,
526
- targetMpoRange,
527
- provenance.pieces,
528
- );
529
-
530
- // .mpo.json is TOOL-owned, never merged: pre-provenance CLIs (≤6.3.0)
531
- // didn't write it, so leaving it in the scaffolds guarantees an add/add
532
- // phantom conflict on every honest old-baseline update. It is stripped
533
- // from both synthetic trees and written authoritatively after the merge.
534
- rmSync(join(base.dir, ".mpo.json"), { force: true });
535
- rmSync(join(next.dir, ".mpo.json"), { force: true });
536
-
131
+ const base = tempDir();
132
+ const theirs = tempDir();
133
+ let oldPieces: { root?: string; cleanup: () => void } = { cleanup: () => {} };
134
+ let pieces = provenance.pieces;
537
135
  try {
538
- // 3. Three-way merge: base → HEAD (ours) vs base → next (theirs).
539
- const head = await gitOut(projectDir, ["rev-parse", "HEAD"]);
540
- const baseCommit = await commitTreeFromDir(
541
- projectDir,
542
- base.dir,
543
- `mpo scaffold ${provenance.template}@${provenance.cliVersion}`,
544
- );
545
- const nextCommit = await commitTreeFromDir(
546
- projectDir,
547
- next.dir,
548
- `mpo scaffold ${provenance.template}@${currentVersion}`,
549
- baseCommit,
550
- );
551
-
552
- let mergedTree: string;
553
- let conflicts: string[] = [];
554
- try {
555
- const out = await gitOut(projectDir, [
556
- "merge-tree",
557
- "--write-tree",
558
- "--name-only",
559
- `--merge-base=${baseCommit}`,
560
- head,
561
- nextCommit,
562
- ]);
563
- mergedTree = out.split("\n")[0]!.trim();
564
- } catch (err) {
565
- // Exit code 1 = conflicts. Output format: <tree>\n<conflicted file
566
- // names, one per line>\n\n<informational messages> — only the names
567
- // section (up to the blank line) lists conflicted paths.
568
- const stdout = (err as { stdout?: string }).stdout ?? "";
569
- const lines = stdout.split("\n");
570
- if (!lines[0]?.trim()) throw err;
571
- mergedTree = lines[0].trim();
572
- const blank = lines.indexOf("", 1);
573
- conflicts = lines
574
- .slice(1, blank === -1 ? undefined : blank)
575
- .map((line) => line.trim())
576
- .filter(Boolean);
577
- }
578
-
579
- // The merged .mpo.json can interleave ours/theirs lines (add/add
580
- // two-way merge) — the post-merge provenance is always written
581
- // authoritatively below. `reconciled: false` is intentionally NOT
582
- // carried over: a successful update reconciles the adopted project,
583
- // so later updates scaffold their merge base from the recorded version.
584
- const finalProvenance: MpoProvenance = {
585
- template: provenance.template,
586
- cliVersion: currentVersion,
587
- name: provenance.name,
588
- mpoVersion: targetMpoRange,
589
- pieces: provenance.pieces,
590
- };
591
-
592
- // .updateignore entries (+ pnpm-lock.yaml) pin to the pre-update HEAD.
593
- // Pins are applied to the MERGED TREE before committing, so a clean
594
- // update never leaves the tree dirty.
595
- //
596
- // Entries are EXACT FILES or DIRECTORY PREFIXES — the pin machinery
597
- // rides `git ls-tree -- <path>`, which does prefix matching, not
598
- // fnmatch. A silently dead ignore rule is the worst failure mode, so
599
- // the one natural glob spelling (`dir/**`, `dir/*`) is normalized to
600
- // its directory and anything else glob-shaped is refused loudly.
601
- const pins = ["pnpm-lock.yaml"];
602
- const updateignore = join(projectDir, ".updateignore");
603
- if (existsSync(updateignore)) {
604
- for (const line of readFileSync(updateignore, "utf-8").split("\n")) {
605
- const pattern = line.trim();
606
- if (!pattern || pattern.startsWith("#")) continue;
607
- const normalized = pattern.replace(/\/\*{1,2}$/, "");
608
- if (/[*?[\]]/.test(normalized)) {
609
- throw new Error(
610
- `.updateignore: "${pattern}" looks like a glob, but entries are ` +
611
- "exact files or directory prefixes (a glob here would silently " +
612
- "pin nothing). List the file or directory instead.",
613
- );
614
- }
615
- pins.push(normalized.replace(/\/+$/, ""));
136
+ if (!firstReconcile) {
137
+ oldPieces = await piecesRootAt(provenance.cliVersion, options);
138
+ scaffoldPieces(base.dir, {
139
+ name,
140
+ mpoVersion: provenance.mpoVersion ?? `^${provenance.cliVersion}`,
141
+ pieces: provenance.pieces,
142
+ root: oldPieces.root,
143
+ });
144
+ pieces = withSplitPieces(provenance.pieces, oldPieces.root);
145
+ if (pieces !== provenance.pieces) {
146
+ console.log(" frappe-bench added: the frappe piece no longer carries the dev bench\n");
616
147
  }
617
148
  }
618
- mergedTree = await pinPathsToHead(projectDir, mergedTree, head, pins);
619
- // Pinning resolves any conflict inside a pinned path (the pre-update
620
- // HEAD content wins by definition).
621
- conflicts = conflicts.filter(
622
- (file) => !pins.some((pin) => file === pin || file.startsWith(`${pin}/`)),
623
- );
149
+ scaffoldPieces(theirs.dir, { name, mpoVersion: targetRange, pieces });
624
150
 
625
- // Dependency-range drift in package.json files resolves mechanically
626
- // (newer minimum version wins) — don't punt those to the user.
627
- const autoResolved = await autoResolveDependencyConflicts(
151
+ const outcome = await mergeScaffolds({
628
152
  projectDir,
629
- mergedTree,
630
- conflicts,
631
- baseCommit,
632
- head,
633
- nextCommit,
634
- );
635
- mergedTree = autoResolved.tree;
636
- conflicts = autoResolved.conflicts;
637
- for (const file of autoResolved.resolved) {
638
- console.log(` auto-resolved: ${file} (dependency ranges)`);
639
- }
640
-
641
- // No-op detection: identical trees mean the project already carries the
642
- // current template — don't stack empty merge commits.
643
- const headTree = await gitOut(projectDir, ["rev-parse", `${head}^{tree}`]);
644
- if (!conflicts.length && mergedTree === headTree) {
153
+ baseDir: firstReconcile ? undefined : base.dir,
154
+ theirsDir: theirs.dir,
155
+ baseLabel: `mpo pieces ${provenance.cliVersion}`,
156
+ theirsLabel: `mpo pieces ${currentVersion}`,
157
+ });
158
+ const next: MpoProvenance = {
159
+ ...provenance,
160
+ cliVersion: currentVersion,
161
+ mpoVersion: targetRange,
162
+ pieces,
163
+ reconciled: undefined,
164
+ };
165
+ if (!outcome.changed && !outcome.conflicts.length) {
645
166
  if (
646
- provenance.cliVersion !== finalProvenance.cliVersion ||
647
- provenance.mpoVersion !== finalProvenance.mpoVersion ||
648
- provenance.reconciled === false
167
+ provenance.cliVersion !== currentVersion ||
168
+ provenance.mpoVersion !== targetRange ||
169
+ pieces !== provenance.pieces ||
170
+ firstReconcile
649
171
  ) {
650
- writeProvenance(projectDir, finalProvenance);
651
- await git(projectDir, ["add", "--", ".mpo.json"]);
652
- await git(projectDir, [
653
- "commit",
654
- "--quiet",
655
- "-m",
656
- `chore: record mpo template provenance ${currentVersion}`,
657
- ]);
658
- console.log("\n✅ already up to date (provenance refreshed)");
172
+ writeProvenance(projectDir, next);
173
+ console.log(`\n✅ already up to date; ${PROVENANCE_FILE} refreshed — commit it`);
659
174
  } else {
660
175
  console.log("\n✅ already up to date");
661
176
  }
662
177
  return;
663
178
  }
179
+ await landTree(projectDir, outcome.mergedTree);
180
+ writeProvenance(projectDir, next);
181
+ await finishMerge(
182
+ projectDir,
183
+ outcome,
184
+ `chore: mpo update ${provenance.cliVersion} → ${currentVersion}`,
185
+ options,
186
+ );
187
+ } finally {
188
+ base.cleanup();
189
+ theirs.cleanup();
190
+ oldPieces.cleanup();
191
+ }
192
+ }
664
193
 
665
- let cleanMergeMessage: string | undefined;
666
- if (!conflicts.length) {
667
- const message = `chore: mpo update ${provenance.cliVersion} → ${currentVersion}`;
668
- const mergeCommit = await gitOut(projectDir, [
669
- "commit-tree",
670
- mergedTree,
671
- "-p",
672
- head,
673
- "-p",
674
- nextCommit,
675
- "-m",
676
- message,
677
- ]);
678
- await git(projectDir, ["update-ref", "HEAD", mergeCommit]);
679
- await git(projectDir, ["reset", "--hard", "HEAD"]);
680
- // Tool-owned outputs belong to the update commit: provenance + the
681
- // regenerated .vscode config.
682
- writeProvenance(projectDir, finalProvenance);
683
- try {
684
- await generateVscodeConfig(projectDir);
685
- } catch {
686
- // best-effort (older layouts)
687
- }
688
- await git(projectDir, ["add", "--", ".mpo.json", ".vscode"]);
689
- await git(projectDir, ["commit", "--amend", "--no-edit", "--quiet"]);
690
- cleanMergeMessage = message;
691
- } else {
692
- // Land the merged tree (with conflict markers) in index + worktree for
693
- // manual resolution; pins are already part of the tree.
694
- await git(projectDir, ["read-tree", "-u", "--reset", mergedTree]);
695
- writeProvenance(projectDir, finalProvenance);
696
- console.log("\n⚠️ merge conflicts — resolve the markers, then commit:");
697
- for (const file of conflicts) console.log(` ${file}`);
698
- console.log(
699
- `\n git add -A && git commit -m "chore: mpo update ${provenance.cliVersion} → ${currentVersion}"`,
700
- );
701
- }
194
+ async function updateClone(
195
+ projectDir: string,
196
+ provenance: MpoProvenance,
197
+ options: UpdateAppOptions,
198
+ ): Promise<void> {
199
+ const currentVersion = cliVersion() ?? "0.0.0";
200
+ const recorded = provenance.source;
201
+ const templateEra = !recorded;
202
+ const apps = provenance.apps;
203
+ if (!apps.length) {
204
+ throw new Error(`${PROVENANCE_FILE} lists no product apps — nothing to update`);
205
+ }
702
206
 
703
- if (conflicts.length && !options.skipInstall) {
704
- console.log("\nSkipping pnpm install until conflicts are resolved (then run: pnpm install).");
705
- } else if (!options.skipInstall) {
706
- console.log("\nInstalling dependencies...");
707
- await spawn("pnpm", ["install"], { cwd: projectDir, stdio: "inherit" });
708
- // The merge changed dependency ranges, so the regenerated lockfile
709
- // belongs with the update: amend it into the clean-merge commit
710
- // (conflict resolutions commit manually and pick it up there).
711
- if (!conflicts.length) {
712
- const lockDirty = await git(projectDir, ["diff", "--quiet", "--", "pnpm-lock.yaml"]).then(
713
- () => false,
714
- () => true,
715
- );
716
- if (lockDirty) {
717
- await git(projectDir, ["add", "--", "pnpm-lock.yaml"]);
718
- await git(projectDir, ["commit", "--amend", "--no-edit", "--quiet"]);
719
- }
720
- }
207
+ // theirs: the CLI's source commit unless --source/--checkout say otherwise.
208
+ const nextSpec = resolveSource({ source: options.source, checkout: options.checkout });
209
+ if (nextSpec.warning) console.warn(`⚠️ ${nextSpec.warning}`);
210
+ // base: the recorded commit, read from --source when given (a mirror or a
211
+ // local clone must hold that commit too).
212
+ const baseSource = options.source ?? recorded?.url ?? nextSpec.source;
213
+ const targetRange = options.version ? mpoRange(options.version) : undefined;
214
+
215
+ console.log(
216
+ `\nmpo update: ${recorded ? recorded.sha.slice(0, 12) : "(template-era project)"} → ${nextSpec.source} @ ${nextSpec.ref}`,
217
+ );
218
+ console.log(` apps ${apps.join(", ")}`);
219
+ console.log(
220
+ ` services ${provenance.services.length ? provenance.services.join(", ") : "(none)"}`,
221
+ );
222
+ if (templateEra) {
223
+ console.log("\n⚠️ This project came from the retired template scaffolder, so there is no");
224
+ console.log(" scaffold to use as a merge base. The monorepo's One app is merged in");
225
+ console.log(" from an empty base once: expect additions and conflicts on shared files.");
226
+ console.log(" Pin what is yours in .updateignore first if needed. After this update the");
227
+ console.log(` project is clone-derived and later updates merge three-way.\n`);
228
+ }
229
+
230
+ if (recorded && !options.version) {
231
+ const nextSha = await resolveSha(nextSpec);
232
+ if (nextSha === recorded.sha) {
233
+ console.log("\n✅ already up to date");
234
+ return;
721
235
  }
236
+ }
722
237
 
723
- // Reported last — the lockfile amend above rewrites the commit, so an
724
- // earlier print would show a hash that no longer exists.
725
- if (cleanMergeMessage) {
726
- const finalHash = await gitOut(projectDir, ["rev-parse", "--short", "HEAD"]);
727
- console.log(`\n✅ merged cleanly → ${finalHash} ("${cleanMergeMessage}")`);
238
+ // Both scaffolds carry the project's own root package name, so the name
239
+ // never reads as a change (an empty base would otherwise conflict on it).
240
+ const name = packageJsonName(projectDir) ?? basename(projectDir);
241
+ const theirs = await scaffoldToTemp({
242
+ source: nextSpec.source,
243
+ ref: nextSpec.ref,
244
+ apps,
245
+ services: provenance.services,
246
+ mpoVersion: targetRange,
247
+ name,
248
+ verbose: options.verbose,
249
+ });
250
+ let base: TempScaffold | undefined;
251
+ try {
252
+ const nextSha = theirs.provenance.source!.sha;
253
+ if (recorded && nextSha === recorded.sha && !options.version) {
254
+ console.log("\n✅ already up to date");
255
+ return;
256
+ }
257
+ if (recorded) {
258
+ base = await scaffoldToTemp({
259
+ source: baseSource,
260
+ ref: recorded.sha,
261
+ apps,
262
+ services: provenance.services,
263
+ mpoVersion: `^${provenance.cliVersion}`,
264
+ name,
265
+ verbose: options.verbose,
266
+ });
267
+ }
268
+ for (const dir of [theirs.dir, base?.dir]) {
269
+ if (dir) rmSync(join(dir, PROVENANCE_FILE), { force: true });
270
+ }
271
+ const outcome = await mergeScaffolds({
272
+ projectDir,
273
+ baseDir: base?.dir,
274
+ theirsDir: theirs.dir,
275
+ baseLabel: recorded ? `mpo scaffold ${recorded.sha}` : "mpo scaffold (empty base)",
276
+ theirsLabel: `mpo scaffold ${nextSha}`,
277
+ });
278
+ const next: MpoProvenance = {
279
+ cliVersion: theirs.provenance.cliVersion || currentVersion,
280
+ source: { sha: nextSha, url: options.source ?? recorded?.url ?? nextSpec.source },
281
+ apps,
282
+ services: provenance.services,
283
+ pieces: [],
284
+ };
285
+ const label = `${recorded ? recorded.sha.slice(0, 12) : "template"} → ${nextSha.slice(0, 12)}`;
286
+ if (!outcome.changed && !outcome.conflicts.length) {
287
+ writeProvenance(projectDir, next);
288
+ console.log(`\n✅ already up to date; ${PROVENANCE_FILE} refreshed — commit it`);
289
+ return;
728
290
  }
291
+ await landTree(projectDir, outcome.mergedTree);
292
+ await dropStalePnpmfileChecksum(projectDir);
293
+ writeProvenance(projectDir, next);
294
+ await finishMerge(projectDir, outcome, `chore: mpo update ${label}`, options);
729
295
  } finally {
730
- base.cleanup();
731
- next.cleanup();
296
+ theirs.cleanup();
297
+ base?.cleanup();
298
+ }
299
+ }
300
+
301
+ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
302
+ const projectDir = await projectRootOf("mpo update");
303
+ await requireCleanTree(projectDir, "mpo update");
304
+ const provenance = readProvenance(projectDir);
305
+ if (!provenance) {
306
+ throw new Error(
307
+ `${PROVENANCE_FILE} not found — this project has no scaffold provenance.\n` +
308
+ " - hand-copied piece files from the templates? bring it under management:\n" +
309
+ " mpo adopt --<piece> [...]\n" +
310
+ " - scaffolded from the monorepo before provenance existed? write .mpo.json:\n" +
311
+ ' { "cliVersion": "<cli>", "source": { "sha": "<commit>", "url": "<repo>" },\n' +
312
+ ' "apps": ["<name>"], "services": [], "pieces": [] }',
313
+ );
314
+ }
315
+ if (provenance.template === "none") {
316
+ await updateAdopted(projectDir, provenance, options);
317
+ return;
732
318
  }
319
+ await updateClone(projectDir, provenance, options);
733
320
  }