@bugmole/cli 0.5.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 (153) hide show
  1. package/package.json +5 -2
  2. package/scripts/bugmole-admin.mjs +194 -0
  3. package/scripts/bugmole-admin.test.mjs +62 -0
  4. package/scripts/bugmole.test.ts +94 -2
  5. package/scripts/bugmole.ts +233 -46
  6. package/scripts/sync-byok.d.mts +2 -0
  7. package/scripts/sync-byok.mjs +18 -0
  8. package/scripts/sync-plan-catalog.d.mts +3 -0
  9. package/scripts/sync-plan-catalog.mjs +16 -6
  10. package/spec/domain_rules.yaml +139 -0
  11. package/spec/roles.yaml +20 -0
  12. package/spec/test-case-results.schema.json +20 -4
  13. package/spec/test-cases.schema.json +131 -16
  14. package/src/billing/plan-catalog.test.ts +82 -2
  15. package/src/billing/plan-catalog.ts +212 -9
  16. package/src/integrations/jira.ts +264 -0
  17. package/src/mcp/roles-and-review.test.ts +111 -0
  18. package/src/mcp/server.ts +358 -22
  19. package/src/mcp/write-test-cases.test.ts +68 -0
  20. package/src/registry/control-plane-client.ts +121 -6
  21. package/src/registry/migrations/0036_task_approval.sql +11 -0
  22. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  23. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  24. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  25. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  26. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  27. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  28. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  29. package/src/registry/migrations/0044_project_identity.sql +31 -0
  30. package/src/registry/project-identity.test.ts +99 -0
  31. package/src/registry/project-identity.ts +206 -0
  32. package/src/registry/roles.test.ts +66 -0
  33. package/src/registry/roles.ts +201 -0
  34. package/src/registry/task-scheduling.test.ts +94 -0
  35. package/src/registry/task-scheduling.ts +199 -2
  36. package/src/registry/test-case-revisions.test.ts +57 -0
  37. package/src/registry/test-case-revisions.ts +132 -0
  38. package/src/registry-worker/ai/routes.ts +3 -3
  39. package/src/registry-worker/artifacts.ts +23 -4
  40. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  41. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  42. package/src/registry-worker/billing/enforcement.ts +67 -12
  43. package/src/registry-worker/billing/paypal/api.ts +14 -0
  44. package/src/registry-worker/billing/paypal/client.ts +9 -0
  45. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  46. package/src/registry-worker/billing/paypal.test.ts +108 -1
  47. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  48. package/src/registry-worker/billing/provider.ts +8 -0
  49. package/src/registry-worker/billing/routes.ts +2 -1
  50. package/src/registry-worker/billing/subscriptions.ts +114 -4
  51. package/src/registry-worker/core.ts +22 -0
  52. package/src/registry-worker/devices/policy.ts +4 -5
  53. package/src/registry-worker/devices/routes.ts +8 -8
  54. package/src/registry-worker/feature-access.ts +165 -0
  55. package/src/registry-worker/feature-flags/admin.ts +300 -0
  56. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  57. package/src/registry-worker/feature-flags/routes.ts +102 -0
  58. package/src/registry-worker/features.ts +9 -0
  59. package/src/registry-worker/feedback/routes.ts +2 -2
  60. package/src/registry-worker/flags.ts +80 -18
  61. package/src/registry-worker/github/checks.ts +4 -4
  62. package/src/registry-worker/hooks.ts +9 -0
  63. package/src/registry-worker/identity/oidc.test.ts +103 -0
  64. package/src/registry-worker/identity/oidc.ts +176 -0
  65. package/src/registry-worker/index.ts +477 -174
  66. package/src/registry-worker/jira/connection.ts +111 -0
  67. package/src/registry-worker/jira/jira.test.ts +404 -0
  68. package/src/registry-worker/jira/routes.ts +432 -0
  69. package/src/registry-worker/jira/workflow.ts +577 -0
  70. package/src/registry-worker/jobs/retention.ts +21 -5
  71. package/src/registry-worker/mcp/tools.ts +2 -1
  72. package/src/registry-worker/notifications/alerts.ts +4 -4
  73. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  74. package/src/registry-worker/notifications/routes.ts +9 -10
  75. package/src/registry-worker/notifications/teams.ts +6 -2
  76. package/src/registry-worker/org/routes.ts +4 -4
  77. package/src/registry-worker/projects/identity.ts +194 -0
  78. package/src/registry-worker/projects/inactivity.ts +162 -0
  79. package/src/registry-worker/projects/projects.test.ts +283 -0
  80. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  81. package/src/registry-worker/proposals/routes.ts +183 -0
  82. package/src/registry-worker/roles/roles.test.ts +84 -0
  83. package/src/registry-worker/roles/routes.ts +141 -0
  84. package/src/registry-worker/runner/dispatch.ts +1 -0
  85. package/src/registry-worker/runner/routes.ts +22 -5
  86. package/src/registry-worker/runner/runner.test.ts +21 -0
  87. package/src/registry-worker/runner/tokens.ts +8 -0
  88. package/src/registry-worker/secrets/crypto.ts +135 -0
  89. package/src/registry-worker/secrets/routes.ts +296 -0
  90. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  91. package/src/registry-worker/signup/policy.ts +2 -2
  92. package/src/registry-worker/signup/routes.ts +12 -3
  93. package/src/registry-worker/sso/membership.ts +4 -2
  94. package/src/registry-worker/sso/routes.ts +24 -8
  95. package/src/registry-worker/sso/sso.test.ts +3 -2
  96. package/src/registry-worker/task-approval.test.ts +67 -0
  97. package/src/registry-worker/task-resume.test.ts +196 -0
  98. package/src/registry-worker/task-review.test.ts +121 -0
  99. package/src/runtime/appium-driver.ts +1 -1
  100. package/src/runtime/apply-proposals.test.ts +74 -0
  101. package/src/runtime/apply-proposals.ts +41 -0
  102. package/src/runtime/cloud-secrets.test.ts +201 -0
  103. package/src/runtime/cloud-secrets.ts +210 -0
  104. package/src/runtime/config-validate.ts +12 -9
  105. package/src/runtime/cursor-driver-run.ts +14 -3
  106. package/src/runtime/discovery-task.test.ts +26 -1
  107. package/src/runtime/discovery-task.ts +67 -6
  108. package/src/runtime/executor.ts +18 -10
  109. package/src/runtime/explorer.test.ts +32 -0
  110. package/src/runtime/explorer.ts +48 -0
  111. package/src/runtime/flow-language.ts +22 -8
  112. package/src/runtime/init-wizard.ts +112 -6
  113. package/src/runtime/journey-editor.ts +39 -2
  114. package/src/runtime/journey-graph.test.ts +18 -0
  115. package/src/runtime/local-registry-stub.test.ts +64 -0
  116. package/src/runtime/local-registry-stub.ts +255 -7
  117. package/src/runtime/local-vault.ts +65 -0
  118. package/src/runtime/pipeline.test.ts +23 -0
  119. package/src/runtime/pipeline.ts +61 -20
  120. package/src/runtime/planner.test.ts +24 -1
  121. package/src/runtime/planner.ts +88 -23
  122. package/src/runtime/playwright-driver.test.ts +21 -2
  123. package/src/runtime/playwright-driver.ts +49 -7
  124. package/src/runtime/project-identity.test.ts +161 -0
  125. package/src/runtime/project-identity.ts +232 -0
  126. package/src/runtime/project-roles.test.ts +107 -0
  127. package/src/runtime/project-roles.ts +158 -0
  128. package/src/runtime/propose-cli.ts +66 -0
  129. package/src/runtime/record-run-verdicts.test.ts +58 -0
  130. package/src/runtime/record-run-verdicts.ts +38 -4
  131. package/src/runtime/reporter.ts +1 -1
  132. package/src/runtime/reset.ts +2 -0
  133. package/src/runtime/roles-cli.test.ts +47 -0
  134. package/src/runtime/roles-cli.ts +60 -0
  135. package/src/runtime/run-once.ts +68 -5
  136. package/src/runtime/scenario-matrix.test.ts +41 -0
  137. package/src/runtime/scenario-matrix.ts +98 -0
  138. package/src/runtime/secret-driver.ts +77 -0
  139. package/src/runtime/secret-redaction.ts +69 -0
  140. package/src/runtime/secret-sources.test.ts +442 -0
  141. package/src/runtime/secret-sources.ts +176 -0
  142. package/src/runtime/secrets-cli.ts +146 -0
  143. package/src/runtime/serve-worker.ts +64 -6
  144. package/src/runtime/site-discovery.test.ts +47 -4
  145. package/src/runtime/site-discovery.ts +79 -9
  146. package/src/runtime/vault-federation.test.ts +37 -0
  147. package/src/runtime/vault-federation.ts +128 -0
  148. package/src/runtime/web-suite.ts +5 -2
  149. package/src/storage/create-object-store.ts +5 -1
  150. package/src/storage/object-store.ts +12 -1
  151. package/src/storage/test-case-results.test.ts +64 -0
  152. package/src/storage/test-case-results.ts +49 -0
  153. 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"
