@multiplatform.one/cli 6.7.0 → 7.1.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
|
@@ -0,0 +1,208 @@
|
|
|
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, readProvenance, writeProvenance } from "./initApp";
|
|
8
|
+
|
|
9
|
+
const readJson = (path: string) => JSON.parse(readFileSync(path, "utf-8"));
|
|
10
|
+
|
|
11
|
+
async function gitOut(cwd: string, args: string[]): Promise<string> {
|
|
12
|
+
const result = await spawn("git", args, { cwd });
|
|
13
|
+
return result.stdout.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** git repo fixture with an identity, the given files, and one commit. */
|
|
17
|
+
async function makeRepo(dir: string, files: Record<string, string>): Promise<void> {
|
|
18
|
+
mkdirSync(dir, { recursive: true });
|
|
19
|
+
for (const [file, content] of Object.entries(files)) {
|
|
20
|
+
mkdirSync(dirname(join(dir, file)), { recursive: true });
|
|
21
|
+
writeFileSync(join(dir, file), content);
|
|
22
|
+
}
|
|
23
|
+
await spawn("git", ["init", "--quiet"], { cwd: dir });
|
|
24
|
+
await spawn("git", ["config", "user.email", "spec@example.com"], { cwd: dir });
|
|
25
|
+
await spawn("git", ["config", "user.name", "Spec"], { cwd: dir });
|
|
26
|
+
await spawn("git", ["add", "-A"], { cwd: dir });
|
|
27
|
+
await spawn("git", ["commit", "--quiet", "-m", "chore: fixture"], { cwd: dir });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("adoptApp", () => {
|
|
31
|
+
let sandbox: string;
|
|
32
|
+
let previousCwd: string;
|
|
33
|
+
let fixtureCount = 0;
|
|
34
|
+
|
|
35
|
+
const fixtureDir = () => join(sandbox, `fixture-${fixtureCount++}`);
|
|
36
|
+
|
|
37
|
+
beforeAll(() => {
|
|
38
|
+
sandbox = mkdtempSync(join(tmpdir(), "mpo-adopt-spec-"));
|
|
39
|
+
previousCwd = process.cwd();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
process.chdir(previousCwd);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(() => {
|
|
47
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("writes adopted provenance, seeds .updateignore, and commits both", async () => {
|
|
51
|
+
const dir = fixtureDir();
|
|
52
|
+
await makeRepo(dir, {
|
|
53
|
+
"package.json": JSON.stringify({ name: "@easel/repo", private: true }),
|
|
54
|
+
"apps/repo/webext/manifest.ts": "// hand-copied\n",
|
|
55
|
+
});
|
|
56
|
+
process.chdir(dir);
|
|
57
|
+
// Intentionally unsorted — provenance must sort.
|
|
58
|
+
await adoptApp({ pieces: ["webext", "gnome"], yes: true });
|
|
59
|
+
|
|
60
|
+
const provenance = readJson(join(dir, ".mpo.json"));
|
|
61
|
+
expect(provenance).toEqual({
|
|
62
|
+
template: "none",
|
|
63
|
+
cliVersion: cliVersion(),
|
|
64
|
+
name: "repo", // scope stripped from package.json name
|
|
65
|
+
mpoVersion: `^${cliVersion()}`,
|
|
66
|
+
pieces: ["gnome", "webext"],
|
|
67
|
+
reconciled: false,
|
|
68
|
+
});
|
|
69
|
+
expect(readProvenance(dir)?.reconciled).toBe(false);
|
|
70
|
+
|
|
71
|
+
const updateignore = readFileSync(join(dir, ".updateignore"), "utf-8");
|
|
72
|
+
expect(updateignore).toMatch(/^#/u);
|
|
73
|
+
expect(updateignore).toContain("mpo update");
|
|
74
|
+
|
|
75
|
+
// Adoption changed NO project files and left the tree clean (committed).
|
|
76
|
+
expect(readFileSync(join(dir, "apps/repo/webext/manifest.ts"), "utf-8")).toBe(
|
|
77
|
+
"// hand-copied\n",
|
|
78
|
+
);
|
|
79
|
+
expect(await gitOut(dir, ["status", "--porcelain"])).toBe("");
|
|
80
|
+
expect(await gitOut(dir, ["log", "-1", "--format=%s"])).toContain("mpo adopt");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("honors --name and preserves an existing .updateignore", async () => {
|
|
84
|
+
const dir = fixtureDir();
|
|
85
|
+
await makeRepo(dir, {
|
|
86
|
+
"package.json": JSON.stringify({ name: "@easel/repo" }),
|
|
87
|
+
".updateignore": "docs/\n",
|
|
88
|
+
});
|
|
89
|
+
process.chdir(dir);
|
|
90
|
+
await adoptApp({ pieces: ["webext"], name: "easel", yes: true });
|
|
91
|
+
expect(readProvenance(dir)?.name).toBe("easel");
|
|
92
|
+
expect(readFileSync(join(dir, ".updateignore"), "utf-8")).toBe("docs/\n");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("refuses outside a git repository", async () => {
|
|
96
|
+
const dir = join(sandbox, "not-a-repo");
|
|
97
|
+
mkdirSync(dir, { recursive: true });
|
|
98
|
+
process.chdir(dir);
|
|
99
|
+
await expect(adoptApp({ pieces: ["webext"], yes: true })).rejects.toThrow(
|
|
100
|
+
/inside a git repository/u,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("refuses a repo without commits", async () => {
|
|
105
|
+
const dir = fixtureDir();
|
|
106
|
+
mkdirSync(dir, { recursive: true });
|
|
107
|
+
await spawn("git", ["init", "--quiet"], { cwd: dir });
|
|
108
|
+
process.chdir(dir);
|
|
109
|
+
await expect(adoptApp({ pieces: ["webext"], yes: true })).rejects.toThrow(
|
|
110
|
+
/at least one commit/u,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("refuses a dirty working tree (unstaged and staged)", async () => {
|
|
115
|
+
const dir = fixtureDir();
|
|
116
|
+
await makeRepo(dir, { "package.json": JSON.stringify({ name: "dirty-demo" }) });
|
|
117
|
+
process.chdir(dir);
|
|
118
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "dirty-demo", changed: true }));
|
|
119
|
+
await expect(adoptApp({ pieces: ["webext"], yes: true })).rejects.toThrow(
|
|
120
|
+
/unstaged changes present/u,
|
|
121
|
+
);
|
|
122
|
+
await spawn("git", ["add", "-A"], { cwd: dir });
|
|
123
|
+
await expect(adoptApp({ pieces: ["webext"], yes: true })).rejects.toThrow(
|
|
124
|
+
/staged changes present/u,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("refuses to adopt nothing", async () => {
|
|
129
|
+
const dir = fixtureDir();
|
|
130
|
+
await makeRepo(dir, { "package.json": JSON.stringify({ name: "no-pieces" }) });
|
|
131
|
+
process.chdir(dir);
|
|
132
|
+
await expect(adoptApp({ yes: true })).rejects.toThrow(/at least one piece/u);
|
|
133
|
+
await expect(adoptApp({ pieces: [], yes: true })).rejects.toThrow(/at least one piece/u);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects unknown pieces", async () => {
|
|
137
|
+
const dir = fixtureDir();
|
|
138
|
+
await makeRepo(dir, { "package.json": JSON.stringify({ name: "bogus" }) });
|
|
139
|
+
process.chdir(dir);
|
|
140
|
+
await expect(adoptApp({ pieces: ["solana" as never], yes: true })).rejects.toThrow(
|
|
141
|
+
/Unknown piece "solana"/u,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("refuses when provenance already exists", async () => {
|
|
146
|
+
const dir = fixtureDir();
|
|
147
|
+
await makeRepo(dir, {
|
|
148
|
+
"package.json": JSON.stringify({ name: "scaffolded" }),
|
|
149
|
+
".mpo.json": JSON.stringify({
|
|
150
|
+
template: "universal",
|
|
151
|
+
cliVersion: "6.5.0",
|
|
152
|
+
name: "scaffolded",
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
process.chdir(dir);
|
|
156
|
+
await expect(adoptApp({ pieces: ["webext"], yes: true })).rejects.toThrow(
|
|
157
|
+
/already has scaffold provenance/u,
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("refuses inside the multiplatform.one monorepo (or a fork)", async () => {
|
|
162
|
+
const dir = fixtureDir();
|
|
163
|
+
await makeRepo(dir, {
|
|
164
|
+
"package.json": JSON.stringify({ name: "root" }),
|
|
165
|
+
"public/cli/package.json": JSON.stringify({ name: "@multiplatform.one/cli" }),
|
|
166
|
+
});
|
|
167
|
+
process.chdir(dir);
|
|
168
|
+
await expect(adoptApp({ pieces: ["webext"], yes: true })).rejects.toThrow(
|
|
169
|
+
/cannot run inside the multiplatform\.one monorepo/u,
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("requires a usable project name", async () => {
|
|
174
|
+
const dir = fixtureDir();
|
|
175
|
+
await makeRepo(dir, { "package.json": JSON.stringify({ name: "Bad_Name" }) });
|
|
176
|
+
process.chdir(dir);
|
|
177
|
+
await expect(adoptApp({ pieces: ["webext"], yes: true })).rejects.toThrow(/--name/u);
|
|
178
|
+
const noPkg = fixtureDir();
|
|
179
|
+
await makeRepo(noPkg, { "README.md": "no package.json here\n" });
|
|
180
|
+
process.chdir(noPkg);
|
|
181
|
+
await expect(adoptApp({ pieces: ["webext"], yes: true })).rejects.toThrow(/--name/u);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("round-trips the reconciled flag through provenance", () => {
|
|
185
|
+
const dir = fixtureDir();
|
|
186
|
+
mkdirSync(dir, { recursive: true });
|
|
187
|
+
writeProvenance(dir, {
|
|
188
|
+
template: "none",
|
|
189
|
+
cliVersion: "6.6.0",
|
|
190
|
+
name: "flagged",
|
|
191
|
+
mpoVersion: "^6.6.0",
|
|
192
|
+
pieces: ["webext"],
|
|
193
|
+
reconciled: false,
|
|
194
|
+
});
|
|
195
|
+
expect(readJson(join(dir, ".mpo.json")).reconciled).toBe(false);
|
|
196
|
+
expect(readProvenance(dir)?.reconciled).toBe(false);
|
|
197
|
+
// Cleared flag serializes to NO field (scaffolded provenance stays lean).
|
|
198
|
+
writeProvenance(dir, {
|
|
199
|
+
template: "none",
|
|
200
|
+
cliVersion: "6.6.0",
|
|
201
|
+
name: "flagged",
|
|
202
|
+
mpoVersion: "^6.6.0",
|
|
203
|
+
pieces: ["webext"],
|
|
204
|
+
});
|
|
205
|
+
expect("reconciled" in readJson(join(dir, ".mpo.json"))).toBe(false);
|
|
206
|
+
expect(readProvenance(dir)?.reconciled).toBeUndefined();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mpo adopt` — bring an EXISTING, never-scaffolded project under `mpo
|
|
3
|
+
* update` management. Projects like this hand-copied one or more delivery
|
|
4
|
+
* targets (webext, gnome, …) from the mpo templates and consume
|
|
5
|
+
* @multiplatform.one/* from npm, but have no .mpo.json provenance, so
|
|
6
|
+
* `mpo update` refuses to run.
|
|
7
|
+
*
|
|
8
|
+
* Adoption writes provenance ONLY (template "none" + the chosen pieces +
|
|
9
|
+
* the current CLI version) and seeds .updateignore — it never rewrites a
|
|
10
|
+
* project file. The FIRST `mpo update` afterwards reconciles the piece
|
|
11
|
+
* files against the piece templates from an empty merge base, so real
|
|
12
|
+
* diffs (and conflicts) against hand-copied files are expected there;
|
|
13
|
+
* every later update is an ordinary three-way merge.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import inquirer from "inquirer";
|
|
19
|
+
import spawn from "nano-spawn";
|
|
20
|
+
import {
|
|
21
|
+
INIT_APP_PIECES,
|
|
22
|
+
PROVENANCE_FILE,
|
|
23
|
+
availablePieces,
|
|
24
|
+
cliVersion,
|
|
25
|
+
normalizePieces,
|
|
26
|
+
packageJsonName,
|
|
27
|
+
pieceDescription,
|
|
28
|
+
validateName,
|
|
29
|
+
writeProvenance,
|
|
30
|
+
type InitAppPiece,
|
|
31
|
+
} from "./initApp";
|
|
32
|
+
|
|
33
|
+
export interface AdoptAppOptions {
|
|
34
|
+
/** Pieces the project hand-adopted (at least one is required). */
|
|
35
|
+
pieces?: InitAppPiece[];
|
|
36
|
+
/** Project name (default: package.json name, scope stripped). */
|
|
37
|
+
name?: string;
|
|
38
|
+
/** Non-interactive: never prompt (pieces must come from flags). */
|
|
39
|
+
yes?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const UPDATEIGNORE_FILE = ".updateignore";
|
|
43
|
+
const UPDATEIGNORE_SEED = `# .updateignore — \`mpo update\` never rewrites paths listed here.
|
|
44
|
+
# One git pathspec per line; matching paths stay pinned to YOUR version
|
|
45
|
+
# during template updates. Lines starting with "#" are comments.
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
async function git(projectDir: string, args: string[]) {
|
|
49
|
+
return spawn("git", args, { cwd: projectDir });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The multiplatform.one monorepo (and its forks) is the home of the
|
|
54
|
+
* templates themselves — adopting it into consumer-project update
|
|
55
|
+
* management would be circular. It is identified by the CLI package
|
|
56
|
+
* living at public/cli.
|
|
57
|
+
*/
|
|
58
|
+
function isMpoMonorepo(projectDir: string): boolean {
|
|
59
|
+
try {
|
|
60
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, "public/cli/package.json"), "utf-8")) as {
|
|
61
|
+
name?: string;
|
|
62
|
+
};
|
|
63
|
+
return pkg.name === "@multiplatform.one/cli";
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function resolveAdoptPieces(options: AdoptAppOptions): Promise<InitAppPiece[]> {
|
|
70
|
+
if (options.pieces?.length) return normalizePieces(options.pieces, "none");
|
|
71
|
+
if (!options.yes && process.stdout.isTTY) {
|
|
72
|
+
const result = await inquirer.prompt([
|
|
73
|
+
{
|
|
74
|
+
message: "Which pieces did this project adopt? (space to toggle, enter to confirm)",
|
|
75
|
+
name: "pieces",
|
|
76
|
+
type: "checkbox",
|
|
77
|
+
choices: availablePieces("none").map((piece) => ({
|
|
78
|
+
name: `${piece} — ${pieceDescription(piece)}`,
|
|
79
|
+
value: piece,
|
|
80
|
+
checked: false,
|
|
81
|
+
})),
|
|
82
|
+
validate: (selection: unknown[]) =>
|
|
83
|
+
selection.length > 0 || "Select at least one piece — adopting nothing is meaningless",
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
return normalizePieces(result.pieces as string[], "none");
|
|
87
|
+
}
|
|
88
|
+
throw new Error(
|
|
89
|
+
"mpo adopt needs at least one piece " +
|
|
90
|
+
`(${INIT_APP_PIECES.map((piece) => `--${piece}`).join(", ")}) — ` +
|
|
91
|
+
"adopting nothing is meaningless",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function adoptApp(options: AdoptAppOptions = {}): Promise<void> {
|
|
96
|
+
const projectDir = await spawn("git", ["rev-parse", "--show-toplevel"], {
|
|
97
|
+
cwd: process.cwd(),
|
|
98
|
+
}).then(
|
|
99
|
+
(result) => result.stdout.trim(),
|
|
100
|
+
() => {
|
|
101
|
+
throw new Error("mpo adopt must run inside a git repository");
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (isMpoMonorepo(projectDir)) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
"mpo adopt cannot run inside the multiplatform.one monorepo (or a fork of it) — " +
|
|
108
|
+
"it is for consumer projects that use @multiplatform.one/* from npm",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (existsSync(join(projectDir, PROVENANCE_FILE))) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`${PROVENANCE_FILE} already exists — this project already has scaffold provenance. ` +
|
|
114
|
+
"Run `mpo update` directly.",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
await git(projectDir, ["rev-parse", "HEAD"]).catch(() => {
|
|
118
|
+
throw new Error("mpo adopt requires at least one commit — commit your project first");
|
|
119
|
+
});
|
|
120
|
+
// Clean tree required — adoption lands as its own commit, and the next
|
|
121
|
+
// step (`mpo update`) needs a clean tree anyway.
|
|
122
|
+
await git(projectDir, ["diff", "--quiet"]).catch(() => {
|
|
123
|
+
throw new Error("mpo adopt requires a clean working tree (unstaged changes present)");
|
|
124
|
+
});
|
|
125
|
+
await git(projectDir, ["diff", "--cached", "--quiet"]).catch(() => {
|
|
126
|
+
throw new Error("mpo adopt requires a clean working tree (staged changes present)");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const rawName = options.name ?? packageJsonName(projectDir);
|
|
130
|
+
if (!rawName) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
"Could not derive a project name from package.json — pass one with --name <name>",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
let name: string;
|
|
136
|
+
try {
|
|
137
|
+
name = validateName(rawName);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`${(err as Error).message} The name renders piece file paths (apps/<name>/…), ` +
|
|
141
|
+
"so pick the one matching your app directory: --name <name>.",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const pieces = await resolveAdoptPieces(options);
|
|
146
|
+
const currentVersion = cliVersion() ?? "0.0.0";
|
|
147
|
+
|
|
148
|
+
writeProvenance(projectDir, {
|
|
149
|
+
template: "none",
|
|
150
|
+
cliVersion: currentVersion,
|
|
151
|
+
name,
|
|
152
|
+
mpoVersion: `^${currentVersion}`,
|
|
153
|
+
pieces,
|
|
154
|
+
reconciled: false,
|
|
155
|
+
});
|
|
156
|
+
const files = [PROVENANCE_FILE];
|
|
157
|
+
if (!existsSync(join(projectDir, UPDATEIGNORE_FILE))) {
|
|
158
|
+
writeFileSync(join(projectDir, UPDATEIGNORE_FILE), UPDATEIGNORE_SEED);
|
|
159
|
+
files.push(UPDATEIGNORE_FILE);
|
|
160
|
+
}
|
|
161
|
+
await git(projectDir, ["add", "--", ...files]);
|
|
162
|
+
const committed = await git(projectDir, [
|
|
163
|
+
"commit",
|
|
164
|
+
"--quiet",
|
|
165
|
+
"-m",
|
|
166
|
+
"chore: adopt multiplatform.one tooling (mpo adopt)",
|
|
167
|
+
]).then(
|
|
168
|
+
() => true,
|
|
169
|
+
() => {
|
|
170
|
+
console.warn("⚠️ git commit failed (missing git identity?) — provenance left staged.");
|
|
171
|
+
return false;
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
console.log(`\n✅ adopted ${name} (pieces: ${pieces.join(", ")}, cli ${currentVersion})`);
|
|
176
|
+
console.log(` ${files.join(" + ")} ${committed ? "committed" : "written"} — NO project files`);
|
|
177
|
+
console.log(" were changed by adoption itself.\n");
|
|
178
|
+
console.log("Next: run `mpo update` to reconcile the piece files.");
|
|
179
|
+
console.log(" THE FIRST UPDATE SURFACES REAL DIFFS: your hand-copied piece files");
|
|
180
|
+
console.log(" are merged against the piece templates from scratch, so conflicts");
|
|
181
|
+
console.log(" there are expected — resolve the markers and commit. Later updates");
|
|
182
|
+
console.log(" are ordinary three-way merges, and re-running `mpo update` right");
|
|
183
|
+
console.log(" after a successful update makes zero changes.");
|
|
184
|
+
console.log(`Pin anything the updater must never touch in ${UPDATEIGNORE_FILE}.`);
|
|
185
|
+
}
|
|
@@ -203,6 +203,77 @@ describe("initApp generator", () => {
|
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
describe("pieces-only template (adopted-project baselines)", () => {
|
|
207
|
+
const name = "adopt-base";
|
|
208
|
+
let root: string;
|
|
209
|
+
|
|
210
|
+
beforeAll(async () => {
|
|
211
|
+
await initApp(name, {
|
|
212
|
+
template: "none",
|
|
213
|
+
skipInstall: true,
|
|
214
|
+
skipGit: true,
|
|
215
|
+
version: "6.6.0",
|
|
216
|
+
yes: true,
|
|
217
|
+
pieces: ["webext"],
|
|
218
|
+
});
|
|
219
|
+
root = join(sandbox, name);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("overlays ONLY the piece fragments (no base template)", () => {
|
|
223
|
+
for (const file of [
|
|
224
|
+
`apps/${name}/vite.config.webext.ts`,
|
|
225
|
+
`apps/${name}/webext/manifest.ts`,
|
|
226
|
+
`apps/${name}/webext/background/main.ts`,
|
|
227
|
+
`apps/${name}/package.json`,
|
|
228
|
+
".gitignore",
|
|
229
|
+
"README.md",
|
|
230
|
+
"package.json",
|
|
231
|
+
]) {
|
|
232
|
+
expect(existsSync(join(root, file)), `missing ${file}`).toBe(true);
|
|
233
|
+
}
|
|
234
|
+
// Base-template content must NOT leak in.
|
|
235
|
+
for (const file of [
|
|
236
|
+
"pnpm-workspace.yaml",
|
|
237
|
+
"tsconfig.base.json",
|
|
238
|
+
`features/${name}`,
|
|
239
|
+
"packages/config",
|
|
240
|
+
`apps/${name}/routes`,
|
|
241
|
+
`apps/${name}/vite.config.ts`,
|
|
242
|
+
]) {
|
|
243
|
+
expect(existsSync(join(root, file)), `unexpected ${file}`).toBe(false);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("creates partial targets from the fragments alone", () => {
|
|
248
|
+
// Root package.json = the webext fragment only — no template fields.
|
|
249
|
+
const rootPkg = readJson(join(root, "package.json"));
|
|
250
|
+
expect(Object.keys(rootPkg)).toEqual(["scripts"]);
|
|
251
|
+
expect(rootPkg.scripts["build:webext"]).toBe(`pnpm --filter @app/${name} build:webext`);
|
|
252
|
+
const manifest = readFileSync(join(root, `apps/${name}/webext/manifest.ts`), "utf-8");
|
|
253
|
+
expect(manifest).toContain('const DISPLAY_NAME = "AdoptBase"');
|
|
254
|
+
expect(manifest).not.toMatch(/__NAME(_PASCAL|_APPID)?__/u);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("records template none in provenance", () => {
|
|
258
|
+
const provenance = readJson(join(root, ".mpo.json"));
|
|
259
|
+
expect(provenance.template).toBe("none");
|
|
260
|
+
expect(provenance.pieces).toEqual(["webext"]);
|
|
261
|
+
expect("reconciled" in provenance).toBe(false);
|
|
262
|
+
expect(readProvenance(root)?.template).toBe("none");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("refuses a pieces-only scaffold without pieces", async () => {
|
|
266
|
+
await expect(
|
|
267
|
+
initApp("no-pieces-base", {
|
|
268
|
+
template: "none",
|
|
269
|
+
skipInstall: true,
|
|
270
|
+
skipGit: true,
|
|
271
|
+
yes: true,
|
|
272
|
+
}),
|
|
273
|
+
).rejects.toThrow(/at least one piece/u);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
206
277
|
it("rejects invalid names", async () => {
|
|
207
278
|
await expect(
|
|
208
279
|
initApp("Bad Name", { template: "app", skipInstall: true, yes: true }),
|
|
@@ -360,6 +431,26 @@ describe("initApp generator", () => {
|
|
|
360
431
|
expect(readme).toContain("## Keycloak auth");
|
|
361
432
|
});
|
|
362
433
|
|
|
434
|
+
// Two measured first-boot failures, both cheap to reintroduce. The
|
|
435
|
+
// keycloak image ships /opt/keycloak/data (keycloak-owned) and no
|
|
436
|
+
// h2/ subdirectory: a named volume mounted at data/h2 materializes
|
|
437
|
+
// root-owned, and Keycloak (uid 1000) dies with H2 90028 on a FRESH
|
|
438
|
+
// volume — it reads like corruption and survives `down -v`, because
|
|
439
|
+
// the wipe recreates the same root-owned mount point. And a
|
|
440
|
+
// hardcoded 8080 collides with whatever already owns the most
|
|
441
|
+
// contested dev port; a yaml !override in a second compose file is
|
|
442
|
+
// not a safe alternative (compose versions without the tag drop the
|
|
443
|
+
// whole ports list silently — container healthy, unreachable).
|
|
444
|
+
it("keeps the dev keycloak bootable: data/ volume, parameterized port", () => {
|
|
445
|
+
const compose = readFileSync(join(root, "docker/compose.keycloak.yaml"), "utf-8");
|
|
446
|
+
expect(compose).toContain('"${KEYCLOAK_HTTP_PORT:-8080}:8080"');
|
|
447
|
+
expect(
|
|
448
|
+
compose,
|
|
449
|
+
"volume mounts data/, which the image ships with keycloak ownership",
|
|
450
|
+
).toMatch(/^\s*- keycloak:\/opt\/keycloak\/data$/mu);
|
|
451
|
+
expect(compose).not.toMatch(/^\s*- keycloak:\/opt\/keycloak\/data\/h2$/mu);
|
|
452
|
+
});
|
|
453
|
+
|
|
363
454
|
it("records pieces sorted in provenance", () => {
|
|
364
455
|
expect(readJson(join(root, ".mpo.json")).pieces).toEqual(["frappe", "keycloak"]);
|
|
365
456
|
});
|
|
@@ -386,18 +477,14 @@ describe("initApp generator", () => {
|
|
|
386
477
|
`apps/${name}/vite.config.gnome.ts`,
|
|
387
478
|
`apps/${name}/gnome/main.tsx`,
|
|
388
479
|
`apps/${name}/gnome/polyfills.ts`,
|
|
480
|
+
`apps/${name}/gnome/anchor.tsx`,
|
|
389
481
|
`apps/${name}/gnome/tamagui-barrel.ts`,
|
|
390
482
|
`apps/${name}/gnome/shims/components.ts`,
|
|
391
483
|
`apps/${name}/gnome/shims/theme.ts`,
|
|
392
484
|
`apps/${name}/gnome/shims/forms.ts`,
|
|
485
|
+
`apps/${name}/gnome/shims/frappe-ui.ts`,
|
|
393
486
|
`apps/${name}/gnome/shims/one.ts`,
|
|
394
487
|
`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
488
|
`apps/${name}/vite.config.vscode.ts`,
|
|
402
489
|
`apps/${name}/vscode/extension/index.ts`,
|
|
403
490
|
`apps/${name}/vscode/webview/main.tsx`,
|
|
@@ -406,27 +493,43 @@ describe("initApp generator", () => {
|
|
|
406
493
|
`apps/${name}/vscode/manifest.ts`,
|
|
407
494
|
`apps/${name}/vite.config.webext.ts`,
|
|
408
495
|
`apps/${name}/webext/manifest.ts`,
|
|
496
|
+
`apps/${name}/src-tauri/tauri.conf.json`,
|
|
497
|
+
`apps/${name}/src-tauri/Cargo.toml`,
|
|
498
|
+
`apps/${name}/src-tauri/build.rs`,
|
|
499
|
+
`apps/${name}/src-tauri/src/main.rs`,
|
|
500
|
+
`apps/${name}/src-tauri/src/lib.rs`,
|
|
501
|
+
`apps/${name}/src-tauri/capabilities/default.json`,
|
|
502
|
+
`apps/${name}/src-tauri/icons/icon.icns`,
|
|
409
503
|
"docker/compose.frappe.yaml",
|
|
410
504
|
"docker/compose.keycloak.yaml",
|
|
411
505
|
]) {
|
|
412
506
|
expect(existsSync(join(root, file)), `missing ${file}`).toBe(true);
|
|
413
507
|
}
|
|
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
508
|
const rootPkg = readJson(join(root, "package.json"));
|
|
418
509
|
for (const script of [
|
|
419
510
|
"gnome",
|
|
420
511
|
"gnome:capture",
|
|
421
|
-
"tauri:dev",
|
|
422
|
-
"tauri:build",
|
|
423
512
|
"build:vscode",
|
|
424
513
|
"build:webext",
|
|
514
|
+
"tauri:dev",
|
|
515
|
+
"tauri:build",
|
|
425
516
|
]) {
|
|
426
517
|
expect(rootPkg.scripts[script], `root script ${script}`).toBeDefined();
|
|
427
518
|
}
|
|
428
519
|
});
|
|
429
520
|
|
|
521
|
+
// Tauri and GNOME are peer desktop targets — the kitchen sink proves
|
|
522
|
+
// they co-select: one scaffold carries both the src-tauri webview
|
|
523
|
+
// shell and the GTK/GJS gnome target side by side.
|
|
524
|
+
it("keeps tauri and gnome as co-selected desktop targets", () => {
|
|
525
|
+
expect(existsSync(join(root, `apps/${name}/src-tauri/tauri.conf.json`))).toBe(true);
|
|
526
|
+
expect(existsSync(join(root, `apps/${name}/vite.config.gnome.ts`))).toBe(true);
|
|
527
|
+
const appPkg = readJson(join(root, `apps/${name}/package.json`));
|
|
528
|
+
expect(appPkg.scripts["tauri:dev"]).toBe("tauri dev");
|
|
529
|
+
expect(appPkg.devDependencies["@tauri-apps/cli"]).toBeDefined();
|
|
530
|
+
expect(appPkg.scripts.gnome).toBeDefined();
|
|
531
|
+
});
|
|
532
|
+
|
|
430
533
|
// The GNOME target is the only piece that rewires module resolution
|
|
431
534
|
// rather than just adding files, and both halves of that rewiring
|
|
432
535
|
// fail silently if they drift. A shim that imports the bare barrel
|
|
@@ -435,7 +538,7 @@ describe("initApp generator", () => {
|
|
|
435
538
|
// error. And the react-gnome packages are the runtime: without them
|
|
436
539
|
// the entry resolves nothing at all.
|
|
437
540
|
it("wires the gnome target without aliasing a shim back to itself", () => {
|
|
438
|
-
const aliased = ["components", "theme", "forms"] as const;
|
|
541
|
+
const aliased = ["components", "theme", "forms", "frappe-ui"] as const;
|
|
439
542
|
for (const barrel of aliased) {
|
|
440
543
|
const shim = readFileSync(join(root, `apps/${name}/gnome/shims/${barrel}.ts`), "utf-8");
|
|
441
544
|
expect(shim, `${barrel} shim must deep-import, never the bare barrel`).not.toMatch(
|
|
@@ -463,6 +566,43 @@ describe("initApp generator", () => {
|
|
|
463
566
|
expect(firstImport).toBe(`import "./polyfills";`);
|
|
464
567
|
});
|
|
465
568
|
|
|
569
|
+
// The other half of that rewiring, and the half that fails most
|
|
570
|
+
// quietly. `@multiplatform.one/platform` ships its GNOME flags as
|
|
571
|
+
// `index.gnome.ts`, and the ONLY thing that selects that file is
|
|
572
|
+
// `.gnome.*` sitting ahead of react-gnome's `.native.*` in
|
|
573
|
+
// resolve.extensions. Drop the registration and a scaffolded project
|
|
574
|
+
// still builds, still runs, and reports `isGnome: false` forever.
|
|
575
|
+
it("registers .gnome.* ahead of .native.* so isGnome resolves", () => {
|
|
576
|
+
const config = readFileSync(join(root, `apps/${name}/vite.config.gnome.ts`), "utf-8");
|
|
577
|
+
expect(config, "gnomePlatformExtensions must be imported").toMatch(
|
|
578
|
+
/import\s*\{[^}]*\bgnomePlatformExtensions\b[^}]*\}\s*from\s*"@multiplatform\.one\/vite-plugin-gnome"/u,
|
|
579
|
+
);
|
|
580
|
+
expect(config, ".gnome.* must be registered ahead of .native.*").toMatch(
|
|
581
|
+
/extensions:\s*\[\s*\.\.\.gnomePlatformExtensions\s*,\s*\.\.\.gnomeExtensions\s*,?\s*\]/u,
|
|
582
|
+
);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// The @react-gnome aliases must not assume the scope sits in the
|
|
586
|
+
// app's OWN node_modules: the scaffold's .npmrc sets
|
|
587
|
+
// node-linker=hoisted, so every dependency hoists to the workspace
|
|
588
|
+
// root, the app-local dir never exists, and every alias points at a
|
|
589
|
+
// nonexistent path (measured — the build fails resolving the
|
|
590
|
+
// renderer). require.resolve cannot do the lookup either, because
|
|
591
|
+
// the @react-gnome export maps hide package.json; the config walks
|
|
592
|
+
// up to whichever node_modules holds the scope. And the registry
|
|
593
|
+
// tarballs ship dist ONLY (files: ["dist", …]), so the renderer
|
|
594
|
+
// alias needs a dist fallback when there is no src checkout.
|
|
595
|
+
it("finds @react-gnome by walking up, with a dist fallback for registry tarballs", () => {
|
|
596
|
+
const config = readFileSync(join(root, `apps/${name}/vite.config.gnome.ts`), "utf-8");
|
|
597
|
+
expect(config, "walk-up helper must exist").toContain("function reactGnomeScopeDir()");
|
|
598
|
+
expect(config, "aliases must use the walk-up, not the app-local dir").toMatch(
|
|
599
|
+
/gnomeLinkedAliases\(reactGnomeScopeDir\(\)\)/u,
|
|
600
|
+
);
|
|
601
|
+
expect(config, "renderer alias needs the dist fallback").toContain(
|
|
602
|
+
"renderer/dist/index.js",
|
|
603
|
+
);
|
|
604
|
+
});
|
|
605
|
+
|
|
466
606
|
it("leaves no template placeholders anywhere", () => {
|
|
467
607
|
const binary = new Set([".woff2", ".png", ".ico", ".icns"]);
|
|
468
608
|
const walk = (dir: string): string[] =>
|