@multiplatform.one/cli 6.6.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 (47) 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 +3 -3
  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 +3 -2
  19. package/templates/app/features/__NAME__/package.json +2 -2
  20. package/templates/app/packages/config/package.json +1 -1
  21. package/templates/pieces/gnome/universal/README.md.partial +5 -5
  22. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/anchor.tsx +39 -0
  23. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/main.tsx +4 -2
  24. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/components.ts +26 -0
  25. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/forms.ts +26 -0
  26. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/frappe-ui.ts +17 -0
  27. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/one.ts +3 -0
  28. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/theme.ts +21 -0
  29. package/templates/pieces/gnome/universal/apps/__NAME__/gnome/tamagui-barrel.ts +21 -0
  30. package/templates/pieces/gnome/universal/apps/__NAME__/vite.config.gnome.ts +49 -7
  31. package/templates/pieces/keycloak/universal/README.md.partial +13 -0
  32. package/templates/pieces/keycloak/universal/docker/compose.keycloak.yaml +19 -3
  33. package/templates/pieces/keycloak/universal/env.example.partial +4 -1
  34. package/templates/pieces/vscode/universal/apps/__NAME__/package.json.partial +1 -1
  35. package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +2 -2
  36. package/templates/universal/apps/__NAME__/package.json +4 -3
  37. package/templates/universal/features/__NAME__/package.json +1 -1
  38. package/templates/universal/packages/config/package.json +1 -1
  39. package/templates/universal/packages/i18n/package.json +1 -1
  40. package/templates/universal/packages/themes/package.json +3 -3
  41. package/types/bin/multiplatformOne.d.ts.map +1 -1
  42. package/types/commands/adoptApp.d.ts +25 -0
  43. package/types/commands/adoptApp.d.ts.map +1 -0
  44. package/types/commands/initApp.d.ts +33 -5
  45. package/types/commands/initApp.d.ts.map +1 -1
  46. package/types/commands/updateApp.d.ts +12 -2
  47. package/types/commands/updateApp.d.ts.map +1 -1
package/README.md CHANGED
@@ -52,23 +52,98 @@ the interactive prompt; they are recorded in `.mpo.json` and reproduced by
52
52
  | `--frappe` | Frappe backend wiring — env keys, provider, dev bench compose | Docker |
53
53
  | `--keycloak` | Keycloak auth — env keys, provider, compose + realm import | Docker |
54
54
  | `--gnome` | GNOME desktop: **real GTK4 widgets** via react-gnome, no webview | gjs, gtk4, libadwaita |
55
- | `--tauri` | Desktop: the web build in a system webview | Rust toolchain |
56
55
  | `--vscode` | VS Code extension — extension host + Tamagui webview | — |
57
56
  | `--webext` | Browser extension — MV3 popup + background | — |
58
57
 
59
- The two desktop pieces are different things, not alternatives to benchmark
60
- against each other. `--tauri` wraps the same web output you already ship and
61
- gives you Linux, macOS and Windows bundles. `--gnome` renders your Tamagui
62
- components as native GTK widgets laid out by Yoga — a genuinely different
63
- runtime, Linux-first, and the one to pick when the app should look and feel
64
- like part of the desktop rather than like a web page in a frame.
58
+ `--gnome` is the desktop piece. It renders your Tamagui components as native
59
+ GTK widgets laid out by Yoga — not the web build in a frame — so the app looks
60
+ and behaves like part of the desktop. Linux-first.
61
+
62
+ ## Update a project
63
+
64
+ `mpo update` upgrades a project to the current templates with a three-way
65
+ merge driven by `.mpo.json` (written at init time): base = the original
66
+ scaffold regenerated at the recorded CLI version, ours = your HEAD, theirs =
67
+ a fresh scaffold from the current CLI. `.updateignore` entries (exact files or directory prefixes — not globs) (and
68
+ `pnpm-lock.yaml`) stay pinned to your version; `package.json` dependency-range
69
+ drift auto-resolves to the newer minimum.
70
+
71
+ **Idempotency contract:** re-running `mpo update` immediately after a
72
+ successful update makes zero changes (clean `git status`). Run it, see no
73
+ changes, know you're current.
74
+
75
+ ### Adopt an existing project
76
+
77
+ A project that was never scaffolded — it hand-copied a delivery target
78
+ (webext, gnome, …) and uses `@multiplatform.one/*` from npm — has no
79
+ provenance. Bring it under `mpo update` management:
80
+
81
+ ```bash
82
+ mpo adopt --webext --name my-app # requires a clean git tree; ≥ 1 piece
83
+ ```
84
+
85
+ Adoption writes `.mpo.json` (template `"none"` + pieces) and seeds
86
+ `.updateignore`; **it changes no project files**. The FIRST `mpo update`
87
+ afterwards reconciles the piece files from an empty merge base, so diffs and
88
+ conflicts on hand-copied files are expected — resolve and commit. Later
89
+ updates are ordinary three-way merges (baselines come from
90
+ `mpo init --pieces-only` at the recorded version).
91
+
92
+ ### Bootstrap missing provenance
93
+
94
+ Scaffolded before `.mpo.json` existed (CLI ≤ 6.3.0)? Assert the version it
95
+ was scaffolded with instead of hand-writing the file:
96
+
97
+ ```bash
98
+ mpo update --assume-version 6.3.0 [--template universal|app]
99
+ ```
100
+
101
+ ## Update a project
102
+
103
+ `mpo update` upgrades a project to the current templates with a three-way
104
+ merge driven by `.mpo.json` (written at init time): base = the original
105
+ scaffold regenerated at the recorded CLI version, ours = your HEAD, theirs =
106
+ a fresh scaffold from the current CLI. `.updateignore` entries (exact files or directory prefixes — not globs) (and
107
+ `pnpm-lock.yaml`) stay pinned to your version; `package.json` dependency-range
108
+ drift auto-resolves to the newer minimum.
109
+
110
+ **Idempotency contract:** re-running `mpo update` immediately after a
111
+ successful update makes zero changes (clean `git status`). Run it, see no
112
+ changes, know you're current.
113
+
114
+ ### Adopt an existing project
115
+
116
+ A project that was never scaffolded — it hand-copied a delivery target
117
+ (webext, tauri, …) and uses `@multiplatform.one/*` from npm — has no
118
+ provenance. Bring it under `mpo update` management:
119
+
120
+ ```bash
121
+ mpo adopt --webext --name my-app # requires a clean git tree; ≥ 1 piece
122
+ ```
123
+
124
+ Adoption writes `.mpo.json` (template `"none"` + pieces) and seeds
125
+ `.updateignore`; **it changes no project files**. The FIRST `mpo update`
126
+ afterwards reconciles the piece files from an empty merge base, so diffs and
127
+ conflicts on hand-copied files are expected — resolve and commit. Later
128
+ updates are ordinary three-way merges (baselines come from
129
+ `mpo init --pieces-only` at the recorded version).
130
+
131
+ ### Bootstrap missing provenance
132
+
133
+ Scaffolded before `.mpo.json` existed (CLI ≤ 6.3.0)? Assert the version it
134
+ was scaffolded with instead of hand-writing the file:
135
+
136
+ ```bash
137
+ mpo update --assume-version 6.3.0 [--template universal|app]
138
+ ```
65
139
 
