@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
@@ -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
  }
@@ -82,6 +82,15 @@ function idPart(value: string): string {
82
82
  return value.trim().replace(/[^a-zA-Z0-9._:-]+/g, "-").replace(/^-+|-+$/g, "");
83
83
  }
84
84
 
85
+ /**
86
+ * Step and transition IDs keep the route in them (screen:/settings,
87
+ * screen:/->screen:/settings:dom-3), as exploration writes them; rewriting
88
+ * the slashes made the agent's next call name a step that didn't exist.
89
+ */
90
+ function graphIdPart(value: string): string {
91
+ return value.trim().replace(/[^a-zA-Z0-9._:/>\[\]-]+/g, "-").replace(/^-+|-+$/g, "");
92
+ }
93
+
85
94
  function requireJourney(graph: JourneyGraph, journeyId: string): UserJourney {
86
95
  const journey = graph.journeys.find((item) => item.id === journeyId);
87
96
  if (!journey) throw new Error(`Journey not found: ${journeyId}`);
@@ -98,6 +107,45 @@ function nextRevision(journey: UserJourney): UserJourney {
98
107
  };
99
108
  }
100
109
 
110
+ /**
111
+ * A run that passed proves the flow it ran. Discovery saves what it finds as
112
+ * draft journeys, and only stable ones are drawn in the dashboard's journey
113
+ * graph, but nothing promoted them: a cloud run has no agent to call
114
+ * stabilizeJourney. This does it for the drafts discovery wrote, reading the
115
+ * stored graph (a cloud container has no local copy). Journeys an agent or a
116
+ * person made are left for them to stabilize. Returns the journey promoted.
117
+ */
118
+ export async function stabilizeAfterPassingRun(
119
+ cfg: JourneyConfig,
120
+ run: { id: string; planId?: string | null; journeyId?: string | null },
121
+ ): Promise<string | null> {
122
+ const store = storeFor(cfg);
123
+ const key = `${projectPrefix(projectId(cfg))}/journeys/graph.json`;
124
+ if (!(await store.has(key))) return null;
125
+ const parsed = JSON.parse(await store.getText(key)) as JourneyGraph;
126
+ const graph = repairJourneyGraph({
127
+ ...emptyJourneyGraph(projectId(cfg)),
128
+ ...parsed,
129
+ journeys: Array.isArray(parsed.journeys) ? parsed.journeys : [],
130
+ nodes: Array.isArray(parsed.nodes) ? parsed.nodes : [],
131
+ edges: Array.isArray(parsed.edges) ? parsed.edges : [],
132
+ }, projectId(cfg));
133
+ const current = graph.journeys.find((journey) =>
134
+ (run.journeyId && journey.id === run.journeyId) || (run.planId && journey.metadata?.planId === run.planId));
135
+ if (!current || current.stability === "stable" || current.metadata?.generatedBy !== "site-discovery") return null;
136
+ const journey: UserJourney = {
137
+ ...current,
138
+ status: "published",
139
+ stability: "stable",
140
+ linkedToGraph: true,
141
+ verificationRunId: run.id,
142
+ verifiedAt: new Date().toISOString(),
143
+ };
144
+ graph.journeys = graph.journeys.map((item) => item.id === current.id ? journey : item);
145
+ await writeJourneyGraph(cfg, graph);
146
+ return journey.id;
147
+ }
148
+
101
149
  export async function stabilizeJourney(
102
150
  cfg: JourneyConfig,
103
151
  input: { journeyId: string; verificationRunId: string; verifiedAt?: string },
@@ -152,7 +200,7 @@ export async function addJourneyStep(
152
200
  ) {
153
201
  const graph = readJourneyGraph(cfg);
154
202
  const journey = requireJourney(graph, input.journeyId);
155
- const nodeId = idPart(input.node.id || `step-${Date.now()}`);
203
+ const nodeId = graphIdPart(input.node.id || `step-${Date.now()}`);
156
204
  if (!nodeId || graph.nodes.some((node) => node.id === nodeId)) throw new Error(`Node already exists: ${nodeId}`);
157
205
  const node = { ...input.node, id: nodeId } as JourneyNode;
158
206
  graph.nodes.push(node);
@@ -202,7 +250,7 @@ export async function addJourneyTransition(
202
250
  if (!graph.nodes.some((node) => node.id === input.from) || !graph.nodes.some((node) => node.id === input.to)) {
203
251
  throw new Error("Transition endpoints must reference existing nodes");
204
252
  }
205
- const edgeId = idPart(input.edge?.id || `${input.from}->${input.to}:${Date.now()}`);
253
+ const edgeId = graphIdPart(input.edge?.id || `${input.from}->${input.to}:${Date.now()}`);
206
254
  if (graph.edges.some((edge) => edge.id === edgeId)) throw new Error(`Edge already exists: ${edgeId}`);
207
255
  const edge: JourneyEdge = { ...input.edge, id: edgeId, from: input.from, to: input.to };
208
256
  graph.edges.push(edge);
@@ -262,6 +310,34 @@ export async function removeJourneyItem(
262
310
  export function nextJourneyTask(cfg: JourneyConfig, journeyId: string): JourneyNextTask {
263
311
  const graph = readJourneyGraph(cfg);
264
312
  const journey = requireJourney(graph, journeyId);
313
+ // An empty journey, or steps nothing connects, is not finished: there is
314
+ // nothing to plan from yet.
315
+ if (!journey.nodeIds.length) {
316
+ return {
317
+ status: "actionable",
318
+ journeyId,
319
+ reason: "The journey has no steps yet.",
320
+ action: "Add the screen the journey starts on",
321
+ nextSteps: [
322
+ "Open the app where this journey starts and record that screen with bugmole_journey_add_step",
323
+ "Call bugmole_journey_next again after recording it",
324
+ ],
325
+ };
326
+ }
327
+ if (journey.nodeIds.length > 1 && !journey.edgeIds.length) {
328
+ return {
329
+ status: "actionable",
330
+ journeyId,
331
+ reason: "The journey's steps aren't connected: no transition says how a person gets from one to the next.",
332
+ nodeId: journey.nodeIds[0],
333
+ route: graph.nodes.find((node) => node.id === journey.nodeIds[0])?.route,
334
+ action: `Connect ${journey.nodeIds[0]} to the next step`,
335
+ nextSteps: [
336
+ "Perform the action that leads from each step to the next and record it with bugmole_journey_add_transition",
337
+ "Call bugmole_journey_next again after recording it",
338
+ ],
339
+ };
340
+ }
265
341
  const included = new Set(journey.nodeIds);
266
342
  const includedEdges = new Set(journey.edgeIds);
267
343
  const coveredTransitions = new Set(
@@ -9,6 +9,7 @@ import {
9
9
  addJourneyTransition,
10
10
  createJourney,
11
11
  reuseJourneyStep,
12
+ stabilizeAfterPassingRun,
12
13
  stabilizeJourney,
13
14
  nextJourneyTask,
14
15
  readJourneyGraph,
@@ -255,3 +256,62 @@ test("requires runtime transition and terminal-goal completion for source paths"
255
256
  assert.equal(nextJourneyTask(cfg, "top-up-flow").status, "exhausted");
256
257
  await fs.promises.rm(root, { recursive: true, force: true });
257
258
  });
259
+
260
+ test("a passing run promotes the explored draft it proved, from the stored graph alone", async () => {
261
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-promote-"));
262
+ const storage = { provider: "local", localDirectory: path.join(root, "artifacts") };
263
+ const writer = { project: { id: "demo" }, runtime: { spec_dir: path.join(root, "spec"), artifacts_dir: path.join(root, "artifacts") }, storage };
264
+ // A cloud container: same project storage, no local copy of the graph.
265
+ const container = { project: { id: "demo" }, runtime: { spec_dir: path.join(root, "container"), artifacts_dir: path.join(root, "artifacts") }, storage };
266
+ try {
267
+ const graph = emptyJourneyGraph("demo");
268
+ graph.nodes = [{ id: "screen:/login", title: "Welcome back", kind: "screen", route: "/login" }];
269
+ const draft = (id: string, generatedBy?: string) => ({
270
+ id,
271
+ name: id,
272
+ actor: "visitor",
273
+ startNodeId: "screen:/login",
274
+ nodeIds: ["screen:/login"],
275
+ edgeIds: [],
276
+ revision: 1,
277
+ status: "draft" as const,
278
+ sourceOfTruth: "canonical_flow" as const,
279
+ stability: "discovered" as const,
280
+ linkedToGraph: false,
281
+ metadata: generatedBy ? { generatedBy, planId: id } : {},
282
+ });
283
+ graph.journeys = [draft("discovered-welcome-back", "site-discovery"), draft("hand-made")];
284
+ await writeJourneyGraph(writer, graph);
285
+
286
+ assert.equal(await stabilizeAfterPassingRun(container, { id: "run_1", planId: "discovered-welcome-back", journeyId: "discovered-welcome-back" }), "discovered-welcome-back");
287
+ const stored = JSON.parse(fs.readFileSync(path.join(root, "artifacts", "projects", "demo", "journeys", "graph.json"), "utf8"));
288
+ const promoted = stored.journeys.find((journey: { id: string }) => journey.id === "discovered-welcome-back");
289
+ assert.equal(promoted.stability, "stable");
290
+ assert.equal(promoted.linkedToGraph, true);
291
+ assert.equal(promoted.verificationRunId, "run_1");
292
+
293
+ assert.equal(await stabilizeAfterPassingRun(container, { id: "run_2", journeyId: "hand-made" }), null, "journeys a person or agent made are theirs to stabilize");
294
+ assert.equal(await stabilizeAfterPassingRun(container, { id: "run_3", journeyId: "discovered-welcome-back" }), null, "already stable");
295
+ assert.equal(await stabilizeAfterPassingRun(container, { id: "run_4", journeyId: "missing" }), null);
296
+ } finally {
297
+ fs.rmSync(root, { recursive: true, force: true });
298
+ }
299
+ });
300
+
301
+ test("route-style step IDs are kept, and an empty or unconnected journey is not exhausted", async () => {
302
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "bugmole-flow-ids-"));
303
+ const cfg = {
304
+ project: { id: "demo" },
305
+ runtime: { spec_dir: path.join(root, "spec"), artifacts_dir: path.join(root, "artifacts") },
306
+ storage: { provider: "local", localDirectory: path.join(root, "artifacts") },
307
+ };
308
+ await createJourney(cfg, { id: "sign-up", name: "Sign up", actor: "guest" });
309
+ assert.equal(nextJourneyTask(cfg, "sign-up").status, "actionable");
310
+ const home = await addJourneyStep(cfg, { journeyId: "sign-up", node: { id: "screen:/", title: "Home", kind: "screen", route: "/" } });
311
+ const signup = await addJourneyStep(cfg, { journeyId: "sign-up", node: { id: "screen:/signup", title: "Sign up", kind: "screen", route: "/signup" } });
312
+ assert.deepEqual(signup.journey.nodeIds, ["screen:/", "screen:/signup"]);
313
+ assert.ok(home);
314
+ assert.match(nextJourneyTask(cfg, "sign-up").reason, /aren't connected/);
315
+ await addJourneyTransition(cfg, { journeyId: "sign-up", from: "screen:/", to: "screen:/signup" });
316
+ assert.match(readJourneyGraph(cfg).edges[0].id, /^screen:\/->screen:\/signup:/);
317
+ });
@@ -323,3 +323,67 @@ test("personas: adding, renaming, and removing, with at least one always kept",
323
323
  assert.equal(removeLast.status, 409, "the last persona in a project can't be removed");
324
324
  });
325
325
  });
326
+
327
+ test("completion review mirrors the registry: in review, then accepted or sent back with a note", async () => {
328
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-local-registry-"));
329
+ const stub = await startLocalRegistryStub({ directory, reviewMode: "auto" });
330
+ const origin = stub.origin;
331
+ try {
332
+ const create = async (taskType: string, key: string) => {
333
+ const created = await postJson(origin, "/api/projects/lumis/tasks", { title: "t", description: "d", taskType, idempotencyKey: key });
334
+ const id = (created.body as { task: { id: string } }).task.id;
335
+ await postJson(origin, `/api/tasks/${id}/claim`, { workerId: "w" });
336
+ return id;
337
+ };
338
+ const investigation = await create("investigation", "a");
339
+ const done = await postJson(origin, `/api/tasks/${investigation}`, { workerId: "w", status: "completed" });
340
+ assert.equal((done.body as { task: { status: string } }).task.status, "completed", "auto doesn't review an investigation");
341
+
342
+ const code = await create("code", "b");
343
+ const review = await postJson(origin, `/api/tasks/${code}`, { workerId: "w", status: "completed" });
344
+ const reviewed = review.body as { inReview: boolean; message: string; task: { status: string } };
345
+ assert.equal(reviewed.task.status, "in_review");
346
+ assert.equal(reviewed.inReview, true);
347
+ assert.equal((await postJson(origin, `/api/tasks/${code}`, { workerId: "w", status: "running" })).status, 409);
348
+ assert.equal((await postJson(origin, `/api/tasks/${code}/resume`, {})).status, 409);
349
+
350
+ assert.equal((await postJson(origin, `/api/tasks/${code}/send-back`, {})).status, 422, "sending back needs a note");
351
+ const sent = await postJson(origin, `/api/tasks/${code}/send-back`, { note: "Also cover the empty cart" });
352
+ const sentTask = (sent.body as { task: { status: string; reviewNote: string; attempts: number } }).task;
353
+ assert.equal(sentTask.status, "queued");
354
+ assert.equal(sentTask.reviewNote, "Also cover the empty cart");
355
+ assert.equal(sentTask.attempts, 0);
356
+
357
+ await postJson(origin, `/api/tasks/${code}/claim`, { workerId: "w" });
358
+ await postJson(origin, `/api/tasks/${code}`, { workerId: "w", status: "completed" });
359
+ const accepted = await postJson(origin, `/api/tasks/${code}/accept`, {});
360
+ assert.equal((accepted.body as { task: { status: string; reviewedBy: string } }).task.status, "completed");
361
+ assert.equal((await postJson(origin, `/api/tasks/${code}/accept`, {})).status, 409);
362
+ } finally {
363
+ await stub.close();
364
+ await fs.rm(directory, { recursive: true, force: true });
365
+ }
366
+ });
367
+
368
+ test("roles: suggestions wait for a decision; the dashboard's add form confirms", async () => {
369
+ await withStub(async (origin) => {
370
+ const suggested = await postJson(origin, "/api/projects/lumis/roles", { name: "Admin", description: "Admin area.", source: "role_gated_route", evidence: "route /admin" });
371
+ assert.equal(suggested.status, 201);
372
+ const role = (suggested.body as { role: { id: string; name: string; status: string } }).role;
373
+ assert.equal(role.name, "admin");
374
+ assert.equal(role.status, "suggested");
375
+ assert.equal((await postJson(origin, "/api/projects/lumis/roles", { name: "x", description: "x" })).status, 422, "a suggestion needs evidence");
376
+
377
+ const rejected = await fetch(`${origin}/api/projects/lumis/roles/${role.id}`, {
378
+ method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "reject" }),
379
+ });
380
+ assert.equal(((await rejected.json()) as { role: { status: string } }).role.status, "rejected");
381
+ const again = await postJson(origin, "/api/projects/lumis/roles", { name: "admin", description: "Admin area.", source: "role_gated_menu", evidence: "menu Admin" });
382
+ assert.equal((again.body as { role: { status: string } }).role.status, "rejected", "a rejected role stays rejected");
383
+
384
+ const added = await postJson(origin, "/api/projects/lumis/roles", { name: "guest", description: "Not signed in.", source: "person" });
385
+ assert.equal((added.body as { role: { status: string } }).role.status, "confirmed");
386
+ const listed = await (await fetch(`${origin}/api/projects/lumis/roles?status=confirmed`)).json() as { roles: Array<{ name: string }> };
387
+ assert.deepEqual(listed.roles.map((item) => item.name), ["guest"]);
388
+ });
389
+ });