@multiplatform.one/cli 6.3.0 → 6.4.1

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.
@@ -1,7 +1,8 @@
1
1
  import { generateVscodeConfig } from "../generateVscode.mjs";
2
2
  import { discoverE2EApp, runE2ESession } from "../commands/e2e.mjs";
3
3
  import { init, runModifyStep } from "../commands/init.mjs";
4
- import { initApp } from "../commands/initApp.mjs";
4
+ import { initApp, readProvenance } from "../commands/initApp.mjs";
5
+ import { updateApp } from "../commands/updateApp.mjs";
5
6
  import { createRequire } from "node:module";
6
7
  import fs from "node:fs/promises";
7
8
  import path from "node:path";
@@ -470,7 +471,7 @@ async function getFrappeApps(root) {
470
471
  const program = new Command();
471
472
  program.name("mpo");
472
473
  program.version(JSON.parse(fsSync.readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../package.json"), "utf8"))?.version);
473
- program.command("init").option("--universal", "scaffold the universal template (web + iOS + Android; default)").option("--web", "scaffold the web-only template (Vite + React)").option("--app", "alias of --web (back-compat)").option("--skip-install", "skip pnpm install after scaffolding").option("--mpo-version <range>", "semver range for @multiplatform.one/* (default: ^<cli version>)").option("-y, --yes", "non-interactive: accept defaults instead of prompting").option("--monorepo", "DEPRECATED: clone/modify the full multiplatform.one monorepo instead of scaffolding a consumer project").option("-c, --checkout <branch>", "branch, tag or commit to checkout (--monorepo only)", "main").option("-a, --apps <apps>", "comma-separated apps to include (--monorepo only)").option("-s, --services <services>", "comma-separated services to include (--monorepo only)").argument("[name]", "the name of the project").description("scaffold a new multiplatform project (universal web + native by default; --web for web-only; semver @multiplatform.one/* deps)").action(async (name, options) => {
474
+ program.command("init").option("--universal", "scaffold the universal template (web + iOS + Android; default)").option("--web", "scaffold the web-only template (Vite + React)").option("--app", "alias of --web (back-compat)").option("--skip-install", "skip pnpm install after scaffolding").option("--skip-git", "skip git init + scaffold commit").option("--mpo-version <range>", "semver range for @multiplatform.one/* (default: ^<cli version>)").option("-y, --yes", "non-interactive: accept defaults instead of prompting").option("--monorepo", "DEPRECATED: clone/modify the full multiplatform.one monorepo instead of scaffolding a consumer project").option("-c, --checkout <branch>", "branch, tag or commit to checkout (--monorepo only)", "main").option("-a, --apps <apps>", "comma-separated apps to include (--monorepo only)").option("-s, --services <services>", "comma-separated services to include (--monorepo only)").argument("[name]", "the name of the project").description("scaffold a new multiplatform project (universal web + native by default; --web for web-only; semver @multiplatform.one/* deps)").action(async (name, options) => {
474
475
  if (options.monorepo) {
475
476
  console.warn("⚠️ `mpo init --monorepo` clones the whole multiplatform.one monorepo (legacy). New projects should use the default consumer scaffold instead.");
476
477
  if (await spawn("git", ["rev-parse", "--is-inside-work-tree"]).then(() => true, () => false)) throw new Error("mpo cannot be initialized inside a git repository");
@@ -486,13 +487,22 @@ program.command("init").option("--universal", "scaffold the universal template (
486
487
  if (webOnly && options.universal) throw new Error("Pass either --web/--app or --universal, not both");
487
488
  await initApp(name, {
488
489
  skipInstall: Boolean(options.skipInstall),
490
+ skipGit: Boolean(options.skipGit),
489
491
  version: options.mpoVersion,
490
492
  template: webOnly ? "app" : options.universal ? "universal" : void 0,
491
493
  yes: Boolean(options.yes)
492
494
  });
493
495
  });
494
- program.command("update").option("-c, --checkout <branch>", "branch, tag or commit to merge from upstream", "main").option("-r, --remote <url>", "upstream remote URL", "https://gitlab.com/bitspur/multiplatform.one/multiplatform.one.git").description("merge upstream and re-apply workspace config").action(async (options) => {
496
+ program.command("update").option("-c, --checkout <branch>", "branch, tag or commit to merge from upstream (monorepo forks)", "main").option("-r, --remote <url>", "upstream remote URL (monorepo forks)", "https://gitlab.com/bitspur/multiplatform.one/multiplatform.one.git").option("--skip-install", "skip pnpm install after the update").option("--mpo-version <range>", "semver range for @multiplatform.one/* (default: ^<cli version>)").description("update a scaffolded project to the current template (three-way merge via .mpo.json provenance); monorepo forks fall back to the upstream merge flow").action(async (options) => {
495
497
  if (await spawn("git", ["rev-parse", "--is-inside-work-tree"]).then(() => false, () => true)) throw new Error("mpo cannot be updated outside of a git repository");
498
+ if (readProvenance(projectRoot)) {
499
+ await updateApp({
500
+ skipInstall: options.skipInstall,
501
+ version: options.mpoVersion
502
+ });
503
+ return;
504
+ }
505
+ if (!await fs.stat(path.resolve(projectRoot, "features/package.json")).then((stat) => stat.isFile(), () => false)) throw new Error(".mpo.json not found — this project predates scaffold provenance. Create it with { template, cliVersion, name } matching how the project was generated (cliVersion = the @multiplatform.one/cli that scaffolded it), then re-run `mpo update`.");
496
506
  if (await spawn("git", [
497
507
  "diff",
498
508
  "--cached",
@@ -812,7 +822,12 @@ program.command("generate").description("run generate command").action(async ()
812
822
  cwd: projectRoot
813
823
  });
814
824
  });
815
- program.parse(process.argv);
825
+ program.parseAsync(process.argv).catch((err) => {
826
+ const error = err;
827
+ if (process.env.MPO_DEBUG) console.error(error);
828
+ else console.error(`✖ ${error.message ?? String(err)}`);
829
+ process.exit(1);
830
+ });
816
831
  async function waitWithSpinner(services, options = {}) {
817
832
  const { interval = 1e3, timeout = 6e5 } = options;
818
833
  const waitFunctions = {
@@ -16,12 +16,36 @@ function validateName(name) {
16
16
  }
17
17
  function resolveMpoVersion(explicit) {
18
18
  if (explicit) return explicit.startsWith("^") || explicit.startsWith("~") || explicit === "*" ? explicit : `^${explicit}`;
19
+ const version = cliVersion();
20
+ return version ? `^${version}` : "^6.1.0";
21
+ }
22
+ function cliVersion() {
19
23
  try {
20
24
  const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../package.json");
21
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
22
- if (pkg.version) return `^${pkg.version}`;
23
- } catch {}
24
- return "^6.1.0";
25
+ return JSON.parse(readFileSync(pkgPath, "utf-8")).version;
26
+ } catch {
27
+ return;
28
+ }
29
+ }
30
+ const PROVENANCE_FILE = ".mpo.json";
31
+ function writeProvenance(targetDir, provenance) {
32
+ writeFileSync(join(targetDir, PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`);
33
+ }
34
+ function readProvenance(projectDir) {
35
+ const file = join(projectDir, PROVENANCE_FILE);
36
+ if (!existsSync(file)) return void 0;
37
+ try {
38
+ const raw = JSON.parse(readFileSync(file, "utf-8"));
39
+ if (!raw.template || !raw.cliVersion || !raw.name) return void 0;
40
+ return {
41
+ template: raw.template,
42
+ cliVersion: raw.cliVersion,
43
+ name: raw.name,
44
+ mpoVersion: raw.mpoVersion ?? `^${raw.cliVersion}`
45
+ };
46
+ } catch {
47
+ return;
48
+ }
25
49
  }
26
50
  function templatesRoot(template) {
27
51
  return resolve(dirname(fileURLToPath(import.meta.url)), "../../templates", template);
@@ -121,12 +145,45 @@ async function initApp(nameArg, options = {}) {
121
145
  }
122
146
  console.log(` @multiplatform.one/* → ${vars.MPO_VERSION}\n`);
123
147
  writeTree(templatesRoot(template), targetDir, vars);
148
+ writeProvenance(targetDir, {
149
+ template,
150
+ cliVersion: cliVersion() ?? "0.0.0",
151
+ name,
152
+ mpoVersion: vars.MPO_VERSION
153
+ });
154
+ let scaffoldCommitted = false;
155
+ if (!options.skipGit) {
156
+ if (!await spawn("git", ["rev-parse", "--is-inside-work-tree"], { cwd: targetDir }).then(() => true, () => false)) {
157
+ const label = template === "universal" ? "universal" : "web-only";
158
+ await spawn("git", ["init", "--quiet"], { cwd: targetDir });
159
+ await spawn("git", ["add", "-A"], { cwd: targetDir });
160
+ scaffoldCommitted = await spawn("git", [
161
+ "commit",
162
+ "--quiet",
163
+ "-m",
164
+ `feat: scaffold ${label} multiplatform.one app (mpo init)`
165
+ ], { cwd: targetDir }).then(() => true, () => {
166
+ console.warn("⚠️ git commit failed (missing git identity?) — repo left staged.");
167
+ return false;
168
+ });
169
+ }
170
+ }
124
171
  if (!options.skipInstall) {
125
172
  console.log("Installing dependencies...");
126
173
  await spawn("pnpm", ["install"], {
127
174
  cwd: targetDir,
128
175
  stdio: "inherit"
129
176
  });
177
+ if (scaffoldCommitted) await spawn("git", [
178
+ "add",
179
+ "--",
180
+ "pnpm-lock.yaml"
181
+ ], { cwd: targetDir }).then(() => spawn("git", [
182
+ "commit",
183
+ "--amend",
184
+ "--no-edit",
185
+ "--quiet"
186
+ ], { cwd: targetDir })).catch(() => {});
130
187
  } else console.log("Skipped pnpm install (--skip-install).");
131
188
  console.log(`\n✅ Project created at ${targetDir}`);
132
189
  console.log("\nNext steps:");
@@ -142,4 +199,4 @@ async function initApp(nameArg, options = {}) {
142
199
  }
143
200
 
144
201
  //#endregion
145
- export { initApp };
202
+ export { PROVENANCE_FILE, cliVersion, initApp, readProvenance, writeProvenance };
@@ -0,0 +1,350 @@
1
+ import { generateVscodeConfig } from "../generateVscode.mjs";
2
+ import { cliVersion, initApp, readProvenance, writeProvenance } from "./initApp.mjs";
3
+ import { join } from "node:path";
4
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
5
+ import spawn from "nano-spawn";
6
+ import { tmpdir } from "node:os";
7
+
8
+ //#region src/commands/updateApp.ts
9
+ /**
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:
13
+ *
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
19
+ *
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 pathspec per line)
23
+ * pins matching paths to your HEAD version, exactly like the old script.
24
+ */
25
+ async function git(projectDir, args, env) {
26
+ return spawn("git", args, {
27
+ cwd: projectDir,
28
+ env: env ? {
29
+ ...process.env,
30
+ ...env
31
+ } : void 0
32
+ });
33
+ }
34
+ async function gitOut(projectDir, args, env) {
35
+ return (await git(projectDir, args, env)).stdout.trim();
36
+ }
37
+ /** Import a directory tree into the project's object database and return a
38
+ * root-less commit for it (temp index; the worktree is never touched). */
39
+ async function commitTreeFromDir(projectDir, dir, message, parent) {
40
+ const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-index-")), "index");
41
+ const env = {
42
+ GIT_INDEX_FILE: indexFile,
43
+ GIT_WORK_TREE: dir
44
+ };
45
+ await git(projectDir, [
46
+ "add",
47
+ "-A",
48
+ "--force",
49
+ "--",
50
+ "."
51
+ ], env);
52
+ const tree = await gitOut(projectDir, ["write-tree"], env);
53
+ const commit = await gitOut(projectDir, parent ? [
54
+ "commit-tree",
55
+ tree,
56
+ "-p",
57
+ parent,
58
+ "-m",
59
+ message
60
+ ] : [
61
+ "commit-tree",
62
+ tree,
63
+ "-m",
64
+ message
65
+ ]);
66
+ rmSync(indexFile, { force: true });
67
+ return commit;
68
+ }
69
+ /** Scaffold a project with a specific published CLI version into tmp and
70
+ * return the generated project dir. Falls back to the local generator when
71
+ * the requested version matches the running CLI (or dlx fails). */
72
+ async function scaffoldBaseline(version, name, template, mpoVersion) {
73
+ const parent = mkdtempSync(join(tmpdir(), "mpo-update-"));
74
+ const cleanup = () => rmSync(parent, {
75
+ recursive: true,
76
+ force: true
77
+ });
78
+ const current = cliVersion();
79
+ const templateFlag = template === "app" ? ["--web"] : ["--universal"];
80
+ const [major = 0, minor = 0] = version.split(".").map(Number);
81
+ if (!(major > 6 || major === 6 && minor >= 3)) console.warn(`⚠️ @multiplatform.one/cli@${version} predates the consumer scaffolder (6.3.0); using the current CLI's template as the merge base.`);
82
+ else if (version !== current) {
83
+ for (let attempt = 1; attempt <= 2; attempt++) try {
84
+ await spawn("pnpm", [
85
+ "--package",
86
+ `@multiplatform.one/cli@${version}`,
87
+ "dlx",
88
+ "mpo",
89
+ "init",
90
+ name,
91
+ "--yes",
92
+ "--skip-install",
93
+ "--mpo-version",
94
+ mpoVersion,
95
+ ...templateFlag
96
+ ], {
97
+ cwd: parent,
98
+ stdio: "inherit"
99
+ });
100
+ const dir = join(parent, name);
101
+ if (existsSync(join(dir, "package.json"))) return {
102
+ dir,
103
+ cleanup
104
+ };
105
+ console.warn(`⚠️ dlx scaffold for @multiplatform.one/cli@${version} produced no project`);
106
+ break;
107
+ } catch {
108
+ rmSync(join(parent, name), {
109
+ recursive: true,
110
+ force: true
111
+ });
112
+ if (attempt === 2) console.warn(`⚠️ could not scaffold baseline with @multiplatform.one/cli@${version} (network?);`);
113
+ }
114
+ console.warn(" falling back to the current CLI's template as the merge base.");
115
+ }
116
+ const previousCwd = process.cwd();
117
+ process.chdir(parent);
118
+ try {
119
+ await initApp(name, {
120
+ skipInstall: true,
121
+ skipGit: true,
122
+ yes: true,
123
+ template,
124
+ version: mpoVersion
125
+ });
126
+ } finally {
127
+ process.chdir(previousCwd);
128
+ }
129
+ return {
130
+ dir: join(parent, name),
131
+ cleanup
132
+ };
133
+ }
134
+ /** Return a tree derived from `tree` where every path matching `pins` is
135
+ * replaced by its content in `headCommit` (removed when absent there).
136
+ * Pure index plumbing — the worktree is untouched. */
137
+ async function pinPathsToHead(projectDir, tree, headCommit, pins) {
138
+ const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-pin-")), "index");
139
+ const env = { GIT_INDEX_FILE: indexFile };
140
+ await git(projectDir, ["read-tree", tree], env);
141
+ for (const pin of pins) {
142
+ const inTree = await gitOut(projectDir, [
143
+ "ls-tree",
144
+ "-r",
145
+ "--name-only",
146
+ tree,
147
+ "--",
148
+ pin
149
+ ], env).then((out) => out.split("\n").filter(Boolean)).catch(() => []);
150
+ for (const path of inTree) await git(projectDir, [
151
+ "update-index",
152
+ "--force-remove",
153
+ "--",
154
+ path
155
+ ], env).catch(() => {});
156
+ const headEntries = await gitOut(projectDir, [
157
+ "ls-tree",
158
+ "-r",
159
+ headCommit,
160
+ "--",
161
+ pin
162
+ ], env).catch(() => "");
163
+ for (const line of headEntries.split("\n").filter(Boolean)) {
164
+ const match = line.match(/^(\d+) \S+ ([0-9a-f]+)\t(.+)$/);
165
+ if (!match) continue;
166
+ await git(projectDir, [
167
+ "update-index",
168
+ "--add",
169
+ "--cacheinfo",
170
+ `${match[1]},${match[2]},${match[3]}`
171
+ ], env).catch(() => {});
172
+ }
173
+ }
174
+ const pinned = await gitOut(projectDir, ["write-tree"], env);
175
+ rmSync(indexFile, { force: true });
176
+ return pinned;
177
+ }
178
+ async function updateApp(options = {}) {
179
+ const projectDir = await gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
180
+ throw new Error("mpo update must run inside a git repository");
181
+ });
182
+ await git(projectDir, ["diff", "--quiet"]).catch(() => {
183
+ throw new Error("mpo update requires a clean working tree (unstaged changes present)");
184
+ });
185
+ await git(projectDir, [
186
+ "diff",
187
+ "--cached",
188
+ "--quiet"
189
+ ]).catch(() => {
190
+ throw new Error("mpo update requires a clean working tree (staged changes present)");
191
+ });
192
+ const provenance = readProvenance(projectDir);
193
+ if (!provenance) throw new Error(".mpo.json not found — this project predates scaffold provenance. Create it with { template, cliVersion, name } matching how the project was generated (cliVersion = the @multiplatform.one/cli that scaffolded it), then re-run `mpo update`.");
194
+ const currentVersion = cliVersion() ?? "0.0.0";
195
+ const targetMpoRange = options.version ? options.version.startsWith("^") || options.version.startsWith("~") ? options.version : `^${options.version}` : `^${currentVersion}`;
196
+ console.log(`\nmpo update: ${provenance.cliVersion} → ${currentVersion}`);
197
+ console.log(` template: ${provenance.template}`);
198
+ console.log(` project: ${provenance.name}\n`);
199
+ console.log(`Scaffolding merge base (cli@${provenance.cliVersion})...`);
200
+ const base = await scaffoldBaseline(provenance.cliVersion, provenance.name, provenance.template, provenance.mpoVersion);
201
+ console.log(`Scaffolding update target (cli@${currentVersion})...`);
202
+ const next = await scaffoldBaseline(currentVersion, provenance.name, provenance.template, targetMpoRange);
203
+ rmSync(join(base.dir, ".mpo.json"), { force: true });
204
+ rmSync(join(next.dir, ".mpo.json"), { force: true });
205
+ try {
206
+ const head = await gitOut(projectDir, ["rev-parse", "HEAD"]);
207
+ const baseCommit = await commitTreeFromDir(projectDir, base.dir, `mpo scaffold ${provenance.template}@${provenance.cliVersion}`);
208
+ const nextCommit = await commitTreeFromDir(projectDir, next.dir, `mpo scaffold ${provenance.template}@${currentVersion}`, baseCommit);
209
+ let mergedTree;
210
+ let conflicts = [];
211
+ try {
212
+ mergedTree = (await gitOut(projectDir, [
213
+ "merge-tree",
214
+ "--write-tree",
215
+ "--name-only",
216
+ `--merge-base=${baseCommit}`,
217
+ head,
218
+ nextCommit
219
+ ])).split("\n")[0].trim();
220
+ } catch (err) {
221
+ const lines = (err.stdout ?? "").split("\n");
222
+ if (!lines[0]?.trim()) throw err;
223
+ mergedTree = lines[0].trim();
224
+ const blank = lines.indexOf("", 1);
225
+ conflicts = lines.slice(1, blank === -1 ? void 0 : blank).map((line) => line.trim()).filter(Boolean);
226
+ }
227
+ const finalProvenance = {
228
+ template: provenance.template,
229
+ cliVersion: currentVersion,
230
+ name: provenance.name,
231
+ mpoVersion: targetMpoRange
232
+ };
233
+ const pins = ["pnpm-lock.yaml"];
234
+ const updateignore = join(projectDir, ".updateignore");
235
+ if (existsSync(updateignore)) for (const line of readFileSync(updateignore, "utf-8").split("\n")) {
236
+ const pattern = line.trim();
237
+ if (pattern && !pattern.startsWith("#")) pins.push(pattern);
238
+ }
239
+ mergedTree = await pinPathsToHead(projectDir, mergedTree, head, pins);
240
+ conflicts = conflicts.filter((file) => !pins.some((pin) => file === pin || file.startsWith(`${pin}/`)));
241
+ const headTree = await gitOut(projectDir, ["rev-parse", `${head}^{tree}`]);
242
+ if (!conflicts.length && mergedTree === headTree) {
243
+ if (provenance.cliVersion !== finalProvenance.cliVersion || provenance.mpoVersion !== finalProvenance.mpoVersion) {
244
+ writeProvenance(projectDir, finalProvenance);
245
+ await git(projectDir, [
246
+ "add",
247
+ "--",
248
+ ".mpo.json"
249
+ ]);
250
+ await git(projectDir, [
251
+ "commit",
252
+ "--quiet",
253
+ "-m",
254
+ `chore: record mpo template provenance ${currentVersion}`
255
+ ]);
256
+ console.log("\n✅ already up to date (provenance refreshed)");
257
+ } else console.log("\n✅ already up to date");
258
+ return;
259
+ }
260
+ if (!conflicts.length) {
261
+ const message = `chore: mpo update ${provenance.cliVersion} → ${currentVersion}`;
262
+ await git(projectDir, [
263
+ "update-ref",
264
+ "HEAD",
265
+ await gitOut(projectDir, [
266
+ "commit-tree",
267
+ mergedTree,
268
+ "-p",
269
+ head,
270
+ "-p",
271
+ nextCommit,
272
+ "-m",
273
+ message
274
+ ])
275
+ ]);
276
+ await git(projectDir, [
277
+ "reset",
278
+ "--hard",
279
+ "HEAD"
280
+ ]);
281
+ writeProvenance(projectDir, finalProvenance);
282
+ try {
283
+ await generateVscodeConfig(projectDir);
284
+ } catch {}
285
+ await git(projectDir, [
286
+ "add",
287
+ "--",
288
+ ".mpo.json",
289
+ ".vscode"
290
+ ]);
291
+ await git(projectDir, [
292
+ "commit",
293
+ "--amend",
294
+ "--no-edit",
295
+ "--quiet"
296
+ ]);
297
+ const amended = await gitOut(projectDir, [
298
+ "rev-parse",
299
+ "--short",
300
+ "HEAD"
301
+ ]);
302
+ console.log(`\n✅ merged cleanly → ${amended} ("${message}")`);
303
+ } else {
304
+ await git(projectDir, [
305
+ "read-tree",
306
+ "-u",
307
+ "--reset",
308
+ mergedTree
309
+ ]);
310
+ writeProvenance(projectDir, finalProvenance);
311
+ console.log("\n⚠️ merge conflicts — resolve the markers, then commit:");
312
+ for (const file of conflicts) console.log(` ${file}`);
313
+ console.log(`\n git add -A && git commit -m "chore: mpo update ${provenance.cliVersion} → ${currentVersion}"`);
314
+ }
315
+ if (conflicts.length && !options.skipInstall) console.log("\nSkipping pnpm install until conflicts are resolved (then run: pnpm install).");
316
+ else if (!options.skipInstall) {
317
+ console.log("\nInstalling dependencies...");
318
+ await spawn("pnpm", ["install"], {
319
+ cwd: projectDir,
320
+ stdio: "inherit"
321
+ });
322
+ if (!conflicts.length) {
323
+ if (await git(projectDir, [
324
+ "diff",
325
+ "--quiet",
326
+ "--",
327
+ "pnpm-lock.yaml"
328
+ ]).then(() => false, () => true)) {
329
+ await git(projectDir, [
330
+ "add",
331
+ "--",
332
+ "pnpm-lock.yaml"
333
+ ]);
334
+ await git(projectDir, [
335
+ "commit",
336
+ "--amend",
337
+ "--no-edit",
338
+ "--quiet"
339
+ ]);
340
+ }
341
+ }
342
+ }
343
+ } finally {
344
+ base.cleanup();
345
+ next.cleanup();
346
+ }
347
+ }
348
+
349
+ //#endregion
350
+ export { updateApp };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/cli",
3
- "version": "6.3.0",
3
+ "version": "6.4.1",
4
4
  "description": "multiplatform.one cli — mpo init / create-multiplatform-app",
5
5
  "keywords": [
6
6
  "create-multiplatform-app",
@@ -61,7 +61,7 @@
61
61
  "nano-spawn": "^2.1.0",
62
62
  "yaml": "^2.8.3",
63
63
  "yocto-spinner": "^1.1.0",
64
- "@multiplatform.one/utils": "6.3.0"
64
+ "@multiplatform.one/utils": "6.4.1"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@types/inquirer": "^9.0.9",
@@ -19,7 +19,8 @@ import YAML from "yaml";
19
19
  import yoctoSpinner from "yocto-spinner";
20
20
  import { discoverE2EApp, runE2ESession } from "../commands/e2e";
21
21
  import { init, runModifyStep } from "../commands/init";
22
- import { initApp } from "../commands/initApp";
22
+ import { initApp, readProvenance } from "../commands/initApp";
23
+ import { updateApp } from "../commands/updateApp";
23
24
 
24
25
  const projectRoot = lookupProjectRoot();
25
26
  const availableServices = ["api", "frappe", "solana", "ethereum", "sui"];
@@ -221,6 +222,7 @@ program
221
222
  .option("--web", "scaffold the web-only template (Vite + React)")
222
223
  .option("--app", "alias of --web (back-compat)")
223
224
  .option("--skip-install", "skip pnpm install after scaffolding")
225
+ .option("--skip-git", "skip git init + scaffold commit")
224
226
  .option(
225
227
  "--mpo-version <range>",
226
228
  "semver range for @multiplatform.one/* (default: ^<cli version>)",
@@ -269,6 +271,7 @@ program
269
271
  }
270
272
  await initApp(name, {
271
273
  skipInstall: Boolean(options.skipInstall),
274
+ skipGit: Boolean(options.skipGit),
272
275
  version: options.mpoVersion,
273
276
  template: webOnly ? "app" : options.universal ? "universal" : undefined,
274
277
  yes: Boolean(options.yes),
@@ -279,10 +282,22 @@ const defaultUpdateRemote = "https://gitlab.com/bitspur/multiplatform.one/multip
279
282
 
280
283
  program
281
284
  .command("update")
282
- .option("-c, --checkout <branch>", "branch, tag or commit to merge from upstream", "main")
283
- .option("-r, --remote <url>", "upstream remote URL", defaultUpdateRemote)
284
- .description("merge upstream and re-apply workspace config")
285
- .action(async (options: { checkout: string; remote: string }) => {
285
+ .option("-c, --checkout <branch>", "branch, tag or commit to merge from upstream (monorepo forks)", "main")
286
+ .option("-r, --remote <url>", "upstream remote URL (monorepo forks)", defaultUpdateRemote)
287
+ .option("--skip-install", "skip pnpm install after the update")
288
+ .option(
289
+ "--mpo-version <range>",
290
+ "semver range for @multiplatform.one/* (default: ^<cli version>)",
291
+ )
292
+ .description(
293
+ "update a scaffolded project to the current template (three-way merge via .mpo.json provenance); monorepo forks fall back to the upstream merge flow",
294
+ )
295
+ .action(async (options: {
296
+ checkout: string;
297
+ remote: string;
298
+ skipInstall?: boolean;
299
+ mpoVersion?: string;
300
+ }) => {
286
301
  // Update prechecks: must be in a git repo
287
302
  if (
288
303
  await spawn("git", ["rev-parse", "--is-inside-work-tree"]).then(
@@ -292,6 +307,27 @@ program
292
307
  ) {
293
308
  throw new Error("mpo cannot be updated outside of a git repository");
294
309
  }
310
+ // Scaffolded consumer projects carry .mpo.json provenance — use the
311
+ // copier-style three-way template update. Monorepo forks (identified by
312
+ // the legacy features/package.json marker) fall through to the upstream-
313
+ // merge flow below; anything else gets the provenance guidance instead
314
+ // of the fork flow's unrelated errors.
315
+ if (readProvenance(projectRoot)) {
316
+ await updateApp({ skipInstall: options.skipInstall, version: options.mpoVersion });
317
+ return;
318
+ }
319
+ const legacyFork = await fs.stat(path.resolve(projectRoot, "features/package.json")).then(
320
+ (stat) => stat.isFile(),
321
+ () => false,
322
+ );
323
+ if (!legacyFork) {
324
+ throw new Error(
325
+ ".mpo.json not found — this project predates scaffold provenance. " +
326
+ "Create it with { template, cliVersion, name } matching how the project " +
327
+ "was generated (cliVersion = the @multiplatform.one/cli that scaffolded it), " +
328
+ "then re-run `mpo update`.",
329
+ );
330
+ }
295
331
  // Require clean working tree (no staged or unstaged changes)
296
332
  if (
297
333
  await spawn("git", ["diff", "--cached", "--quiet"]).then(
@@ -781,7 +817,14 @@ program
781
817
  });
782
818
  });
783
819
 
784
- program.parse(process.argv);
820
+ // One-line errors instead of raw stack traces (matches the
821
+ // create-multiplatform-app bin). Stacks stay available via MPO_DEBUG=1.
822
+ program.parseAsync(process.argv).catch((err: unknown) => {
823
+ const error = err as Error;
824
+ if (process.env.MPO_DEBUG) console.error(error);
825
+ else console.error(`✖ ${error.message ?? String(err)}`);
826
+ process.exit(1);
827
+ });
785
828
 
786
829
  async function waitWithSpinner(
787
830
  services: string[],
@@ -23,6 +23,8 @@ export type InitAppTemplate = "universal" | "app";
23
23
  export interface InitAppOptions {
24
24
  /** Skip pnpm install (useful for CI / dry scaffolding). */
25
25
  skipInstall?: boolean;
26
+ /** Skip git init + scaffold commit (mpo update baselines, nested dirs). */
27
+ skipGit?: boolean;
26
28
  /** Pin @multiplatform.one/* to this semver range. Default: ^<cli version>. */
27
29
  version?: string;
28
30
  /** Project template. When omitted: prompt (interactive) or "universal". */
@@ -67,14 +69,54 @@ function resolveMpoVersion(explicit?: string): string {
67
69
  ? explicit
68
70
  : `^${explicit}`;
69
71
  }
72
+ const version = cliVersion();
73
+ return version ? `^${version}` : "^6.1.0";
74
+ }
75
+
76
+ export function cliVersion(): string | undefined {
70
77
  try {
71
78
  const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../package.json");
72
79
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string };
73
- if (pkg.version) return `^${pkg.version}`;
80
+ return pkg.version;
74
81
  } catch {
75
- // fall through
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Scaffold provenance (the copier-answers equivalent): which template, which
88
+ * CLI version and which vars produced this project. `mpo update` regenerates
89
+ * the ORIGINAL scaffold from this record to use as the merge base for a
90
+ * three-way template update.
91
+ */
92
+ export interface MpoProvenance {
93
+ template: InitAppTemplate;
94
+ cliVersion: string;
95
+ name: string;
96
+ mpoVersion: string;
97
+ }
98
+
99
+ export const PROVENANCE_FILE = ".mpo.json";
100
+
101
+ export function writeProvenance(targetDir: string, provenance: MpoProvenance): void {
102
+ writeFileSync(join(targetDir, PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`);
103
+ }
104
+
105
+ export function readProvenance(projectDir: string): MpoProvenance | undefined {
106
+ const file = join(projectDir, PROVENANCE_FILE);
107
+ if (!existsSync(file)) return undefined;
108
+ try {
109
+ const raw = JSON.parse(readFileSync(file, "utf-8")) as Partial<MpoProvenance>;
110
+ if (!raw.template || !raw.cliVersion || !raw.name) return undefined;
111
+ return {
112
+ template: raw.template,
113
+ cliVersion: raw.cliVersion,
114
+ name: raw.name,
115
+ mpoVersion: raw.mpoVersion ?? `^${raw.cliVersion}`,
116
+ };
117
+ } catch {
118
+ return undefined;
76
119
  }
77
- return "^6.1.0";
78
120
  }
79
121
 
80
122
  function templatesRoot(template: InitAppTemplate): string {
@@ -203,6 +245,41 @@ export async function initApp(
203
245
  console.log(` @multiplatform.one/* → ${vars.MPO_VERSION}\n`);
204
246
 
205
247
  writeTree(templatesRoot(template), targetDir, vars);
248
+ writeProvenance(targetDir, {
249
+ template,
250
+ cliVersion: cliVersion() ?? "0.0.0",
251
+ name,
252
+ mpoVersion: vars.MPO_VERSION,
253
+ });
254
+
255
+ // Initialize git with a scaffold commit (unless already inside a repo —
256
+ // e.g. scaffolding into an existing project dir). `mpo update` needs the
257
+ // scaffold as a committed baseline to merge on top of.
258
+ let scaffoldCommitted = false;
259
+ if (!options.skipGit) {
260
+ const insideRepo = await spawn("git", ["rev-parse", "--is-inside-work-tree"], {
261
+ cwd: targetDir,
262
+ }).then(
263
+ () => true,
264
+ () => false,
265
+ );
266
+ if (!insideRepo) {
267
+ const label = template === "universal" ? "universal" : "web-only";
268
+ await spawn("git", ["init", "--quiet"], { cwd: targetDir });
269
+ await spawn("git", ["add", "-A"], { cwd: targetDir });
270
+ scaffoldCommitted = await spawn(
271
+ "git",
272
+ ["commit", "--quiet", "-m", `feat: scaffold ${label} multiplatform.one app (mpo init)`],
273
+ { cwd: targetDir },
274
+ ).then(
275
+ () => true,
276
+ () => {
277
+ console.warn("⚠️ git commit failed (missing git identity?) — repo left staged.");
278
+ return false;
279
+ },
280
+ );
281
+ }
282
+ }
206
283
 
207
284
  if (!options.skipInstall) {
208
285
  console.log("Installing dependencies...");
@@ -210,6 +287,15 @@ export async function initApp(
210
287
  cwd: targetDir,
211
288
  stdio: "inherit",
212
289
  });
290
+ // The lockfile belongs to the scaffold — fold it into the scaffold
291
+ // commit so a fresh init is immediately clean.
292
+ if (scaffoldCommitted) {
293
+ await spawn("git", ["add", "--", "pnpm-lock.yaml"], { cwd: targetDir })
294
+ .then(() =>
295
+ spawn("git", ["commit", "--amend", "--no-edit", "--quiet"], { cwd: targetDir }),
296
+ )
297
+ .catch(() => {});
298
+ }
213
299
  } else {
214
300
  console.log("Skipped pnpm install (--skip-install).");
215
301
  }
@@ -0,0 +1,401 @@
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:
5
+ *
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
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 pathspec per line)
15
+ * pins matching paths to your HEAD version, exactly like the old script.
16
+ */
17
+
18
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import spawn from "nano-spawn";
22
+ import { generateVscodeConfig } from "../generateVscode";
23
+ import { cliVersion, initApp, readProvenance, writeProvenance } from "./initApp";
24
+
25
+ export interface UpdateAppOptions {
26
+ /** Skip pnpm install after the merge. */
27
+ skipInstall?: boolean;
28
+ /** Override the semver range written for @multiplatform.one/* deps. */
29
+ version?: string;
30
+ }
31
+
32
+ async function git(projectDir: string, args: string[], env?: Record<string, string>) {
33
+ return spawn("git", args, {
34
+ cwd: projectDir,
35
+ env: env ? { ...process.env, ...env } : undefined,
36
+ });
37
+ }
38
+
39
+ async function gitOut(projectDir: string, args: string[], env?: Record<string, string>) {
40
+ const result = await git(projectDir, args, env);
41
+ return result.stdout.trim();
42
+ }
43
+
44
+ /** Import a directory tree into the project's object database and return a
45
+ * root-less commit for it (temp index; the worktree is never touched). */
46
+ async function commitTreeFromDir(
47
+ projectDir: string,
48
+ dir: string,
49
+ message: string,
50
+ parent?: string,
51
+ ): Promise<string> {
52
+ const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-index-")), "index");
53
+ const env = { GIT_INDEX_FILE: indexFile, GIT_WORK_TREE: dir };
54
+ await git(projectDir, ["add", "-A", "--force", "--", "."], env);
55
+ const tree = await gitOut(projectDir, ["write-tree"], env);
56
+ const commitArgs = parent
57
+ ? ["commit-tree", tree, "-p", parent, "-m", message]
58
+ : ["commit-tree", tree, "-m", message];
59
+ const commit = await gitOut(projectDir, commitArgs);
60
+ rmSync(indexFile, { force: true });
61
+ return commit;
62
+ }
63
+
64
+ /** Scaffold a project with a specific published CLI version into tmp and
65
+ * return the generated project dir. Falls back to the local generator when
66
+ * the requested version matches the running CLI (or dlx fails). */
67
+ async function scaffoldBaseline(
68
+ version: string,
69
+ name: string,
70
+ template: "universal" | "app",
71
+ mpoVersion: string,
72
+ ): Promise<{ dir: string; cleanup: () => void }> {
73
+ const parent = mkdtempSync(join(tmpdir(), "mpo-update-"));
74
+ const cleanup = () => rmSync(parent, { recursive: true, force: true });
75
+ const current = cliVersion();
76
+ const templateFlag = template === "app" ? ["--web"] : ["--universal"];
77
+ // The consumer scaffolder (initApp + templates) shipped in 6.3.0 —
78
+ // earlier CLIs only had the monorepo-clone init and cannot regenerate a
79
+ // scaffold baseline.
80
+ const [major = 0, minor = 0] = version.split(".").map(Number);
81
+ const scaffoldCapable = major > 6 || (major === 6 && minor >= 3);
82
+ if (!scaffoldCapable) {
83
+ console.warn(
84
+ `⚠️ @multiplatform.one/cli@${version} predates the consumer scaffolder (6.3.0); ` +
85
+ "using the current CLI's template as the merge base.",
86
+ );
87
+ } else if (version !== current) {
88
+ // Older published CLIs reject flags they don't know (--skip-git), so
89
+ // only the stable flag set is passed; a scaffold-side git repo is
90
+ // harmless (index operations never descend into nested .git dirs).
91
+ for (let attempt = 1; attempt <= 2; attempt++) {
92
+ try {
93
+ await spawn(
94
+ "pnpm",
95
+ [
96
+ "--package",
97
+ `@multiplatform.one/cli@${version}`,
98
+ "dlx",
99
+ "mpo",
100
+ "init",
101
+ name,
102
+ "--yes",
103
+ "--skip-install",
104
+ "--mpo-version",
105
+ mpoVersion,
106
+ ...templateFlag,
107
+ ],
108
+ { cwd: parent, stdio: "inherit" },
109
+ );
110
+ const dir = join(parent, name);
111
+ if (existsSync(join(dir, "package.json"))) return { dir, cleanup };
112
+ console.warn(`⚠️ dlx scaffold for @multiplatform.one/cli@${version} produced no project`);
113
+ break;
114
+ } catch {
115
+ rmSync(join(parent, name), { recursive: true, force: true });
116
+ if (attempt === 2) {
117
+ console.warn(
118
+ `⚠️ could not scaffold baseline with @multiplatform.one/cli@${version} (network?);`,
119
+ );
120
+ }
121
+ }
122
+ }
123
+ console.warn(" falling back to the current CLI's template as the merge base.");
124
+ }
125
+ const previousCwd = process.cwd();
126
+ process.chdir(parent);
127
+ try {
128
+ await initApp(name, {
129
+ skipInstall: true,
130
+ skipGit: true,
131
+ yes: true,
132
+ template,
133
+ version: mpoVersion,
134
+ });
135
+ } finally {
136
+ process.chdir(previousCwd);
137
+ }
138
+ return { dir: join(parent, name), cleanup };
139
+ }
140
+
141
+ /** Return a tree derived from `tree` where every path matching `pins` is
142
+ * replaced by its content in `headCommit` (removed when absent there).
143
+ * Pure index plumbing — the worktree is untouched. */
144
+ async function pinPathsToHead(
145
+ projectDir: string,
146
+ tree: string,
147
+ headCommit: string,
148
+ pins: string[],
149
+ ): Promise<string> {
150
+ const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-pin-")), "index");
151
+ const env = { GIT_INDEX_FILE: indexFile };
152
+ await git(projectDir, ["read-tree", tree], env);
153
+ for (const pin of pins) {
154
+ // Paths under the pin in the merged tree (to drop when HEAD lacks them).
155
+ const inTree = await gitOut(projectDir, ["ls-tree", "-r", "--name-only", tree, "--", pin], env)
156
+ .then((out) => out.split("\n").filter(Boolean))
157
+ .catch(() => [] as string[]);
158
+ for (const path of inTree) {
159
+ await git(projectDir, ["update-index", "--force-remove", "--", path], env).catch(() => {});
160
+ }
161
+ // HEAD's entries for the pin (restored verbatim).
162
+ const headEntries = await gitOut(
163
+ projectDir,
164
+ ["ls-tree", "-r", headCommit, "--", pin],
165
+ env,
166
+ ).catch(() => "");
167
+ for (const line of headEntries.split("\n").filter(Boolean)) {
168
+ // "<mode> blob <oid>\t<path>"
169
+ const match = line.match(/^(\d+) \S+ ([0-9a-f]+)\t(.+)$/);
170
+ if (!match) continue;
171
+ await git(
172
+ projectDir,
173
+ ["update-index", "--add", "--cacheinfo", `${match[1]},${match[2]},${match[3]}`],
174
+ env,
175
+ ).catch(() => {});
176
+ }
177
+ }
178
+ const pinned = await gitOut(projectDir, ["write-tree"], env);
179
+ rmSync(indexFile, { force: true });
180
+ return pinned;
181
+ }
182
+
183
+ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
184
+ const projectDir = await gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
185
+ throw new Error("mpo update must run inside a git repository");
186
+ });
187
+
188
+ // Clean tree required — the merge lands in the worktree.
189
+ await git(projectDir, ["diff", "--quiet"]).catch(() => {
190
+ throw new Error("mpo update requires a clean working tree (unstaged changes present)");
191
+ });
192
+ await git(projectDir, ["diff", "--cached", "--quiet"]).catch(() => {
193
+ throw new Error("mpo update requires a clean working tree (staged changes present)");
194
+ });
195
+
196
+ const provenance = readProvenance(projectDir);
197
+ if (!provenance) {
198
+ throw new Error(
199
+ ".mpo.json not found — this project predates scaffold provenance. " +
200
+ "Create it with { template, cliVersion, name } matching how the project " +
201
+ "was generated (cliVersion = the @multiplatform.one/cli that scaffolded it), " +
202
+ "then re-run `mpo update`.",
203
+ );
204
+ }
205
+
206
+ const currentVersion = cliVersion() ?? "0.0.0";
207
+ const targetMpoRange = options.version
208
+ ? options.version.startsWith("^") || options.version.startsWith("~")
209
+ ? options.version
210
+ : `^${options.version}`
211
+ : `^${currentVersion}`;
212
+
213
+ console.log(`\nmpo update: ${provenance.cliVersion} → ${currentVersion}`);
214
+ console.log(` template: ${provenance.template}`);
215
+ console.log(` project: ${provenance.name}\n`);
216
+
217
+ // 1. Regenerate the ORIGINAL scaffold (merge base).
218
+ console.log(`Scaffolding merge base (cli@${provenance.cliVersion})...`);
219
+ const base = await scaffoldBaseline(
220
+ provenance.cliVersion,
221
+ provenance.name,
222
+ provenance.template,
223
+ provenance.mpoVersion,
224
+ );
225
+
226
+ // 2. Generate the CURRENT scaffold (theirs).
227
+ console.log(`Scaffolding update target (cli@${currentVersion})...`);
228
+ const next = await scaffoldBaseline(
229
+ currentVersion,
230
+ provenance.name,
231
+ provenance.template,
232
+ targetMpoRange,
233
+ );
234
+
235
+ // .mpo.json is TOOL-owned, never merged: pre-provenance CLIs (≤6.3.0)
236
+ // didn't write it, so leaving it in the scaffolds guarantees an add/add
237
+ // phantom conflict on every honest old-baseline update. It is stripped
238
+ // from both synthetic trees and written authoritatively after the merge.
239
+ rmSync(join(base.dir, ".mpo.json"), { force: true });
240
+ rmSync(join(next.dir, ".mpo.json"), { force: true });
241
+
242
+ try {
243
+ // 3. Three-way merge: base → HEAD (ours) vs base → next (theirs).
244
+ const head = await gitOut(projectDir, ["rev-parse", "HEAD"]);
245
+ const baseCommit = await commitTreeFromDir(
246
+ projectDir,
247
+ base.dir,
248
+ `mpo scaffold ${provenance.template}@${provenance.cliVersion}`,
249
+ );
250
+ const nextCommit = await commitTreeFromDir(
251
+ projectDir,
252
+ next.dir,
253
+ `mpo scaffold ${provenance.template}@${currentVersion}`,
254
+ baseCommit,
255
+ );
256
+
257
+ let mergedTree: string;
258
+ let conflicts: string[] = [];
259
+ try {
260
+ const out = await gitOut(projectDir, [
261
+ "merge-tree",
262
+ "--write-tree",
263
+ "--name-only",
264
+ `--merge-base=${baseCommit}`,
265
+ head,
266
+ nextCommit,
267
+ ]);
268
+ mergedTree = out.split("\n")[0]!.trim();
269
+ } catch (err) {
270
+ // Exit code 1 = conflicts. Output format: <tree>\n<conflicted file
271
+ // names, one per line>\n\n<informational messages> — only the names
272
+ // section (up to the blank line) lists conflicted paths.
273
+ const stdout = (err as { stdout?: string }).stdout ?? "";
274
+ const lines = stdout.split("\n");
275
+ if (!lines[0]?.trim()) throw err;
276
+ mergedTree = lines[0].trim();
277
+ const blank = lines.indexOf("", 1);
278
+ conflicts = lines
279
+ .slice(1, blank === -1 ? undefined : blank)
280
+ .map((line) => line.trim())
281
+ .filter(Boolean);
282
+ }
283
+
284
+ // The merged .mpo.json can interleave ours/theirs lines (add/add
285
+ // two-way merge) — the post-merge provenance is always written
286
+ // authoritatively below.
287
+ const finalProvenance = {
288
+ template: provenance.template,
289
+ cliVersion: currentVersion,
290
+ name: provenance.name,
291
+ mpoVersion: targetMpoRange,
292
+ };
293
+
294
+ // .updateignore pathspecs (+ pnpm-lock.yaml) pin to the pre-update HEAD.
295
+ // Pins are applied to the MERGED TREE before committing, so a clean
296
+ // update never leaves the tree dirty.
297
+ const pins = ["pnpm-lock.yaml"];
298
+ const updateignore = join(projectDir, ".updateignore");
299
+ if (existsSync(updateignore)) {
300
+ for (const line of readFileSync(updateignore, "utf-8").split("\n")) {
301
+ const pattern = line.trim();
302
+ if (pattern && !pattern.startsWith("#")) pins.push(pattern);
303
+ }
304
+ }
305
+ mergedTree = await pinPathsToHead(projectDir, mergedTree, head, pins);
306
+ // Pinning resolves any conflict inside a pinned path (the pre-update
307
+ // HEAD content wins by definition).
308
+ conflicts = conflicts.filter(
309
+ (file) => !pins.some((pin) => file === pin || file.startsWith(`${pin}/`)),
310
+ );
311
+
312
+ // No-op detection: identical trees mean the project already carries the
313
+ // current template — don't stack empty merge commits.
314
+ const headTree = await gitOut(projectDir, ["rev-parse", `${head}^{tree}`]);
315
+ if (!conflicts.length && mergedTree === headTree) {
316
+ if (
317
+ provenance.cliVersion !== finalProvenance.cliVersion ||
318
+ provenance.mpoVersion !== finalProvenance.mpoVersion
319
+ ) {
320
+ writeProvenance(projectDir, finalProvenance);
321
+ await git(projectDir, ["add", "--", ".mpo.json"]);
322
+ await git(projectDir, [
323
+ "commit",
324
+ "--quiet",
325
+ "-m",
326
+ `chore: record mpo template provenance ${currentVersion}`,
327
+ ]);
328
+ console.log("\n✅ already up to date (provenance refreshed)");
329
+ } else {
330
+ console.log("\n✅ already up to date");
331
+ }
332
+ return;
333
+ }
334
+
335
+ if (!conflicts.length) {
336
+ const message = `chore: mpo update ${provenance.cliVersion} → ${currentVersion}`;
337
+ const mergeCommit = await gitOut(projectDir, [
338
+ "commit-tree",
339
+ mergedTree,
340
+ "-p",
341
+ head,
342
+ "-p",
343
+ nextCommit,
344
+ "-m",
345
+ message,
346
+ ]);
347
+ await git(projectDir, ["update-ref", "HEAD", mergeCommit]);
348
+ await git(projectDir, ["reset", "--hard", "HEAD"]);
349
+ // Tool-owned outputs belong to the update commit: provenance + the
350
+ // regenerated .vscode config.
351
+ writeProvenance(projectDir, finalProvenance);
352
+ try {
353
+ await generateVscodeConfig(projectDir);
354
+ } catch {
355
+ // best-effort (older layouts)
356
+ }
357
+ await git(projectDir, ["add", "--", ".mpo.json", ".vscode"]);
358
+ await git(projectDir, ["commit", "--amend", "--no-edit", "--quiet"]);
359
+ const amended = await gitOut(projectDir, ["rev-parse", "--short", "HEAD"]);
360
+ console.log(`\n✅ merged cleanly → ${amended} ("${message}")`);
361
+ } else {
362
+ // Land the merged tree (with conflict markers) in index + worktree for
363
+ // manual resolution; pins are already part of the tree.
364
+ await git(projectDir, ["read-tree", "-u", "--reset", mergedTree]);
365
+ writeProvenance(projectDir, finalProvenance);
366
+ console.log("\n⚠️ merge conflicts — resolve the markers, then commit:");
367
+ for (const file of conflicts) console.log(` ${file}`);
368
+ console.log(
369
+ `\n git add -A && git commit -m "chore: mpo update ${provenance.cliVersion} → ${currentVersion}"`,
370
+ );
371
+ }
372
+
373
+ if (conflicts.length && !options.skipInstall) {
374
+ console.log("\nSkipping pnpm install until conflicts are resolved (then run: pnpm install).");
375
+ } else if (!options.skipInstall) {
376
+ console.log("\nInstalling dependencies...");
377
+ await spawn("pnpm", ["install"], { cwd: projectDir, stdio: "inherit" });
378
+ // The merge changed dependency ranges, so the regenerated lockfile
379
+ // belongs with the update: amend it into the clean-merge commit
380
+ // (conflict resolutions commit manually and pick it up there).
381
+ if (!conflicts.length) {
382
+ const lockDirty = await git(projectDir, [
383
+ "diff",
384
+ "--quiet",
385
+ "--",
386
+ "pnpm-lock.yaml",
387
+ ]).then(
388
+ () => false,
389
+ () => true,
390
+ );
391
+ if (lockDirty) {
392
+ await git(projectDir, ["add", "--", "pnpm-lock.yaml"]);
393
+ await git(projectDir, ["commit", "--amend", "--no-edit", "--quiet"]);
394
+ }
395
+ }
396
+ }
397
+ } finally {
398
+ base.cleanup();
399
+ next.cleanup();
400
+ }
401
+ }
@@ -2,6 +2,7 @@ node_modules
2
2
  dist
3
3
  .turbo
4
4
  .vite
5
+ .tamagui
5
6
  *.log
6
7
  .env
7
8
  .env.local
@@ -1,53 +1,8 @@
1
- import { existsSync } from "node:fs";
2
- import { createRequire } from "node:module";
3
1
  import { createViteConfig } from "@multiplatform.one/config/vite";
4
2
  import { one } from "one/vite";
5
- import type { Plugin } from "vite";
6
- import { public as publicConfigKeys } from "../../packages/config/config.json";
3
+ import configJson from "../../packages/config/config.json" with { type: "json" };
7
4
 
8
- const require = createRequire(import.meta.url);
9
-
10
- /**
11
- * Some @multiplatform.one packages at 6.1.0 publish web export targets that
12
- * don't exist in their tarballs (exports say dist/esm/…/index.js; the build
13
- * emitted index.mjs), which breaks resolution for anything importing them —
14
- * e.g. forms → router and frappe-ui → frappe. Resolve those bare imports to
15
- * the real web builds. Native is unaffected (vxrn's native resolver uses the
16
- * packages' valid react-native export condition). Remove this plugin once
17
- * fixed packages are published.
18
- */
19
- const BROKEN_PUBLISHED_ENTRIES: Record<string, { pkg: string; entry: string }> = {
20
- "@multiplatform.one/router": {
21
- pkg: "@multiplatform.one/router",
22
- entry: "dist/esm/index.mjs",
23
- },
24
- "@multiplatform.one/frappe": {
25
- pkg: "@multiplatform.one/frappe",
26
- entry: "dist/esm/index.mjs",
27
- },
28
- "@multiplatform.one/frappe/devtools": {
29
- pkg: "@multiplatform.one/frappe",
30
- entry: "dist/esm/devtools/index.mjs",
31
- },
32
- };
33
-
34
- function fixPublishedEsmEntries(): Plugin {
35
- return {
36
- name: "fix-multiplatform-published-esm-entries",
37
- enforce: "pre",
38
- resolveId(id) {
39
- const broken = BROKEN_PUBLISHED_ENTRIES[id];
40
- if (!broken) return null;
41
- try {
42
- const pkgJson = require.resolve(`${broken.pkg}/package.json`);
43
- const entry = pkgJson.replace(/package\.json$/, broken.entry);
44
- return existsSync(entry) ? entry : null;
45
- } catch {
46
- return null;
47
- }
48
- },
49
- };
50
- }
5
+ const publicConfigKeys = configJson.public;
51
6
 
52
7
  export default createViteConfig({
53
8
  publicConfigKeys,
@@ -61,6 +16,5 @@ export default createViteConfig({
61
16
  react: { compiler: true },
62
17
  native: { key: "__NAME_PASCAL__" },
63
18
  }),
64
- fixPublishedEsmEntries(),
65
19
  ],
66
20
  });
@@ -5,6 +5,8 @@ dist
5
5
  .tamagui
6
6
  .expo
7
7
  *.log
8
+ # One router generated typings
9
+ routes.d.ts
8
10
  .env
9
11
  .env.local
10
12
  .DS_Store
@@ -1 +1 @@
1
- {"version":3,"file":"multiplatformOne.d.ts","sourceRoot":"","sources":["../../src/bin/multiplatformOne.ts"],"names":[],"mappings":"AA0FA,gHAAgH;AAChH,MAAM,MAAM,wBAAwB,GAAG;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,kGAAkG;IAClG,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gGAAgG;IAChG,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC,CAAC"}
1
+ {"version":3,"file":"multiplatformOne.d.ts","sourceRoot":"","sources":["../../src/bin/multiplatformOne.ts"],"names":[],"mappings":"AA2FA,gHAAgH;AAChH,MAAM,MAAM,wBAAwB,GAAG;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,kGAAkG;IAClG,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gGAAgG;IAChG,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC,CAAC"}
@@ -9,6 +9,8 @@ export type InitAppTemplate = "universal" | "app";
9
9
  export interface InitAppOptions {
10
10
  /** Skip pnpm install (useful for CI / dry scaffolding). */
11
11
  skipInstall?: boolean;
12
+ /** Skip git init + scaffold commit (mpo update baselines, nested dirs). */
13
+ skipGit?: boolean;
12
14
  /** Pin @multiplatform.one/* to this semver range. Default: ^<cli version>. */
13
15
  version?: string;
14
16
  /** Project template. When omitted: prompt (interactive) or "universal". */
@@ -16,6 +18,22 @@ export interface InitAppOptions {
16
18
  /** Accept defaults instead of prompting (non-interactive / CI). */
17
19
  yes?: boolean;
18
20
  }
21
+ export declare function cliVersion(): string | undefined;
22
+ /**
23
+ * Scaffold provenance (the copier-answers equivalent): which template, which
24
+ * CLI version and which vars produced this project. `mpo update` regenerates
25
+ * the ORIGINAL scaffold from this record to use as the merge base for a
26
+ * three-way template update.
27
+ */
28
+ export interface MpoProvenance {
29
+ template: InitAppTemplate;
30
+ cliVersion: string;
31
+ name: string;
32
+ mpoVersion: string;
33
+ }
34
+ export declare const PROVENANCE_FILE = ".mpo.json";
35
+ export declare function writeProvenance(targetDir: string, provenance: MpoProvenance): void;
36
+ export declare function readProvenance(projectDir: string): MpoProvenance | undefined;
19
37
  /**
20
38
  * Scaffold a consumer project (universal by default, web-only with
21
39
  * template: "app") that depends on @multiplatform.one/* at semver ranges
@@ -1 +1 @@
1
- {"version":3,"file":"initApp.d.ts","sourceRoot":"","sources":["../../src/commands/initApp.ts"],"names":[],"mappings":"AAaA;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,WAAW,GAAG,KAAK,CAAC;AAElD,MAAM,WAAW,cAAc;IAC7B,2DAA2D;IAC3D,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,8EAA8E;IAC9E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,mEAAmE;IACnE,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAqHD;;;;GAIG;AACH,wBAAsB,OAAO,CAC3B,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAuEf"}
1
+ {"version":3,"file":"initApp.d.ts","sourceRoot":"","sources":["../../src/commands/initApp.ts"],"names":[],"mappings":"AAaA;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,WAAW,GAAG,KAAK,CAAC;AAElD,MAAM,WAAW,cAAc;IAC7B,2DAA2D;IAC3D,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,2EAA2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,8EAA8E;IAC9E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,mEAAmE;IACnE,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AA0CD,wBAAgB,UAAU,IAAI,MAAM,GAAG,SAAS,CAQ/C;AAED;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,eAAe,CAAC;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,eAAO,MAAM,eAAe,cAAc,CAAC;AAE3C,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,aAAa,GAAG,IAAI,CAElF;AAED,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CAe5E;AAuED;;;;GAIG;AACH,wBAAsB,OAAO,CAC3B,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAmHf"}
@@ -0,0 +1,24 @@
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:
5
+ *
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
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 pathspec per line)
15
+ * pins matching paths to your HEAD version, exactly like the old script.
16
+ */
17
+ export interface UpdateAppOptions {
18
+ /** Skip pnpm install after the merge. */
19
+ skipInstall?: boolean;
20
+ /** Override the semver range written for @multiplatform.one/* deps. */
21
+ version?: string;
22
+ }
23
+ export declare function updateApp(options?: UpdateAppOptions): Promise<void>;
24
+ //# sourceMappingURL=updateApp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"updateApp.d.ts","sourceRoot":"","sources":["../../src/commands/updateApp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AASH,MAAM,WAAW,gBAAgB;IAC/B,yCAAyC;IACzC,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,uEAAuE;IACvE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAyJD,wBAAsB,SAAS,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CA0N7E"}