@@ -25,10 +26,13 @@ type Mode =
25
26
  | "serve"
26
27
  | "gateway"
27
28
  | "run-once"
29
+ | "propose"
30
+ | "roles"
28
31
  | "reset"
29
32
  | "cursor-driver"
30
33
  | "suite"
31
- | "init-env";
34
+ | "init-env"
35
+ | "secrets";
32
36
 
33
37
  function readArg(name: string, argv = process.argv): string | undefined {
34
38
  const i = argv.indexOf(name);
@@ -45,23 +49,42 @@ export function parseArgs(argv = process.argv): Record<string, any> {
45
49
  if (positionalCommand === "gateway") args.gateway = true;
46
50
  if (positionalCommand === "run-once") args.runonce = true;
47
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
+ }
48
59
  for (let i = 0; i < argv.length; i++) {
49
60
  const arg = argv[i];
50
61
  if (arg.startsWith("--")) {
51
- const key = arg.slice(2).replace(/-/g, "");
62
+ const name = arg.slice(2);
63
+ const key = name.replace(/-/g, "");
52
64
  const next = argv[i + 1];
53
65
  const value = next && !next.startsWith("--") ? next : true;
54
66
  if (value !== true) i++;
55
- // Try to parse as number or boolean
56
- if (value === true || value === "true") args[key] = true;
57
- else if (value === "false") args[key] = false;
58
- else if (!isNaN(Number(value))) args[key] = Number(value);
59
- 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;
60
78
  }
61
79
  }
62
80
  return args;
63
81
  }
