@multiplatform.one/cli 7.11.0 → 7.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -111
- package/bin/createMultiplatformApp.mjs +39 -37
- package/lib/bin/multiplatformOne.mjs +65 -75
- package/lib/commands/addApp.mjs +78 -0
- package/lib/commands/adoptApp.mjs +11 -7
- package/lib/commands/doctypeTypes.mjs +148 -0
- package/lib/commands/e2e.mjs +2 -2
- package/lib/commands/init.mjs +142 -459
- package/lib/commands/merge.mjs +359 -0
- package/lib/commands/pieces.mjs +236 -0
- package/lib/commands/provenance.mjs +108 -0
- package/lib/commands/scaffold.mjs +105 -0
- package/lib/commands/updateApp.mjs +211 -528
- package/lib/source.json +5 -0
- package/lib/source.mjs +63 -0
- package/package.json +6 -6
- package/scripts/frappe-bootstrap.sh +29 -3
- package/scripts/frappe-dev.sh +1 -2
- package/scripts/frappe-helpers.sh +50 -2
- package/scripts/frappe.sh +5 -0
- package/scripts/scaffold.sh +937 -0
- package/scripts/stamp-source.mjs +51 -0
- package/scripts/stamp-source.spec.ts +86 -0
- package/src/bin/multiplatformOne.ts +116 -226
- package/src/commands/addApp.ts +102 -0
- package/src/commands/adoptApp.spec.ts +5 -1
- package/src/commands/adoptApp.ts +15 -10
- package/src/commands/doctypeTypes.spec.ts +155 -0
- package/src/commands/doctypeTypes.ts +214 -0
- package/src/commands/init.spec.ts +66 -0
- package/src/commands/init.ts +201 -643
- package/src/commands/merge.spec.ts +81 -0
- package/src/commands/merge.ts +411 -0
- package/src/commands/pieces.spec.ts +62 -0
- package/src/commands/pieces.ts +340 -0
- package/src/commands/provenance.spec.ts +92 -0
- package/src/commands/provenance.ts +169 -0
- package/src/commands/scaffold.ts +131 -0
- package/src/commands/updateApp.spec.ts +540 -331
- package/src/commands/updateApp.ts +255 -668
- package/src/source.spec.ts +45 -0
- package/src/source.ts +88 -0
- package/templates/AGENTS.md +161 -0
- package/templates/README.md +62 -0
- package/templates/pieces/frappe/universal/README.md.partial +7 -32
- package/templates/pieces/frappe/universal/env.example.partial +3 -3
- package/templates/pieces/frappe-bench/universal/README.md.partial +28 -0
- package/templates/pieces/{frappe → frappe-bench}/universal/docker/compose.frappe.yaml +2 -2
- package/templates/pieces/tauri/universal/README.md.partial +7 -5
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/dev.rs +463 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +6 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +2 -1
- package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +1 -0
- package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +6 -2
- package/types/bin/multiplatformOne.d.ts.map +1 -1
- package/types/commands/addApp.d.ts +20 -0
- package/types/commands/addApp.d.ts.map +1 -0
- package/types/commands/adoptApp.d.ts +4 -3
- package/types/commands/adoptApp.d.ts.map +1 -1
- package/types/commands/doctypeTypes.d.ts +40 -0
- package/types/commands/doctypeTypes.d.ts.map +1 -0
- package/types/commands/init.d.ts +30 -13
- package/types/commands/init.d.ts.map +1 -1
- package/types/commands/merge.d.ts +54 -0
- package/types/commands/merge.d.ts.map +1 -0
- package/types/commands/pieces.d.ts +54 -0
- package/types/commands/pieces.d.ts.map +1 -0
- package/types/commands/provenance.d.ts +52 -0
- package/types/commands/provenance.d.ts.map +1 -0
- package/types/commands/scaffold.d.ts +43 -0
- package/types/commands/scaffold.d.ts.map +1 -0
- package/types/commands/updateApp.d.ts +19 -27
- package/types/commands/updateApp.d.ts.map +1 -1
- package/types/source.d.ts +35 -0
- package/types/source.d.ts.map +1 -0
- package/lib/commands/initApp.mjs +0 -431
- package/lib/generateVscode.mjs +0 -184
- package/scripts/clone.sh +0 -17
- package/scripts/update.sh +0 -46
- package/src/commands/initApp.spec.ts +0 -720
- package/src/commands/initApp.ts +0 -706
- package/src/generateVscode.ts +0 -238
- package/templates/app/AGENTS.md +0 -142
- package/templates/app/README.md +0 -62
- package/templates/app/apps/__NAME__/index.html +0 -36
- package/templates/app/apps/__NAME__/package.json +0 -31
- package/templates/app/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
- package/templates/app/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
- package/templates/app/apps/__NAME__/src/App.tsx +0 -9
- package/templates/app/apps/__NAME__/src/main.tsx +0 -14
- package/templates/app/apps/__NAME__/tsconfig.json +0 -8
- package/templates/app/apps/__NAME__/vite.config.ts +0 -17
- package/templates/app/features/__NAME__/package.json +0 -34
- package/templates/app/features/__NAME__/src/HomeScreen.tsx +0 -70
- package/templates/app/features/__NAME__/src/index.ts +0 -1
- package/templates/app/features/__NAME__/tsconfig.json +0 -5
- package/templates/app/gitignore +0 -9
- package/templates/app/npmrc +0 -2
- package/templates/app/oxlintrc.json +0 -39
- package/templates/app/package.json +0 -38
- package/templates/app/packages/config/config.json +0 -4
- package/templates/app/packages/config/index.ts +0 -5
- package/templates/app/packages/config/package.json +0 -17
- package/templates/app/packages/config/tsconfig.json +0 -5
- package/templates/app/pnpm-workspace.yaml +0 -4
- package/templates/app/tsconfig.base.json +0 -16
- package/templates/universal/AGENTS.md +0 -142
- package/templates/universal/README.md +0 -97
- package/templates/universal/apps/__NAME__/app.config.js +0 -31
- package/templates/universal/apps/__NAME__/config/tamagui.config.ts +0 -4
- package/templates/universal/apps/__NAME__/package.json +0 -62
- package/templates/universal/apps/__NAME__/polyfills.native.ts +0 -23
- package/templates/universal/apps/__NAME__/polyfills.ts +0 -3
- package/templates/universal/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
- package/templates/universal/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
- package/templates/universal/apps/__NAME__/routes/_layout.tsx +0 -22
- package/templates/universal/apps/__NAME__/routes/_layout.web.tsx +0 -21
- package/templates/universal/apps/__NAME__/routes/about.tsx +0 -3
- package/templates/universal/apps/__NAME__/routes/index.tsx +0 -3
- package/templates/universal/apps/__NAME__/src/app.ts +0 -10
- package/templates/universal/apps/__NAME__/tamagui.css +0 -3
- package/templates/universal/apps/__NAME__/tsconfig.json +0 -8
- package/templates/universal/apps/__NAME__/types.d.ts +0 -9
- package/templates/universal/apps/__NAME__/vite.config.ts +0 -20
- package/templates/universal/features/__NAME__/package.json +0 -35
- package/templates/universal/features/__NAME__/src/AboutScreen.tsx +0 -27
- package/templates/universal/features/__NAME__/src/AppLayout.tsx +0 -50
- package/templates/universal/features/__NAME__/src/ContactForm.spec.tsx +0 -22
- package/templates/universal/features/__NAME__/src/ContactForm.tsx +0 -55
- package/templates/universal/features/__NAME__/src/HomeScreen.tsx +0 -40
- package/templates/universal/features/__NAME__/src/index.ts +0 -4
- package/templates/universal/features/__NAME__/src/screens.spec.ts +0 -12
- package/templates/universal/features/__NAME__/tsconfig.json +0 -5
- package/templates/universal/features/__NAME__/vitest.config.mjs +0 -7
- package/templates/universal/gitignore +0 -14
- package/templates/universal/npmrc +0 -3
- package/templates/universal/oxlintrc.json +0 -39
- package/templates/universal/package.json +0 -51
- package/templates/universal/packages/config/config.json +0 -4
- package/templates/universal/packages/config/index.ts +0 -5
- package/templates/universal/packages/config/package.json +0 -17
- package/templates/universal/packages/config/tsconfig.json +0 -5
- package/templates/universal/packages/i18n/en/common.json +0 -30
- package/templates/universal/packages/i18n/es/common.json +0 -30
- package/templates/universal/packages/i18n/i18n.d.ts +0 -16
- package/templates/universal/packages/i18n/index.ts +0 -26
- package/templates/universal/packages/i18n/package.json +0 -20
- package/templates/universal/packages/i18n/resources.ts +0 -13
- package/templates/universal/packages/i18n/tsconfig.json +0 -5
- package/templates/universal/packages/themes/accent.ts +0 -32
- package/templates/universal/packages/themes/base.ts +0 -50
- package/templates/universal/packages/themes/fonts.ts +0 -8
- package/templates/universal/packages/themes/index.ts +0 -33
- package/templates/universal/packages/themes/package.json +0 -23
- package/templates/universal/packages/themes/tamagui.config.ts +0 -14
- package/templates/universal/packages/themes/tsconfig.json +0 -5
- package/templates/universal/pnpm-workspace.yaml +0 -4
- package/templates/universal/tsconfig.base.json +0 -21
- package/templates/universal/types/env.d.ts +0 -9
- package/types/commands/initApp.d.ts +0 -114
- package/types/commands/initApp.d.ts.map +0 -1
- package/types/generateVscode.d.ts +0 -7
- package/types/generateVscode.d.ts.map +0 -1
|
@@ -1,96 +1,30 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
2
10
|
import { tmpdir } from "node:os";
|
|
3
11
|
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
4
13
|
import spawn from "nano-spawn";
|
|
5
14
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
15
|
+
import { addApp } from "./addApp";
|
|
6
16
|
import { adoptApp } from "./adoptApp";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
17
|
+
import { init } from "./init";
|
|
18
|
+
import { piecesRoot, scaffoldPieces } from "./pieces";
|
|
19
|
+
import { cliVersion, readProvenance } from "./provenance";
|
|
20
|
+
import { updateApp } from "./updateApp";
|
|
21
|
+
|
|
22
|
+
// Every scaffold below reads THIS checkout at HEAD (resolveSource finds the
|
|
23
|
+
// monorepo around public/cli), so the suite exercises the real scaffold.sh
|
|
24
|
+
// against the real tree. Scaffolds take a few seconds each.
|
|
25
|
+
const monorepo = fileURLToPath(new URL("../../../..", import.meta.url));
|
|
26
|
+
const heavy = 300_000;
|
|
10
27
|
const json = (value: unknown) => JSON.stringify(value, null, 2);
|
|
11
|
-
|
|
12
|
-
describe("threeWayMergePackageJson", () => {
|
|
13
|
-
it("resolves dependency-range drift to the newer minimum", () => {
|
|
14
|
-
const base = json({ dependencies: { "@multiplatform.one/components": "^6.3.0" } });
|
|
15
|
-
const ours = json({ dependencies: { "@multiplatform.one/components": "^6.4.2" } });
|
|
16
|
-
const theirs = json({ dependencies: { "@multiplatform.one/components": "^6.4.3" } });
|
|
17
|
-
expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
|
|
18
|
-
dependencies: { "@multiplatform.one/components": "^6.4.3" },
|
|
19
|
-
});
|
|
20
|
-
// Symmetric: the project may be AHEAD of the template.
|
|
21
|
-
expect(threeWayMergePackageJson(base, theirs, ours)).toEqual({
|
|
22
|
-
dependencies: { "@multiplatform.one/components": "^6.4.3" },
|
|
23
|
-
});
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("prefers the template spelling on equal minimums", () => {
|
|
27
|
-
const base = json({ dependencies: { react: "^19.0.0" } });
|
|
28
|
-
const ours = json({ dependencies: { react: "19.2.0" } });
|
|
29
|
-
const theirs = json({ dependencies: { react: "~19.2.0" } });
|
|
30
|
-
expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
|
|
31
|
-
dependencies: { react: "~19.2.0" },
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("keeps project-added dependencies alongside template bumps", () => {
|
|
36
|
-
const base = json({ dependencies: { one: "^1.0.0" } });
|
|
37
|
-
const ours = json({ dependencies: { one: "^1.0.0", "@multiplatform.one/keycloak": "^6.4.2" } });
|
|
38
|
-
const theirs = json({ dependencies: { one: "^1.1.0" } });
|
|
39
|
-
expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
|
|
40
|
-
dependencies: { one: "^1.1.0", "@multiplatform.one/keycloak": "^6.4.2" },
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("applies template deletions the project did not touch", () => {
|
|
45
|
-
const base = json({ scripts: { postinstall: "patch-package", build: "vite build" } });
|
|
46
|
-
const ours = json({ scripts: { postinstall: "patch-package", build: "vite build" } });
|
|
47
|
-
const theirs = json({ scripts: { build: "vite build" } });
|
|
48
|
-
expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
|
|
49
|
-
scripts: { build: "vite build" },
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("resolves nested override maps", () => {
|
|
54
|
-
const base = json({ pnpm: { overrides: { "react-dom": "19.2.4" } } });
|
|
55
|
-
const ours = json({ pnpm: { overrides: { "react-dom": "19.2.5" } } });
|
|
56
|
-
const theirs = json({ pnpm: { overrides: { "react-dom": "19.2.6" } } });
|
|
57
|
-
expect(threeWayMergePackageJson(base, ours, theirs)).toEqual({
|
|
58
|
-
pnpm: { overrides: { "react-dom": "19.2.6" } },
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("returns undefined for genuine two-sided conflicts", () => {
|
|
63
|
-
const base = json({ scripts: { build: "vite build" } });
|
|
64
|
-
const ours = json({ scripts: { build: "vite build --mode ours" } });
|
|
65
|
-
const theirs = json({ scripts: { build: "vite build --mode theirs" } });
|
|
66
|
-
expect(threeWayMergePackageJson(base, ours, theirs)).toBeUndefined();
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("returns undefined for non-range dependency divergence", () => {
|
|
70
|
-
const base = json({ dependencies: { local: "^1.0.0" } });
|
|
71
|
-
const ours = json({ dependencies: { local: "workspace:*" } });
|
|
72
|
-
const theirs = json({ dependencies: { local: "^2.0.0" } });
|
|
73
|
-
expect(threeWayMergePackageJson(base, ours, theirs)).toBeUndefined();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("returns undefined on unparseable input", () => {
|
|
77
|
-
expect(threeWayMergePackageJson("{}", "not json", "{}")).toBeUndefined();
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("treats a missing base as empty (add/add merges)", () => {
|
|
81
|
-
const ours = json({ dependencies: { a: "^1.0.0" } });
|
|
82
|
-
const theirs = json({ dependencies: { b: "^2.0.0" } });
|
|
83
|
-
expect(threeWayMergePackageJson(undefined, ours, theirs)).toEqual({
|
|
84
|
-
dependencies: { a: "^1.0.0", b: "^2.0.0" },
|
|
85
|
-
});
|
|
86
|
-
});
|
|
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
28
|
const readJson = (path: string) => JSON.parse(readFileSync(path, "utf-8"));
|
|
95
29
|
|
|
96
30
|
async function gitOut(cwd: string, args: string[]): Promise<string> {
|
|
@@ -98,12 +32,21 @@ async function gitOut(cwd: string, args: string[]): Promise<string> {
|
|
|
98
32
|
return result.stdout.trim();
|
|
99
33
|
}
|
|
100
34
|
|
|
101
|
-
async function
|
|
102
|
-
await spawn("git", ["init", "--quiet"], { cwd: dir });
|
|
35
|
+
async function identity(dir: string): Promise<void> {
|
|
103
36
|
await spawn("git", ["config", "user.email", "spec@example.com"], { cwd: dir });
|
|
104
37
|
await spawn("git", ["config", "user.name", "Spec"], { cwd: dir });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function commitAll(dir: string, message: string): Promise<string> {
|
|
105
41
|
await spawn("git", ["add", "-A"], { cwd: dir });
|
|
106
|
-
await spawn("git", ["commit", "--quiet", "-m",
|
|
42
|
+
await spawn("git", ["commit", "--quiet", "-m", message], { cwd: dir });
|
|
43
|
+
return gitOut(dir, ["rev-parse", "HEAD"]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function initRepo(dir: string): Promise<string> {
|
|
47
|
+
await spawn("git", ["init", "--quiet"], { cwd: dir });
|
|
48
|
+
await identity(dir);
|
|
49
|
+
return commitAll(dir, "chore: fixture");
|
|
107
50
|
}
|
|
108
51
|
|
|
109
52
|
function writeFiles(dir: string, files: Record<string, string>): void {
|
|
@@ -114,280 +57,546 @@ function writeFiles(dir: string, files: Record<string, string>): void {
|
|
|
114
57
|
}
|
|
115
58
|
}
|
|
116
59
|
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
60
|
+
function replaceFirstLine(file: string, line: string): void {
|
|
61
|
+
const rest = readFileSync(file, "utf-8").split("\n").slice(1).join("\n");
|
|
62
|
+
writeFileSync(file, `${line}\n${rest}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("clone-derived project: init → update → add-app", () => {
|
|
120
66
|
let sandbox: string;
|
|
121
67
|
let previousCwd: string;
|
|
122
|
-
let
|
|
68
|
+
let project: string;
|
|
69
|
+
/** A second clone of the monorepo that moves ahead of the recorded commit. */
|
|
70
|
+
let source: string;
|
|
123
71
|
|
|
124
|
-
/** Scaffold the canonical pieces-only tree (what adopted-update baselines
|
|
125
|
-
* look like) once, to source hand-copied fixtures and compare against. */
|
|
126
72
|
beforeAll(async () => {
|
|
127
|
-
sandbox = mkdtempSync(join(tmpdir(), "mpo-
|
|
73
|
+
sandbox = mkdtempSync(join(tmpdir(), "mpo-update-spec-"));
|
|
128
74
|
previousCwd = process.cwd();
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
template: "none",
|
|
134
|
-
pieces: ["webext"],
|
|
135
|
-
version: currentRange,
|
|
75
|
+
process.chdir(sandbox);
|
|
76
|
+
await init("shop", {
|
|
77
|
+
apps: ["admin"],
|
|
78
|
+
services: [],
|
|
136
79
|
skipInstall: true,
|
|
137
80
|
skipGit: true,
|
|
138
81
|
yes: true,
|
|
139
82
|
});
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
83
|
+
project = join(sandbox, "shop");
|
|
84
|
+
await initRepo(project);
|
|
85
|
+
|
|
86
|
+
source = join(sandbox, "source");
|
|
87
|
+
const head = await gitOut(monorepo, ["rev-parse", "HEAD"]);
|
|
88
|
+
await spawn("git", ["clone", "--quiet", "--shared", "--no-checkout", monorepo, source]);
|
|
89
|
+
await spawn("git", ["checkout", "--quiet", head], { cwd: source });
|
|
90
|
+
await identity(source);
|
|
91
|
+
}, heavy);
|
|
143
92
|
|
|
144
93
|
afterEach(() => {
|
|
145
94
|
process.chdir(previousCwd);
|
|
146
95
|
});
|
|
147
96
|
|
|
148
97
|
afterAll(() => {
|
|
98
|
+
process.chdir(previousCwd);
|
|
149
99
|
rmSync(sandbox, { recursive: true, force: true });
|
|
150
100
|
});
|
|
151
101
|
|
|
152
|
-
it(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
"
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
expect(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
102
|
+
it(
|
|
103
|
+
"merges a One-app change into every product app, commits nothing, then goes quiet",
|
|
104
|
+
async () => {
|
|
105
|
+
// The monorepo moves: apps/one gains a file and a README line.
|
|
106
|
+
writeFileSync(join(source, "apps/one/hello.txt"), "hello from one\n");
|
|
107
|
+
writeFileSync(
|
|
108
|
+
join(source, "apps/one/README.md"),
|
|
109
|
+
`${readFileSync(join(source, "apps/one/README.md"), "utf-8")}\nAdded upstream.\n`,
|
|
110
|
+
);
|
|
111
|
+
const upstream = await commitAll(source, "feat: hello");
|
|
112
|
+
|
|
113
|
+
process.chdir(project);
|
|
114
|
+
const headBefore = await gitOut(project, ["rev-parse", "HEAD"]);
|
|
115
|
+
await updateApp({ source, skipInstall: true });
|
|
116
|
+
|
|
117
|
+
// Nothing committed: HEAD stands, the merge sits in index + worktree.
|
|
118
|
+
expect(await gitOut(project, ["rev-parse", "HEAD"])).toBe(headBefore);
|
|
119
|
+
expect(await gitOut(project, ["status", "--porcelain"])).not.toBe("");
|
|
120
|
+
const staged = await gitOut(project, ["diff", "--cached", "--name-only"]);
|
|
121
|
+
expect(staged).toContain("apps/shop/hello.txt");
|
|
122
|
+
expect(staged).toContain("apps/admin/hello.txt");
|
|
123
|
+
for (const app of ["shop", "admin"]) {
|
|
124
|
+
expect(readFileSync(join(project, `apps/${app}/hello.txt`), "utf-8")).toBe(
|
|
125
|
+
"hello from one\n",
|
|
126
|
+
);
|
|
127
|
+
expect(readFileSync(join(project, `apps/${app}/README.md`), "utf-8")).toContain(
|
|
128
|
+
"Added upstream.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
expect(existsSync(join(project, "public"))).toBe(false);
|
|
132
|
+
const provenance = readProvenance(project);
|
|
133
|
+
expect(provenance?.source?.sha).toBe(upstream);
|
|
134
|
+
expect(provenance?.apps).toEqual(["shop", "admin"]);
|
|
135
|
+
|
|
136
|
+
// Commit as instructed; an immediate re-run changes nothing.
|
|
137
|
+
const committed = await commitAll(project, "chore: mpo update");
|
|
138
|
+
await updateApp({ source, skipInstall: true });
|
|
139
|
+
expect(await gitOut(project, ["rev-parse", "HEAD"])).toBe(committed);
|
|
140
|
+
expect(await gitOut(project, ["status", "--porcelain"])).toBe("");
|
|
141
|
+
},
|
|
142
|
+
heavy,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
it("refuses a dirty tree before touching anything", async () => {
|
|
146
|
+
process.chdir(project);
|
|
147
|
+
const readme = join(project, "README.md");
|
|
148
|
+
const original = readFileSync(readme, "utf-8");
|
|
149
|
+
writeFileSync(readme, `${original}dirty\n`);
|
|
150
|
+
await expect(updateApp({ source, skipInstall: true })).rejects.toThrow(
|
|
151
|
+
/clean working tree \(unstaged changes present\)/,
|
|
189
152
|
);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
expect(
|
|
195
|
-
|
|
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"),
|
|
153
|
+
writeFileSync(readme, original);
|
|
154
|
+
await spawn("git", ["add", "-A"], { cwd: project });
|
|
155
|
+
writeFileSync(readme, `${original}staged\n`);
|
|
156
|
+
await spawn("git", ["add", "--", "README.md"], { cwd: project });
|
|
157
|
+
await expect(updateApp({ source, skipInstall: true })).rejects.toThrow(
|
|
158
|
+
/staged changes present/,
|
|
236
159
|
);
|
|
237
|
-
|
|
238
|
-
await spawn("git", ["
|
|
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("");
|
|
160
|
+
writeFileSync(readme, original);
|
|
161
|
+
await spawn("git", ["add", "--", "README.md"], { cwd: project });
|
|
162
|
+
expect(await gitOut(project, ["status", "--porcelain"])).toBe("");
|
|
245
163
|
});
|
|
246
164
|
|
|
247
|
-
it(
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
//
|
|
252
|
-
"apps/
|
|
253
|
-
|
|
254
|
-
"
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
165
|
+
it(
|
|
166
|
+
"a two-sided change shows conflict markers; .updateignore pins hold",
|
|
167
|
+
async () => {
|
|
168
|
+
process.chdir(project);
|
|
169
|
+
// The project rewrites the first README line of one app and pins the other.
|
|
170
|
+
replaceFirstLine(join(project, "apps/shop/README.md"), "# shop, customized");
|
|
171
|
+
writeFileSync(join(project, ".updateignore"), "apps/admin/README.md\n");
|
|
172
|
+
await commitAll(project, "feat: customize shop");
|
|
173
|
+
const pinned = readFileSync(join(project, "apps/admin/README.md"), "utf-8");
|
|
174
|
+
// The monorepo rewrites the same line.
|
|
175
|
+
replaceFirstLine(join(source, "apps/one/README.md"), "# one, changed upstream");
|
|
176
|
+
const upstream = await commitAll(source, "docs: retitle");
|
|
177
|
+
|
|
178
|
+
const headBefore = await gitOut(project, ["rev-parse", "HEAD"]);
|
|
179
|
+
await updateApp({ source, skipInstall: true });
|
|
180
|
+
expect(await gitOut(project, ["rev-parse", "HEAD"])).toBe(headBefore);
|
|
181
|
+
const conflicted = readFileSync(join(project, "apps/shop/README.md"), "utf-8");
|
|
182
|
+
expect(conflicted).toContain("<<<<<<<");
|
|
183
|
+
expect(conflicted).toContain("# shop, customized");
|
|
184
|
+
expect(conflicted).toContain("# one, changed upstream");
|
|
185
|
+
expect(conflicted).toContain(">>>>>>>");
|
|
186
|
+
// The pinned app took nothing: HEAD's bytes, no markers.
|
|
187
|
+
expect(readFileSync(join(project, "apps/admin/README.md"), "utf-8")).toBe(pinned);
|
|
188
|
+
expect(readProvenance(project)?.source?.sha).toBe(upstream);
|
|
189
|
+
|
|
190
|
+
// Resolve, commit, and the next run is a no-op.
|
|
191
|
+
replaceFirstLine(join(project, "apps/shop/README.md"), "# shop, resolved");
|
|
192
|
+
writeFileSync(
|
|
193
|
+
join(project, "apps/shop/README.md"),
|
|
194
|
+
readFileSync(join(project, "apps/shop/README.md"), "utf-8").replace(
|
|
195
|
+
/<<<<<<<[^\n]*\n[\s\S]*?>>>>>>>[^\n]*\n/,
|
|
196
|
+
"",
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
const committed = await commitAll(project, "chore: mpo update (resolved)");
|
|
200
|
+
await updateApp({ source, skipInstall: true });
|
|
201
|
+
expect(await gitOut(project, ["rev-parse", "HEAD"])).toBe(committed);
|
|
202
|
+
expect(await gitOut(project, ["status", "--porcelain"])).toBe("");
|
|
203
|
+
},
|
|
204
|
+
heavy,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
it(
|
|
208
|
+
"add-app lands one more product app at the recorded commit, uncommitted",
|
|
209
|
+
async () => {
|
|
210
|
+
process.chdir(project);
|
|
211
|
+
const headBefore = await gitOut(project, ["rev-parse", "HEAD"]);
|
|
212
|
+
const shopReadme = readFileSync(join(project, "apps/shop/README.md"), "utf-8");
|
|
213
|
+
await addApp("kiosk", { skipInstall: true });
|
|
214
|
+
|
|
215
|
+
expect(await gitOut(project, ["rev-parse", "HEAD"])).toBe(headBefore);
|
|
216
|
+
expect(await gitOut(project, ["status", "--porcelain"])).not.toBe("");
|
|
217
|
+
expect(readJson(join(project, "apps/kiosk/package.json")).name).toBe("@app/kiosk");
|
|
218
|
+
expect(existsSync(join(project, "features/kiosk/screen.tsx"))).toBe(true);
|
|
219
|
+
expect(readFileSync(join(project, "Makefile"), "utf-8")).toContain(
|
|
220
|
+
"kiosk/%: configure FORCE",
|
|
221
|
+
);
|
|
222
|
+
expect(readJson(join(project, "package.json")).scripts["dev:kiosk"]).toBe(
|
|
223
|
+
"pnpm --filter @app/kiosk dev",
|
|
224
|
+
);
|
|
225
|
+
// The project's own edits are not part of the diff.
|
|
226
|
+
expect(readFileSync(join(project, "apps/shop/README.md"), "utf-8")).toBe(shopReadme);
|
|
227
|
+
expect(readProvenance(project)?.apps).toEqual(["shop", "admin", "kiosk"]);
|
|
228
|
+
|
|
229
|
+
await commitAll(project, "feat: add app kiosk");
|
|
230
|
+
await expect(addApp("kiosk", { skipInstall: true })).rejects.toThrow(/already a product app/);
|
|
231
|
+
},
|
|
232
|
+
heavy,
|
|
233
|
+
);
|
|
234
|
+
});
|
|
258
235
|
|
|
259
|
-
|
|
260
|
-
|
|
236
|
+
describe("template-era project", () => {
|
|
237
|
+
let sandbox: string;
|
|
238
|
+
let previousCwd: string;
|
|
261
239
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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("");
|
|
240
|
+
beforeAll(() => {
|
|
241
|
+
sandbox = mkdtempSync(join(tmpdir(), "mpo-update-legacy-spec-"));
|
|
242
|
+
previousCwd = process.cwd();
|
|
277
243
|
});
|
|
278
244
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
"package.json": json({ name: "fake-ext", private: true }),
|
|
283
|
-
".updateignore": "apps/*.config.ts\n",
|
|
284
|
-
});
|
|
285
|
-
await initRepo(dir);
|
|
286
|
-
process.chdir(dir);
|
|
245
|
+
afterEach(() => {
|
|
246
|
+
process.chdir(previousCwd);
|
|
247
|
+
});
|
|
287
248
|
|
|
288
|
-
|
|
289
|
-
|
|
249
|
+
afterAll(() => {
|
|
250
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
290
251
|
});
|
|
291
252
|
|
|
292
|
-
it(
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
253
|
+
it(
|
|
254
|
+
"reconciles from an empty base and becomes clone-derived",
|
|
255
|
+
async () => {
|
|
256
|
+
const dir = join(sandbox, "legacy");
|
|
257
|
+
writeFiles(dir, {
|
|
258
|
+
"package.json": json({ name: "legacy", private: true, scripts: { custom: "echo custom" } }),
|
|
259
|
+
"README.md": "# legacy\n",
|
|
260
|
+
"apps/legacy/package.json": json({ name: "@app/legacy", private: true }),
|
|
261
|
+
".mpo.json": json({
|
|
262
|
+
template: "universal",
|
|
263
|
+
cliVersion: "7.7.1",
|
|
264
|
+
name: "legacy",
|
|
265
|
+
mpoVersion: "^7.7.1",
|
|
266
|
+
pieces: [],
|
|
267
|
+
}),
|
|
268
|
+
});
|
|
269
|
+
const head = await initRepo(dir);
|
|
270
|
+
process.chdir(dir);
|
|
271
|
+
|
|
272
|
+
await updateApp({ skipInstall: true });
|
|
273
|
+
expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(head);
|
|
274
|
+
// The One app landed under the project's name…
|
|
275
|
+
expect(existsSync(join(dir, "apps/legacy/app.config.js"))).toBe(true);
|
|
276
|
+
expect(existsSync(join(dir, "features/legacy/screen.tsx"))).toBe(true);
|
|
277
|
+
// …shared files that both sides wrote carry markers…
|
|
278
|
+
expect(readFileSync(join(dir, "README.md"), "utf-8")).toContain("<<<<<<<");
|
|
279
|
+
// …package.json resolved structurally (custom script kept, scaffold scripts added).
|
|
280
|
+
const pkg = readJson(join(dir, "package.json"));
|
|
281
|
+
expect(pkg.scripts.custom).toBe("echo custom");
|
|
282
|
+
expect(pkg.scripts.dev).toBe("pnpm --filter @app/legacy dev");
|
|
283
|
+
const provenance = readProvenance(dir);
|
|
284
|
+
expect(provenance?.template).toBeUndefined();
|
|
285
|
+
expect(provenance?.source?.sha).toBe(await gitOut(monorepo, ["rev-parse", "HEAD"]));
|
|
286
|
+
expect(provenance?.apps).toEqual(["legacy"]);
|
|
287
|
+
},
|
|
288
|
+
heavy,
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("project scaffolded while the scaffold mapped @multiplatform.one to Pulp (MPO-289)", () => {
|
|
293
|
+
let sandbox: string;
|
|
294
|
+
let previousCwd: string;
|
|
295
|
+
// The .pnpmfile.cjs that MPO-343 shipped, byte for byte.
|
|
296
|
+
const pulpPnpmfile = `// A project's .pnpmfile.cjs. @multiplatform.one/* is published to Pulp, not
|
|
297
|
+
// npmjs. The scope is mapped here because pnpm drops the whole .npmrc when a
|
|
298
|
+
// variable it names (NPM_AUTH_TOKEN) is unset, and pnpm-workspace.yaml cannot
|
|
299
|
+
// map a scope. A mapping an .npmrc does carry wins.
|
|
300
|
+
module.exports = {
|
|
301
|
+
hooks: {
|
|
302
|
+
updateConfig(config) {
|
|
303
|
+
config.registries["@multiplatform.one"] ??=
|
|
304
|
+
"https://artifacts.bitspur.com/pulp/content/multiplatform.one/";
|
|
305
|
+
return config;
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
};\n`;
|
|
309
|
+
|
|
310
|
+
beforeAll(() => {
|
|
311
|
+
sandbox = mkdtempSync(join(tmpdir(), "mpo-update-pnpmfile-spec-"));
|
|
312
|
+
previousCwd = process.cwd();
|
|
323
313
|
});
|
|
324
314
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
);
|
|
315
|
+
afterEach(() => {
|
|
316
|
+
process.chdir(previousCwd);
|
|
317
|
+
});
|
|
365
318
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(headBefore);
|
|
370
|
-
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
319
|
+
afterAll(() => {
|
|
320
|
+
process.chdir(previousCwd);
|
|
321
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
371
322
|
});
|
|
372
323
|
|
|
373
|
-
it(
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
324
|
+
it(
|
|
325
|
+
"loses .pnpmfile.cjs and its lockfile checksum on update to this checkout, both staged",
|
|
326
|
+
async () => {
|
|
327
|
+
const source = join(sandbox, "source");
|
|
328
|
+
const head = await gitOut(monorepo, ["rev-parse", "HEAD"]);
|
|
329
|
+
await spawn("git", ["clone", "--quiet", "--shared", "--no-checkout", monorepo, source]);
|
|
330
|
+
await spawn("git", ["checkout", "--quiet", head], { cwd: source });
|
|
331
|
+
await identity(source);
|
|
332
|
+
writeFileSync(join(source, "public/cli/templates/pnpmfile.cjs"), pulpPnpmfile);
|
|
333
|
+
await spawn("git", ["add", "-A"], { cwd: source });
|
|
334
|
+
await spawn("git", ["commit", "--quiet", "--allow-empty", "-m", "chore: the Pulp pnpmfile"], {
|
|
335
|
+
cwd: source,
|
|
336
|
+
});
|
|
337
|
+
const pulpEra = await gitOut(source, ["rev-parse", "HEAD"]);
|
|
338
|
+
|
|
339
|
+
process.chdir(sandbox);
|
|
340
|
+
await init("pulp", {
|
|
341
|
+
source,
|
|
342
|
+
checkout: pulpEra,
|
|
343
|
+
skipInstall: true,
|
|
344
|
+
skipGit: true,
|
|
345
|
+
yes: true,
|
|
346
|
+
});
|
|
347
|
+
const project = join(sandbox, "pulp");
|
|
348
|
+
await initRepo(project);
|
|
349
|
+
expect(readFileSync(join(project, ".pnpmfile.cjs"), "utf-8")).toBe(pulpPnpmfile);
|
|
350
|
+
expect(readFileSync(join(project, "pnpm-lock.yaml"), "utf-8")).toMatch(
|
|
351
|
+
/^pnpmfileChecksum: /m,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
// The monorepo moves on to exactly this checkout's tree.
|
|
355
|
+
const tree = await gitOut(source, ["rev-parse", `${head}^{tree}`]);
|
|
356
|
+
const next = await gitOut(source, ["commit-tree", tree, "-p", pulpEra, "-m", "chore: npmjs"]);
|
|
357
|
+
await spawn("git", ["checkout", "--quiet", next], { cwd: source });
|
|
358
|
+
process.chdir(project);
|
|
359
|
+
await updateApp({ source, skipInstall: true });
|
|
360
|
+
expect(existsSync(join(project, ".pnpmfile.cjs"))).toBe(false);
|
|
361
|
+
expect(readFileSync(join(project, "pnpm-lock.yaml"), "utf-8")).not.toMatch(
|
|
362
|
+
/^pnpmfileChecksum:/m,
|
|
363
|
+
);
|
|
364
|
+
const staged = (await gitOut(project, ["diff", "--cached", "--name-status"])).split("\n");
|
|
365
|
+
expect(staged).toContain("D\t.pnpmfile.cjs");
|
|
366
|
+
expect(staged).toContain("M\tpnpm-lock.yaml");
|
|
367
|
+
},
|
|
368
|
+
heavy,
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe("adopted project (pieces)", () => {
|
|
373
|
+
const currentVersion = cliVersion()!;
|
|
374
|
+
const currentRange = `^${currentVersion}`;
|
|
375
|
+
let sandbox: string;
|
|
376
|
+
let previousCwd: string;
|
|
377
|
+
let canonical: string;
|
|
378
|
+
|
|
379
|
+
/** The pieces-only tree an adopted webext project is reconciled against. */
|
|
380
|
+
beforeAll(() => {
|
|
381
|
+
sandbox = mkdtempSync(join(tmpdir(), "mpo-update-adopted-spec-"));
|
|
382
|
+
previousCwd = process.cwd();
|
|
383
|
+
canonical = join(sandbox, "canonical");
|
|
384
|
+
scaffoldPieces(canonical, { name: "fake-ext", mpoVersion: currentRange, pieces: ["webext"] });
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
afterEach(() => {
|
|
388
|
+
process.chdir(previousCwd);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
afterAll(() => {
|
|
392
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
392
393
|
});
|
|
394
|
+
|
|
395
|
+
it(
|
|
396
|
+
"first update reconciles from an empty base without committing; the next run is a no-op",
|
|
397
|
+
async () => {
|
|
398
|
+
const dir = join(sandbox, "adopted-clean", "fake-ext");
|
|
399
|
+
writeFiles(dir, {
|
|
400
|
+
"package.json": json({
|
|
401
|
+
name: "@fake/fake-ext",
|
|
402
|
+
private: true,
|
|
403
|
+
scripts: { custom: "echo custom" },
|
|
404
|
+
}),
|
|
405
|
+
"src/index.ts": "export const custom = true;\n",
|
|
406
|
+
"apps/fake-ext/webext/manifest.ts": readFileSync(
|
|
407
|
+
join(canonical, "apps/fake-ext/webext/manifest.ts"),
|
|
408
|
+
"utf-8",
|
|
409
|
+
),
|
|
410
|
+
});
|
|
411
|
+
await initRepo(dir);
|
|
412
|
+
process.chdir(dir);
|
|
413
|
+
|
|
414
|
+
await adoptApp({ pieces: ["webext"], yes: true });
|
|
415
|
+
expect(readProvenance(dir)?.reconciled).toBe(false);
|
|
416
|
+
const head = await gitOut(dir, ["rev-parse", "HEAD"]);
|
|
417
|
+
|
|
418
|
+
await updateApp({ skipInstall: true });
|
|
419
|
+
|
|
420
|
+
for (const file of [
|
|
421
|
+
"apps/fake-ext/vite.config.webext.ts",
|
|
422
|
+
"apps/fake-ext/vite.config.webext-background.ts",
|
|
423
|
+
"apps/fake-ext/webext/background/main.ts",
|
|
424
|
+
"apps/fake-ext/webext/views/popup/main.tsx",
|
|
425
|
+
"apps/fake-ext/package.json",
|
|
426
|
+
]) {
|
|
427
|
+
expect(existsSync(join(dir, file)), `missing ${file}`).toBe(true);
|
|
428
|
+
}
|
|
429
|
+
expect(readFileSync(join(dir, "apps/fake-ext/webext/manifest.ts"), "utf-8")).toBe(
|
|
430
|
+
readFileSync(join(canonical, "apps/fake-ext/webext/manifest.ts"), "utf-8"),
|
|
431
|
+
);
|
|
432
|
+
expect(readFileSync(join(dir, "src/index.ts"), "utf-8")).toBe(
|
|
433
|
+
"export const custom = true;\n",
|
|
434
|
+
);
|
|
435
|
+
const rootPkg = readJson(join(dir, "package.json"));
|
|
436
|
+
expect(rootPkg.scripts.custom).toBe("echo custom");
|
|
437
|
+
expect(rootPkg.scripts["build:webext"]).toBe("pnpm --filter @app/fake-ext build:webext");
|
|
438
|
+
|
|
439
|
+
// Landed, not committed; the flag is cleared for the commit the user makes.
|
|
440
|
+
expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(head);
|
|
441
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).not.toBe("");
|
|
442
|
+
expect(readProvenance(dir)?.reconciled).toBeUndefined();
|
|
443
|
+
expect(readProvenance(dir)?.cliVersion).toBe(currentVersion);
|
|
444
|
+
|
|
445
|
+
const committed = await commitAll(dir, "chore: mpo update");
|
|
446
|
+
await updateApp({ skipInstall: true });
|
|
447
|
+
expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(committed);
|
|
448
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
449
|
+
},
|
|
450
|
+
heavy,
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
it(
|
|
454
|
+
"divergent hand-copied files surface conflict markers, then updates go quiet",
|
|
455
|
+
async () => {
|
|
456
|
+
const dir = join(sandbox, "adopted-divergent", "fake-ext");
|
|
457
|
+
writeFiles(dir, {
|
|
458
|
+
"package.json": json({ name: "fake-ext", private: true }),
|
|
459
|
+
"apps/fake-ext/webext/manifest.ts": "// hand-rolled manifest, diverged\n",
|
|
460
|
+
});
|
|
461
|
+
await initRepo(dir);
|
|
462
|
+
process.chdir(dir);
|
|
463
|
+
|
|
464
|
+
await adoptApp({ pieces: ["webext"], yes: true });
|
|
465
|
+
await updateApp({ skipInstall: true });
|
|
466
|
+
|
|
467
|
+
const manifest = readFileSync(join(dir, "apps/fake-ext/webext/manifest.ts"), "utf-8");
|
|
468
|
+
expect(manifest).toContain("<<<<<<<");
|
|
469
|
+
expect(manifest).toContain(">>>>>>>");
|
|
470
|
+
expect(readProvenance(dir)?.reconciled).toBeUndefined();
|
|
471
|
+
|
|
472
|
+
writeFileSync(
|
|
473
|
+
join(dir, "apps/fake-ext/webext/manifest.ts"),
|
|
474
|
+
readFileSync(join(canonical, "apps/fake-ext/webext/manifest.ts"), "utf-8"),
|
|
475
|
+
);
|
|
476
|
+
const committed = await commitAll(dir, "chore: mpo update (resolved)");
|
|
477
|
+
await updateApp({ skipInstall: true });
|
|
478
|
+
expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(committed);
|
|
479
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
480
|
+
},
|
|
481
|
+
heavy,
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
it(
|
|
485
|
+
".updateignore: dir/** normalizes to a directory pin; HEAD wins including absence",
|
|
486
|
+
async () => {
|
|
487
|
+
const dir = join(sandbox, "adopted-pinned", "fake-ext");
|
|
488
|
+
writeFiles(dir, {
|
|
489
|
+
"package.json": json({ name: "fake-ext", private: true }),
|
|
490
|
+
"apps/fake-ext/webext/manifest.ts": "// bespoke manifest — never reconcile\n",
|
|
491
|
+
".updateignore": "# bespoke surface\napps/fake-ext/webext/**\n",
|
|
492
|
+
});
|
|
493
|
+
await initRepo(dir);
|
|
494
|
+
process.chdir(dir);
|
|
495
|
+
|
|
496
|
+
await adoptApp({ pieces: ["webext"], yes: true });
|
|
497
|
+
await updateApp({ skipInstall: true });
|
|
498
|
+
|
|
499
|
+
expect(readFileSync(join(dir, "apps/fake-ext/webext/manifest.ts"), "utf-8")).toBe(
|
|
500
|
+
"// bespoke manifest — never reconcile\n",
|
|
501
|
+
);
|
|
502
|
+
expect(existsSync(join(dir, "apps/fake-ext/webext/views/popup/main.tsx"))).toBe(false);
|
|
503
|
+
expect(existsSync(join(dir, "apps/fake-ext/vite.config.webext.ts"))).toBe(true);
|
|
504
|
+
|
|
505
|
+
const committed = await commitAll(dir, "chore: mpo update");
|
|
506
|
+
await updateApp({ skipInstall: true });
|
|
507
|
+
expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(committed);
|
|
508
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
509
|
+
},
|
|
510
|
+
heavy,
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
it(
|
|
514
|
+
".updateignore: a genuinely glob-shaped entry is refused loudly",
|
|
515
|
+
async () => {
|
|
516
|
+
const dir = join(sandbox, "adopted-badglob", "fake-ext");
|
|
517
|
+
writeFiles(dir, {
|
|
518
|
+
"package.json": json({ name: "fake-ext", private: true }),
|
|
519
|
+
".updateignore": "apps/*.config.ts\n",
|
|
520
|
+
});
|
|
521
|
+
await initRepo(dir);
|
|
522
|
+
process.chdir(dir);
|
|
523
|
+
|
|
524
|
+
await adoptApp({ pieces: ["webext"], yes: true });
|
|
525
|
+
await expect(updateApp({ skipInstall: true })).rejects.toThrow(/looks like a glob/);
|
|
526
|
+
},
|
|
527
|
+
heavy,
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
it(
|
|
531
|
+
"a Frappe client adoption updates cleanly and never gains a bench (MPO-241)",
|
|
532
|
+
async () => {
|
|
533
|
+
const dir = join(sandbox, "adopted-frappe-client", "shop");
|
|
534
|
+
writeFiles(dir, { "package.json": json({ name: "shop", private: true }) });
|
|
535
|
+
await initRepo(dir);
|
|
536
|
+
process.chdir(dir);
|
|
537
|
+
|
|
538
|
+
await adoptApp({ pieces: ["frappe"], yes: true });
|
|
539
|
+
await updateApp({ skipInstall: true });
|
|
540
|
+
expect(readFileSync(join(dir, "apps/shop/src/app.ts"), "utf-8")).toContain("FRAPPE_URL");
|
|
541
|
+
expect(existsSync(join(dir, "docker"))).toBe(false);
|
|
542
|
+
|
|
543
|
+
const committed = await commitAll(dir, "chore: mpo update");
|
|
544
|
+
await updateApp({ skipInstall: true });
|
|
545
|
+
expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(committed);
|
|
546
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
547
|
+
expect(readProvenance(dir)?.pieces).toEqual(["frappe"]);
|
|
548
|
+
},
|
|
549
|
+
heavy,
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
it(
|
|
553
|
+
"an adoption recorded when frappe carried the bench keeps the bench (MPO-241)",
|
|
554
|
+
async () => {
|
|
555
|
+
// v0.0.1 of a stand-in monorepo, whose frappe piece still bundles the bench.
|
|
556
|
+
const oldSource = join(sandbox, "pre-split-source");
|
|
557
|
+
const oldPieces = join(oldSource, "public/cli/templates/pieces");
|
|
558
|
+
cpSync(join(piecesRoot(), "frappe"), join(oldPieces, "frappe"), { recursive: true });
|
|
559
|
+
cpSync(
|
|
560
|
+
join(piecesRoot(), "frappe-bench/universal/docker"),
|
|
561
|
+
join(oldPieces, "frappe/universal/docker"),
|
|
562
|
+
{ recursive: true },
|
|
563
|
+
);
|
|
564
|
+
await initRepo(oldSource);
|
|
565
|
+
await spawn("git", ["tag", "v0.0.1"], { cwd: oldSource });
|
|
566
|
+
|
|
567
|
+
const dir = join(sandbox, "adopted-pre-split", "shop");
|
|
568
|
+
scaffoldPieces(dir, {
|
|
569
|
+
name: "shop",
|
|
570
|
+
mpoVersion: "^0.0.1",
|
|
571
|
+
pieces: ["frappe"],
|
|
572
|
+
root: oldPieces,
|
|
573
|
+
});
|
|
574
|
+
writeFiles(dir, {
|
|
575
|
+
"package.json": json({ name: "shop", private: true }),
|
|
576
|
+
".mpo.json": json({
|
|
577
|
+
template: "none",
|
|
578
|
+
cliVersion: "0.0.1",
|
|
579
|
+
name: "shop",
|
|
580
|
+
mpoVersion: "^0.0.1",
|
|
581
|
+
apps: [],
|
|
582
|
+
services: [],
|
|
583
|
+
pieces: ["frappe"],
|
|
584
|
+
}),
|
|
585
|
+
});
|
|
586
|
+
const compose = readFileSync(join(dir, "docker/compose.frappe.yaml"), "utf-8");
|
|
587
|
+
await initRepo(dir);
|
|
588
|
+
process.chdir(dir);
|
|
589
|
+
|
|
590
|
+
await updateApp({ source: oldSource, skipInstall: true });
|
|
591
|
+
expect(readFileSync(join(dir, "docker/compose.frappe.yaml"), "utf-8")).toBe(compose);
|
|
592
|
+
expect(readFileSync(join(dir, "README.md"), "utf-8")).toContain("bench start");
|
|
593
|
+
expect(readProvenance(dir)?.pieces).toEqual(["frappe", "frappe-bench"]);
|
|
594
|
+
|
|
595
|
+
const committed = await commitAll(dir, "chore: mpo update");
|
|
596
|
+
await updateApp({ skipInstall: true });
|
|
597
|
+
expect(await gitOut(dir, ["rev-parse", "HEAD"])).toBe(committed);
|
|
598
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
599
|
+
},
|
|
600
|
+
heavy,
|
|
601
|
+
);
|
|
393
602
|
});
|