@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.
@@ -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("--no-prompt", "do not prompt for services and platforms")
192
- .description("update multiplatform.one")
193
- .action(
194
- async (options: {
195
- checkout: string;
196
- noPrompt?: boolean;
197
- platforms?: string;
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.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
- 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",
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
- 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(
457
+ await spawn(
423
458
  "pnpm",
424
459
  [
425
460
  "turbo",
426
461
  "run",
427
462
  "build",
428
463
  "--filter",
429
- "'!./platforms/*'",
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 await fs.readdir("platforms")) {
447
- if (
448
- ((!projects.length && !packages) || projects.includes(platform)) &&
449
- (await fs
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, "platforms", platform, "package.json"),
482
+ path.join(projectRoot, "apps", platform, "package.json"),
456
483
  "utf8",
457
484
  ),
458
485
  ).scripts?.build,
459
486
  )
460
- .catch(() => false))
461
- ) {
462
- await execa("./mkpm", [`${platform}/build`], {
463
- stdio: "inherit",
464
- shell: true,
465
- cwd: projectRoot,
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 availableServices) {
470
- if (
471
- ((!projects.length && !packages) || projects.includes(service)) &&
472
- (await fs
473
- .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"))
474
502
  .then(
475
503
  () =>
476
- JSON.parse(
477
- fsSync.readFileSync(
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
- await execa("./mkpm", [`${service}/build`], {
486
- stdio: "inherit",
487
- shell: true,
488
- cwd: projectRoot,
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
- "[args...]",
498
- `test to run: ${[...availableServices, "packages"].join(", ")} (default: all)`,
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
- args = args.flatMap((arg) => arg.split(","));
503
- const packages = args.includes("packages");
504
- const projects = args.filter((arg) => arg !== "packages");
505
- if ((!projects.length && !packages) || packages) {
506
- await execa(
507
- "pnpm",
508
- [
509
- "turbo",
510
- "run",
511
- "test",
512
- "--filter",
513
- "'!./platforms/*'",
514
- "--filter",
515
- "'!./api'",
516
- "--filter",
517
- "'!./solana'",
518
- "--filter",
519
- "'!./ethereum'",
520
- "--filter",
521
- "'!./sui'",
522
- ],
523
- {
524
- stdio: "inherit",
525
- shell: true,
526
- cwd: projectRoot,
527
- },
528
- );
529
- }
530
- for (const platform of await fs.readdir("platforms")) {
531
- if (
532
- ((!projects.length && !packages) || projects.includes(platform)) &&
533
- (await fs
534
- .access(path.join(projectRoot, "platforms", platform, "package.json"))
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
- program
579
- .command("check")
580
- .argument("[args...]", "checks to run: spelling, types, lint (default: all)")
581
- .description("run check command")
582
- .action(async (args: string[]) => {
583
- args = args.flatMap((arg) => arg.split(","));
584
- const spelling = !args.length || args.includes("spelling");
585
- const types = !args.length || args.includes("types");
586
- const lint = !args.length || args.includes("lint");
587
- let exitCode = 0;
588
- if (spelling) {
589
- try {
590
- await execa(
591
- "pnpm",
592
- [
593
- "cspell",
594
- "--unique",
595
- "`(git ls-files && (git lfs ls-files | cut -d' ' -f3))`",
596
- ],
597
- {
598
- stdio: "inherit",
599
- shell: true,
600
- cwd: projectRoot,
601
- },
602
- );
603
- } catch (err) {
604
- if (err instanceof Error && "exitCode" in err) {
605
- exitCode = (err as { exitCode: number }).exitCode;
606
- } else {
607
- 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
+ }
608
600
  }
609
601
  }
602
+
603
+ if (parsed.length === 1) return;
610
604
  }
611
- if (types) {
612
- try {
613
- await execa("turbo", ["run", "typecheck"], {
614
- stdio: "inherit",
615
- shell: true,
616
- cwd: projectRoot,
617
- });
618
- } catch (err) {
619
- if (err instanceof Error && "exitCode" in err) {
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
- if (lint) {
627
- try {
628
- await execa(
629
- "pnpm",
630
- [
631
- "biome",
632
- "check",
633
- "--fix",
634
- "--unsafe",
635
- "`(git ls-files && (git lfs ls-files | cut -d' ' -f3)) | sort | uniq -u | grep -E '(html)|(s?css)|(md)|(json)|(yaml)|([jt]sx?)$'`",
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
- } catch (err) {
644
- if (err instanceof Error && "exitCode" in err) {
645
- exitCode = (err as { exitCode: number }).exitCode;
646
- } else {
647
- 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
+ });
648
655
  }
649
656
  }
657
+ return;
650
658
  }
651
- 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);
652
718
  });
653
719
 
654
720
  program
655
721
  .command("count")
656
722
  .description("count lines of code")
657
723
  .action(async () => {
658
- await execa(
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 execa("pnpm", ["turbo", "run", "generate"], {
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 = ora(
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.succeed(`${readyService} is ready`);
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.fail(
725
- `${formatServiceList(unreadyServices)} timed out after ${timeout}ms`,
726
- );
787
+ spinner.error(`${formatServiceList(unreadyServices)} timed out after ${timeout}ms`);
727
788
  } else {
728
- spinner.fail(error.message);
789
+ spinner.error(error.message);
729
790
  }
730
791
  throw error;
731
792
  } finally {