@multiplatform.one/cli 6.4.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.
@@ -471,7 +471,7 @@ async function getFrappeApps(root) {
471
471
  const program = new Command();
472
472
  program.name("mpo");
473
473
  program.version(JSON.parse(fsSync.readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../package.json"), "utf8"))?.version);
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("--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) => {
475
475
  if (options.monorepo) {
476
476
  console.warn("⚠️ `mpo init --monorepo` clones the whole multiplatform.one monorepo (legacy). New projects should use the default consumer scaffold instead.");
477
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");
@@ -487,6 +487,7 @@ program.command("init").option("--universal", "scaffold the universal template (
487
487
  if (webOnly && options.universal) throw new Error("Pass either --web/--app or --universal, not both");
488
488
  await initApp(name, {
489
489
  skipInstall: Boolean(options.skipInstall),
490
+ skipGit: Boolean(options.skipGit),
490
491
  version: options.mpoVersion,
491
492
  template: webOnly ? "app" : options.universal ? "universal" : void 0,
492
493
  yes: Boolean(options.yes)
@@ -499,11 +500,9 @@ program.command("update").option("-c, --checkout <branch>", "branch, tag or comm
499
500
  skipInstall: options.skipInstall,
500
501
  version: options.mpoVersion
501
502
  });
502
- try {
503
- await generateVscodeConfig(projectRoot);
504
- } catch {}
505
503
  return;
506
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`.");
507
506
  if (await spawn("git", [
508
507
  "diff",
509
508
  "--cached",
@@ -823,7 +822,12 @@ program.command("generate").description("run generate command").action(async ()
823
822
  cwd: projectRoot
824
823
  });
825
824
  });
826
- 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
+ });
827
831
  async function waitWithSpinner(services, options = {}) {
828
832
  const { interval = 1e3, timeout = 6e5 } = options;
829
833
  const waitFunctions = {
@@ -151,12 +151,39 @@ async function initApp(nameArg, options = {}) {
151
151
  name,
152
152
  mpoVersion: vars.MPO_VERSION
153
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
+ }
154
171
  if (!options.skipInstall) {
155
172
  console.log("Installing dependencies...");
156
173
  await spawn("pnpm", ["install"], {
157
174
  cwd: targetDir,
158
175
  stdio: "inherit"
159
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(() => {});
160
187
  } else console.log("Skipped pnpm install (--skip-install).");
161
188
  console.log(`\n✅ Project created at ${targetDir}`);
162
189
  console.log("\nNext steps:");
@@ -1,3 +1,4 @@
1
+ import { generateVscodeConfig } from "../generateVscode.mjs";
1
2
  import { cliVersion, initApp, readProvenance, writeProvenance } from "./initApp.mjs";
2
3
  import { join } from "node:path";
3
4
  import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
@@ -76,8 +77,10 @@ async function scaffoldBaseline(version, name, template, mpoVersion) {
76
77
  });
77
78
  const current = cliVersion();
78
79
  const templateFlag = template === "app" ? ["--web"] : ["--universal"];
79
- if (version !== current) {
80
- try {
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 {
81
84
  await spawn("pnpm", [
82
85
  "--package",
83
86
  `@multiplatform.one/cli@${version}`,
@@ -99,9 +102,14 @@ async function scaffoldBaseline(version, name, template, mpoVersion) {
99
102
  dir,
100
103
  cleanup
101
104
  };
102
- console.warn(`⚠️ dlx scaffold for @multiplatform.one/cli@${version} produced no project;`);
105
+ console.warn(`⚠️ dlx scaffold for @multiplatform.one/cli@${version} produced no project`);
106
+ break;
103
107
  } catch {
104
- console.warn(`⚠️ could not scaffold baseline with @multiplatform.one/cli@${version} (network?);`);
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?);`);
105
113
  }
106
114
  console.warn(" falling back to the current CLI's template as the merge base.");
107
115
  }
@@ -110,6 +118,7 @@ async function scaffoldBaseline(version, name, template, mpoVersion) {
110
118
  try {
111
119
  await initApp(name, {
112
120
  skipInstall: true,
121
+ skipGit: true,
113
122
  yes: true,
114
123
  template,
115
124
  version: mpoVersion
@@ -122,6 +131,50 @@ async function scaffoldBaseline(version, name, template, mpoVersion) {
122
131
  cleanup
123
132
  };
124
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
+ }
125
178
  async function updateApp(options = {}) {
126
179
  const projectDir = await gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
127
180
  throw new Error("mpo update must run inside a git repository");
@@ -147,12 +200,8 @@ async function updateApp(options = {}) {
147
200
  const base = await scaffoldBaseline(provenance.cliVersion, provenance.name, provenance.template, provenance.mpoVersion);
148
201
  console.log(`Scaffolding update target (cli@${currentVersion})...`);
149
202
  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
- });
203
+ rmSync(join(base.dir, ".mpo.json"), { force: true });
204
+ rmSync(join(next.dir, ".mpo.json"), { force: true });
156
205
  try {
157
206
  const head = await gitOut(projectDir, ["rev-parse", "HEAD"]);
158
207
  const baseCommit = await commitTreeFromDir(projectDir, base.dir, `mpo scaffold ${provenance.template}@${provenance.cliVersion}`);
@@ -169,10 +218,11 @@ async function updateApp(options = {}) {
169
218
  nextCommit
170
219
  ])).split("\n")[0].trim();
171
220
  } catch (err) {
172
- const lines = (err.stdout ?? "").split("\n").filter(Boolean);
173
- if (!lines.length) throw err;
221
+ const lines = (err.stdout ?? "").split("\n");
222
+ if (!lines[0]?.trim()) throw err;
174
223
  mergedTree = lines[0].trim();
175
- conflicts = lines.slice(1).map((line) => line.trim());
224
+ const blank = lines.indexOf("", 1);
225
+ conflicts = lines.slice(1, blank === -1 ? void 0 : blank).map((line) => line.trim()).filter(Boolean);
176
226
  }
177
227
  const finalProvenance = {
178
228
  template: provenance.template,
@@ -180,6 +230,33 @@ async function updateApp(options = {}) {
180
230
  name: provenance.name,
181
231
  mpoVersion: targetMpoRange
182
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
+ }
183
260
  if (!conflicts.length) {
184
261
  const message = `chore: mpo update ${provenance.cliVersion} → ${currentVersion}`;
185
262
  await git(projectDir, [
@@ -202,10 +279,14 @@ async function updateApp(options = {}) {
202
279
  "HEAD"
203
280
  ]);
204
281
  writeProvenance(projectDir, finalProvenance);
282
+ try {
283
+ await generateVscodeConfig(projectDir);
284
+ } catch {}
205
285
  await git(projectDir, [
206
286
  "add",
207
287
  "--",
208
- ".mpo.json"
288
+ ".mpo.json",
289
+ ".vscode"
209
290
  ]);
210
291
  await git(projectDir, [
211
292
  "commit",
@@ -231,24 +312,33 @@ async function updateApp(options = {}) {
231
312
  for (const file of conflicts) console.log(` ${file}`);
232
313
  console.log(`\n git add -A && git commit -m "chore: mpo update ${provenance.cliVersion} → ${currentVersion}"`);
233
314
  }
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) {
315
+ if (conflicts.length && !options.skipInstall) console.log("\nSkipping pnpm install until conflicts are resolved (then run: pnpm install).");
316
+ else if (!options.skipInstall) {
247
317
  console.log("\nInstalling dependencies...");
248
318
  await spawn("pnpm", ["install"], {
249
319
  cwd: projectDir,
250
320
  stdio: "inherit"
251
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
+ }
252
342
  }
253
343
  } finally {
254
344
  base.cleanup();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/cli",
3
- "version": "6.4.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.4.0"
64
+ "@multiplatform.one/utils": "6.4.1"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@types/inquirer": "^9.0.9",
@@ -222,6 +222,7 @@ program
222
222
  .option("--web", "scaffold the web-only template (Vite + React)")
223
223
  .option("--app", "alias of --web (back-compat)")
224
224
  .option("--skip-install", "skip pnpm install after scaffolding")
225
+ .option("--skip-git", "skip git init + scaffold commit")
225
226
  .option(
226
227
  "--mpo-version <range>",
227
228
  "semver range for @multiplatform.one/* (default: ^<cli version>)",
@@ -270,6 +271,7 @@ program
270
271
  }
271
272
  await initApp(name, {
272
273
  skipInstall: Boolean(options.skipInstall),
274
+ skipGit: Boolean(options.skipGit),
273
275
  version: options.mpoVersion,
274
276
  template: webOnly ? "app" : options.universal ? "universal" : undefined,
275
277
  yes: Boolean(options.yes),
@@ -306,17 +308,26 @@ program
306
308
  throw new Error("mpo cannot be updated outside of a git repository");
307
309
  }
308
310
  // 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
+ // 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.
311
315
  if (readProvenance(projectRoot)) {
312
316
  await updateApp({ skipInstall: options.skipInstall, version: options.mpoVersion });
313
- try {
314
- await generateVscodeConfig(projectRoot);
315
- } catch {
316
- // best-effort
317
- }
318
317
  return;
319
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
+ }
320
331
  // Require clean working tree (no staged or unstaged changes)
321
332
  if (
322
333
  await spawn("git", ["diff", "--cached", "--quiet"]).then(
@@ -806,7 +817,14 @@ program
806
817
  });
807
818
  });
808
819
 
809
- 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
+ });
810
828
 
811
829
  async function waitWithSpinner(
812
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". */
@@ -250,12 +252,50 @@ export async function initApp(
250
252
  mpoVersion: vars.MPO_VERSION,
251
253
  });
252
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
+ }
283
+
253
284
  if (!options.skipInstall) {
254
285
  console.log("Installing dependencies...");
255
286
  await spawn("pnpm", ["install"], {
256
287
  cwd: targetDir,
257
288
  stdio: "inherit",
258
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
+ }
259
299
  } else {
260
300
  console.log("Skipped pnpm install (--skip-install).");
261
301
  }
@@ -19,6 +19,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
19
19
  import { tmpdir } from "node:os";
20
20
  import { join } from "node:path";
21
21
  import spawn from "nano-spawn";
22
+ import { generateVscodeConfig } from "../generateVscode";
22
23
  import { cliVersion, initApp, readProvenance, writeProvenance } from "./initApp";
23
24
 
24
25
  export interface UpdateAppOptions {
@@ -73,32 +74,51 @@ async function scaffoldBaseline(
73
74
  const cleanup = () => rmSync(parent, { recursive: true, force: true });
74
75
  const current = cliVersion();
75
76
  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
- );
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
+ }
102
122
  }
103
123
  console.warn(" falling back to the current CLI's template as the merge base.");
104
124
  }
@@ -107,6 +127,7 @@ async function scaffoldBaseline(
107
127
  try {
108
128
  await initApp(name, {
109
129
  skipInstall: true,
130
+ skipGit: true,
110
131
  yes: true,
111
132
  template,
112
133
  version: mpoVersion,
@@ -117,6 +138,48 @@ async function scaffoldBaseline(
117
138
  return { dir: join(parent, name), cleanup };
118
139
  }
119
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
+
120
183
  export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
121
184
  const projectDir = await gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
122
185
  throw new Error("mpo update must run inside a git repository");
@@ -168,12 +231,13 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
168
231
  provenance.template,
169
232
  targetMpoRange,
170
233
  );
171
- writeProvenance(next.dir, {
172
- template: provenance.template,
173
- cliVersion: currentVersion,
174
- name: provenance.name,
175
- mpoVersion: targetMpoRange,
176
- });
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 });
177
241
 
178
242
  try {
179
243
  // 3. Three-way merge: base → HEAD (ours) vs base → next (theirs).
@@ -203,12 +267,18 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
203
267
  ]);
204
268
  mergedTree = out.split("\n")[0]!.trim();
205
269
  } catch (err) {
206
- // Exit code 1 = conflicts; stdout still carries tree + conflict names.
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.
207
273
  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());
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);
212
282
  }
213
283
 
214
284
  // The merged .mpo.json can interleave ours/theirs lines (add/add
@@ -221,6 +291,47 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
221
291
  mpoVersion: targetMpoRange,
222
292
  };
223
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
+
224
335
  if (!conflicts.length) {
225
336
  const message = `chore: mpo update ${provenance.cliVersion} → ${currentVersion}`;
226
337
  const mergeCommit = await gitOut(projectDir, [
@@ -235,14 +346,21 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
235
346
  ]);
236
347
  await git(projectDir, ["update-ref", "HEAD", mergeCommit]);
237
348
  await git(projectDir, ["reset", "--hard", "HEAD"]);
349
+ // Tool-owned outputs belong to the update commit: provenance + the
350
+ // regenerated .vscode config.
238
351
  writeProvenance(projectDir, finalProvenance);
239
- await git(projectDir, ["add", "--", ".mpo.json"]);
352
+ try {
353
+ await generateVscodeConfig(projectDir);
354
+ } catch {
355
+ // best-effort (older layouts)
356
+ }
357
+ await git(projectDir, ["add", "--", ".mpo.json", ".vscode"]);
240
358
  await git(projectDir, ["commit", "--amend", "--no-edit", "--quiet"]);
241
359
  const amended = await gitOut(projectDir, ["rev-parse", "--short", "HEAD"]);
242
360
  console.log(`\n✅ merged cleanly → ${amended} ("${message}")`);
243
361
  } else {
244
362
  // Land the merged tree (with conflict markers) in index + worktree for
245
- // manual resolution.
363
+ // manual resolution; pins are already part of the tree.
246
364
  await git(projectDir, ["read-tree", "-u", "--reset", mergedTree]);
247
365
  writeProvenance(projectDir, finalProvenance);
248
366
  console.log("\n⚠️ merge conflicts — resolve the markers, then commit:");
@@ -252,23 +370,29 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
252
370
  );
253
371
  }
254
372
 
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) {
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) {
270
376
  console.log("\nInstalling dependencies...");
271
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
+ }
272
396
  }
273
397
  } finally {
274
398
  base.cleanup();
@@ -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
@@ -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". */
@@ -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;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"}
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"}
@@ -1 +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"}
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"}