66
140
  ## Other commands
67
141
 
68
142
  | Command | Purpose |
69
143
  | ---------------------------- | -------------------------------------------------------------------- |
70
144
  | `mpo init --monorepo [name]` | DEPRECATED: clone/modify the full monorepo (framework dev forks) |
71
- | `mpo update` | Merge upstream and re-apply workspace config |
145
+ | `mpo adopt` | Write provenance into an existing, never-scaffolded project |
146
+ | `mpo update` | Three-way template update via `.mpo.json` (legacy merge for forks) |
72
147
  | `mpo wait` | Wait for a service (frappe, postgres, mariadb, keycloak) to be ready |
73
148
  | `mpo frappe bootstrap` | Bootstrap the Frappe development environment |
74
149
  | `mpo frappe dev` | Start the Frappe development server |
@@ -1,7 +1,8 @@
1
1
  import { generateVscodeConfig } from "../generateVscode.mjs";
2
+ import { INIT_APP_PIECES, initApp, readProvenance } from "../commands/initApp.mjs";
3
+ import { adoptApp } from "../commands/adoptApp.mjs";
2
4
  import { discoverE2EApp, runE2ESession } from "../commands/e2e.mjs";
3
5
  import { init, runModifyStep } from "../commands/init.mjs";
4
- import { INIT_APP_PIECES, initApp, readProvenance } from "../commands/initApp.mjs";
5
6
  import { updateApp } from "../commands/updateApp.mjs";
6
7
  import { createRequire } from "node:module";
7
8
  import fs from "node:fs/promises";
