@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.
Files changed (163) hide show
  1. package/README.md +121 -111
  2. package/bin/createMultiplatformApp.mjs +39 -37
  3. package/lib/bin/multiplatformOne.mjs +65 -75
  4. package/lib/commands/addApp.mjs +78 -0
  5. package/lib/commands/adoptApp.mjs +11 -7
  6. package/lib/commands/doctypeTypes.mjs +148 -0
  7. package/lib/commands/e2e.mjs +2 -2
  8. package/lib/commands/init.mjs +142 -459
  9. package/lib/commands/merge.mjs +359 -0
  10. package/lib/commands/pieces.mjs +236 -0
  11. package/lib/commands/provenance.mjs +108 -0
  12. package/lib/commands/scaffold.mjs +105 -0
  13. package/lib/commands/updateApp.mjs +211 -528
  14. package/lib/source.json +5 -0
  15. package/lib/source.mjs +63 -0
  16. package/package.json +6 -6
  17. package/scripts/frappe-bootstrap.sh +29 -3
  18. package/scripts/frappe-dev.sh +1 -2
  19. package/scripts/frappe-helpers.sh +50 -2
  20. package/scripts/frappe.sh +5 -0
  21. package/scripts/scaffold.sh +937 -0
  22. package/scripts/stamp-source.mjs +51 -0
  23. package/scripts/stamp-source.spec.ts +86 -0
  24. package/src/bin/multiplatformOne.ts +116 -226
  25. package/src/commands/addApp.ts +102 -0
  26. package/src/commands/adoptApp.spec.ts +5 -1
  27. package/src/commands/adoptApp.ts +15 -10
  28. package/src/commands/doctypeTypes.spec.ts +155 -0
  29. package/src/commands/doctypeTypes.ts +214 -0
  30. package/src/commands/init.spec.ts +66 -0
  31. package/src/commands/init.ts +201 -643
  32. package/src/commands/merge.spec.ts +81 -0
  33. package/src/commands/merge.ts +411 -0
  34. package/src/commands/pieces.spec.ts +62 -0
  35. package/src/commands/pieces.ts +340 -0
  36. package/src/commands/provenance.spec.ts +92 -0
  37. package/src/commands/provenance.ts +169 -0
  38. package/src/commands/scaffold.ts +131 -0
  39. package/src/commands/updateApp.spec.ts +540 -331
  40. package/src/commands/updateApp.ts +255 -668
  41. package/src/source.spec.ts +45 -0
  42. package/src/source.ts +88 -0
  43. package/templates/AGENTS.md +161 -0
  44. package/templates/README.md +62 -0
  45. package/templates/pieces/frappe/universal/README.md.partial +7 -32
  46. package/templates/pieces/frappe/universal/env.example.partial +3 -3
  47. package/templates/pieces/frappe-bench/universal/README.md.partial +28 -0
  48. package/templates/pieces/{frappe → frappe-bench}/universal/docker/compose.frappe.yaml +2 -2
  49. package/templates/pieces/tauri/universal/README.md.partial +7 -5
  50. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/dev.rs +463 -0
  51. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +6 -0
  52. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +2 -1
  53. package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +1 -0
  54. package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +6 -2
  55. package/types/bin/multiplatformOne.d.ts.map +1 -1
  56. package/types/commands/addApp.d.ts +20 -0
  57. package/types/commands/addApp.d.ts.map +1 -0
  58. package/types/commands/adoptApp.d.ts +4 -3
  59. package/types/commands/adoptApp.d.ts.map +1 -1
  60. package/types/commands/doctypeTypes.d.ts +40 -0
  61. package/types/commands/doctypeTypes.d.ts.map +1 -0
  62. package/types/commands/init.d.ts +30 -13
  63. package/types/commands/init.d.ts.map +1 -1
  64. package/types/commands/merge.d.ts +54 -0
  65. package/types/commands/merge.d.ts.map +1 -0
  66. package/types/commands/pieces.d.ts +54 -0
  67. package/types/commands/pieces.d.ts.map +1 -0
  68. package/types/commands/provenance.d.ts +52 -0
  69. package/types/commands/provenance.d.ts.map +1 -0
  70. package/types/commands/scaffold.d.ts +43 -0
  71. package/types/commands/scaffold.d.ts.map +1 -0
  72. package/types/commands/updateApp.d.ts +19 -27
  73. package/types/commands/updateApp.d.ts.map +1 -1
  74. package/types/source.d.ts +35 -0
  75. package/types/source.d.ts.map +1 -0
  76. package/lib/commands/initApp.mjs +0 -431
  77. package/lib/generateVscode.mjs +0 -184
  78. package/scripts/clone.sh +0 -17
  79. package/scripts/update.sh +0 -46
  80. package/src/commands/initApp.spec.ts +0 -720
  81. package/src/commands/initApp.ts +0 -706
  82. package/src/generateVscode.ts +0 -238
  83. package/templates/app/AGENTS.md +0 -142
  84. package/templates/app/README.md +0 -62
  85. package/templates/app/apps/__NAME__/index.html +0 -36
  86. package/templates/app/apps/__NAME__/package.json +0 -31
  87. package/templates/app/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  88. package/templates/app/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  89. package/templates/app/apps/__NAME__/src/App.tsx +0 -9
  90. package/templates/app/apps/__NAME__/src/main.tsx +0 -14
  91. package/templates/app/apps/__NAME__/tsconfig.json +0 -8
  92. package/templates/app/apps/__NAME__/vite.config.ts +0 -17
  93. package/templates/app/features/__NAME__/package.json +0 -34
  94. package/templates/app/features/__NAME__/src/HomeScreen.tsx +0 -70
  95. package/templates/app/features/__NAME__/src/index.ts +0 -1
  96. package/templates/app/features/__NAME__/tsconfig.json +0 -5
  97. package/templates/app/gitignore +0 -9
  98. package/templates/app/npmrc +0 -2
  99. package/templates/app/oxlintrc.json +0 -39
  100. package/templates/app/package.json +0 -38
  101. package/templates/app/packages/config/config.json +0 -4
  102. package/templates/app/packages/config/index.ts +0 -5
  103. package/templates/app/packages/config/package.json +0 -17
  104. package/templates/app/packages/config/tsconfig.json +0 -5
  105. package/templates/app/pnpm-workspace.yaml +0 -4
  106. package/templates/app/tsconfig.base.json +0 -16
  107. package/templates/universal/AGENTS.md +0 -142
  108. package/templates/universal/README.md +0 -97
  109. package/templates/universal/apps/__NAME__/app.config.js +0 -31
  110. package/templates/universal/apps/__NAME__/config/tamagui.config.ts +0 -4
  111. package/templates/universal/apps/__NAME__/package.json +0 -62
  112. package/templates/universal/apps/__NAME__/polyfills.native.ts +0 -23
  113. package/templates/universal/apps/__NAME__/polyfills.ts +0 -3
  114. package/templates/universal/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  115. package/templates/universal/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  116. package/templates/universal/apps/__NAME__/routes/_layout.tsx +0 -22
  117. package/templates/universal/apps/__NAME__/routes/_layout.web.tsx +0 -21
  118. package/templates/universal/apps/__NAME__/routes/about.tsx +0 -3
  119. package/templates/universal/apps/__NAME__/routes/index.tsx +0 -3
  120. package/templates/universal/apps/__NAME__/src/app.ts +0 -10
  121. package/templates/universal/apps/__NAME__/tamagui.css +0 -3
  122. package/templates/universal/apps/__NAME__/tsconfig.json +0 -8
  123. package/templates/universal/apps/__NAME__/types.d.ts +0 -9
  124. package/templates/universal/apps/__NAME__/vite.config.ts +0 -20
  125. package/templates/universal/features/__NAME__/package.json +0 -35
  126. package/templates/universal/features/__NAME__/src/AboutScreen.tsx +0 -27
  127. package/templates/universal/features/__NAME__/src/AppLayout.tsx +0 -50
  128. package/templates/universal/features/__NAME__/src/ContactForm.spec.tsx +0 -22
  129. package/templates/universal/features/__NAME__/src/ContactForm.tsx +0 -55
  130. package/templates/universal/features/__NAME__/src/HomeScreen.tsx +0 -40
  131. package/templates/universal/features/__NAME__/src/index.ts +0 -4
  132. package/templates/universal/features/__NAME__/src/screens.spec.ts +0 -12
  133. package/templates/universal/features/__NAME__/tsconfig.json +0 -5
  134. package/templates/universal/features/__NAME__/vitest.config.mjs +0 -7
  135. package/templates/universal/gitignore +0 -14
  136. package/templates/universal/npmrc +0 -3
  137. package/templates/universal/oxlintrc.json +0 -39
  138. package/templates/universal/package.json +0 -51
  139. package/templates/universal/packages/config/config.json +0 -4
  140. package/templates/universal/packages/config/index.ts +0 -5
  141. package/templates/universal/packages/config/package.json +0 -17
  142. package/templates/universal/packages/config/tsconfig.json +0 -5
  143. package/templates/universal/packages/i18n/en/common.json +0 -30
  144. package/templates/universal/packages/i18n/es/common.json +0 -30
  145. package/templates/universal/packages/i18n/i18n.d.ts +0 -16
  146. package/templates/universal/packages/i18n/index.ts +0 -26
  147. package/templates/universal/packages/i18n/package.json +0 -20
  148. package/templates/universal/packages/i18n/resources.ts +0 -13
  149. package/templates/universal/packages/i18n/tsconfig.json +0 -5
  150. package/templates/universal/packages/themes/accent.ts +0 -32
  151. package/templates/universal/packages/themes/base.ts +0 -50
  152. package/templates/universal/packages/themes/fonts.ts +0 -8
  153. package/templates/universal/packages/themes/index.ts +0 -33
  154. package/templates/universal/packages/themes/package.json +0 -23
  155. package/templates/universal/packages/themes/tamagui.config.ts +0 -14
  156. package/templates/universal/packages/themes/tsconfig.json +0 -5
  157. package/templates/universal/pnpm-workspace.yaml +0 -4
  158. package/templates/universal/tsconfig.base.json +0 -21
  159. package/templates/universal/types/env.d.ts +0 -9
  160. package/types/commands/initApp.d.ts +0 -114
  161. package/types/commands/initApp.d.ts.map +0 -1
  162. package/types/generateVscode.d.ts +0 -7
  163. 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 "./initApp";
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);
@@ -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 the mpo templates and consume
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
- type InitAppPiece,
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, "none");
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("none").map((piece) => ({
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[], "none");
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
+ });