@multiplatform.one/cli 5.0.26 → 6.0.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.
@@ -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 { program } from "commander";
14
+ import { Command } from "commander";
37
15
  import dotenv from "dotenv";
38
- import { execa } from "execa";
39
- import inquirer from "inquirer";
40
- import ora from "ora";
41
- import type { CookieCutterConfig } from "../types";
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
- const availablePlatforms = [
47
- "electron",
48
- "expo",
49
- "keycloak",
50
- "one",
51
- "storybook",
52
- "storybook-expo",
53
- "vocs",
54
- "vscode",
55
- "webext",
56
- ];
57
-
58
- process.env.COOKIECUTTER = `sh ${path.resolve(
59
- path.dirname(fileURLToPath(import.meta.url)),
60
- "../../scripts/cookiecutter.sh",
61
- )}`;
62
- program.name("multiplatform.one");
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
- "-r, --remote <remote>",
79
- "the remote to use",
80
- "https://gitlab.com/bitspur/multiplatform.one/cookiecutter",
81
- )
82
- .option(
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
- await execa("git", ["rev-parse", "--is-inside-work-tree"], {
96
- reject: false,
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
- if (!platforms) {
129
- const platformsResult = (
130
- await inquirer.prompt([
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
- try {
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
- "-r, --remote <remote>",
181
- "the remote to use",
182
- "https://gitlab.com/bitspur/multiplatform.one/cookiecutter",
183
- )
184
- .option(
185
- "-c, --checkout <branch>",
186
- "branch, tag or commit to checkout",
187
- "main",
188
- )
189
- .option("-p, --platforms <platforms>", "platforms to keep")
190
- .option("-s, --services <services>", "services to keep")
191
- .option("--prompt", "prompt for services and platforms")
192
- .description("update multiplatform.one")
193
- .action(
194
- async (options: {
195
- checkout: string;
196
- platforms?: string;
197
- prompt?: boolean;
198
- remote: string;
199
- services?: string;
200
- }) => {
201
- let platforms: string[] = [];
202
- let services: string[] = [];
203
- if (!options.platforms) {
204
- platforms = (
205
- await fs.readdir(path.join(projectRoot, "platforms"), {
206
- withFileTypes: true,
207
- })
208
- )
209
- .filter((d) => d.isDirectory())
210
- .map((d) => d.name)
211
- .filter((name) => availablePlatforms.includes(name));
212
- }
213
- if (!options.services) {
214
- services = (await fs.readdir(projectRoot, { withFileTypes: true }))
215
- .filter((d) => d.isDirectory())
216
- .map((d) => d.name)
217
- .filter((name) => availableServices.includes(name));
218
- }
219
- if (options.prompt) {
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
- if (
257
- (
258
- await execa("git", ["diff", "--cached", "--quiet"], {
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
- let cookieCutterConfig: CookieCutterConfig | undefined;
268
- if (
269
- (await fs.stat(path.resolve(projectRoot, "package.json"))).isFile() &&
270
- (
271
- await fs.stat(path.resolve(projectRoot, "app/package.json"))
272
- ).isFile() &&
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
- if (!cookieCutterConfig) {
294
- throw new Error("not a multiplatform.one project");
295
+ } catch (err) {
296
+ if (err instanceof Error && err.message.includes("valid project")) {
297
+ throw err;
295
298
  }
296
- const cookieCutterConfigFile = path.join(
297
- await fs.mkdtemp(path.join(os.tmpdir(), "multiplatform-")),
298
- "config.json",
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
- try {
301
- await fs.writeFile(
302
- cookieCutterConfigFile,
303
- JSON.stringify(cookieCutterConfig, null, 2),
304
- );
305
- await execa(
306
- "sh",
307
- [
308
- path.resolve(
309
- path.dirname(fileURLToPath(import.meta.url)),
310
- "../../scripts/update.sh",
311
- ),
312
- "--no-input",
313
- "-f",
314
- "--config-file",
315
- cookieCutterConfigFile,
316
- "--checkout",
317
- options.checkout,
318
- options.remote,
319
- ],
320
- {
321
- cwd: projectRoot,
322
- stdio: "inherit",
323
- shell: true,
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 (err) {
351
+ } catch {
350
352
  process.exit(1);
351
353
  }
352
354
  });
353
355
 
354
- program
355
- .command("mesh")
356
- .description("start the mesh server")
357
- .option("-a, --api", "use api", false)
358
- .option("-e, --dotenv <dotenv>", "dotenv file path", ".env")
359
- .option("-f, --frappe", "use frappe", false)
360
- .option("-i, --interval <interval>", "interval to wait for", "1000")
361
- .option("-p, --port <port>", "port to run mesh on", "5002")
362
- .option("-t, --timeout <timeout>", "timeout to wait for", "600000")
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
- dotenv.config({ path: options.dotenv || defaultDotenvPath });
365
- process.env.UWS_HTTP_MAX_HEADERS_SIZE = "16384";
366
- if (options.frappe || options.api) {
367
- process.env.MESH_API = options.api ? "1" : "0";
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",
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",
399
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,25 +448,21 @@ program
415
448
  )
416
449
  .description("run build command")
417
450
  .action(async (args: string[]) => {
418
- args = args.flatMap((arg) => arg.split(","));
419
- const packages = args.includes("packages");
420
- const projects = args.filter((arg) => arg !== "packages");
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 execa(
423
- "turbo",
457
+ await spawn(
458
+ "pnpm",
424
459
  [
460
+ "turbo",
425
461
  "run",
426
462
  "build",
427
463
  "--filter",
428
- "'!./platforms/*'",
429
- "--filter",
430
- "'!./api'",
431
- "--filter",
432
- "'!./solana'",
433
- "--filter",
434
- "'!./ethereum'",
435
- "--filter",
436
- "'!./sui'",
464
+ "'!./apps/*'",
465
+ ...presentServices.flatMap((s) => ["--filter", `'!./${serviceDir(s)}'`]),
437
466
  ],
438
467
  {
439
468
  stdio: "inherit",
@@ -442,220 +471,259 @@ program
442
471
  },
443
472
  );
444
473
  }
445
- for (const platform of await fs.readdir("platforms")) {
446
- if (
447
- ((!projects.length && !packages) || projects.includes(platform)) &&
448
- (await fs
449
- .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"))
450
478
  .then(
451
479
  () =>
452
480
  JSON.parse(
453
481
  fsSync.readFileSync(
454
- path.join(projectRoot, "platforms", platform, "package.json"),
482
+ path.join(projectRoot, "apps", platform, "package.json"),
455
483
  "utf8",
456
484
  ),
457
485
  ).scripts?.build,
458
486
  )
459
- .catch(() => false))
460
- ) {
461
- await execa("./mkpm", [`${platform}/build`], {
462
- stdio: "inherit",
463
- shell: true,
464
- cwd: projectRoot,
465
- });
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
+ }
466
495
  }
467
496
  }
468
- for (const service of availableServices) {
469
- if (
470
- ((!projects.length && !packages) || projects.includes(service)) &&
471
- (await fs
472
- .access(path.join(projectRoot, service, "package.json"))
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"))
473
502
  .then(
474
503
  () =>
475
- JSON.parse(
476
- fsSync.readFileSync(
477
- path.join(projectRoot, service, "package.json"),
478
- "utf8",
479
- ),
480
- ).scripts?.build,
504
+ JSON.parse(fsSync.readFileSync(path.join(projectRoot, dir, "package.json"), "utf8"))
505
+ .scripts?.build,
481
506
  )
482
- .catch(() => false))
483
- ) {
484
- await execa("./mkpm", [`${service}/build`], {
485
- stdio: "inherit",
486
- shell: true,
487
- cwd: projectRoot,
488
- });
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
+ }
489
515
  }
490
516
  }
491
517
  });
492
518
 
493
519
  program
494
520
  .command("test")
495
- .argument(
496
- "[args...]",
497
- `test to run: ${[...availableServices, "packages"].join(", ")} (default: all)`,
498
- )
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")
499
528
  .description("run test command")
500
- .action(async (args: string[]) => {
501
- args = args.flatMap((arg) => arg.split(","));
502
- const packages = args.includes("packages");
503
- const projects = args.filter((arg) => arg !== "packages");
504
- if ((!projects.length && !packages) || packages) {
505
- await execa(
506
- "turbo",
507
- [
508
- "run",
509
- "test",
510
- "--filter",
511
- "'!./platforms/*'",
512
- "--filter",
513
- "'!./api'",
514
- "--filter",
515
- "'!./solana'",
516
- "--filter",
517
- "'!./ethereum'",
518
- "--filter",
519
- "'!./sui'",
520
- ],
521
- {
522
- stdio: "inherit",
523
- shell: true,
524
- cwd: projectRoot,
525
- },
526
- );
527
- }
528
- for (const platform of await fs.readdir("platforms")) {
529
- if (
530
- ((!projects.length && !packages) || projects.includes(platform)) &&
531
- (await fs
532
- .access(path.join(projectRoot, "platforms", platform, "package.json"))
533
- .then(
534
- () =>
535
- JSON.parse(
536
- fsSync.readFileSync(
537
- path.join(projectRoot, "platforms", platform, "package.json"),
538
- "utf8",
539
- ),
540
- ).scripts?.test,
541
- )
542
- .catch(() => false))
543
- ) {
544
- await execa("./mkpm", [`${platform}/test`], {
545
- stdio: "inherit",
546
- shell: true,
547
- cwd: projectRoot,
548
- });
549
- }
550
- }
551
- for (const service of availableServices) {
552
- if (
553
- ((!projects.length && !packages) || projects.includes(service)) &&
554
- (await fs
555
- .access(path.join(projectRoot, service, "package.json"))
556
- .then(
557
- () =>
558
- JSON.parse(
559
- fsSync.readFileSync(
560
- path.join(projectRoot, service, "package.json"),
561
- "utf8",
562
- ),
563
- ).scripts?.test,
564
- )
565
- .catch(() => false))
566
- ) {
567
- await execa("./mkpm", [`${service}/test`], {
568
- stdio: "inherit",
569
- shell: true,
570
- cwd: projectRoot,
571
- });
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
+ }
572
563
  }
564
+
565
+ if (parsed.length === 1) return;
573
566
  }
574
- });
575
567
 
576
- program
577
- .command("check")
578
- .argument("[args...]", "checks to run: spelling, types, lint (default: all)")
579
- .description("run check command")
580
- .action(async (args: string[]) => {
581
- args = args.flatMap((arg) => arg.split(","));
582
- const spelling = !args.length || args.includes("spelling");
583
- const types = !args.length || args.includes("types");
584
- const lint = !args.length || args.includes("lint");
585
- let exitCode = 0;
586
- if (spelling) {
587
- try {
588
- await execa(
589
- "cspell",
590
- [
591
- "--unique",
592
- "`(git ls-files && (git lfs ls-files | cut -d' ' -f3))`",
593
- ],
594
- {
595
- stdio: "inherit",
596
- shell: true,
597
- cwd: projectRoot,
598
- },
599
- );
600
- } catch (err) {
601
- if (err instanceof Error && "exitCode" in err) {
602
- exitCode = (err as { exitCode: number }).exitCode;
603
- } else {
604
- exitCode = 1;
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
+ }
605
600
  }
606
601
  }
602
+
603
+ if (parsed.length === 1) return;
607
604
  }
608
- if (types) {
609
- try {
610
- await execa("turbo", ["run", "typecheck"], {
611
- stdio: "inherit",
612
- shell: true,
613
- cwd: projectRoot,
614
- });
615
- } catch (err) {
616
- if (err instanceof Error && "exitCode" in err) {
617
- exitCode = (err as { exitCode: number }).exitCode;
618
- } else {
619
- exitCode = 1;
620
- }
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);
621
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;
622
624
  }
623
- if (lint) {
624
- try {
625
- await execa(
626
- "biome",
627
- [
628
- "check",
629
- "--fix",
630
- "--unsafe",
631
- "`(git ls-files && (git lfs ls-files | cut -d' ' -f3)) | sort | uniq -u | grep -E '(html)|(s?css)|(md)|(json)|(yaml)|([jt]sx?)$'`",
632
- ],
633
- {
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"], {
634
638
  stdio: "inherit",
635
- shell: true,
636
639
  cwd: projectRoot,
637
- },
638
- );
639
- } catch (err) {
640
- if (err instanceof Error && "exitCode" in err) {
641
- exitCode = (err as { exitCode: number }).exitCode;
642
- } else {
643
- exitCode = 1;
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
+ });
644
655
  }
645
656
  }
657
+ return;
646
658
  }
647
- process.exit(exitCode);
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);
648
718
  });
649
719
 
650
720
  program
651
721
  .command("count")
652
722
  .description("count lines of code")
653
723
  .action(async () => {
654
- await execa(
724
+ await spawn(
655
725
  "cloc",
656
- [
657
- "`(git ls-files && (git lfs ls-files | cut -d' ' -f3)) | sort | uniq -u | grep -E '(html)|(s?css)|(md)|(json)|(yaml)|([jt]sx?)$'`",
658
- ],
726
+ ["`git ls-files | grep -E '(html)|(s?css)|(md)|(json)|(yaml)|([jt]sx?)$'`"],
659
727
  {
660
728
  stdio: "inherit",
661
729
  shell: true,
@@ -668,7 +736,7 @@ program
668
736
  .command("generate")
669
737
  .description("run generate command")
670
738
  .action(async () => {
671
- await execa("turbo", ["run", "generate"], {
739
+ await spawn("pnpm", ["turbo", "run", "generate"], {
672
740
  stdio: "inherit",
673
741
  shell: true,
674
742
  cwd: projectRoot,
@@ -683,18 +751,17 @@ async function waitWithSpinner(
683
751
  ) {
684
752
  const { interval = 1000, timeout = 600000 } = options;
685
753
  const waitFunctions = {
686
- api: waitForApi,
687
754
  frappe: waitForFrappe,
688
755
  postgres: waitForPostgres,
689
756
  keycloak: waitForKeycloak,
690
757
  };
691
758
  const unreadyServices = [...services];
692
- const spinner = ora(
693
- `waiting for ${formatServiceList(unreadyServices)}`,
694
- ).start();
759
+ const spinner = yoctoSpinner({
760
+ text: `waiting for ${formatServiceList(unreadyServices)}`,
761
+ }).start();
695
762
  function updateSpinner(readyService: string) {
696
763
  unreadyServices.splice(unreadyServices.indexOf(readyService), 1);
697
- spinner.succeed(`${readyService} is ready`);
764
+ spinner.success(`${readyService} is ready`);
698
765
  if (unreadyServices.length) {
699
766
  spinner.start(`waiting for ${formatServiceList(unreadyServices)}`);
700
767
  }
@@ -717,11 +784,9 @@ async function waitWithSpinner(
717
784
  } catch (err) {
718
785
  const error = err as Error;
719
786
  if (error.message === "Timeout") {
720
- spinner.fail(
721
- `${formatServiceList(unreadyServices)} timed out after ${timeout}ms`,
722
- );
787
+ spinner.error(`${formatServiceList(unreadyServices)} timed out after ${timeout}ms`);
723
788
  } else {
724
- spinner.fail(error.message);
789
+ spinner.error(error.message);
725
790
  }
726
791
  throw error;
727
792
  } finally {