64
82
 
83
+ const NUMERIC_FLAGS = new Set([
84
+ "timeout", "maxpages", "maxdepth", "localregistryport", "artifactport", "gatewayport", "pollinterval",
85
+ "nudgeinterval", "nudgemaxnudges", "nudgeinactivitythreshold", "nudgeprogressreporttimeout",
86
+ ]);
87
+
65
88
  export function printHelp(init = false): void {
66
89
  if (init) {
67
90
  console.log(`Usage: bugmole init [options]
@@ -71,40 +94,87 @@ Initialize .bugmole/config.yaml and .bugmole.env without reading credentials.
71
94
  Options:
72
95
  --non-interactive Use defaults and never prompt
73
96
  --force Overwrite an existing configuration
74
- --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)
75
107
  --workspace-id <id> Workspace identifier
76
108
  --workspace-name <name> Workspace display name
77
109
  --app-id <id> Default app identifier
78
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)
79
113
  --config <path> Configuration output path
80
- --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.`);
81
119
  return;
82
120
  }
83
121
  console.log(`Usage: bugmole [command] [options]
84
122
 
85
123
  Commands:
86
124
  init Initialize a project configuration
87
- 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
88
129
  --mcp Start the MCP server
89
130
  artifacts serve Serve local artifacts to the dashboard over loopback
90
131
  --artifact-port <port> Loopback port (default: 3190)
91
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
92
144
  serve Start the local artifact bridge and HTTP MCP server
93
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).
94
148
  --dashboard-url <url> Dashboard URL for the handoff (overrides BUGMOLE_DASHBOARD_URL)
95
149
  --local-registry Stand in for the real registry locally (overrides BUGMOLE_LOCAL_REGISTRY)
96
150
  so queueing tasks/runs and this worker executing them works without
97
- 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.
98
152
  --local-registry-port Local registry stub port (default: 4600, or BUGMOLE_LOCAL_REGISTRY_PORT)
99
153
  gateway Multiplex project serve workers over loopback
100
154
  --gateway-port <port> Loopback port (default: 3191)
101
155
  --gateway-secret <value> Shared secret for worker registration
102
156
  reset Delete current-project execution data after confirmation
157
+ --yes Skip the typed confirmation (scripts and CI)
103
158
  run-once Execute one Bugmole Cloud run and exit (used inside cloud runners;
104
159
  reads BUGMOLE_REGISTRY_URL, BUGMOLE_RUN_ID, BUGMOLE_RUN_TOKEN)
105
- --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
106
176
  --mode suite Run a directory of web flows against a running app
107
- --flows <dir> Flow directory (default: .maestro)
177
+ --flows <dir> Flow directory (default: spec/flows, then flows, then .maestro)
108
178
  --base-url <url> Application base URL (or BUGMOLE_BASE_URL)
109
179
  --artifacts-dir <dir> Evidence directory (default: qa/bugmole-runs)
110
180
  --project <id> Project id used in the evidence path
@@ -129,6 +199,9 @@ export function resolveMode(args: Record<string, any>): Mode {
129
199
  if (args.gateway === true) return "gateway";
130
200
  if (args.runonce === true) return "run-once";
131
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";
132
205
  if (args.artifacts === true) return "artifacts";
133
206
  return (args.mode ?? "mcp") as Mode;
134
207
  }
@@ -138,9 +211,10 @@ export function resolveMode(args: Record<string, any>): Mode {
138
211
  // step needs it to fail the build, so `--mode run` is the one mode whose exit code
139
212
  // reflects the result rather than just whether the process threw.
140
213
  export function runModeExitCode(mode: string, result: unknown): number {
141
- if (mode !== "run") return 0;
142
214
  const status = result && typeof result === "object" ? (result as { status?: unknown }).status : undefined;
143
- 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;
144
218
  }
145
219
 
146
220
  export function shouldOpenServeBrowser(
@@ -210,6 +284,19 @@ export function resolveLocalRegistryEnabled(args: Record<string, any>): boolean
210
284
  // every fetch with "bad port" — 4190 is the reserved ManageSieve port).
211
285
  const DEFAULT_LOCAL_REGISTRY_PORT = 4600;
212
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
+
213
300
  export function resolveLocalRegistryPort(args: Record<string, any>): number {
214
301
  if (typeof args.localregistryport === "number") return args.localregistryport;
215
302
  const fromEnv = process.env.BUGMOLE_LOCAL_REGISTRY_PORT?.trim();
@@ -352,6 +439,13 @@ async function main() {
352
439
  if (positionalCommand === "artifacts") args.artifacts = true;
353
440
  if (positionalCommand === "serve") args.serve = true;
354
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
+ }
355
449
  // quiet: dotenv 17 prints an "injected env" banner on every load, which
356
450
  // would land in the middle of this CLI's own output.
357
451
  dotenv.config({ path: preferExisting(".bugmole.env", ".valk.env"), quiet: true });
@@ -385,29 +479,38 @@ async function main() {
385
479
 
386
480
  // Init must run from the caller's workspace, not the package directory.
387
481
  if (resolveMode(args) === "init") {
388
- const { runInitWizard } = await import("../src/runtime/init-wizard.js");
389
- await runInitWizard({
390
- nonInteractive: args.noninteractive === true,
391
- force: args.force === true,
392
- configPath: args.config,
393
- workspaceId: args.workspaceid,
394
- workspaceName: args.workspacename,
395
- projectId: args.projectid,
396
- projectName: args.projectname,
397
- appId: args.appid,
398
- appName: args.appname,
399
- environment: args.environment,
400
- specDir: args.specdir,
401
- evidenceManifest: args.evidencemanifest,
402
- evidenceScreenshots: args.evidencescreenshots,
403
- artifactsDir: args.artifactsdir,
404
- storageProvider: args.storageprovider,
405
- bucket: args.bucket,
406
- region: args.region,
407
- endpoint: args.endpoint,
408
- cloudProjectId: args.cloudprojectid,
409
- accountId: args.accountid,
410
- });
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
+ }
411
514
  return;
412
515
  }
413
516
 
@@ -449,23 +552,56 @@ async function main() {
449
552
  return;
450
553
  }
451
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
+
452
572
  // A web suite is deterministic: it needs a running app and flow files, not
453
573
  // an LLM or a project configuration, so it runs in any workspace as-is.
454
574
  if (mode === "suite") {
455
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
+ }
456
591
  const { targetsFromFlags } = await import("../src/runtime/browser-matrix.js");
457
592
  const baseUrl = typeof args.baseurl === "string" ? args.baseurl : process.env.BUGMOLE_BASE_URL;
458
593
  if (!baseUrl) throw new Error("--base-url (or BUGMOLE_BASE_URL) is required for --mode suite");
459
594
  const report = await runWebSuite({
460
- flowsDir: path.resolve(workspaceRoot, typeof args.flows === "string" ? args.flows : ".maestro"),
595
+ flowsDir: path.resolve(workspaceRoot, typeof args.flows === "string" ? args.flows : defaultFlows!),
461
596
  baseUrl,
462
597
  artifactsDir: path.resolve(workspaceRoot, typeof args.artifactsdir === "string" ? args.artifactsdir : preferExisting(path.join(workspaceRoot, "qa/bugmole-runs"), path.join(workspaceRoot, "qa/valkyrie-runs"))),
463
- projectId: typeof args.project === "string" ? args.project : path.basename(workspaceRoot),
598
+ projectId: suiteProject,
464
599
  headless: args.headed !== true,
465
600
  timeoutMs: typeof args.timeout === "number" ? args.timeout : undefined,
466
601
  grep: typeof args.grep === "string" ? args.grep : undefined,
467
602
  shareSession: args.sharesession === true,
468
603
  targets: targetsFromFlags(args.browsers, args.devices),
604
+ wrapDriver: await flowSecrets(suiteProject),
469
605
  });
470
606
  const { total, passed, failed, blocked } = report.totals;
471
607
  console.log(`[Bugmole] Suite ${passed}/${total} passed (${failed} failed, ${blocked} blocked). Report: ${report.reportPath}`);
@@ -489,7 +625,8 @@ async function main() {
489
625
  if (typeof args.signin === "string") {
490
626
  const { PlaywrightDriver } = await import("../src/runtime/playwright-driver.js");
491
627
  storageState = path.join(sessionDir, "session-state.json");
492
- const signIn = await new PlaywrightDriver({ headless: args.headed !== true }).run({
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({
493
630
  runId: `discover-${Date.now()}`,
494
631
  projectId: typeof args.project === "string" ? args.project : path.basename(workspaceRoot),
495
632
  journeyId: "sign-in",
@@ -520,10 +657,25 @@ async function main() {
520
657
  }
521
658
  await fs.promises.writeFile(
522
659
  path.join(outDir, "discovery.json"),
523
- `${JSON.stringify({ baseUrl: result.baseUrl, pages: result.pages.map(({ route, heading, title, path: via }) => ({ route, heading, title, via })), events: result.events }, null, 2)}\n`,
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`,
524
666
  "utf8",
525
667
  );
526
- console.log(`[Bugmole] Discovered ${result.pages.length} pages and wrote ${result.flows.length} flows to ${path.relative(workspaceRoot, outDir) || "."}`);
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
+ }
527
679
  if (result.pages.length === 0) process.exitCode = 1;
