@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
@@ -0,0 +1,161 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+ import { RegistryRequestError, projectInactiveMessage } from "../registry/control-plane-client.js";
8
+ import { runInitWizard } from "./init-wizard.js";
9
+ import { SCOPE_MESSAGE, describeDetection, detectProjectIdentity, discoverApps } from "./project-identity.js";
10
+ import { announceProjectIdentity, isClaimConflict } from "./serve-worker.js";
11
+
12
+ async function monorepo(remote = "https://ada:ghp_secret@github.com/Acme/Shop.git") {
13
+ const root = await mkdtemp(path.join(os.tmpdir(), "bugmole-identity-"));
14
+ execFileSync("git", ["init", "-q"], { cwd: root });
15
+ execFileSync("git", ["remote", "add", "origin", remote], { cwd: root });
16
+ await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "shop", private: true, workspaces: ["apps/*", "packages/*"], devDependencies: { react: "19" } }));
17
+ await mkdir(path.join(root, "apps/web/src/app"), { recursive: true });
18
+ await writeFile(path.join(root, "apps/web/package.json"), JSON.stringify({ dependencies: { next: "16", react: "19" } }));
19
+ await mkdir(path.join(root, "apps/mobile"), { recursive: true });
20
+ await writeFile(path.join(root, "apps/mobile/package.json"), JSON.stringify({ dependencies: { expo: "54", "react-native": "0.81" } }));
21
+ await mkdir(path.join(root, "packages/ui"), { recursive: true });
22
+ await writeFile(path.join(root, "packages/ui/package.json"), JSON.stringify({ name: "ui", peerDependencies: { react: "19" } }));
23
+ await mkdir(path.join(root, "apps/web/node_modules/next"), { recursive: true });
24
+ await writeFile(path.join(root, "apps/web/node_modules/next/package.json"), JSON.stringify({ dependencies: { react: "19" } }));
25
+ return root;
26
+ }
27
+
28
+ function captureConsole() {
29
+ const lines: string[] = [];
30
+ const original = { log: console.log, warn: console.warn };
31
+ console.log = (...args: unknown[]) => lines.push(args.join(" "));
32
+ console.warn = (...args: unknown[]) => lines.push(args.join(" "));
33
+ return { lines, restore: () => Object.assign(console, original) };
34
+ }
35
+
36
+ test("a monorepo's apps are found separately, and a workspace root isn't one of them", async () => {
37
+ const root = await monorepo();
38
+ try {
39
+ assert.deepEqual(discoverApps(root), [
40
+ { path: "apps/mobile", kind: "mobile", framework: "Expo" },
41
+ { path: "apps/web", kind: "web", framework: "Next.js" },
42
+ ]);
43
+ const atRoot = detectProjectIdentity({ cwd: root });
44
+ assert.equal(atRoot.reason, "ambiguous", "never collapses two apps into one project");
45
+ assert.equal(atRoot.identity, null);
46
+ assert.match(describeDetection(atRoot).join("\n"), /has 2 apps: apps\/mobile \(Expo\), apps\/web \(Next\.js\)[\s\S]*--app-path/);
47
+
48
+ const inWeb = detectProjectIdentity({ cwd: path.join(root, "apps/web/src/app") });
49
+ assert.deepEqual(inWeb.identity, { kind: "repo", remote: "github.com/acme/shop", appPath: "apps/web" });
50
+ assert.equal(inWeb.preview?.key, "repo:github.com/acme/shop#apps/web");
51
+ assert.doesNotMatch(JSON.stringify(inWeb.identity), /ghp_secret|ada/, "the remote's credentials never leave the machine");
52
+
53
+ const chosen = detectProjectIdentity({ cwd: root, appPath: "apps/mobile", appUrl: "http://localhost:8081" });
54
+ assert.deepEqual(chosen.identity, { kind: "repo", remote: "github.com/acme/shop", appPath: "apps/mobile", appUrl: "http://localhost:8081" });
55
+ assert.equal(chosen.app?.kind, "mobile");
56
+ } finally {
57
+ await rm(root, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ test("without a remote, a public URL identifies the app by domain; data projects are out of scope", async () => {
62
+ const app = await mkdtemp(path.join(os.tmpdir(), "bugmole-identity-solo-"));
63
+ const data = await mkdtemp(path.join(os.tmpdir(), "bugmole-identity-data-"));
64
+ try {
65
+ execFileSync("git", ["init", "-q"], { cwd: app });
66
+ await writeFile(path.join(app, "package.json"), JSON.stringify({ dependencies: { vue: "3" } }));
67
+ assert.equal(detectProjectIdentity({ cwd: app }).reason, "unidentified");
68
+ assert.deepEqual(detectProjectIdentity({ cwd: app, appUrl: "https://www.shop.example.com/cart" }).preview?.key, "domain:shop.example.com");
69
+
70
+ await writeFile(path.join(data, "train.ipynb"), "{}");
71
+ await writeFile(path.join(data, "requirements.txt"), "pandas\ntorch\n");
72
+ const detection = detectProjectIdentity({ cwd: data });
73
+ assert.equal(detection.reason, "no_app");
74
+ assert.ok(describeDetection(detection).includes(SCOPE_MESSAGE));
75
+ assert.match(SCOPE_MESSAGE, /web apps and mobile apps \(Android and iOS\).*out of scope/);
76
+ } finally {
77
+ await rm(app, { recursive: true, force: true });
78
+ await rm(data, { recursive: true, force: true });
79
+ }
80
+ });
81
+
82
+ test("bugmole init says when there's no web or mobile app to test", async () => {
83
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "bugmole-init-scope-"));
84
+ const output = captureConsole();
85
+ try {
86
+ await runInitWizard({ cwd, nonInteractive: true });
87
+ } finally {
88
+ output.restore();
89
+ await rm(cwd, { recursive: true, force: true });
90
+ }
91
+ assert.ok(output.lines.includes(SCOPE_MESSAGE), output.lines.join("\n"));
92
+ });
93
+
94
+ test("bugmole init sends the app's identity and joins the project that already exists for it", async () => {
95
+ const root = await monorepo();
96
+ const cwd = path.join(root, "apps/web");
97
+ const saved = { key: process.env.BUGMOLE_API_KEY, registry: process.env.BUGMOLE_REGISTRY_URL };
98
+ const realFetch = globalThis.fetch;
99
+ const sent: Array<Record<string, any>> = [];
100
+ process.env.BUGMOLE_API_KEY = "bm_live_test";
101
+ process.env.BUGMOLE_REGISTRY_URL = "https://registry.test";
102
+ globalThis.fetch = (async (_url: string, init: RequestInit) => {
103
+ sent.push(JSON.parse(String(init.body)));
104
+ return Response.json({ project: { id: "shop-web" }, existing: true }, { status: 200 });
105
+ }) as typeof fetch;
106
+ const output = captureConsole();
107
+ try {
108
+ const result = await runInitWizard({ cwd, nonInteractive: true, projectId: "my-copy" });
109
+ assert.equal(result.config.projectId, "shop-web");
110
+ assert.equal(result.config.appPath, "apps/web");
111
+ const config = await readFile(path.join(cwd, result.configPath), "utf8");
112
+ assert.match(config, /^project:\n id: shop-web$/m);
113
+ assert.match(config, /app_path: "apps\/web"/);
114
+ assert.match(await readFile(path.join(cwd, result.envPath), "utf8"), /^BUGMOLE_PROJECT_ID=shop-web$/m);
115
+ } finally {
116
+ output.restore();
117
+ globalThis.fetch = realFetch;
118
+ for (const [name, value] of [["BUGMOLE_API_KEY", saved.key], ["BUGMOLE_REGISTRY_URL", saved.registry]] as const) {
119
+ if (value === undefined) delete process.env[name];
120
+ else process.env[name] = value;
121
+ }
122
+ await rm(root, { recursive: true, force: true });
123
+ }
124
+ assert.deepEqual(sent[0].identity, { kind: "repo", remote: "github.com/acme/shop", appPath: "apps/web" });
125
+ assert.match(output.lines.join("\n"), /Found a web app \(Next\.js\) at apps\/web/);
126
+ assert.match(output.lines.join("\n"), /already project shop-web in the workspace; joined it/);
127
+ });
128
+
129
+ test("a paused project is its own refusal: serve prints the activation link instead of treating it as a lost claim", async () => {
130
+ const paused = new RegistryRequestError(409, "project_inactive", "This project is paused", { activateUrl: "https://dash.test/?project=shop" });
131
+ assert.equal(isClaimConflict(paused), false);
132
+ assert.equal(isClaimConflict(new RegistryRequestError(409)), true);
133
+ assert.match(paused.message, /This project is paused \(409 project_inactive\)/);
134
+ assert.equal(
135
+ projectInactiveMessage("shop", "https://dash.test/?project=shop"),
136
+ "Project shop is paused because nobody used it for a while, so it won't run tests or tasks. A project member can activate it: https://dash.test/?project=shop.",
137
+ );
138
+
139
+ const root = await monorepo();
140
+ const logged: string[] = [];
141
+ const log = { log: (line: string) => logged.push(line), warn: (line: string) => logged.push(line) };
142
+ const reported: unknown[] = [];
143
+ try {
144
+ await announceProjectIdentity({
145
+ reportIdentity: async (identity) => {
146
+ reported.push(identity);
147
+ return { identity: null, inactiveAt: "2026-09-01T00:00:00Z", activateUrl: "https://dash.test/?project=shop" };
148
+ },
149
+ }, "shop", { cwd: root, appPath: "apps/web" }, log);
150
+ await announceProjectIdentity({
151
+ reportIdentity: async () => {
152
+ throw new RegistryRequestError(409, "identity_mismatch", "This project is github.com/acme/shop (apps/web), not github.com/acme/shop (apps/mobile).");
153
+ },
154
+ }, "shop", { cwd: root, appPath: "apps/mobile" }, log);
155
+ } finally {
156
+ await rm(root, { recursive: true, force: true });
157
+ }
158
+ assert.deepEqual(reported, [{ kind: "repo", remote: "github.com/acme/shop", appPath: "apps/web" }]);
159
+ assert.match(logged[0], /Project shop is paused .* activate it: https:\/\/dash\.test\/\?project=shop/);
160
+ assert.match(logged[1], /not github\.com\/acme\/shop \(apps\/mobile\)/);
161
+ });
@@ -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
+ }