@multiplatform.one/cli 6.4.3 → 6.7.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.
- package/README.md +23 -1
- package/lib/bin/multiplatformOne.mjs +5 -3
- package/lib/commands/initApp.mjs +209 -8
- package/lib/commands/updateApp.mjs +170 -16
- package/package.json +3 -3
- package/src/bin/multiplatformOne.ts +14 -1
- package/src/commands/initApp.spec.ts +369 -3
- package/src/commands/initApp.ts +324 -4
- package/src/commands/updateApp.spec.ts +81 -0
- package/src/commands/updateApp.ts +212 -6
- package/templates/app/apps/__NAME__/package.json +2 -1
- package/templates/app/features/__NAME__/package.json +1 -1
- package/templates/app/packages/config/package.json +1 -1
- package/templates/pieces/frappe/universal/README.md.partial +32 -0
- package/templates/pieces/frappe/universal/docker/compose.frappe.yaml +52 -0
- package/templates/pieces/frappe/universal/env.example.partial +8 -0
- package/templates/pieces/frappe/universal/packages/config/config.json.partial +3 -0
- package/templates/pieces/gnome/universal/README.md.partial +68 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/main.tsx +165 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/polyfills.ts +10 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/components.ts +36 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/empty-module.ts +28 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/forms.ts +18 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/one.ts +89 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/theme.ts +25 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/tamagui-barrel.ts +77 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/package.json.partial +18 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/vite.config.gnome.ts +124 -0
- package/templates/pieces/gnome/universal/gitignore.partial +3 -0
- package/templates/pieces/gnome/universal/package.json.partial +6 -0
- package/templates/pieces/keycloak/universal/README.md.partial +19 -0
- package/templates/pieces/keycloak/universal/docker/compose.keycloak.yaml +27 -0
- package/templates/pieces/keycloak/universal/docker/keycloak/realm.json +43 -0
- package/templates/pieces/keycloak/universal/env.example.partial +9 -0
- package/templates/pieces/keycloak/universal/packages/config/config.json.partial +9 -0
- package/templates/pieces/tauri/universal/README.md.partial +18 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/package.json.partial +10 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/Cargo.toml +26 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/build.rs +3 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/capabilities/default.json +7 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/128x128.png +0 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/128x128@2x.png +0 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/32x32.png +0 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/icon.icns +0 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/icon.ico +0 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +14 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/main.rs +9 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +43 -0
- package/templates/pieces/tauri/universal/gitignore.partial +3 -0
- package/templates/pieces/tauri/universal/package.json.partial +6 -0
- package/templates/pieces/vscode/universal/README.md.partial +17 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/package.json.partial +19 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vite.config.vscode.ts +17 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/env.d.ts +1 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/index.ts +13 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/views/helper.ts +14 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/views/panel.ts +38 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/index.html +49 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/manifest.ts +33 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/package.json +7 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/tamagui.css +3 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/tsconfig.json +9 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/app.tsx +24 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/components/ErrorBoundary.tsx +41 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts/inter-400.woff2 +0 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts/inter-700.woff2 +0 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts.css +21 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/main.tsx +35 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/router.tsx +58 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/useVSCodeTheme.ts +56 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/utils/vscode.ts +16 -0
- package/templates/pieces/vscode/universal/gitignore.partial +2 -0
- package/templates/pieces/vscode/universal/package.json.partial +6 -0
- package/templates/pieces/webext/universal/README.md.partial +16 -0
- package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +22 -0
- package/templates/pieces/webext/universal/apps/__NAME__/tsconfig.json.partial +5 -0
- package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext-background.ts +19 -0
- package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +35 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/assets/fonts/inter-400.woff2 +0 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/assets/fonts/inter-700.woff2 +0 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/background/main.ts +10 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/components/ErrorBoundary.tsx +41 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/env.ts +2 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/manifest.ts +45 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/prepare.ts +21 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/static/assets/icon-512.png +0 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/tamagui.css +3 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/tsconfig.json +9 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/views/popup/index.html +77 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/views/popup/main.tsx +73 -0
- package/templates/pieces/webext/universal/gitignore.partial +2 -0
- package/templates/pieces/webext/universal/package.json.partial +6 -0
- package/templates/universal/apps/__NAME__/package.json +2 -1
- package/templates/universal/features/__NAME__/package.json +1 -1
- package/templates/universal/packages/config/package.json +1 -1
- package/templates/universal/packages/i18n/package.json +1 -1
- package/templates/universal/packages/themes/package.json +1 -1
- package/types/commands/initApp.d.ts +43 -0
- package/types/commands/initApp.d.ts.map +1 -1
- package/types/commands/updateApp.d.ts +4 -0
- package/types/commands/updateApp.d.ts.map +1 -1
package/README.md
CHANGED
|
@@ -38,9 +38,31 @@ Want web-only? `mpo init --web my-app` scaffolds the Vite + React variant
|
|
|
38
38
|
(`--app` is a back-compat alias; `create-multiplatform-app` without flags also
|
|
39
39
|
produces it — pass `--universal` there for the full project).
|
|
40
40
|
|
|
41
|
-
Options: `--universal`, `--web`/`--app`, `--skip-install`,
|
|
41
|
+
Options: `--universal`, `--web`/`--app`, `--skip-install`, `--skip-git`,
|
|
42
42
|
`--mpo-version <range>` (defaults to `^<cli version>`), `-y/--yes`.
|
|
43
43
|
|
|
44
|
+
### Pieces
|
|
45
|
+
|
|
46
|
+
Composable fragments overlaid onto the base template. Pass any of them to skip
|
|
47
|
+
the interactive prompt; they are recorded in `.mpo.json` and reproduced by
|
|
48
|
+
`mpo update`, so pick them at init time.
|
|
49
|
+
|
|
50
|
+
| Flag | Adds | Needs |
|
|
51
|
+
| ------------ | ---------------------------------------------------------------- | --------------------- |
|
|
52
|
+
| `--frappe` | Frappe backend wiring — env keys, provider, dev bench compose | Docker |
|
|
53
|
+
| `--keycloak` | Keycloak auth — env keys, provider, compose + realm import | Docker |
|
|
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
|
+
| `--vscode` | VS Code extension — extension host + Tamagui webview | — |
|
|
57
|
+
| `--webext` | Browser extension — MV3 popup + background | — |
|
|
58
|
+
|
|
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.
|
|
65
|
+
|
|
44
66
|
## Other commands
|
|
45
67
|
|
|
46
68
|
| Command | Purpose |
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { generateVscodeConfig } from "../generateVscode.mjs";
|
|
2
2
|
import { discoverE2EApp, runE2ESession } from "../commands/e2e.mjs";
|
|
3
3
|
import { init, runModifyStep } from "../commands/init.mjs";
|
|
4
|
-
import { initApp, readProvenance } from "../commands/initApp.mjs";
|
|
4
|
+
import { INIT_APP_PIECES, initApp, readProvenance } from "../commands/initApp.mjs";
|
|
5
5
|
import { updateApp } from "../commands/updateApp.mjs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import fs from "node:fs/promises";
|
|
@@ -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("--skip-git", "skip git init + scaffold commit").option("--mpo-version <range>", "semver range for @multiplatform.one/* (default: ^<cli version>)").option("-y, --yes", "non-interactive: accept defaults instead of prompting").option("--monorepo", "DEPRECATED: clone/modify the full multiplatform.one monorepo instead of scaffolding a consumer project").option("-c, --checkout <branch>", "branch, tag or commit to checkout (--monorepo only)", "main").option("-a, --apps <apps>", "comma-separated apps to include (--monorepo only)").option("-s, --services <services>", "comma-separated services to include (--monorepo only)").argument("[name]", "the name of the project").description("scaffold a new multiplatform project (universal web + native by default; --web for web-only; semver @multiplatform.one/* deps)").action(async (name, options) => {
|
|
474
|
+
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
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");
|
|
@@ -485,12 +485,14 @@ program.command("init").option("--universal", "scaffold the universal template (
|
|
|
485
485
|
if (options.apps || options.services) throw new Error("--apps/--services select monorepo pieces and require --monorepo (legacy clone mode)");
|
|
486
486
|
const webOnly = Boolean(options.web || options.app);
|
|
487
487
|
if (webOnly && options.universal) throw new Error("Pass either --web/--app or --universal, not both");
|
|
488
|
+
const pieces = INIT_APP_PIECES.filter((piece) => Boolean(options[piece]));
|
|
488
489
|
await initApp(name, {
|
|
489
490
|
skipInstall: Boolean(options.skipInstall),
|
|
490
491
|
skipGit: Boolean(options.skipGit),
|
|
491
492
|
version: options.mpoVersion,
|
|
492
493
|
template: webOnly ? "app" : options.universal ? "universal" : void 0,
|
|
493
|
-
yes: Boolean(options.yes)
|
|
494
|
+
yes: Boolean(options.yes),
|
|
495
|
+
pieces: pieces.length ? pieces : void 0
|
|
494
496
|
});
|
|
495
497
|
});
|
|
496
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) => {
|
package/lib/commands/initApp.mjs
CHANGED
|
@@ -5,6 +5,28 @@ import spawn from "nano-spawn";
|
|
|
5
5
|
import inquirer from "inquirer";
|
|
6
6
|
|
|
7
7
|
//#region src/commands/initApp.ts
|
|
8
|
+
/**
|
|
9
|
+
* Optional composable pieces overlaid onto the base template (additive
|
|
10
|
+
* fragments under templates/pieces/<piece>/ — the inverse of the old
|
|
11
|
+
* cookiecutter KEEP_SERVICES/KEEP_PLATFORMS removal hook). Keep sorted:
|
|
12
|
+
* pieces are recorded sorted in provenance and overlaid in this order.
|
|
13
|
+
*/
|
|
14
|
+
const INIT_APP_PIECES = [
|
|
15
|
+
"frappe",
|
|
16
|
+
"gnome",
|
|
17
|
+
"keycloak",
|
|
18
|
+
"tauri",
|
|
19
|
+
"vscode",
|
|
20
|
+
"webext"
|
|
21
|
+
];
|
|
22
|
+
const PIECE_DESCRIPTIONS = {
|
|
23
|
+
frappe: "Frappe backend wiring (env keys, provider, docker compose bench)",
|
|
24
|
+
gnome: "GNOME desktop target (GTK4/GJS via react-gnome — native widgets, no webview)",
|
|
25
|
+
keycloak: "Keycloak auth wiring (env keys, provider, docker compose + realm)",
|
|
26
|
+
tauri: "Desktop target (src-tauri skeleton, tauri scripts)",
|
|
27
|
+
vscode: "VS Code extension target (extension host + Tamagui webview)",
|
|
28
|
+
webext: "Browser extension target (MV3 popup + background)"
|
|
29
|
+
};
|
|
8
30
|
function toPascalCase(name) {
|
|
9
31
|
return name.split(/[-_\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
10
32
|
}
|
|
@@ -29,7 +51,10 @@ function cliVersion() {
|
|
|
29
51
|
}
|
|
30
52
|
const PROVENANCE_FILE = ".mpo.json";
|
|
31
53
|
function writeProvenance(targetDir, provenance) {
|
|
32
|
-
writeFileSync(join(targetDir, PROVENANCE_FILE), `${JSON.stringify(
|
|
54
|
+
writeFileSync(join(targetDir, PROVENANCE_FILE), `${JSON.stringify({
|
|
55
|
+
...provenance,
|
|
56
|
+
pieces: [...provenance.pieces].sort()
|
|
57
|
+
}, null, 2)}\n`);
|
|
33
58
|
}
|
|
34
59
|
function readProvenance(projectDir) {
|
|
35
60
|
const file = join(projectDir, PROVENANCE_FILE);
|
|
@@ -41,7 +66,8 @@ function readProvenance(projectDir) {
|
|
|
41
66
|
template: raw.template,
|
|
42
67
|
cliVersion: raw.cliVersion,
|
|
43
68
|
name: raw.name,
|
|
44
|
-
mpoVersion: raw.mpoVersion ?? `^${raw.cliVersion}
|
|
69
|
+
mpoVersion: raw.mpoVersion ?? `^${raw.cliVersion}`,
|
|
70
|
+
pieces: Array.isArray(raw.pieces) ? [...raw.pieces].sort() : []
|
|
45
71
|
};
|
|
46
72
|
} catch {
|
|
47
73
|
return;
|
|
@@ -50,13 +76,26 @@ function readProvenance(projectDir) {
|
|
|
50
76
|
function templatesRoot(template) {
|
|
51
77
|
return resolve(dirname(fileURLToPath(import.meta.url)), "../../templates", template);
|
|
52
78
|
}
|
|
79
|
+
function piecesRoot(piece) {
|
|
80
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "../../templates/pieces", piece);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Fragment source dirs for a piece + template: shared/ applies to every
|
|
84
|
+
* template, <template>/ only to that one. A piece supports a template when
|
|
85
|
+
* at least one of the two exists.
|
|
86
|
+
*/
|
|
87
|
+
function pieceFragmentDirs(piece, template) {
|
|
88
|
+
return [join(piecesRoot(piece), "shared"), join(piecesRoot(piece), template)].filter((dir) => existsSync(dir));
|
|
89
|
+
}
|
|
53
90
|
/**
|
|
54
|
-
* npm always strips .gitignore / .npmrc
|
|
55
|
-
* template stores them without the leading dot; restore it
|
|
91
|
+
* npm always strips .gitignore / .npmrc (and newer npms .env*) from published
|
|
92
|
+
* tarballs, so the template stores them without the leading dot; restore it
|
|
93
|
+
* at scaffold time.
|
|
56
94
|
*/
|
|
57
95
|
const DOTFILE_NAMES = {
|
|
58
96
|
gitignore: ".gitignore",
|
|
59
|
-
npmrc: ".npmrc"
|
|
97
|
+
npmrc: ".npmrc",
|
|
98
|
+
"env.example": ".env.example"
|
|
60
99
|
};
|
|
61
100
|
function render(content, vars) {
|
|
62
101
|
return content.replaceAll("__NAME_PASCAL__", vars.NAME_PASCAL).replaceAll("__NAME_APPID__", vars.NAME_APPID).replaceAll("__NAME__", vars.NAME).replaceAll("__MPO_VERSION__", vars.MPO_VERSION).replaceAll("__YEAR__", vars.YEAR);
|
|
@@ -72,7 +111,8 @@ const BINARY_EXTENSIONS = new Set([
|
|
|
72
111
|
".otf",
|
|
73
112
|
".png",
|
|
74
113
|
".jpg",
|
|
75
|
-
".ico"
|
|
114
|
+
".ico",
|
|
115
|
+
".icns"
|
|
76
116
|
]);
|
|
77
117
|
function writeTree(srcDir, destDir, vars) {
|
|
78
118
|
if (!existsSync(srcDir)) throw new Error(`Project template missing at ${srcDir}`);
|
|
@@ -91,6 +131,156 @@ function writeTree(srcDir, destDir, vars) {
|
|
|
91
131
|
writeFileSync(to, render(readFileSync(from, "utf-8"), vars));
|
|
92
132
|
}
|
|
93
133
|
}
|
|
134
|
+
function isJsonObject(value) {
|
|
135
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Deep-merge for JSON partials: objects merge recursively (fragment keys
|
|
139
|
+
* append after base keys, so e.g. package.json script order stays stable),
|
|
140
|
+
* arrays union (base order first — config.json public keys accumulate
|
|
141
|
+
* across pieces), scalars/type-mismatches take the fragment value.
|
|
142
|
+
*/
|
|
143
|
+
function mergeJsonValues(base, fragment) {
|
|
144
|
+
if (Array.isArray(base) && Array.isArray(fragment)) {
|
|
145
|
+
const merged = [...base];
|
|
146
|
+
const seen = new Set(base.map((item) => JSON.stringify(item)));
|
|
147
|
+
for (const item of fragment) {
|
|
148
|
+
const key = JSON.stringify(item);
|
|
149
|
+
if (seen.has(key)) continue;
|
|
150
|
+
seen.add(key);
|
|
151
|
+
merged.push(item);
|
|
152
|
+
}
|
|
153
|
+
return merged;
|
|
154
|
+
}
|
|
155
|
+
if (isJsonObject(base) && isJsonObject(fragment)) {
|
|
156
|
+
const merged = { ...base };
|
|
157
|
+
for (const [key, value] of Object.entries(fragment)) merged[key] = key in merged ? mergeJsonValues(merged[key], value) : value;
|
|
158
|
+
return merged;
|
|
159
|
+
}
|
|
160
|
+
return fragment;
|
|
161
|
+
}
|
|
162
|
+
/** Dependency maps stay alphabetically sorted after fragment merges (the
|
|
163
|
+
* base templates are sorted; multiple pieces appending must not depend on
|
|
164
|
+
* overlay order for the final byte content). */
|
|
165
|
+
const SORTED_DEPENDENCY_FIELDS = [
|
|
166
|
+
"dependencies",
|
|
167
|
+
"devDependencies",
|
|
168
|
+
"peerDependencies",
|
|
169
|
+
"optionalDependencies"
|
|
170
|
+
];
|
|
171
|
+
function sortDependencyFields(value) {
|
|
172
|
+
if (!isJsonObject(value)) return value;
|
|
173
|
+
const result = { ...value };
|
|
174
|
+
for (const field of SORTED_DEPENDENCY_FIELDS) {
|
|
175
|
+
const deps = result[field];
|
|
176
|
+
if (!isJsonObject(deps)) continue;
|
|
177
|
+
result[field] = Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Apply a rendered `<target>.partial` fragment onto the target file:
|
|
183
|
+
* - `.json` targets deep-merge (see mergeJsonValues); package.json-style
|
|
184
|
+
* dependency maps are re-sorted for deterministic output
|
|
185
|
+
* - everything else appends as a text block separated by a blank line
|
|
186
|
+
* Missing targets are created from the fragment alone.
|
|
187
|
+
*/
|
|
188
|
+
function applyPartial(targetPath, renderedFragment) {
|
|
189
|
+
if (targetPath.endsWith(".json")) {
|
|
190
|
+
const fragment = JSON.parse(renderedFragment);
|
|
191
|
+
const base = existsSync(targetPath) ? JSON.parse(readFileSync(targetPath, "utf-8")) : void 0;
|
|
192
|
+
const merged = sortDependencyFields(base === void 0 ? fragment : mergeJsonValues(base, fragment));
|
|
193
|
+
writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const block = `${renderedFragment.replace(/^\n+/u, "").replace(/\n+$/u, "")}\n`;
|
|
197
|
+
if (!existsSync(targetPath)) {
|
|
198
|
+
writeFileSync(targetPath, block);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
writeFileSync(targetPath, `${readFileSync(targetPath, "utf-8").replace(/\n+$/u, "")}\n\n${block}`);
|
|
202
|
+
}
|
|
203
|
+
const PARTIAL_SUFFIX = ".partial";
|
|
204
|
+
/**
|
|
205
|
+
* Overlay a piece fragment tree onto the scaffold: regular files render and
|
|
206
|
+
* overwrite, `<target>.partial` files merge into (or create) their target
|
|
207
|
+
* (deep-merge for .json, append for text — see applyPartial).
|
|
208
|
+
*/
|
|
209
|
+
function overlayTree(srcDir, destDir, vars) {
|
|
210
|
+
mkdirSync(destDir, { recursive: true });
|
|
211
|
+
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
212
|
+
const from = join(srcDir, entry.name);
|
|
213
|
+
if (entry.isDirectory()) {
|
|
214
|
+
overlayTree(from, join(destDir, render(DOTFILE_NAMES[entry.name] ?? entry.name, vars)), vars);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (entry.name.endsWith(PARTIAL_SUFFIX)) {
|
|
218
|
+
const targetName = entry.name.slice(0, -8);
|
|
219
|
+
applyPartial(join(destDir, render(DOTFILE_NAMES[targetName] ?? targetName, vars)), render(readFileSync(from, "utf-8"), vars));
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const to = join(destDir, render(DOTFILE_NAMES[entry.name] ?? entry.name, vars));
|
|
223
|
+
if (BINARY_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
|
224
|
+
copyFileSync(from, to);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
writeFileSync(to, render(readFileSync(from, "utf-8"), vars));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Validate + canonicalize a piece selection for a template: unknown names
|
|
232
|
+
* throw, duplicates collapse, order is always sorted (provenance and overlay
|
|
233
|
+
* order must be deterministic), and every piece must ship a fragment for the
|
|
234
|
+
* chosen template.
|
|
235
|
+
*/
|
|
236
|
+
function normalizePieces(pieces, template) {
|
|
237
|
+
const known = new Set(INIT_APP_PIECES);
|
|
238
|
+
const normalized = [...new Set(pieces)].sort();
|
|
239
|
+
for (const piece of normalized) {
|
|
240
|
+
if (!known.has(piece)) throw new Error(`Unknown piece "${piece}". Available pieces: ${INIT_APP_PIECES.join(", ")}`);
|
|
241
|
+
if (pieceFragmentDirs(piece, template).length === 0) throw new Error(`Piece "${piece}" is not available for the ${template} template (pieces currently target the universal template)`);
|
|
242
|
+
}
|
|
243
|
+
return normalized;
|
|
244
|
+
}
|
|
245
|
+
/** Pieces that can be offered for a template (fragment exists for it). */
|
|
246
|
+
function availablePieces(template) {
|
|
247
|
+
return INIT_APP_PIECES.filter((piece) => pieceFragmentDirs(piece, template).length > 0);
|
|
248
|
+
}
|
|
249
|
+
async function resolvePieces(options, template) {
|
|
250
|
+
if (options.pieces) return normalizePieces(options.pieces, template);
|
|
251
|
+
if (options.yes || !process.stdout.isTTY) return [];
|
|
252
|
+
const choices = availablePieces(template);
|
|
253
|
+
if (choices.length === 0) return [];
|
|
254
|
+
return normalizePieces((await inquirer.prompt([{
|
|
255
|
+
message: "Add optional pieces? (space to toggle, enter to confirm)",
|
|
256
|
+
name: "pieces",
|
|
257
|
+
type: "checkbox",
|
|
258
|
+
choices: choices.map((piece) => ({
|
|
259
|
+
name: `${piece} — ${PIECE_DESCRIPTIONS[piece]}`,
|
|
260
|
+
value: piece,
|
|
261
|
+
checked: false
|
|
262
|
+
}))
|
|
263
|
+
}])).pieces, template);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Generate apps/<name>/src/app.ts for the selected backend pieces instead of
|
|
267
|
+
* storing 2^n template variants: each selected provider adds one createApp
|
|
268
|
+
* key (frappe/keycloak read their runtime settings from the packages/config
|
|
269
|
+
* public env keys their fragments register). Only called when at least one
|
|
270
|
+
* provider piece is selected — the base template file stays otherwise.
|
|
271
|
+
*/
|
|
272
|
+
function composeAppTs(pieces) {
|
|
273
|
+
const frappe = pieces.includes("frappe");
|
|
274
|
+
const keycloak = pieces.includes("keycloak");
|
|
275
|
+
const lines = ["import { createApp } from \"@multiplatform.one/core\";"];
|
|
276
|
+
if (frappe || keycloak) lines.push("import { config } from \"@multiplatform.one/platform\";");
|
|
277
|
+
lines.push("import { themeConfig } from \"@package/themes\";");
|
|
278
|
+
lines.push("", "/**", " * App-wide provider stack. Providers below were enabled by the pieces", " * selected at `mpo init` time — see CreateAppConfig in", " * @multiplatform.one/core for everything else that can be wired here", " * (tanstack, web3, custom providers).", " */", "export const { AppProvider, useAppConfig, withApp } = createApp({", " theme: themeConfig,");
|
|
279
|
+
if (frappe) lines.push(" // Frappe backend (mpo init --frappe): FrappeProvider mounts when", " // FRAPPE_ENABLED=1 (a packages/config public env key, loaded from", " // .env — see .env.example). FRAPPE_URL overrides the bench URL", " // (default: BASE_URL, then http://localhost:8000);", " // docker/compose.frappe.yaml runs a dev bench.", " frappe: { baseURL: config.get(\"FRAPPE_URL\") },");
|
|
280
|
+
if (keycloak) lines.push(" // Keycloak auth (mpo init --keycloak): KeycloakProvider mounts when", " // KEYCLOAK_ENABLED=1. Realm/client settings come from the KEYCLOAK_*", " // env keys (packages/config, .env.example);", " // docker/compose.keycloak.yaml runs a dev server with an imported", " // realm.", " keycloak: { baseUrl: config.get(\"KEYCLOAK_BASE_URL\") },");
|
|
281
|
+
lines.push("});", "");
|
|
282
|
+
return lines.join("\n");
|
|
283
|
+
}
|
|
94
284
|
async function resolveTemplate(options) {
|
|
95
285
|
if (options.template) return options.template;
|
|
96
286
|
if (options.yes || !process.stdout.isTTY) return "universal";
|
|
@@ -124,6 +314,7 @@ async function initApp(nameArg, options = {}) {
|
|
|
124
314
|
}])).name;
|
|
125
315
|
const name = validateName(projectName);
|
|
126
316
|
const template = await resolveTemplate(options);
|
|
317
|
+
const pieces = await resolvePieces(options, template);
|
|
127
318
|
const targetDir = resolve(name);
|
|
128
319
|
if (existsSync(targetDir)) {
|
|
129
320
|
if (readdirSync(targetDir).length > 0) throw new Error(`Directory "${targetDir}" already exists and is not empty`);
|
|
@@ -143,13 +334,17 @@ async function initApp(nameArg, options = {}) {
|
|
|
143
334
|
console.log(` packages/i18n`);
|
|
144
335
|
console.log(` packages/themes`);
|
|
145
336
|
}
|
|
337
|
+
if (pieces.length) console.log(` pieces: ${pieces.join(", ")}`);
|
|
146
338
|
console.log(` @multiplatform.one/* → ${vars.MPO_VERSION}\n`);
|
|
147
339
|
writeTree(templatesRoot(template), targetDir, vars);
|
|
340
|
+
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));
|
|
148
342
|
writeProvenance(targetDir, {
|
|
149
343
|
template,
|
|
150
344
|
cliVersion: cliVersion() ?? "0.0.0",
|
|
151
345
|
name,
|
|
152
|
-
mpoVersion: vars.MPO_VERSION
|
|
346
|
+
mpoVersion: vars.MPO_VERSION,
|
|
347
|
+
pieces
|
|
153
348
|
});
|
|
154
349
|
let scaffoldCommitted = false;
|
|
155
350
|
if (!options.skipGit) {
|
|
@@ -194,9 +389,15 @@ async function initApp(nameArg, options = {}) {
|
|
|
194
389
|
console.log(" pnpm ios # iOS (requires pnpm dev in another terminal)");
|
|
195
390
|
console.log(" pnpm android # Android");
|
|
196
391
|
}
|
|
392
|
+
if (pieces.includes("frappe")) console.log(" docker compose -f docker/compose.frappe.yaml up -d # dev bench (see README)");
|
|
393
|
+
if (pieces.includes("keycloak")) console.log(" docker compose -f docker/compose.keycloak.yaml up -d # dev keycloak");
|
|
394
|
+
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)");
|
|
396
|
+
if (pieces.includes("vscode")) console.log(" pnpm build:vscode # stage the VS Code extension into apps dist-vscode/");
|
|
397
|
+
if (pieces.includes("webext")) console.log(" pnpm build:webext # stage the browser extension into apps dist-webext/");
|
|
197
398
|
console.log("\nThis project consumes published @multiplatform.one/* packages.");
|
|
198
399
|
console.log("It is not a fork of the multiplatform.one monorepo.");
|
|
199
400
|
}
|
|
200
401
|
|
|
201
402
|
//#endregion
|
|
202
|
-
export { PROVENANCE_FILE, cliVersion, initApp, readProvenance, writeProvenance };
|
|
403
|
+
export { INIT_APP_PIECES, PROVENANCE_FILE, availablePieces, cliVersion, composeAppTs, initApp, mergeJsonValues, normalizePieces, readProvenance, writeProvenance };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { generateVscodeConfig } from "../generateVscode.mjs";
|
|
2
2
|
import { cliVersion, initApp, readProvenance, writeProvenance } from "./initApp.mjs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import spawn from "nano-spawn";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
|
|
@@ -69,7 +69,7 @@ async function commitTreeFromDir(projectDir, dir, message, parent) {
|
|
|
69
69
|
/** Scaffold a project with a specific published CLI version into tmp and
|
|
70
70
|
* return the generated project dir. Falls back to the local generator when
|
|
71
71
|
* the requested version matches the running CLI (or dlx fails). */
|
|
72
|
-
async function scaffoldBaseline(version, name, template, mpoVersion) {
|
|
72
|
+
async function scaffoldBaseline(version, name, template, mpoVersion, pieces) {
|
|
73
73
|
const parent = mkdtempSync(join(tmpdir(), "mpo-update-"));
|
|
74
74
|
const cleanup = () => rmSync(parent, {
|
|
75
75
|
recursive: true,
|
|
@@ -78,7 +78,11 @@ async function scaffoldBaseline(version, name, template, mpoVersion) {
|
|
|
78
78
|
const current = cliVersion();
|
|
79
79
|
const templateFlag = template === "app" ? ["--web"] : ["--universal"];
|
|
80
80
|
const [major = 0, minor = 0] = version.split(".").map(Number);
|
|
81
|
-
|
|
81
|
+
const scaffoldCapable = major > 6 || major === 6 && minor >= 3;
|
|
82
|
+
const piecesCapable = major > 6 || major === 6 && minor >= 5;
|
|
83
|
+
const pieceFlags = piecesCapable ? pieces.map((piece) => `--${piece}`) : [];
|
|
84
|
+
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.`);
|
|
82
86
|
else if (version !== current) {
|
|
83
87
|
for (let attempt = 1; attempt <= 2; attempt++) try {
|
|
84
88
|
await spawn("pnpm", [
|
|
@@ -92,7 +96,8 @@ async function scaffoldBaseline(version, name, template, mpoVersion) {
|
|
|
92
96
|
"--skip-install",
|
|
93
97
|
"--mpo-version",
|
|
94
98
|
mpoVersion,
|
|
95
|
-
...templateFlag
|
|
99
|
+
...templateFlag,
|
|
100
|
+
...pieceFlags
|
|
96
101
|
], {
|
|
97
102
|
cwd: parent,
|
|
98
103
|
stdio: "inherit"
|
|
@@ -121,7 +126,8 @@ async function scaffoldBaseline(version, name, template, mpoVersion) {
|
|
|
121
126
|
skipGit: true,
|
|
122
127
|
yes: true,
|
|
123
128
|
template,
|
|
124
|
-
version: mpoVersion
|
|
129
|
+
version: mpoVersion,
|
|
130
|
+
pieces
|
|
125
131
|
});
|
|
126
132
|
} finally {
|
|
127
133
|
process.chdir(previousCwd);
|
|
@@ -175,6 +181,144 @@ async function pinPathsToHead(projectDir, tree, headCommit, pins) {
|
|
|
175
181
|
rmSync(indexFile, { force: true });
|
|
176
182
|
return pinned;
|
|
177
183
|
}
|
|
184
|
+
const UNRESOLVED = Symbol("unresolved");
|
|
185
|
+
/** Deleted-on-one-side sentinel (distinct from a key that never existed). */
|
|
186
|
+
const ABSENT = Symbol("absent");
|
|
187
|
+
const DEP_MAP_KEYS = new Set([
|
|
188
|
+
"dependencies",
|
|
189
|
+
"devDependencies",
|
|
190
|
+
"peerDependencies",
|
|
191
|
+
"optionalDependencies",
|
|
192
|
+
"overrides",
|
|
193
|
+
"resolutions"
|
|
194
|
+
]);
|
|
195
|
+
/** Minimum concrete version of a simple range (`^x.y.z`, `~x.y.z`,
|
|
196
|
+
* `x.y.z`, optional prerelease). Anything fancier is not auto-resolved. */
|
|
197
|
+
function rangeMinimum(range) {
|
|
198
|
+
if (typeof range !== "string") return void 0;
|
|
199
|
+
const match = range.match(/^[\^~]?(\d+)\.(\d+)\.(\d+)(?:-[\w.]+)?$/);
|
|
200
|
+
if (!match) return void 0;
|
|
201
|
+
return [
|
|
202
|
+
Number(match[1]),
|
|
203
|
+
Number(match[2]),
|
|
204
|
+
Number(match[3])
|
|
205
|
+
];
|
|
206
|
+
}
|
|
207
|
+
function isPlainObject(value) {
|
|
208
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
209
|
+
}
|
|
210
|
+
function jsonEqual(a, b) {
|
|
211
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
212
|
+
}
|
|
213
|
+
function mergeNode(base, ours, theirs, inDepMap) {
|
|
214
|
+
if (jsonEqual(ours, theirs)) return ours === void 0 ? ABSENT : ours;
|
|
215
|
+
if (jsonEqual(theirs, base)) return ours === void 0 ? ABSENT : ours;
|
|
216
|
+
if (jsonEqual(ours, base)) return theirs === void 0 ? ABSENT : theirs;
|
|
217
|
+
if (isPlainObject(ours) && isPlainObject(theirs)) {
|
|
218
|
+
const baseObject = isPlainObject(base) ? base : {};
|
|
219
|
+
const merged = {};
|
|
220
|
+
const keys = [...Object.keys(ours), ...Object.keys(theirs).filter((key) => !(key in ours))];
|
|
221
|
+
for (const key of keys) {
|
|
222
|
+
const child = mergeNode(baseObject[key], ours[key], theirs[key], inDepMap || DEP_MAP_KEYS.has(key));
|
|
223
|
+
if (child === UNRESOLVED) return UNRESOLVED;
|
|
224
|
+
if (child !== ABSENT) merged[key] = child;
|
|
225
|
+
}
|
|
226
|
+
return merged;
|
|
227
|
+
}
|
|
228
|
+
if (inDepMap) {
|
|
229
|
+
const oursMin = rangeMinimum(ours);
|
|
230
|
+
const theirsMin = rangeMinimum(theirs);
|
|
231
|
+
if (oursMin && theirsMin) {
|
|
232
|
+
for (let i = 0; i < 3; i++) {
|
|
233
|
+
if (oursMin[i] > theirsMin[i]) return ours;
|
|
234
|
+
if (oursMin[i] < theirsMin[i]) return theirs;
|
|
235
|
+
}
|
|
236
|
+
return theirs;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return UNRESOLVED;
|
|
240
|
+
}
|
|
241
|
+
/** Structural three-way merge of package.json content. Returns the merged
|
|
242
|
+
* object, or undefined when a divergence can't be auto-resolved (the file
|
|
243
|
+
* then keeps its textual conflict markers for manual resolution). */
|
|
244
|
+
function threeWayMergePackageJson(base, ours, theirs) {
|
|
245
|
+
let baseJson;
|
|
246
|
+
let oursJson;
|
|
247
|
+
let theirsJson;
|
|
248
|
+
try {
|
|
249
|
+
baseJson = base === void 0 ? {} : JSON.parse(base);
|
|
250
|
+
oursJson = JSON.parse(ours);
|
|
251
|
+
theirsJson = JSON.parse(theirs);
|
|
252
|
+
} catch {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!isPlainObject(oursJson) || !isPlainObject(theirsJson)) return void 0;
|
|
256
|
+
const merged = mergeNode(baseJson, oursJson, theirsJson, false);
|
|
257
|
+
if (merged === UNRESOLVED || merged === ABSENT || !isPlainObject(merged)) return void 0;
|
|
258
|
+
return merged;
|
|
259
|
+
}
|
|
260
|
+
/** Replace auto-resolvable conflicted package.json files in `mergedTree`
|
|
261
|
+
* with their structural merge. Pure index plumbing, worktree untouched. */
|
|
262
|
+
async function autoResolveDependencyConflicts(projectDir, mergedTree, conflicts, baseCommit, headCommit, nextCommit) {
|
|
263
|
+
const candidates = conflicts.filter((file) => basename(file) === "package.json");
|
|
264
|
+
if (!candidates.length) return {
|
|
265
|
+
tree: mergedTree,
|
|
266
|
+
conflicts,
|
|
267
|
+
resolved: []
|
|
268
|
+
};
|
|
269
|
+
const scratch = mkdtempSync(join(tmpdir(), "mpo-resolve-"));
|
|
270
|
+
const resolved = [];
|
|
271
|
+
try {
|
|
272
|
+
for (const file of candidates) {
|
|
273
|
+
const show = (commit) => gitOut(projectDir, ["show", `${commit}:${file}`]).catch(() => void 0);
|
|
274
|
+
const [base, ours, theirs] = await Promise.all([
|
|
275
|
+
show(baseCommit),
|
|
276
|
+
show(headCommit),
|
|
277
|
+
show(nextCommit)
|
|
278
|
+
]);
|
|
279
|
+
if (ours === void 0 || theirs === void 0) continue;
|
|
280
|
+
const merged = threeWayMergePackageJson(base, ours, theirs);
|
|
281
|
+
if (!merged) continue;
|
|
282
|
+
const blobFile = join(scratch, "blob.json");
|
|
283
|
+
writeFileSync(blobFile, `${JSON.stringify(merged, null, 2)}\n`);
|
|
284
|
+
const oid = await gitOut(projectDir, [
|
|
285
|
+
"hash-object",
|
|
286
|
+
"-w",
|
|
287
|
+
"--",
|
|
288
|
+
blobFile
|
|
289
|
+
]);
|
|
290
|
+
resolved.push({
|
|
291
|
+
path: file,
|
|
292
|
+
oid
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (!resolved.length) return {
|
|
296
|
+
tree: mergedTree,
|
|
297
|
+
conflicts,
|
|
298
|
+
resolved: []
|
|
299
|
+
};
|
|
300
|
+
const env = { GIT_INDEX_FILE: join(scratch, "index") };
|
|
301
|
+
await git(projectDir, ["read-tree", mergedTree], env);
|
|
302
|
+
for (const entry of resolved) await git(projectDir, [
|
|
303
|
+
"update-index",
|
|
304
|
+
"--add",
|
|
305
|
+
"--cacheinfo",
|
|
306
|
+
`100644,${entry.oid},${entry.path}`
|
|
307
|
+
], env);
|
|
308
|
+
const tree = await gitOut(projectDir, ["write-tree"], env);
|
|
309
|
+
const resolvedPaths = new Set(resolved.map((entry) => entry.path));
|
|
310
|
+
return {
|
|
311
|
+
tree,
|
|
312
|
+
conflicts: conflicts.filter((file) => !resolvedPaths.has(file)),
|
|
313
|
+
resolved: [...resolvedPaths]
|
|
314
|
+
};
|
|
315
|
+
} finally {
|
|
316
|
+
rmSync(scratch, {
|
|
317
|
+
recursive: true,
|
|
318
|
+
force: true
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
178
322
|
async function updateApp(options = {}) {
|
|
179
323
|
const projectDir = await gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
|
|
180
324
|
throw new Error("mpo update must run inside a git repository");
|
|
@@ -195,11 +339,12 @@ async function updateApp(options = {}) {
|
|
|
195
339
|
const targetMpoRange = options.version ? options.version.startsWith("^") || options.version.startsWith("~") ? options.version : `^${options.version}` : `^${currentVersion}`;
|
|
196
340
|
console.log(`\nmpo update: ${provenance.cliVersion} → ${currentVersion}`);
|
|
197
341
|
console.log(` template: ${provenance.template}`);
|
|
342
|
+
if (provenance.pieces.length) console.log(` pieces: ${provenance.pieces.join(", ")}`);
|
|
198
343
|
console.log(` project: ${provenance.name}\n`);
|
|
199
344
|
console.log(`Scaffolding merge base (cli@${provenance.cliVersion})...`);
|
|
200
|
-
const base = await scaffoldBaseline(provenance.cliVersion, provenance.name, provenance.template, provenance.mpoVersion);
|
|
345
|
+
const base = await scaffoldBaseline(provenance.cliVersion, provenance.name, provenance.template, provenance.mpoVersion, provenance.pieces);
|
|
201
346
|
console.log(`Scaffolding update target (cli@${currentVersion})...`);
|
|
202
|
-
const next = await scaffoldBaseline(currentVersion, provenance.name, provenance.template, targetMpoRange);
|
|
347
|
+
const next = await scaffoldBaseline(currentVersion, provenance.name, provenance.template, targetMpoRange, provenance.pieces);
|
|
203
348
|
rmSync(join(base.dir, ".mpo.json"), { force: true });
|
|
204
349
|
rmSync(join(next.dir, ".mpo.json"), { force: true });
|
|
205
350
|
try {
|
|
@@ -228,7 +373,8 @@ async function updateApp(options = {}) {
|
|
|
228
373
|
template: provenance.template,
|
|
229
374
|
cliVersion: currentVersion,
|
|
230
375
|
name: provenance.name,
|
|
231
|
-
mpoVersion: targetMpoRange
|
|
376
|
+
mpoVersion: targetMpoRange,
|
|
377
|
+
pieces: provenance.pieces
|
|
232
378
|
};
|
|
233
379
|
const pins = ["pnpm-lock.yaml"];
|
|
234
380
|
const updateignore = join(projectDir, ".updateignore");
|
|
@@ -238,6 +384,10 @@ async function updateApp(options = {}) {
|
|
|
238
384
|
}
|
|
239
385
|
mergedTree = await pinPathsToHead(projectDir, mergedTree, head, pins);
|
|
240
386
|
conflicts = conflicts.filter((file) => !pins.some((pin) => file === pin || file.startsWith(`${pin}/`)));
|
|
387
|
+
const autoResolved = await autoResolveDependencyConflicts(projectDir, mergedTree, conflicts, baseCommit, head, nextCommit);
|
|
388
|
+
mergedTree = autoResolved.tree;
|
|
389
|
+
conflicts = autoResolved.conflicts;
|
|
390
|
+
for (const file of autoResolved.resolved) console.log(` auto-resolved: ${file} (dependency ranges)`);
|
|
241
391
|
const headTree = await gitOut(projectDir, ["rev-parse", `${head}^{tree}`]);
|
|
242
392
|
if (!conflicts.length && mergedTree === headTree) {
|
|
243
393
|
if (provenance.cliVersion !== finalProvenance.cliVersion || provenance.mpoVersion !== finalProvenance.mpoVersion) {
|
|
@@ -257,6 +407,7 @@ async function updateApp(options = {}) {
|
|
|
257
407
|
} else console.log("\n✅ already up to date");
|
|
258
408
|
return;
|
|
259
409
|
}
|
|
410
|
+
let cleanMergeMessage;
|
|
260
411
|
if (!conflicts.length) {
|
|
261
412
|
const message = `chore: mpo update ${provenance.cliVersion} → ${currentVersion}`;
|
|
262
413
|
await git(projectDir, [
|
|
@@ -294,12 +445,7 @@ async function updateApp(options = {}) {
|
|
|
294
445
|
"--no-edit",
|
|
295
446
|
"--quiet"
|
|
296
447
|
]);
|
|
297
|
-
|
|
298
|
-
"rev-parse",
|
|
299
|
-
"--short",
|
|
300
|
-
"HEAD"
|
|
301
|
-
]);
|
|
302
|
-
console.log(`\n✅ merged cleanly → ${amended} ("${message}")`);
|
|
448
|
+
cleanMergeMessage = message;
|
|
303
449
|
} else {
|
|
304
450
|
await git(projectDir, [
|
|
305
451
|
"read-tree",
|
|
@@ -340,6 +486,14 @@ async function updateApp(options = {}) {
|
|
|
340
486
|
}
|
|
341
487
|
}
|
|
342
488
|
}
|
|
489
|
+
if (cleanMergeMessage) {
|
|
490
|
+
const finalHash = await gitOut(projectDir, [
|
|
491
|
+
"rev-parse",
|
|
492
|
+
"--short",
|
|
493
|
+
"HEAD"
|
|
494
|
+
]);
|
|
495
|
+
console.log(`\n✅ merged cleanly → ${finalHash} ("${cleanMergeMessage}")`);
|
|
496
|
+
}
|
|
343
497
|
} finally {
|
|
344
498
|
base.cleanup();
|
|
345
499
|
next.cleanup();
|
|
@@ -347,4 +501,4 @@ async function updateApp(options = {}) {
|
|
|
347
501
|
}
|
|
348
502
|
|
|
349
503
|
//#endregion
|
|
350
|
-
export { updateApp };
|
|
504
|
+
export { threeWayMergePackageJson, updateApp };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/cli",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.7.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.
|
|
64
|
+
"@multiplatform.one/utils": "6.7.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@types/inquirer": "^9.0.9",
|
|
@@ -75,6 +75,6 @@
|
|
|
75
75
|
"scripts": {
|
|
76
76
|
"build": "rm -rf lib types 2>/dev/null && tsc -p tsconfig.build.json && tsdown",
|
|
77
77
|
"test": "vitest run",
|
|
78
|
-
"typecheck": "
|
|
78
|
+
"typecheck": "tsgo --noEmit"
|
|
79
79
|
}
|
|
80
80
|
}
|