@multiplatform.one/cli 5.0.27 → 6.0.1
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/bin/multiplatformOne.mjs +0 -21
- package/lib/bin/multiplatformOne.mjs +820 -556
- package/lib/commands/e2e.mjs +409 -0
- package/lib/commands/init.mjs +480 -0
- package/lib/generateVscode.mjs +167 -0
- package/lib/index.mjs +1 -0
- package/lib/types.mjs +1 -0
- package/package.json +37 -43
- package/scripts/clone.sh +17 -0
- package/scripts/frappe-bench.sh +9 -0
- package/scripts/frappe-bootstrap.sh +495 -0
- package/scripts/frappe-clean.sh +31 -0
- package/scripts/frappe-dev.sh +41 -0
- package/scripts/frappe-helpers.sh +94 -0
- package/scripts/frappe.sh +72 -0
- package/scripts/update.sh +29 -25
- package/src/bin/multiplatformOne.ts +602 -541
- package/src/commands/e2e.ts +515 -0
- package/src/commands/init.ts +682 -0
- package/src/generateVscode.ts +216 -0
- package/src/index.ts +0 -21
- package/src/types.ts +3 -26
- package/scripts/cookiecutter.sh +0 -20
- package/scripts/init.sh +0 -5
- package/types/.tsbuildinfo +0 -1
- package/types/bin/multiplatformOne.d.ts +0 -2
- package/types/bin/multiplatformOne.d.ts.map +0 -1
- package/types/index.d.ts +0 -2
- package/types/index.d.ts.map +0 -1
- package/types/types.d.ts +0 -28
- package/types/types.d.ts.map +0 -1
|
@@ -1,72 +1,214 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* File: /src/bin/multiplatformOne.ts
|
|
3
|
-
* Project: @multiplatform.one/cli
|
|
4
|
-
* File Created: 10-01-2025 21:02:36
|
|
5
|
-
* Author: Clay Risser
|
|
6
|
-
* -----
|
|
7
|
-
* BitSpur (c) Copyright 2021 - 2025
|
|
8
|
-
*
|
|
9
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
10
|
-
* you may not use this file except in compliance with the License.
|
|
11
|
-
* You may obtain a copy of the License at
|
|
12
|
-
*
|
|
13
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
14
|
-
*
|
|
15
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
16
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
17
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
18
|
-
* See the License for the specific language governing permissions and
|
|
19
|
-
* limitations under the License.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
1
|
import fsSync from "node:fs";
|
|
23
2
|
import fs from "node:fs/promises";
|
|
24
|
-
import os from "node:os";
|
|
25
3
|
import path from "node:path";
|
|
26
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { generateVscodeConfig } from "../generateVscode.js";
|
|
27
6
|
import {
|
|
28
7
|
formatServiceList,
|
|
29
8
|
lookupProjectRoot,
|
|
30
|
-
waitForApi,
|
|
31
9
|
waitForFrappe,
|
|
32
10
|
waitForKeycloak,
|
|
33
11
|
waitForPostgres,
|
|
34
12
|
waitServices,
|
|
35
13
|
} from "@multiplatform.one/utils/dev";
|
|
36
|
-
import {
|
|
14
|
+
import { Command } from "commander";
|
|
37
15
|
import dotenv from "dotenv";
|
|
38
|
-
|
|
39
|
-
import
|
|
40
|
-
import
|
|
41
|
-
import
|
|
16
|
+
|
|
17
|
+
import spawn from "nano-spawn";
|
|
18
|
+
import YAML from "yaml";
|
|
19
|
+
import yoctoSpinner from "yocto-spinner";
|
|
20
|
+
import { discoverE2EApp, runE2ESession } from "../commands/e2e";
|
|
21
|
+
import { init, runModifyStep } from "../commands/init";
|
|
42
22
|
|
|
43
23
|
const projectRoot = lookupProjectRoot();
|
|
44
24
|
const availableServices = ["api", "frappe", "solana", "ethereum", "sui"];
|
|
25
|
+
|
|
26
|
+
/** Map a service name to its relative directory from the project root. */
|
|
27
|
+
function serviceDir(name: string): string {
|
|
28
|
+
return `apps/${name}`;
|
|
29
|
+
}
|
|
45
30
|
const defaultDotenvPath = path.resolve(projectRoot, ".env");
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
31
|
+
|
|
32
|
+
/** Apps that exist under apps/ and have package.json (discovered from filesystem). */
|
|
33
|
+
async function getPresentApps(root: string): Promise<string[]> {
|
|
34
|
+
const appsDir = path.join(root, "apps");
|
|
35
|
+
try {
|
|
36
|
+
const entries = await fs.readdir(appsDir, { withFileTypes: true });
|
|
37
|
+
const names: string[] = [];
|
|
38
|
+
for (const e of entries) {
|
|
39
|
+
if (!e.isDirectory()) continue;
|
|
40
|
+
const pkgPath = path.join(appsDir, e.name, "package.json");
|
|
41
|
+
try {
|
|
42
|
+
await fs.access(pkgPath);
|
|
43
|
+
names.push(e.name);
|
|
44
|
+
} catch {
|
|
45
|
+
// no package.json
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return names;
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Root-level services that exist and have package.json (discovered from filesystem). */
|
|
55
|
+
async function getPresentServices(root: string): Promise<string[]> {
|
|
56
|
+
const names: string[] = [];
|
|
57
|
+
for (const name of availableServices) {
|
|
58
|
+
const dir = path.join(root, serviceDir(name));
|
|
59
|
+
const pkgPath = path.join(dir, "package.json");
|
|
60
|
+
try {
|
|
61
|
+
const stat = await fs.stat(dir);
|
|
62
|
+
if (!stat.isDirectory()) continue;
|
|
63
|
+
await fs.access(pkgPath);
|
|
64
|
+
names.push(name);
|
|
65
|
+
} catch {
|
|
66
|
+
// dir or package.json missing
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return names;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Turbo --filter args to exclude root-level workspaces that are not present (so turbo only runs where they exist).
|
|
73
|
+
* Only generates exclude filters for directories that exist on disk (turbo errors on non-existent filter paths). */
|
|
74
|
+
async function getTurboExcludeFiltersForMissing(root: string): Promise<string[]> {
|
|
75
|
+
const present = await getPresentServices(root);
|
|
76
|
+
const filters: string[] = [];
|
|
77
|
+
for (const s of availableServices) {
|
|
78
|
+
if (present.includes(s)) continue;
|
|
79
|
+
const dir = serviceDir(s);
|
|
80
|
+
try {
|
|
81
|
+
const stat = await fs.stat(path.join(root, dir));
|
|
82
|
+
if (stat.isDirectory()) filters.push(`!./${dir}`);
|
|
83
|
+
} catch {
|
|
84
|
+
// directory doesn't exist; turbo won't find it, no filter needed
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return filters;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Workspaces that have Storybook and can run visual regression (discovered from filesystem + package.json). */
|
|
91
|
+
export type StorybookVisualWorkspace = {
|
|
92
|
+
dirName: string;
|
|
93
|
+
absolutePath: string;
|
|
94
|
+
/** How to run visual test: run `make test/visual` in absolutePath, or run pnpm script by name. */
|
|
95
|
+
runVisual: "make" | "pnpm";
|
|
96
|
+
pnpmScript?: string;
|
|
97
|
+
/** If true, run `pnpm run build` then `pnpm exec lost-pixel` in workspace (no named script). */
|
|
98
|
+
runLostPixelAfterBuild?: boolean;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
async function getStorybookVisualWorkspaces(root: string): Promise<StorybookVisualWorkspace[]> {
|
|
102
|
+
const out: StorybookVisualWorkspace[] = [];
|
|
103
|
+
const searchDirs = [
|
|
104
|
+
{ base: path.join(root, "apps"), prefix: "apps/" },
|
|
105
|
+
{ base: path.join(root, "packages"), prefix: "packages/" },
|
|
106
|
+
];
|
|
107
|
+
for (const { base, prefix: _prefix } of searchDirs) {
|
|
108
|
+
let entries: { name: string }[];
|
|
109
|
+
try {
|
|
110
|
+
entries = (await fs.readdir(base, { withFileTypes: true }))
|
|
111
|
+
.filter((e) => e.isDirectory())
|
|
112
|
+
.map((e) => ({ name: e.name }));
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
for (const { name } of entries) {
|
|
117
|
+
const dirPath = path.join(base, name);
|
|
118
|
+
const storybookDir = path.join(dirPath, ".storybook");
|
|
119
|
+
try {
|
|
120
|
+
const stat = await fs.stat(storybookDir);
|
|
121
|
+
if (!stat.isDirectory()) continue;
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const pkgPath = path.join(dirPath, "package.json");
|
|
126
|
+
let pkg: {
|
|
127
|
+
scripts?: Record<string, string>;
|
|
128
|
+
dependencies?: Record<string, string>;
|
|
129
|
+
devDependencies?: Record<string, string>;
|
|
130
|
+
} | null = null;
|
|
131
|
+
try {
|
|
132
|
+
pkg = JSON.parse(await fs.readFile(pkgPath, "utf8"));
|
|
133
|
+
} catch {
|
|
134
|
+
// no package.json or invalid
|
|
135
|
+
}
|
|
136
|
+
const hasLostPixel =
|
|
137
|
+
pkg && (pkg.dependencies?.["lost-pixel"] || pkg.devDependencies?.["lost-pixel"]);
|
|
138
|
+
const scripts = pkg?.scripts ?? {};
|
|
139
|
+
const testVisualScript =
|
|
140
|
+
scripts["test:visual"] ?? scripts["test/visual"] ?? scripts["test:visual:ci"];
|
|
141
|
+
const makefilePath = path.join(dirPath, "Makefile");
|
|
142
|
+
let makefileHasTestVisual = false;
|
|
143
|
+
try {
|
|
144
|
+
const makefile = await fs.readFile(makefilePath, "utf8");
|
|
145
|
+
makefileHasTestVisual = /test\/visual[\s:]/.test(makefile);
|
|
146
|
+
} catch {
|
|
147
|
+
// no Makefile
|
|
148
|
+
}
|
|
149
|
+
if (makefileHasTestVisual) {
|
|
150
|
+
out.push({ dirName: name, absolutePath: dirPath, runVisual: "make" });
|
|
151
|
+
} else if (testVisualScript) {
|
|
152
|
+
const scriptName = scripts["test:visual"]
|
|
153
|
+
? "test:visual"
|
|
154
|
+
: scripts["test/visual"]
|
|
155
|
+
? "test/visual"
|
|
156
|
+
: "test:visual:ci";
|
|
157
|
+
out.push({
|
|
158
|
+
dirName: name,
|
|
159
|
+
absolutePath: dirPath,
|
|
160
|
+
runVisual: "pnpm",
|
|
161
|
+
pnpmScript: scriptName,
|
|
162
|
+
});
|
|
163
|
+
} else if (hasLostPixel) {
|
|
164
|
+
out.push({
|
|
165
|
+
dirName: name,
|
|
166
|
+
absolutePath: dirPath,
|
|
167
|
+
runVisual: "pnpm",
|
|
168
|
+
pnpmScript: undefined,
|
|
169
|
+
runLostPixelAfterBuild: true,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Discover frappe apps from frappe.yaml that have local source paths.
|
|
178
|
+
* Supports both string entries (`- ./apps/core`) and object entries (`- name: core\n source: ./apps/core`). */
|
|
179
|
+
async function getFrappeApps(root: string): Promise<{ name: string; absolutePath: string }[]> {
|
|
180
|
+
const frappeDir = path.join(root, "apps", "frappe");
|
|
181
|
+
const frappeYamlPath = path.join(frappeDir, "frappe.yaml");
|
|
182
|
+
if (!fsSync.existsSync(frappeYamlPath)) return [];
|
|
183
|
+
const raw = await fs.readFile(frappeYamlPath, "utf8");
|
|
184
|
+
const config = YAML.parse(raw) as { apps?: (string | { name?: string; source?: string })[] };
|
|
185
|
+
const out: { name: string; absolutePath: string }[] = [];
|
|
186
|
+
for (const app of config?.apps ?? []) {
|
|
187
|
+
let src: string;
|
|
188
|
+
let name: string;
|
|
189
|
+
if (typeof app === "string") {
|
|
190
|
+
src = app;
|
|
191
|
+
name = path.basename(app);
|
|
192
|
+
} else {
|
|
193
|
+
if (!app.source || !app.name) continue;
|
|
194
|
+
src = app.source;
|
|
195
|
+
name = app.name;
|
|
196
|
+
}
|
|
197
|
+
src = src.startsWith("file://") ? src.slice(7) : src;
|
|
198
|
+
if (!src.startsWith("./") && !src.startsWith("../") && !src.startsWith("/")) continue;
|
|
199
|
+
const absPath = path.isAbsolute(src) ? src : path.resolve(frappeDir, src);
|
|
200
|
+
if (!fsSync.existsSync(absPath)) continue;
|
|
201
|
+
out.push({ name, absolutePath: absPath });
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const program = new Command();
|
|
207
|
+
program.name("mpo");
|
|
63
208
|
program.version(
|
|
64
209
|
JSON.parse(
|
|
65
210
|
fsSync.readFileSync(
|
|
66
|
-
path.resolve(
|
|
67
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
68
|
-
"../../package.json",
|
|
69
|
-
),
|
|
211
|
+
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../package.json"),
|
|
70
212
|
"utf8",
|
|
71
213
|
),
|
|
72
214
|
)?.version,
|
|
@@ -74,260 +216,123 @@ program.version(
|
|
|
74
216
|
|
|
75
217
|
program
|
|
76
218
|
.command("init")
|
|
77
|
-
.option(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
.
|
|
83
|
-
"-c, --checkout <branch>",
|
|
84
|
-
"branch, tag or commit to checkout",
|
|
85
|
-
"main",
|
|
86
|
-
)
|
|
87
|
-
.option("-p, --platforms <platforms>", "platforms to use")
|
|
88
|
-
.option("-s, --services <services>", "services to use")
|
|
89
|
-
.argument("[name]", "the name of the project", "")
|
|
90
|
-
.description("init multiplatform.one")
|
|
91
|
-
.action(async (name, options) => {
|
|
92
|
-
let { services, platforms } = options;
|
|
219
|
+
.option("-c, --checkout <branch>", "branch, tag or commit to checkout", "main")
|
|
220
|
+
.option("-a, --apps <apps>", "comma-separated apps to include")
|
|
221
|
+
.option("-s, --services <services>", "comma-separated services to include")
|
|
222
|
+
.argument("[name]", "the name of the project")
|
|
223
|
+
.description("clone multiplatform.one and apply selected apps/services")
|
|
224
|
+
.action(async (name: string | undefined, options) => {
|
|
93
225
|
if (
|
|
94
|
-
(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
).exitCode === 0
|
|
226
|
+
await spawn("git", ["rev-parse", "--is-inside-work-tree"]).then(
|
|
227
|
+
() => true,
|
|
228
|
+
() => false,
|
|
229
|
+
)
|
|
99
230
|
) {
|
|
100
|
-
throw new Error(
|
|
101
|
-
"multiplatform.one cannot be initialized inside a git repository",
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
if (!name) {
|
|
105
|
-
name = (
|
|
106
|
-
await inquirer.prompt([
|
|
107
|
-
{
|
|
108
|
-
message: "What is the project name?",
|
|
109
|
-
name: "name",
|
|
110
|
-
type: "input",
|
|
111
|
-
},
|
|
112
|
-
])
|
|
113
|
-
).name;
|
|
114
|
-
}
|
|
115
|
-
if (!services) {
|
|
116
|
-
const servicesResult = (
|
|
117
|
-
await inquirer.prompt([
|
|
118
|
-
{
|
|
119
|
-
message: "What services are you using?",
|
|
120
|
-
name: "services",
|
|
121
|
-
type: "checkbox",
|
|
122
|
-
choices: availableServices.map((name) => ({ name })),
|
|
123
|
-
},
|
|
124
|
-
])
|
|
125
|
-
).services;
|
|
126
|
-
services = servicesResult.join(",");
|
|
231
|
+
throw new Error("mpo cannot be initialized inside a git repository");
|
|
127
232
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
{
|
|
132
|
-
message: "What platforms are you using?",
|
|
133
|
-
name: "platforms",
|
|
134
|
-
type: "checkbox",
|
|
135
|
-
choices: availablePlatforms.map((name) => ({ name })),
|
|
136
|
-
},
|
|
137
|
-
])
|
|
138
|
-
).platforms;
|
|
139
|
-
platforms = platformsResult.join(",");
|
|
140
|
-
}
|
|
141
|
-
const cookieCutterConfig: CookieCutterConfig = {
|
|
142
|
-
default_context: { name, platforms, services },
|
|
143
|
-
};
|
|
144
|
-
const cookieCutterConfigFile = path.join(
|
|
145
|
-
await fs.mkdtemp(path.join(os.tmpdir(), "multiplatform-")),
|
|
146
|
-
"config.json",
|
|
233
|
+
const cloneScript = path.resolve(
|
|
234
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
235
|
+
"../../scripts/clone.sh",
|
|
147
236
|
);
|
|
148
|
-
|
|
149
|
-
await fs.writeFile(
|
|
150
|
-
cookieCutterConfigFile,
|
|
151
|
-
JSON.stringify(cookieCutterConfig, null, 2),
|
|
152
|
-
);
|
|
153
|
-
await execa(
|
|
154
|
-
"sh",
|
|
155
|
-
[
|
|
156
|
-
path.resolve(
|
|
157
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
158
|
-
"../../scripts/init.sh",
|
|
159
|
-
),
|
|
160
|
-
"--no-input",
|
|
161
|
-
"-f",
|
|
162
|
-
"--config-file",
|
|
163
|
-
cookieCutterConfigFile,
|
|
164
|
-
"--checkout",
|
|
165
|
-
options.checkout,
|
|
166
|
-
options.remote,
|
|
167
|
-
],
|
|
168
|
-
{
|
|
169
|
-
stdio: "inherit",
|
|
170
|
-
},
|
|
171
|
-
);
|
|
172
|
-
} finally {
|
|
173
|
-
await fs.rm(cookieCutterConfigFile, { recursive: true, force: true });
|
|
174
|
-
}
|
|
237
|
+
await init(name, { ...options, cloneScript });
|
|
175
238
|
});
|
|
176
239
|
|
|
240
|
+
const defaultUpdateRemote = "https://gitlab.com/bitspur/multiplatform.one/multiplatform.one.git";
|
|
241
|
+
|
|
177
242
|
program
|
|
178
243
|
.command("update")
|
|
179
|
-
.option(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
if (!options.noPrompt) {
|
|
220
|
-
const servicesResult = await inquirer.prompt([
|
|
221
|
-
{
|
|
222
|
-
message: "What services are you using?",
|
|
223
|
-
name: "services",
|
|
224
|
-
type: "checkbox",
|
|
225
|
-
choices: availableServices.map((name) => ({
|
|
226
|
-
name,
|
|
227
|
-
checked: services.includes(name),
|
|
228
|
-
})),
|
|
229
|
-
},
|
|
230
|
-
]);
|
|
231
|
-
services = servicesResult.services;
|
|
232
|
-
const platformsResult = await inquirer.prompt([
|
|
233
|
-
{
|
|
234
|
-
message: "What platforms are you using?",
|
|
235
|
-
name: "platforms",
|
|
236
|
-
type: "checkbox",
|
|
237
|
-
choices: availablePlatforms.map((name) => ({
|
|
238
|
-
name,
|
|
239
|
-
checked: platforms.includes(name),
|
|
240
|
-
})),
|
|
241
|
-
},
|
|
242
|
-
]);
|
|
243
|
-
platforms = platformsResult.platforms;
|
|
244
|
-
}
|
|
245
|
-
if (
|
|
246
|
-
(
|
|
247
|
-
await execa("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
248
|
-
reject: false,
|
|
249
|
-
})
|
|
250
|
-
).exitCode !== 0
|
|
251
|
-
) {
|
|
252
|
-
throw new Error(
|
|
253
|
-
"multiplatform.one cannot be updated outside of a git repository",
|
|
254
|
-
);
|
|
244
|
+
.option("-c, --checkout <branch>", "branch, tag or commit to merge from upstream", "main")
|
|
245
|
+
.option("-r, --remote <url>", "upstream remote URL", defaultUpdateRemote)
|
|
246
|
+
.description("merge upstream and re-apply workspace config")
|
|
247
|
+
.action(async (options: { checkout: string; remote: string }) => {
|
|
248
|
+
// Update prechecks: must be in a git repo
|
|
249
|
+
if (
|
|
250
|
+
await spawn("git", ["rev-parse", "--is-inside-work-tree"]).then(
|
|
251
|
+
() => false,
|
|
252
|
+
() => true,
|
|
253
|
+
)
|
|
254
|
+
) {
|
|
255
|
+
throw new Error("mpo cannot be updated outside of a git repository");
|
|
256
|
+
}
|
|
257
|
+
// Require clean working tree (no staged or unstaged changes)
|
|
258
|
+
if (
|
|
259
|
+
await spawn("git", ["diff", "--cached", "--quiet"]).then(
|
|
260
|
+
() => false,
|
|
261
|
+
() => true,
|
|
262
|
+
)
|
|
263
|
+
) {
|
|
264
|
+
throw new Error("mpo cannot be updated with uncommitted changes (staged changes present)");
|
|
265
|
+
}
|
|
266
|
+
if (
|
|
267
|
+
await spawn("git", ["diff", "--quiet"]).then(
|
|
268
|
+
() => false,
|
|
269
|
+
() => true,
|
|
270
|
+
)
|
|
271
|
+
) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
"multiplatform.one cannot be updated with uncommitted changes (unstaged changes present)",
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
// Validate multiplatform.one project with clear errors per failure
|
|
277
|
+
const rootPkgPath = path.resolve(projectRoot, "package.json");
|
|
278
|
+
const featuresPkgPath = path.resolve(projectRoot, "features/package.json");
|
|
279
|
+
try {
|
|
280
|
+
const stat = await fs.stat(rootPkgPath);
|
|
281
|
+
if (!stat.isFile()) {
|
|
282
|
+
throw new Error("mpo update requires a valid project: root package.json not found");
|
|
255
283
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
reject: false,
|
|
260
|
-
})
|
|
261
|
-
).exitCode !== 0
|
|
262
|
-
) {
|
|
263
|
-
throw new Error(
|
|
264
|
-
"multiplatform.one cannot be updated with uncommitted changes",
|
|
265
|
-
);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
if (err instanceof Error && err.message.includes("valid project")) {
|
|
286
|
+
throw err;
|
|
266
287
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
JSON.parse(
|
|
274
|
-
await fs.readFile(
|
|
275
|
-
path.resolve(projectRoot, "app/package.json"),
|
|
276
|
-
"utf8",
|
|
277
|
-
),
|
|
278
|
-
)?.dependencies?.["multiplatform.one"]?.length
|
|
279
|
-
) {
|
|
280
|
-
const name = JSON.parse(
|
|
281
|
-
await fs.readFile(path.resolve(projectRoot, "package.json"), "utf8"),
|
|
282
|
-
)?.name;
|
|
283
|
-
if (name) {
|
|
284
|
-
cookieCutterConfig = {
|
|
285
|
-
default_context: {
|
|
286
|
-
name,
|
|
287
|
-
platforms: platforms.join(","),
|
|
288
|
-
services: services.join(","),
|
|
289
|
-
},
|
|
290
|
-
};
|
|
291
|
-
}
|
|
288
|
+
throw new Error("mpo update requires a valid project: root package.json not found");
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const stat = await fs.stat(featuresPkgPath);
|
|
292
|
+
if (!stat.isFile()) {
|
|
293
|
+
throw new Error("mpo update requires a valid project: features/package.json not found");
|
|
292
294
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (err instanceof Error && err.message.includes("valid project")) {
|
|
297
|
+
throw err;
|
|
295
298
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
+
throw new Error("mpo update requires a valid project: features/package.json not found");
|
|
300
|
+
}
|
|
301
|
+
const featuresPkg = JSON.parse(await fs.readFile(featuresPkgPath, "utf8")) as {
|
|
302
|
+
dependencies?: Record<string, string>;
|
|
303
|
+
};
|
|
304
|
+
if (
|
|
305
|
+
!featuresPkg?.dependencies?.["multiplatform.one"] ||
|
|
306
|
+
!String(featuresPkg.dependencies["multiplatform.one"]).length
|
|
307
|
+
) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
"mpo update requires a valid project: features must depend on multiplatform.one",
|
|
299
310
|
);
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
);
|
|
326
|
-
} finally {
|
|
327
|
-
await fs.rm(cookieCutterConfigFile, { recursive: true, force: true });
|
|
328
|
-
}
|
|
329
|
-
},
|
|
330
|
-
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Merge from upstream (update.sh: add remote, fetch, merge, remove remote, restore .updateignore list, pnpm lint)
|
|
314
|
+
const updateScript = path.resolve(
|
|
315
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
316
|
+
"../../scripts/update.sh",
|
|
317
|
+
);
|
|
318
|
+
await spawn("sh", [updateScript, options.remote, options.checkout], {
|
|
319
|
+
cwd: projectRoot,
|
|
320
|
+
stdio: "inherit",
|
|
321
|
+
shell: true,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Re-apply modify step with auto-detected apps and services
|
|
325
|
+
const presentServices = await getPresentServices(projectRoot);
|
|
326
|
+
const presentApps = await getPresentApps(projectRoot);
|
|
327
|
+
runModifyStep(projectRoot, presentServices, presentApps);
|
|
328
|
+
|
|
329
|
+
// Post-update refresh: .vscode from current workspace
|
|
330
|
+
try {
|
|
331
|
+
await generateVscodeConfig(projectRoot);
|
|
332
|
+
} catch {
|
|
333
|
+
// may fail in older projects without apps/ layout
|
|
334
|
+
}
|
|
335
|
+
});
|
|
331
336
|
|
|
332
337
|
program
|
|
333
338
|
.command("wait")
|
|
@@ -335,10 +340,7 @@ program
|
|
|
335
340
|
.option("-i, --interval <interval>", "interval to wait for", "1000")
|
|
336
341
|
.option("-t, --timeout <timeout>", "timeout to wait for", "600000")
|
|
337
342
|
.option("-e, --dotenv <dotenv>", "dotenv file path", ".env")
|
|
338
|
-
.argument(
|
|
339
|
-
"<services>",
|
|
340
|
-
`the services to wait for (${waitServices.join(", ")})`,
|
|
341
|
-
)
|
|
343
|
+
.argument("<services>", `the services to wait for (${waitServices.join(", ")})`)
|
|
342
344
|
.action(async (servicesString, options) => {
|
|
343
345
|
dotenv.config({ path: options.dotenv || defaultDotenvPath });
|
|
344
346
|
const interval = Number.parseInt(options.interval);
|
|
@@ -346,63 +348,94 @@ program
|
|
|
346
348
|
const services: string[] = servicesString.split(",");
|
|
347
349
|
try {
|
|
348
350
|
await waitWithSpinner(services, { interval, timeout });
|
|
349
|
-
} catch
|
|
351
|
+
} catch {
|
|
350
352
|
process.exit(1);
|
|
351
353
|
}
|
|
352
354
|
});
|
|
353
355
|
|
|
354
|
-
program
|
|
355
|
-
.command("
|
|
356
|
-
.description("
|
|
357
|
-
.option("-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
.
|
|
361
|
-
.
|
|
362
|
-
.option("-
|
|
356
|
+
const frappe = program
|
|
357
|
+
.command("frappe")
|
|
358
|
+
.description("manage frappe development environment")
|
|
359
|
+
.option("-e, --dotenv <dotenv>", "dotenv file path", ".env");
|
|
360
|
+
|
|
361
|
+
frappe
|
|
362
|
+
.command("bootstrap")
|
|
363
|
+
.description("bootstrap frappe development environment")
|
|
364
|
+
.option("-u, --update", "update dependencies")
|
|
363
365
|
.action(async (options) => {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
process.env.MESH_FRAPPE = options.frappe ? "1" : "0";
|
|
369
|
-
}
|
|
370
|
-
if (
|
|
371
|
-
!(await fs
|
|
372
|
-
.stat(path.resolve(projectRoot, "app/main.ts"))
|
|
373
|
-
.catch(() => false))
|
|
374
|
-
) {
|
|
375
|
-
process.env.MESH_APP = "0";
|
|
376
|
-
}
|
|
377
|
-
if (
|
|
378
|
-
!(await fs
|
|
379
|
-
.stat(path.resolve(projectRoot, "frappe/package.json"))
|
|
380
|
-
.catch(() => false))
|
|
381
|
-
) {
|
|
382
|
-
process.env.MESH_FRAPPE = "0";
|
|
383
|
-
}
|
|
384
|
-
const services = [
|
|
385
|
-
...(process.env.MESH_API === "1" ? ["api"] : []),
|
|
386
|
-
...(process.env.MESH_FRAPPE === "1" ? ["frappe"] : []),
|
|
387
|
-
];
|
|
388
|
-
if (services.length > 0) {
|
|
389
|
-
const interval = Number.parseInt(options.interval);
|
|
390
|
-
const timeout = Number.parseInt(options.timeout);
|
|
391
|
-
try {
|
|
392
|
-
await waitWithSpinner(services, { interval, timeout });
|
|
393
|
-
} catch (err) {
|
|
394
|
-
process.exit(1);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
await execa(
|
|
398
|
-
"mesh",
|
|
366
|
+
const parentOptions = frappe.opts();
|
|
367
|
+
dotenv.config({ path: parentOptions.dotenv || defaultDotenvPath });
|
|
368
|
+
await spawn(
|
|
369
|
+
"sh",
|
|
399
370
|
[
|
|
371
|
+
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../scripts/frappe.sh"),
|
|
372
|
+
"bootstrap",
|
|
373
|
+
...(options.update ? ["--update"] : []),
|
|
374
|
+
],
|
|
375
|
+
{
|
|
376
|
+
stdio: "inherit",
|
|
377
|
+
shell: true,
|
|
378
|
+
cwd: projectRoot,
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
frappe
|
|
384
|
+
.command("clean")
|
|
385
|
+
.description("clean frappe bench artifacts")
|
|
386
|
+
.option("--cache", "also remove frappe cache")
|
|
387
|
+
.action(async (options) => {
|
|
388
|
+
await spawn(
|
|
389
|
+
"sh",
|
|
390
|
+
[
|
|
391
|
+
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../scripts/frappe.sh"),
|
|
392
|
+
"clean",
|
|
393
|
+
...(options.cache ? ["--cache"] : []),
|
|
394
|
+
],
|
|
395
|
+
{
|
|
396
|
+
stdio: "inherit",
|
|
397
|
+
shell: true,
|
|
398
|
+
cwd: projectRoot,
|
|
399
|
+
},
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
frappe
|
|
404
|
+
.command("dev")
|
|
405
|
+
.description("start frappe development server")
|
|
406
|
+
.action(async () => {
|
|
407
|
+
const parentOptions = frappe.opts();
|
|
408
|
+
dotenv.config({ path: parentOptions.dotenv || defaultDotenvPath });
|
|
409
|
+
await spawn(
|
|
410
|
+
"sh",
|
|
411
|
+
[
|
|
412
|
+
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../scripts/frappe.sh"),
|
|
400
413
|
"dev",
|
|
401
|
-
"--port",
|
|
402
|
-
Number(options.port || process.env.MESH_PORT || 5002).toString(),
|
|
403
414
|
],
|
|
404
415
|
{
|
|
405
416
|
stdio: "inherit",
|
|
417
|
+
shell: true,
|
|
418
|
+
cwd: projectRoot,
|
|
419
|
+
},
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
frappe
|
|
424
|
+
.command("bench")
|
|
425
|
+
.description("run bench command")
|
|
426
|
+
.action(async () => {
|
|
427
|
+
const parentOptions = frappe.opts();
|
|
428
|
+
dotenv.config({ path: parentOptions.dotenv || defaultDotenvPath });
|
|
429
|
+
await spawn(
|
|
430
|
+
"sh",
|
|
431
|
+
[
|
|
432
|
+
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../scripts/frappe.sh"),
|
|
433
|
+
"bench",
|
|
434
|
+
],
|
|
435
|
+
{
|
|
436
|
+
stdio: "inherit",
|
|
437
|
+
shell: true,
|
|
438
|
+
cwd: projectRoot,
|
|
406
439
|
},
|
|
407
440
|
);
|
|
408
441
|
});
|
|
@@ -415,26 +448,21 @@ program
|
|
|
415
448
|
)
|
|
416
449
|
.description("run build command")
|
|
417
450
|
.action(async (args: string[]) => {
|
|
418
|
-
|
|
419
|
-
const packages =
|
|
420
|
-
const projects =
|
|
451
|
+
const parsed = args.flatMap((arg) => arg.split(","));
|
|
452
|
+
const packages = parsed.includes("packages");
|
|
453
|
+
const projects = parsed.filter((arg) => arg !== "packages");
|
|
454
|
+
const presentApps = await getPresentApps(projectRoot);
|
|
455
|
+
const presentServices = await getPresentServices(projectRoot);
|
|
421
456
|
if ((!projects.length && !packages) || packages) {
|
|
422
|
-
await
|
|
457
|
+
await spawn(
|
|
423
458
|
"pnpm",
|
|
424
459
|
[
|
|
425
460
|
"turbo",
|
|
426
461
|
"run",
|
|
427
462
|
"build",
|
|
428
463
|
"--filter",
|
|
429
|
-
"'!./
|
|
430
|
-
"--filter",
|
|
431
|
-
"'!./api'",
|
|
432
|
-
"--filter",
|
|
433
|
-
"'!./solana'",
|
|
434
|
-
"--filter",
|
|
435
|
-
"'!./ethereum'",
|
|
436
|
-
"--filter",
|
|
437
|
-
"'!./sui'",
|
|
464
|
+
"'!./apps/*'",
|
|
465
|
+
...presentServices.flatMap((s) => ["--filter", `'!./${serviceDir(s)}'`]),
|
|
438
466
|
],
|
|
439
467
|
{
|
|
440
468
|
stdio: "inherit",
|
|
@@ -443,223 +471,259 @@ program
|
|
|
443
471
|
},
|
|
444
472
|
);
|
|
445
473
|
}
|
|
446
|
-
for (const platform of
|
|
447
|
-
if (
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
.access(path.join(projectRoot, "platforms", platform, "package.json"))
|
|
474
|
+
for (const platform of presentApps) {
|
|
475
|
+
if ((!projects.length && !packages) || projects.includes(platform)) {
|
|
476
|
+
const hasBuild = await fs
|
|
477
|
+
.access(path.join(projectRoot, "apps", platform, "package.json"))
|
|
451
478
|
.then(
|
|
452
479
|
() =>
|
|
453
480
|
JSON.parse(
|
|
454
481
|
fsSync.readFileSync(
|
|
455
|
-
path.join(projectRoot, "
|
|
482
|
+
path.join(projectRoot, "apps", platform, "package.json"),
|
|
456
483
|
"utf8",
|
|
457
484
|
),
|
|
458
485
|
).scripts?.build,
|
|
459
486
|
)
|
|
460
|
-
.catch(() => false)
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
487
|
+
.catch(() => false);
|
|
488
|
+
if (hasBuild) {
|
|
489
|
+
await spawn("pnpm", ["turbo", "run", "build", `--filter=./apps/${platform}`], {
|
|
490
|
+
stdio: "inherit",
|
|
491
|
+
shell: true,
|
|
492
|
+
cwd: projectRoot,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
467
495
|
}
|
|
468
496
|
}
|
|
469
|
-
for (const service of
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
.access(path.join(projectRoot,
|
|
497
|
+
for (const service of presentServices) {
|
|
498
|
+
if ((!projects.length && !packages) || projects.includes(service)) {
|
|
499
|
+
const dir = serviceDir(service);
|
|
500
|
+
const hasBuild = await fs
|
|
501
|
+
.access(path.join(projectRoot, dir, "package.json"))
|
|
474
502
|
.then(
|
|
475
503
|
() =>
|
|
476
|
-
JSON.parse(
|
|
477
|
-
|
|
478
|
-
path.join(projectRoot, service, "package.json"),
|
|
479
|
-
"utf8",
|
|
480
|
-
),
|
|
481
|
-
).scripts?.build,
|
|
504
|
+
JSON.parse(fsSync.readFileSync(path.join(projectRoot, dir, "package.json"), "utf8"))
|
|
505
|
+
.scripts?.build,
|
|
482
506
|
)
|
|
483
|
-
.catch(() => false)
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
507
|
+
.catch(() => false);
|
|
508
|
+
if (hasBuild) {
|
|
509
|
+
await spawn("pnpm", ["turbo", "run", "build", `--filter=./${dir}`], {
|
|
510
|
+
stdio: "inherit",
|
|
511
|
+
shell: true,
|
|
512
|
+
cwd: projectRoot,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
490
515
|
}
|
|
491
516
|
}
|
|
492
517
|
});
|
|
493
518
|
|
|
494
519
|
program
|
|
495
520
|
.command("test")
|
|
496
|
-
.argument(
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
)
|
|
521
|
+
.argument("[args...]", "test to run: unit, integration, e2e, visual (default: unit)")
|
|
522
|
+
.option("--up-only", "start E2E environment and exit (for agent/manual testing)")
|
|
523
|
+
.option("--down", "tear down E2E test environment")
|
|
524
|
+
.option("--port-offset <number>", "port offset from defaults", "1000")
|
|
525
|
+
.option("--project <name>", "Docker project name for E2E")
|
|
526
|
+
.option("--no-build", "skip image builds for E2E")
|
|
527
|
+
.option("--filter <pattern>", "run specific Playwright test files")
|
|
500
528
|
.description("run test command")
|
|
501
|
-
.action(async (args: string[]) => {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
if ((
|
|
506
|
-
await
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
.then(
|
|
536
|
-
() =>
|
|
537
|
-
JSON.parse(
|
|
538
|
-
fsSync.readFileSync(
|
|
539
|
-
path.join(projectRoot, "platforms", platform, "package.json"),
|
|
540
|
-
"utf8",
|
|
541
|
-
),
|
|
542
|
-
).scripts?.test,
|
|
543
|
-
)
|
|
544
|
-
.catch(() => false))
|
|
545
|
-
) {
|
|
546
|
-
await execa("./mkpm", [`${platform}/test`], {
|
|
547
|
-
stdio: "inherit",
|
|
548
|
-
shell: true,
|
|
549
|
-
cwd: projectRoot,
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
for (const service of availableServices) {
|
|
554
|
-
if (
|
|
555
|
-
((!projects.length && !packages) || projects.includes(service)) &&
|
|
556
|
-
(await fs
|
|
557
|
-
.access(path.join(projectRoot, service, "package.json"))
|
|
558
|
-
.then(
|
|
559
|
-
() =>
|
|
560
|
-
JSON.parse(
|
|
561
|
-
fsSync.readFileSync(
|
|
562
|
-
path.join(projectRoot, service, "package.json"),
|
|
563
|
-
"utf8",
|
|
564
|
-
),
|
|
565
|
-
).scripts?.test,
|
|
566
|
-
)
|
|
567
|
-
.catch(() => false))
|
|
568
|
-
) {
|
|
569
|
-
await execa("./mkpm", [`${service}/test`], {
|
|
570
|
-
stdio: "inherit",
|
|
571
|
-
shell: true,
|
|
572
|
-
cwd: projectRoot,
|
|
573
|
-
});
|
|
529
|
+
.action(async (args: string[], options) => {
|
|
530
|
+
const parsed = args.flatMap((arg) => arg.split(","));
|
|
531
|
+
|
|
532
|
+
// Handle unit tests (turbo test with dynamic excludes: missing services + E2E app, plus frappe app unit tests)
|
|
533
|
+
if (parsed.includes("unit")) {
|
|
534
|
+
const excludeFilters = await getTurboExcludeFiltersForMissing(projectRoot);
|
|
535
|
+
const e2eApp = await discoverE2EApp(projectRoot);
|
|
536
|
+
const filters = [
|
|
537
|
+
...excludeFilters.flatMap((f) => ["--filter", f]),
|
|
538
|
+
...(e2eApp ? ["--filter", `'!./apps/${e2eApp}'`] : []),
|
|
539
|
+
];
|
|
540
|
+
await spawn("pnpm", ["turbo", "run", "test", ...filters], {
|
|
541
|
+
stdio: "inherit",
|
|
542
|
+
shell: true,
|
|
543
|
+
cwd: projectRoot,
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// Run frappe app unit tests (exclude integration-marked tests)
|
|
547
|
+
const frappeApps = await getFrappeApps(projectRoot);
|
|
548
|
+
for (const app of frappeApps) {
|
|
549
|
+
const hasPytest = await fs
|
|
550
|
+
.access(path.join(app.absolutePath, "pyproject.toml"))
|
|
551
|
+
.then(() => true)
|
|
552
|
+
.catch(() => false);
|
|
553
|
+
if (hasPytest) {
|
|
554
|
+
try {
|
|
555
|
+
await spawn("bench", ["run-tests", "--app", app.name, "--", "-m", "not integration"], {
|
|
556
|
+
stdio: "inherit",
|
|
557
|
+
cwd: path.join(projectRoot, "apps", "frappe"),
|
|
558
|
+
});
|
|
559
|
+
} catch {
|
|
560
|
+
console.warn(`frappe app ${app.name} unit tests failed (bench may not be available)`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
574
563
|
}
|
|
564
|
+
|
|
565
|
+
if (parsed.length === 1) return;
|
|
575
566
|
}
|
|
576
|
-
});
|
|
577
567
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
568
|
+
// Handle integration tests (turbo test + frappe app integration tests)
|
|
569
|
+
if (parsed.includes("integration")) {
|
|
570
|
+
const excludeFilters = await getTurboExcludeFiltersForMissing(projectRoot);
|
|
571
|
+
const e2eApp = await discoverE2EApp(projectRoot);
|
|
572
|
+
const filters = [
|
|
573
|
+
...excludeFilters.flatMap((f) => ["--filter", f]),
|
|
574
|
+
...(e2eApp ? ["--filter", `'!./apps/${e2eApp}'`] : []),
|
|
575
|
+
];
|
|
576
|
+
await spawn("pnpm", ["turbo", "run", "test", ...filters], {
|
|
577
|
+
stdio: "inherit",
|
|
578
|
+
shell: true,
|
|
579
|
+
cwd: projectRoot,
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// Run frappe app integration tests (only integration-marked tests)
|
|
583
|
+
const frappeApps = await getFrappeApps(projectRoot);
|
|
584
|
+
for (const app of frappeApps) {
|
|
585
|
+
const hasPytest = await fs
|
|
586
|
+
.access(path.join(app.absolutePath, "pyproject.toml"))
|
|
587
|
+
.then(() => true)
|
|
588
|
+
.catch(() => false);
|
|
589
|
+
if (hasPytest) {
|
|
590
|
+
try {
|
|
591
|
+
await spawn("bench", ["run-tests", "--app", app.name, "--", "-m", "integration"], {
|
|
592
|
+
stdio: "inherit",
|
|
593
|
+
cwd: path.join(projectRoot, "apps", "frappe"),
|
|
594
|
+
});
|
|
595
|
+
} catch {
|
|
596
|
+
console.warn(
|
|
597
|
+
`frappe app ${app.name} integration tests failed (bench may not be available)`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
608
600
|
}
|
|
609
601
|
}
|
|
602
|
+
|
|
603
|
+
if (parsed.length === 1) return;
|
|
610
604
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
exitCode = (err as { exitCode: number }).exitCode;
|
|
621
|
-
} else {
|
|
622
|
-
exitCode = 1;
|
|
623
|
-
}
|
|
605
|
+
|
|
606
|
+
// Handle E2E test sessions
|
|
607
|
+
if (parsed.includes("e2e")) {
|
|
608
|
+
const teardownHandler = () => {
|
|
609
|
+
runE2ESession(projectRoot, { down: true }).then(() => process.exit(130));
|
|
610
|
+
};
|
|
611
|
+
if (!options.down) {
|
|
612
|
+
process.on("SIGINT", teardownHandler);
|
|
613
|
+
process.on("SIGTERM", teardownHandler);
|
|
624
614
|
}
|
|
615
|
+
await runE2ESession(projectRoot, {
|
|
616
|
+
upOnly: options.upOnly,
|
|
617
|
+
down: options.down,
|
|
618
|
+
portOffset: Number.parseInt(options.portOffset),
|
|
619
|
+
project: options.project,
|
|
620
|
+
noBuild: options.build === false,
|
|
621
|
+
filter: options.filter,
|
|
622
|
+
});
|
|
623
|
+
return;
|
|
625
624
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
625
|
+
|
|
626
|
+
// Handle visual regression tests (discovered dynamically from storybook + lost-pixel)
|
|
627
|
+
if (parsed.includes("visual")) {
|
|
628
|
+
const workspaces = await getStorybookVisualWorkspaces(projectRoot);
|
|
629
|
+
if (!workspaces.length) {
|
|
630
|
+
console.warn(
|
|
631
|
+
"No visual regression workspaces found (.storybook + Makefile test/visual, test:visual script, or lost-pixel).",
|
|
632
|
+
);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
for (const w of workspaces) {
|
|
636
|
+
if (w.runVisual === "make") {
|
|
637
|
+
await spawn("make", ["-C", w.absolutePath, "test/visual"], {
|
|
638
638
|
stdio: "inherit",
|
|
639
|
-
shell: true,
|
|
640
639
|
cwd: projectRoot,
|
|
641
|
-
}
|
|
642
|
-
)
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
640
|
+
});
|
|
641
|
+
} else if (w.runLostPixelAfterBuild) {
|
|
642
|
+
await spawn("pnpm", ["run", "build"], {
|
|
643
|
+
stdio: "inherit",
|
|
644
|
+
cwd: w.absolutePath,
|
|
645
|
+
});
|
|
646
|
+
await spawn("pnpm", ["exec", "lost-pixel"], {
|
|
647
|
+
stdio: "inherit",
|
|
648
|
+
cwd: w.absolutePath,
|
|
649
|
+
});
|
|
650
|
+
} else if (w.pnpmScript) {
|
|
651
|
+
await spawn("pnpm", ["run", w.pnpmScript], {
|
|
652
|
+
stdio: "inherit",
|
|
653
|
+
cwd: w.absolutePath,
|
|
654
|
+
});
|
|
648
655
|
}
|
|
649
656
|
}
|
|
657
|
+
return;
|
|
650
658
|
}
|
|
651
|
-
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Run ruff format or check on the Frappe Python apps (if present).
|
|
663
|
+
* Returns true if the ruff step failed.
|
|
664
|
+
*/
|
|
665
|
+
async function ruffStep(mode: "format" | "check"): Promise<boolean> {
|
|
666
|
+
const frappeApps = path.join(projectRoot, "apps", "frappe", "apps");
|
|
667
|
+
if (!fsSync.existsSync(frappeApps)) return false;
|
|
668
|
+
const label = mode === "format" ? "ruff format" : "ruff check";
|
|
669
|
+
console.log(`\n── ${label} (Python) ──`);
|
|
670
|
+
try {
|
|
671
|
+
await spawn("ruff", [mode, frappeApps], {
|
|
672
|
+
stdio: "inherit",
|
|
673
|
+
cwd: projectRoot,
|
|
674
|
+
});
|
|
675
|
+
return false;
|
|
676
|
+
} catch {
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
program
|
|
682
|
+
.command("format")
|
|
683
|
+
.description("format all supported files (TS, Python, etc.) via turbo")
|
|
684
|
+
.action(async () => {
|
|
685
|
+
await spawn("turbo", ["run", "format", "--filter=//"], {
|
|
686
|
+
stdio: "inherit",
|
|
687
|
+
cwd: projectRoot,
|
|
688
|
+
});
|
|
689
|
+
await ruffStep("format");
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
program
|
|
693
|
+
.command("lint")
|
|
694
|
+
.description("lint and typecheck all supported files (TS, Python, etc.) via turbo")
|
|
695
|
+
.action(async () => {
|
|
696
|
+
const excludeFilters = await getTurboExcludeFiltersForMissing(projectRoot);
|
|
697
|
+
let turboFailed = false;
|
|
698
|
+
try {
|
|
699
|
+
await spawn(
|
|
700
|
+
"turbo",
|
|
701
|
+
[
|
|
702
|
+
"run",
|
|
703
|
+
"lint",
|
|
704
|
+
"typecheck",
|
|
705
|
+
"--filter=//",
|
|
706
|
+
"--continue",
|
|
707
|
+
"--filter",
|
|
708
|
+
"!@package/features",
|
|
709
|
+
...excludeFilters.flatMap((f) => ["--filter", f]),
|
|
710
|
+
],
|
|
711
|
+
{ stdio: "inherit", cwd: projectRoot },
|
|
712
|
+
);
|
|
713
|
+
} catch {
|
|
714
|
+
turboFailed = true;
|
|
715
|
+
}
|
|
716
|
+
const ruffFailed = await ruffStep("check");
|
|
717
|
+
if (turboFailed || ruffFailed) process.exit(1);
|
|
652
718
|
});
|
|
653
719
|
|
|
654
720
|
program
|
|
655
721
|
.command("count")
|
|
656
722
|
.description("count lines of code")
|
|
657
723
|
.action(async () => {
|
|
658
|
-
await
|
|
724
|
+
await spawn(
|
|
659
725
|
"cloc",
|
|
660
|
-
[
|
|
661
|
-
"`(git ls-files && (git lfs ls-files | cut -d' ' -f3)) | sort | uniq -u | grep -E '(html)|(s?css)|(md)|(json)|(yaml)|([jt]sx?)$'`",
|
|
662
|
-
],
|
|
726
|
+
["`git ls-files | grep -E '(html)|(s?css)|(md)|(json)|(yaml)|([jt]sx?)$'`"],
|
|
663
727
|
{
|
|
664
728
|
stdio: "inherit",
|
|
665
729
|
shell: true,
|
|
@@ -672,7 +736,7 @@ program
|
|
|
672
736
|
.command("generate")
|
|
673
737
|
.description("run generate command")
|
|
674
738
|
.action(async () => {
|
|
675
|
-
await
|
|
739
|
+
await spawn("pnpm", ["turbo", "run", "generate"], {
|
|
676
740
|
stdio: "inherit",
|
|
677
741
|
shell: true,
|
|
678
742
|
cwd: projectRoot,
|
|
@@ -687,18 +751,17 @@ async function waitWithSpinner(
|
|
|
687
751
|
) {
|
|
688
752
|
const { interval = 1000, timeout = 600000 } = options;
|
|
689
753
|
const waitFunctions = {
|
|
690
|
-
api: waitForApi,
|
|
691
754
|
frappe: waitForFrappe,
|
|
692
755
|
postgres: waitForPostgres,
|
|
693
756
|
keycloak: waitForKeycloak,
|
|
694
757
|
};
|
|
695
758
|
const unreadyServices = [...services];
|
|
696
|
-
const spinner =
|
|
697
|
-
`waiting for ${formatServiceList(unreadyServices)}`,
|
|
698
|
-
).start();
|
|
759
|
+
const spinner = yoctoSpinner({
|
|
760
|
+
text: `waiting for ${formatServiceList(unreadyServices)}`,
|
|
761
|
+
}).start();
|
|
699
762
|
function updateSpinner(readyService: string) {
|
|
700
763
|
unreadyServices.splice(unreadyServices.indexOf(readyService), 1);
|
|
701
|
-
spinner.
|
|
764
|
+
spinner.success(`${readyService} is ready`);
|
|
702
765
|
if (unreadyServices.length) {
|
|
703
766
|
spinner.start(`waiting for ${formatServiceList(unreadyServices)}`);
|
|
704
767
|
}
|
|
@@ -721,11 +784,9 @@ async function waitWithSpinner(
|
|
|
721
784
|
} catch (err) {
|
|
722
785
|
const error = err as Error;
|
|
723
786
|
if (error.message === "Timeout") {
|
|
724
|
-
spinner.
|
|
725
|
-
`${formatServiceList(unreadyServices)} timed out after ${timeout}ms`,
|
|
726
|
-
);
|
|
787
|
+
spinner.error(`${formatServiceList(unreadyServices)} timed out after ${timeout}ms`);
|
|
727
788
|
} else {
|
|
728
|
-
spinner.
|
|
789
|
+
spinner.error(error.message);
|
|
729
790
|
}
|
|
730
791
|
throw error;
|
|
731
792
|
} finally {
|