@bugmole/cli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/LICENSE +18 -4
  2. package/package.json +7 -4
  3. package/scripts/bugmole-admin.mjs +194 -0
  4. package/scripts/bugmole-admin.test.mjs +62 -0
  5. package/scripts/bugmole.test.ts +94 -2
  6. package/scripts/bugmole.ts +290 -43
  7. package/scripts/ensure-playwright.cjs +87 -0
  8. package/scripts/ensure-playwright.test.ts +36 -0
  9. package/scripts/sync-byok.d.mts +2 -0
  10. package/scripts/sync-byok.mjs +18 -0
  11. package/scripts/sync-launcher-version.cjs +24 -0
  12. package/scripts/sync-launcher-version.test.ts +28 -0
  13. package/scripts/sync-plan-catalog.d.mts +3 -0
  14. package/scripts/sync-plan-catalog.mjs +16 -6
  15. package/spec/domain_rules.yaml +139 -0
  16. package/spec/roles.yaml +20 -0
  17. package/spec/test-case-results.schema.json +20 -4
  18. package/spec/test-cases.schema.json +131 -16
  19. package/src/billing/plan-catalog.test.ts +82 -2
  20. package/src/billing/plan-catalog.ts +212 -9
  21. package/src/integrations/jira.ts +264 -0
  22. package/src/mcp/roles-and-review.test.ts +111 -0
  23. package/src/mcp/server.ts +358 -22
  24. package/src/mcp/write-test-cases.test.ts +68 -0
  25. package/src/registry/control-plane-client.ts +121 -6
  26. package/src/registry/migrations/0034_signup_attribution.sql +19 -0
  27. package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
  28. package/src/registry/migrations/0036_task_approval.sql +11 -0
  29. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  30. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  31. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  32. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  33. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  34. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  35. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  36. package/src/registry/migrations/0044_project_identity.sql +31 -0
  37. package/src/registry/project-identity.test.ts +99 -0
  38. package/src/registry/project-identity.ts +206 -0
  39. package/src/registry/roles.test.ts +66 -0
  40. package/src/registry/roles.ts +201 -0
  41. package/src/registry/task-scheduling.test.ts +94 -0
  42. package/src/registry/task-scheduling.ts +199 -2
  43. package/src/registry/test-case-revisions.test.ts +57 -0
  44. package/src/registry/test-case-revisions.ts +132 -0
  45. package/src/registry-worker/ai/routes.ts +3 -3
  46. package/src/registry-worker/artifacts.ts +23 -4
  47. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  48. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  49. package/src/registry-worker/billing/enforcement.ts +67 -12
  50. package/src/registry-worker/billing/paypal/api.ts +14 -0
  51. package/src/registry-worker/billing/paypal/client.ts +9 -0
  52. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  53. package/src/registry-worker/billing/paypal.test.ts +108 -1
  54. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  55. package/src/registry-worker/billing/provider.ts +8 -0
  56. package/src/registry-worker/billing/routes.ts +2 -1
  57. package/src/registry-worker/billing/subscriptions.ts +114 -4
  58. package/src/registry-worker/core.ts +24 -0
  59. package/src/registry-worker/devices/policy.ts +4 -5
  60. package/src/registry-worker/devices/routes.ts +8 -8
  61. package/src/registry-worker/domains/domains.test.ts +64 -1
  62. package/src/registry-worker/domains/routes.ts +32 -15
  63. package/src/registry-worker/domains.ts +40 -2
  64. package/src/registry-worker/feature-access.ts +165 -0
  65. package/src/registry-worker/feature-flags/admin.ts +300 -0
  66. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  67. package/src/registry-worker/feature-flags/routes.ts +102 -0
  68. package/src/registry-worker/features.ts +11 -0
  69. package/src/registry-worker/feedback/feedback.test.ts +47 -0
  70. package/src/registry-worker/feedback/routes.ts +2 -2
  71. package/src/registry-worker/feedback/website.ts +77 -0
  72. package/src/registry-worker/flags.ts +80 -18
  73. package/src/registry-worker/github/checks.ts +4 -4
  74. package/src/registry-worker/hooks.ts +9 -0
  75. package/src/registry-worker/identity/oidc.test.ts +103 -0
  76. package/src/registry-worker/identity/oidc.ts +176 -0
  77. package/src/registry-worker/index.ts +494 -173
  78. package/src/registry-worker/jira/connection.ts +111 -0
  79. package/src/registry-worker/jira/jira.test.ts +404 -0
  80. package/src/registry-worker/jira/routes.ts +432 -0
  81. package/src/registry-worker/jira/workflow.ts +577 -0
  82. package/src/registry-worker/jobs/retention.ts +21 -5
  83. package/src/registry-worker/mcp/tools.ts +2 -1
  84. package/src/registry-worker/notifications/alerts.ts +4 -4
  85. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  86. package/src/registry-worker/notifications/routes.ts +9 -10
  87. package/src/registry-worker/notifications/teams.ts +6 -2
  88. package/src/registry-worker/org/routes.test.ts +24 -1
  89. package/src/registry-worker/org/routes.ts +9 -8
  90. package/src/registry-worker/projects/identity.ts +194 -0
  91. package/src/registry-worker/projects/inactivity.ts +162 -0
  92. package/src/registry-worker/projects/projects.test.ts +283 -0
  93. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  94. package/src/registry-worker/proposals/routes.ts +183 -0
  95. package/src/registry-worker/roles/roles.test.ts +84 -0
  96. package/src/registry-worker/roles/routes.ts +141 -0
  97. package/src/registry-worker/runner/dispatch.ts +1 -0
  98. package/src/registry-worker/runner/routes.ts +22 -5
  99. package/src/registry-worker/runner/runner.test.ts +59 -0
  100. package/src/registry-worker/runner/tokens.ts +12 -1
  101. package/src/registry-worker/secrets/crypto.ts +135 -0
  102. package/src/registry-worker/secrets/routes.ts +296 -0
  103. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  104. package/src/registry-worker/signup/attribution.test.ts +147 -0
  105. package/src/registry-worker/signup/attribution.ts +194 -0
  106. package/src/registry-worker/signup/policy.ts +2 -2
  107. package/src/registry-worker/signup/routes.ts +12 -3
  108. package/src/registry-worker/sso/membership.ts +4 -2
  109. package/src/registry-worker/sso/routes.ts +24 -8
  110. package/src/registry-worker/sso/sso.test.ts +3 -2
  111. package/src/registry-worker/task-approval.test.ts +67 -0
  112. package/src/registry-worker/task-resume.test.ts +196 -0
  113. package/src/registry-worker/task-review.test.ts +121 -0
  114. package/src/runtime/ai-exploration.test.ts +34 -1
  115. package/src/runtime/ai-exploration.ts +97 -3
  116. package/src/runtime/appium-driver.ts +7 -1
  117. package/src/runtime/apply-proposals.test.ts +74 -0
  118. package/src/runtime/apply-proposals.ts +41 -0
  119. package/src/runtime/cloud-secrets.test.ts +201 -0
  120. package/src/runtime/cloud-secrets.ts +210 -0
  121. package/src/runtime/config-validate.ts +12 -9
  122. package/src/runtime/cursor-driver-run.ts +14 -3
  123. package/src/runtime/discovery-task.test.ts +26 -1
  124. package/src/runtime/discovery-task.ts +87 -7
  125. package/src/runtime/executor.ts +18 -10
  126. package/src/runtime/explorer.test.ts +32 -0
  127. package/src/runtime/explorer.ts +48 -0
  128. package/src/runtime/flow-language.ts +43 -8
  129. package/src/runtime/init-wizard.ts +112 -6
  130. package/src/runtime/journey-editor.ts +78 -2
  131. package/src/runtime/journey-graph.test.ts +60 -0
  132. package/src/runtime/local-registry-stub.test.ts +64 -0
  133. package/src/runtime/local-registry-stub.ts +255 -7
  134. package/src/runtime/local-vault.ts +65 -0
  135. package/src/runtime/pipeline.test.ts +23 -0
  136. package/src/runtime/pipeline.ts +61 -20
  137. package/src/runtime/planner.test.ts +24 -1
  138. package/src/runtime/planner.ts +88 -23
  139. package/src/runtime/playwright-driver.test.ts +164 -0
  140. package/src/runtime/playwright-driver.ts +286 -15
  141. package/src/runtime/project-identity.test.ts +161 -0
  142. package/src/runtime/project-identity.ts +232 -0
  143. package/src/runtime/project-roles.test.ts +107 -0
  144. package/src/runtime/project-roles.ts +158 -0
  145. package/src/runtime/propose-cli.ts +66 -0
  146. package/src/runtime/record-run-verdicts.test.ts +58 -0
  147. package/src/runtime/record-run-verdicts.ts +38 -4
  148. package/src/runtime/reporter.ts +1 -1
  149. package/src/runtime/reset.ts +2 -0
  150. package/src/runtime/roles-cli.test.ts +47 -0
  151. package/src/runtime/roles-cli.ts +60 -0
  152. package/src/runtime/run-job.test.ts +21 -0
  153. package/src/runtime/run-job.ts +8 -0
  154. package/src/runtime/run-once.ts +68 -5
  155. package/src/runtime/scenario-matrix.test.ts +41 -0
  156. package/src/runtime/scenario-matrix.ts +98 -0
  157. package/src/runtime/secret-driver.ts +77 -0
  158. package/src/runtime/secret-redaction.ts +69 -0
  159. package/src/runtime/secret-sources.test.ts +442 -0
  160. package/src/runtime/secret-sources.ts +176 -0
  161. package/src/runtime/secrets-cli.ts +146 -0
  162. package/src/runtime/serve-worker.ts +64 -6
  163. package/src/runtime/site-discovery.test.ts +181 -4
  164. package/src/runtime/site-discovery.ts +248 -29
  165. package/src/runtime/vault-federation.test.ts +37 -0
  166. package/src/runtime/vault-federation.ts +128 -0
  167. package/src/runtime/web-suite.test.ts +65 -0
  168. package/src/runtime/web-suite.ts +8 -2
  169. package/src/storage/create-object-store.ts +5 -1
  170. package/src/storage/object-store.ts +12 -1
  171. package/src/storage/test-case-results.test.ts +64 -0
  172. package/src/storage/test-case-results.ts +49 -0
  173. package/src/vendor/byok.ts +371 -0
