@multiplatform.one/cli 6.7.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -8
- package/lib/bin/multiplatformOne.mjs +21 -7
- package/lib/commands/adoptApp.mjs +127 -0
- package/lib/commands/init.mjs +1 -1
- package/lib/commands/initApp.mjs +43 -15
- package/lib/commands/updateApp.mjs +84 -15
- package/package.json +2 -2
- package/scripts/frappe-app-name.py +125 -0
- package/scripts/frappe-app-name.spec.ts +191 -0
- package/scripts/frappe-bootstrap.sh +8 -30
- package/src/bin/multiplatformOne.ts +78 -13
- package/src/commands/adoptApp.spec.ts +208 -0
- package/src/commands/adoptApp.ts +185 -0
- package/src/commands/initApp.spec.ts +152 -12
- package/src/commands/initApp.ts +95 -21
- package/src/commands/updateApp.spec.ts +314 -2
- package/src/commands/updateApp.ts +164 -33
- package/templates/app/apps/__NAME__/package.json +1 -1
- package/templates/app/features/__NAME__/package.json +1 -1
- package/templates/pieces/gnome/universal/README.md.partial +5 -5
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/anchor.tsx +39 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/main.tsx +4 -2
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/components.ts +26 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/forms.ts +26 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/frappe-ui.ts +17 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/one.ts +3 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/theme.ts +21 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/tamagui-barrel.ts +21 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/vite.config.gnome.ts +49 -7
- package/templates/pieces/keycloak/universal/README.md.partial +13 -0
- package/templates/pieces/keycloak/universal/docker/compose.keycloak.yaml +19 -3
- package/templates/pieces/keycloak/universal/env.example.partial +4 -1
- package/templates/pieces/vscode/universal/apps/__NAME__/package.json.partial +1 -1
- package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +2 -2
- package/templates/universal/apps/__NAME__/package.json +2 -2
- package/templates/universal/packages/themes/package.json +2 -2
- package/types/bin/multiplatformOne.d.ts.map +1 -1
- package/types/commands/adoptApp.d.ts +25 -0
- package/types/commands/adoptApp.d.ts.map +1 -0
- package/types/commands/initApp.d.ts +33 -5
- package/types/commands/initApp.d.ts.map +1 -1
- package/types/commands/updateApp.d.ts +12 -2
- package/types/commands/updateApp.d.ts.map +1 -1
package/src/commands/initApp.ts
CHANGED
|
@@ -20,6 +20,15 @@ import spawn from "nano-spawn";
|
|
|
20
20
|
*/
|
|
21
21
|
export type InitAppTemplate = "universal" | "app";
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The provenance template domain: the scaffold templates plus "none" for
|
|
25
|
+
* ADOPTED projects (`mpo adopt`) — existing repos that were never scaffolded.
|
|
26
|
+
* A "none" scaffold is the selected piece fragments overlaid on an EMPTY
|
|
27
|
+
* tree (no universal/app base), which is exactly the tool-owned surface of
|
|
28
|
+
* an adopted project.
|
|
29
|
+
*/
|
|
30
|
+
export type ProvenanceTemplate = InitAppTemplate | "none";
|
|
31
|
+
|
|
23
32
|
/**
|
|
24
33
|
* Optional composable pieces overlaid onto the base template (additive
|
|
25
34
|
* fragments under templates/pieces/<piece>/ — the inverse of the old
|
|
@@ -40,7 +49,7 @@ const PIECE_DESCRIPTIONS: Record<InitAppPiece, string> = {
|
|
|
40
49
|
frappe: "Frappe backend wiring (env keys, provider, docker compose bench)",
|
|
41
50
|
gnome: "GNOME desktop target (GTK4/GJS via react-gnome — native widgets, no webview)",
|
|
42
51
|
keycloak: "Keycloak auth wiring (env keys, provider, docker compose + realm)",
|
|
43
|
-
tauri: "
|
|
52
|
+
tauri: "Tauri desktop target (src-tauri skeleton — webview shell, Rust toolchain)",
|
|
44
53
|
vscode: "VS Code extension target (extension host + Tamagui webview)",
|
|
45
54
|
webext: "Browser extension target (MV3 popup + background)",
|
|
46
55
|
};
|
|
@@ -52,8 +61,13 @@ export interface InitAppOptions {
|
|
|
52
61
|
skipGit?: boolean;
|
|
53
62
|
/** Pin @multiplatform.one/* to this semver range. Default: ^<cli version>. */
|
|
54
63
|
version?: string;
|
|
55
|
-
/**
|
|
56
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Project template. When omitted: prompt (interactive) or "universal".
|
|
66
|
+
* "none" scaffolds ONLY the selected pieces onto an empty tree (the
|
|
67
|
+
* `--pieces-only` flag) — `mpo update` uses it to rebuild the merge
|
|
68
|
+
* baselines of adopted projects.
|
|
69
|
+
*/
|
|
70
|
+
template?: ProvenanceTemplate;
|
|
57
71
|
/** Accept defaults instead of prompting (non-interactive / CI). */
|
|
58
72
|
yes?: boolean;
|
|
59
73
|
/**
|
|
@@ -80,7 +94,7 @@ function toPascalCase(name: string): string {
|
|
|
80
94
|
.join("");
|
|
81
95
|
}
|
|
82
96
|
|
|
83
|
-
function validateName(name: string): string {
|
|
97
|
+
export function validateName(name: string): string {
|
|
84
98
|
const trimmed = name.trim();
|
|
85
99
|
if (!trimmed) {
|
|
86
100
|
throw new Error("Project name is required");
|
|
@@ -93,6 +107,19 @@ function validateName(name: string): string {
|
|
|
93
107
|
return trimmed;
|
|
94
108
|
}
|
|
95
109
|
|
|
110
|
+
/** Root package.json name with any @scope/ prefix stripped (adopt +
|
|
111
|
+
* update-bootstrap provenance name derivation). */
|
|
112
|
+
export function packageJsonName(projectDir: string): string | undefined {
|
|
113
|
+
try {
|
|
114
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf-8")) as {
|
|
115
|
+
name?: string;
|
|
116
|
+
};
|
|
117
|
+
return pkg.name?.replace(/^@[^/]+\//u, "");
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
96
123
|
function resolveMpoVersion(explicit?: string): string {
|
|
97
124
|
if (explicit) {
|
|
98
125
|
return explicit.startsWith("^") || explicit.startsWith("~") || explicit === "*"
|
|
@@ -120,12 +147,21 @@ export function cliVersion(): string | undefined {
|
|
|
120
147
|
* three-way template update.
|
|
121
148
|
*/
|
|
122
149
|
export interface MpoProvenance {
|
|
123
|
-
|
|
150
|
+
/** "none" = adopted project (`mpo adopt`): pieces only, no base template. */
|
|
151
|
+
template: ProvenanceTemplate;
|
|
124
152
|
cliVersion: string;
|
|
125
153
|
name: string;
|
|
126
154
|
mpoVersion: string;
|
|
127
155
|
/** Composable pieces overlaid at init time (sorted). */
|
|
128
156
|
pieces: InitAppPiece[];
|
|
157
|
+
/**
|
|
158
|
+
* false = adopted but never reconciled: `mpo adopt` wrote provenance
|
|
159
|
+
* without touching any project file, so no tool-owned content exists yet.
|
|
160
|
+
* The first `mpo update` merges the piece baseline from an EMPTY base
|
|
161
|
+
* (surfacing real diffs against hand-copied files) and then clears the
|
|
162
|
+
* flag. Absent/true = the tree carries the recorded scaffold.
|
|
163
|
+
*/
|
|
164
|
+
reconciled?: boolean;
|
|
129
165
|
}
|
|
130
166
|
|
|
131
167
|
export const PROVENANCE_FILE = ".mpo.json";
|
|
@@ -150,6 +186,8 @@ export function readProvenance(projectDir: string): MpoProvenance | undefined {
|
|
|
150
186
|
mpoVersion: raw.mpoVersion ?? `^${raw.cliVersion}`,
|
|
151
187
|
// Scaffolds from pre-piece CLIs (≤6.4.x) have no pieces field.
|
|
152
188
|
pieces: Array.isArray(raw.pieces) ? [...(raw.pieces as InitAppPiece[])].sort() : [],
|
|
189
|
+
// Only adopted-but-unreconciled provenance carries the flag.
|
|
190
|
+
...(raw.reconciled === false ? { reconciled: false } : {}),
|
|
153
191
|
};
|
|
154
192
|
} catch {
|
|
155
193
|
return undefined;
|
|
@@ -167,10 +205,13 @@ function piecesRoot(piece: InitAppPiece): string {
|
|
|
167
205
|
/**
|
|
168
206
|
* Fragment source dirs for a piece + template: shared/ applies to every
|
|
169
207
|
* template, <template>/ only to that one. A piece supports a template when
|
|
170
|
-
* at least one of the two exists.
|
|
208
|
+
* at least one of the two exists. Adopted projects (template "none") use the
|
|
209
|
+
* universal fragment set — pieces ship universal-layout fragments
|
|
210
|
+
* (apps/<name>/…), which is the layout adopted repos follow.
|
|
171
211
|
*/
|
|
172
|
-
function pieceFragmentDirs(piece: InitAppPiece, template:
|
|
173
|
-
|
|
212
|
+
function pieceFragmentDirs(piece: InitAppPiece, template: ProvenanceTemplate): string[] {
|
|
213
|
+
const effective: InitAppTemplate = template === "none" ? "universal" : template;
|
|
214
|
+
return [join(piecesRoot(piece), "shared"), join(piecesRoot(piece), effective)].filter((dir) =>
|
|
174
215
|
existsSync(dir),
|
|
175
216
|
);
|
|
176
217
|
}
|
|
@@ -357,7 +398,7 @@ function overlayTree(srcDir: string, destDir: string, vars: TemplateVars): void
|
|
|
357
398
|
* order must be deterministic), and every piece must ship a fragment for the
|
|
358
399
|
* chosen template.
|
|
359
400
|
*/
|
|
360
|
-
export function normalizePieces(pieces: string[], template:
|
|
401
|
+
export function normalizePieces(pieces: string[], template: ProvenanceTemplate): InitAppPiece[] {
|
|
361
402
|
const known = new Set<string>(INIT_APP_PIECES);
|
|
362
403
|
const normalized = [...new Set(pieces)].sort() as InitAppPiece[];
|
|
363
404
|
for (const piece of normalized) {
|
|
@@ -375,13 +416,18 @@ export function normalizePieces(pieces: string[], template: InitAppTemplate): In
|
|
|
375
416
|
}
|
|
376
417
|
|
|
377
418
|
/** Pieces that can be offered for a template (fragment exists for it). */
|
|
378
|
-
export function availablePieces(template:
|
|
419
|
+
export function availablePieces(template: ProvenanceTemplate): InitAppPiece[] {
|
|
379
420
|
return INIT_APP_PIECES.filter((piece) => pieceFragmentDirs(piece, template).length > 0);
|
|
380
421
|
}
|
|
381
422
|
|
|
423
|
+
/** Human-readable piece summaries (adopt + init prompts). */
|
|
424
|
+
export function pieceDescription(piece: InitAppPiece): string {
|
|
425
|
+
return PIECE_DESCRIPTIONS[piece];
|
|
426
|
+
}
|
|
427
|
+
|
|
382
428
|
async function resolvePieces(
|
|
383
429
|
options: InitAppOptions,
|
|
384
|
-
template:
|
|
430
|
+
template: ProvenanceTemplate,
|
|
385
431
|
): Promise<InitAppPiece[]> {
|
|
386
432
|
if (options.pieces) return normalizePieces(options.pieces, template);
|
|
387
433
|
if (options.yes || !process.stdout.isTTY) return [];
|
|
@@ -454,7 +500,7 @@ export function composeAppTs(pieces: InitAppPiece[]): string {
|
|
|
454
500
|
return lines.join("\n");
|
|
455
501
|
}
|
|
456
502
|
|
|
457
|
-
async function resolveTemplate(options: InitAppOptions): Promise<
|
|
503
|
+
async function resolveTemplate(options: InitAppOptions): Promise<ProvenanceTemplate> {
|
|
458
504
|
if (options.template) return options.template;
|
|
459
505
|
if (options.yes || !process.stdout.isTTY) return "universal";
|
|
460
506
|
const result = await inquirer.prompt([
|
|
@@ -501,6 +547,12 @@ export async function initApp(
|
|
|
501
547
|
const name = validateName(projectName);
|
|
502
548
|
const template = await resolveTemplate(options);
|
|
503
549
|
const pieces = await resolvePieces(options, template);
|
|
550
|
+
if (template === "none" && pieces.length === 0) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
"A pieces-only scaffold needs at least one piece " +
|
|
553
|
+
`(${INIT_APP_PIECES.map((piece) => `--${piece}`).join(", ")})`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
504
556
|
const targetDir = resolve(name);
|
|
505
557
|
|
|
506
558
|
if (existsSync(targetDir)) {
|
|
@@ -518,11 +570,18 @@ export async function initApp(
|
|
|
518
570
|
YEAR: String(new Date().getFullYear()),
|
|
519
571
|
};
|
|
520
572
|
|
|
521
|
-
const label =
|
|
573
|
+
const label =
|
|
574
|
+
template === "universal"
|
|
575
|
+
? "universal (web + iOS + Android)"
|
|
576
|
+
: template === "app"
|
|
577
|
+
? "web-only"
|
|
578
|
+
: "pieces-only (adopted-project baseline)";
|
|
522
579
|
console.log(`\nScaffolding ${label} project at ${targetDir}`);
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
580
|
+
if (template !== "none") {
|
|
581
|
+
console.log(` apps/${name}`);
|
|
582
|
+
console.log(` features/${name}`);
|
|
583
|
+
console.log(` packages/config`);
|
|
584
|
+
}
|
|
526
585
|
if (template === "universal") {
|
|
527
586
|
console.log(` packages/i18n`);
|
|
528
587
|
console.log(` packages/themes`);
|
|
@@ -532,7 +591,13 @@ export async function initApp(
|
|
|
532
591
|
}
|
|
533
592
|
console.log(` @multiplatform.one/* → ${vars.MPO_VERSION}\n`);
|
|
534
593
|
|
|
535
|
-
|
|
594
|
+
// "none" (adopted-project baselines) overlays pieces on an EMPTY tree —
|
|
595
|
+
// there is no base template to write.
|
|
596
|
+
if (template === "none") {
|
|
597
|
+
mkdirSync(targetDir, { recursive: true });
|
|
598
|
+
} else {
|
|
599
|
+
writeTree(templatesRoot(template), targetDir, vars);
|
|
600
|
+
}
|
|
536
601
|
// Pieces overlay AFTER the base template, in sorted order (normalizePieces)
|
|
537
602
|
// so repeated scaffolds are byte-identical — mpo update regenerates these
|
|
538
603
|
// exact trees as merge baselines.
|
|
@@ -544,6 +609,8 @@ export async function initApp(
|
|
|
544
609
|
// Backend providers are wired by generating src/app.ts from the selection
|
|
545
610
|
// (not by stacking file overwrites per combination).
|
|
546
611
|
if (pieces.includes("frappe") || pieces.includes("keycloak")) {
|
|
612
|
+
// Pieces-only trees have no base template dirs — create the path.
|
|
613
|
+
mkdirSync(join(targetDir, "apps", name, "src"), { recursive: true });
|
|
547
614
|
writeFileSync(join(targetDir, "apps", name, "src/app.ts"), composeAppTs(pieces));
|
|
548
615
|
}
|
|
549
616
|
writeProvenance(targetDir, {
|
|
@@ -566,7 +633,8 @@ export async function initApp(
|
|
|
566
633
|
() => false,
|
|
567
634
|
);
|
|
568
635
|
if (!insideRepo) {
|
|
569
|
-
const label =
|
|
636
|
+
const label =
|
|
637
|
+
template === "universal" ? "universal" : template === "app" ? "web-only" : "pieces-only";
|
|
570
638
|
await spawn("git", ["init", "--quiet"], { cwd: targetDir });
|
|
571
639
|
await spawn("git", ["add", "-A"], { cwd: targetDir });
|
|
572
640
|
scaffoldCommitted = await spawn(
|
|
@@ -583,7 +651,9 @@ export async function initApp(
|
|
|
583
651
|
}
|
|
584
652
|
}
|
|
585
653
|
|
|
586
|
-
|
|
654
|
+
// Pieces-only trees are merge baselines, not runnable projects — never
|
|
655
|
+
// install into them.
|
|
656
|
+
if (!options.skipInstall && template !== "none") {
|
|
587
657
|
console.log("Installing dependencies...");
|
|
588
658
|
await spawn("pnpm", ["install"], {
|
|
589
659
|
cwd: targetDir,
|
|
@@ -596,11 +666,15 @@ export async function initApp(
|
|
|
596
666
|
.then(() => spawn("git", ["commit", "--amend", "--no-edit", "--quiet"], { cwd: targetDir }))
|
|
597
667
|
.catch(() => {});
|
|
598
668
|
}
|
|
599
|
-
} else {
|
|
669
|
+
} else if (options.skipInstall) {
|
|
600
670
|
console.log("Skipped pnpm install (--skip-install).");
|
|
601
671
|
}
|
|
602
672
|
|
|
603
673
|
console.log(`\n✅ Project created at ${targetDir}`);
|
|
674
|
+
if (template === "none") {
|
|
675
|
+
console.log("\nPieces-only tree (adopted-project baseline) — not a runnable project.");
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
604
678
|
console.log("\nNext steps:");
|
|
605
679
|
console.log(` cd ${name}`);
|
|
606
680
|
if (options.skipInstall) console.log(" pnpm install");
|
|
@@ -619,7 +693,7 @@ export async function initApp(
|
|
|
619
693
|
console.log(" pnpm gnome # GTK4 desktop app (requires gjs + gtk4 — see README)");
|
|
620
694
|
}
|
|
621
695
|
if (pieces.includes("tauri")) {
|
|
622
|
-
console.log(" pnpm tauri:dev # desktop app (requires the Rust toolchain)");
|
|
696
|
+
console.log(" pnpm tauri:dev # Tauri desktop app (requires the Rust toolchain)");
|
|
623
697
|
}
|
|
624
698
|
if (pieces.includes("vscode")) {
|
|
625
699
|
console.log(" pnpm build:vscode # stage the VS Code extension into apps dist-vscode/");
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import spawn from "nano-spawn";
|
|
5
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
6
|
+
import { adoptApp } from "./adoptApp";
|
|
7
|
+
import { cliVersion, initApp, readProvenance } from "./initApp";
|
|
8
|
+
import { threeWayMergePackageJson, updateApp } from "./updateApp";
|
|
3
9
|
|
|
4
10
|
const json = (value: unknown) => JSON.stringify(value, null, 2);
|
|
5
11
|
|
|
@@ -79,3 +85,309 @@ describe("threeWayMergePackageJson", () => {
|
|
|
79
85
|
});
|
|
80
86
|
});
|
|
81
87
|
});
|
|
88
|
+
|
|
89
|
+
// ── integration: adopt → update, bootstrap, idempotency ───────────
|
|
90
|
+
//
|
|
91
|
+
// Everything below runs at the CURRENT cli version, so both merge
|
|
92
|
+
// baselines come from the local generator (never pnpm dlx / network).
|
|
93
|
+
|
|
94
|
+
const readJson = (path: string) => JSON.parse(readFileSync(path, "utf-8"));
|
|
95
|
+
|
|
96
|
+
async function gitOut(cwd: string, args: string[]): Promise<string> {
|
|
97
|
+
const result = await spawn("git", args, { cwd });
|
|
98
|
+
return result.stdout.trim();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function initRepo(dir: string): Promise<void> {
|
|
102
|
+
await spawn("git", ["init", "--quiet"], { cwd: dir });
|
|
103
|
+
await spawn("git", ["config", "user.email", "spec@example.com"], { cwd: dir });
|
|
104
|
+
await spawn("git", ["config", "user.name", "Spec"], { cwd: dir });
|
|
105
|
+
await spawn("git", ["add", "-A"], { cwd: dir });
|
|
106
|
+
await spawn("git", ["commit", "--quiet", "-m", "chore: fixture"], { cwd: dir });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function writeFiles(dir: string, files: Record<string, string>): void {
|
|
110
|
+
mkdirSync(dir, { recursive: true });
|
|
111
|
+
for (const [file, content] of Object.entries(files)) {
|
|
112
|
+
mkdirSync(dirname(join(dir, file)), { recursive: true });
|
|
113
|
+
writeFileSync(join(dir, file), content);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe("updateApp (integration)", () => {
|
|
118
|
+
const currentVersion = cliVersion()!;
|
|
119
|
+
const currentRange = `^${currentVersion}`;
|
|
120
|
+
let sandbox: string;
|
|
121
|
+
let previousCwd: string;
|
|
122
|
+
let canonical: string;
|
|
123
|
+
|
|
124
|
+
/** Scaffold the canonical pieces-only tree (what adopted-update baselines
|
|
125
|
+
* look like) once, to source hand-copied fixtures and compare against. */
|
|
126
|
+
beforeAll(async () => {
|
|
127
|
+
sandbox = mkdtempSync(join(tmpdir(), "mpo-updateapp-spec-"));
|
|
128
|
+
previousCwd = process.cwd();
|
|
129
|
+
const parent = join(sandbox, "canonical");
|
|
130
|
+
mkdirSync(parent, { recursive: true });
|
|
131
|
+
process.chdir(parent);
|
|
132
|
+
await initApp("fake-ext", {
|
|
133
|
+
template: "none",
|
|
134
|
+
pieces: ["webext"],
|
|
135
|
+
version: currentRange,
|
|
136
|
+
skipInstall: true,
|
|
137
|
+
skipGit: true,
|
|
138
|
+
yes: true,
|
|
139
|
+
});
|
|
140
|
+
process.chdir(previousCwd);
|
|
141
|
+
canonical = join(parent, "fake-ext");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
afterEach(() => {
|
|
145
|
+
process.chdir(previousCwd);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
afterAll(() => {
|
|
149
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("adopted project: first update reconciles from an empty base, second run is a no-op", async () => {
|
|
153
|
+
// A never-scaffolded project that hand-copied ONE webext piece file
|
|
154
|
+
// verbatim and has its own unrelated files.
|
|
155
|
+
const dir = join(sandbox, "adopted-clean", "fake-ext");
|
|
156
|
+
writeFiles(dir, {
|
|
157
|
+
"package.json": json({
|
|
158
|
+
name: "@fake/fake-ext",
|
|
159
|
+
private: true,
|
|
160
|
+
scripts: { custom: "echo custom" },
|
|
161
|
+
}),
|
|
162
|
+
"src/index.ts": "export const custom = true;\n",
|
|
163
|
+
"apps/fake-ext/webext/manifest.ts": readFileSync(
|
|
164
|
+
join(canonical, "apps/fake-ext/webext/manifest.ts"),
|
|
165
|
+
"utf-8",
|
|
166
|
+
),
|
|
167
|
+
});
|
|
168
|
+
await initRepo(dir);
|
|
169
|
+
process.chdir(dir);
|
|
170
|
+
|
|
171
|
+
await adoptApp({ pieces: ["webext"], yes: true });
|
|
172
|
+
expect(readProvenance(dir)?.reconciled).toBe(false);
|
|
173
|
+
|
|
174
|
+
await updateApp({ skipInstall: true });
|
|
175
|
+
|
|
176
|
+
// Missing piece files landed…
|
|
177
|
+
for (const file of [
|
|
178
|
+
"apps/fake-ext/vite.config.webext.ts",
|
|
179
|
+
"apps/fake-ext/vite.config.webext-background.ts",
|
|
180
|
+
"apps/fake-ext/webext/background/main.ts",
|
|
181
|
+
"apps/fake-ext/webext/views/popup/main.tsx",
|
|
182
|
+
"apps/fake-ext/package.json",
|
|
183
|
+
]) {
|
|
184
|
+
expect(existsSync(join(dir, file)), `missing ${file}`).toBe(true);
|
|
185
|
+
}
|
|
186
|
+
// …the identical hand-copied file stayed identical…
|
|
187
|
+
expect(readFileSync(join(dir, "apps/fake-ext/webext/manifest.ts"), "utf-8")).toBe(
|
|
188
|
+
readFileSync(join(canonical, "apps/fake-ext/webext/manifest.ts"), "utf-8"),
|
|
189
|
+
);
|
|
190
|
+
// …the project's own files survived…
|
|
191
|
+
expect(readFileSync(join(dir, "src/index.ts"), "utf-8")).toBe("export const custom = true;\n");
|
|
192
|
+
// …and package.json merged structurally (custom + piece scripts).
|
|
193
|
+
const rootPkg = readJson(join(dir, "package.json"));
|
|
194
|
+
expect(rootPkg.scripts.custom).toBe("echo custom");
|
|
195
|
+
expect(rootPkg.scripts["build:webext"]).toBe("pnpm --filter @app/fake-ext build:webext");
|
|
196
|
+
|
|
197
|
+
// Reconciliation committed cleanly and cleared the flag.
|
|
198
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
199
|
+
expect(await gitOut(dir, ["log", "-1", "--format=%s"])).toContain("mpo update");
|
|
200
|
+
const provenance = readProvenance(dir);
|
|
201
|
+
expect(provenance?.reconciled).toBeUndefined();
|
|
202
|
+
expect(provenance?.cliVersion).toBe(currentVersion);
|
|
203
|
+
|
|
204
|
+
// Idempotency: an immediate second update changes NOTHING.
|
|
205
|
+
const headBefore = await gitOut(dir, ["rev-parse", "HEAD"]);
|
|
206
|
+
await updateApp({ skipInstall: true });
|
|
207
|
+
expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(headBefore);
|
|
208
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("adopted project: divergent hand-copied files surface conflict markers, then updates go quiet", async () => {
|
|
212
|
+
const dir = join(sandbox, "adopted-divergent", "fake-ext");
|
|
213
|
+
writeFiles(dir, {
|
|
214
|
+
"package.json": json({ name: "fake-ext", private: true }),
|
|
215
|
+
"apps/fake-ext/webext/manifest.ts": "// hand-rolled manifest, diverged\n",
|
|
216
|
+
});
|
|
217
|
+
await initRepo(dir);
|
|
218
|
+
process.chdir(dir);
|
|
219
|
+
|
|
220
|
+
await adoptApp({ pieces: ["webext"], yes: true });
|
|
221
|
+
await updateApp({ skipInstall: true });
|
|
222
|
+
|
|
223
|
+
// The divergent file carries conflict markers in the worktree — the
|
|
224
|
+
// documented (expected) first-update behavior for hand-adopted files.
|
|
225
|
+
const manifest = readFileSync(join(dir, "apps/fake-ext/webext/manifest.ts"), "utf-8");
|
|
226
|
+
expect(manifest).toContain("<<<<<<<");
|
|
227
|
+
expect(manifest).toContain(">>>>>>>");
|
|
228
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).not.toBe("");
|
|
229
|
+
// Provenance was still rewritten (flag cleared) for the resolution commit.
|
|
230
|
+
expect(readProvenance(dir)?.reconciled).toBeUndefined();
|
|
231
|
+
|
|
232
|
+
// Resolve (keep the template side) and commit like the CLI instructs.
|
|
233
|
+
writeFileSync(
|
|
234
|
+
join(dir, "apps/fake-ext/webext/manifest.ts"),
|
|
235
|
+
readFileSync(join(canonical, "apps/fake-ext/webext/manifest.ts"), "utf-8"),
|
|
236
|
+
);
|
|
237
|
+
await spawn("git", ["add", "-A"], { cwd: dir });
|
|
238
|
+
await spawn("git", ["commit", "--quiet", "-m", "chore: mpo update (resolved)"], { cwd: dir });
|
|
239
|
+
|
|
240
|
+
// Idempotency after resolution.
|
|
241
|
+
const headBefore = await gitOut(dir, ["rev-parse", "HEAD"]);
|
|
242
|
+
await updateApp({ skipInstall: true });
|
|
243
|
+
expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(headBefore);
|
|
244
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it(".updateignore: dir/** normalizes to a directory pin; HEAD wins including absence", async () => {
|
|
248
|
+
const dir = join(sandbox, "adopted-pinned", "fake-ext");
|
|
249
|
+
writeFiles(dir, {
|
|
250
|
+
"package.json": json({ name: "fake-ext", private: true }),
|
|
251
|
+
// Deliberately diverged piece file the pin must protect verbatim.
|
|
252
|
+
"apps/fake-ext/webext/manifest.ts": "// bespoke manifest — never reconcile\n",
|
|
253
|
+
// The natural (glob) spelling users reach for first.
|
|
254
|
+
".updateignore": "# bespoke surface\napps/fake-ext/webext/**\n",
|
|
255
|
+
});
|
|
256
|
+
await initRepo(dir);
|
|
257
|
+
process.chdir(dir);
|
|
258
|
+
|
|
259
|
+
await adoptApp({ pieces: ["webext"], yes: true });
|
|
260
|
+
await updateApp({ skipInstall: true });
|
|
261
|
+
|
|
262
|
+
// Pinned file byte-identical, no conflict markers anywhere under the pin.
|
|
263
|
+
expect(readFileSync(join(dir, "apps/fake-ext/webext/manifest.ts"), "utf-8")).toBe(
|
|
264
|
+
"// bespoke manifest — never reconcile\n",
|
|
265
|
+
);
|
|
266
|
+
// Pinning means HEAD wins INCLUDING absence: template-only files under
|
|
267
|
+
// the pinned dir must not land…
|
|
268
|
+
expect(existsSync(join(dir, "apps/fake-ext/webext/views/popup/main.tsx"))).toBe(false);
|
|
269
|
+
// …while piece files OUTSIDE the pin still flow.
|
|
270
|
+
expect(existsSync(join(dir, "apps/fake-ext/vite.config.webext.ts"))).toBe(true);
|
|
271
|
+
|
|
272
|
+
// Idempotency with pins active.
|
|
273
|
+
const headBefore = await gitOut(dir, ["rev-parse", "HEAD"]);
|
|
274
|
+
await updateApp({ skipInstall: true });
|
|
275
|
+
expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(headBefore);
|
|
276
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it(".updateignore: a genuinely glob-shaped entry is refused loudly, not silently dead", async () => {
|
|
280
|
+
const dir = join(sandbox, "adopted-badglob", "fake-ext");
|
|
281
|
+
writeFiles(dir, {
|
|
282
|
+
"package.json": json({ name: "fake-ext", private: true }),
|
|
283
|
+
".updateignore": "apps/*.config.ts\n",
|
|
284
|
+
});
|
|
285
|
+
await initRepo(dir);
|
|
286
|
+
process.chdir(dir);
|
|
287
|
+
|
|
288
|
+
await adoptApp({ pieces: ["webext"], yes: true });
|
|
289
|
+
await expect(updateApp({ skipInstall: true })).rejects.toThrow(/looks like a glob/);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("scaffolded project: immediate re-update is a no-op, customizations survive", async () => {
|
|
293
|
+
const parent = join(sandbox, "scaffolded");
|
|
294
|
+
mkdirSync(parent, { recursive: true });
|
|
295
|
+
process.chdir(parent);
|
|
296
|
+
await initApp("uni-idem", {
|
|
297
|
+
template: "universal",
|
|
298
|
+
pieces: ["webext"],
|
|
299
|
+
version: currentRange,
|
|
300
|
+
skipInstall: true,
|
|
301
|
+
skipGit: true,
|
|
302
|
+
yes: true,
|
|
303
|
+
});
|
|
304
|
+
const dir = join(parent, "uni-idem");
|
|
305
|
+
await initRepo(dir);
|
|
306
|
+
process.chdir(dir);
|
|
307
|
+
|
|
308
|
+
// Fresh scaffold at the current version: update must be a pure no-op.
|
|
309
|
+
const headBefore = await gitOut(dir, ["rev-parse", "HEAD"]);
|
|
310
|
+
await updateApp({ skipInstall: true });
|
|
311
|
+
expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(headBefore);
|
|
312
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
313
|
+
|
|
314
|
+
// User customizations don't destabilize it.
|
|
315
|
+
writeFileSync(join(dir, "custom.md"), "# mine\n");
|
|
316
|
+
await spawn("git", ["add", "-A"], { cwd: dir });
|
|
317
|
+
await spawn("git", ["commit", "--quiet", "-m", "feat: customize"], { cwd: dir });
|
|
318
|
+
const headCustom = await gitOut(dir, ["rev-parse", "HEAD"]);
|
|
319
|
+
await updateApp({ skipInstall: true });
|
|
320
|
+
expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(headCustom);
|
|
321
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
322
|
+
expect(readFileSync(join(dir, "custom.md"), "utf-8")).toBe("# mine\n");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("bootstraps missing provenance with --assume-version and stays idempotent", async () => {
|
|
326
|
+
const parent = join(sandbox, "bootstrap");
|
|
327
|
+
mkdirSync(parent, { recursive: true });
|
|
328
|
+
process.chdir(parent);
|
|
329
|
+
await initApp("pre-prov", {
|
|
330
|
+
template: "universal",
|
|
331
|
+
version: currentRange,
|
|
332
|
+
skipInstall: true,
|
|
333
|
+
skipGit: true,
|
|
334
|
+
yes: true,
|
|
335
|
+
});
|
|
336
|
+
const dir = join(parent, "pre-prov");
|
|
337
|
+
// Simulate a pre-provenance scaffold: no .mpo.json ever existed.
|
|
338
|
+
rmSync(join(dir, ".mpo.json"));
|
|
339
|
+
await initRepo(dir);
|
|
340
|
+
process.chdir(dir);
|
|
341
|
+
|
|
342
|
+
// Without provenance the update refuses, pointing at both escape
|
|
343
|
+
// hatches (bootstrap + adopt).
|
|
344
|
+
await expect(updateApp({ skipInstall: true })).rejects.toThrow(/--assume-version/u);
|
|
345
|
+
await expect(updateApp({ skipInstall: true })).rejects.toThrow(/mpo adopt/u);
|
|
346
|
+
// Bad version strings are rejected before anything is written.
|
|
347
|
+
await expect(updateApp({ skipInstall: true, assumeVersion: "not-a-version" })).rejects.toThrow(
|
|
348
|
+
/exact CLI version/u,
|
|
349
|
+
);
|
|
350
|
+
expect(existsSync(join(dir, ".mpo.json"))).toBe(false);
|
|
351
|
+
|
|
352
|
+
await updateApp({ skipInstall: true, assumeVersion: currentVersion });
|
|
353
|
+
const provenance = readProvenance(dir);
|
|
354
|
+
expect(provenance?.template).toBe("universal");
|
|
355
|
+
expect(provenance?.cliVersion).toBe(currentVersion);
|
|
356
|
+
expect(provenance?.pieces).toEqual([]);
|
|
357
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
358
|
+
// The bootstrap provenance is committed, not floating.
|
|
359
|
+
expect(await gitOut(dir, ["show", "HEAD:.mpo.json"])).toContain(currentVersion);
|
|
360
|
+
|
|
361
|
+
// Re-asserting a version over existing provenance is refused.
|
|
362
|
+
await expect(updateApp({ skipInstall: true, assumeVersion: currentVersion })).rejects.toThrow(
|
|
363
|
+
/already has provenance/u,
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// Plain updates are now no-ops.
|
|
367
|
+
const headBefore = await gitOut(dir, ["rev-parse", "HEAD"]);
|
|
368
|
+
await updateApp({ skipInstall: true });
|
|
369
|
+
expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(headBefore);
|
|
370
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("bootstraps the app template when told to", async () => {
|
|
374
|
+
const parent = join(sandbox, "bootstrap-app");
|
|
375
|
+
mkdirSync(parent, { recursive: true });
|
|
376
|
+
process.chdir(parent);
|
|
377
|
+
await initApp("pre-prov-app", {
|
|
378
|
+
template: "app",
|
|
379
|
+
version: currentRange,
|
|
380
|
+
skipInstall: true,
|
|
381
|
+
skipGit: true,
|
|
382
|
+
yes: true,
|
|
383
|
+
});
|
|
384
|
+
const dir = join(parent, "pre-prov-app");
|
|
385
|
+
rmSync(join(dir, ".mpo.json"));
|
|
386
|
+
await initRepo(dir);
|
|
387
|
+
process.chdir(dir);
|
|
388
|
+
|
|
389
|
+
await updateApp({ skipInstall: true, assumeVersion: currentVersion, assumeTemplate: "app" });
|
|
390
|
+
expect(readProvenance(dir)?.template).toBe("app");
|
|
391
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
392
|
+
});
|
|
393
|
+
});
|