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