@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
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Which app the CLI is running in, and so which Bugmole project it belongs to.
3
+ *
4
+ * One app is one project. A local app is identified by its git repository
5
+ * (the normalized remote, never its credentials) plus the app's folder in it,
6
+ * so two teammates who clone the same repository pair with the same project,
7
+ * and each app of a monorepo gets its own. With no git remote, a public app
8
+ * URL identifies it by domain instead. See src/registry/project-identity.ts.
9
+ *
10
+ * Bugmole tests web apps and mobile apps (Android, iOS). A repository with
11
+ * neither, such as a data pipeline or a model, is out of scope; `bugmole init`
12
+ * says so rather than guessing.
13
+ */
14
+ import { execFileSync } from "node:child_process";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import {
18
+ describeIdentity,
19
+ domainIdentityFromUrl,
20
+ normalizeAppPath,
21
+ normalizeGitRemote,
22
+ repoIdentity,
23
+ type ProjectIdentity,
24
+ type ProjectIdentityInput,
25
+ } from "../registry/project-identity.js";
26
+
27
+ export type AppKind = "web" | "mobile";
28
+
29
+ export type DetectedApp = {
30
+ /** The app's folder relative to the repository root (POSIX); "." for the root. */
31
+ path: string;
32
+ kind: AppKind;
33
+ /** What gave it away, e.g. "Next.js", "Android", "Expo". */
34
+ framework: string;
35
+ };
36
+
37
+ export type ProjectIdentityDetection = {
38
+ /** What the CLI sends the registry (credential-free); null when nothing identifies the app. */
39
+ identity: ProjectIdentityInput | null;
40
+ /** The identity as the registry will store it, for messages. */
41
+ preview: ProjectIdentity | null;
42
+ /** The app the CLI is in, when it could tell. */
43
+ app: DetectedApp | null;
44
+ /** Every web or mobile app found in the repository. */
45
+ apps: DetectedApp[];
46
+ repoRoot: string;
47
+ /** Why there is no identity: no web/mobile app, several apps and no way to pick, or no remote and no public URL. */
48
+ reason?: "no_app" | "ambiguous" | "unidentified";
49
+ /** The --app-url given, when nothing in the folder is an app. */
50
+ appUrl?: string;
51
+ };
52
+
53
+ export const SCOPE_MESSAGE =
54
+ "Bugmole tests web apps and mobile apps (Android and iOS). Data and AI projects, such as notebooks, pipelines and models, are out of scope.";
55
+
56
+ const SKIP_DIRS = new Set([
57
+ "node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", ".svelte-kit", ".turbo", ".cache", "coverage",
58
+ "vendor", "Pods", "DerivedData", ".gradle", ".idea", ".vscode", "target", "__pycache__", ".venv", "venv", "tmp",
59
+ ]);
60
+
61
+ const WEB_DEPENDENCIES: Array<[string, string]> = [
62
+ ["next", "Next.js"], ["nuxt", "Nuxt"], ["@sveltejs/kit", "SvelteKit"], ["@remix-run/react", "Remix"], ["astro", "Astro"],
63
+ ["gatsby", "Gatsby"], ["@angular/core", "Angular"], ["vue", "Vue"], ["svelte", "Svelte"], ["solid-js", "Solid"],
64
+ ["preact", "Preact"], ["ember-source", "Ember"], ["@builder.io/qwik", "Qwik"], ["react-dom", "React"], ["vite", "Vite"],
65
+ // Server-rendered sites: their pages come from the server, not a front-end framework.
66
+ ["express", "Express"], ["fastify", "Fastify"], ["koa", "Koa"], ["hono", "Hono"], ["@hapi/hapi", "hapi"], ["@nestjs/core", "NestJS"],
67
+ ];
68
+ const MOBILE_DEPENDENCIES: Array<[string, string]> = [["expo", "Expo"], ["react-native", "React Native"], ["@capacitor/core", "Capacitor"], ["@ionic/core", "Ionic"]];
69
+
70
+ function readJson(file: string): Record<string, any> | null {
71
+ try {
72
+ return JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, any>;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function readText(file: string): string {
79
+ try {
80
+ return fs.readFileSync(file, "utf8");
81
+ } catch {
82
+ return "";
83
+ }
84
+ }
85
+
86
+ /** Whether a folder is a web or mobile app by what it contains, or null. */
87
+ export function classifyApp(dir: string): { kind: AppKind; framework: string } | null {
88
+ const pkg = readJson(path.join(dir, "package.json"));
89
+ if (pkg) {
90
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) } as Record<string, unknown>;
91
+ const mobile = MOBILE_DEPENDENCIES.find(([name]) => name in deps);
92
+ if (mobile) return { kind: "mobile", framework: mobile[1] };
93
+ const web = WEB_DEPENDENCIES.find(([name]) => name in deps);
94
+ if (web) return { kind: "web", framework: web[1] };
95
+ }
96
+ if (/flutter\s*:/.test(readText(path.join(dir, "pubspec.yaml")))) return { kind: "mobile", framework: "Flutter" };
97
+ for (const gradle of ["build.gradle", "build.gradle.kts", "app/build.gradle", "app/build.gradle.kts"]) {
98
+ if (/com\.android\.application/.test(readText(path.join(dir, gradle)))) return { kind: "mobile", framework: "Android" };
99
+ }
100
+ let entries: fs.Dirent[] = [];
101
+ try {
102
+ entries = fs.readdirSync(dir, { withFileTypes: true });
103
+ } catch {
104
+ return null;
105
+ }
106
+ if (entries.some((entry) => entry.isDirectory() && /\.(xcodeproj|xcworkspace)$/.test(entry.name))) return { kind: "mobile", framework: "iOS" };
107
+ if (fs.existsSync(path.join(dir, "manage.py")) && /django/i.test(readText(path.join(dir, "manage.py")))) return { kind: "web", framework: "Django" };
108
+ if (fs.existsSync(path.join(dir, "config", "routes.rb"))) return { kind: "web", framework: "Rails" };
109
+ if (fs.existsSync(path.join(dir, "artisan"))) return { kind: "web", framework: "Laravel" };
110
+ if (fs.existsSync(path.join(dir, "index.html")) || fs.existsSync(path.join(dir, "public", "index.html"))) return { kind: "web", framework: "Static site" };
111
+ return null;
112
+ }
113
+
114
+ /** A workspace root (npm/yarn/pnpm workspaces, Turborepo, Nx, Lerna) holds apps; it isn't one. */
115
+ function isWorkspaceRoot(dir: string): boolean {
116
+ const pkg = readJson(path.join(dir, "package.json"));
117
+ if (pkg?.workspaces) return true;
118
+ return ["pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json"].some((file) => fs.existsSync(path.join(dir, file)));
119
+ }
120
+
121
+ /** Every web or mobile app in a repository, outermost first; an app's own subfolders aren't searched. */
122
+ export function discoverApps(repoRoot: string, maxDepth = 4): DetectedApp[] {
123
+ const apps: DetectedApp[] = [];
124
+ const visit = (dir: string, depth: number) => {
125
+ const relative = path.relative(repoRoot, dir).split(path.sep).join("/") || ".";
126
+ if (!(depth === 0 && isWorkspaceRoot(dir))) {
127
+ const found = classifyApp(dir);
128
+ if (found) {
129
+ apps.push({ path: relative, ...found });
130
+ return;
131
+ }
132
+ }
133
+ if (depth >= maxDepth) return;
134
+ let entries: fs.Dirent[] = [];
135
+ try {
136
+ entries = fs.readdirSync(dir, { withFileTypes: true });
137
+ } catch {
138
+ return;
139
+ }
140
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
141
+ if (!entry.isDirectory() || entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue;
142
+ visit(path.join(dir, entry.name), depth + 1);
143
+ }
144
+ };
145
+ visit(repoRoot, 0);
146
+ return apps;
147
+ }
148
+
149
+ function git(cwd: string, args: string[]): string | null {
150
+ try {
151
+ return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5_000 }).trim() || null;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ /** The repository root containing `cwd`, or null outside a git checkout. */
158
+ export function gitRoot(cwd: string): string | null {
159
+ const root = git(cwd, ["rev-parse", "--show-toplevel"]);
160
+ return root ? fs.realpathSync(root) : null;
161
+ }
162
+
163
+ /** The repository's remote ("origin", else the first one), normalized: no credentials ever leave the machine. */
164
+ export function gitRemote(repoRoot: string): string | null {
165
+ const origin = git(repoRoot, ["remote", "get-url", "origin"]);
166
+ if (origin) return normalizeGitRemote(origin);
167
+ const first = git(repoRoot, ["remote"])?.split(/\r?\n/)[0]?.trim();
168
+ return first ? normalizeGitRemote(git(repoRoot, ["remote", "get-url", first])) : null;
169
+ }
170
+
171
+ /**
172
+ * Works out which app `cwd` is: the app folder that contains it, the app
173
+ * named by `appPath`, or the repository's only app. With several apps and no
174
+ * way to choose, it doesn't pick one (so apps are never collapsed into one
175
+ * project) and reports "ambiguous".
176
+ */
177
+ export function detectProjectIdentity(options: { cwd: string; appPath?: string; appUrl?: string }): ProjectIdentityDetection {
178
+ const cwd = fs.realpathSync(options.cwd);
179
+ const repoRoot = gitRoot(cwd) ?? cwd;
180
+ const apps = discoverApps(repoRoot);
181
+ let app: DetectedApp | null = null;
182
+ const requested = options.appPath !== undefined ? normalizeAppPath(options.appPath) : undefined;
183
+ if (requested) {
184
+ const classified = classifyApp(path.join(repoRoot, requested));
185
+ app = apps.find((item) => item.path === requested) ?? (classified ? { path: requested, ...classified } : null);
186
+ } else {
187
+ const here = path.relative(repoRoot, cwd).split(path.sep).join("/") || ".";
188
+ // The deepest app folder that contains the working directory.
189
+ app = apps
190
+ .filter((item) => item.path === "." || here === item.path || here.startsWith(`${item.path}/`))
191
+ .sort((a, b) => b.path.length - a.path.length)[0] ?? null;
192
+ if (!app && here === "." && apps.length === 1) app = apps[0];
193
+ }
194
+ const base = { app, apps, repoRoot };
195
+ if (!app && apps.length > 1 && !requested) return { ...base, identity: null, preview: null, reason: "ambiguous" };
196
+ if (!app && !apps.length) {
197
+ // Still identifiable by a public URL, but nothing here looks like a web or mobile app.
198
+ const byUrl = domainIdentityFromUrl(options.appUrl);
199
+ return { ...base, identity: byUrl ? { appUrl: options.appUrl } : null, preview: byUrl, reason: "no_app", ...(options.appUrl ? { appUrl: options.appUrl } : {}) };
200
+ }
201
+ const remote = gitRemote(repoRoot);
202
+ const appPath = app?.path ?? requested ?? ".";
203
+ const preview = remote ? repoIdentity(remote, appPath, options.appUrl) : null;
204
+ if (preview) {
205
+ return { ...base, identity: { kind: "repo", remote, appPath, ...(preview.origin ? { appUrl: preview.origin } : {}) }, preview };
206
+ }
207
+ const byUrl = domainIdentityFromUrl(options.appUrl);
208
+ if (byUrl) return { ...base, identity: { appUrl: byUrl.origin ?? undefined }, preview: byUrl };
209
+ return { ...base, identity: null, preview: null, reason: "unidentified" };
210
+ }
211
+
212
+ /** The lines `bugmole init` prints about which app it found. */
213
+ export function describeDetection(detection: ProjectIdentityDetection): string[] {
214
+ if (detection.reason === "no_app") {
215
+ if (detection.appUrl) return [`Nothing in ${detection.repoRoot} looks like a web or mobile app; Bugmole will test the app at ${detection.appUrl}.`];
216
+ return [
217
+ `No web or mobile app found in ${detection.repoRoot}. If it serves a website, pass its address with --app-url.`,
218
+ SCOPE_MESSAGE,
219
+ ];
220
+ }
221
+ if (detection.reason === "ambiguous") {
222
+ return [
223
+ `This repository has ${detection.apps.length} apps: ${detection.apps.map((app) => `${app.path} (${app.framework})`).join(", ")}.`,
224
+ "Each app is its own Bugmole project. Run `bugmole init` in the app's folder, or pass --app-path <folder>.",
225
+ ];
226
+ }
227
+ const lines: string[] = [];
228
+ if (detection.app) lines.push(`Found a ${detection.app.kind} app (${detection.app.framework}) at ${detection.app.path}.`);
229
+ if (detection.preview) lines.push(`This project is ${describeIdentity(detection.preview)}; teammates who clone the same repository join it.`);
230
+ else lines.push("No git remote or public app URL identifies this app yet; add a remote so teammates join the same project.");
231
+ return lines;
232
+ }
@@ -0,0 +1,107 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import YAML from "yaml";
7
+ import {
8
+ collectRoleSuggestions, holdAgentRoleWrites, projectActorProblem, projectRoles, proposeRoleSuggestions, readSpecRoles, rolesClient,
9
+ } from "./project-roles.js";
10
+
11
+ async function specDir(files: Record<string, string>) {
12
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-roles-"));
13
+ for (const [name, content] of Object.entries(files)) await fs.writeFile(path.join(directory, name), content);
14
+ return directory;
15
+ }
16
+
17
+ const ROLES_YAML = [
18
+ "roles:",
19
+ " - name: user",
20
+ " description: Signed-in user.",
21
+ " - name: admin",
22
+ " description: Admin.",
23
+ " status: suggested",
24
+ " - name: robot",
25
+ " status: rejected",
26
+ ].join("\n");
27
+
28
+ test("roles.yaml entries without a status are confirmed; suggested and rejected ones aren't usable", async () => {
29
+ const dir = await specDir({ "roles.yaml": ROLES_YAML });
30
+ assert.deepEqual(readSpecRoles(dir).map((role) => `${role.name}:${role.status}`), ["user:confirmed", "admin:suggested", "robot:rejected"]);
31
+ const roles = await projectRoles({ runtime: { spec_dir: dir } }, null);
32
+ assert.deepEqual(roles.usable, ["user"]);
33
+ assert.deepEqual(roles.suggested, ["admin"]);
34
+ assert.equal(projectActorProblem(roles, "user"), null);
35
+ assert.match(projectActorProblem(roles, "admin")!, /only suggested/);
36
+ assert.match(projectActorProblem(roles, "robot")!, /rejected/);
37
+ });
38
+
39
+ test("a person's decision in the dashboard wins over roles.yaml", async () => {
40
+ const dir = await specDir({ "roles.yaml": ROLES_YAML });
41
+ const client = {
42
+ listRoles: async () => ({
43
+ roles: [
44
+ { id: "r1", name: "admin", description: "Admin.", status: "confirmed" as const, source: "role_gated_route", evidence: "route /admin" },
45
+ { id: "r2", name: "user", description: "User.", status: "rejected" as const, source: "spec", evidence: null },
46
+ ],
47
+ }),
48
+ };
49
+ const roles = await projectRoles({ runtime: { spec_dir: dir } }, client);
50
+ assert.deepEqual(roles.usable, ["admin"]);
51
+ assert.match(projectActorProblem(roles, "user")!, /rejected/);
52
+
53
+ const down = await projectRoles({ runtime: { spec_dir: dir } }, { listRoles: async () => { throw new Error("offline"); } });
54
+ assert.equal(down.registryRead, false);
55
+ assert.deepEqual(down.usable, ["user"], "without the registry, roles.yaml decides");
56
+ });
57
+
58
+ test("only a machine with an API key and a project asks the registry", () => {
59
+ assert.equal(rolesClient({ project: { id: "shop" } }, {}), null);
60
+ assert.equal(rolesClient({}, { BUGMOLE_API_KEY: "bm_x" }), null);
61
+ assert.ok(rolesClient({ project: { id: "shop" } }, { BUGMOLE_API_KEY: "bm_x" }));
62
+ });
63
+
64
+ test("an agent writing roles.yaml can only suggest: new or promoted roles are held back", () => {
65
+ const next = [
66
+ "roles:",
67
+ " - name: user",
68
+ " - name: admin",
69
+ " - name: robot",
70
+ " - name: support",
71
+ " status: suggested",
72
+ ].join("\n");
73
+ const { content, heldBack } = holdAgentRoleWrites(ROLES_YAML, next);
74
+ assert.deepEqual(heldBack, ["admin", "robot"]);
75
+ const written = YAML.parse(content).roles as Array<{ name: string; status?: string }>;
76
+ assert.equal(written.find((role) => role.name === "user")!.status, undefined, "a confirmed role is left as it was");
77
+ assert.equal(written.find((role) => role.name === "admin")!.status, "suggested");
78
+ assert.equal(written.find((role) => role.name === "robot")!.status, "rejected", "a rejected role stays rejected");
79
+ assert.equal(written.find((role) => role.name === "support")!.status, "suggested");
80
+
81
+ const fresh = holdAgentRoleWrites(null, "roles:\n - name: guest\n");
82
+ assert.deepEqual(fresh.heldBack, ["guest"]);
83
+ assert.equal(holdAgentRoleWrites(null, "not: [valid").heldBack.length, 0, "unparseable content is left to the writer");
84
+ });
85
+
86
+ test("discovery evidence turns into suggestions, and settled roles are not re-proposed", async () => {
87
+ const graph = {
88
+ nodes: [{ id: "screen:/login", route: "/login" }, { id: "screen:/admin", route: "/admin" }],
89
+ edges: [],
90
+ journeys: [{ id: "flow:login", name: "Login", actor: "user" }],
91
+ };
92
+ const dir = await specDir({ "journeys.graph.json": JSON.stringify(graph), "roles.yaml": ROLES_YAML });
93
+ const suggestions = collectRoleSuggestions({ runtime: { spec_dir: dir } });
94
+ assert.deepEqual(suggestions.map((item) => item.name).sort(), ["admin", "guest", "member", "user"]);
95
+
96
+ const sent: string[] = [];
97
+ const results = await proposeRoleSuggestions(suggestions, {
98
+ proposeRole: async (input) => {
99
+ sent.push(input.name);
100
+ if (input.name === "guest") throw new Error("Registry request failed (500)");
101
+ return { role: { id: `r-${input.name}`, name: input.name, description: input.description, status: "suggested", source: input.source, evidence: input.evidence } };
102
+ },
103
+ }, ["user"]);
104
+ assert.ok(!sent.includes("user"), "a confirmed role isn't suggested again");
105
+ assert.equal(results.find((item) => item.name === "guest")!.status, "not_sent");
106
+ assert.equal(results.find((item) => item.name === "admin")!.status, "suggested");
107
+ });
@@ -0,0 +1,158 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+ import { ControlPlaneClient, type RegistryRole } from "../registry/control-plane-client.js";
5
+ import {
6
+ actorProblem, normalizeRoleName, specRoleStatus, suggestRoles, suggestedRoleNames, usableRoleNames,
7
+ type RoleRecord, type RoleSuggestion,
8
+ } from "../registry/roles.js";
9
+
10
+ /**
11
+ * The runtime side of roles (see src/registry/roles.ts): reading the
12
+ * project's spec/roles.yaml and the registry's decisions, telling the
13
+ * planner which actors it may use, and turning discovery evidence into
14
+ * suggestions a person decides on.
15
+ */
16
+
17
+ function readYaml(filePath: string): any {
18
+ try {
19
+ return YAML.parse(fs.readFileSync(filePath, "utf8"));
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function specDirOf(cfg: any): string {
26
+ return cfg?.runtime?.spec_dir || "./spec";
27
+ }
28
+
29
+ /** spec/roles.yaml as role records; an entry without `status` is confirmed. */
30
+ export function readSpecRoles(specDir: string): RoleRecord[] {
31
+ const doc = readYaml(path.join(specDir, "roles.yaml"));
32
+ if (!Array.isArray(doc?.roles)) return [];
33
+ return doc.roles
34
+ .filter((role: any) => typeof role?.name === "string")
35
+ .map((role: any) => ({
36
+ name: role.name,
37
+ description: typeof role.description === "string" ? role.description : null,
38
+ status: specRoleStatus(role),
39
+ source: "spec",
40
+ }));
41
+ }
42
+
43
+ /** A registry client when this machine has a project API key; otherwise null. */
44
+ export function rolesClient(cfg: any, env: Record<string, string | undefined> = process.env): ControlPlaneClient | null {
45
+ const apiKey = env.BUGMOLE_API_KEY?.trim() || cfg?.registry?.api_key;
46
+ const projectId = cfg?.project?.id;
47
+ if (!apiKey || !projectId) return null;
48
+ const registryUrl = env.BUGMOLE_REGISTRY_URL?.trim() || cfg?.registry?.url || "https://api.bugmole.com";
49
+ return new ControlPlaneClient({ registryUrl, apiKey, projectId });
50
+ }
51
+
52
+ export type ProjectRoles = {
53
+ spec: RoleRecord[];
54
+ registry: RoleRecord[];
55
+ /** False when there is a registry but it couldn't be read; roles.yaml decided alone. */
56
+ registryRead: boolean;
57
+ usable: string[];
58
+ suggested: string[];
59
+ };
60
+
61
+ /** Everything the planner and pipeline need to know about roles right now. */
62
+ export async function projectRoles(cfg: any, client: Pick<ControlPlaneClient, "listRoles"> | null = rolesClient(cfg)): Promise<ProjectRoles> {
63
+ const spec = readSpecRoles(specDirOf(cfg));
64
+ let registry: RoleRecord[] = [];
65
+ let registryRead = true;
66
+ if (client) {
67
+ try {
68
+ registry = (await client.listRoles()).roles.map((role: RegistryRole) => ({ name: role.name, description: role.description, status: role.status, source: role.source }));
69
+ } catch {
70
+ registryRead = false;
71
+ }
72
+ }
73
+ return { spec, registry, registryRead, usable: usableRoleNames(spec, registry), suggested: suggestedRoleNames(spec, registry) };
74
+ }
75
+
76
+ /** Why `actor` can't be planned for, or null when it is a confirmed role. */
77
+ export function projectActorProblem(roles: ProjectRoles, actor: string): string | null {
78
+ return actorProblem(actor, roles.spec, roles.registry);
79
+ }
80
+
81
+ /** Suggestions from the project's journey graph, blockers and roles.yaml. */
82
+ export function collectRoleSuggestions(cfg: any): RoleSuggestion[] {
83
+ const specDir = specDirOf(cfg);
84
+ const graph = readYaml(path.join(specDir, "journeys.graph.json"));
85
+ const blockers = readYaml(path.join(specDir, "blockers.yaml"));
86
+ const roles = readYaml(path.join(specDir, "roles.yaml"));
87
+ return suggestRoles({
88
+ nodes: Array.isArray(graph?.nodes) ? graph.nodes : [],
89
+ edges: Array.isArray(graph?.edges) ? graph.edges : [],
90
+ journeys: Array.isArray(graph?.journeys) ? graph.journeys : [],
91
+ blockers: Array.isArray(blockers?.blockers) ? blockers.blockers : [],
92
+ specRoles: Array.isArray(roles?.roles) ? roles.roles : [],
93
+ });
94
+ }
95
+
96
+ export type ProposedRole = { name: string; source: string; status: string; alreadyDecided?: boolean; error?: string };
97
+
98
+ /**
99
+ * Sends each suggestion to the registry, where it waits for a person. Roles
100
+ * a person already decided on (or that roles.yaml already confirms) are
101
+ * left alone, so re-running discovery never re-asks a settled question.
102
+ */
103
+ export async function proposeRoleSuggestions(
104
+ suggestions: RoleSuggestion[],
105
+ client: Pick<ControlPlaneClient, "proposeRole">,
106
+ alreadyUsable: string[] = [],
107
+ ): Promise<ProposedRole[]> {
108
+ const results: ProposedRole[] = [];
109
+ for (const suggestion of suggestions) {
110
+ if (alreadyUsable.includes(suggestion.name)) continue;
111
+ try {
112
+ const { role, alreadyDecided } = await client.proposeRole(suggestion);
113
+ results.push({ name: role.name, source: suggestion.source, status: role.status, ...(alreadyDecided ? { alreadyDecided } : {}) });
114
+ } catch (error) {
115
+ results.push({ name: suggestion.name, source: suggestion.source, status: "not_sent", error: error instanceof Error ? error.message : String(error) });
116
+ }
117
+ }
118
+ return results;
119
+ }
120
+
121
+ /**
122
+ * An agent writing spec/roles.yaml may add roles, but only as suggestions:
123
+ * any role it adds, or tries to promote to confirmed, is written
124
+ * `status: suggested` (a rejected one stays rejected). Roles a person
125
+ * already confirmed are kept as they were. Returns the content to write and
126
+ * the names it held back.
127
+ */
128
+ export function holdAgentRoleWrites(previous: string | null, next: string): { content: string; heldBack: string[] } {
129
+ let doc: any;
130
+ try {
131
+ doc = YAML.parse(next);
132
+ } catch {
133
+ return { content: next, heldBack: [] };
134
+ }
135
+ if (!Array.isArray(doc?.roles)) return { content: next, heldBack: [] };
136
+ let before: any = null;
137
+ try {
138
+ before = previous ? YAML.parse(previous) : null;
139
+ } catch {
140
+ before = null;
141
+ }
142
+ const previousStatus = new Map<string, string>();
143
+ for (const role of Array.isArray(before?.roles) ? before.roles : []) {
144
+ if (typeof role?.name === "string") previousStatus.set(normalizeRoleName(role.name) ?? role.name, specRoleStatus(role));
145
+ }
146
+ const heldBack: string[] = [];
147
+ for (const role of doc.roles) {
148
+ if (typeof role?.name !== "string") continue;
149
+ const key = normalizeRoleName(role.name) ?? role.name;
150
+ const wanted = specRoleStatus(role);
151
+ const had = previousStatus.get(key);
152
+ if (wanted !== "confirmed") continue;
153
+ if (had === "confirmed") continue;
154
+ role.status = had === "rejected" ? "rejected" : "suggested";
155
+ heldBack.push(role.name);
156
+ }
157
+ return heldBack.length ? { content: YAML.stringify(doc), heldBack } : { content: next, heldBack };
158
+ }
@@ -0,0 +1,66 @@
1
+ // `bugmole propose <file>`: a developer proposes test-case changes from a
2
+ // file, the way an agent does with bugmole_propose_test_case.
3
+ //
4
+ // { "caseSetId": "login", "summary": "Why, for every change below",
5
+ // "changes": [ { "caseId": "TC-login", "action": "change", "case": { ... } },
6
+ // { "caseId": "TC-old", "action": "retire", "summary": "Why this one" } ] }
7
+ //
8
+ // Each change names its case; the revision being changed is read from the
9
+ // project's storage, so the file never has to track revision numbers.
10
+ import fs from "node:fs";
11
+ import { ControlPlaneClient, type RegistryProposal } from "../registry/control-plane-client.js";
12
+ import { activeRevision, caseProblem, revisionOf, type CaseSetDocument } from "../registry/test-case-revisions.js";
13
+ import { createProjectObjectStore } from "../storage/create-object-store.js";
14
+ import { projectPrefix } from "../storage/keys.js";
15
+ import { applyAcceptedProposals } from "./apply-proposals.js";
16
+
17
+ type ProposalFile = {
18
+ caseSetId?: unknown;
19
+ summary?: unknown;
20
+ changes?: Array<{ caseId?: unknown; action?: unknown; case?: Record<string, unknown>; summary?: unknown }>;
21
+ };
22
+
23
+ export function registryClient(cfg: any, env: Record<string, string | undefined> = process.env): ControlPlaneClient {
24
+ const apiKey = env.BUGMOLE_API_KEY ?? cfg.registry?.api_key;
25
+ if (!apiKey) throw new Error("BUGMOLE_API_KEY is required to propose changes");
26
+ if (!cfg.project?.id) throw new Error("A project id is required to propose changes");
27
+ return new ControlPlaneClient({ registryUrl: env.BUGMOLE_REGISTRY_URL ?? cfg.registry?.url ?? "https://api.bugmole.com", apiKey, projectId: cfg.project.id });
28
+ }
29
+
30
+ /** Proposes every change in the file; stops at the first invalid one before sending anything. */
31
+ export async function proposeFromFile(cfg: any, filePath: string, client: Pick<ControlPlaneClient, "proposeTestCaseChange" | "listUnappliedProposals" | "reportProposalApplied"> = registryClient(cfg)): Promise<RegistryProposal[]> {
32
+ const file = JSON.parse(fs.readFileSync(filePath, "utf8")) as ProposalFile;
33
+ const caseSetId = typeof file.caseSetId === "string" ? file.caseSetId : "";
34
+ if (!caseSetId) throw new Error(`${filePath}: caseSetId is required`);
35
+ if (!Array.isArray(file.changes) || !file.changes.length) throw new Error(`${filePath}: changes must list at least one change`);
36
+ const store = createProjectObjectStore(cfg);
37
+ const key = `${projectPrefix(cfg.project?.id ?? "default")}/test-cases/${caseSetId}.json`;
38
+ if (!(await store.has(key))) throw new Error(`There's no case set ${caseSetId} in this project's storage`);
39
+ const caseSet = JSON.parse(await store.getText(key)) as CaseSetDocument;
40
+
41
+ const requests = file.changes.map((change, index) => {
42
+ const where = `${filePath}: change ${index + 1}`;
43
+ const caseId = typeof change.caseId === "string" ? change.caseId : "";
44
+ const action = change.action as "add" | "change" | "retire";
45
+ const summary = typeof change.summary === "string" && change.summary.trim() ? change.summary : typeof file.summary === "string" ? file.summary : "";
46
+ if (action !== "add" && action !== "change" && action !== "retire") throw new Error(`${where}: action must be add, change or retire`);
47
+ if (!summary.trim()) throw new Error(`${where}: say why in summary`);
48
+ const current = activeRevision(caseSet, caseId);
49
+ if (action !== "add" && !current) throw new Error(`${where}: ${caseId} has no current revision`);
50
+ if (action === "add" && current) throw new Error(`${where}: ${caseId} already exists; use change`);
51
+ if (action !== "retire") {
52
+ const problem = caseProblem({ ...(change.case ?? {}), caseId } as never);
53
+ if (problem) throw new Error(`${where}: ${problem}`);
54
+ }
55
+ return {
56
+ caseSetId, caseId, action, summary, source: "cli" as const,
57
+ ...(current ? { baseRevision: revisionOf(current) } : {}),
58
+ ...(action !== "retire" ? { case: change.case } : {}),
59
+ };
60
+ });
61
+ const proposals: RegistryProposal[] = [];
62
+ for (const request of requests) proposals.push((await client.proposeTestCaseChange(request)).proposal);
63
+ // Accepted on arrival (extreme mode) on the project's own storage: write them now.
64
+ if (proposals.some((proposal) => proposal.status === "accepted" && !proposal.appliedAt)) await applyAcceptedProposals(cfg, client);
65
+ return proposals;
66
+ }
@@ -0,0 +1,58 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { recordRunVerdicts } from "./record-run-verdicts.js";
7
+
8
+ function setup() {
9
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-verdicts-"));
10
+ const cfg = { storage: { provider: "local", local: { directory } } };
11
+ const base = path.join(directory, "projects", "shop");
12
+ fs.mkdirSync(path.join(base, "test-cases"), { recursive: true });
13
+ fs.writeFileSync(path.join(base, "test-cases", "checkout-cases.json"), JSON.stringify({
14
+ caseSetId: "checkout-cases",
15
+ journeyId: "checkout",
16
+ cases: [
17
+ { caseId: "TC-login", nodeId: "screen:/login", title: "Sign in" },
18
+ { caseId: "TC-logout", nodeId: "screen:/logout", title: "Sign out" },
19
+ ],
20
+ }));
21
+ const read = (relative: string) => JSON.parse(fs.readFileSync(path.join(base, relative), "utf8"));
22
+ const history = () => fs.readdirSync(path.join(base, "test-case-history", "checkout-cases"));
23
+ return { cfg, directory, read, history };
24
+ }
25
+
26
+ test("a scoped retest judges only its cases and keeps every other case's latest verdict", async () => {
27
+ const { cfg, directory, read, history } = setup();
28
+ try {
29
+ const nodes = [
30
+ { nodeId: "screen:/login", outcome: "PASS" as const, status: "success" as const },
31
+ { nodeId: "screen:/logout", outcome: "FAIL" as const, status: "failed" as const, reason: { message: "Nothing happened" } },
32
+ ];
33
+ const full = await recordRunVerdicts(cfg, "shop", "checkout", nodes, "run_full");
34
+ assert.deepEqual(full, { caseSetId: "checkout-cases", judged: 2 });
35
+
36
+ const retest = await recordRunVerdicts(
37
+ cfg,
38
+ "shop",
39
+ "checkout",
40
+ [{ nodeId: "screen:/login", outcome: "FAIL", status: "failed" }, { nodeId: "screen:/logout", outcome: "PASS", status: "success" }],
41
+ "run_retest",
42
+ undefined,
43
+ { caseSetId: "checkout-cases", caseIds: ["TC-logout"] },
44
+ );
45
+ assert.deepEqual(retest, { caseSetId: "checkout-cases", judged: 1 });
46
+ const latest = read("test-case-results/checkout-cases.json");
47
+ assert.equal(latest.runId, "run_retest");
48
+ const byCase = Object.fromEntries(latest.results.map((item: { caseId: string; status: string }) => [item.caseId, item.status]));
49
+ assert.deepEqual(byCase, { "TC-login": "passed", "TC-logout": "passed" }, "the login verdict is the full run's, not the retest's");
50
+
51
+ const entries = history();
52
+ assert.equal(entries.length, 2);
53
+ const retestEntry = JSON.parse(fs.readFileSync(path.join(directory, "projects", "shop", "test-case-history", "checkout-cases", entries.find((name) => name.endsWith("-run_retest.json"))!), "utf8"));
54
+ assert.deepEqual(retestEntry.results.map((item: { caseId: string }) => item.caseId), ["TC-logout"], "history holds only what the retest judged");
55
+ } finally {
56
+ fs.rmSync(directory, { recursive: true, force: true });
57
+ }
58
+ });