@@ -9,6 +9,7 @@ import dotenv from "dotenv";
9
9
  import YAML from "yaml";
10
10
  import { resolveLocalStorageDirectory } from "../src/storage/storage-directory.js";
11
11
  import { adoptLegacyEnv, preferExisting } from "../src/runtime/legacy-names.js";
12
+ import { approvalModeOf, workerApprovalMode, type ApprovalMode } from "../src/registry/task-scheduling.js";
12
13
 
13
14
  type Mode =
14
15
  | "api-key"
@@ -18,16 +19,20 @@ type Mode =
18
19
  | "plan"
19
20
  | "run"
20
21
  | "propose-diff"
22
+ | "discover"
21
23
  | "review"
22
24
  | "report"
23
25
  | "artifacts"
24
26
  | "serve"
25
27
  | "gateway"
26
28
  | "run-once"
29
+ | "propose"
30
+ | "roles"
27
31
  | "reset"
28
32
  | "cursor-driver"
29
33
  | "suite"
30
- | "init-env";
34
+ | "init-env"
35
+ | "secrets";
31
36
 
32
37
  function readArg(name: string, argv = process.argv): string | undefined {
33
38
  const i = argv.indexOf(name);
@@ -44,23 +49,42 @@ export function parseArgs(argv = process.argv): Record<string, any> {
44
49
  if (positionalCommand === "gateway") args.gateway = true;
45
50
  if (positionalCommand === "run-once") args.runonce = true;
46
51
  if (positionalCommand === "reset") args.reset = true;
52
+ if (positionalCommand === "roles") args.roles = true;
53
+ if (positionalCommand === "secrets") args.secrets = true;
54
+ if (positionalCommand === "propose") {
55
+ args.propose = true;
56
+ const file = argv.slice(argv.indexOf("propose") + 1).find((arg) => !arg.startsWith("--"));
57
+ if (file) args.proposefile = file;
58
+ }
47
59
  for (let i = 0; i < argv.length; i++) {
48
60
  const arg = argv[i];
49
61
  if (arg.startsWith("--")) {
50
- const key = arg.slice(2).replace(/-/g, "");
62
+ const name = arg.slice(2);
63
+ const key = name.replace(/-/g, "");
51
64
  const next = argv[i + 1];
52
65
  const value = next && !next.startsWith("--") ? next : true;
53
66
  if (value !== true) i++;
54
- // Try to parse as number or boolean
55
- if (value === true || value === "true") args[key] = true;
56
- else if (value === "false") args[key] = false;
57
- else if (!isNaN(Number(value))) args[key] = Number(value);
58
- else args[key] = value;
67
+ // Only counts and durations become numbers: `--grep 404` or
68
+ // `--project 2024` are names, and must stay strings.
69
+ let parsed: unknown;
70
+ if (value === true || value === "true") parsed = true;
71
+ else if (value === "false") parsed = false;
72
+ else if (NUMERIC_FLAGS.has(key.toLowerCase()) && value.trim() !== "" && !isNaN(Number(value))) parsed = Number(value);
73
+ else parsed = value;
74
+ args[key] = parsed;
75
+ // Modes read either spelling: --file-path is args.filepath and args.filePath.
76
+ const camel = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
77
+ if (camel !== key) args[camel] = parsed;
59
78
  }
60
79
  }
61
80
  return args;
62
81
  }
63
82
 
83
+ const NUMERIC_FLAGS = new Set([
84
+ "timeout", "maxpages", "maxdepth", "localregistryport", "artifactport", "gatewayport", "pollinterval",
85
+ "nudgeinterval", "nudgemaxnudges", "nudgeinactivitythreshold", "nudgeprogressreporttimeout",
86
+ ]);
87
+
64
88
  export function printHelp(init = false): void {
65
89
  if (init) {
66
90
  console.log(`Usage: bugmole init [options]
@@ -70,40 +94,87 @@ Initialize .bugmole/config.yaml and .bugmole.env without reading credentials.
70
94
  Options:
71
95
  --non-interactive Use defaults and never prompt
72
96
  --force Overwrite an existing configuration
73
- --storage-provider <name> local, gcs, s3, or r2
97
+ --storage-provider <name> local, bugmole, gcs, s3, or r2
98
+ --bucket <name> Bucket for gcs, s3 or r2
99
+ --region <region> S3 region
100
+ --account-id <id> R2 account ID
101
+ --cloud-project-id <id> Google Cloud project for gcs
102
+ --endpoint <url> Custom storage endpoint
103
+ --project-id <id> Project identifier (default: the folder name)
104
+ --project-name <name> Project display name
105
+ --environment <name> Environment name (default: local)
106
+ --spec-dir <folder> Where flows, plans and roles live (default: ./spec)
74
107
  --workspace-id <id> Workspace identifier
75
108
  --workspace-name <name> Workspace display name
76
109
  --app-id <id> Default app identifier
77
110
  --app-name <name> Default app display name
111
+ --app-path <folder> The app's folder in a repository with several apps
112
+ --app-url <url> The app's URL (a public one identifies the project by domain)
78
113
  --config <path> Configuration output path
79
- --help Show this help`);
114
+ --help Show this help
115
+
116
+ Bugmole tests web apps and mobile apps (Android and iOS). One app is one
117
+ project: a repository's git remote plus the app's folder identify it, so
118
+ teammates who clone the same repository join the same project.`);
80
119
  return;
81
120
  }
82
121
  console.log(`Usage: bugmole [command] [options]
83
122
 
84
123
  Commands:
85
124
  init Initialize a project configuration
86
- api-key create Generate a one-time machine API key
125
+ api-key create Generate a one-time machine API key (needs a key with keys:write;
126
+ the dashboard's API keys page works without one)
127
+ --name <name> What the key is for
128
+ --project-ids <ids> Comma-separated projects it may use
87
129
  --mcp Start the MCP server
88
130
  artifacts serve Serve local artifacts to the dashboard over loopback
89
131
  --artifact-port <port> Loopback port (default: 3190)
90
132
  --dashboard-origin <url> Allowed dashboard origin
133
+ propose <file.json> Propose test-case changes (add, change, retire) for review;
134
+ once accepted each becomes a new revision and the old one obsolete
135
+ secrets List the {{secret.NAME}}s your flows use and where each comes from
136
+ secrets set <NAME> Keep a value in this machine's secure store (typed hidden, or piped on stdin)
137
+ secrets delete <NAME> Remove it. Or map names to your vault (gcpsm://, awssm://, keychain://,
138
+ env://) under secrets: in the config
139
+ roles List the project's roles: confirmed ones are used for test cases and runs
140
+ roles suggest <name> Suggest a role for a person to confirm on the dashboard's Roles page
141
+ --evidence <text> What points at it (a sign-in wall, a gated route or menu)
142
+ --description <text> Who this is and what they can do
143
+ --from-evidence Suggest every role the journey graph, blockers and roles.yaml imply
91
144
  serve Start the local artifact bridge and HTTP MCP server
92
145
  --no-open Print the dashboard handoff without opening a browser
146
+ --approval <mode> manual | auto | extreme: only take tasks this mode would allow.
147
+ Stricter than the project's mode, never looser (sets BUGMOLE_APPROVAL).
93
148
  --dashboard-url <url> Dashboard URL for the handoff (overrides BUGMOLE_DASHBOARD_URL)
94
149
  --local-registry Stand in for the real registry locally (overrides BUGMOLE_LOCAL_REGISTRY)
95
150
  so queueing tasks/runs and this worker executing them works without
96
- a real Aegis login or a BUGMOLE_API_KEY scoped to this project.
151
+ signing in or a BUGMOLE_API_KEY scoped to this project.
97
152
  --local-registry-port Local registry stub port (default: 4600, or BUGMOLE_LOCAL_REGISTRY_PORT)
98
153
  gateway Multiplex project serve workers over loopback
99
154
  --gateway-port <port> Loopback port (default: 3191)
100
155
  --gateway-secret <value> Shared secret for worker registration
101
156
  reset Delete current-project execution data after confirmation
157
+ --yes Skip the typed confirmation (scripts and CI)
102
158
  run-once Execute one Bugmole Cloud run and exit (used inside cloud runners;
103
159
  reads BUGMOLE_REGISTRY_URL, BUGMOLE_RUN_ID, BUGMOLE_RUN_TOKEN)
104
- --mode <mode> explore, plan, run, report, review, propose-diff, or suite
160
+ --mode <mode> explore, plan, run, report, review, propose-diff, suite, or discover
161
+ --app-path <folder> The app's folder in a repository with several apps
162
+ --app-url <url> The app's URL
163
+ --timeout <ms> How long a flow may take
164
+ --mode explore Learn the app's screens from its source, or by crawling it
165
+ --base-url <url> The running app
166
+ --mode plan Write a runnable plan for a journey
167
+ --journey-id <id> The journey to plan
168
+ --actor <role> A confirmed role to plan it for
169
+ --mode discover Click through a running app and write a flow for each page and form
170
+ --base-url <url> The running app
171
+ --out <dir> Where to write the flows (required)
172
+ --sign-in <flow> Sign in with this flow first, to explore the signed-in app
173
+ --max-pages <n> Stop after this many pages
174
+ --max-depth <n> Follow links at most this many clicks from the start
175
+ --open-each-page Open every page directly as well as by clicking
105
176
  --mode suite Run a directory of web flows against a running app
106
- --flows <dir> Flow directory (default: .maestro)
177
+ --flows <dir> Flow directory (default: spec/flows, then flows, then .maestro)
107
178
  --base-url <url> Application base URL (or BUGMOLE_BASE_URL)
108
179
  --artifacts-dir <dir> Evidence directory (default: qa/bugmole-runs)
109
180
  --project <id> Project id used in the evidence path
@@ -128,6 +199,9 @@ export function resolveMode(args: Record<string, any>): Mode {
128
199
  if (args.gateway === true) return "gateway";
129
200
  if (args.runonce === true) return "run-once";
130
201
  if (args.reset === true) return "reset";
202
+ if (args.propose === true) return "propose";
203
+ if (args.roles === true) return "roles";
204
+ if (args.secrets === true) return "secrets";
131
205
  if (args.artifacts === true) return "artifacts";
132
206
  return (args.mode ?? "mcp") as Mode;
133
207
  }
@@ -137,9 +211,10 @@ export function resolveMode(args: Record<string, any>): Mode {
137
211
  // step needs it to fail the build, so `--mode run` is the one mode whose exit code
138
212
  // reflects the result rather than just whether the process threw.
139
213
  export function runModeExitCode(mode: string, result: unknown): number {
140
- if (mode !== "run") return 0;
141
214
  const status = result && typeof result === "object" ? (result as { status?: unknown }).status : undefined;
142
- return status === "success" ? 0 : 1;
215
+ // A run passes only when it all passed; the other modes fail on an error.
216
+ if (mode === "run") return status === "success" ? 0 : 1;
217
+ return status === "error" ? 1 : 0;
143
218
  }
144
219
 
145
220
  export function shouldOpenServeBrowser(
@@ -209,6 +284,19 @@ export function resolveLocalRegistryEnabled(args: Record<string, any>): boolean
209
284
  // every fetch with "bad port" — 4190 is the reserved ManageSieve port).
210
285
  const DEFAULT_LOCAL_REGISTRY_PORT = 4600;
211
286
 
287
+ /**
288
+ * `--approval manual|auto|extreme` for `bugmole serve`. It becomes
289
+ * BUGMOLE_APPROVAL so every part of this process (worker, MCP, the coding
290
+ * agent driver) holds back the same tasks. Returns null for a bad value.
291
+ */
292
+ export function applyApprovalFlag(args: Record<string, any>, env: Record<string, string | undefined> = process.env): ApprovalMode | undefined | null {
293
+ if (args.approval === undefined) return workerApprovalMode(env);
294
+ const mode = approvalModeOf(typeof args.approval === "string" ? args.approval.trim() : "");
295
+ if (!mode) return null;
296
+ env.BUGMOLE_APPROVAL = mode;
297
+ return mode;
298
+ }
299
+
212
300
  export function resolveLocalRegistryPort(args: Record<string, any>): number {
213
301
  if (typeof args.localregistryport === "number") return args.localregistryport;
214
302
  const fromEnv = process.env.BUGMOLE_LOCAL_REGISTRY_PORT?.trim();
@@ -351,6 +439,13 @@ async function main() {
351
439
  if (positionalCommand === "artifacts") args.artifacts = true;
352
440
  if (positionalCommand === "serve") args.serve = true;
353
441
  if (positionalCommand === "reset") args.reset = true;
442
+ if (positionalCommand === "roles") args.roles = true;
443
+ if (positionalCommand === "secrets") args.secrets = true;
444
+ if (positionalCommand === "propose") {
445
+ args.propose = true;
446
+ const file = process.argv.slice(process.argv.indexOf("propose") + 1).find((arg) => !arg.startsWith("--"));
447
+ if (file) args.proposefile = file;
448
+ }
354
449
  // quiet: dotenv 17 prints an "injected env" banner on every load, which
355
450
  // would land in the middle of this CLI's own output.
356
451
  dotenv.config({ path: preferExisting(".bugmole.env", ".valk.env"), quiet: true });
@@ -384,29 +479,38 @@ async function main() {
384
479
 
385
480
  // Init must run from the caller's workspace, not the package directory.
386
481
  if (resolveMode(args) === "init") {
387
- const { runInitWizard } = await import("../src/runtime/init-wizard.js");
388
- await runInitWizard({
389
- nonInteractive: args.noninteractive === true,
390
- force: args.force === true,
391
- configPath: args.config,
392
- workspaceId: args.workspaceid,
393
- workspaceName: args.workspacename,
394
- projectId: args.projectid,
395
- projectName: args.projectname,
396
- appId: args.appid,
397
- appName: args.appname,
398
- environment: args.environment,
399
- specDir: args.specdir,
400
- evidenceManifest: args.evidencemanifest,
401
- evidenceScreenshots: args.evidencescreenshots,
402
- artifactsDir: args.artifactsdir,
403
- storageProvider: args.storageprovider,
404
- bucket: args.bucket,
405
- region: args.region,
406
- endpoint: args.endpoint,
407
- cloudProjectId: args.cloudprojectid,
408
- accountId: args.accountid,
409
- });
482
+ const { runInitWizard, PlanLimitError, describePlanLimit } = await import("../src/runtime/init-wizard.js");
483
+ try {
484
+ await runInitWizard({
485
+ nonInteractive: args.noninteractive === true,
486
+ force: args.force === true,
487
+ configPath: args.config,
488
+ workspaceId: args.workspaceid,
489
+ workspaceName: args.workspacename,
490
+ projectId: args.projectid,
491
+ projectName: args.projectname,
492
+ appId: args.appid,
493
+ appName: args.appname,
494
+ environment: args.environment,
495
+ specDir: args.specdir,
496
+ evidenceManifest: args.evidencemanifest,
497
+ evidenceScreenshots: args.evidencescreenshots,
498
+ artifactsDir: args.artifactsdir,
499
+ storageProvider: args.storageprovider,
500
+ bucket: args.bucket,
501
+ region: args.region,
502
+ endpoint: args.endpoint,
503
+ cloudProjectId: args.cloudprojectid,
504
+ accountId: args.accountid,
505
+ appPath: typeof args.apppath === "string" ? args.apppath : undefined,
506
+ appUrl: typeof args.appurl === "string" ? args.appurl : undefined,
507
+ });
508
+ } catch (error) {
509
+ // A plan limit is an answer, not a crash: say what the plan allows and where to upgrade.
510
+ if (!(error instanceof PlanLimitError)) throw error;
511
+ console.error(describePlanLimit(error));
512
+ process.exitCode = 1;
513
+ }
410
514
  return;
411
515
  }
412
516
 
@@ -448,23 +552,56 @@ async function main() {
448
552
  return;
449
553
  }
450
554
 
555
+ // Suite and discover run without a project configuration, but use its
556
+ // secrets: map when there is one, so flows can type values from a vault.
557
+ const flowSecrets = async (projectId: string) => {
558
+ const { secretMap } = await import("../src/runtime/secret-sources.js");
559
+ const { withFlowSecrets } = await import("../src/runtime/secret-driver.js");
560
+ let cfg: { secrets?: unknown; project?: { id?: string } } | null = null;
561
+ try {
562
+ if (fs.existsSync(path.resolve(workspaceRoot, configPath))) cfg = loadConfig(path.resolve(workspaceRoot, configPath));
563
+ } catch {
564
+ cfg = null;
565
+ }
566
+ const { map, problems } = secretMap(cfg);
567
+ for (const problem of problems) console.warn(`[Bugmole] ${problem}`);
568
+ const id = cfg?.project?.id ?? projectId;
569
+ return (driver: import("../src/runtime/driver.js").AppDriver) => withFlowSecrets(driver, { map, projectId: id });
570
+ };
571
+
451
572
  // A web suite is deterministic: it needs a running app and flow files, not
452
573
  // an LLM or a project configuration, so it runs in any workspace as-is.
453
574
  if (mode === "suite") {
454
575
  const { runWebSuite } = await import("../src/runtime/web-suite.js");
576
+ let configured: { project?: { id?: string }; runtime?: { spec_dir?: string } } | null = null;
577
+ try {
578
+ if (fs.existsSync(path.resolve(workspaceRoot, configPath))) configured = loadConfig(path.resolve(workspaceRoot, configPath));
579
+ } catch {
580
+ configured = null;
581
+ }
582
+ // Evidence goes under the project's ID, so reset finds it.
583
+ const suiteProject = typeof args.project === "string" ? args.project : configured?.project?.id ?? path.basename(workspaceRoot);
584
+ // The first flows folder that exists: spec/flows (where the docs put
585
+ // them), flows/, then .maestro.
586
+ const specFlows = path.join(configured?.runtime?.spec_dir ?? "spec", "flows");
587
+ const defaultFlows = [specFlows, "flows", ".maestro"].find((dir) => fs.existsSync(path.resolve(workspaceRoot, dir)));
588
+ if (typeof args.flows !== "string" && !defaultFlows) {
589
+ throw new Error(`No flows folder found (looked for ${specFlows}, flows and .maestro). Pass --flows <dir>.`);
590
+ }
455
591
  const { targetsFromFlags } = await import("../src/runtime/browser-matrix.js");
456
592
  const baseUrl = typeof args.baseurl === "string" ? args.baseurl : process.env.BUGMOLE_BASE_URL;
457
593
  if (!baseUrl) throw new Error("--base-url (or BUGMOLE_BASE_URL) is required for --mode suite");
458
594
  const report = await runWebSuite({
459
- flowsDir: path.resolve(workspaceRoot, typeof args.flows === "string" ? args.flows : ".maestro"),
595
+ flowsDir: path.resolve(workspaceRoot, typeof args.flows === "string" ? args.flows : defaultFlows!),
460
596
  baseUrl,
461
597
  artifactsDir: path.resolve(workspaceRoot, typeof args.artifactsdir === "string" ? args.artifactsdir : preferExisting(path.join(workspaceRoot, "qa/bugmole-runs"), path.join(workspaceRoot, "qa/valkyrie-runs"))),
462
- projectId: typeof args.project === "string" ? args.project : path.basename(workspaceRoot),
598
+ projectId: suiteProject,
463
599
  headless: args.headed !== true,
464
600
  timeoutMs: typeof args.timeout === "number" ? args.timeout : undefined,
465
601
  grep: typeof args.grep === "string" ? args.grep : undefined,
466
602
  shareSession: args.sharesession === true,
467
603
  targets: targetsFromFlags(args.browsers, args.devices),
604
+ wrapDriver: await flowSecrets(suiteProject),
468
605
  });
469
606
  const { total, passed, failed, blocked } = report.totals;
470
607
  console.log(`[Bugmole] Suite ${passed}/${total} passed (${failed} failed, ${blocked} blocked). Report: ${report.reportPath}`);
@@ -472,6 +609,81 @@ async function main() {
472
609
  return;
473
610
  }
474
611
 
612
+ // Explores a running app like a first-time visitor and writes a flow for
613
+ // every page and form it finds, ready for --mode suite. With --sign-in, that
614
+ // flow runs first and exploration continues in its session, which is
615
+ // deleted afterwards.
616
+ if (mode === "discover") {
617
+ const { discoverSite } = await import("../src/runtime/site-discovery.js");
618
+ const baseUrl = typeof args.baseurl === "string" ? args.baseurl : process.env.BUGMOLE_BASE_URL;
619
+ if (!baseUrl) throw new Error("--base-url (or BUGMOLE_BASE_URL) is required for --mode discover");
620
+ if (typeof args.out !== "string") throw new Error("--out <directory> is required for --mode discover");
621
+ const outDir = path.resolve(workspaceRoot, args.out);
622
+ const sessionDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "bugmole-discover-"));
623
+ try {
624
+ let storageState: string | undefined;
625
+ if (typeof args.signin === "string") {
626
+ const { PlaywrightDriver } = await import("../src/runtime/playwright-driver.js");
627
+ storageState = path.join(sessionDir, "session-state.json");
628
+ const projectId = typeof args.project === "string" ? args.project : path.basename(workspaceRoot);
629
+ const signIn = await (await flowSecrets(projectId))(new PlaywrightDriver({ headless: args.headed !== true })).run({
630
+ runId: `discover-${Date.now()}`,
631
+ projectId: typeof args.project === "string" ? args.project : path.basename(workspaceRoot),
632
+ journeyId: "sign-in",
633
+ flowPath: path.resolve(workspaceRoot, args.signin),
634
+ platform: "web",
635
+ target: { baseUrl },
636
+ rebaseOrigin: true,
637
+ timeoutMs: typeof args.timeout === "number" ? args.timeout : 30_000,
638
+ artifactsDir: path.join(sessionDir, "artifacts"),
639
+ sessionStatePath: storageState,
640
+ });
641
+ if (signIn.status !== "passed") throw new Error(`Sign-in flow did not pass: ${signIn.message}`);
642
+ console.log(`[Bugmole] Signed in with ${args.signin}`);
643
+ }
644
+ const result = await discoverSite(baseUrl, {
645
+ storageState,
646
+ openEachPage: args.openeachpage === true,
647
+ maxPages: typeof args.maxpages === "number" ? args.maxpages : undefined,
648
+ maxDepth: typeof args.maxdepth === "number" ? args.maxdepth : undefined,
649
+ headless: args.headed !== true,
650
+ onEvent: (event) => {
651
+ if (event.kind !== "click") console.log(`[discover] ${event.text}`);
652
+ },
653
+ });
654
+ await fs.promises.mkdir(outDir, { recursive: true });
655
+ for (const flow of result.flows) {
656
+ await fs.promises.writeFile(path.join(outDir, path.basename(flow.file)), flow.content, "utf8");
657
+ }
658
+ await fs.promises.writeFile(
659
+ path.join(outDir, "discovery.json"),
660
+ `${JSON.stringify({
661
+ baseUrl: result.baseUrl,
662
+ pages: result.pages.map(({ route, heading, title, path: via }) => ({ route, heading, title, via })),
663
+ limits: result.limits,
664
+ events: result.events,
665
+ }, null, 2)}\n`,
666
+ "utf8",
667
+ );
668
+ // Flows found signed in only pass signed in: put the sign-in flow first
669
+ // in the folder, so a suite with --share-session signs in, then runs them.
670
+ if (typeof args.signin === "string") {
671
+ await fs.promises.copyFile(path.resolve(workspaceRoot, args.signin), path.join(outDir, "00-sign-in.yaml"));
672
+ }
673
+ const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`;
674
+ const outName = path.relative(workspaceRoot, outDir) || ".";
675
+ console.log(`[Bugmole] Discovered ${plural(result.pages.length, "page")} and wrote ${plural(result.flows.length, "flow")} to ${outName}`);
676
+ if (result.flows.length) {
677
+ console.log(`[Bugmole] Run them: bugmole --mode suite --flows ${outName} --base-url ${baseUrl}${typeof args.signin === "string" ? " --share-session" : ""}`);
678
+ }
679
+ if (result.pages.length === 0) process.exitCode = 1;
680
+ } finally {
681
+ // Holds the app's cookies and tokens: never outlives the exploration.
682
+ await fs.promises.rm(sessionDir, { recursive: true, force: true });
683
+ }
684
+ return;
685
+ }
686
+
475
687
  const cfg = loadConfig(configPath);
476
688
 
477
689
  if (mode === "reset") {
@@ -489,7 +701,13 @@ async function main() {
489
701
  // Serve only needs the local runtime/storage and registry settings. Full
490
702
  // execution validation belongs to plan/run modes, which require LLM and
491
703
  // browser configuration that an idle worker does not use.
492
- if (mode !== "serve" && mode !== "artifacts" && mode !== "explore") {
704
+ if (mode === "secrets") {
705
+ const { runSecretsCommand } = await import("../src/runtime/secrets-cli.js");
706
+ process.exitCode = await runSecretsCommand(cfg, process.argv, { workspaceRoot });
707
+ return;
708
+ }
709
+
710
+ if (mode !== "serve" && mode !== "artifacts" && mode !== "explore" && mode !== "roles") {
493
711
  const { validateConfigOrThrow } = await import("../src/runtime/config-validate.js");
494
712
  validateConfigOrThrow(cfg);
495
713
  }
@@ -503,7 +721,36 @@ async function main() {
503
721
 
504
722
  // Dispatch modes
505
723
  switch (mode) {
724
+ case "roles": {
725
+ const { parseRolesCommand, runRolesCommand } = await import("../src/runtime/roles-cli.js");
726
+ const command = parseRolesCommand(process.argv, args);
727
+ if ("error" in command) {
728
+ console.error(command.error);
729
+ process.exit(2);
730
+ }
731
+ for (const line of await runRolesCommand(cfg, command)) console.log(`[Bugmole] ${line}`);
732
+ return;
733
+ }
734
+ case "propose": {
735
+ if (typeof args.proposefile !== "string") {
736
+ console.error("Usage: bugmole propose <changes.json>");
737
+ process.exit(2);
738
+ }
739
+ const { proposeFromFile } = await import("../src/runtime/propose-cli.js");
740
+ const proposals = await proposeFromFile(cfg, args.proposefile);
741
+ for (const proposal of proposals) {
742
+ const state = proposal.status === "pending" ? "waiting for a manager's review" : proposal.status;
743
+ console.log(`[Bugmole] ${proposal.action} ${proposal.caseSetId}/${proposal.caseId}: ${state} (${proposal.id})`);
744
+ }
745
+ return;
746
+ }
506
747
  case "serve": {
748
+ const approval = applyApprovalFlag(args);
749
+ if (approval === null) {
750
+ console.error("[Bugmole] --approval must be manual, auto or extreme");
751
+ process.exit(2);
752
+ }
753
+ if (approval) console.log(`[Bugmole] Approval: ${approval}. This worker waits for a person's approval on ${approval === "manual" ? "every task" : approval === "auto" ? "tasks that change code" : "nothing"}, whatever the project allows.`);
507
754
  const remoteStorage = Boolean(cfg.storage?.provider && cfg.storage.provider !== "local");
508
755
  let closeBridge: () => void = () => {};
509
756
  if (remoteStorage) {
@@ -542,7 +789,7 @@ async function main() {
542
789
  console.log(`[Bugmole] MCP will start on the configured HTTP port`);
543
790
  console.log(`[Bugmole] Capability expires at ${bridge.expiresAt}`);
544
791
  if (!process.env.BUGMOLE_AEGIS_EMAIL?.trim()) {
545
- console.warn("[Bugmole] No BUGMOLE_AEGIS_EMAIL found; enter your Aegis email on the login page to generate rid.");
792
+ console.warn("[Bugmole] Sign in with your Bugmole account email on the page that opens.");
546
793
  }
547
794
  console.log(
548
795
  `[Bugmole] Dashboard handoff: ${buildDashboardHandoffUrl(dashboardHandoff.toString(), cfg.project?.id ?? "default", fragment)}`,
@@ -651,7 +898,7 @@ async function startMcpServerForServe(cfg: any): Promise<void> {
651
898
 
652
899
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
653
900
  main().catch((err) => {
654
- console.error(`[Bugmole MCP] fatal: ${err?.message ?? err}`);
901
+ console.error(`[Bugmole] ${err?.message ?? err}`);
655
902
  process.exit(1);
656
903
  });
657
904
  }
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ // Downloads the browsers Bugmole runs tests in, since the `playwright` package
3
+ // installs only the library: without this, the first `bugmole run` or
4
+ // `bugmole serve` on a new machine stops with "Chromium is not installed".
5
+ // Runs as a postinstall step next to ensure-maestro.cjs, and like it never
6
+ // fails the parent `npm install` — this is best-effort.
7
+ //
8
+ // It uses the package's own Playwright, so the browsers always match the
9
+ // version Bugmole drives. Edge is left out: installing it needs admin rights
10
+ // (sudo on Linux, a system package on macOS), so it stays a one-line opt-in.
11
+ const { spawnSync } = require("node:child_process");
12
+ const path = require("node:path");
13
+
14
+ // Chromium, Firefox and WebKit (Safari) are the browsers a run targets by
15
+ // default; ffmpeg is what records a run's video.
16
+ const BROWSERS = ["chromium", "firefox", "webkit", "ffmpeg"];
17
+
18
+ function log(message) {
19
+ console.log(`[bugmole postinstall] ${message}`);
20
+ }
21
+
22
+ function skipReason(env = process.env) {
23
+ if (env.BUGMOLE_SKIP_PLAYWRIGHT_INSTALL === "true") return "BUGMOLE_SKIP_PLAYWRIGHT_INSTALL=true";
24
+ // A CI job that runs Bugmole's tests asks for the browsers explicitly.
25
+ if (env.BUGMOLE_PLAYWRIGHT_INSTALL === "true") return null;
26
+ // Playwright's own switch, which CI images and Docker builds already set.
27
+ if (env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD && env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD !== "0") return "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD is set";
28
+ // CI installs dependencies on every job; several hundred MB of browsers
29
+ // each time is wasted unless the job runs tests, and then it says so.
30
+ if (env.CI && env.CI !== "false" && env.CI !== "0") return "running in CI";
31
+ return null;
32
+ }
33
+
34
+ // Playwright doesn't export its CLI, so find it the way npm does: through
35
+ // the `bin` its package.json declares.
36
+ function playwrightCli() {
37
+ try {
38
+ const manifest = require.resolve("playwright/package.json");
39
+ const bin = require(manifest).bin;
40
+ const entry = typeof bin === "string" ? bin : bin && bin.playwright;
41
+ return entry ? path.join(path.dirname(manifest), entry) : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function main() {
48
+ const reason = skipReason();
49
+ if (reason) {
50
+ log(`Skipping Playwright browser install (${reason}). Set BUGMOLE_PLAYWRIGHT_INSTALL=true to install them anyway, or later: npx playwright install ${BROWSERS.join(" ")}`);
51
+ return;
52
+ }
53
+ const cli = playwrightCli();
54
+ if (!cli) {
55
+ log("Playwright is not installed next to Bugmole, so there are no browsers to fetch yet.");
56
+ return;
57
+ }
58
+ log(`Installing Playwright browsers (${BROWSERS.join(", ")}). Already-installed ones are skipped.`);
59
+ const install = spawnSync(process.execPath, [cli, "install", ...BROWSERS], {
60
+ stdio: "inherit",
61
+ timeout: 10 * 60 * 1000,
62
+ });
63
+ if (install.error || install.status !== 0) {
64
+ log(
65
+ "Automatic browser install failed. Runs report the missing browser until it is installed: " +
66
+ `npx playwright install ${BROWSERS.join(" ")}`,
67
+ );
68
+ if (process.platform === "linux") {
69
+ log("On Linux the browsers also need system libraries: sudo npx playwright install-deps");
70
+ }
71
+ return;
72
+ }
73
+ log("Playwright browsers are ready. For Edge as well: npx playwright install msedge (needs admin rights).");
74
+ if (process.platform === "linux") {
75
+ log("If a browser fails to start on Linux, install its system libraries: sudo npx playwright install-deps");
76
+ }
77
+ }
78
+
79
+ module.exports = { BROWSERS, playwrightCli, skipReason };
80
+
81
+ if (require.main === module) {
82
+ try {
83
+ main();
84
+ } catch (error) {
85
+ log(`Skipping Playwright browser install after unexpected error: ${error.message}`);
86
+ }
87
+ }
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import { createRequire } from "node:module";
3
+ import { readFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const require = createRequire(import.meta.url);
9
+ const { BROWSERS, playwrightCli, skipReason } = require("./ensure-playwright.cjs") as {
10
+ BROWSERS: string[];
11
+ playwrightCli: () => string | null;
12
+ skipReason: (env: Record<string, string | undefined>) => string | null;
13
+ };
14
+
15
+ test("installing the CLI fetches the browsers a run targets by default, plus ffmpeg for video", () => {
16
+ assert.deepEqual(BROWSERS, ["chromium", "firefox", "webkit", "ffmpeg"]);
17
+ assert.ok(!BROWSERS.includes("msedge"), "Edge needs admin rights, so it stays opt-in");
18
+ const pkg = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")) as { scripts: Record<string, string> };
19
+ assert.match(pkg.scripts.postinstall, /ensure-playwright\.cjs/);
20
+ });
21
+
22
+ test("the browser download is skipped in CI and when either skip switch is set", () => {
23
+ assert.equal(skipReason({}), null);
24
+ assert.equal(skipReason({ CI: "false" }), null);
25
+ assert.match(skipReason({ CI: "true" }) ?? "", /CI/);
26
+ assert.match(skipReason({ BUGMOLE_SKIP_PLAYWRIGHT_INSTALL: "true" }) ?? "", /BUGMOLE_SKIP_PLAYWRIGHT_INSTALL/);
27
+ assert.match(skipReason({ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" }) ?? "", /PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD/);
28
+ assert.equal(skipReason({ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "0" }), null);
29
+ assert.equal(skipReason({ CI: "true", BUGMOLE_PLAYWRIGHT_INSTALL: "true" }), null, "a CI job that runs tests can ask for the browsers");
30
+ assert.match(skipReason({ BUGMOLE_PLAYWRIGHT_INSTALL: "true", BUGMOLE_SKIP_PLAYWRIGHT_INSTALL: "true" }) ?? "", /SKIP/, "an explicit skip still wins");
31
+ });
32
+
33
+ test("finds Playwright's CLI even though the package doesn't export it", () => {
34
+ const cli = playwrightCli();
35
+ assert.ok(cli && cli.endsWith("cli.js"), `expected Playwright's cli.js, got ${cli}`);
36
+ });
@@ -0,0 +1,2 @@
1
+ export const byokCopy: string;
2
+ export const BYOK_HEADER: string;
@@ -0,0 +1,18 @@
1
+ // Vendors Arcana's bring-your-own-vault resolver (packages/byok/src/byok.ts
2
+ // in theaiinc/arcana) into src/vendor/byok.ts, verbatim under a header. It is
3
+ // one dependency-free file, so the CLI ships it without the Arcana app.
4
+ // ARCANA_DIR=~/arcana node scripts/sync-byok.mjs
5
+ import { readFile, writeFile } from "node:fs/promises";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+
9
+ const root = path.resolve(import.meta.dirname, "..");
10
+ export const byokCopy = path.join(root, "src/vendor/byok.ts");
11
+ export const BYOK_HEADER = "// VENDORED from theaiinc/arcana packages/byok/src/byok.ts by `node scripts/sync-byok.mjs` — change it there, then sync.\n";
12
+
13
+ if (import.meta.url === `file://${process.argv[1]}`) {
14
+ const arcana = process.env.ARCANA_DIR ?? path.join(os.homedir(), "arcana");
15
+ const source = await readFile(path.join(arcana, "packages/byok/src/byok.ts"), "utf8");
16
+ await writeFile(byokCopy, BYOK_HEADER + source);
17
+ console.log(`Wrote ${path.relative(root, byokCopy)} from ${arcana}`);
18
+ }