@multiplatform.one/cli 7.11.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 +6 -6
  17. package/scripts/frappe-bootstrap.sh +29 -3
  18. package/scripts/frappe-dev.sh +1 -2
  19. package/scripts/frappe-helpers.sh +50 -2
  20. package/scripts/frappe.sh +5 -0
  21. package/scripts/scaffold.sh +937 -0
  22. package/scripts/stamp-source.mjs +51 -0
  23. package/scripts/stamp-source.spec.ts +86 -0
  24. package/src/bin/multiplatformOne.ts +116 -226
  25. package/src/commands/addApp.ts +102 -0
  26. package/src/commands/adoptApp.spec.ts +5 -1
  27. package/src/commands/adoptApp.ts +15 -10
  28. package/src/commands/doctypeTypes.spec.ts +155 -0
  29. package/src/commands/doctypeTypes.ts +214 -0
  30. package/src/commands/init.spec.ts +66 -0
  31. package/src/commands/init.ts +201 -643
  32. package/src/commands/merge.spec.ts +81 -0
  33. package/src/commands/merge.ts +411 -0
  34. package/src/commands/pieces.spec.ts +62 -0
  35. package/src/commands/pieces.ts +340 -0
  36. package/src/commands/provenance.spec.ts +92 -0
  37. package/src/commands/provenance.ts +169 -0
  38. package/src/commands/scaffold.ts +131 -0
  39. package/src/commands/updateApp.spec.ts +540 -331
  40. package/src/commands/updateApp.ts +255 -668
  41. package/src/source.spec.ts +45 -0
  42. package/src/source.ts +88 -0
  43. package/templates/AGENTS.md +161 -0
  44. package/templates/README.md +62 -0
  45. package/templates/pieces/frappe/universal/README.md.partial +7 -32
  46. package/templates/pieces/frappe/universal/env.example.partial +3 -3
  47. package/templates/pieces/frappe-bench/universal/README.md.partial +28 -0
  48. package/templates/pieces/{frappe → frappe-bench}/universal/docker/compose.frappe.yaml +2 -2
  49. package/templates/pieces/tauri/universal/README.md.partial +7 -5
  50. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/dev.rs +463 -0
  51. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +6 -0
  52. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +2 -1
  53. package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +1 -0
  54. package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +6 -2
  55. package/types/bin/multiplatformOne.d.ts.map +1 -1
  56. package/types/commands/addApp.d.ts +20 -0
  57. package/types/commands/addApp.d.ts.map +1 -0
  58. package/types/commands/adoptApp.d.ts +4 -3
  59. package/types/commands/adoptApp.d.ts.map +1 -1
  60. package/types/commands/doctypeTypes.d.ts +40 -0
  61. package/types/commands/doctypeTypes.d.ts.map +1 -0
  62. package/types/commands/init.d.ts +30 -13
  63. package/types/commands/init.d.ts.map +1 -1
  64. package/types/commands/merge.d.ts +54 -0
  65. package/types/commands/merge.d.ts.map +1 -0
  66. package/types/commands/pieces.d.ts +54 -0
  67. package/types/commands/pieces.d.ts.map +1 -0
  68. package/types/commands/provenance.d.ts +52 -0
  69. package/types/commands/provenance.d.ts.map +1 -0
  70. package/types/commands/scaffold.d.ts +43 -0
  71. package/types/commands/scaffold.d.ts.map +1 -0
  72. package/types/commands/updateApp.d.ts +19 -27
  73. package/types/commands/updateApp.d.ts.map +1 -1
  74. package/types/source.d.ts +35 -0
  75. package/types/source.d.ts.map +1 -0
  76. package/lib/commands/initApp.mjs +0 -431
  77. package/lib/generateVscode.mjs +0 -184
  78. package/scripts/clone.sh +0 -17
  79. package/scripts/update.sh +0 -46
  80. package/src/commands/initApp.spec.ts +0 -720
  81. package/src/commands/initApp.ts +0 -706
  82. package/src/generateVscode.ts +0 -238
  83. package/templates/app/AGENTS.md +0 -142
  84. package/templates/app/README.md +0 -62
  85. package/templates/app/apps/__NAME__/index.html +0 -36
  86. package/templates/app/apps/__NAME__/package.json +0 -31
  87. package/templates/app/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  88. package/templates/app/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  89. package/templates/app/apps/__NAME__/src/App.tsx +0 -9
  90. package/templates/app/apps/__NAME__/src/main.tsx +0 -14
  91. package/templates/app/apps/__NAME__/tsconfig.json +0 -8
  92. package/templates/app/apps/__NAME__/vite.config.ts +0 -17
  93. package/templates/app/features/__NAME__/package.json +0 -34
  94. package/templates/app/features/__NAME__/src/HomeScreen.tsx +0 -70
  95. package/templates/app/features/__NAME__/src/index.ts +0 -1
  96. package/templates/app/features/__NAME__/tsconfig.json +0 -5
  97. package/templates/app/gitignore +0 -9
  98. package/templates/app/npmrc +0 -2
  99. package/templates/app/oxlintrc.json +0 -39
  100. package/templates/app/package.json +0 -38
  101. package/templates/app/packages/config/config.json +0 -4
  102. package/templates/app/packages/config/index.ts +0 -5
  103. package/templates/app/packages/config/package.json +0 -17
  104. package/templates/app/packages/config/tsconfig.json +0 -5
  105. package/templates/app/pnpm-workspace.yaml +0 -4
  106. package/templates/app/tsconfig.base.json +0 -16
  107. package/templates/universal/AGENTS.md +0 -142
  108. package/templates/universal/README.md +0 -97
  109. package/templates/universal/apps/__NAME__/app.config.js +0 -31
  110. package/templates/universal/apps/__NAME__/config/tamagui.config.ts +0 -4
  111. package/templates/universal/apps/__NAME__/package.json +0 -62
  112. package/templates/universal/apps/__NAME__/polyfills.native.ts +0 -23
  113. package/templates/universal/apps/__NAME__/polyfills.ts +0 -3
  114. package/templates/universal/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  115. package/templates/universal/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  116. package/templates/universal/apps/__NAME__/routes/_layout.tsx +0 -22
  117. package/templates/universal/apps/__NAME__/routes/_layout.web.tsx +0 -21
  118. package/templates/universal/apps/__NAME__/routes/about.tsx +0 -3
  119. package/templates/universal/apps/__NAME__/routes/index.tsx +0 -3
  120. package/templates/universal/apps/__NAME__/src/app.ts +0 -10
  121. package/templates/universal/apps/__NAME__/tamagui.css +0 -3
  122. package/templates/universal/apps/__NAME__/tsconfig.json +0 -8
  123. package/templates/universal/apps/__NAME__/types.d.ts +0 -9
  124. package/templates/universal/apps/__NAME__/vite.config.ts +0 -20
  125. package/templates/universal/features/__NAME__/package.json +0 -35
  126. package/templates/universal/features/__NAME__/src/AboutScreen.tsx +0 -27
  127. package/templates/universal/features/__NAME__/src/AppLayout.tsx +0 -50
  128. package/templates/universal/features/__NAME__/src/ContactForm.spec.tsx +0 -22
  129. package/templates/universal/features/__NAME__/src/ContactForm.tsx +0 -55
  130. package/templates/universal/features/__NAME__/src/HomeScreen.tsx +0 -40
  131. package/templates/universal/features/__NAME__/src/index.ts +0 -4
  132. package/templates/universal/features/__NAME__/src/screens.spec.ts +0 -12
  133. package/templates/universal/features/__NAME__/tsconfig.json +0 -5
  134. package/templates/universal/features/__NAME__/vitest.config.mjs +0 -7
  135. package/templates/universal/gitignore +0 -14
  136. package/templates/universal/npmrc +0 -3
  137. package/templates/universal/oxlintrc.json +0 -39
  138. package/templates/universal/package.json +0 -51
  139. package/templates/universal/packages/config/config.json +0 -4
  140. package/templates/universal/packages/config/index.ts +0 -5
  141. package/templates/universal/packages/config/package.json +0 -17
  142. package/templates/universal/packages/config/tsconfig.json +0 -5
  143. package/templates/universal/packages/i18n/en/common.json +0 -30
  144. package/templates/universal/packages/i18n/es/common.json +0 -30
  145. package/templates/universal/packages/i18n/i18n.d.ts +0 -16
  146. package/templates/universal/packages/i18n/index.ts +0 -26
  147. package/templates/universal/packages/i18n/package.json +0 -20
  148. package/templates/universal/packages/i18n/resources.ts +0 -13
  149. package/templates/universal/packages/i18n/tsconfig.json +0 -5
  150. package/templates/universal/packages/themes/accent.ts +0 -32
  151. package/templates/universal/packages/themes/base.ts +0 -50
  152. package/templates/universal/packages/themes/fonts.ts +0 -8
  153. package/templates/universal/packages/themes/index.ts +0 -33
  154. package/templates/universal/packages/themes/package.json +0 -23
  155. package/templates/universal/packages/themes/tamagui.config.ts +0 -14
  156. package/templates/universal/packages/themes/tsconfig.json +0 -5
  157. package/templates/universal/pnpm-workspace.yaml +0 -4
  158. package/templates/universal/tsconfig.base.json +0 -21
  159. package/templates/universal/types/env.d.ts +0 -9
  160. package/types/commands/initApp.d.ts +0 -114
  161. package/types/commands/initApp.d.ts.map +0 -1
  162. package/types/generateVscode.d.ts +0 -7
  163. package/types/generateVscode.d.ts.map +0 -1
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { threeWayMergePackageJson } from "./merge";
3
+
4
+ const json = (value: unknown) => JSON.stringify(value, null, 2);
5
+
6
+ describe("threeWayMergePackageJson", () => {
7
+ it("resolves dependency-range drift to the newer minimum", () => {
8
+ const base = json({ dependencies: { "@multiplatform.one/components": "^6.3.0" } });
9
+ const ours = json({ dependencies: { "@multiplatform.one/components": "^6.4.2" } });
10
+ const theirs = json({ dependencies: { "@multiplatform.one/components": "^6.4.3" } });
11
+ expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
12
+ dependencies: { "@multiplatform.one/components": "^6.4.3" },
13
+ });
14
+ // Symmetric: the project may be AHEAD of the scaffold.
15
+ expect(threeWayMergePackageJson(base, theirs, ours)).toEqual({
16
+ dependencies: { "@multiplatform.one/components": "^6.4.3" },
17
+ });
18
+ });
19
+
20
+ it("prefers the scaffold spelling on equal minimums", () => {
21
+ const base = json({ dependencies: { react: "^19.0.0" } });
22
+ const ours = json({ dependencies: { react: "19.2.0" } });
23
+ const theirs = json({ dependencies: { react: "~19.2.0" } });
24
+ expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
25
+ dependencies: { react: "~19.2.0" },
26
+ });
27
+ });
28
+
29
+ it("keeps project-added dependencies alongside scaffold bumps", () => {
30
+ const base = json({ dependencies: { one: "^1.0.0" } });
31
+ const ours = json({ dependencies: { one: "^1.0.0", "@multiplatform.one/keycloak": "^6.4.2" } });
32
+ const theirs = json({ dependencies: { one: "^1.1.0" } });
33
+ expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
34
+ dependencies: { one: "^1.1.0", "@multiplatform.one/keycloak": "^6.4.2" },
35
+ });
36
+ });
37
+
38
+ it("applies scaffold deletions the project did not touch", () => {
39
+ const base = json({ scripts: { postinstall: "patch-package", build: "vite build" } });
40
+ const ours = json({ scripts: { postinstall: "patch-package", build: "vite build" } });
41
+ const theirs = json({ scripts: { build: "vite build" } });
42
+ expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
43
+ scripts: { build: "vite build" },
44
+ });
45
+ });
46
+
47
+ it("resolves nested override maps", () => {
48
+ const base = json({ pnpm: { overrides: { "react-dom": "19.2.4" } } });
49
+ const ours = json({ pnpm: { overrides: { "react-dom": "19.2.5" } } });
50
+ const theirs = json({ pnpm: { overrides: { "react-dom": "19.2.6" } } });
51
+ expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
52
+ pnpm: { overrides: { "react-dom": "19.2.6" } },
53
+ });
54
+ });
55
+
56
+ it("returns undefined for genuine two-sided conflicts", () => {
57
+ const base = json({ scripts: { build: "vite build" } });
58
+ const ours = json({ scripts: { build: "vite build --mode ours" } });
59
+ const theirs = json({ scripts: { build: "vite build --mode theirs" } });
60
+ expect(threeWayMergePackageJson(base, ours, theirs)).toBeUndefined();
61
+ });
62
+
63
+ it("returns undefined for non-range dependency divergence", () => {
64
+ const base = json({ dependencies: { local: "^1.0.0" } });
65
+ const ours = json({ dependencies: { local: "workspace:*" } });
66
+ const theirs = json({ dependencies: { local: "^2.0.0" } });
67
+ expect(threeWayMergePackageJson(base, ours, theirs)).toBeUndefined();
68
+ });
69
+
70
+ it("returns undefined on unparseable input", () => {
71
+ expect(threeWayMergePackageJson("{}", "not json", "{}")).toBeUndefined();
72
+ });
73
+
74
+ it("treats a missing base as empty (add/add merges)", () => {
75
+ const ours = json({ dependencies: { a: "^1.0.0" } });
76
+ const theirs = json({ dependencies: { b: "^2.0.0" } });
77
+ expect(threeWayMergePackageJson(undefined, ours, theirs)).toEqual({
78
+ dependencies: { a: "^1.0.0", b: "^2.0.0" },
79
+ });
80
+ });
81
+ });
@@ -0,0 +1,411 @@
1
+ /**
2
+ * The three-way merge behind `mpo update` and `mpo add-app`:
3
+ *
4
+ * base = the scaffold the project started from (recorded in .mpo.json)
5
+ * ours = the project's HEAD (every commit the project made)
6
+ * theirs = the scaffold it is moving to
7
+ *
8
+ * `git merge-tree --write-tree --merge-base=<base>` merges the scaffold's
9
+ * evolution with the project's history; the result lands in the index and
10
+ * worktree, never in a commit, so the project commits when it is happy.
11
+ * `.updateignore` entries (exact files or directory prefixes, not globs) and
12
+ * pnpm-lock.yaml stay pinned to HEAD.
13
+ */
14
+
15
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { basename, join } from "node:path";
18
+ import spawn from "nano-spawn";
19
+
20
+ export async function git(projectDir: string, args: string[], env?: Record<string, string>) {
21
+ return spawn("git", args, {
22
+ cwd: projectDir,
23
+ env: env ? { ...process.env, ...env } : undefined,
24
+ });
25
+ }
26
+
27
+ export async function gitOut(projectDir: string, args: string[], env?: Record<string, string>) {
28
+ const result = await git(projectDir, args, env);
29
+ return result.stdout.trim();
30
+ }
31
+
32
+ export async function projectRootOf(command: string): Promise<string> {
33
+ return gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
34
+ throw new Error(`${command} must run inside a git repository`);
35
+ });
36
+ }
37
+
38
+ /** The merge lands in the worktree, so nothing else may be in flight there. */
39
+ export async function requireCleanTree(projectDir: string, command: string): Promise<void> {
40
+ await git(projectDir, ["rev-parse", "HEAD"]).catch(() => {
41
+ throw new Error(`${command} requires at least one commit`);
42
+ });
43
+ await git(projectDir, ["diff", "--quiet"]).catch(() => {
44
+ throw new Error(`${command} requires a clean working tree (unstaged changes present)`);
45
+ });
46
+ await git(projectDir, ["diff", "--cached", "--quiet"]).catch(() => {
47
+ throw new Error(`${command} requires a clean working tree (staged changes present)`);
48
+ });
49
+ }
50
+
51
+ /** Import a directory tree into the project's object database as a
52
+ * commit (temp index; the worktree is never touched). The directory's own
53
+ * .gitignore applies, so a generated .env never enters the tree. */
54
+ async function commitTreeFromDir(
55
+ projectDir: string,
56
+ dir: string,
57
+ message: string,
58
+ parent?: string,
59
+ ): Promise<string> {
60
+ const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-index-")), "index");
61
+ const env = { GIT_INDEX_FILE: indexFile, GIT_WORK_TREE: dir };
62
+ await git(projectDir, ["add", "-A", "--", "."], env);
63
+ const tree = await gitOut(projectDir, ["write-tree"], env);
64
+ const commitArgs = parent
65
+ ? ["commit-tree", tree, "-p", parent, "-m", message]
66
+ : ["commit-tree", tree, "-m", message];
67
+ const commit = await gitOut(projectDir, commitArgs);
68
+ rmSync(indexFile, { force: true });
69
+ return commit;
70
+ }
71
+
72
+ async function emptyCommit(projectDir: string, message: string): Promise<string> {
73
+ const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-index-")), "index");
74
+ const env = { GIT_INDEX_FILE: indexFile };
75
+ await git(projectDir, ["read-tree", "--empty"], env);
76
+ const tree = await gitOut(projectDir, ["write-tree"], env);
77
+ rmSync(indexFile, { force: true });
78
+ return gitOut(projectDir, ["commit-tree", tree, "-m", message]);
79
+ }
80
+
81
+ /** A tree derived from `tree` where every path under `pins` is replaced by
82
+ * its content in `headCommit` (removed when absent there). */
83
+ async function pinPathsToHead(
84
+ projectDir: string,
85
+ tree: string,
86
+ headCommit: string,
87
+ pins: string[],
88
+ ): Promise<string> {
89
+ const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-pin-")), "index");
90
+ const env = { GIT_INDEX_FILE: indexFile };
91
+ await git(projectDir, ["read-tree", tree], env);
92
+ for (const pin of pins) {
93
+ const inTree = await gitOut(projectDir, ["ls-tree", "-r", "--name-only", tree, "--", pin], env)
94
+ .then((out) => out.split("\n").filter(Boolean))
95
+ .catch(() => [] as string[]);
96
+ for (const path of inTree) {
97
+ await git(projectDir, ["update-index", "--force-remove", "--", path], env).catch(() => {});
98
+ }
99
+ const headEntries = await gitOut(
100
+ projectDir,
101
+ ["ls-tree", "-r", headCommit, "--", pin],
102
+ env,
103
+ ).catch(() => "");
104
+ for (const line of headEntries.split("\n").filter(Boolean)) {
105
+ // "<mode> blob <oid>\t<path>"
106
+ const match = line.match(/^(\d+) \S+ ([0-9a-f]+)\t(.+)$/);
107
+ if (!match) continue;
108
+ await git(
109
+ projectDir,
110
+ ["update-index", "--add", "--cacheinfo", `${match[1]},${match[2]},${match[3]}`],
111
+ env,
112
+ ).catch(() => {});
113
+ }
114
+ }
115
+ const pinned = await gitOut(projectDir, ["write-tree"], env);
116
+ rmSync(indexFile, { force: true });
117
+ return pinned;
118
+ }
119
+
120
+ /**
121
+ * Pins: pnpm-lock.yaml plus every .updateignore entry. Entries are exact
122
+ * files or directory prefixes (`git ls-tree -- <path>` semantics); the one
123
+ * natural glob spelling (`dir/**`, `dir/*`) normalizes to its directory and
124
+ * any other glob shape is refused, because a silently dead ignore rule is
125
+ * the worst failure mode.
126
+ */
127
+ export function readPins(projectDir: string): string[] {
128
+ const pins = ["pnpm-lock.yaml"];
129
+ const updateignore = join(projectDir, ".updateignore");
130
+ if (!existsSync(updateignore)) return pins;
131
+ for (const line of readFileSync(updateignore, "utf-8").split("\n")) {
132
+ const pattern = line.trim();
133
+ if (!pattern || pattern.startsWith("#")) continue;
134
+ const normalized = pattern.replace(/\/\*{1,2}$/, "");
135
+ if (/[*?[\]]/.test(normalized)) {
136
+ throw new Error(
137
+ `.updateignore: "${pattern}" looks like a glob, but entries are ` +
138
+ "exact files or directory prefixes (a glob here would silently " +
139
+ "pin nothing). List the file or directory instead.",
140
+ );
141
+ }
142
+ pins.push(normalized.replace(/\/+$/, ""));
143
+ }
144
+ return pins;
145
+ }
146
+
147
+ // ── package.json conflict auto-resolution ─────────────────────────
148
+ //
149
+ // The dominant conflict class in real updates is dependency-range drift:
150
+ // the project bumped a range (ours ^6.4.2) while the scaffold moved too
151
+ // (theirs ^6.4.3). Both sides changed the same JSON entry, so textual merge
152
+ // punts, but the resolution is mechanical: the newer minimum wins.
153
+
154
+ const UNRESOLVED = Symbol("unresolved");
155
+ const ABSENT = Symbol("absent");
156
+
157
+ const DEP_MAP_KEYS = new Set([
158
+ "dependencies",
159
+ "devDependencies",
160
+ "peerDependencies",
161
+ "optionalDependencies",
162
+ "overrides",
163
+ "resolutions",
164
+ ]);
165
+
166
+ function rangeMinimum(range: unknown): [number, number, number] | undefined {
167
+ if (typeof range !== "string") return undefined;
168
+ const match = range.match(/^[\^~]?(\d+)\.(\d+)\.(\d+)(?:-[\w.]+)?$/);
169
+ if (!match) return undefined;
170
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
171
+ }
172
+
173
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
174
+ return typeof value === "object" && value !== null && !Array.isArray(value);
175
+ }
176
+
177
+ function jsonEqual(a: unknown, b: unknown): boolean {
178
+ return JSON.stringify(a) === JSON.stringify(b);
179
+ }
180
+
181
+ function mergeNode(
182
+ base: unknown,
183
+ ours: unknown,
184
+ theirs: unknown,
185
+ inDepMap: boolean,
186
+ ): unknown | typeof UNRESOLVED | typeof ABSENT {
187
+ if (jsonEqual(ours, theirs)) return ours === undefined ? ABSENT : ours;
188
+ if (jsonEqual(theirs, base)) return ours === undefined ? ABSENT : ours;
189
+ if (jsonEqual(ours, base)) return theirs === undefined ? ABSENT : theirs;
190
+ if (isPlainObject(ours) && isPlainObject(theirs)) {
191
+ const baseObject = isPlainObject(base) ? base : {};
192
+ const merged: Record<string, unknown> = {};
193
+ const keys = [...Object.keys(ours), ...Object.keys(theirs).filter((key) => !(key in ours))];
194
+ for (const key of keys) {
195
+ const child = mergeNode(
196
+ baseObject[key],
197
+ ours[key],
198
+ theirs[key],
199
+ inDepMap || DEP_MAP_KEYS.has(key),
200
+ );
201
+ if (child === UNRESOLVED) return UNRESOLVED;
202
+ if (child !== ABSENT) merged[key] = child;
203
+ }
204
+ return merged;
205
+ }
206
+ if (inDepMap) {
207
+ const oursMin = rangeMinimum(ours);
208
+ const theirsMin = rangeMinimum(theirs);
209
+ if (oursMin && theirsMin) {
210
+ for (let i = 0; i < 3; i++) {
211
+ if (oursMin[i]! > theirsMin[i]!) return ours;
212
+ if (oursMin[i]! < theirsMin[i]!) return theirs;
213
+ }
214
+ return theirs; // equal minimums, different spelling: the scaffold wins
215
+ }
216
+ }
217
+ return UNRESOLVED;
218
+ }
219
+
220
+ /** Structural three-way merge of package.json content, or undefined when a
221
+ * divergence cannot be auto-resolved (the textual markers then stay). */
222
+ export function threeWayMergePackageJson(
223
+ base: string | undefined,
224
+ ours: string,
225
+ theirs: string,
226
+ ): Record<string, unknown> | undefined {
227
+ let baseJson: unknown;
228
+ let oursJson: unknown;
229
+ let theirsJson: unknown;
230
+ try {
231
+ baseJson = base === undefined ? {} : JSON.parse(base);
232
+ oursJson = JSON.parse(ours);
233
+ theirsJson = JSON.parse(theirs);
234
+ } catch {
235
+ return undefined;
236
+ }
237
+ if (!isPlainObject(oursJson) || !isPlainObject(theirsJson)) return undefined;
238
+ const merged = mergeNode(baseJson, oursJson, theirsJson, false);
239
+ if (merged === UNRESOLVED || merged === ABSENT || !isPlainObject(merged)) return undefined;
240
+ return merged;
241
+ }
242
+
243
+ async function autoResolveDependencyConflicts(
244
+ projectDir: string,
245
+ mergedTree: string,
246
+ conflicts: string[],
247
+ baseCommit: string,
248
+ headCommit: string,
249
+ nextCommit: string,
250
+ ): Promise<{ tree: string; conflicts: string[]; resolved: string[] }> {
251
+ const candidates = conflicts.filter((file) => basename(file) === "package.json");
252
+ if (!candidates.length) return { tree: mergedTree, conflicts, resolved: [] };
253
+
254
+ const scratch = mkdtempSync(join(tmpdir(), "mpo-resolve-"));
255
+ const resolved: { path: string; oid: string }[] = [];
256
+ try {
257
+ for (const file of candidates) {
258
+ const show = (commit: string) =>
259
+ gitOut(projectDir, ["show", `${commit}:${file}`]).catch(() => undefined);
260
+ const [base, ours, theirs] = await Promise.all([
261
+ show(baseCommit),
262
+ show(headCommit),
263
+ show(nextCommit),
264
+ ]);
265
+ if (ours === undefined || theirs === undefined) continue;
266
+ const merged = threeWayMergePackageJson(base, ours, theirs);
267
+ if (!merged) continue;
268
+ const blobFile = join(scratch, "blob.json");
269
+ writeFileSync(blobFile, `${JSON.stringify(merged, null, 2)}\n`);
270
+ const oid = await gitOut(projectDir, ["hash-object", "-w", "--", blobFile]);
271
+ resolved.push({ path: file, oid });
272
+ }
273
+ if (!resolved.length) return { tree: mergedTree, conflicts, resolved: [] };
274
+
275
+ const indexFile = join(scratch, "index");
276
+ const env = { GIT_INDEX_FILE: indexFile };
277
+ await git(projectDir, ["read-tree", mergedTree], env);
278
+ for (const entry of resolved) {
279
+ await git(
280
+ projectDir,
281
+ ["update-index", "--add", "--cacheinfo", `100644,${entry.oid},${entry.path}`],
282
+ env,
283
+ );
284
+ }
285
+ const tree = await gitOut(projectDir, ["write-tree"], env);
286
+ const resolvedPaths = new Set(resolved.map((entry) => entry.path));
287
+ return {
288
+ tree,
289
+ conflicts: conflicts.filter((file) => !resolvedPaths.has(file)),
290
+ resolved: [...resolvedPaths],
291
+ };
292
+ } finally {
293
+ rmSync(scratch, { recursive: true, force: true });
294
+ }
295
+ }
296
+
297
+ export interface MergeRequest {
298
+ projectDir: string;
299
+ /** The scaffold the project started from; undefined = the empty tree. */
300
+ baseDir?: string;
301
+ theirsDir: string;
302
+ baseLabel: string;
303
+ theirsLabel: string;
304
+ }
305
+
306
+ export interface MergeOutcome {
307
+ mergedTree: string;
308
+ /** false when the merged tree is HEAD's tree already. */
309
+ changed: boolean;
310
+ conflicts: string[];
311
+ resolved: string[];
312
+ }
313
+
314
+ /** Compute the merged tree. Pure object-database work; nothing is written to the worktree. */
315
+ export async function mergeScaffolds(request: MergeRequest): Promise<MergeOutcome> {
316
+ const { projectDir } = request;
317
+ const head = await gitOut(projectDir, ["rev-parse", "HEAD"]);
318
+ const baseCommit = request.baseDir
319
+ ? await commitTreeFromDir(projectDir, request.baseDir, request.baseLabel)
320
+ : await emptyCommit(projectDir, request.baseLabel);
321
+ const nextCommit = await commitTreeFromDir(
322
+ projectDir,
323
+ request.theirsDir,
324
+ request.theirsLabel,
325
+ baseCommit,
326
+ );
327
+
328
+ let mergedTree: string;
329
+ let conflicts: string[] = [];
330
+ try {
331
+ const out = await gitOut(projectDir, [
332
+ "merge-tree",
333
+ "--write-tree",
334
+ "--name-only",
335
+ `--merge-base=${baseCommit}`,
336
+ head,
337
+ nextCommit,
338
+ ]);
339
+ mergedTree = out.split("\n")[0]!.trim();
340
+ } catch (err) {
341
+ // Exit code 1 = conflicts: <tree>\n<conflicted paths>\n\n<messages>.
342
+ const stdout = (err as { stdout?: string }).stdout ?? "";
343
+ const lines = stdout.split("\n");
344
+ if (!lines[0]?.trim()) throw err;
345
+ mergedTree = lines[0].trim();
346
+ const blank = lines.indexOf("", 1);
347
+ conflicts = lines
348
+ .slice(1, blank === -1 ? undefined : blank)
349
+ .map((line) => line.trim())
350
+ .filter(Boolean);
351
+ }
352
+
353
+ const pins = readPins(projectDir);
354
+ mergedTree = await pinPathsToHead(projectDir, mergedTree, head, pins);
355
+ conflicts = conflicts.filter(
356
+ (file) => !pins.some((pin) => file === pin || file.startsWith(`${pin}/`)),
357
+ );
358
+
359
+ const autoResolved = await autoResolveDependencyConflicts(
360
+ projectDir,
361
+ mergedTree,
362
+ conflicts,
363
+ baseCommit,
364
+ head,
365
+ nextCommit,
366
+ );
367
+ const headTree = await gitOut(projectDir, ["rev-parse", `${head}^{tree}`]);
368
+ return {
369
+ mergedTree: autoResolved.tree,
370
+ changed: autoResolved.tree !== headTree,
371
+ conflicts: autoResolved.conflicts,
372
+ resolved: autoResolved.resolved,
373
+ };
374
+ }
375
+
376
+ /** Land a tree in the index and worktree. The tree was clean before, so
377
+ * every difference is the merge; nothing is committed. */
378
+ export async function landTree(projectDir: string, tree: string): Promise<void> {
379
+ await git(projectDir, ["read-tree", "-u", "--reset", tree]);
380
+ }
381
+
382
+ /** Print the outcome and the commit line; run pnpm install on a clean merge. */
383
+ export async function finishMerge(
384
+ projectDir: string,
385
+ outcome: MergeOutcome,
386
+ commitMessage: string,
387
+ options: { skipInstall?: boolean } = {},
388
+ ): Promise<void> {
389
+ for (const file of outcome.resolved) {
390
+ console.log(` auto-resolved: ${file} (dependency ranges)`);
391
+ }
392
+ if (outcome.conflicts.length) {
393
+ console.log("\n⚠️ merge conflicts — resolve the markers, then commit:");
394
+ for (const file of outcome.conflicts) console.log(` ${file}`);
395
+ } else {
396
+ console.log("\n✅ merged cleanly into the index and worktree (nothing committed)");
397
+ }
398
+ if (outcome.conflicts.length) {
399
+ if (!options.skipInstall) {
400
+ console.log(
401
+ "\nSkipping pnpm install until the conflicts are resolved (then run: pnpm install).",
402
+ );
403
+ }
404
+ } else if (!options.skipInstall) {
405
+ console.log("\nInstalling dependencies...");
406
+ await spawn("pnpm", ["install"], { cwd: projectDir, stdio: "inherit" });
407
+ }
408
+ console.log(
409
+ `\nReview with git status / git diff, then:\n git add -A && git commit -m "${commitMessage}"`,
410
+ );
411
+ }
@@ -0,0 +1,62 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
+ import { piecesRoot, scaffoldPieces, withSplitPieces } from "./pieces";
6
+
7
+ describe("the frappe client and bench pieces (MPO-241)", () => {
8
+ let sandbox: string;
9
+
10
+ beforeAll(() => {
11
+ sandbox = mkdtempSync(join(tmpdir(), "mpo-pieces-spec-"));
12
+ });
13
+
14
+ afterAll(() => {
15
+ rmSync(sandbox, { recursive: true, force: true });
16
+ });
17
+
18
+ it("frappe alone is the client: env keys and provider, and nothing names a bench", () => {
19
+ const dir = join(sandbox, "client");
20
+ scaffoldPieces(dir, { name: "shop", mpoVersion: "^7.0.0", pieces: ["frappe"] });
21
+
22
+ expect(existsSync(join(dir, "docker"))).toBe(false);
23
+ const env = readFileSync(join(dir, ".env.example"), "utf-8");
24
+ expect(env).toContain("FRAPPE_ENABLED=1");
25
+ expect(env).toContain("FRAPPE_URL=http://localhost:8000");
26
+ const appTs = readFileSync(join(dir, "apps/shop/src/app.ts"), "utf-8");
27
+ expect(appTs).toContain('frappe: { baseURL: config.get("FRAPPE_URL") }');
28
+ const readme = readFileSync(join(dir, "README.md"), "utf-8");
29
+ expect(readme).toContain("no server");
30
+ for (const text of [env, appTs, readme]) {
31
+ expect(text).not.toMatch(/compose|bench (init|start)|a dev bench/u);
32
+ }
33
+ });
34
+
35
+ it("frappe-bench adds the compose bench, its README section and the app.ts note", () => {
36
+ const dir = join(sandbox, "bench");
37
+ scaffoldPieces(dir, { name: "shop", mpoVersion: "^7.0.0", pieces: ["frappe", "frappe-bench"] });
38
+
39
+ expect(existsSync(join(dir, "docker/compose.frappe.yaml"))).toBe(true);
40
+ expect(readFileSync(join(dir, "README.md"), "utf-8")).toContain(
41
+ "docker compose -f docker/compose.frappe.yaml up -d",
42
+ );
43
+ expect(readFileSync(join(dir, "apps/shop/src/app.ts"), "utf-8")).toContain(
44
+ "docker/compose.frappe.yaml runs a dev bench",
45
+ );
46
+ });
47
+
48
+ it("an adoption recorded when frappe carried the bench keeps it, as frappe-bench", () => {
49
+ const recorded = join(sandbox, "recorded-pieces");
50
+ mkdirSync(join(recorded, "frappe/universal/docker"), { recursive: true });
51
+ writeFileSync(join(recorded, "frappe/universal/docker/compose.frappe.yaml"), "services: {}\n");
52
+
53
+ expect(withSplitPieces(["frappe", "webext"], recorded)).toEqual([
54
+ "frappe",
55
+ "frappe-bench",
56
+ "webext",
57
+ ]);
58
+ expect(withSplitPieces(["frappe"], piecesRoot())).toEqual(["frappe"]);
59
+ expect(withSplitPieces(["frappe"], undefined)).toEqual(["frappe"]);
60
+ expect(withSplitPieces(["webext"], recorded)).toEqual(["webext"]);
61
+ });
62
+ });