@multiplatform.one/cli 7.11.0 → 7.16.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 +121 -111
- package/bin/createMultiplatformApp.mjs +39 -37
- package/lib/bin/multiplatformOne.mjs +65 -75
- package/lib/commands/addApp.mjs +78 -0
- package/lib/commands/adoptApp.mjs +11 -7
- package/lib/commands/doctypeTypes.mjs +148 -0
- package/lib/commands/e2e.mjs +2 -2
- package/lib/commands/init.mjs +142 -459
- package/lib/commands/merge.mjs +359 -0
- package/lib/commands/pieces.mjs +236 -0
- package/lib/commands/provenance.mjs +108 -0
- package/lib/commands/scaffold.mjs +105 -0
- package/lib/commands/updateApp.mjs +211 -528
- package/lib/source.json +5 -0
- package/lib/source.mjs +63 -0
- package/package.json +6 -6
- package/scripts/frappe-bootstrap.sh +29 -3
- package/scripts/frappe-dev.sh +1 -2
- package/scripts/frappe-helpers.sh +50 -2
- package/scripts/frappe.sh +5 -0
- package/scripts/scaffold.sh +937 -0
- package/scripts/stamp-source.mjs +51 -0
- package/scripts/stamp-source.spec.ts +86 -0
- package/src/bin/multiplatformOne.ts +116 -226
- package/src/commands/addApp.ts +102 -0
- package/src/commands/adoptApp.spec.ts +5 -1
- package/src/commands/adoptApp.ts +15 -10
- package/src/commands/doctypeTypes.spec.ts +155 -0
- package/src/commands/doctypeTypes.ts +214 -0
- package/src/commands/init.spec.ts +66 -0
- package/src/commands/init.ts +201 -643
- package/src/commands/merge.spec.ts +81 -0
- package/src/commands/merge.ts +411 -0
- package/src/commands/pieces.spec.ts +62 -0
- package/src/commands/pieces.ts +340 -0
- package/src/commands/provenance.spec.ts +92 -0
- package/src/commands/provenance.ts +169 -0
- package/src/commands/scaffold.ts +131 -0
- package/src/commands/updateApp.spec.ts +540 -331
- package/src/commands/updateApp.ts +255 -668
- package/src/source.spec.ts +45 -0
- package/src/source.ts +88 -0
- package/templates/AGENTS.md +161 -0
- package/templates/README.md +62 -0
- package/templates/pieces/frappe/universal/README.md.partial +7 -32
- package/templates/pieces/frappe/universal/env.example.partial +3 -3
- package/templates/pieces/frappe-bench/universal/README.md.partial +28 -0
- package/templates/pieces/{frappe → frappe-bench}/universal/docker/compose.frappe.yaml +2 -2
- package/templates/pieces/tauri/universal/README.md.partial +7 -5
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/dev.rs +463 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +6 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +2 -1
- package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +1 -0
- package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +6 -2
- package/types/bin/multiplatformOne.d.ts.map +1 -1
- package/types/commands/addApp.d.ts +20 -0
- package/types/commands/addApp.d.ts.map +1 -0
- package/types/commands/adoptApp.d.ts +4 -3
- package/types/commands/adoptApp.d.ts.map +1 -1
- package/types/commands/doctypeTypes.d.ts +40 -0
- package/types/commands/doctypeTypes.d.ts.map +1 -0
- package/types/commands/init.d.ts +30 -13
- package/types/commands/init.d.ts.map +1 -1
- package/types/commands/merge.d.ts +54 -0
- package/types/commands/merge.d.ts.map +1 -0
- package/types/commands/pieces.d.ts +54 -0
- package/types/commands/pieces.d.ts.map +1 -0
- package/types/commands/provenance.d.ts +52 -0
- package/types/commands/provenance.d.ts.map +1 -0
- package/types/commands/scaffold.d.ts +43 -0
- package/types/commands/scaffold.d.ts.map +1 -0
- package/types/commands/updateApp.d.ts +19 -27
- package/types/commands/updateApp.d.ts.map +1 -1
- package/types/source.d.ts +35 -0
- package/types/source.d.ts.map +1 -0
- package/lib/commands/initApp.mjs +0 -431
- package/lib/generateVscode.mjs +0 -184
- package/scripts/clone.sh +0 -17
- package/scripts/update.sh +0 -46
- package/src/commands/initApp.spec.ts +0 -720
- package/src/commands/initApp.ts +0 -706
- package/src/generateVscode.ts +0 -238
- package/templates/app/AGENTS.md +0 -142
- package/templates/app/README.md +0 -62
- package/templates/app/apps/__NAME__/index.html +0 -36
- package/templates/app/apps/__NAME__/package.json +0 -31
- package/templates/app/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
- package/templates/app/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
- package/templates/app/apps/__NAME__/src/App.tsx +0 -9
- package/templates/app/apps/__NAME__/src/main.tsx +0 -14
- package/templates/app/apps/__NAME__/tsconfig.json +0 -8
- package/templates/app/apps/__NAME__/vite.config.ts +0 -17
- package/templates/app/features/__NAME__/package.json +0 -34
- package/templates/app/features/__NAME__/src/HomeScreen.tsx +0 -70
- package/templates/app/features/__NAME__/src/index.ts +0 -1
- package/templates/app/features/__NAME__/tsconfig.json +0 -5
- package/templates/app/gitignore +0 -9
- package/templates/app/npmrc +0 -2
- package/templates/app/oxlintrc.json +0 -39
- package/templates/app/package.json +0 -38
- package/templates/app/packages/config/config.json +0 -4
- package/templates/app/packages/config/index.ts +0 -5
- package/templates/app/packages/config/package.json +0 -17
- package/templates/app/packages/config/tsconfig.json +0 -5
- package/templates/app/pnpm-workspace.yaml +0 -4
- package/templates/app/tsconfig.base.json +0 -16
- package/templates/universal/AGENTS.md +0 -142
- package/templates/universal/README.md +0 -97
- package/templates/universal/apps/__NAME__/app.config.js +0 -31
- package/templates/universal/apps/__NAME__/config/tamagui.config.ts +0 -4
- package/templates/universal/apps/__NAME__/package.json +0 -62
- package/templates/universal/apps/__NAME__/polyfills.native.ts +0 -23
- package/templates/universal/apps/__NAME__/polyfills.ts +0 -3
- package/templates/universal/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
- package/templates/universal/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
- package/templates/universal/apps/__NAME__/routes/_layout.tsx +0 -22
- package/templates/universal/apps/__NAME__/routes/_layout.web.tsx +0 -21
- package/templates/universal/apps/__NAME__/routes/about.tsx +0 -3
- package/templates/universal/apps/__NAME__/routes/index.tsx +0 -3
- package/templates/universal/apps/__NAME__/src/app.ts +0 -10
- package/templates/universal/apps/__NAME__/tamagui.css +0 -3
- package/templates/universal/apps/__NAME__/tsconfig.json +0 -8
- package/templates/universal/apps/__NAME__/types.d.ts +0 -9
- package/templates/universal/apps/__NAME__/vite.config.ts +0 -20
- package/templates/universal/features/__NAME__/package.json +0 -35
- package/templates/universal/features/__NAME__/src/AboutScreen.tsx +0 -27
- package/templates/universal/features/__NAME__/src/AppLayout.tsx +0 -50
- package/templates/universal/features/__NAME__/src/ContactForm.spec.tsx +0 -22
- package/templates/universal/features/__NAME__/src/ContactForm.tsx +0 -55
- package/templates/universal/features/__NAME__/src/HomeScreen.tsx +0 -40
- package/templates/universal/features/__NAME__/src/index.ts +0 -4
- package/templates/universal/features/__NAME__/src/screens.spec.ts +0 -12
- package/templates/universal/features/__NAME__/tsconfig.json +0 -5
- package/templates/universal/features/__NAME__/vitest.config.mjs +0 -7
- package/templates/universal/gitignore +0 -14
- package/templates/universal/npmrc +0 -3
- package/templates/universal/oxlintrc.json +0 -39
- package/templates/universal/package.json +0 -51
- package/templates/universal/packages/config/config.json +0 -4
- package/templates/universal/packages/config/index.ts +0 -5
- package/templates/universal/packages/config/package.json +0 -17
- package/templates/universal/packages/config/tsconfig.json +0 -5
- package/templates/universal/packages/i18n/en/common.json +0 -30
- package/templates/universal/packages/i18n/es/common.json +0 -30
- package/templates/universal/packages/i18n/i18n.d.ts +0 -16
- package/templates/universal/packages/i18n/index.ts +0 -26
- package/templates/universal/packages/i18n/package.json +0 -20
- package/templates/universal/packages/i18n/resources.ts +0 -13
- package/templates/universal/packages/i18n/tsconfig.json +0 -5
- package/templates/universal/packages/themes/accent.ts +0 -32
- package/templates/universal/packages/themes/base.ts +0 -50
- package/templates/universal/packages/themes/fonts.ts +0 -8
- package/templates/universal/packages/themes/index.ts +0 -33
- package/templates/universal/packages/themes/package.json +0 -23
- package/templates/universal/packages/themes/tamagui.config.ts +0 -14
- package/templates/universal/packages/themes/tsconfig.json +0 -5
- package/templates/universal/pnpm-workspace.yaml +0 -4
- package/templates/universal/tsconfig.base.json +0 -21
- package/templates/universal/types/env.d.ts +0 -9
- package/types/commands/initApp.d.ts +0 -114
- package/types/commands/initApp.d.ts.map +0 -1
- package/types/generateVscode.d.ts +0 -7
- package/types/generateVscode.d.ts.map +0 -1
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import {
|
|
2
|
+
copyFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { extname, join } from "node:path";
|
|
10
|
+
import { cliRoot } from "./provenance";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pieces: additive fragment trees under templates/pieces/<piece>/ that an
|
|
14
|
+
* ADOPTED project (`mpo adopt`) hand-copied and wants `mpo update` to keep
|
|
15
|
+
* reconciling. A project from `mpo init` never needs them: apps/one already
|
|
16
|
+
* ships gnome/, tauri/, vscode/ and webext/, and frappe / keycloak are
|
|
17
|
+
* `--service` selections of the real apps. Keep sorted: pieces are recorded
|
|
18
|
+
* sorted in provenance and overlaid in this order.
|
|
19
|
+
*/
|
|
20
|
+
export const INIT_APP_PIECES = [
|
|
21
|
+
"frappe",
|
|
22
|
+
"frappe-bench",
|
|
23
|
+
"gnome",
|
|
24
|
+
"keycloak",
|
|
25
|
+
"tauri",
|
|
26
|
+
"vscode",
|
|
27
|
+
"webext",
|
|
28
|
+
] as const;
|
|
29
|
+
export type InitAppPiece = (typeof INIT_APP_PIECES)[number];
|
|
30
|
+
|
|
31
|
+
const PIECE_DESCRIPTIONS: Record<InitAppPiece, string> = {
|
|
32
|
+
frappe: "Frappe client wiring (env keys, provider)",
|
|
33
|
+
"frappe-bench": "Frappe dev bench (docker compose: bench, MariaDB, Redis)",
|
|
34
|
+
gnome: "GNOME desktop target (GTK4/GJS via react-gnome — native widgets, no webview)",
|
|
35
|
+
keycloak: "Keycloak auth wiring (env keys, provider, docker compose + realm)",
|
|
36
|
+
tauri: "Tauri desktop target (src-tauri skeleton — webview shell, Rust toolchain)",
|
|
37
|
+
vscode: "VS Code extension target (extension host + Tamagui webview)",
|
|
38
|
+
webext: "Browser extension target (MV3 popup + background)",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function piecesRoot(): string {
|
|
42
|
+
return join(cliRoot(), "templates/pieces");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type TemplateVars = {
|
|
46
|
+
NAME: string;
|
|
47
|
+
NAME_PASCAL: string;
|
|
48
|
+
/** Lowercase alphanumeric form for iOS bundle ids / Android packages. */
|
|
49
|
+
NAME_APPID: string;
|
|
50
|
+
MPO_VERSION: string;
|
|
51
|
+
YEAR: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function toPascalCase(name: string): string {
|
|
55
|
+
return name
|
|
56
|
+
.split(/[-_\s]+/)
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
59
|
+
.join("");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fragment source dirs for a piece: shared/ plus universal/ (the layout
|
|
64
|
+
* adopted repos follow: apps/<name>/...). A piece is available when at least
|
|
65
|
+
* one of the two exists.
|
|
66
|
+
*/
|
|
67
|
+
function pieceFragmentDirs(piece: InitAppPiece, root: string): string[] {
|
|
68
|
+
return [join(root, piece, "shared"), join(root, piece, "universal")].filter((dir) =>
|
|
69
|
+
existsSync(dir),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* npm always strips .gitignore / .npmrc (and newer npms .env*) from published
|
|
75
|
+
* tarballs, so the template stores them without the leading dot; restore it
|
|
76
|
+
* at scaffold time.
|
|
77
|
+
*/
|
|
78
|
+
const DOTFILE_NAMES: Record<string, string> = {
|
|
79
|
+
gitignore: ".gitignore",
|
|
80
|
+
npmrc: ".npmrc",
|
|
81
|
+
"env.example": ".env.example",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function render(content: string, vars: TemplateVars): string {
|
|
85
|
+
return content
|
|
86
|
+
.replaceAll("__NAME_PASCAL__", vars.NAME_PASCAL)
|
|
87
|
+
.replaceAll("__NAME_APPID__", vars.NAME_APPID)
|
|
88
|
+
.replaceAll("__NAME__", vars.NAME)
|
|
89
|
+
.replaceAll("__MPO_VERSION__", vars.MPO_VERSION)
|
|
90
|
+
.replaceAll("__YEAR__", vars.YEAR);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Assets copied byte-for-byte; the utf-8 render pass would corrupt them. */
|
|
94
|
+
const BINARY_EXTENSIONS = new Set([
|
|
95
|
+
".woff2",
|
|
96
|
+
".woff",
|
|
97
|
+
".ttf",
|
|
98
|
+
".otf",
|
|
99
|
+
".png",
|
|
100
|
+
".jpg",
|
|
101
|
+
".ico",
|
|
102
|
+
".icns",
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
106
|
+
|
|
107
|
+
function isJsonObject(value: JsonValue | undefined): value is { [key: string]: JsonValue } {
|
|
108
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Deep-merge for JSON partials: objects merge recursively (fragment keys
|
|
113
|
+
* append after base keys, so e.g. package.json script order stays stable),
|
|
114
|
+
* arrays union (base order first), scalars/type-mismatches take the
|
|
115
|
+
* fragment value.
|
|
116
|
+
*/
|
|
117
|
+
export function mergeJsonValues(base: JsonValue, fragment: JsonValue): JsonValue {
|
|
118
|
+
if (Array.isArray(base) && Array.isArray(fragment)) {
|
|
119
|
+
const merged = [...base];
|
|
120
|
+
const seen = new Set(base.map((item) => JSON.stringify(item)));
|
|
121
|
+
for (const item of fragment) {
|
|
122
|
+
const key = JSON.stringify(item);
|
|
123
|
+
if (seen.has(key)) continue;
|
|
124
|
+
seen.add(key);
|
|
125
|
+
merged.push(item);
|
|
126
|
+
}
|
|
127
|
+
return merged;
|
|
128
|
+
}
|
|
129
|
+
if (isJsonObject(base) && isJsonObject(fragment)) {
|
|
130
|
+
const merged: { [key: string]: JsonValue } = { ...base };
|
|
131
|
+
for (const [key, value] of Object.entries(fragment)) {
|
|
132
|
+
merged[key] = key in merged ? mergeJsonValues(merged[key]!, value) : value;
|
|
133
|
+
}
|
|
134
|
+
return merged;
|
|
135
|
+
}
|
|
136
|
+
return fragment;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const SORTED_DEPENDENCY_FIELDS = [
|
|
140
|
+
"dependencies",
|
|
141
|
+
"devDependencies",
|
|
142
|
+
"peerDependencies",
|
|
143
|
+
"optionalDependencies",
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
/** Dependency maps stay sorted after fragment merges so overlay order never changes the bytes. */
|
|
147
|
+
function sortDependencyFields(value: JsonValue): JsonValue {
|
|
148
|
+
if (!isJsonObject(value)) return value;
|
|
149
|
+
const result = { ...value };
|
|
150
|
+
for (const field of SORTED_DEPENDENCY_FIELDS) {
|
|
151
|
+
const deps = result[field];
|
|
152
|
+
if (!isJsonObject(deps)) continue;
|
|
153
|
+
result[field] = Object.fromEntries(
|
|
154
|
+
Object.entries(deps).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Apply a rendered `<target>.partial` fragment onto the target file:
|
|
162
|
+
* - `.json` targets deep-merge (see mergeJsonValues)
|
|
163
|
+
* - everything else appends as a text block separated by a blank line
|
|
164
|
+
* Missing targets are created from the fragment alone.
|
|
165
|
+
*/
|
|
166
|
+
function applyPartial(targetPath: string, renderedFragment: string): void {
|
|
167
|
+
if (targetPath.endsWith(".json")) {
|
|
168
|
+
const fragment = JSON.parse(renderedFragment) as JsonValue;
|
|
169
|
+
const base = existsSync(targetPath)
|
|
170
|
+
? (JSON.parse(readFileSync(targetPath, "utf-8")) as JsonValue)
|
|
171
|
+
: undefined;
|
|
172
|
+
const merged = sortDependencyFields(
|
|
173
|
+
base === undefined ? fragment : mergeJsonValues(base, fragment),
|
|
174
|
+
);
|
|
175
|
+
writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const block = `${renderedFragment.replace(/^\n+/u, "").replace(/\n+$/u, "")}\n`;
|
|
179
|
+
if (!existsSync(targetPath)) {
|
|
180
|
+
writeFileSync(targetPath, block);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const existing = readFileSync(targetPath, "utf-8").replace(/\n+$/u, "");
|
|
184
|
+
writeFileSync(targetPath, `${existing}\n\n${block}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const PARTIAL_SUFFIX = ".partial";
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Overlay a piece fragment tree: regular files render and overwrite,
|
|
191
|
+
* `<target>.partial` files merge into (or create) their target.
|
|
192
|
+
*/
|
|
193
|
+
function overlayTree(srcDir: string, destDir: string, vars: TemplateVars): void {
|
|
194
|
+
mkdirSync(destDir, { recursive: true });
|
|
195
|
+
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
196
|
+
const from = join(srcDir, entry.name);
|
|
197
|
+
if (entry.isDirectory()) {
|
|
198
|
+
overlayTree(from, join(destDir, render(DOTFILE_NAMES[entry.name] ?? entry.name, vars)), vars);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (entry.name.endsWith(PARTIAL_SUFFIX)) {
|
|
202
|
+
const targetName = entry.name.slice(0, -PARTIAL_SUFFIX.length);
|
|
203
|
+
const toName = render(DOTFILE_NAMES[targetName] ?? targetName, vars);
|
|
204
|
+
applyPartial(join(destDir, toName), render(readFileSync(from, "utf-8"), vars));
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const toName = render(DOTFILE_NAMES[entry.name] ?? entry.name, vars);
|
|
208
|
+
const to = join(destDir, toName);
|
|
209
|
+
if (BINARY_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
|
210
|
+
copyFileSync(from, to);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
writeFileSync(to, render(readFileSync(from, "utf-8"), vars));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Validate + canonicalize a piece selection: unknown names throw, duplicates
|
|
219
|
+
* collapse, order is always sorted, and every piece must ship a fragment.
|
|
220
|
+
*/
|
|
221
|
+
export function normalizePieces(pieces: string[], root = piecesRoot()): InitAppPiece[] {
|
|
222
|
+
const known = new Set<string>(INIT_APP_PIECES);
|
|
223
|
+
const normalized = [...new Set(pieces)].sort() as InitAppPiece[];
|
|
224
|
+
for (const piece of normalized) {
|
|
225
|
+
if (!known.has(piece)) {
|
|
226
|
+
throw new Error(`Unknown piece "${piece}". Available pieces: ${INIT_APP_PIECES.join(", ")}`);
|
|
227
|
+
}
|
|
228
|
+
if (pieceFragmentDirs(piece, root).length === 0) {
|
|
229
|
+
throw new Error(`Piece "${piece}" ships no fragments under ${root}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return normalized;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The frappe piece carried the dev bench until the bench became its own piece
|
|
237
|
+
* (MPO-241). An adoption recorded against pieces that still bundle it keeps
|
|
238
|
+
* the bench, now as frappe-bench, so an update does not delete it.
|
|
239
|
+
*/
|
|
240
|
+
export function withSplitPieces(pieces: InitAppPiece[], recordedRoot?: string): InitAppPiece[] {
|
|
241
|
+
if (!recordedRoot || !pieces.includes("frappe") || pieces.includes("frappe-bench")) return pieces;
|
|
242
|
+
if (!existsSync(join(recordedRoot, "frappe/universal/docker/compose.frappe.yaml"))) return pieces;
|
|
243
|
+
return [...pieces, "frappe-bench" as const].sort();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function availablePieces(root = piecesRoot()): InitAppPiece[] {
|
|
247
|
+
return INIT_APP_PIECES.filter((piece) => pieceFragmentDirs(piece, root).length > 0);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function pieceDescription(piece: InitAppPiece): string {
|
|
251
|
+
return PIECE_DESCRIPTIONS[piece];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* apps/<name>/src/app.ts for the selected backend pieces: each provider
|
|
256
|
+
* adds one createApp key. Only written when a provider piece is selected.
|
|
257
|
+
*/
|
|
258
|
+
export function composeAppTs(pieces: InitAppPiece[]): string {
|
|
259
|
+
const frappe = pieces.includes("frappe");
|
|
260
|
+
const keycloak = pieces.includes("keycloak");
|
|
261
|
+
const lines: string[] = ['import { createApp } from "@multiplatform.one/core";'];
|
|
262
|
+
if (frappe || keycloak) {
|
|
263
|
+
lines.push('import { config } from "@multiplatform.one/platform";');
|
|
264
|
+
}
|
|
265
|
+
lines.push('import { themeConfig } from "@package/themes";');
|
|
266
|
+
lines.push(
|
|
267
|
+
"",
|
|
268
|
+
"/**",
|
|
269
|
+
" * App-wide provider stack. Providers below were enabled by the pieces",
|
|
270
|
+
" * this project adopted — see CreateAppConfig in @multiplatform.one/core",
|
|
271
|
+
" * for everything else that can be wired here (tanstack, web3, custom",
|
|
272
|
+
" * providers).",
|
|
273
|
+
" */",
|
|
274
|
+
"export const { AppProvider, useAppConfig, withApp } = createApp({",
|
|
275
|
+
" theme: themeConfig,",
|
|
276
|
+
);
|
|
277
|
+
if (frappe) {
|
|
278
|
+
lines.push(
|
|
279
|
+
" // Frappe client: FrappeProvider mounts when FRAPPE_ENABLED=1 (a",
|
|
280
|
+
" // packages/config public env key, loaded from .env — see",
|
|
281
|
+
" // .env.example). FRAPPE_URL names the Frappe server (default:",
|
|
282
|
+
" // BASE_URL, then http://localhost:8000).",
|
|
283
|
+
);
|
|
284
|
+
if (pieces.includes("frappe-bench")) {
|
|
285
|
+
lines.push(" // docker/compose.frappe.yaml runs a dev bench.");
|
|
286
|
+
}
|
|
287
|
+
lines.push(' frappe: { baseURL: config.get("FRAPPE_URL") },');
|
|
288
|
+
}
|
|
289
|
+
if (keycloak) {
|
|
290
|
+
lines.push(
|
|
291
|
+
" // Keycloak auth: KeycloakProvider mounts when KEYCLOAK_ENABLED=1.",
|
|
292
|
+
" // Realm/client settings come from the KEYCLOAK_* env keys",
|
|
293
|
+
" // (packages/config, .env.example); docker/compose.keycloak.yaml runs",
|
|
294
|
+
" // a dev server with an imported realm.",
|
|
295
|
+
' keycloak: { baseUrl: config.get("KEYCLOAK_BASE_URL") },',
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
lines.push("});", "");
|
|
299
|
+
return lines.join("\n");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface PiecesScaffold {
|
|
303
|
+
name: string;
|
|
304
|
+
mpoVersion: string;
|
|
305
|
+
pieces: InitAppPiece[];
|
|
306
|
+
/** templates/pieces of another CLI version (the merge base of an older adoption). */
|
|
307
|
+
root?: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The tool-owned surface of an adopted project: the selected piece
|
|
312
|
+
* fragments overlaid, in sorted order, on an EMPTY tree. `mpo update`
|
|
313
|
+
* builds both merge baselines of an adopted project with this.
|
|
314
|
+
*/
|
|
315
|
+
export function scaffoldPieces(targetDir: string, request: PiecesScaffold): void {
|
|
316
|
+
const root = request.root ?? piecesRoot();
|
|
317
|
+
const pieces = normalizePieces(request.pieces, root);
|
|
318
|
+
if (!pieces.length) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`A pieces scaffold needs at least one piece (${INIT_APP_PIECES.map((piece) => `--${piece}`).join(", ")})`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
const vars: TemplateVars = {
|
|
324
|
+
NAME: request.name,
|
|
325
|
+
NAME_PASCAL: toPascalCase(request.name),
|
|
326
|
+
NAME_APPID: request.name.replaceAll("-", ""),
|
|
327
|
+
MPO_VERSION: request.mpoVersion,
|
|
328
|
+
YEAR: String(new Date().getFullYear()),
|
|
329
|
+
};
|
|
330
|
+
mkdirSync(targetDir, { recursive: true });
|
|
331
|
+
for (const piece of pieces) {
|
|
332
|
+
for (const fragmentDir of pieceFragmentDirs(piece, root)) {
|
|
333
|
+
overlayTree(fragmentDir, targetDir, vars);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (pieces.includes("frappe") || pieces.includes("keycloak")) {
|
|
337
|
+
mkdirSync(join(targetDir, "apps", request.name, "src"), { recursive: true });
|
|
338
|
+
writeFileSync(join(targetDir, "apps", request.name, "src/app.ts"), composeAppTs(pieces));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
5
|
+
import { mpoRange, readProvenance, writeProvenance } from "./provenance";
|
|
6
|
+
|
|
7
|
+
describe("provenance", () => {
|
|
8
|
+
let dir: string;
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
dir = mkdtempSync(join(tmpdir(), "mpo-provenance-spec-"));
|
|
11
|
+
});
|
|
12
|
+
afterAll(() => {
|
|
13
|
+
rmSync(dir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("round-trips the clone-derived record", () => {
|
|
17
|
+
writeProvenance(dir, {
|
|
18
|
+
cliVersion: "7.11.0",
|
|
19
|
+
source: { sha: "a".repeat(40), url: "https://example.com/mpo.git" },
|
|
20
|
+
apps: ["shop", "admin"],
|
|
21
|
+
services: ["keycloak", "frappe"],
|
|
22
|
+
pieces: [],
|
|
23
|
+
});
|
|
24
|
+
expect(JSON.parse(readFileSync(join(dir, ".mpo.json"), "utf-8"))).toEqual({
|
|
25
|
+
cliVersion: "7.11.0",
|
|
26
|
+
source: { sha: "a".repeat(40), url: "https://example.com/mpo.git" },
|
|
27
|
+
apps: ["shop", "admin"],
|
|
28
|
+
services: ["frappe", "keycloak"],
|
|
29
|
+
pieces: [],
|
|
30
|
+
});
|
|
31
|
+
expect(readProvenance(dir)).toEqual({
|
|
32
|
+
cliVersion: "7.11.0",
|
|
33
|
+
source: { sha: "a".repeat(40), url: "https://example.com/mpo.git" },
|
|
34
|
+
apps: ["shop", "admin"],
|
|
35
|
+
services: ["frappe", "keycloak"],
|
|
36
|
+
pieces: [],
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("reads the template-era {template, name} record as one product app with no base", () => {
|
|
41
|
+
writeFileSync(
|
|
42
|
+
join(dir, ".mpo.json"),
|
|
43
|
+
JSON.stringify({ template: "universal", cliVersion: "7.7.1", name: "wealthflow" }),
|
|
44
|
+
);
|
|
45
|
+
expect(readProvenance(dir)).toEqual({
|
|
46
|
+
cliVersion: "7.7.1",
|
|
47
|
+
apps: ["wealthflow"],
|
|
48
|
+
services: [],
|
|
49
|
+
pieces: [],
|
|
50
|
+
template: "universal",
|
|
51
|
+
name: "wealthflow",
|
|
52
|
+
mpoVersion: "^7.7.1",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("reads an adopted record with its pieces sorted and the reconciled flag", () => {
|
|
57
|
+
writeFileSync(
|
|
58
|
+
join(dir, ".mpo.json"),
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
template: "none",
|
|
61
|
+
cliVersion: "7.9.0",
|
|
62
|
+
name: "easel",
|
|
63
|
+
mpoVersion: "^7.9.0",
|
|
64
|
+
pieces: ["webext", "gnome"],
|
|
65
|
+
reconciled: false,
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
expect(readProvenance(dir)).toEqual({
|
|
69
|
+
cliVersion: "7.9.0",
|
|
70
|
+
apps: [],
|
|
71
|
+
services: [],
|
|
72
|
+
pieces: ["gnome", "webext"],
|
|
73
|
+
template: "none",
|
|
74
|
+
name: "easel",
|
|
75
|
+
mpoVersion: "^7.9.0",
|
|
76
|
+
reconciled: false,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns undefined for records it cannot use", () => {
|
|
81
|
+
writeFileSync(join(dir, ".mpo.json"), JSON.stringify({ template: "universal" }));
|
|
82
|
+
expect(readProvenance(dir)).toBeUndefined();
|
|
83
|
+
writeFileSync(join(dir, ".mpo.json"), "not json");
|
|
84
|
+
expect(readProvenance(dir)).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("mpoRange carets a bare version and passes ranges through", () => {
|
|
88
|
+
expect(mpoRange("7.11.0")).toBe("^7.11.0");
|
|
89
|
+
expect(mpoRange("~7.11.0")).toBe("~7.11.0");
|
|
90
|
+
expect(mpoRange(undefined, "7.11.0")).toBe("^7.11.0");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { InitAppPiece } from "./pieces";
|
|
5
|
+
|
|
6
|
+
export const PROVENANCE_FILE = ".mpo.json";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What `mpo update` needs to rebuild the merge base of a project.
|
|
10
|
+
*
|
|
11
|
+
* A project from `mpo init` records the monorepo commit it was pruned from
|
|
12
|
+
* and the selection made at that time; the scaffold at that commit with
|
|
13
|
+
* that selection IS the merge base. An adopted project (`mpo adopt`,
|
|
14
|
+
* template "none") records only the pieces it hand-copied. A project from
|
|
15
|
+
* the retired template scaffolder (template "universal" / "app") has no
|
|
16
|
+
* reproducible base left; its next update reconciles from an empty tree.
|
|
17
|
+
*/
|
|
18
|
+
export interface MpoProvenance {
|
|
19
|
+
cliVersion: string;
|
|
20
|
+
/** The monorepo commit the project was scaffolded from. */
|
|
21
|
+
source?: { sha: string; url?: string };
|
|
22
|
+
/** Product apps derived from apps/one. */
|
|
23
|
+
apps: string[];
|
|
24
|
+
/** Singleton apps kept under apps/ (frappe, keycloak, storybook, ...). */
|
|
25
|
+
services: string[];
|
|
26
|
+
/** Pieces overlaid for adopted projects (sorted). */
|
|
27
|
+
pieces: InitAppPiece[];
|
|
28
|
+
/** "none" = adopted; "universal" / "app" = template-era. Absent on clone-derived projects. */
|
|
29
|
+
template?: "none" | "universal" | "app";
|
|
30
|
+
/** Adopted and template-era projects: the name that renders piece paths (apps/<name>/...). */
|
|
31
|
+
name?: string;
|
|
32
|
+
mpoVersion?: string;
|
|
33
|
+
/**
|
|
34
|
+
* false = adopted but never reconciled: `mpo adopt` wrote provenance
|
|
35
|
+
* without touching a project file, so the first update merges the piece
|
|
36
|
+
* baseline from an EMPTY base and then clears the flag.
|
|
37
|
+
*/
|
|
38
|
+
reconciled?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function validateName(name: string): string {
|
|
42
|
+
const trimmed = name.trim();
|
|
43
|
+
if (!trimmed) {
|
|
44
|
+
throw new Error("Project name is required");
|
|
45
|
+
}
|
|
46
|
+
if (!/^[a-z][a-z0-9-]*$/.test(trimmed)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Invalid project name "${trimmed}". Use lowercase letters, numbers, and hyphens (e.g. my-app).`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return trimmed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Root package.json name with any @scope/ prefix stripped (adopt derives the project name from it). */
|
|
55
|
+
export function packageJsonName(projectDir: string): string | undefined {
|
|
56
|
+
try {
|
|
57
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf-8")) as {
|
|
58
|
+
name?: string;
|
|
59
|
+
};
|
|
60
|
+
return pkg.name?.replace(/^@[^/]+\//u, "");
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function cliRoot(): string {
|
|
67
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function cliVersion(): string | undefined {
|
|
71
|
+
try {
|
|
72
|
+
const pkg = JSON.parse(readFileSync(join(cliRoot(), "package.json"), "utf-8")) as {
|
|
73
|
+
version?: string;
|
|
74
|
+
};
|
|
75
|
+
return pkg.version;
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `^x.y.z` for a bare version; explicit ranges pass through. */
|
|
82
|
+
export function mpoRange(explicit: string | undefined, fallbackVersion = cliVersion()): string {
|
|
83
|
+
if (explicit) {
|
|
84
|
+
return explicit.startsWith("^") || explicit.startsWith("~") || explicit === "*"
|
|
85
|
+
? explicit
|
|
86
|
+
: `^${explicit}`;
|
|
87
|
+
}
|
|
88
|
+
return fallbackVersion ? `^${fallbackVersion}` : "^0.0.0";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function writeProvenance(targetDir: string, provenance: MpoProvenance): void {
|
|
92
|
+
const pieces = [...provenance.pieces].sort();
|
|
93
|
+
const record: Record<string, unknown> = provenance.source
|
|
94
|
+
? {
|
|
95
|
+
cliVersion: provenance.cliVersion,
|
|
96
|
+
source: provenance.source,
|
|
97
|
+
apps: provenance.apps,
|
|
98
|
+
services: [...provenance.services].sort(),
|
|
99
|
+
pieces,
|
|
100
|
+
}
|
|
101
|
+
: {
|
|
102
|
+
template: provenance.template ?? "none",
|
|
103
|
+
cliVersion: provenance.cliVersion,
|
|
104
|
+
name: provenance.name,
|
|
105
|
+
mpoVersion: provenance.mpoVersion ?? `^${provenance.cliVersion}`,
|
|
106
|
+
pieces,
|
|
107
|
+
...(provenance.reconciled === false ? { reconciled: false } : {}),
|
|
108
|
+
};
|
|
109
|
+
writeFileSync(join(targetDir, PROVENANCE_FILE), `${JSON.stringify(record, null, 2)}\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Reads both shapes: the clone-derived record ({cliVersion, source, apps,
|
|
114
|
+
* services, pieces}) and the older {template, name} record written by
|
|
115
|
+
* `mpo adopt` and the retired template scaffolder.
|
|
116
|
+
*/
|
|
117
|
+
export function readProvenance(projectDir: string): MpoProvenance | undefined {
|
|
118
|
+
const file = join(projectDir, PROVENANCE_FILE);
|
|
119
|
+
if (!existsSync(file)) return undefined;
|
|
120
|
+
let raw: Record<string, unknown>;
|
|
121
|
+
try {
|
|
122
|
+
raw = JSON.parse(readFileSync(file, "utf-8")) as Record<string, unknown>;
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
if (typeof raw.cliVersion !== "string") return undefined;
|
|
127
|
+
const strings = (value: unknown): string[] =>
|
|
128
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
129
|
+
const pieces = strings(raw.pieces).sort() as InitAppPiece[];
|
|
130
|
+
const source = raw.source as { sha?: unknown; url?: unknown } | undefined;
|
|
131
|
+
if (source && typeof source.sha === "string") {
|
|
132
|
+
return {
|
|
133
|
+
cliVersion: raw.cliVersion,
|
|
134
|
+
source: {
|
|
135
|
+
sha: source.sha,
|
|
136
|
+
...(typeof source.url === "string" ? { url: source.url } : {}),
|
|
137
|
+
},
|
|
138
|
+
apps: strings(raw.apps),
|
|
139
|
+
services: strings(raw.services).sort(),
|
|
140
|
+
pieces,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const template = raw.template;
|
|
144
|
+
if (template !== "none" && template !== "universal" && template !== "app") {
|
|
145
|
+
// A clone-derived record without a sha cannot be rebuilt.
|
|
146
|
+
if (Array.isArray(raw.apps)) {
|
|
147
|
+
return {
|
|
148
|
+
cliVersion: raw.cliVersion,
|
|
149
|
+
apps: strings(raw.apps),
|
|
150
|
+
services: strings(raw.services).sort(),
|
|
151
|
+
pieces,
|
|
152
|
+
template: "universal",
|
|
153
|
+
name: strings(raw.apps)[0],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
if (typeof raw.name !== "string") return undefined;
|
|
159
|
+
return {
|
|
160
|
+
cliVersion: raw.cliVersion,
|
|
161
|
+
apps: template === "none" ? [] : [raw.name],
|
|
162
|
+
services: [],
|
|
163
|
+
pieces,
|
|
164
|
+
template,
|
|
165
|
+
name: raw.name,
|
|
166
|
+
mpoVersion: typeof raw.mpoVersion === "string" ? raw.mpoVersion : `^${raw.cliVersion}`,
|
|
167
|
+
...(raw.reconciled === false ? { reconciled: false } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|