@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
@@ -6,9 +6,10 @@ import { createProjectObjectStore } from "../storage/create-object-store.js";
6
6
  import { projectPrefix } from "../storage/keys.js";
7
7
  import { readJourneyGraph, writeJourneyGraph } from "./journey-editor.js";
8
8
  import type { JourneyEdge, JourneyNode, UserJourney } from "./journey-graph.js";
9
- import { discoverSite, stoppedAtSignIn, type DiscoveryResult } from "./site-discovery.js";
9
+ import { discoverSite, stoppedAtSignIn, type DiscoveryLimits, type DiscoveryResult } from "./site-discovery.js";
10
10
  import { explorationPrompt, parseCheckedExploration, type ExplorationPersona } from "./ai-exploration.js";
11
11
  import { modelCaller } from "./platform-ai.js";
12
+ import { proposeRoleSuggestions } from "./project-roles.js";
12
13
 
13
14
  /** Discovery tasks queued as "Explore with AI …" also have a model write multi-page journeys. */
14
15
  export function discoveryUsesAi(task: Pick<RegistryTask, "title">): boolean {
@@ -69,6 +70,8 @@ export type DiscoveryProgress = {
69
70
  events: DiscoveryResult["events"];
70
71
  pages: Array<{ route: string; title: string; heading?: string; forms: number }>;
71
72
  flows: Array<{ id: string; name: string; kind: "page" | "form" | "journey"; route: string; steps: string[]; planId?: string }>;
73
+ /** Present once the crawl has finished: the bounds it ran with and what they left out. */
74
+ limits?: Omit<DiscoveryLimits, "notFollowed"> & { notFollowed: number; notFollowedRoutes: string[] };
72
75
  };
73
76
 
74
77
  export function progressOf(url: string, result: DiscoveryResult, planIds: Record<string, string> = {}): DiscoveryProgress {
@@ -79,6 +82,18 @@ export function progressOf(url: string, result: DiscoveryResult, planIds: Record
79
82
  events: result.events.slice(-40),
80
83
  pages: result.pages.map((page) => ({ route: page.route, title: page.title, ...(page.heading ? { heading: page.heading } : {}), forms: page.forms.length })),
81
84
  flows: result.flows.map((flow) => ({ id: flow.id, name: flow.name, kind: flow.kind, route: flow.route, steps: flow.steps, ...(planIds[flow.id] ? { planId: planIds[flow.id] } : {}) })),
85
+ ...(result.limits
86
+ ? {
87
+ limits: {
88
+ maxPages: result.limits.maxPages,
89
+ maxDepth: result.limits.maxDepth,
90
+ stoppedAtPageLimit: result.limits.stoppedAtPageLimit,
91
+ notFollowed: result.limits.notFollowed.length,
92
+ // A sample is enough to show which; every update carries it.
93
+ notFollowedRoutes: result.limits.notFollowed.slice(0, 20),
94
+ },
95
+ }
96
+ : {}),
82
97
  };
83
98
  }
84
99
 
@@ -191,7 +206,7 @@ export async function saveDiscovery(cfg: WorkerConfig, client: Pick<ControlPlane
191
206
  */
192
207
  export async function runDiscoveryTask(
193
208
  cfg: WorkerConfig,
194
- client: Pick<ControlPlaneClient, "claimTask" | "updateTask" | "publishPlan">,
209
+ client: Pick<ControlPlaneClient, "claimTask" | "updateTask" | "publishPlan"> & Partial<Pick<ControlPlaneClient, "proposeRole">>,
195
210
  task: RegistryTask,
196
211
  workerId: string,
197
212
  discover: typeof discoverSite = discoverSite,
@@ -200,6 +215,16 @@ export async function runDiscoveryTask(
200
215
  claimed?: boolean;
201
216
  /** Called once if the registry answers an update with `stop`. */
202
217
  onStop?: (signal: { reason: string; message: string }) => void;
218
+ /**
219
+ * Signs in before exploring (a cloud runner with the project's sign-in
220
+ * flow) and returns the saved browser session to explore in.
221
+ */
222
+ signIn?: (url: string) => Promise<string>;
223
+ /**
224
+ * How far to explore: the workspace plan's bounds on Bugmole Cloud, the
225
+ * machine's own settings on a project worker. Discovery's defaults otherwise.
226
+ */
227
+ limits?: { maxPages?: number; maxDepth?: number };
203
228
  } = {},
204
229
  ): Promise<void> {
205
230
  const url = discoveryUrl(task);
@@ -242,12 +267,27 @@ export async function runDiscoveryTask(
242
267
  heartbeat.unref?.();
243
268
  try {
244
269
  await send("running");
270
+ // Sign-in happens before discovery has a result to report into, so its
271
+ // events are kept here and put in front of discovery's own.
272
+ const before: DiscoveryResult["events"] = [];
273
+ let storageState: string | undefined;
274
+ if (options.signIn) {
275
+ before.push({ at: new Date().toISOString(), kind: "visit", text: "Signing in with the project's sign-in flow…" });
276
+ latest = progressOf(url, { baseUrl: url, pages: [], flows: [], events: before });
277
+ await send("running");
278
+ storageState = await options.signIn(url);
279
+ before.push({ at: new Date().toISOString(), kind: "visit", text: "Signed in; exploring as a signed-in user" });
280
+ }
245
281
  const result = await discover(url, {
282
+ ...(storageState ? { storageState } : {}),
283
+ ...(options.limits?.maxPages ? { maxPages: options.limits.maxPages } : {}),
284
+ ...(options.limits?.maxDepth ? { maxDepth: options.limits.maxDepth } : {}),
246
285
  onEvent: async (_event, snapshot) => {
247
- latest = progressOf(url, snapshot);
286
+ latest = progressOf(url, { ...snapshot, events: [...before, ...snapshot.events] });
248
287
  await send("running");
249
288
  },
250
289
  });
290
+ result.events.unshift(...before);
251
291
  // Everything reachable was a sign-in screen. Say so plainly, and don't
252
292
  // ask AI to imagine the app behind it: that spends credits on journeys
253
293
  // through pages nobody has seen.
@@ -256,11 +296,29 @@ export async function runDiscoveryTask(
256
296
  result.events.push({
257
297
  at: new Date().toISOString(),
258
298
  kind: "skip",
259
- text: "Stopped at a sign-in page: Bugmole Cloud can't sign in to your app yet, so everything behind it is unexplored. "
260
- + "To explore signed in, run discovery from your machine or CI with a sign-in flow: bugmole --mode discover --sign-in <flow>.",
299
+ text: storageState
300
+ ? "Stopped at a sign-in page even after the sign-in flow passed, so everything behind it is unexplored. "
301
+ + "Check that the sign-in flow ends signed in (assert something only a signed-in user sees)."
302
+ : "Stopped at a sign-in page, so everything behind it is unexplored. To explore signed in, add a sign-in flow and its secrets "
303
+ + "under Settings → Sign-in for cloud runs, or run discovery from your machine or CI: bugmole --mode discover --sign-in <flow>.",
261
304
  });
262
305
  latest = progressOf(url, result);
263
306
  await send("running");
307
+ // A sign-in wall says the app has signed-out and signed-in people:
308
+ // suggest both roles for a person to confirm on the Roles page.
309
+ if (client.proposeRole && !storageState) {
310
+ const evidence = `Exploring ${url} reached only a sign-in page`;
311
+ const proposed = await proposeRoleSuggestions([
312
+ { name: "guest", description: "Visitor who is not signed in and only sees public screens.", source: "sign_in_wall", evidence },
313
+ { name: "member", description: "Signed-in user who gets past the sign-in wall.", source: "sign_in_wall", evidence },
314
+ ], { proposeRole: client.proposeRole.bind(client) }).catch(() => []);
315
+ const sent = proposed.filter((role) => role.status === "suggested" && !role.alreadyDecided).map((role) => role.name);
316
+ if (sent.length) {
317
+ result.events.push({ at: new Date().toISOString(), kind: "flows", text: `Suggested the roles ${sent.join(" and ")} for you to confirm under Settings → Roles` });
318
+ latest = progressOf(url, result);
319
+ await send("running");
320
+ }
321
+ }
264
322
  }
265
323
  if (discoveryUsesAi(task) && !signInWall) {
266
324
  const caller = modelCaller(cfg, { projectId: cfg.project?.id ?? "default", requestId: task.id });
@@ -292,7 +350,10 @@ export async function runDiscoveryTask(
292
350
  latest = progressOf(url, result, planIds);
293
351
  await send("completed", {
294
352
  completionSummary: `Explored ${result.pages.length} page(s) of ${url} and discovered ${result.flows.length} flow(s).`
295
- + (signInWall ? " Stopped at a sign-in page; the app behind it is unexplored." : ""),
353
+ + (signInWall ? " Stopped at a sign-in page; the app behind it is unexplored." : "")
354
+ + (result.limits?.notFollowed.length
355
+ ? ` ${result.limits.notFollowed.length} linked page(s) were not opened (limit: ${result.limits.maxPages} pages, ${result.limits.maxDepth} clicks deep).`
356
+ : ""),
296
357
  });
297
358
  } catch (error) {
298
359
  await send("error", { errorMessage: error instanceof Error ? error.message : String(error) });
@@ -11,6 +11,8 @@ import { journeyScreenshotKey } from "./journey-evidence.js";
11
11
  import type { AppDriver, DriverPlatform } from "./driver.js";
12
12
  import { MaestroDriver } from "./maestro-driver.js";
13
13
  import { PlaywrightDriver } from "./playwright-driver.js";
14
+ import { withFlowSecrets } from "./secret-driver.js";
15
+ import { secretMap } from "./secret-sources.js";
14
16
  import { recoverDriverFailure } from "./driver-recovery.js";
15
17
  import { startAndroidScreenRecording } from "./android-screen-record.js";
16
18
  import { startIosScreenRecording } from "./ios-screen-record.js";
@@ -115,16 +117,27 @@ export interface ExecutionResult {
115
117
  }
116
118
 
117
119
  function configuredDriver(cfg: any, driverId: string): AppDriver {
120
+ // Flows type {{secret.NAME}} from wherever the project keeps it (its
121
+ // config's secrets: map, or this machine's keychain).
122
+ const secrets = (driver: AppDriver) => {
123
+ const { map } = secretMap(cfg);
124
+ return withFlowSecrets(driver, {
125
+ map,
126
+ projectId: cfg.project?.id ?? "default",
127
+ // A cloud runner has the project's released secrets already; it has no keychain.
128
+ localFallback: !process.env.BUGMOLE_RUN_TOKEN,
129
+ });
130
+ };
118
131
  if (driverId === "maestro") {
119
- return new MaestroDriver({
132
+ return secrets(new MaestroDriver({
120
133
  command: cfg.execution?.maestro?.command,
121
134
  extraArgs: Array.isArray(cfg.execution?.maestro?.args) ? cfg.execution.maestro.args : undefined,
122
- });
135
+ }));
123
136
  }
124
137
  if (driverId === "playwright") {
125
- return new PlaywrightDriver({
138
+ return secrets(new PlaywrightDriver({
126
139
  headless: cfg.execution?.playwright?.headless,
127
- });
140
+ }));
128
141
  }
129
142
  throw new Error(`No app driver adapter is registered for "${driverId}"`);
130
143
  }
@@ -400,12 +413,7 @@ export async function execute(cfg: any, runOptions: RunOptions): Promise<Executi
400
413
  stepsPassed: 0,
401
414
  stepsFailed: 0,
402
415
  error:
403
- `Plan not found: ${planPath}\n` +
404
- `Plan ID provided: ${planId}\n` +
405
- `Spec dir: ${specDir}\n` +
406
- `Plans dir: ${plansDir}\n` +
407
- `Current working directory: ${process.cwd()}\n` +
408
- `Environment variables: CURSOR_PROJECT_DIRECTORY=${process.env.CURSOR_PROJECT_DIRECTORY || "not set"}, BUGMOLE_PROJECT_DIR=${process.env.BUGMOLE_PROJECT_DIR || "not set"}`,
416
+ `No plan "${planId}" at ${planPath}. Create it with bugmole_plan (or bugmole --mode plan), or check the plan ID and that Bugmole runs in your project folder (now ${process.cwd()}).`,
409
417
  };
410
418
  }
411
419
 
@@ -99,3 +99,35 @@ test("manage hub links resolve against optional catch-all resource routes", asyn
99
99
  assert.ok(targets.has(route), `expected /manage to link to ${route}`);
100
100
  }
101
101
  });
102
+
103
+ test("an app with no route files is explored by crawling it, and recorded transitions survive re-exploring", async () => {
104
+ const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-explorer-plain-"));
105
+ await fs.writeFile(path.join(projectDir, "server.mjs"), "// a plain http server\n");
106
+ const specDir = path.join(projectDir, "spec");
107
+ const cfg = {
108
+ runtime: { spec_dir: specDir, artifacts_dir: path.join(projectDir, "runs") },
109
+ cursor_driver: { project_directory: projectDir },
110
+ project: { id: "plain" },
111
+ };
112
+ const page = (route: string, title: string, links: Array<{ label: string; route: string }>) => ({ route, url: `http://127.0.0.1:1${route}`, title, links });
113
+ const discover = async () => ({
114
+ pages: [
115
+ page("/", "Home", [{ label: "Pricing", route: "/pricing" }, { label: "Sign up", route: "/signup" }]),
116
+ page("/pricing", "Pricing", [{ label: "Home", route: "/" }]),
117
+ page("/signup", "Sign up", []),
118
+ ],
119
+ });
120
+ const first = await explore(cfg, { baseUrl: "http://127.0.0.1:1", captureScreenshots: false, discover });
121
+ assert.equal(first.status, "success");
122
+ let graph = JSON.parse(await fs.readFile(path.join(specDir, "journeys.graph.json"), "utf-8"));
123
+ assert.deepEqual(graph.nodes.map((node: { route: string }) => node.route).sort(), ["/", "/pricing", "/signup"]);
124
+ assert.ok(graph.edges.some((edge: { from: string; to: string; label?: string }) => edge.from === "screen:/" && edge.to === "screen:/signup" && edge.label === "Sign up"));
125
+
126
+ // A transition an agent recorded by hand, for its own journey.
127
+ graph.edges.push({ id: "screen:/signup->screen:/:manual", from: "screen:/signup", to: "screen:/", label: "Back home" });
128
+ graph.journeys.push({ id: "manual", name: "Manual", actor: "visitor", nodeIds: ["screen:/signup", "screen:/"], edgeIds: ["screen:/signup->screen:/:manual"], revision: 1, status: "draft", startNodeId: "screen:/signup" });
129
+ await fs.writeFile(path.join(specDir, "journeys.graph.json"), JSON.stringify(graph));
130
+ await explore(cfg, { baseUrl: "http://127.0.0.1:1", captureScreenshots: false, discover });
131
+ graph = JSON.parse(await fs.readFile(path.join(specDir, "journeys.graph.json"), "utf-8"));
132
+ assert.ok(graph.edges.some((edge: { id: string }) => edge.id === "screen:/signup->screen:/:manual"));
133
+ });
@@ -21,6 +21,10 @@ interface ExploreOptions {
21
21
  accessMethod?: "configured" | "handoff" | "manual" | "unknown";
22
22
  journeyId?: string;
23
23
  actor?: string;
24
+ /** false: never crawl the running app when the source has no route files. */
25
+ crawl?: boolean;
26
+ /** The crawler, replaceable in tests. */
27
+ discover?: (baseUrl: string, options: { maxPages?: number; maxDepth?: number }) => Promise<{ pages: Array<{ route: string; url: string; title: string; heading?: string; links: Array<{ label: string; route: string }> }> }>;
24
28
  }
25
29
 
26
30
  interface ExploreResult {
@@ -572,10 +576,43 @@ export async function explore(cfg: any, options: ExploreOptions): Promise<Explor
572
576
  sourcePaths: entry.source ? [entry.source] : [],
573
577
  });
574
578
  }
579
+ // No route files (Express, Fastify, a plain http server, a framework the
580
+ // walk doesn't know): learn the screens from the running app instead,
581
+ // by clicking through it the way site discovery does.
582
+ const crawledEdges: any[] = [];
583
+ if (discoveredNodes.size === 0 && options.crawl !== false) {
584
+ const { discoverSite } = await import("./site-discovery.js");
585
+ const site = await (options.discover ?? discoverSite)(baseUrl, { maxPages: 30, maxDepth: 3 });
586
+ for (const page of site.pages) {
587
+ const route = normalizeRoute(page.route) || "/";
588
+ const nodeId = `screen:${route}`;
589
+ if (discoveredNodes.has(nodeId)) continue;
590
+ discoveredNodes.set(nodeId, {
591
+ id: nodeId,
592
+ title: page.heading || page.title || semanticScreenTitle(route),
593
+ kind: "screen",
594
+ signature: `${projectRoot}@route:${route}`,
595
+ route,
596
+ source: "running app",
597
+ sourcePaths: [],
598
+ runtimeProbe: { status: "reachable", url: page.url },
599
+ metadata: { discoveredBy: "site-discovery" },
600
+ });
601
+ }
602
+ for (const page of site.pages) {
603
+ const from = `screen:${normalizeRoute(page.route) || "/"}`;
604
+ for (const [index, link] of page.links.entries()) {
605
+ const to = `screen:${normalizeRoute(link.route) || "/"}`;
606
+ if (to === from || !discoveredNodes.has(to)) continue;
607
+ crawledEdges.push({ id: `${from}->${to}:crawl-${index}`, from, to, label: link.label, action: `navigate:${normalizeRoute(link.route) || "/"}`, source: "running app" });
608
+ }
609
+ }
610
+ }
575
611
  graph.nodes = [...discoveredNodes.values()];
576
612
  // Resolve links after all route files are known so source order cannot
577
613
  // make a valid link look unresolved.
578
614
  const routeNodes = graph.nodes as any[];
615
+ const previousEdges: any[] = Array.isArray(graph.edges) ? graph.edges : [];
579
616
  graph.edges = [];
580
617
  for (const filePath of sourceFiles) {
581
618
  const relativePath = path.relative(projectRoot, filePath);
@@ -619,6 +656,17 @@ export async function explore(cfg: any, options: ExploreOptions): Promise<Explor
619
656
  });
