@multiplatform.one/cli 6.7.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +83 -8
  2. package/lib/bin/multiplatformOne.mjs +21 -7
  3. package/lib/commands/adoptApp.mjs +127 -0
  4. package/lib/commands/init.mjs +1 -1
  5. package/lib/commands/initApp.mjs +43 -15
  6. package/lib/commands/updateApp.mjs +84 -15
  7. package/package.json +2 -2
  8. package/scripts/frappe-app-name.py +125 -0
  9. package/scripts/frappe-app-name.spec.ts +191 -0
  10. package/scripts/frappe-bootstrap.sh +8 -30
  11. package/src/bin/multiplatformOne.ts +78 -13
  12. package/src/commands/adoptApp.spec.ts +208 -0
  13. package/src/commands/adoptApp.ts +185 -0
  14. package/src/commands/initApp.spec.ts +152 -12
  15. package/src/commands/initApp.ts +95 -21
  16. package/src/commands/updateApp.spec.ts +314 -2
  17. package/src/commands/updateApp.ts +164 -33
  18. package/templates/app/apps/__NAME__/package.json +1 -1
  19. package/templates/app/features/__NAME__/package.json +1 -1
  20. package/templates/pieces/gnome/universal/README.md.partial +5 -5
  21. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/anchor.tsx +39 -0
  22. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/main.tsx +4 -2
  23. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/components.ts +26 -0
  24. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/forms.ts +26 -0
  25. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/frappe-ui.ts +17 -0
  26. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/one.ts +3 -0
  27. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/theme.ts +21 -0
  28. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/tamagui-barrel.ts +21 -0
  29. package/templates/pieces/gnome/universal/apps/__NAME__/vite.config.gnome.ts +49 -7
  30. package/templates/pieces/keycloak/universal/README.md.partial +13 -0
  31. package/templates/pieces/keycloak/universal/docker/compose.keycloak.yaml +19 -3
  32. package/templates/pieces/keycloak/universal/env.example.partial +4 -1
  33. package/templates/pieces/vscode/universal/apps/__NAME__/package.json.partial +1 -1
  34. package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +2 -2
  35. package/templates/universal/apps/__NAME__/package.json +2 -2
  36. package/templates/universal/packages/themes/package.json +2 -2
  37. package/types/bin/multiplatformOne.d.ts.map +1 -1
  38. package/types/commands/adoptApp.d.ts +25 -0
  39. package/types/commands/adoptApp.d.ts.map +1 -0
  40. package/types/commands/initApp.d.ts +33 -5
  41. package/types/commands/initApp.d.ts.map +1 -1
  42. package/types/commands/updateApp.d.ts +12 -2
  43. package/types/commands/updateApp.d.ts.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/cli",
