@multiplatform.one/cli 6.3.0 → 6.4.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.
@@ -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";
@@ -491,8 +492,18 @@ program.command("init").option("--universal", "scaffold the universal template (
491
492
  yes: Boolean(options.yes)
492
493
  });
493
494
  });
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) => {
495
+ 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
496
  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");
497
+ if (readProvenance(projectRoot)) {
498
+ await updateApp({
499
+ skipInstall: options.skipInstall,
500
+ version: options.mpoVersion
501
+ });
502
+ try {
503
+ await generateVscodeConfig(projectRoot);
504
+ } catch {}
505
+ return;
506
+ }
496
507
  if (await spawn("git", [
497
508
  "diff",
498
509
  "--cached",
@@ -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,6 +145,12 @@ 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
+ });
124
154
  if (!options.skipInstall) {
125
155
  console.log("Installing dependencies...");
126
156
  await spawn("pnpm", ["install"], {
@@ -142,4 +172,4 @@ async function initApp(nameArg, options = {}) {
142
172
  }
143
173
 
144
174
  //#endregion
145
- export { initApp };
175
+ export { PROVENANCE_FILE, cliVersion, initApp, readProvenance, writeProvenance };
@@ -0,0 +1,260 @@
1
+ import { cliVersion, initApp, readProvenance, writeProvenance } from "./initApp.mjs";
2
+ import { join } from "node:path";
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import spawn from "nano-spawn";
5
+ import { tmpdir } from "node:os";
6
+
7
+ //#region src/commands/updateApp.ts
8
+ /**
9
+ * `mpo update` for scaffolded consumer projects — the modern successor of
10
+ * the old cookiecutter flow (init a fresh project, swap your .git in, merge
11
+ * your changes on top), rebuilt with a TRUE three-way merge:
12
+ *
13
+ * base = the ORIGINAL scaffold, regenerated from the CLI version recorded
14
+ * in .mpo.json at init time (published CLIs ship their templates,
15
+ * so `pnpm dlx @multiplatform.one/cli@<old> init` reproduces it)
16
+ * ours = the project's HEAD (all your custom changes)
17
+ * theirs= a fresh scaffold from the CURRENT CLI version
18
+ *
19
+ * `git merge-tree --write-tree --merge-base=<base>` merges template
20
+ * evolution with your customizations; conflicts land in the worktree with
21
+ * normal conflict markers. An `.updateignore` file (one pathspec per line)
22
+ * pins matching paths to your HEAD version, exactly like the old script.
23
+ */
24
+ async function git(projectDir, args, env) {
25
+ return spawn("git", args, {
26
+ cwd: projectDir,
27
+ env: env ? {
28
+ ...process.env,
29
+ ...env
30
+ } : void 0
31
+ });
32
+ }
33
+ async function gitOut(projectDir, args, env) {
34
+ return (await git(projectDir, args, env)).stdout.trim();
35
+ }
36
+ /** Import a directory tree into the project's object database and return a
37
+ * root-less commit for it (temp index; the worktree is never touched). */
38
+ async function commitTreeFromDir(projectDir, dir, message, parent) {
39
+ const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-index-")), "index");
40
+ const env = {
41
+ GIT_INDEX_FILE: indexFile,
42
+ GIT_WORK_TREE: dir
43
+ };
44
+ await git(projectDir, [
45
+ "add",
46
+ "-A",
47
+ "--force",
48
+ "--",
49
+ "."
50
+ ], env);
51
+ const tree = await gitOut(projectDir, ["write-tree"], env);
52
+ const commit = await gitOut(projectDir, parent ? [
53
+ "commit-tree",
54
+ tree,
55
+ "-p",
56
+ parent,
57
+ "-m",
58
+ message
59
+ ] : [
60
+ "commit-tree",
61
+ tree,
62
+ "-m",
63
+ message
64
+ ]);
65
+ rmSync(indexFile, { force: true });
66
+ return commit;
67
+ }
68
+ /** Scaffold a project with a specific published CLI version into tmp and
69
+ * return the generated project dir. Falls back to the local generator when
70
+ * the requested version matches the running CLI (or dlx fails). */
71
+ async function scaffoldBaseline(version, name, template, mpoVersion) {
72
+ const parent = mkdtempSync(join(tmpdir(), "mpo-update-"));
73
+ const cleanup = () => rmSync(parent, {
74
+ recursive: true,
75
+ force: true
76
+ });
77
+ const current = cliVersion();
78
+ const templateFlag = template === "app" ? ["--web"] : ["--universal"];
79
+ if (version !== current) {
80
+ try {
81
+ await spawn("pnpm", [
82
+ "--package",
83
+ `@multiplatform.one/cli@${version}`,
84
+ "dlx",
85
+ "mpo",
86
+ "init",
87
+ name,
88
+ "--yes",
89
+ "--skip-install",
90
+ "--mpo-version",
91
+ mpoVersion,
92
+ ...templateFlag
93
+ ], {
94
+ cwd: parent,
95
+ stdio: "inherit"
96
+ });
97
+ const dir = join(parent, name);
98
+ if (existsSync(join(dir, "package.json"))) return {
99
+ dir,
100
+ cleanup
101
+ };
102
+ console.warn(`⚠️ dlx scaffold for @multiplatform.one/cli@${version} produced no project;`);
103
+ } catch {
104
+ console.warn(`⚠️ could not scaffold baseline with @multiplatform.one/cli@${version} (network?);`);
105
+ }
106
+ console.warn(" falling back to the current CLI's template as the merge base.");
107
+ }
108
+ const previousCwd = process.cwd();
109
+ process.chdir(parent);
110
+ try {
111
+ await initApp(name, {
112
+ skipInstall: true,
113
+ yes: true,
114
+ template,
115
+ version: mpoVersion
116
+ });
117
+ } finally {
118
+ process.chdir(previousCwd);
119
+ }
120
+ return {
121
+ dir: join(parent, name),
122
+ cleanup
123
+ };
124
+ }
125
+ async function updateApp(options = {}) {
126
+ const projectDir = await gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
127
+ throw new Error("mpo update must run inside a git repository");
128
+ });
129
+ await git(projectDir, ["diff", "--quiet"]).catch(() => {
130
+ throw new Error("mpo update requires a clean working tree (unstaged changes present)");
131
+ });
132
+ await git(projectDir, [
133
+ "diff",
134
+ "--cached",
135
+ "--quiet"
136
+ ]).catch(() => {
137
+ throw new Error("mpo update requires a clean working tree (staged changes present)");
138
+ });
139
+ const provenance = readProvenance(projectDir);
140
+ 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`.");
141
+ const currentVersion = cliVersion() ?? "0.0.0";
142
+ const targetMpoRange = options.version ? options.version.startsWith("^") || options.version.startsWith("~") ? options.version : `^${options.version}` : `^${currentVersion}`;
143
+ console.log(`\nmpo update: ${provenance.cliVersion} → ${currentVersion}`);
144
+ console.log(` template: ${provenance.template}`);
145
+ console.log(` project: ${provenance.name}\n`);
146
+ console.log(`Scaffolding merge base (cli@${provenance.cliVersion})...`);
147
+ const base = await scaffoldBaseline(provenance.cliVersion, provenance.name, provenance.template, provenance.mpoVersion);
148
+ console.log(`Scaffolding update target (cli@${currentVersion})...`);
149
+ const next = await scaffoldBaseline(currentVersion, provenance.name, provenance.template, targetMpoRange);
150
+ writeProvenance(next.dir, {
151
+ template: provenance.template,
152
+ cliVersion: currentVersion,
153
+ name: provenance.name,
154
+ mpoVersion: targetMpoRange
155
+ });
156
+ try {
157
+ const head = await gitOut(projectDir, ["rev-parse", "HEAD"]);
158
+ const baseCommit = await commitTreeFromDir(projectDir, base.dir, `mpo scaffold ${provenance.template}@${provenance.cliVersion}`);
159
+ const nextCommit = await commitTreeFromDir(projectDir, next.dir, `mpo scaffold ${provenance.template}@${currentVersion}`, baseCommit);
160
+ let mergedTree;
161
+ let conflicts = [];
162
+ try {
163
+ mergedTree = (await gitOut(projectDir, [
164
+ "merge-tree",
165
+ "--write-tree",
166
+ "--name-only",
167
+ `--merge-base=${baseCommit}`,
168
+ head,
169
+ nextCommit
170
+ ])).split("\n")[0].trim();
171
+ } catch (err) {
172
+ const lines = (err.stdout ?? "").split("\n").filter(Boolean);
173
+ if (!lines.length) throw err;
174
+ mergedTree = lines[0].trim();
175
+ conflicts = lines.slice(1).map((line) => line.trim());
176
+ }
177
+ const finalProvenance = {
178
+ template: provenance.template,
179
+ cliVersion: currentVersion,
180
+ name: provenance.name,
181
+ mpoVersion: targetMpoRange
182
+ };
183
+ if (!conflicts.length) {
184
+ const message = `chore: mpo update ${provenance.cliVersion} → ${currentVersion}`;
185
+ await git(projectDir, [
186
+ "update-ref",
187
+ "HEAD",
188
+ await gitOut(projectDir, [
189
+ "commit-tree",
190
+ mergedTree,
191
+ "-p",
192
+ head,
193
+ "-p",
194
+ nextCommit,
195
+ "-m",
196
+ message
197
+ ])
198
+ ]);
199
+ await git(projectDir, [
200
+ "reset",
201
+ "--hard",
202
+ "HEAD"
203
+ ]);
204
+ writeProvenance(projectDir, finalProvenance);
205
+ await git(projectDir, [
206
+ "add",
207
+ "--",
208
+ ".mpo.json"
209
+ ]);
210
+ await git(projectDir, [
211
+ "commit",
212
+ "--amend",
213
+ "--no-edit",
214
+ "--quiet"
215
+ ]);
216
+ const amended = await gitOut(projectDir, [
217
+ "rev-parse",
218
+ "--short",
219
+ "HEAD"
220
+ ]);
221
+ console.log(`\n✅ merged cleanly → ${amended} ("${message}")`);
222
+ } else {
223
+ await git(projectDir, [
224
+ "read-tree",
225
+ "-u",
226
+ "--reset",
227
+ mergedTree
228
+ ]);
229
+ writeProvenance(projectDir, finalProvenance);
230
+ console.log("\n⚠️ merge conflicts — resolve the markers, then commit:");
231
+ for (const file of conflicts) console.log(` ${file}`);
232
+ console.log(`\n git add -A && git commit -m "chore: mpo update ${provenance.cliVersion} → ${currentVersion}"`);
233
+ }
234
+ const pins = ["pnpm-lock.yaml"];
235
+ const updateignore = join(projectDir, ".updateignore");
236
+ if (existsSync(updateignore)) for (const line of readFileSync(updateignore, "utf-8").split("\n")) {
237
+ const pattern = line.trim();
238
+ if (pattern && !pattern.startsWith("#")) pins.push(pattern);
239
+ }
240
+ for (const pin of pins) await git(projectDir, [
241
+ "checkout",
242
+ head,
243
+ "--",
244
+ pin
245
+ ]).catch(() => {});
246
+ if (!options.skipInstall) {
247
+ console.log("\nInstalling dependencies...");
248
+ await spawn("pnpm", ["install"], {
249
+ cwd: projectDir,
250
+ stdio: "inherit"
251
+ });
252
+ }
253
+ } finally {
254
+ base.cleanup();
255
+ next.cleanup();
256
+ }
257
+ }
258
+
259
+ //#endregion
260
+ 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.0",
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.0"
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"];
@@ -279,10 +280,22 @@ const defaultUpdateRemote = "https://gitlab.com/bitspur/multiplatform.one/multip
279
280
 
280
281
  program
281
282
  .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 }) => {
283
+ .option("-c, --checkout <branch>", "branch, tag or commit to merge from upstream (monorepo forks)", "main")
284
+ .option("-r, --remote <url>", "upstream remote URL (monorepo forks)", defaultUpdateRemote)
285
+ .option("--skip-install", "skip pnpm install after the update")
286
+ .option(
287
+ "--mpo-version <range>",
288
+ "semver range for @multiplatform.one/* (default: ^<cli version>)",
289
+ )
290
+ .description(
291
+ "update a scaffolded project to the current template (three-way merge via .mpo.json provenance); monorepo forks fall back to the upstream merge flow",
292
+ )
293
+ .action(async (options: {
294
+ checkout: string;
295
+ remote: string;
296
+ skipInstall?: boolean;
297
+ mpoVersion?: string;
298
+ }) => {
286
299
  // Update prechecks: must be in a git repo
287
300
  if (
288
301
  await spawn("git", ["rev-parse", "--is-inside-work-tree"]).then(
@@ -292,6 +305,18 @@ program
292
305
  ) {
293
306
  throw new Error("mpo cannot be updated outside of a git repository");
294
307
  }
308
+ // Scaffolded consumer projects carry .mpo.json provenance — use the
309
+ // copier-style three-way template update. Monorepo forks fall through to
310
+ // the legacy upstream-merge flow below.
311
+ if (readProvenance(projectRoot)) {
312
+ await updateApp({ skipInstall: options.skipInstall, version: options.mpoVersion });
313
+ try {
314
+ await generateVscodeConfig(projectRoot);
315
+ } catch {
316
+ // best-effort
317
+ }
318
+ return;
319
+ }
295
320
  // Require clean working tree (no staged or unstaged changes)
296
321
  if (
297
322
  await spawn("git", ["diff", "--cached", "--quiet"]).then(
@@ -67,14 +67,54 @@ function resolveMpoVersion(explicit?: string): string {
67
67
  ? explicit
68
68
  : `^${explicit}`;
69
69
  }
70
+ const version = cliVersion();
71
+ return version ? `^${version}` : "^6.1.0";
72
+ }
73
+
74
+ export function cliVersion(): string | undefined {
70
75
  try {
71
76
  const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../package.json");
72
77
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string };
73
- if (pkg.version) return `^${pkg.version}`;
78
+ return pkg.version;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Scaffold provenance (the copier-answers equivalent): which template, which
86
+ * CLI version and which vars produced this project. `mpo update` regenerates
87
+ * the ORIGINAL scaffold from this record to use as the merge base for a
88
+ * three-way template update.
89
+ */
90
+ export interface MpoProvenance {
91
+ template: InitAppTemplate;
92
+ cliVersion: string;
93
+ name: string;
94
+ mpoVersion: string;
95
+ }
96
+
97
+ export const PROVENANCE_FILE = ".mpo.json";
98
+
99
+ export function writeProvenance(targetDir: string, provenance: MpoProvenance): void {
100
+ writeFileSync(join(targetDir, PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`);
101
+ }
102
+
103
+ export function readProvenance(projectDir: string): MpoProvenance | undefined {
104
+ const file = join(projectDir, PROVENANCE_FILE);
105
+ if (!existsSync(file)) return undefined;
106
+ try {
107
+ const raw = JSON.parse(readFileSync(file, "utf-8")) as Partial<MpoProvenance>;
108
+ if (!raw.template || !raw.cliVersion || !raw.name) return undefined;
109
+ return {
110
+ template: raw.template,
111
+ cliVersion: raw.cliVersion,
112
+ name: raw.name,
113
+ mpoVersion: raw.mpoVersion ?? `^${raw.cliVersion}`,
114
+ };
74
115
  } catch {
75
- // fall through
116
+ return undefined;
76
117
  }
77
- return "^6.1.0";
78
118
  }
79
119
 
80
120
  function templatesRoot(template: InitAppTemplate): string {
@@ -203,6 +243,12 @@ export async function initApp(
203
243
  console.log(` @multiplatform.one/* → ${vars.MPO_VERSION}\n`);
204
244
 
205
245
  writeTree(templatesRoot(template), targetDir, vars);
246
+ writeProvenance(targetDir, {
247
+ template,
248
+ cliVersion: cliVersion() ?? "0.0.0",
249
+ name,
250
+ mpoVersion: vars.MPO_VERSION,
251
+ });
206
252
 
207
253
  if (!options.skipInstall) {
208
254
  console.log("Installing dependencies...");
@@ -0,0 +1,277 @@
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 { cliVersion, initApp, readProvenance, writeProvenance } from "./initApp";
23
+
24
+ export interface UpdateAppOptions {
25
+ /** Skip pnpm install after the merge. */
26
+ skipInstall?: boolean;
27
+ /** Override the semver range written for @multiplatform.one/* deps. */
28
+ version?: string;
29
+ }
30
+
31
+ async function git(projectDir: string, args: string[], env?: Record<string, string>) {
32
+ return spawn("git", args, {
33
+ cwd: projectDir,
34
+ env: env ? { ...process.env, ...env } : undefined,
35
+ });
36
+ }
37
+
38
+ async function gitOut(projectDir: string, args: string[], env?: Record<string, string>) {
39
+ const result = await git(projectDir, args, env);
40
+ return result.stdout.trim();
41
+ }
42
+
43
+ /** Import a directory tree into the project's object database and return a
44
+ * root-less commit for it (temp index; the worktree is never touched). */
45
+ async function commitTreeFromDir(
46
+ projectDir: string,
47
+ dir: string,
48
+ message: string,
49
+ parent?: string,
50
+ ): Promise<string> {
51
+ const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-index-")), "index");
52
+ const env = { GIT_INDEX_FILE: indexFile, GIT_WORK_TREE: dir };
53
+ await git(projectDir, ["add", "-A", "--force", "--", "."], env);
54
+ const tree = await gitOut(projectDir, ["write-tree"], env);
55
+ const commitArgs = parent
56
+ ? ["commit-tree", tree, "-p", parent, "-m", message]
57
+ : ["commit-tree", tree, "-m", message];
58
+ const commit = await gitOut(projectDir, commitArgs);
59
+ rmSync(indexFile, { force: true });
60
+ return commit;
61
+ }
62
+
63
+ /** Scaffold a project with a specific published CLI version into tmp and
64
+ * return the generated project dir. Falls back to the local generator when
65
+ * the requested version matches the running CLI (or dlx fails). */
66
+ async function scaffoldBaseline(
67
+ version: string,
68
+ name: string,
69
+ template: "universal" | "app",
70
+ mpoVersion: string,
71
+ ): Promise<{ dir: string; cleanup: () => void }> {
72
+ const parent = mkdtempSync(join(tmpdir(), "mpo-update-"));
73
+ const cleanup = () => rmSync(parent, { recursive: true, force: true });
74
+ const current = cliVersion();
75
+ const templateFlag = template === "app" ? ["--web"] : ["--universal"];
76
+ if (version !== current) {
77
+ try {
78
+ await spawn(
79
+ "pnpm",
80
+ [
81
+ "--package",
82
+ `@multiplatform.one/cli@${version}`,
83
+ "dlx",
84
+ "mpo",
85
+ "init",
86
+ name,
87
+ "--yes",
88
+ "--skip-install",
89
+ "--mpo-version",
90
+ mpoVersion,
91
+ ...templateFlag,
92
+ ],
93
+ { cwd: parent, stdio: "inherit" },
94
+ );
95
+ const dir = join(parent, name);
96
+ if (existsSync(join(dir, "package.json"))) return { dir, cleanup };
97
+ console.warn(`⚠️ dlx scaffold for @multiplatform.one/cli@${version} produced no project;`);
98
+ } catch {
99
+ console.warn(
100
+ `⚠️ could not scaffold baseline with @multiplatform.one/cli@${version} (network?);`,
101
+ );
102
+ }
103
+ console.warn(" falling back to the current CLI's template as the merge base.");
104
+ }
105
+ const previousCwd = process.cwd();
106
+ process.chdir(parent);
107
+ try {
108
+ await initApp(name, {
109
+ skipInstall: true,
110
+ yes: true,
111
+ template,
112
+ version: mpoVersion,
113
+ });
114
+ } finally {
115
+ process.chdir(previousCwd);
116
+ }
117
+ return { dir: join(parent, name), cleanup };
118
+ }
119
+
120
+ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
121
+ const projectDir = await gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
122
+ throw new Error("mpo update must run inside a git repository");
123
+ });
124
+
125
+ // Clean tree required — the merge lands in the worktree.
126
+ await git(projectDir, ["diff", "--quiet"]).catch(() => {
127
+ throw new Error("mpo update requires a clean working tree (unstaged changes present)");
128
+ });
129
+ await git(projectDir, ["diff", "--cached", "--quiet"]).catch(() => {
130
+ throw new Error("mpo update requires a clean working tree (staged changes present)");
131
+ });
132
+
133
+ const provenance = readProvenance(projectDir);
134
+ if (!provenance) {
135
+ throw new Error(
136
+ ".mpo.json not found — this project predates scaffold provenance. " +
137
+ "Create it with { template, cliVersion, name } matching how the project " +
138
+ "was generated (cliVersion = the @multiplatform.one/cli that scaffolded it), " +
139
+ "then re-run `mpo update`.",
140
+ );
141
+ }
142
+
143
+ const currentVersion = cliVersion() ?? "0.0.0";
144
+ const targetMpoRange = options.version
145
+ ? options.version.startsWith("^") || options.version.startsWith("~")
146
+ ? options.version
147
+ : `^${options.version}`
148
+ : `^${currentVersion}`;
149
+
150
+ console.log(`\nmpo update: ${provenance.cliVersion} → ${currentVersion}`);
151
+ console.log(` template: ${provenance.template}`);
152
+ console.log(` project: ${provenance.name}\n`);
153
+
154
+ // 1. Regenerate the ORIGINAL scaffold (merge base).
155
+ console.log(`Scaffolding merge base (cli@${provenance.cliVersion})...`);
156
+ const base = await scaffoldBaseline(
157
+ provenance.cliVersion,
158
+ provenance.name,
159
+ provenance.template,
160
+ provenance.mpoVersion,
161
+ );
162
+
163
+ // 2. Generate the CURRENT scaffold (theirs).
164
+ console.log(`Scaffolding update target (cli@${currentVersion})...`);
165
+ const next = await scaffoldBaseline(
166
+ currentVersion,
167
+ provenance.name,
168
+ provenance.template,
169
+ targetMpoRange,
170
+ );
171
+ writeProvenance(next.dir, {
172
+ template: provenance.template,
173
+ cliVersion: currentVersion,
174
+ name: provenance.name,
175
+ mpoVersion: targetMpoRange,
176
+ });
177
+
178
+ try {
179
+ // 3. Three-way merge: base → HEAD (ours) vs base → next (theirs).
180
+ const head = await gitOut(projectDir, ["rev-parse", "HEAD"]);
181
+ const baseCommit = await commitTreeFromDir(
182
+ projectDir,
183
+ base.dir,
184
+ `mpo scaffold ${provenance.template}@${provenance.cliVersion}`,
185
+ );
186
+ const nextCommit = await commitTreeFromDir(
187
+ projectDir,
188
+ next.dir,
189
+ `mpo scaffold ${provenance.template}@${currentVersion}`,
190
+ baseCommit,
191
+ );
192
+
193
+ let mergedTree: string;
194
+ let conflicts: string[] = [];
195
+ try {
196
+ const out = await gitOut(projectDir, [
197
+ "merge-tree",
198
+ "--write-tree",
199
+ "--name-only",
200
+ `--merge-base=${baseCommit}`,
201
+ head,
202
+ nextCommit,
203
+ ]);
204
+ mergedTree = out.split("\n")[0]!.trim();
205
+ } catch (err) {
206
+ // Exit code 1 = conflicts; stdout still carries tree + conflict names.
207
+ const stdout = (err as { stdout?: string }).stdout ?? "";
208
+ const lines = stdout.split("\n").filter(Boolean);
209
+ if (!lines.length) throw err;
210
+ mergedTree = lines[0]!.trim();
211
+ conflicts = lines.slice(1).map((line) => line.trim());
212
+ }
213
+
214
+ // The merged .mpo.json can interleave ours/theirs lines (add/add
215
+ // two-way merge) — the post-merge provenance is always written
216
+ // authoritatively below.
217
+ const finalProvenance = {
218
+ template: provenance.template,
219
+ cliVersion: currentVersion,
220
+ name: provenance.name,
221
+ mpoVersion: targetMpoRange,
222
+ };
223
+
224
+ if (!conflicts.length) {
225
+ const message = `chore: mpo update ${provenance.cliVersion} → ${currentVersion}`;
226
+ const mergeCommit = await gitOut(projectDir, [
227
+ "commit-tree",
228
+ mergedTree,
229
+ "-p",
230
+ head,
231
+ "-p",
232
+ nextCommit,
233
+ "-m",
234
+ message,
235
+ ]);
236
+ await git(projectDir, ["update-ref", "HEAD", mergeCommit]);
237
+ await git(projectDir, ["reset", "--hard", "HEAD"]);
238
+ writeProvenance(projectDir, finalProvenance);
239
+ await git(projectDir, ["add", "--", ".mpo.json"]);
240
+ await git(projectDir, ["commit", "--amend", "--no-edit", "--quiet"]);
241
+ const amended = await gitOut(projectDir, ["rev-parse", "--short", "HEAD"]);
242
+ console.log(`\n✅ merged cleanly → ${amended} ("${message}")`);
243
+ } else {
244
+ // Land the merged tree (with conflict markers) in index + worktree for
245
+ // manual resolution.
246
+ await git(projectDir, ["read-tree", "-u", "--reset", mergedTree]);
247
+ writeProvenance(projectDir, finalProvenance);
248
+ console.log("\n⚠️ merge conflicts — resolve the markers, then commit:");
249
+ for (const file of conflicts) console.log(` ${file}`);
250
+ console.log(
251
+ `\n git add -A && git commit -m "chore: mpo update ${provenance.cliVersion} → ${currentVersion}"`,
252
+ );
253
+ }
254
+
255
+ // 4. .updateignore: pin matching paths to the pre-update HEAD (always
256
+ // includes pnpm-lock.yaml — the install below regenerates it).
257
+ const pins = ["pnpm-lock.yaml"];
258
+ const updateignore = join(projectDir, ".updateignore");
259
+ if (existsSync(updateignore)) {
260
+ for (const line of readFileSync(updateignore, "utf-8").split("\n")) {
261
+ const pattern = line.trim();
262
+ if (pattern && !pattern.startsWith("#")) pins.push(pattern);
263
+ }
264
+ }
265
+ for (const pin of pins) {
266
+ await git(projectDir, ["checkout", head, "--", pin]).catch(() => {});
267
+ }
268
+
269
+ if (!options.skipInstall) {
270
+ console.log("\nInstalling dependencies...");
271
+ await spawn("pnpm", ["install"], { cwd: projectDir, stdio: "inherit" });
272
+ }
273
+ } finally {
274
+ base.cleanup();
275
+ next.cleanup();
276
+ }
277
+ }
@@ -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"}
@@ -16,6 +16,22 @@ export interface InitAppOptions {
16
16
  /** Accept defaults instead of prompting (non-interactive / CI). */
17
17
  yes?: boolean;
18
18
  }
19
+ export declare function cliVersion(): string | undefined;
20
+ /**
21
+ * Scaffold provenance (the copier-answers equivalent): which template, which
22
+ * CLI version and which vars produced this project. `mpo update` regenerates
23
+ * the ORIGINAL scaffold from this record to use as the merge base for a
24
+ * three-way template update.
25
+ */
26
+ export interface MpoProvenance {
27
+ template: InitAppTemplate;
28
+ cliVersion: string;
29
+ name: string;
30
+ mpoVersion: string;
31
+ }
32
+ export declare const PROVENANCE_FILE = ".mpo.json";
33
+ export declare function writeProvenance(targetDir: string, provenance: MpoProvenance): void;
34
+ export declare function readProvenance(projectDir: string): MpoProvenance | undefined;
19
35
  /**
20
36
  * Scaffold a consumer project (universal by default, web-only with
21
37
  * 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,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,CA6Ef"}
@@ -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;AAQH,MAAM,WAAW,gBAAgB;IAC/B,yCAAyC;IACzC,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,uEAAuE;IACvE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AA2FD,wBAAsB,SAAS,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6J7E"}