620
657
  }
621
658
  }
659
+ for (const edge of crawledEdges) {
660
+ if (!graph.edges.some((existing: any) => existing.from === edge.from && existing.to === edge.to)) graph.edges.push(edge);
661
+ }
662
+ // Transitions a person or an agent recorded for a journey aren't in the
663
+ // source or the crawl: keep them while both of their screens still exist,
664
+ // or rebuilding the sitemap breaks those journeys.
665
+ const recorded = new Set(existingJourneys.flatMap((journey: any) => Array.isArray(journey.edgeIds) ? journey.edgeIds : []));
666
+ for (const edge of previousEdges) {
667
+ if (!recorded.has(edge?.id) || graph.edges.some((existing: any) => existing.id === edge.id)) continue;
668
+ if (discoveredNodes.has(edge.from) && discoveredNodes.has(edge.to)) graph.edges.push(edge);
669
+ }
622
670
  for (const [index, link] of (routesManifest.links ?? []).entries()) {
623
671
  if (!link?.from || !link?.to) continue;
624
672
  const fromNode = routeNodes.find((candidate: any) =>
@@ -1,4 +1,5 @@
1
1
  import YAML from "yaml";
2
+ import { registerSecretValue } from "./secret-redaction.js";
2
3
 
3
4
  /**
4
5
  * The Maestro-style flow subset this project's web plans author.
@@ -310,6 +311,13 @@ export type InterpolateOptions = {
310
311
  env?: Readonly<Record<string, string | undefined>>;
311
312
  };
312
313
 
314
+ /** The secret names a flow's text references as `{{secret.NAME}}`, sorted and unique. */
315
+ export function referencedSecretNames(content: string): string[] {
316
+ const names = new Set<string>();
317
+ for (const match of content.matchAll(/\{\{\s*secret\.([a-zA-Z0-9_]+)\s*\}\}/g)) names.add(match[1]);
318
+ return [...names].sort();
319
+ }
320
+
313
321
  /**
314
322
  * Substitutes `{{name}}` tokens with a value bound earlier in the same flow
315
323
  * (currently, only requestTempEmail binds one). Plain text with no template
@@ -335,6 +343,8 @@ export function interpolateTemplate(
335
343
  if (value === undefined || value === "") {
336
344
  throw new Error(`Flow references "{{secret.${name}}}", but ${variable} is not set on this worker.`);
337
345
  }
346
+ // Typed from here on, so it must never show up in a log or result.
347
+ registerSecretValue(value);
338
348
  return value;
339
349
  }
340
350
  if (!(name in context)) {
@@ -403,22 +413,26 @@ export function describeAddressForLog(address: string): string {
403
413
  * have templated. `retry`'s nested steps are deliberately left alone here --
404
414
  * the driver interpolates each one individually as it actually executes it.
405
415
  */
406
- export function interpolateStep(step: FlowStep, context: Readonly<Record<string, string>>): FlowStep {
416
+ export function interpolateStep(
417
+ step: FlowStep,
418
+ context: Readonly<Record<string, string>>,
419
+ options: Pick<InterpolateOptions, "env"> = {},
420
+ ): FlowStep {
407
421
  switch (step.type) {
408
422
  case "tapOn":
409
- return { ...step, text: interpolateTemplate(step.text, context) };
423
+ return { ...step, text: interpolateTemplate(step.text, context, options) };
410
424
  case "inputText":
411
- return { ...step, text: interpolateTemplate(step.text, context, { allowSecrets: true }) };
425
+ return { ...step, text: interpolateTemplate(step.text, context, { ...options, allowSecrets: true }) };
412
426
  case "assertVisible":
413
- return { ...step, text: interpolateTemplate(step.text, context) };
427
+ return { ...step, text: interpolateTemplate(step.text, context, options) };
414
428
  case "assertNotVisible":
415
- return { ...step, text: interpolateTemplate(step.text, context) };
429
+ return { ...step, text: interpolateTemplate(step.text, context, options) };
416
430
  case "assertClickable":
417
- return { ...step, text: interpolateTemplate(step.text, context) };
431
+ return { ...step, text: interpolateTemplate(step.text, context, options) };
418
432
  case "scrollUntilVisible":
419
- return { ...step, text: interpolateTemplate(step.text, context) };
433
+ return { ...step, text: interpolateTemplate(step.text, context, options) };
420
434
  case "openLink":
421
- return { ...step, url: interpolateTemplate(step.url, context) };
435
+ return { ...step, url: interpolateTemplate(step.url, context, options) };
422
436
  default:
423
437
  return step;
424
438
  }
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import readline from "node:readline/promises";
5
5
  import { stdin as input, stdout as output } from "node:process";
6
6
  import YAML from "yaml";
7
+ import { describeDetection, detectProjectIdentity, type ProjectIdentityDetection } from "./project-identity.js";
7
8
 
8
9
  export type StorageProvider = "local" | "gcs" | "s3" | "r2" | "bugmole";
9
10
 
@@ -30,6 +31,10 @@ export type InitOptions = {
30
31
  endpoint?: string;
31
32
  cloudProjectId?: string;
32
33
  accountId?: string;
34
+ /** The app's folder inside the repository, for a repository with several apps. */
35
+ appPath?: string;
36
+ /** The app's URL (a public one identifies the project by domain when there is no git remote). */
37
+ appUrl?: string;
33
38
  };
34
39
 
35
40
  export type InitConfig = {
@@ -50,8 +55,40 @@ export type InitConfig = {
50
55
  endpoint?: string;
51
56
  cloudProjectId?: string;
52
57
  accountId?: string;
58
+ /** Which app of the repository this project is ("." for the root). */
59
+ appPath?: string;
53
60
  };
54
61
 
62
+ /**
63
+ * The registry refused a project or workspace because of the plan (402
64
+ * plan_limit or free_workspace_exists). Carries the registry's own message
65
+ * and the Billing link, so the CLI prints those instead of a stack trace.
66
+ */
67
+ export class PlanLimitError extends Error {
68
+ constructor(message: string, readonly billingUrl: string, readonly code: string) {
69
+ super(message);
70
+ this.name = "PlanLimitError";
71
+ }
72
+ }
73
+
74
+ /** A PlanLimitError for a registry refusal about the plan; null for anything else. */
75
+ export function planLimitFromRegistry(
76
+ status: number,
77
+ body: { error?: string; code?: string; upgradeUrl?: string; billingUrl?: string } | null,
78
+ dashboardUrl: string,
79
+ ): PlanLimitError | null {
80
+ if (!body || (body.code !== "plan_limit" && body.code !== "free_workspace_exists" && body.code !== "identity_in_use" && status !== 402)) return null;
81
+ const base = dashboardUrl.replace(/\/$/, "");
82
+ const relative = typeof body.upgradeUrl === "string" && body.upgradeUrl.startsWith("/billing") ? body.upgradeUrl : "/billing";
83
+ const billingUrl = typeof body.billingUrl === "string" && /^https?:\/\//.test(body.billingUrl) ? body.billingUrl : `${base}${relative}`;
84
+ return new PlanLimitError(body.error || "Your Bugmole plan doesn't allow this.", billingUrl, body.code ?? "plan_limit");
85
+ }
86
+
87
+ /** What `bugmole init` prints for a plan refusal. */
88
+ export function describePlanLimit(error: PlanLimitError): string {
89
+ return `${error.message}\nSee plans and upgrade: ${error.billingUrl}`;
90
+ }
91
+
55
92
  const providers = new Set<StorageProvider>(["local", "gcs", "s3", "r2", "bugmole"]);
56
93
 
57
94
  export function validateStorageProvider(value: string): StorageProvider {
@@ -90,6 +127,7 @@ function existingDefaults(cwd: string, configPath: string): Partial<InitConfig>
90
127
  endpoint: storage[provider]?.endpoint,
91
128
  cloudProjectId: storage[provider]?.project_id,
92
129
  accountId: storage[provider]?.account_id,
130
+ appPath: project.app_path ?? project.appPath,
93
131
  };
94
132
  }
95
133
 
@@ -134,7 +172,7 @@ project:
134
172
  workspace_id: ${config.workspaceId}
135
173
  workspace_name: ${JSON.stringify(config.workspaceName)}
136
174
  environment: ${config.environment}
137
- spec_dir: ${config.specDir}
175
+ ${config.appPath ? ` app_path: ${JSON.stringify(config.appPath)}\n` : ""} spec_dir: ${config.specDir}
138
176
  evidence_manifest: ${config.evidenceManifest}
139
177
  evidence_screenshots: ${config.evidenceScreenshots}
140
178
 
@@ -167,7 +205,8 @@ function renderEnv(config: InitConfig): string {
167
205
  `BUGMOLE_ENVIRONMENT=${config.environment}`,
168
206
  `BUGMOLE_STORAGE_PROVIDER=${config.storageProvider}`,
169
207
  `BUGMOLE_LOCAL_ARTIFACTS_DIR=${config.artifactsDir}`,
170
- `BUGMOLE_AEGIS_EMAIL=${JSON.stringify(process.env.BUGMOLE_AEGIS_EMAIL?.trim() ?? "")}`,
208
+ // The email you sign in with, remembered to prefill the next sign-in.
209
+ ...(process.env.BUGMOLE_AEGIS_EMAIL?.trim() ? [`BUGMOLE_AEGIS_EMAIL=${JSON.stringify(process.env.BUGMOLE_AEGIS_EMAIL.trim())}`] : []),
171
210
  "",
172
211
  ];
173
212
  if (config.storageProvider === "gcs") {
@@ -199,11 +238,50 @@ function renderEnv(config: InitConfig): string {
199
238
  return `${lines.join("\n")}\n`;
200
239
  }
201
240
 
241
+ /** The registry matched this app to a project that already exists (a teammate's): use its id from now on. */
242
+ function adoptProjectId(configFile: string, envFile: string, config: InitConfig, projectId: string): void {
243
+ if (projectId === config.projectId) return;
244
+ if (fs.existsSync(configFile)) {
245
+ const text = fs.readFileSync(configFile, "utf8");
246
+ fs.writeFileSync(configFile, text.replace(/^(project:\r?\n id: ).*$/m, `$1${projectId}`), "utf8");
247
+ }
248
+ if (fs.existsSync(envFile)) writeEnvValue(envFile, "BUGMOLE_PROJECT_ID", projectId);
249
+ config.projectId = projectId;
250
+ }
251
+
252
+ /** Which app this is; never fails init (detection is best-effort). */
253
+ function detectIdentity(cwd: string, options: InitOptions, defaults: Partial<InitConfig>): ProjectIdentityDetection | null {
254
+ try {
255
+ return detectProjectIdentity({
256
+ cwd,
257
+ appPath: options.appPath ?? defaults.appPath,
258
+ appUrl: options.appUrl ?? (process.env.BUGMOLE_BASE_URL?.trim() || process.env.PLAYWRIGHT_BASE_URL?.trim() || undefined),
259
+ });
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
264
+
202
265
  async function ask(rl: readline.Interface, label: string, fallback: string): Promise<string> {
203
266
  const answer = await rl.question(`${label} [${fallback}]: `);
204
267
  return valueOr(answer, fallback);
205
268
  }
206
269
 
270
+ /**
271
+ * Adds the env file to the repository's .gitignore (when the workspace is a
272
+ * git repository and doesn't ignore it yet). Returns whether it added it.
273
+ */
274
+ export function ensureGitignored(cwd: string, file: string): boolean {
275
+ if (!fs.existsSync(path.join(cwd, ".git"))) return false;
276
+ const gitignore = path.join(cwd, ".gitignore");
277
+ const entry = `/${file.replace(/^\.\//, "")}`;
278
+ const current = fs.existsSync(gitignore) ? fs.readFileSync(gitignore, "utf8") : "";
279
+ const lines = current.split(/\r?\n/).map((line) => line.trim());
280
+ if (lines.includes(entry) || lines.includes(entry.slice(1)) || lines.includes(".bugmole.env*") || lines.includes("*.env")) return false;
281
+ fs.writeFileSync(gitignore, `${current}${current && !current.endsWith("\n") ? "\n" : ""}${entry}\n`, "utf8");
282
+ return true;
283
+ }
284
+
207
285
  export async function runInitWizard(
208
286
  options: InitOptions = {},
209
287
  ): Promise<{ configPath: string; envPath: string; config: InitConfig }> {
@@ -232,6 +310,12 @@ export async function runInitWizard(
232
310
  await choose(options.storageProvider, "Storage provider (local|gcs|s3|r2|bugmole)", valueOr(defaults.storageProvider, "local")),
233
311
  ),
234
312
  };
313
+ const detection = detectIdentity(cwd, options, defaults);
314
+ if (detection) {
315
+ for (const line of describeDetection(detection)) console.log(line);
316
+ if (detection.app && detection.app.path !== ".") config.appPath = detection.app.path;
317
+ }
318
+ const identity = detection?.identity ?? undefined;
235
319
  if (config.storageProvider !== "local" && config.storageProvider !== "bugmole") {
236
320
  config.bucket = await choose(options.bucket, "Bucket", valueOr(defaults.bucket, ""));
237
321
  if (config.storageProvider === "gcs")
@@ -256,6 +340,7 @@ export async function runInitWizard(
256
340
  if (options.force || !fs.existsSync(envFile)) {
257
341
  fs.writeFileSync(envFile, renderEnv(config), "utf8");
258
342
  console.log(`Generated ${configPath} and ${envPath}.`);
343
+ if (ensureGitignored(cwd, envPath)) console.log(`Added ${envPath} to .gitignore: it will hold your API key.`);
259
344
  } else {
260
345
  console.log(`Generated ${configPath}; kept existing ${envPath}.`);
261
346
  }
@@ -264,6 +349,8 @@ export async function runInitWizard(
264
349
  }
265
350
  const registryUrl =
266
351
  process.env.BUGMOLE_REGISTRY_URL?.trim() || "https://api.bugmole.com";
352
+ const dashboardUrl =
353
+ process.env.BUGMOLE_DASHBOARD_URL?.trim() || "https://app.bugmole.com";
267
354
  const apiKey = process.env.BUGMOLE_API_KEY?.trim();
268
355
  if (registryUrl && apiKey && shouldWrite) {
269
356
  const headers: Record<string, string> = {
@@ -282,12 +369,22 @@ export async function runInitWizard(
282
369
  workspaceName: config.workspaceName,
283
370
  environment: config.environment,
284
371
  storageProvider: config.storageProvider,
372
+ identity,
285
373
  }),
286
374
  });
287
375
  if (!response.ok) {
288
- throw new Error(`Bugmole registry rejected project registration (${response.status})`);
376
+ const refusal = await response.json().catch(() => null) as { error?: string; code?: string; upgradeUrl?: string; billingUrl?: string } | null;
377
+ const planLimit = planLimitFromRegistry(response.status, refusal, dashboardUrl);
378
+ if (planLimit) throw planLimit;
379
+ throw new Error(`Bugmole registry rejected project registration (${response.status})${refusal?.error ? `: ${refusal.error}` : ""}`);
380
+ }
381
+ const registered = await response.json().catch(() => null) as { project?: { id?: string }; existing?: boolean } | null;
382
+ if (registered?.existing && registered.project?.id) {
383
+ adoptProjectId(configFile, envFile, config, registered.project.id);
384
+ console.log(`This app is already project ${config.projectId} in the workspace; joined it instead of creating another.`);
385
+ } else {
386
+ console.log(`Registered ${config.projectId} with the Bugmole registry.`);
289
387
  }
290
- console.log(`Registered ${config.projectId} with the Bugmole registry.`);
291
388
  } else if (registryUrl && !apiKey && shouldWrite && !nonInteractive) {
292
389
  const createResponse = await fetch(`${registryUrl.replace(/\/$/, "")}/api/device-authorizations`, {
293
390
  method: "POST",
@@ -297,6 +394,7 @@ export async function runInitWizard(
297
394
  projectName: config.projectName,
298
395
  environment: config.environment,
299
396
  storageProvider: config.storageProvider,
397
+ identity,
300
398
  }),
301
399
  });
302
400
  const attempt = await createResponse.json().catch(() => null) as {
@@ -309,8 +407,6 @@ export async function runInitWizard(
309
407
  if (!createResponse.ok || !attempt?.attemptId || !attempt.code || !attempt.pollSecret) {
310
408
  throw new Error(attempt?.error ?? "Unable to start Bugmole device registration");
311
409
  }
312
- const dashboardUrl =
313
- process.env.BUGMOLE_DASHBOARD_URL?.trim() || "https://app.bugmole.com";
314
410
  const registrationUrl =
315
411
  `${dashboardUrl.replace(/\/$/, "")}/register?attemptId=${encodeURIComponent(attempt.attemptId)}&projectId=${encodeURIComponent(config.projectId)}${process.env.BUGMOLE_AEGIS_EMAIL?.trim() ? `&email=${encodeURIComponent(process.env.BUGMOLE_AEGIS_EMAIL.trim())}` : ""}`;
316
412
  console.log(`Open ${registrationUrl} and enter code: ${attempt.code}`);
@@ -327,14 +423,24 @@ export async function runInitWizard(
327
423
  status?: string;
328
424
  token?: string;
329
425
  email?: string;
426
+ projectId?: string;
330
427
  alreadyInitialized?: boolean;
331
428
  error?: string;
429
+ code?: string;
430
+ upgradeUrl?: string;
431
+ billingUrl?: string;
332
432
  } | null;
333
433
  if (result?.status === "pending") continue;
334
434
  if (!pollResponse.ok) {
435
+ const planLimit = planLimitFromRegistry(pollResponse.status, result, dashboardUrl);
436
+ if (planLimit) throw planLimit;
335
437
  const detail = result?.error ?? result?.status ?? `HTTP ${pollResponse.status}`;
336
438
  throw new Error(`Bugmole device registration failed: ${detail}`);
337
439
  }
440
+ if (result?.projectId && result.projectId !== config.projectId) {
441
+ adoptProjectId(configFile, envFile, config, result.projectId);
442
+ console.log(`This app is already project ${config.projectId}; this machine joined it.`);
443
+ }
338
444
  if (result?.alreadyInitialized) {
339
445
  throw new Error("This Aegis account already has an API key; revoke it or use that key in .bugmole.env.");
340
446
  }