@multiplatform.one/cli 7.11.0 → 7.16.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
|
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import spawn from "nano-spawn";
|
|
5
5
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
6
6
|
import { adoptApp } from "./adoptApp";
|
|
7
|
-
import { cliVersion, readProvenance, writeProvenance } from "./
|
|
7
|
+
import { cliVersion, readProvenance, writeProvenance } from "./provenance";
|
|
8
8
|
|
|
9
9
|
const readJson = (path: string) => JSON.parse(readFileSync(path, "utf-8"));
|
|
10
10
|
|
|
@@ -189,6 +189,8 @@ describe("adoptApp", () => {
|
|
|
189
189
|
cliVersion: "6.6.0",
|
|
190
190
|
name: "flagged",
|
|
191
191
|
mpoVersion: "^6.6.0",
|
|
192
|
+
apps: [],
|
|
193
|
+
services: [],
|
|
192
194
|
pieces: ["webext"],
|
|
193
195
|
reconciled: false,
|
|
194
196
|
});
|
|
@@ -200,6 +202,8 @@ describe("adoptApp", () => {
|
|
|
200
202
|
cliVersion: "6.6.0",
|
|
201
203
|
name: "flagged",
|
|
202
204
|
mpoVersion: "^6.6.0",
|
|
205
|
+
apps: [],
|
|
206
|
+
services: [],
|
|
203
207
|
pieces: ["webext"],
|
|
204
208
|
});
|
|
205
209
|
expect("reconciled" in readJson(join(dir, ".mpo.json"))).toBe(false);
|
package/src/commands/adoptApp.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `mpo adopt` — bring an EXISTING, never-scaffolded project under `mpo
|
|
3
3
|
* update` management. Projects like this hand-copied one or more delivery
|
|
4
|
-
* targets (webext, gnome, …) from
|
|
4
|
+
* targets (webext, gnome, …) from templates/pieces and consume
|
|
5
5
|
* @multiplatform.one/* from npm, but have no .mpo.json provenance, so
|
|
6
|
-
* `mpo update` refuses to run.
|
|
6
|
+
* `mpo update` refuses to run. A project that wants the whole monorepo
|
|
7
|
+
* scaffold instead runs `mpo init --into`.
|
|
7
8
|
*
|
|
8
9
|
* Adoption writes provenance ONLY (template "none" + the chosen pieces +
|
|
9
10
|
* the current CLI version) and seeds .updateignore — it never rewrites a
|
|
@@ -19,16 +20,18 @@ import inquirer from "inquirer";
|
|
|
19
20
|
import spawn from "nano-spawn";
|
|
20
21
|
import {
|
|
21
22
|
INIT_APP_PIECES,
|
|
22
|
-
PROVENANCE_FILE,
|
|
23
23
|
availablePieces,
|
|
24
|
-
cliVersion,
|
|
25
24
|
normalizePieces,
|
|
26
|
-
packageJsonName,
|
|
27
25
|
pieceDescription,
|
|
26
|
+
type InitAppPiece,
|
|
27
|
+
} from "./pieces";
|
|
28
|
+
import {
|
|
29
|
+
PROVENANCE_FILE,
|
|
30
|
+
cliVersion,
|
|
31
|
+
packageJsonName,
|
|
28
32
|
validateName,
|
|
29
33
|
writeProvenance,
|
|
30
|
-
|
|
31
|
-
} from "./initApp";
|
|
34
|
+
} from "./provenance";
|
|
32
35
|
|
|
33
36
|
export interface AdoptAppOptions {
|
|
34
37
|
/** Pieces the project hand-adopted (at least one is required). */
|
|
@@ -67,14 +70,14 @@ function isMpoMonorepo(projectDir: string): boolean {
|
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
async function resolveAdoptPieces(options: AdoptAppOptions): Promise<InitAppPiece[]> {
|
|
70
|
-
if (options.pieces?.length) return normalizePieces(options.pieces
|
|
73
|
+
if (options.pieces?.length) return normalizePieces(options.pieces);
|
|
71
74
|
if (!options.yes && process.stdout.isTTY) {
|
|
72
75
|
const result = await inquirer.prompt([
|
|
73
76
|
{
|
|
74
77
|
message: "Which pieces did this project adopt? (space to toggle, enter to confirm)",
|
|
75
78
|
name: "pieces",
|
|
76
79
|
type: "checkbox",
|
|
77
|
-
choices: availablePieces(
|
|
80
|
+
choices: availablePieces().map((piece) => ({
|
|
78
81
|
name: `${piece} — ${pieceDescription(piece)}`,
|
|
79
82
|
value: piece,
|
|
80
83
|
checked: false,
|
|
@@ -83,7 +86,7 @@ async function resolveAdoptPieces(options: AdoptAppOptions): Promise<InitAppPiec
|
|
|
83
86
|
selection.length > 0 || "Select at least one piece — adopting nothing is meaningless",
|
|
84
87
|
},
|
|
85
88
|
]);
|
|
86
|
-
return normalizePieces(result.pieces as string[]
|
|
89
|
+
return normalizePieces(result.pieces as string[]);
|
|
87
90
|
}
|
|
88
91
|
throw new Error(
|
|
89
92
|
"mpo adopt needs at least one piece " +
|
|
@@ -150,6 +153,8 @@ export async function adoptApp(options: AdoptAppOptions = {}): Promise<void> {
|
|
|
150
153
|
cliVersion: currentVersion,
|
|
151
154
|
name,
|
|
152
155
|
mpoVersion: `^${currentVersion}`,
|
|
156
|
+
apps: [],
|
|
157
|
+
services: [],
|
|
153
158
|
pieces,
|
|
154
159
|
reconciled: false,
|
|
155
160
|
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
type DoctypeMeta,
|
|
7
|
+
doctypesToTypeScript,
|
|
8
|
+
generateDoctypeTypes,
|
|
9
|
+
generatedHeader,
|
|
10
|
+
interfaceName,
|
|
11
|
+
typesDrift,
|
|
12
|
+
} from "./doctypeTypes";
|
|
13
|
+
|
|
14
|
+
function salesOrder(): DoctypeMeta[] {
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
name: "Sales Order",
|
|
18
|
+
fields: [
|
|
19
|
+
{ fieldname: "customer", fieldtype: "Link", options: "Customer", reqd: 1 },
|
|
20
|
+
{ fieldname: "status", fieldtype: "Select", options: "\nDraft\nTo Deliver\nCompleted" },
|
|
21
|
+
{ fieldname: "grand_total", fieldtype: "Currency" },
|
|
22
|
+
{ fieldname: "is_return", fieldtype: "Check" },
|
|
23
|
+
{ fieldname: "items", fieldtype: "Table", options: "Sales Order Item", reqd: 1 },
|
|
24
|
+
{ fieldname: "section_break_1", fieldtype: "Section Break" },
|
|
25
|
+
{ fieldname: "notes_html", fieldtype: "HTML" },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "Sales Order Item",
|
|
30
|
+
istable: 1,
|
|
31
|
+
fields: [
|
|
32
|
+
{ fieldname: "item_code", fieldtype: "Link", options: "Item", reqd: 1 },
|
|
33
|
+
{ fieldname: "qty", fieldtype: "Float" },
|
|
34
|
+
{ fieldname: "idx", fieldtype: "Int" },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let docs = salesOrder();
|
|
41
|
+
let lastAuthorization: string | undefined;
|
|
42
|
+
let server: http.Server;
|
|
43
|
+
let baseURL: string;
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
server = http.createServer((request, response) => {
|
|
47
|
+
const url = new URL(request.url ?? "/", "http://stub");
|
|
48
|
+
lastAuthorization = request.headers.authorization;
|
|
49
|
+
if (url.pathname !== "/api/method/frappe.desk.form.load.getdoctype") {
|
|
50
|
+
response.writeHead(404).end();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (url.searchParams.get("doctype") !== "Sales Order") {
|
|
54
|
+
response.writeHead(403, { "Content-Type": "application/json" }).end("{}");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
response.writeHead(200, { "Content-Type": "application/json" });
|
|
58
|
+
response.end(JSON.stringify({ docs }));
|
|
59
|
+
});
|
|
60
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
61
|
+
baseURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
|
65
|
+
|
|
66
|
+
describe("mpo frappe types (MPO-22)", () => {
|
|
67
|
+
it("emits an interface per doctype from getdoctype, child tables included", async () => {
|
|
68
|
+
docs = salesOrder();
|
|
69
|
+
const output = await generateDoctypeTypes(["Sales Order"], {
|
|
70
|
+
baseURL,
|
|
71
|
+
apiKey: "key",
|
|
72
|
+
apiSecret: "secret",
|
|
73
|
+
});
|
|
74
|
+
expect(lastAuthorization).toBe("token key:secret");
|
|
75
|
+
expect(output).toBe(
|
|
76
|
+
[
|
|
77
|
+
generatedHeader,
|
|
78
|
+
"",
|
|
79
|
+
"export interface SalesOrder {",
|
|
80
|
+
' doctype: "Sales Order";',
|
|
81
|
+
" name: string;",
|
|
82
|
+
" owner?: string;",
|
|
83
|
+
" creation?: string;",
|
|
84
|
+
" modified?: string;",
|
|
85
|
+
" modified_by?: string;",
|
|
86
|
+
" docstatus?: 0 | 1 | 2;",
|
|
87
|
+
" idx?: number;",
|
|
88
|
+
" customer: string;",
|
|
89
|
+
' status?: "" | "Draft" | "To Deliver" | "Completed";',
|
|
90
|
+
" grand_total?: string;",
|
|
91
|
+
" is_return?: 0 | 1;",
|
|
92
|
+
" items: SalesOrderItem[];",
|
|
93
|
+
"}",
|
|
94
|
+
"",
|
|
95
|
+
"export interface SalesOrderItem {",
|
|
96
|
+
' doctype: "Sales Order Item";',
|
|
97
|
+
" name: string;",
|
|
98
|
+
" owner?: string;",
|
|
99
|
+
" creation?: string;",
|
|
100
|
+
" modified?: string;",
|
|
101
|
+
" modified_by?: string;",
|
|
102
|
+
" docstatus?: 0 | 1 | 2;",
|
|
103
|
+
" idx?: number;",
|
|
104
|
+
" parent?: string;",
|
|
105
|
+
" parentfield?: string;",
|
|
106
|
+
" parenttype?: string;",
|
|
107
|
+
" item_code: string;",
|
|
108
|
+
" qty?: number;",
|
|
109
|
+
"}",
|
|
110
|
+
"",
|
|
111
|
+
].join("\n"),
|
|
112
|
+
);
|
|
113
|
+
const { diagnostics } = ts.transpileModule(output, { reportDiagnostics: true });
|
|
114
|
+
expect(diagnostics ?? []).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("a diff against the committed file catches drift on the server", async () => {
|
|
118
|
+
docs = salesOrder();
|
|
119
|
+
const committed = await generateDoctypeTypes(["Sales Order"], { baseURL });
|
|
120
|
+
expect(typesDrift(committed, await generateDoctypeTypes(["Sales Order"], { baseURL }))).toEqual(
|
|
121
|
+
[],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
docs = salesOrder();
|
|
125
|
+
docs[0].fields.splice(1, 1, {
|
|
126
|
+
fieldname: "status",
|
|
127
|
+
fieldtype: "Select",
|
|
128
|
+
options: "\nDraft\nCompleted",
|
|
129
|
+
});
|
|
130
|
+
docs[0].fields.push({ fieldname: "delivery_date", fieldtype: "Date" });
|
|
131
|
+
docs[1].fields = docs[1].fields.filter((field) => field.fieldname !== "qty");
|
|
132
|
+
|
|
133
|
+
expect(typesDrift(committed, await generateDoctypeTypes(["Sales Order"], { baseURL }))).toEqual(
|
|
134
|
+
[
|
|
135
|
+
'- status?: "" | "Draft" | "To Deliver" | "Completed";',
|
|
136
|
+
'+ status?: "" | "Draft" | "Completed";',
|
|
137
|
+
"+ delivery_date?: string;",
|
|
138
|
+
"- qty?: number;",
|
|
139
|
+
],
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("fails loudly when the server will not hand over the meta", async () => {
|
|
144
|
+
await expect(generateDoctypeTypes(["ToDo"], { baseURL })).rejects.toThrow(
|
|
145
|
+
"getdoctype ToDo: HTTP 403",
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("names interfaces from doctype names", () => {
|
|
150
|
+
expect(interfaceName("Sales Invoice Item")).toBe("SalesInvoiceItem");
|
|
151
|
+
expect(interfaceName("ToDo")).toBe("ToDo");
|
|
152
|
+
expect(interfaceName("2FA Settings")).toBe("DocType2FASettings");
|
|
153
|
+
expect(doctypesToTypeScript([]).trim()).toBe(generatedHeader);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mpo frappe types`: TypeScript interfaces for DocTypes, generated from the
|
|
3
|
+
* server's own meta (`frappe.desk.form.load.getdoctype`), so a TDoc type
|
|
4
|
+
* parameter is the server schema and not a hand copy of it. `--check`
|
|
5
|
+
* regenerates and diffs against the committed file, so drift fails loudly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface DocFieldMeta {
|
|
9
|
+
fieldname: string;
|
|
10
|
+
fieldtype: string;
|
|
11
|
+
label?: string;
|
|
12
|
+
options?: string;
|
|
13
|
+
reqd?: 0 | 1 | boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DoctypeMeta {
|
|
17
|
+
name: string;
|
|
18
|
+
istable?: 0 | 1 | boolean;
|
|
19
|
+
fields: DocFieldMeta[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GetDoctypeOptions {
|
|
23
|
+
baseURL: string;
|
|
24
|
+
apiKey?: string;
|
|
25
|
+
apiSecret?: string;
|
|
26
|
+
fetch?: typeof fetch;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const generatedHeader =
|
|
30
|
+
"// Generated by `mpo frappe types` from frappe.desk.form.load.getdoctype. Do not edit.";
|
|
31
|
+
|
|
32
|
+
const stringFieldtypes = new Set([
|
|
33
|
+
"Attach",
|
|
34
|
+
"Attach Image",
|
|
35
|
+
"Autocomplete",
|
|
36
|
+
"Barcode",
|
|
37
|
+
"Code",
|
|
38
|
+
"Color",
|
|
39
|
+
"Currency",
|
|
40
|
+
"Data",
|
|
41
|
+
"Date",
|
|
42
|
+
"Datetime",
|
|
43
|
+
"Dynamic Link",
|
|
44
|
+
"Geolocation",
|
|
45
|
+
"HTML Editor",
|
|
46
|
+
"Icon",
|
|
47
|
+
"JSON",
|
|
48
|
+
"Link",
|
|
49
|
+
"Long Text",
|
|
50
|
+
"Markdown Editor",
|
|
51
|
+
"Password",
|
|
52
|
+
"Phone",
|
|
53
|
+
"Read Only",
|
|
54
|
+
"Signature",
|
|
55
|
+
"Small Text",
|
|
56
|
+
"Text",
|
|
57
|
+
"Text Editor",
|
|
58
|
+
"Time",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const numberFieldtypes = new Set(["Duration", "Float", "Int", "Percent", "Rating"]);
|
|
62
|
+
|
|
63
|
+
const valuelessFieldtypes = new Set([
|
|
64
|
+
"Button",
|
|
65
|
+
"Column Break",
|
|
66
|
+
"Fold",
|
|
67
|
+
"HTML",
|
|
68
|
+
"Heading",
|
|
69
|
+
"Image",
|
|
70
|
+
"Section Break",
|
|
71
|
+
"Tab Break",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const standardFields: Array<[string, string]> = [
|
|
75
|
+
["name", "string"],
|
|
76
|
+
["owner", "string"],
|
|
77
|
+
["creation", "string"],
|
|
78
|
+
["modified", "string"],
|
|
79
|
+
["modified_by", "string"],
|
|
80
|
+
["docstatus", "0 | 1 | 2"],
|
|
81
|
+
["idx", "number"],
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const childFields: Array<[string, string]> = [
|
|
85
|
+
["parent", "string"],
|
|
86
|
+
["parentfield", "string"],
|
|
87
|
+
["parenttype", "string"],
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/** "Sales Invoice Item" → "SalesInvoiceItem". */
|
|
91
|
+
export function interfaceName(doctype: string): string {
|
|
92
|
+
const name = doctype
|
|
93
|
+
.split(/[^A-Za-z0-9]+/)
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
96
|
+
.join("");
|
|
97
|
+
return /^[0-9]/.test(name) ? `DocType${name}` : name || "DocType";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function propertyKey(fieldname: string): string {
|
|
101
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(fieldname) ? fieldname : JSON.stringify(fieldname);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function selectType(options?: string): string {
|
|
105
|
+
if (!options?.trim()) return "string";
|
|
106
|
+
const choices = [...new Set(options.split("\n").map((choice) => choice.trim()))];
|
|
107
|
+
return choices
|
|
108
|
+
.filter((choice, index) => choice !== "" || index === 0)
|
|
109
|
+
.map((choice) => JSON.stringify(choice))
|
|
110
|
+
.join(" | ");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The TypeScript type a field's value takes, or undefined for layout/display fields. */
|
|
114
|
+
export function fieldTsType(field: DocFieldMeta): string | undefined {
|
|
115
|
+
const { fieldtype, options } = field;
|
|
116
|
+
if (valuelessFieldtypes.has(fieldtype)) return undefined;
|
|
117
|
+
if (stringFieldtypes.has(fieldtype)) return "string";
|
|
118
|
+
if (numberFieldtypes.has(fieldtype)) return "number";
|
|
119
|
+
if (fieldtype === "Check") return "0 | 1";
|
|
120
|
+
if (fieldtype === "Select") return selectType(options);
|
|
121
|
+
if (fieldtype === "Table" || fieldtype === "Table MultiSelect") {
|
|
122
|
+
return options ? `${interfaceName(options)}[]` : "Record<string, unknown>[]";
|
|
123
|
+
}
|
|
124
|
+
return "unknown";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function interfaceFor(meta: DoctypeMeta): string {
|
|
128
|
+
const lines = [`export interface ${interfaceName(meta.name)} {`];
|
|
129
|
+
lines.push(` doctype: ${JSON.stringify(meta.name)};`);
|
|
130
|
+
const base = meta.istable ? [...standardFields, ...childFields] : standardFields;
|
|
131
|
+
const taken = new Set(["doctype", ...base.map(([key]) => key)]);
|
|
132
|
+
for (const [key, type] of base) lines.push(` ${key}${key === "name" ? "" : "?"}: ${type};`);
|
|
133
|
+
for (const field of meta.fields) {
|
|
134
|
+
if (!field.fieldname || taken.has(field.fieldname)) continue;
|
|
135
|
+
const type = fieldTsType(field);
|
|
136
|
+
if (!type) continue;
|
|
137
|
+
taken.add(field.fieldname);
|
|
138
|
+
lines.push(` ${propertyKey(field.fieldname)}${field.reqd ? "" : "?"}: ${type};`);
|
|
139
|
+
}
|
|
140
|
+
lines.push("}");
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** One module with an interface per DocType, sorted by name so output is stable. */
|
|
145
|
+
export function doctypesToTypeScript(docs: DoctypeMeta[]): string {
|
|
146
|
+
const byName = new Map(docs.map((meta) => [meta.name, meta]));
|
|
147
|
+
const sorted = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
148
|
+
return `${[generatedHeader, ...sorted.map(interfaceFor)].join("\n\n")}\n`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The requested DocType plus its child tables, as getdoctype returns them. */
|
|
152
|
+
export async function fetchDoctypeMeta(
|
|
153
|
+
doctype: string,
|
|
154
|
+
options: GetDoctypeOptions,
|
|
155
|
+
): Promise<DoctypeMeta[]> {
|
|
156
|
+
const url = new URL("/api/method/frappe.desk.form.load.getdoctype", options.baseURL);
|
|
157
|
+
url.searchParams.set("doctype", doctype);
|
|
158
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
159
|
+
if (options.apiKey && options.apiSecret) {
|
|
160
|
+
headers.Authorization = `token ${options.apiKey}:${options.apiSecret}`;
|
|
161
|
+
}
|
|
162
|
+
const response = await (options.fetch ?? fetch)(url, { headers });
|
|
163
|
+
if (!response.ok) {
|
|
164
|
+
throw new Error(`getdoctype ${doctype}: HTTP ${response.status}`);
|
|
165
|
+
}
|
|
166
|
+
const body = (await response.json()) as { docs?: DoctypeMeta[] };
|
|
167
|
+
if (!Array.isArray(body.docs) || !body.docs.some((meta) => meta?.name === doctype)) {
|
|
168
|
+
throw new Error(`getdoctype ${doctype}: the response carried no meta for it`);
|
|
169
|
+
}
|
|
170
|
+
return body.docs;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function generateDoctypeTypes(
|
|
174
|
+
doctypes: string[],
|
|
175
|
+
options: GetDoctypeOptions,
|
|
176
|
+
): Promise<string> {
|
|
177
|
+
const docs: DoctypeMeta[] = [];
|
|
178
|
+
for (const doctype of doctypes) docs.push(...(await fetchDoctypeMeta(doctype, options)));
|
|
179
|
+
return doctypesToTypeScript(docs);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Line diff of the committed file against a fresh generation: `-` lines the
|
|
184
|
+
* server no longer has, `+` lines it now has. Empty means no drift.
|
|
185
|
+
*/
|
|
186
|
+
export function typesDrift(committed: string, generated: string): string[] {
|
|
187
|
+
const before = committed.replace(/\r\n/g, "\n").split("\n");
|
|
188
|
+
const after = generated.split("\n");
|
|
189
|
+
const lcs: number[][] = Array.from({ length: before.length + 1 }, () =>
|
|
190
|
+
Array.from({ length: after.length + 1 }, () => 0),
|
|
191
|
+
);
|
|
192
|
+
for (let i = before.length - 1; i >= 0; i -= 1) {
|
|
193
|
+
for (let j = after.length - 1; j >= 0; j -= 1) {
|
|
194
|
+
lcs[i][j] =
|
|
195
|
+
before[i] === after[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const drift: string[] = [];
|
|
199
|
+
let i = 0;
|
|
200
|
+
let j = 0;
|
|
201
|
+
while (i < before.length || j < after.length) {
|
|
202
|
+
if (i < before.length && j < after.length && before[i] === after[j]) {
|
|
203
|
+
i += 1;
|
|
204
|
+
j += 1;
|
|
205
|
+
} else if (i < before.length && (j >= after.length || lcs[i + 1][j] >= lcs[i][j + 1])) {
|
|
206
|
+
drift.push(`- ${before[i]}`);
|
|
207
|
+
i += 1;
|
|
208
|
+
} else {
|
|
209
|
+
drift.push(`+ ${after[j]}`);
|
|
210
|
+
j += 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return drift;
|
|
214
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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 { afterAll, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { init } from "./init";
|
|
6
|
+
|
|
7
|
+
// init reads THIS checkout at HEAD (resolveSource finds the monorepo around
|
|
8
|
+
// public/cli), so this runs the real scaffold.sh.
|
|
9
|
+
const heavy = 300_000;
|
|
10
|
+
|
|
11
|
+
describe("mpo init --into", () => {
|
|
12
|
+
const sandbox = mkdtempSync(join(tmpdir(), "mpo-init-spec-"));
|
|
13
|
+
|
|
14
|
+
afterAll(() => {
|
|
15
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
16
|
+
}, heavy);
|
|
17
|
+
|
|
18
|
+
it(
|
|
19
|
+
"leaves an existing repository's files byte for byte and names every one it kept",
|
|
20
|
+
async () => {
|
|
21
|
+
const target = join(sandbox, "wealthflow-repo");
|
|
22
|
+
const existing: Record<string, string> = {
|
|
23
|
+
"pyproject.toml": '[project]\nname = "wealthflow"\n',
|
|
24
|
+
"uv.lock": "version = 1\n",
|
|
25
|
+
Makefile: ".POSIX:\nbuild:\n\tuv build\n",
|
|
26
|
+
"README.md": "# WealthFlow\n",
|
|
27
|
+
"src/wealthflow/__init__.py": 'print("hi")\n',
|
|
28
|
+
"tests/test_x.py": "def test_x():\n assert True\n",
|
|
29
|
+
"docs/index.md": "# docs\n",
|
|
30
|
+
};
|
|
31
|
+
for (const [file, content] of Object.entries(existing)) {
|
|
32
|
+
mkdirSync(dirname(join(target, file)), { recursive: true });
|
|
33
|
+
writeFileSync(join(target, file), content);
|
|
34
|
+
}
|
|
35
|
+
const lines: string[] = [];
|
|
36
|
+
const log = vi.spyOn(console, "log").mockImplementation((...args) => {
|
|
37
|
+
lines.push(args.join(" "));
|
|
38
|
+
});
|
|
39
|
+
try {
|
|
40
|
+
await init(target, {
|
|
41
|
+
into: true,
|
|
42
|
+
apps: ["wealthflow"],
|
|
43
|
+
services: [],
|
|
44
|
+
skipInstall: true,
|
|
45
|
+
skipGit: true,
|
|
46
|
+
yes: true,
|
|
47
|
+
});
|
|
48
|
+
} finally {
|
|
49
|
+
log.mockRestore();
|
|
50
|
+
}
|
|
51
|
+
const output = lines.join("\n");
|
|
52
|
+
|
|
53
|
+
for (const [file, content] of Object.entries(existing)) {
|
|
54
|
+
expect(readFileSync(join(target, file), "utf-8"), file).toBe(content);
|
|
55
|
+
}
|
|
56
|
+
expect(existsSync(join(target, "apps/wealthflow/package.json"))).toBe(true);
|
|
57
|
+
expect(output).toMatch(/Kept \d+ path\(s\) already here, pinned in \.updateignore:/);
|
|
58
|
+
const updateignore = readFileSync(join(target, ".updateignore"), "utf-8");
|
|
59
|
+
for (const kept of ["Makefile", "README.md"]) {
|
|
60
|
+
expect(output).toContain(` ${kept}`);
|
|
61
|
+
expect(updateignore).toContain(`${kept}\n`);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
heavy,
|
|
65
|
+
);
|
|
66
|
+
});
|