@multiplatform.one/cli 6.4.3 → 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 +5 -3
- package/lib/commands/initApp.mjs +209 -8
- package/lib/commands/updateApp.mjs +170 -16
- package/package.json +2 -2
- package/src/bin/multiplatformOne.ts +14 -1
- package/src/commands/initApp.spec.ts +369 -3
- package/src/commands/initApp.ts +324 -4
- package/src/commands/updateApp.spec.ts +81 -0
- package/src/commands/updateApp.ts +212 -6
- package/templates/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/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
|
@@ -19,7 +19,7 @@ import YAML from "yaml";
|
|
|
19
19
|
import yoctoSpinner from "yocto-spinner";
|
|
20
20
|
import { discoverE2EApp, runE2ESession } from "../commands/e2e";
|
|
21
21
|
import { init, runModifyStep } from "../commands/init";
|
|
22
|
-
import { initApp, readProvenance } from "../commands/initApp";
|
|
22
|
+
import { INIT_APP_PIECES, initApp, readProvenance } from "../commands/initApp";
|
|
23
23
|
import { updateApp } from "../commands/updateApp";
|
|
24
24
|
|
|
25
25
|
const projectRoot = lookupProjectRoot();
|
|
@@ -221,6 +221,15 @@ program
|
|
|
221
221
|
.option("--universal", "scaffold the universal template (web + iOS + Android; default)")
|
|
222
222
|
.option("--web", "scaffold the web-only template (Vite + React)")
|
|
223
223
|
.option("--app", "alias of --web (back-compat)")
|
|
224
|
+
.option("--frappe", "add the Frappe backend piece (env keys, provider, docker compose bench)")
|
|
225
|
+
.option("--keycloak", "add the Keycloak auth piece (env keys, provider, docker compose + realm)")
|
|
226
|
+
.option(
|
|
227
|
+
"--gnome",
|
|
228
|
+
"add the GNOME desktop target piece (GTK4/GJS via react-gnome — native widgets, no webview)",
|
|
229
|
+
)
|
|
230
|
+
.option("--tauri", "add the desktop target piece (src-tauri skeleton, webview)")
|
|
231
|
+
.option("--vscode", "add the VS Code extension target piece")
|
|
232
|
+
.option("--webext", "add the browser extension target piece (MV3 popup + background)")
|
|
224
233
|
.option("--skip-install", "skip pnpm install after scaffolding")
|
|
225
234
|
.option("--skip-git", "skip git init + scaffold commit")
|
|
226
235
|
.option(
|
|
@@ -269,12 +278,16 @@ program
|
|
|
269
278
|
if (webOnly && options.universal) {
|
|
270
279
|
throw new Error("Pass either --web/--app or --universal, not both");
|
|
271
280
|
}
|
|
281
|
+
// Piece flags are multi-select; passing any of them skips the interactive
|
|
282
|
+
// pieces prompt. No flags + --yes (or non-TTY) → no pieces.
|
|
283
|
+
const pieces = INIT_APP_PIECES.filter((piece) => Boolean(options[piece]));
|
|
272
284
|
await initApp(name, {
|
|
273
285
|
skipInstall: Boolean(options.skipInstall),
|
|
274
286
|
skipGit: Boolean(options.skipGit),
|
|
275
287
|
version: options.mpoVersion,
|
|
276
288
|
template: webOnly ? "app" : options.universal ? "universal" : undefined,
|
|
277
289
|
yes: Boolean(options.yes),
|
|
290
|
+
pieces: pieces.length ? pieces : undefined,
|
|
278
291
|
});
|
|
279
292
|
});
|
|
280
293
|
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
2
10
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
11
|
+
import { extname, join } from "node:path";
|
|
4
12
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
5
|
-
import { initApp } from "./initApp";
|
|
13
|
+
import { initApp, mergeJsonValues, readProvenance } from "./initApp";
|
|
6
14
|
|
|
7
15
|
const readJson = (path: string) => JSON.parse(readFileSync(path, "utf-8"));
|
|
8
16
|
|
|
@@ -207,4 +215,362 @@ describe("initApp generator", () => {
|
|
|
207
215
|
initApp("occupied", { template: "app", skipInstall: true, yes: true }),
|
|
208
216
|
).rejects.toThrow(/already exists and is not empty/u);
|
|
209
217
|
});
|
|
218
|
+
|
|
219
|
+
describe("composable pieces", () => {
|
|
220
|
+
describe("webext piece", () => {
|
|
221
|
+
const name = "ext-demo";
|
|
222
|
+
let root: string;
|
|
223
|
+
|
|
224
|
+
beforeAll(async () => {
|
|
225
|
+
await initApp(name, {
|
|
226
|
+
template: "universal",
|
|
227
|
+
skipInstall: true,
|
|
228
|
+
skipGit: true,
|
|
229
|
+
version: "6.1.0",
|
|
230
|
+
yes: true,
|
|
231
|
+
pieces: ["webext"],
|
|
232
|
+
});
|
|
233
|
+
root = join(sandbox, name);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("overlays the webext target files", () => {
|
|
237
|
+
for (const file of [
|
|
238
|
+
`apps/${name}/vite.config.webext.ts`,
|
|
239
|
+
`apps/${name}/vite.config.webext-background.ts`,
|
|
240
|
+
`apps/${name}/webext/env.ts`,
|
|
241
|
+
`apps/${name}/webext/manifest.ts`,
|
|
242
|
+
`apps/${name}/webext/prepare.ts`,
|
|
243
|
+
`apps/${name}/webext/tamagui.css`,
|
|
244
|
+
`apps/${name}/webext/tsconfig.json`,
|
|
245
|
+
`apps/${name}/webext/background/main.ts`,
|
|
246
|
+
`apps/${name}/webext/components/ErrorBoundary.tsx`,
|
|
247
|
+
`apps/${name}/webext/views/popup/index.html`,
|
|
248
|
+
`apps/${name}/webext/views/popup/main.tsx`,
|
|
249
|
+
`apps/${name}/webext/assets/fonts/inter-400.woff2`,
|
|
250
|
+
`apps/${name}/webext/assets/fonts/inter-700.woff2`,
|
|
251
|
+
`apps/${name}/webext/static/assets/icon-512.png`,
|
|
252
|
+
]) {
|
|
253
|
+
expect(existsSync(join(root, file)), `missing ${file}`).toBe(true);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("renders template vars in fragment files", () => {
|
|
258
|
+
const manifest = readFileSync(join(root, `apps/${name}/webext/manifest.ts`), "utf-8");
|
|
259
|
+
expect(manifest).toContain('const DISPLAY_NAME = "ExtDemo"');
|
|
260
|
+
for (const file of [
|
|
261
|
+
`apps/${name}/vite.config.webext.ts`,
|
|
262
|
+
`apps/${name}/webext/manifest.ts`,
|
|
263
|
+
`apps/${name}/webext/views/popup/main.tsx`,
|
|
264
|
+
]) {
|
|
265
|
+
expect(readFileSync(join(root, file), "utf-8")).not.toMatch(/__NAME(_PASCAL|_APPID)?__/u);
|
|
266
|
+
}
|
|
267
|
+
const popup = readFileSync(join(root, `apps/${name}/webext/views/popup/main.tsx`), "utf-8");
|
|
268
|
+
expect(popup).toContain(`from "@features/${name}"`);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("deep-merges package.json.partial into the app manifest", () => {
|
|
272
|
+
const appPkg = readJson(join(root, `apps/${name}/package.json`));
|
|
273
|
+
// base scripts survive, fragment scripts append
|
|
274
|
+
expect(appPkg.scripts.dev).toContain("one dev");
|
|
275
|
+
expect(appPkg.scripts["build:webext"]).toContain("vite.config.webext.ts");
|
|
276
|
+
expect(appPkg.scripts["typecheck:webext"]).toBe("tsc --noEmit -p webext/tsconfig.json");
|
|
277
|
+
// fragment deps merge in with rendered versions; base deps survive
|
|
278
|
+
expect(appPkg.dependencies["webextension-polyfill"]).toBe("^0.12.0");
|
|
279
|
+
expect(appPkg.dependencies["@multiplatform.one/router"]).toBe("^6.1.0");
|
|
280
|
+
expect(appPkg.dependencies[`@features/${name}`]).toBe("workspace:*");
|
|
281
|
+
expect(appPkg.devDependencies["@multiplatform.one/vite-plugin-webext"]).toBe("^6.1.0");
|
|
282
|
+
// dependency maps stay sorted after the merge
|
|
283
|
+
for (const field of ["dependencies", "devDependencies"]) {
|
|
284
|
+
const keys = Object.keys(appPkg[field]);
|
|
285
|
+
expect(keys, `${field} sorted`).toEqual([...keys].sort());
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("unions tsconfig types and root scripts, appends gitignore", () => {
|
|
290
|
+
const tsconfig = readJson(join(root, `apps/${name}/tsconfig.json`));
|
|
291
|
+
expect(tsconfig.compilerOptions.types).toEqual(["vite/client", "node"]);
|
|
292
|
+
const rootPkg = readJson(join(root, "package.json"));
|
|
293
|
+
expect(rootPkg.scripts["build:webext"]).toBe(`pnpm --filter @app/${name} build:webext`);
|
|
294
|
+
const gitignore = readFileSync(join(root, ".gitignore"), "utf-8");
|
|
295
|
+
expect(gitignore).toContain("node_modules");
|
|
296
|
+
expect(gitignore).toContain("dist-webext");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("records pieces in provenance", () => {
|
|
300
|
+
const provenance = readJson(join(root, ".mpo.json"));
|
|
301
|
+
expect(provenance.pieces).toEqual(["webext"]);
|
|
302
|
+
expect(readProvenance(root)?.pieces).toEqual(["webext"]);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("frappe + keycloak pieces", () => {
|
|
307
|
+
const name = "backend-demo";
|
|
308
|
+
let root: string;
|
|
309
|
+
|
|
310
|
+
beforeAll(async () => {
|
|
311
|
+
await initApp(name, {
|
|
312
|
+
template: "universal",
|
|
313
|
+
skipInstall: true,
|
|
314
|
+
skipGit: true,
|
|
315
|
+
version: "6.1.0",
|
|
316
|
+
yes: true,
|
|
317
|
+
// intentionally unsorted — provenance must sort
|
|
318
|
+
pieces: ["keycloak", "frappe"],
|
|
319
|
+
});
|
|
320
|
+
root = join(sandbox, name);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("generates src/app.ts from the selected providers", () => {
|
|
324
|
+
const appTs = readFileSync(join(root, `apps/${name}/src/app.ts`), "utf-8");
|
|
325
|
+
expect(appTs).toContain('import { config } from "@multiplatform.one/platform"');
|
|
326
|
+
expect(appTs).toContain('frappe: { baseURL: config.get("FRAPPE_URL") }');
|
|
327
|
+
expect(appTs).toContain('keycloak: { baseUrl: config.get("KEYCLOAK_BASE_URL") }');
|
|
328
|
+
expect(appTs).toContain("theme: themeConfig");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("accumulates public config keys across pieces", () => {
|
|
332
|
+
const config = readJson(join(root, "packages/config/config.json"));
|
|
333
|
+
expect(config.public).toEqual([
|
|
334
|
+
"BASE_URL",
|
|
335
|
+
"DEBUG",
|
|
336
|
+
"I18N_DEFAULT_LANGUAGE",
|
|
337
|
+
"FRAPPE_ENABLED",
|
|
338
|
+
"FRAPPE_URL",
|
|
339
|
+
"KEYCLOAK_ENABLED",
|
|
340
|
+
"KEYCLOAK_BASE_URL",
|
|
341
|
+
"KEYCLOAK_REALM",
|
|
342
|
+
"KEYCLOAK_CLIENT_ID",
|
|
343
|
+
"KEYCLOAK_PUBLIC_CLIENT_ID",
|
|
344
|
+
]);
|
|
345
|
+
expect(config.private).toEqual(["SECRET"]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("writes env, docker, and README fragments", () => {
|
|
349
|
+
const env = readFileSync(join(root, ".env.example"), "utf-8");
|
|
350
|
+
expect(env).toContain("FRAPPE_ENABLED=1");
|
|
351
|
+
expect(env).toContain("KEYCLOAK_ENABLED=1");
|
|
352
|
+
expect(env).toContain(`KEYCLOAK_REALM=${name}`);
|
|
353
|
+
expect(existsSync(join(root, "docker/compose.frappe.yaml"))).toBe(true);
|
|
354
|
+
expect(existsSync(join(root, "docker/compose.keycloak.yaml"))).toBe(true);
|
|
355
|
+
const realm = readJson(join(root, "docker/keycloak/realm.json"));
|
|
356
|
+
expect(realm.realm).toBe(name);
|
|
357
|
+
expect(realm.clients[1].clientId).toBe(`${name}-public`);
|
|
358
|
+
const readme = readFileSync(join(root, "README.md"), "utf-8");
|
|
359
|
+
expect(readme).toContain("## Frappe backend");
|
|
360
|
+
expect(readme).toContain("## Keycloak auth");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("records pieces sorted in provenance", () => {
|
|
364
|
+
expect(readJson(join(root, ".mpo.json")).pieces).toEqual(["frappe", "keycloak"]);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("kitchen sink (all pieces)", () => {
|
|
369
|
+
const name = "full-demo";
|
|
370
|
+
let root: string;
|
|
371
|
+
|
|
372
|
+
beforeAll(async () => {
|
|
373
|
+
await initApp(name, {
|
|
374
|
+
template: "universal",
|
|
375
|
+
skipInstall: true,
|
|
376
|
+
skipGit: true,
|
|
377
|
+
version: "6.1.0",
|
|
378
|
+
yes: true,
|
|
379
|
+
pieces: ["frappe", "gnome", "keycloak", "tauri", "vscode", "webext"],
|
|
380
|
+
});
|
|
381
|
+
root = join(sandbox, name);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("overlays every target", () => {
|
|
385
|
+
for (const file of [
|
|
386
|
+
`apps/${name}/vite.config.gnome.ts`,
|
|
387
|
+
`apps/${name}/gnome/main.tsx`,
|
|
388
|
+
`apps/${name}/gnome/polyfills.ts`,
|
|
389
|
+
`apps/${name}/gnome/tamagui-barrel.ts`,
|
|
390
|
+
`apps/${name}/gnome/shims/components.ts`,
|
|
391
|
+
`apps/${name}/gnome/shims/theme.ts`,
|
|
392
|
+
`apps/${name}/gnome/shims/forms.ts`,
|
|
393
|
+
`apps/${name}/gnome/shims/one.ts`,
|
|
394
|
+
`apps/${name}/gnome/shims/empty-module.ts`,
|
|
395
|
+
`apps/${name}/src-tauri/tauri.conf.json`,
|
|
396
|
+
`apps/${name}/src-tauri/Cargo.toml`,
|
|
397
|
+
`apps/${name}/src-tauri/src/main.rs`,
|
|
398
|
+
`apps/${name}/src-tauri/src/lib.rs`,
|
|
399
|
+
`apps/${name}/src-tauri/capabilities/default.json`,
|
|
400
|
+
`apps/${name}/src-tauri/icons/icon.icns`,
|
|
401
|
+
`apps/${name}/vite.config.vscode.ts`,
|
|
402
|
+
`apps/${name}/vscode/extension/index.ts`,
|
|
403
|
+
`apps/${name}/vscode/webview/main.tsx`,
|
|
404
|
+
`apps/${name}/vscode/webview/router.tsx`,
|
|
405
|
+
`apps/${name}/vscode/package.json`,
|
|
406
|
+
`apps/${name}/vscode/manifest.ts`,
|
|
407
|
+
`apps/${name}/vite.config.webext.ts`,
|
|
408
|
+
`apps/${name}/webext/manifest.ts`,
|
|
409
|
+
"docker/compose.frappe.yaml",
|
|
410
|
+
"docker/compose.keycloak.yaml",
|
|
411
|
+
]) {
|
|
412
|
+
expect(existsSync(join(root, file)), `missing ${file}`).toBe(true);
|
|
413
|
+
}
|
|
414
|
+
const tauriConf = readJson(join(root, `apps/${name}/src-tauri/tauri.conf.json`));
|
|
415
|
+
expect(tauriConf.productName).toBe(name);
|
|
416
|
+
expect(tauriConf.identifier).toBe("com.fulldemo.app");
|
|
417
|
+
const rootPkg = readJson(join(root, "package.json"));
|
|
418
|
+
for (const script of [
|
|
419
|
+
"gnome",
|
|
420
|
+
"gnome:capture",
|
|
421
|
+
"tauri:dev",
|
|
422
|
+
"tauri:build",
|
|
423
|
+
"build:vscode",
|
|
424
|
+
"build:webext",
|
|
425
|
+
]) {
|
|
426
|
+
expect(rootPkg.scripts[script], `root script ${script}`).toBeDefined();
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// The GNOME target is the only piece that rewires module resolution
|
|
431
|
+
// rather than just adding files, and both halves of that rewiring
|
|
432
|
+
// fail silently if they drift. A shim that imports the bare barrel
|
|
433
|
+
// it is aliased FROM resolves back to itself — an infinite alias
|
|
434
|
+
// loop that surfaces as an out-of-memory bundler, not an import
|
|
435
|
+
// error. And the react-gnome packages are the runtime: without them
|
|
436
|
+
// the entry resolves nothing at all.
|
|
437
|
+
it("wires the gnome target without aliasing a shim back to itself", () => {
|
|
438
|
+
const aliased = ["components", "theme", "forms"] as const;
|
|
439
|
+
for (const barrel of aliased) {
|
|
440
|
+
const shim = readFileSync(join(root, `apps/${name}/gnome/shims/${barrel}.ts`), "utf-8");
|
|
441
|
+
expect(shim, `${barrel} shim must deep-import, never the bare barrel`).not.toMatch(
|
|
442
|
+
new RegExp(String.raw`from "@multiplatform\.one/${barrel}"`, "u"),
|
|
443
|
+
);
|
|
444
|
+
expect(shim, `${barrel} shim reaches its subgraph`).toMatch(
|
|
445
|
+
new RegExp(String.raw`@multiplatform\.one/${barrel}/src/`, "u"),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
const appPkg = readJson(join(root, `apps/${name}/package.json`));
|
|
449
|
+
for (const dep of [
|
|
450
|
+
"@react-gnome/renderer",
|
|
451
|
+
"@react-gnome/react-native",
|
|
452
|
+
"@react-gnome/animation",
|
|
453
|
+
"@react-gnome/tamagui",
|
|
454
|
+
]) {
|
|
455
|
+
expect(appPkg.dependencies[dep], `runtime dep ${dep}`).toBeDefined();
|
|
456
|
+
}
|
|
457
|
+
expect(appPkg.devDependencies["@react-gnome/vite-plugin"]).toBeDefined();
|
|
458
|
+
expect(appPkg.devDependencies["@multiplatform.one/vite-plugin-gnome"]).toBeDefined();
|
|
459
|
+
// polyfills must be the FIRST import: the globals have to exist
|
|
460
|
+
// before any hoisted module initializer probes for them.
|
|
461
|
+
const entry = readFileSync(join(root, `apps/${name}/gnome/main.tsx`), "utf-8");
|
|
462
|
+
const firstImport = entry.split("\n").find((line) => line.startsWith("import "));
|
|
463
|
+
expect(firstImport).toBe(`import "./polyfills";`);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("leaves no template placeholders anywhere", () => {
|
|
467
|
+
const binary = new Set([".woff2", ".png", ".ico", ".icns"]);
|
|
468
|
+
const walk = (dir: string): string[] =>
|
|
469
|
+
readdirSync(dir).flatMap((entry) => {
|
|
470
|
+
const path = join(dir, entry);
|
|
471
|
+
if (statSync(path).isDirectory()) return walk(path);
|
|
472
|
+
return [path];
|
|
473
|
+
});
|
|
474
|
+
for (const file of walk(root)) {
|
|
475
|
+
if (binary.has(extname(file).toLowerCase())) continue;
|
|
476
|
+
expect(readFileSync(file, "utf-8"), `placeholder left in ${file}`).not.toMatch(
|
|
477
|
+
/__NAME(_PASCAL|_APPID)?__|__MPO_VERSION__|__YEAR__/u,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("scaffolds deterministically (update baselines depend on it)", async () => {
|
|
483
|
+
// Same name in a different parent dir (paths and rendered content
|
|
484
|
+
// embed the project name); pieces passed in a different order.
|
|
485
|
+
const parent = mkdtempSync(join(tmpdir(), "mpo-again-"));
|
|
486
|
+
const cwd = process.cwd();
|
|
487
|
+
process.chdir(parent);
|
|
488
|
+
try {
|
|
489
|
+
await initApp(name, {
|
|
490
|
+
template: "universal",
|
|
491
|
+
skipInstall: true,
|
|
492
|
+
skipGit: true,
|
|
493
|
+
version: "6.1.0",
|
|
494
|
+
yes: true,
|
|
495
|
+
pieces: ["webext", "vscode", "tauri", "keycloak", "gnome", "frappe"],
|
|
496
|
+
});
|
|
497
|
+
} finally {
|
|
498
|
+
process.chdir(cwd);
|
|
499
|
+
}
|
|
500
|
+
const other = join(parent, name);
|
|
501
|
+
const rel = (dir: string, base: string): string[] =>
|
|
502
|
+
readdirSync(dir).flatMap((entry) => {
|
|
503
|
+
const path = join(dir, entry);
|
|
504
|
+
if (statSync(path).isDirectory()) return rel(path, base);
|
|
505
|
+
return [path.slice(base.length + 1)];
|
|
506
|
+
});
|
|
507
|
+
const ours = rel(root, root).sort();
|
|
508
|
+
expect(rel(other, other).sort()).toEqual(ours);
|
|
509
|
+
for (const file of ours) {
|
|
510
|
+
const a = readFileSync(join(root, file));
|
|
511
|
+
const b = readFileSync(join(other, file));
|
|
512
|
+
expect(b.equals(a), `content differs for ${file}`).toBe(true);
|
|
513
|
+
}
|
|
514
|
+
rmSync(parent, { recursive: true, force: true });
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("rejects pieces on the web-only template", async () => {
|
|
519
|
+
await expect(
|
|
520
|
+
initApp("web-pieces", {
|
|
521
|
+
template: "app",
|
|
522
|
+
skipInstall: true,
|
|
523
|
+
skipGit: true,
|
|
524
|
+
yes: true,
|
|
525
|
+
pieces: ["webext"],
|
|
526
|
+
}),
|
|
527
|
+
).rejects.toThrow(/not available for the app template/u);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("rejects unknown pieces", async () => {
|
|
531
|
+
await expect(
|
|
532
|
+
initApp("bogus-pieces", {
|
|
533
|
+
template: "universal",
|
|
534
|
+
skipInstall: true,
|
|
535
|
+
skipGit: true,
|
|
536
|
+
yes: true,
|
|
537
|
+
pieces: ["solana" as never],
|
|
538
|
+
}),
|
|
539
|
+
).rejects.toThrow(/Unknown piece "solana"/u);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("defaults provenance pieces to [] for pre-piece scaffolds", () => {
|
|
543
|
+
const legacy = mkdtempSync(join(tmpdir(), "mpo-legacy-prov-"));
|
|
544
|
+
writeFileSync(
|
|
545
|
+
join(legacy, ".mpo.json"),
|
|
546
|
+
JSON.stringify({
|
|
547
|
+
template: "universal",
|
|
548
|
+
cliVersion: "6.4.0",
|
|
549
|
+
name: "legacy",
|
|
550
|
+
mpoVersion: "^6.4.0",
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
expect(readProvenance(legacy)?.pieces).toEqual([]);
|
|
554
|
+
rmSync(legacy, { recursive: true, force: true });
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
describe("mergeJsonValues", () => {
|
|
559
|
+
it("unions arrays preserving base order", () => {
|
|
560
|
+
expect(mergeJsonValues(["a", "b"], ["b", "c"])).toEqual(["a", "b", "c"]);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("merges nested objects with fragment scalars winning", () => {
|
|
564
|
+
expect(
|
|
565
|
+
mergeJsonValues(
|
|
566
|
+
{ scripts: { dev: "vite" }, private: true },
|
|
567
|
+
{ scripts: { build: "vite build", dev: "vite --host" } },
|
|
568
|
+
),
|
|
569
|
+
).toEqual({ scripts: { dev: "vite --host", build: "vite build" }, private: true });
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("replaces on type mismatch", () => {
|
|
573
|
+
expect(mergeJsonValues({ a: 1 }, ["x"])).toEqual(["x"]);
|
|
574
|
+
});
|
|
575
|
+
});
|
|
210
576
|
});
|