@multiplatform.one/cli 6.4.2 → 6.6.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 +6 -4
- package/lib/commands/init.mjs +1 -1
- package/lib/commands/initApp.mjs +209 -8
- package/lib/commands/updateApp.mjs +170 -16
- package/package.json +2 -2
- package/scripts/clone.sh +1 -1
- package/scripts/update.sh +1 -1
- package/src/bin/multiplatformOne.ts +127 -108
- package/src/commands/init.ts +1 -1
- package/src/commands/initApp.spec.ts +369 -3
- package/src/commands/initApp.ts +325 -7
- package/src/commands/updateApp.spec.ts +81 -0
- package/src/commands/updateApp.ts +213 -12
- package/templates/app/package.json +19 -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 +1 -1
- package/templates/universal/package.json +25 -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/src/commands/initApp.ts
CHANGED
|
@@ -20,6 +20,31 @@ import spawn from "nano-spawn";
|
|
|
20
20
|
*/
|
|
21
21
|
export type InitAppTemplate = "universal" | "app";
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Optional composable pieces overlaid onto the base template (additive
|
|
25
|
+
* fragments under templates/pieces/<piece>/ — the inverse of the old
|
|
26
|
+
* cookiecutter KEEP_SERVICES/KEEP_PLATFORMS removal hook). Keep sorted:
|
|
27
|
+
* pieces are recorded sorted in provenance and overlaid in this order.
|
|
28
|
+
*/
|
|
29
|
+
export const INIT_APP_PIECES = [
|
|
30
|
+
"frappe",
|
|
31
|
+
"gnome",
|
|
32
|
+
"keycloak",
|
|
33
|
+
"tauri",
|
|
34
|
+
"vscode",
|
|
35
|
+
"webext",
|
|
36
|
+
] as const;
|
|
37
|
+
export type InitAppPiece = (typeof INIT_APP_PIECES)[number];
|
|
38
|
+
|
|
39
|
+
const PIECE_DESCRIPTIONS: Record<InitAppPiece, string> = {
|
|
40
|
+
frappe: "Frappe backend wiring (env keys, provider, docker compose bench)",
|
|
41
|
+
gnome: "GNOME desktop target (GTK4/GJS via react-gnome — native widgets, no webview)",
|
|
42
|
+
keycloak: "Keycloak auth wiring (env keys, provider, docker compose + realm)",
|
|
43
|
+
tauri: "Desktop target (src-tauri skeleton, tauri scripts)",
|
|
44
|
+
vscode: "VS Code extension target (extension host + Tamagui webview)",
|
|
45
|
+
webext: "Browser extension target (MV3 popup + background)",
|
|
46
|
+
};
|
|
47
|
+
|
|
23
48
|
export interface InitAppOptions {
|
|
24
49
|
/** Skip pnpm install (useful for CI / dry scaffolding). */
|
|
25
50
|
skipInstall?: boolean;
|
|
@@ -31,6 +56,11 @@ export interface InitAppOptions {
|
|
|
31
56
|
template?: InitAppTemplate;
|
|
32
57
|
/** Accept defaults instead of prompting (non-interactive / CI). */
|
|
33
58
|
yes?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Composable pieces overlaid onto the base template. When omitted:
|
|
61
|
+
* checkbox prompt (interactive) or none (--yes / non-TTY).
|
|
62
|
+
*/
|
|
63
|
+
pieces?: InitAppPiece[];
|
|
34
64
|
}
|
|
35
65
|
|
|
36
66
|
type TemplateVars = {
|
|
@@ -94,12 +124,17 @@ export interface MpoProvenance {
|
|
|
94
124
|
cliVersion: string;
|
|
95
125
|
name: string;
|
|
96
126
|
mpoVersion: string;
|
|
127
|
+
/** Composable pieces overlaid at init time (sorted). */
|
|
128
|
+
pieces: InitAppPiece[];
|
|
97
129
|
}
|
|
98
130
|
|
|
99
131
|
export const PROVENANCE_FILE = ".mpo.json";
|
|
100
132
|
|
|
101
133
|
export function writeProvenance(targetDir: string, provenance: MpoProvenance): void {
|
|
102
|
-
writeFileSync(
|
|
134
|
+
writeFileSync(
|
|
135
|
+
join(targetDir, PROVENANCE_FILE),
|
|
136
|
+
`${JSON.stringify({ ...provenance, pieces: [...provenance.pieces].sort() }, null, 2)}\n`,
|
|
137
|
+
);
|
|
103
138
|
}
|
|
104
139
|
|
|
105
140
|
export function readProvenance(projectDir: string): MpoProvenance | undefined {
|
|
@@ -113,6 +148,8 @@ export function readProvenance(projectDir: string): MpoProvenance | undefined {
|
|
|
113
148
|
cliVersion: raw.cliVersion,
|
|
114
149
|
name: raw.name,
|
|
115
150
|
mpoVersion: raw.mpoVersion ?? `^${raw.cliVersion}`,
|
|
151
|
+
// Scaffolds from pre-piece CLIs (≤6.4.x) have no pieces field.
|
|
152
|
+
pieces: Array.isArray(raw.pieces) ? [...(raw.pieces as InitAppPiece[])].sort() : [],
|
|
116
153
|
};
|
|
117
154
|
} catch {
|
|
118
155
|
return undefined;
|
|
@@ -123,13 +160,30 @@ function templatesRoot(template: InitAppTemplate): string {
|
|
|
123
160
|
return resolve(dirname(fileURLToPath(import.meta.url)), "../../templates", template);
|
|
124
161
|
}
|
|
125
162
|
|
|
163
|
+
function piecesRoot(piece: InitAppPiece): string {
|
|
164
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "../../templates/pieces", piece);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Fragment source dirs for a piece + template: shared/ applies to every
|
|
169
|
+
* template, <template>/ only to that one. A piece supports a template when
|
|
170
|
+
* at least one of the two exists.
|
|
171
|
+
*/
|
|
172
|
+
function pieceFragmentDirs(piece: InitAppPiece, template: InitAppTemplate): string[] {
|
|
173
|
+
return [join(piecesRoot(piece), "shared"), join(piecesRoot(piece), template)].filter((dir) =>
|
|
174
|
+
existsSync(dir),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
126
178
|
/**
|
|
127
|
-
* npm always strips .gitignore / .npmrc
|
|
128
|
-
* template stores them without the leading dot; restore it
|
|
179
|
+
* npm always strips .gitignore / .npmrc (and newer npms .env*) from published
|
|
180
|
+
* tarballs, so the template stores them without the leading dot; restore it
|
|
181
|
+
* at scaffold time.
|
|
129
182
|
*/
|
|
130
183
|
const DOTFILE_NAMES: Record<string, string> = {
|
|
131
184
|
gitignore: ".gitignore",
|
|
132
185
|
npmrc: ".npmrc",
|
|
186
|
+
"env.example": ".env.example",
|
|
133
187
|
};
|
|
134
188
|
|
|
135
189
|
function render(content: string, vars: TemplateVars): string {
|
|
@@ -145,7 +199,16 @@ function render(content: string, vars: TemplateVars): string {
|
|
|
145
199
|
* Assets copied byte-for-byte — running them through the utf-8 render pass
|
|
146
200
|
* would corrupt them (fonts ship in the templates for LC-33 stable mount).
|
|
147
201
|
*/
|
|
148
|
-
const BINARY_EXTENSIONS = new Set([
|
|
202
|
+
const BINARY_EXTENSIONS = new Set([
|
|
203
|
+
".woff2",
|
|
204
|
+
".woff",
|
|
205
|
+
".ttf",
|
|
206
|
+
".otf",
|
|
207
|
+
".png",
|
|
208
|
+
".jpg",
|
|
209
|
+
".ico",
|
|
210
|
+
".icns",
|
|
211
|
+
]);
|
|
149
212
|
|
|
150
213
|
function writeTree(srcDir: string, destDir: string, vars: TemplateVars): void {
|
|
151
214
|
if (!existsSync(srcDir)) {
|
|
@@ -170,6 +233,227 @@ function writeTree(srcDir: string, destDir: string, vars: TemplateVars): void {
|
|
|
170
233
|
}
|
|
171
234
|
}
|
|
172
235
|
|
|
236
|
+
// ── piece fragment overlay ─────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
239
|
+
|
|
240
|
+
function isJsonObject(value: JsonValue | undefined): value is { [key: string]: JsonValue } {
|
|
241
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Deep-merge for JSON partials: objects merge recursively (fragment keys
|
|
246
|
+
* append after base keys, so e.g. package.json script order stays stable),
|
|
247
|
+
* arrays union (base order first — config.json public keys accumulate
|
|
248
|
+
* across pieces), scalars/type-mismatches take the fragment value.
|
|
249
|
+
*/
|
|
250
|
+
export function mergeJsonValues(base: JsonValue, fragment: JsonValue): JsonValue {
|
|
251
|
+
if (Array.isArray(base) && Array.isArray(fragment)) {
|
|
252
|
+
const merged = [...base];
|
|
253
|
+
const seen = new Set(base.map((item) => JSON.stringify(item)));
|
|
254
|
+
for (const item of fragment) {
|
|
255
|
+
const key = JSON.stringify(item);
|
|
256
|
+
if (seen.has(key)) continue;
|
|
257
|
+
seen.add(key);
|
|
258
|
+
merged.push(item);
|
|
259
|
+
}
|
|
260
|
+
return merged;
|
|
261
|
+
}
|
|
262
|
+
if (isJsonObject(base) && isJsonObject(fragment)) {
|
|
263
|
+
const merged: { [key: string]: JsonValue } = { ...base };
|
|
264
|
+
for (const [key, value] of Object.entries(fragment)) {
|
|
265
|
+
merged[key] = key in merged ? mergeJsonValues(merged[key]!, value) : value;
|
|
266
|
+
}
|
|
267
|
+
return merged;
|
|
268
|
+
}
|
|
269
|
+
return fragment;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Dependency maps stay alphabetically sorted after fragment merges (the
|
|
273
|
+
* base templates are sorted; multiple pieces appending must not depend on
|
|
274
|
+
* overlay order for the final byte content). */
|
|
275
|
+
const SORTED_DEPENDENCY_FIELDS = [
|
|
276
|
+
"dependencies",
|
|
277
|
+
"devDependencies",
|
|
278
|
+
"peerDependencies",
|
|
279
|
+
"optionalDependencies",
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
function sortDependencyFields(value: JsonValue): JsonValue {
|
|
283
|
+
if (!isJsonObject(value)) return value;
|
|
284
|
+
const result = { ...value };
|
|
285
|
+
for (const field of SORTED_DEPENDENCY_FIELDS) {
|
|
286
|
+
const deps = result[field];
|
|
287
|
+
if (!isJsonObject(deps)) continue;
|
|
288
|
+
result[field] = Object.fromEntries(
|
|
289
|
+
Object.entries(deps).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Apply a rendered `<target>.partial` fragment onto the target file:
|
|
297
|
+
* - `.json` targets deep-merge (see mergeJsonValues); package.json-style
|
|
298
|
+
* dependency maps are re-sorted for deterministic output
|
|
299
|
+
* - everything else appends as a text block separated by a blank line
|
|
300
|
+
* Missing targets are created from the fragment alone.
|
|
301
|
+
*/
|
|
302
|
+
function applyPartial(targetPath: string, renderedFragment: string): void {
|
|
303
|
+
if (targetPath.endsWith(".json")) {
|
|
304
|
+
const fragment = JSON.parse(renderedFragment) as JsonValue;
|
|
305
|
+
const base = existsSync(targetPath)
|
|
306
|
+
? (JSON.parse(readFileSync(targetPath, "utf-8")) as JsonValue)
|
|
307
|
+
: undefined;
|
|
308
|
+
const merged = sortDependencyFields(
|
|
309
|
+
base === undefined ? fragment : mergeJsonValues(base, fragment),
|
|
310
|
+
);
|
|
311
|
+
writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const block = `${renderedFragment.replace(/^\n+/u, "").replace(/\n+$/u, "")}\n`;
|
|
315
|
+
if (!existsSync(targetPath)) {
|
|
316
|
+
writeFileSync(targetPath, block);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const existing = readFileSync(targetPath, "utf-8").replace(/\n+$/u, "");
|
|
320
|
+
writeFileSync(targetPath, `${existing}\n\n${block}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const PARTIAL_SUFFIX = ".partial";
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Overlay a piece fragment tree onto the scaffold: regular files render and
|
|
327
|
+
* overwrite, `<target>.partial` files merge into (or create) their target
|
|
328
|
+
* (deep-merge for .json, append for text — see applyPartial).
|
|
329
|
+
*/
|
|
330
|
+
function overlayTree(srcDir: string, destDir: string, vars: TemplateVars): void {
|
|
331
|
+
mkdirSync(destDir, { recursive: true });
|
|
332
|
+
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
333
|
+
const from = join(srcDir, entry.name);
|
|
334
|
+
if (entry.isDirectory()) {
|
|
335
|
+
overlayTree(from, join(destDir, render(DOTFILE_NAMES[entry.name] ?? entry.name, vars)), vars);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (entry.name.endsWith(PARTIAL_SUFFIX)) {
|
|
339
|
+
const targetName = entry.name.slice(0, -PARTIAL_SUFFIX.length);
|
|
340
|
+
const toName = render(DOTFILE_NAMES[targetName] ?? targetName, vars);
|
|
341
|
+
applyPartial(join(destDir, toName), render(readFileSync(from, "utf-8"), vars));
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const toName = render(DOTFILE_NAMES[entry.name] ?? entry.name, vars);
|
|
345
|
+
const to = join(destDir, toName);
|
|
346
|
+
if (BINARY_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
|
347
|
+
copyFileSync(from, to);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
writeFileSync(to, render(readFileSync(from, "utf-8"), vars));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Validate + canonicalize a piece selection for a template: unknown names
|
|
356
|
+
* throw, duplicates collapse, order is always sorted (provenance and overlay
|
|
357
|
+
* order must be deterministic), and every piece must ship a fragment for the
|
|
358
|
+
* chosen template.
|
|
359
|
+
*/
|
|
360
|
+
export function normalizePieces(pieces: string[], template: InitAppTemplate): InitAppPiece[] {
|
|
361
|
+
const known = new Set<string>(INIT_APP_PIECES);
|
|
362
|
+
const normalized = [...new Set(pieces)].sort() as InitAppPiece[];
|
|
363
|
+
for (const piece of normalized) {
|
|
364
|
+
if (!known.has(piece)) {
|
|
365
|
+
throw new Error(`Unknown piece "${piece}". Available pieces: ${INIT_APP_PIECES.join(", ")}`);
|
|
366
|
+
}
|
|
367
|
+
if (pieceFragmentDirs(piece, template).length === 0) {
|
|
368
|
+
throw new Error(
|
|
369
|
+
`Piece "${piece}" is not available for the ${template} template ` +
|
|
370
|
+
"(pieces currently target the universal template)",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return normalized;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Pieces that can be offered for a template (fragment exists for it). */
|
|
378
|
+
export function availablePieces(template: InitAppTemplate): InitAppPiece[] {
|
|
379
|
+
return INIT_APP_PIECES.filter((piece) => pieceFragmentDirs(piece, template).length > 0);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function resolvePieces(
|
|
383
|
+
options: InitAppOptions,
|
|
384
|
+
template: InitAppTemplate,
|
|
385
|
+
): Promise<InitAppPiece[]> {
|
|
386
|
+
if (options.pieces) return normalizePieces(options.pieces, template);
|
|
387
|
+
if (options.yes || !process.stdout.isTTY) return [];
|
|
388
|
+
const choices = availablePieces(template);
|
|
389
|
+
if (choices.length === 0) return [];
|
|
390
|
+
const result = await inquirer.prompt([
|
|
391
|
+
{
|
|
392
|
+
message: "Add optional pieces? (space to toggle, enter to confirm)",
|
|
393
|
+
name: "pieces",
|
|
394
|
+
type: "checkbox",
|
|
395
|
+
choices: choices.map((piece) => ({
|
|
396
|
+
name: `${piece} — ${PIECE_DESCRIPTIONS[piece]}`,
|
|
397
|
+
value: piece,
|
|
398
|
+
checked: false,
|
|
399
|
+
})),
|
|
400
|
+
},
|
|
401
|
+
]);
|
|
402
|
+
return normalizePieces(result.pieces as string[], template);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── app.ts composition (frappe / keycloak provider wiring) ─────────
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Generate apps/<name>/src/app.ts for the selected backend pieces instead of
|
|
409
|
+
* storing 2^n template variants: each selected provider adds one createApp
|
|
410
|
+
* key (frappe/keycloak read their runtime settings from the packages/config
|
|
411
|
+
* public env keys their fragments register). Only called when at least one
|
|
412
|
+
* provider piece is selected — the base template file stays otherwise.
|
|
413
|
+
*/
|
|
414
|
+
export function composeAppTs(pieces: InitAppPiece[]): string {
|
|
415
|
+
const frappe = pieces.includes("frappe");
|
|
416
|
+
const keycloak = pieces.includes("keycloak");
|
|
417
|
+
const lines: string[] = ['import { createApp } from "@multiplatform.one/core";'];
|
|
418
|
+
if (frappe || keycloak) {
|
|
419
|
+
lines.push('import { config } from "@multiplatform.one/platform";');
|
|
420
|
+
}
|
|
421
|
+
lines.push('import { themeConfig } from "@package/themes";');
|
|
422
|
+
lines.push(
|
|
423
|
+
"",
|
|
424
|
+
"/**",
|
|
425
|
+
" * App-wide provider stack. Providers below were enabled by the pieces",
|
|
426
|
+
" * selected at `mpo init` time — see CreateAppConfig in",
|
|
427
|
+
" * @multiplatform.one/core for everything else that can be wired here",
|
|
428
|
+
" * (tanstack, web3, custom providers).",
|
|
429
|
+
" */",
|
|
430
|
+
"export const { AppProvider, useAppConfig, withApp } = createApp({",
|
|
431
|
+
" theme: themeConfig,",
|
|
432
|
+
);
|
|
433
|
+
if (frappe) {
|
|
434
|
+
lines.push(
|
|
435
|
+
" // Frappe backend (mpo init --frappe): FrappeProvider mounts when",
|
|
436
|
+
" // FRAPPE_ENABLED=1 (a packages/config public env key, loaded from",
|
|
437
|
+
" // .env — see .env.example). FRAPPE_URL overrides the bench URL",
|
|
438
|
+
" // (default: BASE_URL, then http://localhost:8000);",
|
|
439
|
+
" // docker/compose.frappe.yaml runs a dev bench.",
|
|
440
|
+
' frappe: { baseURL: config.get("FRAPPE_URL") },',
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
if (keycloak) {
|
|
444
|
+
lines.push(
|
|
445
|
+
" // Keycloak auth (mpo init --keycloak): KeycloakProvider mounts when",
|
|
446
|
+
" // KEYCLOAK_ENABLED=1. Realm/client settings come from the KEYCLOAK_*",
|
|
447
|
+
" // env keys (packages/config, .env.example);",
|
|
448
|
+
" // docker/compose.keycloak.yaml runs a dev server with an imported",
|
|
449
|
+
" // realm.",
|
|
450
|
+
' keycloak: { baseUrl: config.get("KEYCLOAK_BASE_URL") },',
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
lines.push("});", "");
|
|
454
|
+
return lines.join("\n");
|
|
455
|
+
}
|
|
456
|
+
|
|
173
457
|
async function resolveTemplate(options: InitAppOptions): Promise<InitAppTemplate> {
|
|
174
458
|
if (options.template) return options.template;
|
|
175
459
|
if (options.yes || !process.stdout.isTTY) return "universal";
|
|
@@ -216,6 +500,7 @@ export async function initApp(
|
|
|
216
500
|
|
|
217
501
|
const name = validateName(projectName);
|
|
218
502
|
const template = await resolveTemplate(options);
|
|
503
|
+
const pieces = await resolvePieces(options, template);
|
|
219
504
|
const targetDir = resolve(name);
|
|
220
505
|
|
|
221
506
|
if (existsSync(targetDir)) {
|
|
@@ -242,14 +527,31 @@ export async function initApp(
|
|
|
242
527
|
console.log(` packages/i18n`);
|
|
243
528
|
console.log(` packages/themes`);
|
|
244
529
|
}
|
|
530
|
+
if (pieces.length) {
|
|
531
|
+
console.log(` pieces: ${pieces.join(", ")}`);
|
|
532
|
+
}
|
|
245
533
|
console.log(` @multiplatform.one/* → ${vars.MPO_VERSION}\n`);
|
|
246
534
|
|
|
247
535
|
writeTree(templatesRoot(template), targetDir, vars);
|
|
536
|
+
// Pieces overlay AFTER the base template, in sorted order (normalizePieces)
|
|
537
|
+
// so repeated scaffolds are byte-identical — mpo update regenerates these
|
|
538
|
+
// exact trees as merge baselines.
|
|
539
|
+
for (const piece of pieces) {
|
|
540
|
+
for (const fragmentDir of pieceFragmentDirs(piece, template)) {
|
|
541
|
+
overlayTree(fragmentDir, targetDir, vars);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// Backend providers are wired by generating src/app.ts from the selection
|
|
545
|
+
// (not by stacking file overwrites per combination).
|
|
546
|
+
if (pieces.includes("frappe") || pieces.includes("keycloak")) {
|
|
547
|
+
writeFileSync(join(targetDir, "apps", name, "src/app.ts"), composeAppTs(pieces));
|
|
548
|
+
}
|
|
248
549
|
writeProvenance(targetDir, {
|
|
249
550
|
template,
|
|
250
551
|
cliVersion: cliVersion() ?? "0.0.0",
|
|
251
552
|
name,
|
|
252
553
|
mpoVersion: vars.MPO_VERSION,
|
|
554
|
+
pieces,
|
|
253
555
|
});
|
|
254
556
|
|
|
255
557
|
// Initialize git with a scaffold commit (unless already inside a repo —
|
|
@@ -291,9 +593,7 @@ export async function initApp(
|
|
|
291
593
|
// commit so a fresh init is immediately clean.
|
|
292
594
|
if (scaffoldCommitted) {
|
|
293
595
|
await spawn("git", ["add", "--", "pnpm-lock.yaml"], { cwd: targetDir })
|
|
294
|
-
.then(() =>
|
|
295
|
-
spawn("git", ["commit", "--amend", "--no-edit", "--quiet"], { cwd: targetDir }),
|
|
296
|
-
)
|
|
596
|
+
.then(() => spawn("git", ["commit", "--amend", "--no-edit", "--quiet"], { cwd: targetDir }))
|
|
297
597
|
.catch(() => {});
|
|
298
598
|
}
|
|
299
599
|
} else {
|
|
@@ -309,6 +609,24 @@ export async function initApp(
|
|
|
309
609
|
console.log(" pnpm ios # iOS (requires pnpm dev in another terminal)");
|
|
310
610
|
console.log(" pnpm android # Android");
|
|
311
611
|
}
|
|
612
|
+
if (pieces.includes("frappe")) {
|
|
613
|
+
console.log(" docker compose -f docker/compose.frappe.yaml up -d # dev bench (see README)");
|
|
614
|
+
}
|
|
615
|
+
if (pieces.includes("keycloak")) {
|
|
616
|
+
console.log(" docker compose -f docker/compose.keycloak.yaml up -d # dev keycloak");
|
|
617
|
+
}
|
|
618
|
+
if (pieces.includes("gnome")) {
|
|
619
|
+
console.log(" pnpm gnome # GTK4 desktop app (requires gjs + gtk4 — see README)");
|
|
620
|
+
}
|
|
621
|
+
if (pieces.includes("tauri")) {
|
|
622
|
+
console.log(" pnpm tauri:dev # desktop app (requires the Rust toolchain)");
|
|
623
|
+
}
|
|
624
|
+
if (pieces.includes("vscode")) {
|
|
625
|
+
console.log(" pnpm build:vscode # stage the VS Code extension into apps dist-vscode/");
|
|
626
|
+
}
|
|
627
|
+
if (pieces.includes("webext")) {
|
|
628
|
+
console.log(" pnpm build:webext # stage the browser extension into apps dist-webext/");
|
|
629
|
+
}
|
|
312
630
|
console.log("\nThis project consumes published @multiplatform.one/* packages.");
|
|
313
631
|
console.log("It is not a fork of the multiplatform.one monorepo.");
|
|
314
632
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { threeWayMergePackageJson } from "./updateApp";
|
|
3
|
+
|
|
4
|
+
const json = (value: unknown) => JSON.stringify(value, null, 2);
|
|
5
|
+
|
|
6
|
+
describe("threeWayMergePackageJson", () => {
|
|
7
|
+
it("resolves dependency-range drift to the newer minimum", () => {
|
|
8
|
+
const base = json({ dependencies: { "@multiplatform.one/components": "^6.3.0" } });
|
|
9
|
+
const ours = json({ dependencies: { "@multiplatform.one/components": "^6.4.2" } });
|
|
10
|
+
const theirs = json({ dependencies: { "@multiplatform.one/components": "^6.4.3" } });
|
|
11
|
+
expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
|
|
12
|
+
dependencies: { "@multiplatform.one/components": "^6.4.3" },
|
|
13
|
+
});
|
|
14
|
+
// Symmetric: the project may be AHEAD of the template.
|
|
15
|
+
expect(threeWayMergePackageJson(base, theirs, ours)).toEqual({
|
|
16
|
+
dependencies: { "@multiplatform.one/components": "^6.4.3" },
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("prefers the template spelling on equal minimums", () => {
|
|
21
|
+
const base = json({ dependencies: { react: "^19.0.0" } });
|
|
22
|
+
const ours = json({ dependencies: { react: "19.2.0" } });
|
|
23
|
+
const theirs = json({ dependencies: { react: "~19.2.0" } });
|
|
24
|
+
expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
|
|
25
|
+
dependencies: { react: "~19.2.0" },
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps project-added dependencies alongside template bumps", () => {
|
|
30
|
+
const base = json({ dependencies: { one: "^1.0.0" } });
|
|
31
|
+
const ours = json({ dependencies: { one: "^1.0.0", "@multiplatform.one/keycloak": "^6.4.2" } });
|
|
32
|
+
const theirs = json({ dependencies: { one: "^1.1.0" } });
|
|
33
|
+
expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
|
|
34
|
+
dependencies: { one: "^1.1.0", "@multiplatform.one/keycloak": "^6.4.2" },
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("applies template deletions the project did not touch", () => {
|
|
39
|
+
const base = json({ scripts: { postinstall: "patch-package", build: "vite build" } });
|
|
40
|
+
const ours = json({ scripts: { postinstall: "patch-package", build: "vite build" } });
|
|
41
|
+
const theirs = json({ scripts: { build: "vite build" } });
|
|
42
|
+
expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
|
|
43
|
+
scripts: { build: "vite build" },
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("resolves nested override maps", () => {
|
|
48
|
+
const base = json({ pnpm: { overrides: { "react-dom": "19.2.4" } } });
|
|
49
|
+
const ours = json({ pnpm: { overrides: { "react-dom": "19.2.5" } } });
|
|
50
|
+
const theirs = json({ pnpm: { overrides: { "react-dom": "19.2.6" } } });
|
|
51
|
+
expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
|
|
52
|
+
pnpm: { overrides: { "react-dom": "19.2.6" } },
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns undefined for genuine two-sided conflicts", () => {
|
|
57
|
+
const base = json({ scripts: { build: "vite build" } });
|
|
58
|
+
const ours = json({ scripts: { build: "vite build --mode ours" } });
|
|
59
|
+
const theirs = json({ scripts: { build: "vite build --mode theirs" } });
|
|
60
|
+
expect(threeWayMergePackageJson(base, ours, theirs)).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns undefined for non-range dependency divergence", () => {
|
|
64
|
+
const base = json({ dependencies: { local: "^1.0.0" } });
|
|
65
|
+
const ours = json({ dependencies: { local: "workspace:*" } });
|
|
66
|
+
const theirs = json({ dependencies: { local: "^2.0.0" } });
|
|
67
|
+
expect(threeWayMergePackageJson(base, ours, theirs)).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns undefined on unparseable input", () => {
|
|
71
|
+
expect(threeWayMergePackageJson("{}", "not json", "{}")).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("treats a missing base as empty (add/add merges)", () => {
|
|
75
|
+
const ours = json({ dependencies: { a: "^1.0.0" } });
|
|
76
|
+
const theirs = json({ dependencies: { b: "^2.0.0" } });
|
|
77
|
+
expect(threeWayMergePackageJson(undefined, ours, theirs)).toEqual({
|
|
78
|
+
dependencies: { a: "^1.0.0", b: "^2.0.0" },
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|