528
680
  } finally {
529
681
  // Holds the app's cookies and tokens: never outlives the exploration.
@@ -549,7 +701,13 @@ async function main() {
549
701
  // Serve only needs the local runtime/storage and registry settings. Full
550
702
  // execution validation belongs to plan/run modes, which require LLM and
551
703
  // browser configuration that an idle worker does not use.
552
- 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") {
553
711
  const { validateConfigOrThrow } = await import("../src/runtime/config-validate.js");
554
712
  validateConfigOrThrow(cfg);
555
713
  }
@@ -563,7 +721,36 @@ async function main() {
563
721
 
564
722
  // Dispatch modes
565
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
+ }
566
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.`);
567
754
  const remoteStorage = Boolean(cfg.storage?.provider && cfg.storage.provider !== "local");
568
755
  let closeBridge: () => void = () => {};
569
756
  if (remoteStorage) {
@@ -602,7 +789,7 @@ async function main() {
602
789
  console.log(`[Bugmole] MCP will start on the configured HTTP port`);
603
790
  console.log(`[Bugmole] Capability expires at ${bridge.expiresAt}`);
604
791
  if (!process.env.BUGMOLE_AEGIS_EMAIL?.trim()) {
605
- 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.");
606
793
  }
607
794
  console.log(
608
795
  `[Bugmole] Dashboard handoff: ${buildDashboardHandoffUrl(dashboardHandoff.toString(), cfg.project?.id ?? "default", fragment)}`,
@@ -711,7 +898,7 @@ async function startMcpServerForServe(cfg: any): Promise<void> {
711
898
 
712
899
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
713
900
  main().catch((err) => {
714
- console.error(`[Bugmole MCP] fatal: ${err?.message ?? err}`);
901
+ console.error(`[Bugmole] ${err?.message ?? err}`);
715
902
  process.exit(1);
716
903
  });
717
904
  }
@@ -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
+ }
@@ -1,3 +1,6 @@
1
1
  export const catalogSource: string;
2
2
  export const catalogCopy: string;
3
3
  export const COPY_HEADER: string;
4
+ export const scenarioSource: string;
5
+ export const scenarioCopy: string;
6
+ export const SCENARIO_COPY_HEADER: string;
@@ -1,6 +1,7 @@
1
- // Copies the plan catalog into the dashboard, which deliberately builds
2
- // without importing files from outside apps/qa. Run after editing
3
- // src/billing/plan-catalog.ts; the root test suite fails if the copy drifts.
1
+ // Copies files the dashboard shares with the CLI and registry into
2
+ // apps/qa, which deliberately builds without importing files from outside
3
+ // it. Run after editing a source below; the root test suite fails if a copy
4
+ // drifts.
4
5
  import { readFile, writeFile } from "node:fs/promises";
5
6
  import path from "node:path";
6
7
 
@@ -8,9 +9,18 @@ const root = path.resolve(import.meta.dirname, "..");
8
9
  export const catalogSource = path.join(root, "src/billing/plan-catalog.ts");
9
10
  export const catalogCopy = path.join(root, "apps/qa/src/lib/plan-catalog.ts");
10
11
  export const COPY_HEADER = "// GENERATED by `npm run sync:catalog` from src/billing/plan-catalog.ts — edit that file instead.\n";
12
+ export const scenarioSource = path.join(root, "src/runtime/scenario-matrix.ts");
13
+ export const scenarioCopy = path.join(root, "apps/qa/src/lib/scenario-matrix.ts");
14
+ export const SCENARIO_COPY_HEADER = "// GENERATED by `npm run sync:catalog` from src/runtime/scenario-matrix.ts — edit that file instead.\n";
15
+
16
+ const COPIES = [
17
+ [catalogSource, catalogCopy, COPY_HEADER],
18
+ [scenarioSource, scenarioCopy, SCENARIO_COPY_HEADER],
19
+ ];
11
20
 
12
21
  if (import.meta.url === `file://${process.argv[1]}`) {
13
- const source = await readFile(catalogSource, "utf8");
14
- await writeFile(catalogCopy, COPY_HEADER + source);
15
- console.log(`Wrote ${path.relative(root, catalogCopy)}`);
22
+ for (const [source, copy, header] of COPIES) {
23
+ await writeFile(copy, header + await readFile(source, "utf8"));
24
+ console.log(`Wrote ${path.relative(root, copy)}`);
25
+ }
16
26
  }