3
- "version": "6.7.0",
3
+ "version": "7.0.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.7.0"
64
+ "@multiplatform.one/utils": "7.0.0"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@types/inquirer": "^9.0.9",
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve a frappe app's python MODULE name from its source tree.
3
+
4
+ sites/apps.txt entries, bench app directory names, and `bench --app`
5
+ arguments must be importable python module names (frappe_mcp), while git
6
+ repo basenames and pyproject/setup.py distribution names are often
7
+ hyphenated (frappe-mcp, github.com/frappe/mcp). A hyphenated entry in
8
+ sites/apps.txt breaks every bench boot, so the module name is derived
9
+ from the most authoritative source available:
10
+
11
+ 1. the package directory containing hooks.py (classic frappe app layout)
12
+ 2. the package directory matching the normalized distribution name
13
+ 3. the sole package directory at the app root
14
+ 4. the package directory matching the normalized fallback name
15
+ 5. the normalized distribution name (pyproject [project].name, setup.py name=)
16
+ 6. the normalized fallback (repo/dir basename)
17
+
18
+ Usage: frappe-app-name.py <app-path> [fallback]
19
+ Prints the module name on stdout; exits non-zero if none can be derived.
20
+ """
21
+
22
+ import ast
23
+ import re
24
+ import sys
25
+ from pathlib import Path
26
+
27
+
28
+ def normalize(name: str) -> str:
29
+ """Map a distribution/repo name to module-name form (PEP 503 runs of
30
+ `-_.` and whitespace become single underscores, lowercased)."""
31
+ return re.sub(r"[-_.\s]+", "_", name.strip().lower())
32
+
33
+
34
+ def distribution_name(app_path: Path) -> str:
35
+ pyproject = app_path / "pyproject.toml"
36
+ if pyproject.is_file():
37
+ import tomllib
38
+
39
+ try:
40
+ with pyproject.open("rb") as f:
41
+ name = tomllib.load(f).get("project", {}).get("name", "")
42
+ except (tomllib.TOMLDecodeError, OSError):
43
+ name = ""
44
+ if isinstance(name, str) and name:
45
+ return name
46
+ setup_py = app_path / "setup.py"
47
+ if setup_py.is_file():
48
+ try:
49
+ tree = ast.parse(setup_py.read_text(encoding="utf-8"))
50
+ except (SyntaxError, OSError):
51
+ return ""
52
+ for node in ast.walk(tree):
53
+ if not isinstance(node, ast.Call):
54
+ continue
55
+ for kw in node.keywords:
56
+ if (
57
+ kw.arg == "name"
58
+ and isinstance(kw.value, ast.Constant)
59
+ and isinstance(kw.value.value, str)
60
+ ):
61
+ return kw.value.value
62
+ return ""
63
+
64
+
65
+ def package_dirs(app_path: Path) -> "list[Path]":
66
+ """Direct child directories importable as python packages."""
67
+ try:
68
+ children = sorted(app_path.iterdir())
69
+ except OSError:
70
+ return []
71
+ return [
72
+ child
73
+ for child in children
74
+ if child.is_dir()
75
+ and child.name.isidentifier()
76
+ and (child / "__init__.py").is_file()
77
+ ]
78
+
79
+
80
+ def derive_module_name(app_path: Path, fallback: str = "") -> str:
81
+ dist = distribution_name(app_path)
82
+ packages = package_dirs(app_path)
83
+ hooks = [p for p in packages if (p / "hooks.py").is_file()]
84
+ if hooks:
85
+ if len(hooks) > 1:
86
+ for want in filter(None, (normalize(dist), normalize(fallback))):
87
+ for package in hooks:
88
+ if package.name == want:
89
+ return package.name
90
+ return hooks[0].name
91
+ if dist:
92
+ want = normalize(dist)
93
+ for package in packages:
94
+ if package.name == want:
95
+ return package.name
96
+ if len(packages) == 1:
97
+ return packages[0].name
98
+ if fallback:
99
+ want = normalize(fallback)
100
+ for package in packages:
101
+ if package.name == want:
102
+ return package.name
103
+ if dist:
104
+ return normalize(dist)
105
+ if fallback:
106
+ return normalize(fallback)
107
+ return ""
108
+
109
+
110
+ def main(argv: "list[str]") -> int:
111
+ if len(argv) < 2 or not argv[1]:
112
+ print("usage: frappe-app-name.py <app-path> [fallback]", file=sys.stderr)
113
+ return 2
114
+ app_path = Path(argv[1])
115
+ fallback = argv[2] if len(argv) > 2 else ""
116
+ name = derive_module_name(app_path, fallback)
117
+ if not name:
118
+ print(f"could not determine app name from {app_path}", file=sys.stderr)
119
+ return 1
120
+ print(name)
121
+ return 0
122
+
123
+
124
+ if __name__ == "__main__":
125
+ sys.exit(main(sys.argv))
@@ -0,0 +1,191 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
7
+
8
+ // frappe-bootstrap.sh writes this script's output into sites/apps.txt and
9
+ // uses it for apps/<name> dirs and bench --app args, so it must always be
10
+ // the importable python module name (frappe_mcp), never the hyphenated
11
+ // repo/distribution name (frappe-mcp).
12
+ const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "frappe-app-name.py");
13
+
14
+ function derive(appPath: string, fallback?: string) {
15
+ const result = spawnSync(
16
+ "python3",
17
+ [SCRIPT, appPath, ...(fallback === undefined ? [] : [fallback])],
18
+ { encoding: "utf-8" },
19
+ );
20
+ return {
21
+ status: result.status,
22
+ stdout: result.stdout.trim(),
23
+ stderr: result.stderr.trim(),
24
+ };
25
+ }
26
+
27
+ interface Case {
28
+ name: string;
29
+ /** Relative file path -> contents. Paths ending in "/" create bare dirs. */
30
+ files: Record<string, string>;
31
+ fallback?: string;
32
+ expected: string;
33
+ }
34
+
35
+ const pyproject = (name: string) => `[project]\nname = "${name}"\n`;
36
+
37
+ const cases: Case[] = [
38
+ {
39
+ name: "classic app: hyphenated distribution, underscored module with hooks",
40
+ files: {
41
+ "pyproject.toml": pyproject("frappe-mcp"),
42
+ "frappe_mcp/__init__.py": "",
43
+ "frappe_mcp/hooks.py": 'app_name = "frappe_mcp"\n',
44
+ },
45
+ fallback: "mcp",
46
+ expected: "frappe_mcp",
47
+ },
48
+ {
49
+ name: "matching distribution and module names (plus stray root __init__.py)",
50
+ files: {
51
+ "pyproject.toml": pyproject("core"),
52
+ "__init__.py": "",
53
+ "core/__init__.py": "",
54
+ "core/hooks.py": 'app_name = "core"\n',
55
+ },
56
+ fallback: "core",
57
+ expected: "core",
58
+ },
59
+ {
60
+ name: "hookless package with hyphenated distribution (frappe/mcp shape)",
61
+ files: {
62
+ "pyproject.toml": pyproject("frappe-mcp"),
63
+ "frappe_mcp/__init__.py": "",
64
+ "frappe_mcp/server/__init__.py": "",
65
+ },
66
+ fallback: "mcp",
67
+ expected: "frappe_mcp",
68
+ },
69
+ {
70
+ name: "layout only: hooks package without any metadata",
71
+ files: {
72
+ "my_app/__init__.py": "",
73
+ "my_app/hooks.py": "",
74
+ },
75
+ fallback: "my-app",
76
+ expected: "my_app",
77
+ },
78
+ {
79
+ name: "nested module dirs and decoy packages do not confuse the hooks rule",
80
+ files: {
81
+ "pyproject.toml": pyproject("myapp"),
82
+ "myapp/__init__.py": "",
83
+ "myapp/hooks.py": "",
84
+ "myapp/sub/__init__.py": "",
85
+ "myapp/sub/hooks.py": "",
86
+ "tests/__init__.py": "",
87
+ },
88
+ fallback: "myapp-repo",
89
+ expected: "myapp",
90
+ },
91
+ {
92
+ name: "multiple hookless packages: distribution name picks the module",
93
+ files: {
94
+ "pyproject.toml": pyproject("cool-app"),
95
+ "cool_app/__init__.py": "",
96
+ "tests/__init__.py": "",
97
+ },
98
+ fallback: "cool-app-repo",
99
+ expected: "cool_app",
100
+ },
101
+ {
102
+ name: "multiple hooks packages: distribution name breaks the tie",
103
+ files: {
104
+ "pyproject.toml": pyproject("b-app"),
105
+ "a_app/__init__.py": "",
106
+ "a_app/hooks.py": "",
107
+ "b_app/__init__.py": "",
108
+ "b_app/hooks.py": "",
109
+ },
110
+ expected: "b_app",
111
+ },
112
+ {
113
+ name: "setup.py name is normalized and matched against the layout",
114
+ files: {
115
+ "setup.py": 'from setuptools import setup\nsetup(name="legacy-app")\n',
116
+ "legacy_app/__init__.py": "",
117
+ "tests/__init__.py": "",
118
+ },
119
+ fallback: "legacy",
120
+ expected: "legacy_app",
121
+ },
122
+ {
123
+ name: "metadata only: distribution name is normalized (dots and hyphens)",
124
+ files: {
125
+ "pyproject.toml": pyproject("multiplatform.one-app"),
126
+ },
127
+ fallback: "repo",
128
+ expected: "multiplatform_one_app",
129
+ },
130
+ {
131
+ name: "fallback only: repo basename is normalized",
132
+ files: {},
133
+ fallback: "repo-name",
134
+ expected: "repo_name",
135
+ },
136
+ ];
137
+
138
+ describe("frappe-app-name.py", () => {
139
+ let sandbox: string;
140
+
141
+ beforeAll(() => {
142
+ const python = spawnSync("python3", ["--version"], { encoding: "utf-8" });
143
+ if (python.status !== 0) {
144
+ throw new Error("python3 is required to run the frappe-app-name derivation specs");
145
+ }
146
+ sandbox = mkdtempSync(join(tmpdir(), "mpo-frappe-app-name-"));
147
+ });
148
+
149
+ afterAll(() => {
150
+ rmSync(sandbox, { recursive: true, force: true });
151
+ });
152
+
153
+ function makeApp(name: string, files: Record<string, string>): string {
154
+ const root = join(sandbox, name.replace(/[^a-z0-9]+/gi, "-"));
155
+ mkdirSync(root, { recursive: true });
156
+ for (const [relative, contents] of Object.entries(files)) {
157
+ const target = join(root, relative);
158
+ if (relative.endsWith("/")) {
159
+ mkdirSync(target, { recursive: true });
160
+ continue;
161
+ }
162
+ mkdirSync(dirname(target), { recursive: true });
163
+ writeFileSync(target, contents);
164
+ }
165
+ return root;
166
+ }
167
+
168
+ for (const [index, spec] of cases.entries()) {
169
+ it(spec.name, () => {
170
+ const root = makeApp(`case-${index}`, spec.files);
171
+ const result = derive(root, spec.fallback);
172
+ expect(result.stderr).toBe("");
173
+ expect(result.status).toBe(0);
174
+ expect(result.stdout).toBe(spec.expected);
175
+ });
176
+ }
177
+
178
+ it("fails loudly when nothing can be derived", () => {
179
+ const root = makeApp("case-underivable", {});
180
+ const result = derive(root);
181
+ expect(result.status).toBe(1);
182
+ expect(result.stdout).toBe("");
183
+ expect(result.stderr).toContain("could not determine app name");
184
+ });
185
+
186
+ it("rejects a missing app path argument", () => {
187
+ const result = derive("");
188
+ expect(result.status).toBe(2);
189
+ expect(result.stderr).toContain("usage:");
190
+ });
191
+ });
@@ -22,7 +22,8 @@ _sed() {
22
22
  fi
23
23
  }
24
24
 
25
- . "$(cd "$(dirname "$0")" && pwd)/frappe-helpers.sh"
25
+ FRAPPE_SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)"
26
+ . "$FRAPPE_SCRIPTS_DIR/frappe-helpers.sh"
26
27
 
27
28
  _clean_bench_artifacts() {
28
29
  rm -rf "$BENCH_DIR/apps/frappe" 2>/dev/null || true
@@ -62,36 +63,13 @@ print(" ".join(f"{k}=={v}" if "=" not in v else f"{k}{v}" for k, v in d.items())
62
63
  fi
63
64
  }
64
65
 
66
+ # Resolves the app's python MODULE name (frappe_mcp), never the repo or
67
+ # pyproject distribution name (frappe-mcp): apps.txt entries, apps/ dir
68
+ # names, and bench --app args must all be importable module names -- a
69
+ # hyphenated apps.txt entry breaks every bench boot. Derivation lives in
70
+ # frappe-app-name.py (layout-first, unit-tested).
65
71
  _get_app_name() {
66
- _APP_PATH="$1"
67
- _FALLBACK="$2"
68
- _NAME=""
69
- if [ -f "$_APP_PATH/pyproject.toml" ]; then
70
- _NAME="$(python3 -c '
71
- import tomllib, sys
72
- with open(sys.argv[1], "rb") as f:
73
- print(tomllib.load(f).get("project", {}).get("name", ""))
74
- ' "$_APP_PATH/pyproject.toml")"
75
- fi
76
- if [ -z "$_NAME" ] && [ -f "$_APP_PATH/setup.py" ]; then
77
- _NAME="$(python3 -c '
78
- import ast, sys
79
- tree = ast.parse(open(sys.argv[1]).read())
80
- for node in ast.walk(tree):
81
- if isinstance(node, ast.Call) and any(
82
- kw.arg == "name" and isinstance(kw.value, ast.Constant)
83
- for kw in node.keywords
84
- ):
85
- print(next(kw.value.value for kw in node.keywords if kw.arg == "name"))
86
- break
87
- ' "$_APP_PATH/setup.py" 2>/dev/null || true)"
88
- fi
89
- _NAME="${_NAME:-$_FALLBACK}"
90
- if [ -z "$_NAME" ]; then
91
- echo "could not determine app name from $_APP_PATH" >&2
92
- return 1
93
- fi
94
- echo "$_NAME"
72
+ python3 "$FRAPPE_SCRIPTS_DIR/frappe-app-name.py" "$1" "$2"
95
73
  }
96
74
 
97
75
  _register_app() {
@@ -17,6 +17,7 @@ import dotenv from "dotenv";
17
17
  import spawn from "nano-spawn";
18
18
  import YAML from "yaml";
19
19
  import yoctoSpinner from "yocto-spinner";
20
+ import { adoptApp } from "../commands/adoptApp";
20
21
  import { discoverE2EApp, runE2ESession } from "../commands/e2e";
21
22
  import { init, runModifyStep } from "../commands/init";
22
23
  import { INIT_APP_PIECES, initApp, readProvenance } from "../commands/initApp";
@@ -227,9 +228,16 @@ program
227
228
  "--gnome",
228
229
  "add the GNOME desktop target piece (GTK4/GJS via react-gnome — native widgets, no webview)",
229
230
  )
230
- .option("--tauri", "add the desktop target piece (src-tauri skeleton, webview)")
231
+ .option(
232
+ "--tauri",
233
+ "add the Tauri desktop target piece (src-tauri webview shell — Rust; combines with --gnome)",
234
+ )
231
235
  .option("--vscode", "add the VS Code extension target piece")
232
236
  .option("--webext", "add the browser extension target piece (MV3 popup + background)")
237
+ .option(
238
+ "--pieces-only",
239
+ "scaffold ONLY the selected piece fragments onto an empty tree (no base template) — used by `mpo update` to rebuild adopted-project merge baselines",
240
+ )
233
241
  .option("--skip-install", "skip pnpm install after scaffolding")
234
242
  .option("--skip-git", "skip git init + scaffold commit")
235
243
  .option(
@@ -278,6 +286,9 @@ program
278
286
  if (webOnly && options.universal) {
279
287
  throw new Error("Pass either --web/--app or --universal, not both");
280
288
  }
289
+ if (options.piecesOnly && (webOnly || options.universal)) {
290
+ throw new Error("--pieces-only scaffolds no base template — drop --web/--universal");
291
+ }
281
292
  // Piece flags are multi-select; passing any of them skips the interactive
282
293
  // pieces prompt. No flags + --yes (or non-TTY) → no pieces.
283
294
  const pieces = INIT_APP_PIECES.filter((piece) => Boolean(options[piece]));
@@ -285,12 +296,43 @@ program
285
296
  skipInstall: Boolean(options.skipInstall),
286
297
  skipGit: Boolean(options.skipGit),
287
298
  version: options.mpoVersion,
288
- template: webOnly ? "app" : options.universal ? "universal" : undefined,
299
+ template: options.piecesOnly
300
+ ? "none"
301
+ : webOnly
302
+ ? "app"
303
+ : options.universal
304
+ ? "universal"
305
+ : undefined,
289
306
  yes: Boolean(options.yes),
290
307
  pieces: pieces.length ? pieces : undefined,
291
308
  });
292
309
  });
293
310
 
311
+ program
312
+ .command("adopt")
313
+ .option("--frappe", "the project adopted the Frappe backend piece")
314
+ .option("--keycloak", "the project adopted the Keycloak auth piece")
315
+ .option("--gnome", "the project adopted the GNOME desktop target piece")
316
+ .option("--tauri", "the project adopted the Tauri desktop target piece")
317
+ .option("--vscode", "the project adopted the VS Code extension target piece")
318
+ .option("--webext", "the project adopted the browser extension target piece")
319
+ .option(
320
+ "--name <name>",
321
+ "project name recorded in provenance (default: package.json name, scope stripped) — it renders piece paths (apps/<name>/…), so match your app directory",
322
+ )
323
+ .option("-y, --yes", "non-interactive: never prompt (pieces must come from flags)")
324
+ .description(
325
+ "bring an existing, never-scaffolded project under `mpo update` management: writes .mpo.json provenance (and seeds .updateignore) without changing any project file — the FIRST `mpo update` afterwards reconciles piece files (real diffs expected)",
326
+ )
327
+ .action(async (options) => {
328
+ const pieces = INIT_APP_PIECES.filter((piece) => Boolean(options[piece]));
329
+ await adoptApp({
330
+ pieces: pieces.length ? pieces : undefined,
331
+ name: options.name,
332
+ yes: Boolean(options.yes),
333
+ });
334
+ });
335
+
294
336
  const defaultUpdateRemote = "https://gitlab.com/bitspur/frappe/multiplatform.one.git";
295
337
 
296
338
  program
@@ -306,8 +348,16 @@ program
306
348
  "--mpo-version <range>",
307
349
  "semver range for @multiplatform.one/* (default: ^<cli version>)",
308
350
  )
351
+ .option(
352
+ "--assume-version <cliVersion>",
353
+ "bootstrap missing .mpo.json: assume the project was scaffolded by this exact CLI version, record provenance, then update",
354
+ )
355
+ .option(
356
+ "--template <template>",
357
+ "template recorded by --assume-version (universal|app, default universal)",
358
+ )
309
359
  .description(
310
- "update a scaffolded project to the current template (three-way merge via .mpo.json provenance); monorepo forks fall back to the upstream merge flow",
360
+ "update a scaffolded or adopted project to the current template (three-way merge via .mpo.json provenance); monorepo forks fall back to the upstream merge flow",
311
361
  )
312
362
  .action(
313
363
  async (options: {
@@ -315,6 +365,8 @@ program
315
365
  remote: string;
316
366
  skipInstall?: boolean;
317
367
  mpoVersion?: string;
368
+ assumeVersion?: string;
369
+ template?: string;
318
370
  }) => {
319
371
  // Update prechecks: must be in a git repo
320
372
  if (
@@ -325,13 +377,25 @@ program
325
377
  ) {
326
378
  throw new Error("mpo cannot be updated outside of a git repository");
327
379
  }
380
+ if (options.template && !options.assumeVersion) {
381
+ throw new Error("--template only applies to the --assume-version bootstrap");
382
+ }
383
+ if (options.template && options.template !== "universal" && options.template !== "app") {
384
+ throw new Error(`--template must be universal or app, got "${options.template}"`);
385
+ }
328
386
  // Scaffolded consumer projects carry .mpo.json provenance — use the
329
- // copier-style three-way template update. Monorepo forks (identified by
330
- // the legacy features/package.json marker) fall through to the upstream-
331
- // merge flow below; anything else gets the provenance guidance instead
332
- // of the fork flow's unrelated errors.
333
- if (readProvenance(projectRoot)) {
334
- await updateApp({ skipInstall: options.skipInstall, version: options.mpoVersion });
387
+ // copier-style three-way template update (--assume-version bootstraps
388
+ // the provenance for pre-6.3 scaffolds first). Monorepo forks
389
+ // (identified by the legacy features/package.json marker) fall through
390
+ // to the upstream-merge flow below; anything else gets the provenance
391
+ // guidance instead of the fork flow's unrelated errors.
392
+ if (readProvenance(projectRoot) || options.assumeVersion) {
393
+ await updateApp({
394
+ skipInstall: options.skipInstall,
395
+ version: options.mpoVersion,
396
+ assumeVersion: options.assumeVersion,
397
+ assumeTemplate: options.template as "universal" | "app" | undefined,
398
+ });
335
399
  return;
336
400
  }
337
401
  const legacyFork = await fs.stat(path.resolve(projectRoot, "features/package.json")).then(
@@ -340,10 +404,11 @@ program
340
404
  );
341
405
  if (!legacyFork) {
342
406
  throw new Error(
343
- ".mpo.json not found — this project predates scaffold provenance. " +
344
- "Create it with { template, cliVersion, name } matching how the project " +
345
- "was generated (cliVersion = the @multiplatform.one/cli that scaffolded it), " +
346
- "then re-run `mpo update`.",
407
+ ".mpo.json not found — this project predates scaffold provenance.\n" +
408
+ " - scaffolded by an old CLI (pre-6.3)? bootstrap it:\n" +
409
+ " mpo update --assume-version <cliVersion> [--template universal|app]\n" +
410
+ " - never scaffolded (hand-adopted pieces)? bring it under management:\n" +
411
+ " mpo adopt --<piece> [...]",
347
412
  );
348
413
  }
349
414
  // Require clean working tree (no staged or unstaged changes)