@@ -471,7 +472,7 @@ async function getFrappeApps(root) {
471
472
  const program = new Command();
472
473
  program.name("mpo");
473
474
  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("--frappe", "add the Frappe backend piece (env keys, provider, docker compose bench)").option("--keycloak", "add the Keycloak auth piece (env keys, provider, docker compose + realm)").option("--gnome", "add the GNOME desktop target piece (GTK4/GJS via react-gnome — native widgets, no webview)").option("--tauri", "add the desktop target piece (src-tauri skeleton, webview)").option("--vscode", "add the VS Code extension target piece").option("--webext", "add the browser extension target piece (MV3 popup + background)").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
+ 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("--frappe", "add the Frappe backend piece (env keys, provider, docker compose bench)").option("--keycloak", "add the Keycloak auth piece (env keys, provider, docker compose + realm)").option("--gnome", "add the GNOME desktop target piece (GTK4/GJS via react-gnome — native widgets, no webview)").option("--tauri", "add the Tauri desktop target piece (src-tauri webview shell — Rust; combines with --gnome)").option("--vscode", "add the VS Code extension target piece").option("--webext", "add the browser extension target piece (MV3 popup + background)").option("--pieces-only", "scaffold ONLY the selected piece fragments onto an empty tree (no base template) — used by `mpo update` to rebuild adopted-project merge baselines").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
476
  if (options.monorepo) {
476
477
  console.warn("⚠️ `mpo init --monorepo` clones the whole multiplatform.one monorepo (legacy). New projects should use the default consumer scaffold instead.");
477
478
  if (await spawn("git", ["rev-parse", "--is-inside-work-tree"]).then(() => true, () => false)) throw new Error("mpo cannot be initialized inside a git repository");
@@ -485,26 +486,39 @@ program.command("init").option("--universal", "scaffold the universal template (
485
486
  if (options.apps || options.services) throw new Error("--apps/--services select monorepo pieces and require --monorepo (legacy clone mode)");
486
487
  const webOnly = Boolean(options.web || options.app);
487
488
  if (webOnly && options.universal) throw new Error("Pass either --web/--app or --universal, not both");
489
+ if (options.piecesOnly && (webOnly || options.universal)) throw new Error("--pieces-only scaffolds no base template — drop --web/--universal");
488
490
  const pieces = INIT_APP_PIECES.filter((piece) => Boolean(options[piece]));
489
491
  await initApp(name, {
490
492
  skipInstall: Boolean(options.skipInstall),
491
493
  skipGit: Boolean(options.skipGit),
492
494
  version: options.mpoVersion,
493
- template: webOnly ? "app" : options.universal ? "universal" : void 0,
495
+ template: options.piecesOnly ? "none" : webOnly ? "app" : options.universal ? "universal" : void 0,
494
496
  yes: Boolean(options.yes),
495
497
  pieces: pieces.length ? pieces : void 0
496
498
  });
497
499
  });
498
- 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/frappe/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) => {
500
+ program.command("adopt").option("--frappe", "the project adopted the Frappe backend piece").option("--keycloak", "the project adopted the Keycloak auth piece").option("--gnome", "the project adopted the GNOME desktop target piece").option("--tauri", "the project adopted the Tauri desktop target piece").option("--vscode", "the project adopted the VS Code extension target piece").option("--webext", "the project adopted the browser extension target piece").option("--name <name>", "project name recorded in provenance (default: package.json name, scope stripped) — it renders piece paths (apps/<name>/…), so match your app directory").option("-y, --yes", "non-interactive: never prompt (pieces must come from flags)").description("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)").action(async (options) => {
501
+ const pieces = INIT_APP_PIECES.filter((piece) => Boolean(options[piece]));
502
+ await adoptApp({
503
+ pieces: pieces.length ? pieces : void 0,
504
+ name: options.name,
505
+ yes: Boolean(options.yes)
506
+ });
507
+ });
508
+ 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/frappe/multiplatform.one.git").option("--skip-install", "skip pnpm install after the update").option("--mpo-version <range>", "semver range for @multiplatform.one/* (default: ^<cli version>)").option("--assume-version <cliVersion>", "bootstrap missing .mpo.json: assume the project was scaffolded by this exact CLI version, record provenance, then update").option("--template <template>", "template recorded by --assume-version (universal|app, default universal)").description("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").action(async (options) => {
499
509
  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");
500
- if (readProvenance(projectRoot)) {
510
+ if (options.template && !options.assumeVersion) throw new Error("--template only applies to the --assume-version bootstrap");
511
+ if (options.template && options.template !== "universal" && options.template !== "app") throw new Error(`--template must be universal or app, got "${options.template}"`);
512
+ if (readProvenance(projectRoot) || options.assumeVersion) {
501
513
  await updateApp({
502
514
  skipInstall: options.skipInstall,
503
- version: options.mpoVersion
515
+ version: options.mpoVersion,
516
+ assumeVersion: options.assumeVersion,
517
+ assumeTemplate: options.template
504
518
  });
505
519
  return;
506
520
  }
507
- 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`.");
521
+ 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.\n - scaffolded by an old CLI (pre-6.3)? bootstrap it:\n mpo update --assume-version <cliVersion> [--template universal|app]\n - never scaffolded (hand-adopted pieces)? bring it under management:\n mpo adopt --<piece> [...]");
508
522
  if (await spawn("git", [
509
523
  "diff",
510
524
  "--cached",
@@ -0,0 +1,127 @@
1
+ import { INIT_APP_PIECES, PROVENANCE_FILE, availablePieces, cliVersion, normalizePieces, packageJsonName, pieceDescription, validateName, writeProvenance } from "./initApp.mjs";
2
+ import { join } from "node:path";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import spawn from "nano-spawn";
5
+ import inquirer from "inquirer";
6
+
7
+ //#region src/commands/adoptApp.ts
8
+ /**
9
+ * `mpo adopt` — bring an EXISTING, never-scaffolded project under `mpo
10
+ * update` management. Projects like this hand-copied one or more delivery
11
+ * targets (webext, gnome, …) from the mpo templates and consume
12
+ * @multiplatform.one/* from npm, but have no .mpo.json provenance, so
13
+ * `mpo update` refuses to run.
14
+ *
15
+ * Adoption writes provenance ONLY (template "none" + the chosen pieces +
16
+ * the current CLI version) and seeds .updateignore — it never rewrites a
17
+ * project file. The FIRST `mpo update` afterwards reconciles the piece
18
+ * files against the piece templates from an empty merge base, so real
19
+ * diffs (and conflicts) against hand-copied files are expected there;
20
+ * every later update is an ordinary three-way merge.
21
+ */
22
+ const UPDATEIGNORE_FILE = ".updateignore";
23
+ const UPDATEIGNORE_SEED = `# .updateignore — \`mpo update\` never rewrites paths listed here.
24
+ # One git pathspec per line; matching paths stay pinned to YOUR version
25
+ # during template updates. Lines starting with "#" are comments.
26
+ `;
27
+ async function git(projectDir, args) {
28
+ return spawn("git", args, { cwd: projectDir });
29
+ }
30
+ /**
31
+ * The multiplatform.one monorepo (and its forks) is the home of the
32
+ * templates themselves — adopting it into consumer-project update
33
+ * management would be circular. It is identified by the CLI package
34
+ * living at public/cli.
35
+ */
36
+ function isMpoMonorepo(projectDir) {
37
+ try {
38
+ return JSON.parse(readFileSync(join(projectDir, "public/cli/package.json"), "utf-8")).name === "@multiplatform.one/cli";
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+ async function resolveAdoptPieces(options) {
44
+ if (options.pieces?.length) return normalizePieces(options.pieces, "none");
45
+ if (!options.yes && process.stdout.isTTY) return normalizePieces((await inquirer.prompt([{
46
+ message: "Which pieces did this project adopt? (space to toggle, enter to confirm)",
47
+ name: "pieces",
48
+ type: "checkbox",
49
+ choices: availablePieces("none").map((piece) => ({
50
+ name: `${piece} — ${pieceDescription(piece)}`,
51
+ value: piece,
52
+ checked: false
53
+ })),
54
+ validate: (selection) => selection.length > 0 || "Select at least one piece — adopting nothing is meaningless"
55
+ }])).pieces, "none");
56
+ throw new Error(`mpo adopt needs at least one piece (${INIT_APP_PIECES.map((piece) => `--${piece}`).join(", ")}) — adopting nothing is meaningless`);
57
+ }
58
+ async function adoptApp(options = {}) {
59
+ const projectDir = await spawn("git", ["rev-parse", "--show-toplevel"], { cwd: process.cwd() }).then((result) => result.stdout.trim(), () => {
60
+ throw new Error("mpo adopt must run inside a git repository");
61
+ });
62
+ if (isMpoMonorepo(projectDir)) throw new Error("mpo adopt cannot run inside the multiplatform.one monorepo (or a fork of it) — it is for consumer projects that use @multiplatform.one/* from npm");
63
+ if (existsSync(join(projectDir, ".mpo.json"))) throw new Error(`${PROVENANCE_FILE} already exists — this project already has scaffold provenance. Run \`mpo update\` directly.`);
64
+ await git(projectDir, ["rev-parse", "HEAD"]).catch(() => {
65
+ throw new Error("mpo adopt requires at least one commit — commit your project first");
66
+ });
67
+ await git(projectDir, ["diff", "--quiet"]).catch(() => {
68
+ throw new Error("mpo adopt requires a clean working tree (unstaged changes present)");
69
+ });
70
+ await git(projectDir, [
71
+ "diff",
72
+ "--cached",
73
+ "--quiet"
74
+ ]).catch(() => {
75
+ throw new Error("mpo adopt requires a clean working tree (staged changes present)");
76
+ });
77
+ const rawName = options.name ?? packageJsonName(projectDir);
78
+ if (!rawName) throw new Error("Could not derive a project name from package.json — pass one with --name <name>");
79
+ let name;
80
+ try {
81
+ name = validateName(rawName);
82
+ } catch (err) {
83
+ throw new Error(`${err.message} The name renders piece file paths (apps/<name>/…), so pick the one matching your app directory: --name <name>.`);
84
+ }
85
+ const pieces = await resolveAdoptPieces(options);
86
+ const currentVersion = cliVersion() ?? "0.0.0";
87
+ writeProvenance(projectDir, {
88
+ template: "none",
89
+ cliVersion: currentVersion,
90
+ name,
91
+ mpoVersion: `^${currentVersion}`,
92
+ pieces,
93
+ reconciled: false
94
+ });
95
+ const files = [PROVENANCE_FILE];
96
+ if (!existsSync(join(projectDir, UPDATEIGNORE_FILE))) {
97
+ writeFileSync(join(projectDir, UPDATEIGNORE_FILE), UPDATEIGNORE_SEED);
98
+ files.push(UPDATEIGNORE_FILE);
99
+ }
100
+ await git(projectDir, [
101
+ "add",
102
+ "--",
103
+ ...files
104
+ ]);
105
+ const committed = await git(projectDir, [
106
+ "commit",
107
+ "--quiet",
108
+ "-m",
109
+ "chore: adopt multiplatform.one tooling (mpo adopt)"
110
+ ]).then(() => true, () => {
111
+ console.warn("⚠️ git commit failed (missing git identity?) — provenance left staged.");
112
+ return false;
113
+ });
114
+ console.log(`\n✅ adopted ${name} (pieces: ${pieces.join(", ")}, cli ${currentVersion})`);
115
+ console.log(` ${files.join(" + ")} ${committed ? "committed" : "written"} — NO project files`);
116
+ console.log(" were changed by adoption itself.\n");
117
+ console.log("Next: run `mpo update` to reconcile the piece files.");
118
+ console.log(" THE FIRST UPDATE SURFACES REAL DIFFS: your hand-copied piece files");
119
+ console.log(" are merged against the piece templates from scratch, so conflicts");
120
+ console.log(" there are expected — resolve the markers and commit. Later updates");
121
+ console.log(" are ordinary three-way merges, and re-running `mpo update` right");
122
+ console.log(" after a successful update makes zero changes.");
123
+ console.log(`Pin anything the updater must never touch in ${UPDATEIGNORE_FILE}.`);
124
+ }
125
+
126
+ //#endregion
127
+ export { adoptApp };
@@ -1,7 +1,7 @@
1
1
  import { join, resolve } from "node:path";
2
2
  import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
- import { execSync } from "node:child_process";
4
3
  import inquirer from "inquirer";
4
+ import { execSync } from "node:child_process";
5
5
 
6
6
  //#region src/commands/init.ts
7
7
  const repoUrl = "https://gitlab.com/bitspur/frappe/multiplatform.one.git";
@@ -23,7 +23,7 @@ const PIECE_DESCRIPTIONS = {
23
23
  frappe: "Frappe backend wiring (env keys, provider, docker compose bench)",
24
24
  gnome: "GNOME desktop target (GTK4/GJS via react-gnome — native widgets, no webview)",
25
25
  keycloak: "Keycloak auth wiring (env keys, provider, docker compose + realm)",
26
- tauri: "Desktop target (src-tauri skeleton, tauri scripts)",
26
+ tauri: "Tauri desktop target (src-tauri skeleton — webview shell, Rust toolchain)",
27
27
  vscode: "VS Code extension target (extension host + Tamagui webview)",
28
28
  webext: "Browser extension target (MV3 popup + background)"
29
29
  };
@@ -36,6 +36,15 @@ function validateName(name) {
36
36
  if (!/^[a-z][a-z0-9-]*$/.test(trimmed)) throw new Error(`Invalid project name "${trimmed}". Use lowercase letters, numbers, and hyphens (e.g. my-app).`);
37
37
  return trimmed;
38
38
  }
39
+ /** Root package.json name with any @scope/ prefix stripped (adopt +
40
+ * update-bootstrap provenance name derivation). */
41
+ function packageJsonName(projectDir) {
42
+ try {
43
+ return JSON.parse(readFileSync(join(projectDir, "package.json"), "utf-8")).name?.replace(/^@[^/]+\//u, "");
44
+ } catch {
45
+ return;
46
+ }
47
+ }
39
48
  function resolveMpoVersion(explicit) {
40
49
  if (explicit) return explicit.startsWith("^") || explicit.startsWith("~") || explicit === "*" ? explicit : `^${explicit}`;
41
50
  const version = cliVersion();
@@ -67,7 +76,8 @@ function readProvenance(projectDir) {
67
76
  cliVersion: raw.cliVersion,
68
77
  name: raw.name,
69
78
  mpoVersion: raw.mpoVersion ?? `^${raw.cliVersion}`,
70
- pieces: Array.isArray(raw.pieces) ? [...raw.pieces].sort() : []
79
+ pieces: Array.isArray(raw.pieces) ? [...raw.pieces].sort() : [],
80
+ ...raw.reconciled === false ? { reconciled: false } : {}
71
81
  };
72
82
  } catch {
73
83
  return;
@@ -82,10 +92,13 @@ function piecesRoot(piece) {
82
92
  /**
83
93
  * Fragment source dirs for a piece + template: shared/ applies to every
84
94
  * template, <template>/ only to that one. A piece supports a template when
85
- * at least one of the two exists.
95
+ * at least one of the two exists. Adopted projects (template "none") use the
96
+ * universal fragment set — pieces ship universal-layout fragments
97
+ * (apps/<name>/…), which is the layout adopted repos follow.
86
98
  */
87
99
  function pieceFragmentDirs(piece, template) {
88
- return [join(piecesRoot(piece), "shared"), join(piecesRoot(piece), template)].filter((dir) => existsSync(dir));
100
+ const effective = template === "none" ? "universal" : template;
101
+ return [join(piecesRoot(piece), "shared"), join(piecesRoot(piece), effective)].filter((dir) => existsSync(dir));
89
102
  }
90
103
  /**
91
104
  * npm always strips .gitignore / .npmrc (and newer npms .env*) from published
@@ -246,6 +259,10 @@ function normalizePieces(pieces, template) {
246
259
  function availablePieces(template) {
247
260
  return INIT_APP_PIECES.filter((piece) => pieceFragmentDirs(piece, template).length > 0);
248
261
  }
262
+ /** Human-readable piece summaries (adopt + init prompts). */
263
+ function pieceDescription(piece) {
264
+ return PIECE_DESCRIPTIONS[piece];
265
+ }
249
266
  async function resolvePieces(options, template) {
250
267
  if (options.pieces) return normalizePieces(options.pieces, template);
251
268
  if (options.yes || !process.stdout.isTTY) return [];
@@ -315,6 +332,7 @@ async function initApp(nameArg, options = {}) {
315
332
  const name = validateName(projectName);
316
333
  const template = await resolveTemplate(options);
317
334
  const pieces = await resolvePieces(options, template);
335
+ if (template === "none" && pieces.length === 0) throw new Error(`A pieces-only scaffold needs at least one piece (${INIT_APP_PIECES.map((piece) => `--${piece}`).join(", ")})`);
318
336
  const targetDir = resolve(name);
319
337
  if (existsSync(targetDir)) {
320
338
  if (readdirSync(targetDir).length > 0) throw new Error(`Directory "${targetDir}" already exists and is not empty`);
@@ -326,19 +344,25 @@ async function initApp(nameArg, options = {}) {
326
344
  MPO_VERSION: resolveMpoVersion(options.version),
327
345
  YEAR: String((/* @__PURE__ */ new Date()).getFullYear())
328
346
  };
329
- console.log(`\nScaffolding ${template === "universal" ? "universal (web + iOS + Android)" : "web-only"} project at ${targetDir}`);
330
- console.log(` apps/${name}`);
331
- console.log(` features/${name}`);
332
- console.log(` packages/config`);
347
+ console.log(`\nScaffolding ${template === "universal" ? "universal (web + iOS + Android)" : template === "app" ? "web-only" : "pieces-only (adopted-project baseline)"} project at ${targetDir}`);
348
+ if (template !== "none") {
349
+ console.log(` apps/${name}`);
350
+ console.log(` features/${name}`);
351
+ console.log(` packages/config`);
352
+ }
333
353
  if (template === "universal") {
334
354
  console.log(` packages/i18n`);
335
355
  console.log(` packages/themes`);
336
356
  }
337
357
  if (pieces.length) console.log(` pieces: ${pieces.join(", ")}`);
338
358
  console.log(` @multiplatform.one/* → ${vars.MPO_VERSION}\n`);
339
- writeTree(templatesRoot(template), targetDir, vars);
359
+ if (template === "none") mkdirSync(targetDir, { recursive: true });
360
+ else writeTree(templatesRoot(template), targetDir, vars);
340
361
  for (const piece of pieces) for (const fragmentDir of pieceFragmentDirs(piece, template)) overlayTree(fragmentDir, targetDir, vars);
341
- if (pieces.includes("frappe") || pieces.includes("keycloak")) writeFileSync(join(targetDir, "apps", name, "src/app.ts"), composeAppTs(pieces));
362
+ if (pieces.includes("frappe") || pieces.includes("keycloak")) {
363
+ mkdirSync(join(targetDir, "apps", name, "src"), { recursive: true });
364
+ writeFileSync(join(targetDir, "apps", name, "src/app.ts"), composeAppTs(pieces));
365
+ }
342
366
  writeProvenance(targetDir, {
343
367
  template,
344
368
  cliVersion: cliVersion() ?? "0.0.0",
@@ -349,7 +373,7 @@ async function initApp(nameArg, options = {}) {
349
373
  let scaffoldCommitted = false;
350
374
  if (!options.skipGit) {
351
375
  if (!await spawn("git", ["rev-parse", "--is-inside-work-tree"], { cwd: targetDir }).then(() => true, () => false)) {
352
- const label = template === "universal" ? "universal" : "web-only";
376
+ const label = template === "universal" ? "universal" : template === "app" ? "web-only" : "pieces-only";
353
377
  await spawn("git", ["init", "--quiet"], { cwd: targetDir });
354
378
  await spawn("git", ["add", "-A"], { cwd: targetDir });
355
379
  scaffoldCommitted = await spawn("git", [
@@ -363,7 +387,7 @@ async function initApp(nameArg, options = {}) {
363
387
  });
364
388
  }
365
389
  }
366
- if (!options.skipInstall) {
390
+ if (!options.skipInstall && template !== "none") {
367
391
  console.log("Installing dependencies...");
368
392
  await spawn("pnpm", ["install"], {
369
393
  cwd: targetDir,
@@ -379,8 +403,12 @@ async function initApp(nameArg, options = {}) {
379
403
  "--no-edit",
380
404
  "--quiet"
381
405
  ], { cwd: targetDir })).catch(() => {});
382
- } else console.log("Skipped pnpm install (--skip-install).");
406
+ } else if (options.skipInstall) console.log("Skipped pnpm install (--skip-install).");
383
407
  console.log(`\n✅ Project created at ${targetDir}`);
408
+ if (template === "none") {
409
+ console.log("\nPieces-only tree (adopted-project baseline) — not a runnable project.");
410
+ return;
411
+ }
384
412
  console.log("\nNext steps:");
385
413
  console.log(` cd ${name}`);
386
414
  if (options.skipInstall) console.log(" pnpm install");
@@ -392,7 +420,7 @@ async function initApp(nameArg, options = {}) {
392
420
  if (pieces.includes("frappe")) console.log(" docker compose -f docker/compose.frappe.yaml up -d # dev bench (see README)");
393
421
  if (pieces.includes("keycloak")) console.log(" docker compose -f docker/compose.keycloak.yaml up -d # dev keycloak");
394
422
  if (pieces.includes("gnome")) console.log(" pnpm gnome # GTK4 desktop app (requires gjs + gtk4 — see README)");
395
- if (pieces.includes("tauri")) console.log(" pnpm tauri:dev # desktop app (requires the Rust toolchain)");
423
+ if (pieces.includes("tauri")) console.log(" pnpm tauri:dev # Tauri desktop app (requires the Rust toolchain)");
396
424
  if (pieces.includes("vscode")) console.log(" pnpm build:vscode # stage the VS Code extension into apps dist-vscode/");
397
425
  if (pieces.includes("webext")) console.log(" pnpm build:webext # stage the browser extension into apps dist-webext/");
398
426
  console.log("\nThis project consumes published @multiplatform.one/* packages.");
@@ -400,4 +428,4 @@ async function initApp(nameArg, options = {}) {
400
428
  }
401
429
 
402
430
  //#endregion
403
- export { INIT_APP_PIECES, PROVENANCE_FILE, availablePieces, cliVersion, composeAppTs, initApp, mergeJsonValues, normalizePieces, readProvenance, writeProvenance };
431
+ export { INIT_APP_PIECES, PROVENANCE_FILE, availablePieces, cliVersion, composeAppTs, initApp, mergeJsonValues, normalizePieces, packageJsonName, pieceDescription, readProvenance, validateName, writeProvenance };
@@ -1,5 +1,5 @@
1
1
  import { generateVscodeConfig } from "../generateVscode.mjs";
2
- import { cliVersion, initApp, readProvenance, writeProvenance } from "./initApp.mjs";
2
+ import { PROVENANCE_FILE, cliVersion, initApp, packageJsonName, readProvenance, validateName, writeProvenance } from "./initApp.mjs";
3
3
  import { basename, join } from "node:path";
4
4
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import spawn from "nano-spawn";
@@ -19,8 +19,9 @@ import { tmpdir } from "node:os";
19
19
  *
20
20
  * `git merge-tree --write-tree --merge-base=<base>` merges template
21
21
  * evolution with your customizations; conflicts land in the worktree with
22
- * normal conflict markers. An `.updateignore` file (one pathspec per line)
23
- * pins matching paths to your HEAD version, exactly like the old script.
22
+ * normal conflict markers. An `.updateignore` file (one exact file or
23
+ * directory prefix per line not globs; `dir/**` is normalized, other
24
+ * glob shapes are refused) pins matching paths to your HEAD version.
24
25
  */
25
26
  async function git(projectDir, args, env) {
26
27
  return spawn("git", args, {
@@ -76,14 +77,15 @@ async function scaffoldBaseline(version, name, template, mpoVersion, pieces) {
76
77
  force: true
77
78
  });
78
79
  const current = cliVersion();
79
- const templateFlag = template === "app" ? ["--web"] : ["--universal"];
80
+ const templateFlag = template === "none" ? ["--pieces-only"] : template === "app" ? ["--web"] : ["--universal"];
80
81
  const [major = 0, minor = 0] = version.split(".").map(Number);
81
- const scaffoldCapable = major > 6 || major === 6 && minor >= 3;
82
+ const scaffoldCapable = template === "none" ? major > 6 || major === 6 && minor >= 6 : major > 6 || major === 6 && minor >= 3;
82
83
  const piecesCapable = major > 6 || major === 6 && minor >= 5;
83
84
  const pieceFlags = piecesCapable ? pieces.map((piece) => `--${piece}`) : [];
84
85
  if (pieces.length && scaffoldCapable && !piecesCapable && version !== current) console.warn(`⚠️ @multiplatform.one/cli@${version} predates composable pieces (6.5.0); the merge base is scaffolded without them.`);
85
- if (!scaffoldCapable) console.warn(`⚠️ @multiplatform.one/cli@${version} predates the consumer scaffolder (6.3.0); using the current CLI's template as the merge base.`);
86
- else if (version !== current) {
86
+ if (!scaffoldCapable) {
87
+ if (version !== current) console.warn(template === "none" ? `⚠️ @multiplatform.one/cli@${version} predates pieces-only baselines (6.6.0); using the current CLI's piece templates as the merge base (the merge degrades to two-way: template changes since that version won't land).` : `⚠️ @multiplatform.one/cli@${version} predates the consumer scaffolder (6.3.0); using the current CLI's template as the merge base.`);
88
+ } else if (version !== current) {
87
89
  for (let attempt = 1; attempt <= 2; attempt++) try {
88
90
  await spawn("pnpm", [
89
91
  "--package",
@@ -103,7 +105,7 @@ async function scaffoldBaseline(version, name, template, mpoVersion, pieces) {
103
105
  stdio: "inherit"
104
106
  });
105
107
  const dir = join(parent, name);
106
- if (existsSync(join(dir, "package.json"))) return {
108
+ if (existsSync(join(dir, template === "none" ? ".mpo.json" : "package.json"))) return {
107
109
  dir,
108
110
  cleanup
109
111
  };
@@ -319,6 +321,42 @@ async function autoResolveDependencyConflicts(projectDir, mergedTree, conflicts,
319
321
  });
320
322
  }
321
323
  }
324
+ /**
325
+ * Pre-provenance bootstrap (`mpo update --assume-version <cliVersion>`):
326
+ * projects scaffolded by CLIs older than the provenance file (≤6.3.0) have
327
+ * no .mpo.json. The user asserts which CLI version (and template) produced
328
+ * the project; provenance is written and committed, then the normal update
329
+ * proceeds against that baseline.
330
+ */
331
+ async function bootstrapProvenance(projectDir, options) {
332
+ const version = options.assumeVersion;
333
+ if (!/^\d+\.\d+\.\d+(?:-[\w.]+)?$/u.test(version)) throw new Error(`--assume-version must be an exact CLI version (e.g. 6.3.0), got "${version}"`);
334
+ const rawName = packageJsonName(projectDir);
335
+ if (!rawName) throw new Error(`Could not derive the project name from package.json — create ${PROVENANCE_FILE} by hand with { template, cliVersion, name } instead`);
336
+ const provenance = {
337
+ template: options.assumeTemplate ?? "universal",
338
+ cliVersion: version,
339
+ name: validateName(rawName),
340
+ mpoVersion: `^${version}`,
341
+ pieces: []
342
+ };
343
+ console.log(`\nBootstrapping provenance: assuming this project was scaffolded by cli@${version} (${provenance.template} template).`);
344
+ writeProvenance(projectDir, provenance);
345
+ await git(projectDir, [
346
+ "add",
347
+ "--",
348
+ PROVENANCE_FILE
349
+ ]);
350
+ await git(projectDir, [
351
+ "commit",
352
+ "--quiet",
353
+ "-m",
354
+ `chore: record assumed mpo scaffold provenance (cli@${version})`
355
+ ]).catch(() => {
356
+ console.warn("⚠️ git commit failed (missing git identity?) — provenance left staged.");
357
+ });
358
+ return provenance;
359
+ }
322
360
  async function updateApp(options = {}) {
323
361
  const projectDir = await gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
324
362
  throw new Error("mpo update must run inside a git repository");
@@ -333,16 +371,44 @@ async function updateApp(options = {}) {
333
371
  ]).catch(() => {
334
372
  throw new Error("mpo update requires a clean working tree (staged changes present)");
335
373
  });
336
- const provenance = readProvenance(projectDir);
337
- 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`.");
374
+ let provenance = readProvenance(projectDir);
375
+ if (options.assumeVersion) {
376
+ if (provenance) throw new Error(`--assume-version is for projects without ${PROVENANCE_FILE} — this project already has provenance (cli@${provenance.cliVersion})`);
377
+ provenance = await bootstrapProvenance(projectDir, options);
378
+ }
379
+ if (!provenance) throw new Error(`${PROVENANCE_FILE} not found — this project predates scaffold provenance.\n - scaffolded by an old CLI (pre-6.3)? bootstrap it:
380
+ mpo update --assume-version <cliVersion> [--template universal|app]
381
+ - never scaffolded (hand-adopted pieces)? bring it under management:
382
+ mpo adopt --<piece> [...]`);
338
383
  const currentVersion = cliVersion() ?? "0.0.0";
339
384
  const targetMpoRange = options.version ? options.version.startsWith("^") || options.version.startsWith("~") ? options.version : `^${options.version}` : `^${currentVersion}`;
385
+ const adopted = provenance.template === "none";
386
+ const firstReconcile = adopted && provenance.reconciled === false;
340
387
  console.log(`\nmpo update: ${provenance.cliVersion} → ${currentVersion}`);
341
- console.log(` template: ${provenance.template}`);
388
+ console.log(` template: ${provenance.template}${adopted ? " (adopted)" : ""}`);
342
389
  if (provenance.pieces.length) console.log(` pieces: ${provenance.pieces.join(", ")}`);
343
390
  console.log(` project: ${provenance.name}\n`);
344
- console.log(`Scaffolding merge base (cli@${provenance.cliVersion})...`);
345
- const base = await scaffoldBaseline(provenance.cliVersion, provenance.name, provenance.template, provenance.mpoVersion, provenance.pieces);
391
+ if (firstReconcile) {
392
+ console.log("⚠️ FIRST UPDATE AFTER ADOPTION: piece files are reconciled against the");
393
+ console.log(" piece templates from an empty merge base. Diffs and conflicts on");
394
+ console.log(" hand-copied piece files are EXPECTED — resolve the markers and");
395
+ console.log(" commit. Later updates merge three-way and stay quiet.\n");
396
+ }
397
+ let base;
398
+ if (firstReconcile) {
399
+ console.log("Using an empty merge base (first update after adoption)...");
400
+ const dir = mkdtempSync(join(tmpdir(), "mpo-update-"));
401
+ base = {
402
+ dir,
403
+ cleanup: () => rmSync(dir, {
404
+ recursive: true,
405
+ force: true
406
+ })
407
+ };
408
+ } else {
409
+ console.log(`Scaffolding merge base (cli@${provenance.cliVersion})...`);
410
+ base = await scaffoldBaseline(provenance.cliVersion, provenance.name, provenance.template, provenance.mpoVersion, provenance.pieces);
411
+ }
346
412
  console.log(`Scaffolding update target (cli@${currentVersion})...`);
347
413
  const next = await scaffoldBaseline(currentVersion, provenance.name, provenance.template, targetMpoRange, provenance.pieces);
348
414
  rmSync(join(base.dir, ".mpo.json"), { force: true });
@@ -380,7 +446,10 @@ async function updateApp(options = {}) {
380
446
  const updateignore = join(projectDir, ".updateignore");
381
447
  if (existsSync(updateignore)) for (const line of readFileSync(updateignore, "utf-8").split("\n")) {
382
448
  const pattern = line.trim();
383
- if (pattern && !pattern.startsWith("#")) pins.push(pattern);
449
+ if (!pattern || pattern.startsWith("#")) continue;
450
+ const normalized = pattern.replace(/\/\*{1,2}$/, "");
451
+ if (/[*?[\]]/.test(normalized)) throw new Error(`.updateignore: "${pattern}" looks like a glob, but entries are exact files or directory prefixes (a glob here would silently pin nothing). List the file or directory instead.`);
452
+ pins.push(normalized.replace(/\/+$/, ""));
384
453
  }
385
454
  mergedTree = await pinPathsToHead(projectDir, mergedTree, head, pins);
386
455
  conflicts = conflicts.filter((file) => !pins.some((pin) => file === pin || file.startsWith(`${pin}/`)));
@@ -390,7 +459,7 @@ async function updateApp(options = {}) {
390
459
  for (const file of autoResolved.resolved) console.log(` auto-resolved: ${file} (dependency ranges)`);
391
460
  const headTree = await gitOut(projectDir, ["rev-parse", `${head}^{tree}`]);
392
461
  if (!conflicts.length && mergedTree === headTree) {
393
- if (provenance.cliVersion !== finalProvenance.cliVersion || provenance.mpoVersion !== finalProvenance.mpoVersion) {
462
+ if (provenance.cliVersion !== finalProvenance.cliVersion || provenance.mpoVersion !== finalProvenance.mpoVersion || provenance.reconciled === false) {
394
463
  writeProvenance(projectDir, finalProvenance);
395
464
  await git(projectDir, [
396
465
  "add",