@bugmole/cli 0.4.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 (308) hide show
  1. package/.bugmole.env.example +20 -0
  2. package/LICENSE +7 -0
  3. package/README.md +293 -0
  4. package/TESTING.md +117 -0
  5. package/bugmole.config.yaml +39 -0
  6. package/package.json +85 -0
  7. package/scripts/billing/paypal-setup.mjs +121 -0
  8. package/scripts/bugmole-continue.ts +318 -0
  9. package/scripts/bugmole-init.ts +188 -0
  10. package/scripts/bugmole.cjs +17 -0
  11. package/scripts/bugmole.test.ts +344 -0
  12. package/scripts/bugmole.ts +657 -0
  13. package/scripts/ensure-maestro.cjs +79 -0
  14. package/scripts/ios-tunnel-keeper.sh +45 -0
  15. package/scripts/ios-wda-keeper.sh +66 -0
  16. package/scripts/sync-plan-catalog.d.mts +3 -0
  17. package/scripts/sync-plan-catalog.mjs +16 -0
  18. package/scripts/ui-parity-diff.py +65 -0
  19. package/scripts/ui-parity-requirements.txt +1 -0
  20. package/scripts/verify-manage-to-plans.mts +194 -0
  21. package/spec/app-ui-audit.schema.json +176 -0
  22. package/spec/blockers.yaml +79 -0
  23. package/spec/bugs.index.json +42 -0
  24. package/spec/design-dna.schema.json +38 -0
  25. package/spec/domain_rules.yaml +24 -0
  26. package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
  27. package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
  28. package/spec/journey_graph.yaml +126 -0
  29. package/spec/journeys.graph.json +2618 -0
  30. package/spec/plans/dashboard-smoke.flow.yaml +20 -0
  31. package/spec/plans/example.flow.yaml +99 -0
  32. package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
  33. package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
  34. package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
  35. package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
  36. package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
  37. package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
  38. package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
  39. package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
  40. package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
  41. package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
  42. package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
  43. package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
  44. package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
  45. package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
  46. package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
  47. package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
  48. package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
  49. package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
  50. package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
  51. package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
  52. package/spec/plans/login.flow.yaml +22 -0
  53. package/spec/plans/owner-operations.flow.yaml +20 -0
  54. package/spec/project_config.yaml +55 -0
  55. package/spec/roles.yaml +30 -0
  56. package/spec/schema.md +394 -0
  57. package/spec/test-case-results.schema.json +62 -0
  58. package/spec/test-cases.schema.json +85 -0
  59. package/spec/ui-parity-audit.schema.json +194 -0
  60. package/spec/ui-reverse-engineering.schema.json +94 -0
  61. package/src/billing/plan-catalog.test.ts +46 -0
  62. package/src/billing/plan-catalog.ts +199 -0
  63. package/src/integrations/aws-sigv4.test.ts +42 -0
  64. package/src/integrations/aws-sigv4.ts +72 -0
  65. package/src/integrations/device-farm.ts +155 -0
  66. package/src/integrations/github-app.test.ts +57 -0
  67. package/src/integrations/github-app.ts +143 -0
  68. package/src/integrations/gitlab.ts +81 -0
  69. package/src/integrations/temp-email.test.ts +123 -0
  70. package/src/integrations/temp-email.ts +175 -0
  71. package/src/integrations/testflight-feedback.test.ts +51 -0
  72. package/src/integrations/testflight-feedback.ts +173 -0
  73. package/src/integrations/webdriver-client.ts +131 -0
  74. package/src/mcp/server.test.ts +1220 -0
  75. package/src/mcp/server.ts +3064 -0
  76. package/src/mcp/write-test-cases.test.ts +287 -0
  77. package/src/registry/api-key-client.ts +39 -0
  78. package/src/registry/control-plane-client.ts +212 -0
  79. package/src/registry/migrations/0001_registry.sql +47 -0
  80. package/src/registry/migrations/0002_device_authorizations.sql +23 -0
  81. package/src/registry/migrations/0003_project_environments.sql +25 -0
  82. package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
  83. package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
  84. package/src/registry/migrations/0006_workspaces.sql +36 -0
  85. package/src/registry/migrations/0007_project_apps.sql +26 -0
  86. package/src/registry/migrations/0008_agent_tasks.sql +30 -0
  87. package/src/registry/migrations/0009_journey_revisions.sql +17 -0
  88. package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
  89. package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
  90. package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
  91. package/src/registry/migrations/0013_device_sessions.sql +22 -0
  92. package/src/registry/migrations/0014_agent_task_step.sql +1 -0
  93. package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
  94. package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
  95. package/src/registry/migrations/0017_run_targets.sql +7 -0
  96. package/src/registry/migrations/0018_run_fix_from.sql +4 -0
  97. package/src/registry/migrations/0019_workspace_flags.sql +9 -0
  98. package/src/registry/migrations/0020_orgs.sql +40 -0
  99. package/src/registry/migrations/0021_billing_core.sql +58 -0
  100. package/src/registry/migrations/0022_cloud_runners.sql +19 -0
  101. package/src/registry/migrations/0023_signup.sql +4 -0
  102. package/src/registry/migrations/0024_billing.sql +67 -0
  103. package/src/registry/migrations/0025_notifications.sql +47 -0
  104. package/src/registry/migrations/0026_repo_bindings.sql +28 -0
  105. package/src/registry/migrations/0027_feedback.sql +29 -0
  106. package/src/registry/migrations/0028_devices.sql +48 -0
  107. package/src/registry/migrations/0029_sso.sql +31 -0
  108. package/src/registry/migrations/0030_workspace_domains.sql +18 -0
  109. package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
  110. package/src/registry/migrations/0032_personas.sql +15 -0
  111. package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
  112. package/src/registry/task-scheduling.test.ts +100 -0
  113. package/src/registry/task-scheduling.ts +80 -0
  114. package/src/registry-worker/ai/platform-model.ts +77 -0
  115. package/src/registry-worker/ai/routes.test.ts +88 -0
  116. package/src/registry-worker/ai/routes.ts +90 -0
  117. package/src/registry-worker/artifacts.test.ts +98 -0
  118. package/src/registry-worker/artifacts.ts +85 -0
  119. package/src/registry-worker/billing/billing-core.test.ts +175 -0
  120. package/src/registry-worker/billing/checkout-routes.ts +209 -0
  121. package/src/registry-worker/billing/enforcement.ts +69 -0
  122. package/src/registry-worker/billing/entitlements.ts +108 -0
  123. package/src/registry-worker/billing/ledger.ts +186 -0
  124. package/src/registry-worker/billing/paypal/api.ts +259 -0
  125. package/src/registry-worker/billing/paypal/client.ts +91 -0
  126. package/src/registry-worker/billing/paypal/provider.ts +143 -0
  127. package/src/registry-worker/billing/paypal.test.ts +466 -0
  128. package/src/registry-worker/billing/provider.ts +114 -0
  129. package/src/registry-worker/billing/routes.ts +66 -0
  130. package/src/registry-worker/billing/subscriptions.ts +780 -0
  131. package/src/registry-worker/billing/thresholds.ts +107 -0
  132. package/src/registry-worker/core.ts +308 -0
  133. package/src/registry-worker/devices/devices.test.ts +185 -0
  134. package/src/registry-worker/devices/policy.ts +71 -0
  135. package/src/registry-worker/devices/routes.ts +453 -0
  136. package/src/registry-worker/domains/domains.test.ts +210 -0
  137. package/src/registry-worker/domains/routes.ts +139 -0
  138. package/src/registry-worker/domains.ts +88 -0
  139. package/src/registry-worker/email/sender.ts +75 -0
  140. package/src/registry-worker/env.d.ts +14716 -0
  141. package/src/registry-worker/features.ts +20 -0
  142. package/src/registry-worker/feedback/feedback.test.ts +230 -0
  143. package/src/registry-worker/feedback/format.ts +148 -0
  144. package/src/registry-worker/feedback/routes.ts +386 -0
  145. package/src/registry-worker/flags.ts +39 -0
  146. package/src/registry-worker/github/checks.test.ts +177 -0
  147. package/src/registry-worker/github/checks.ts +374 -0
  148. package/src/registry-worker/gitlab/checks.test.ts +141 -0
  149. package/src/registry-worker/gitlab/checks.ts +349 -0
  150. package/src/registry-worker/hooks.ts +54 -0
  151. package/src/registry-worker/index.ts +2077 -0
  152. package/src/registry-worker/jobs/index.ts +29 -0
  153. package/src/registry-worker/jobs/retention.ts +68 -0
  154. package/src/registry-worker/mcp/mcp.test.ts +355 -0
  155. package/src/registry-worker/mcp/routes.ts +215 -0
  156. package/src/registry-worker/mcp/token.ts +126 -0
  157. package/src/registry-worker/mcp/tools.ts +563 -0
  158. package/src/registry-worker/notifications/alerts.ts +212 -0
  159. package/src/registry-worker/notifications/notifications.test.ts +298 -0
  160. package/src/registry-worker/notifications/outbox.ts +83 -0
  161. package/src/registry-worker/notifications/routes.ts +280 -0
  162. package/src/registry-worker/notifications/secrets.ts +49 -0
  163. package/src/registry-worker/notifications/slack.ts +96 -0
  164. package/src/registry-worker/notifications/teams.ts +46 -0
  165. package/src/registry-worker/org/audit.ts +116 -0
  166. package/src/registry-worker/org/routes.test.ts +163 -0
  167. package/src/registry-worker/org/routes.ts +302 -0
  168. package/src/registry-worker/personas/personas.test.ts +78 -0
  169. package/src/registry-worker/personas/routes.ts +100 -0
  170. package/src/registry-worker/repo-triggers.ts +20 -0
  171. package/src/registry-worker/routes/index.ts +74 -0
  172. package/src/registry-worker/run-events.ts +24 -0
  173. package/src/registry-worker/runner/dispatch.ts +219 -0
  174. package/src/registry-worker/runner/jobs.ts +43 -0
  175. package/src/registry-worker/runner/metering.ts +82 -0
  176. package/src/registry-worker/runner/policy.ts +59 -0
  177. package/src/registry-worker/runner/routes.ts +171 -0
  178. package/src/registry-worker/runner/runner.test.ts +358 -0
  179. package/src/registry-worker/runner/tokens.ts +93 -0
  180. package/src/registry-worker/runs.test.ts +60 -0
  181. package/src/registry-worker/signup/policy.ts +57 -0
  182. package/src/registry-worker/signup/routes.ts +106 -0
  183. package/src/registry-worker/signup/signup.test.ts +81 -0
  184. package/src/registry-worker/sso/aegis.ts +141 -0
  185. package/src/registry-worker/sso/membership.ts +157 -0
  186. package/src/registry-worker/sso/routes.ts +458 -0
  187. package/src/registry-worker/sso/sso.test.ts +344 -0
  188. package/src/registry-worker/testing/d1-shim.ts +180 -0
  189. package/src/registry-worker/testing/harness.ts +137 -0
  190. package/src/runner-worker/index.ts +108 -0
  191. package/src/runtime/ai-analysis.ts +97 -0
  192. package/src/runtime/ai-exploration.test.ts +32 -0
  193. package/src/runtime/ai-exploration.ts +69 -0
  194. package/src/runtime/ai-repair.ts +74 -0
  195. package/src/runtime/ai-work.test.ts +99 -0
  196. package/src/runtime/android-screen-record.test.ts +75 -0
  197. package/src/runtime/android-screen-record.ts +192 -0
  198. package/src/runtime/app-understanding.test.ts +123 -0
  199. package/src/runtime/app-understanding.ts +201 -0
  200. package/src/runtime/appium-driver.test.ts +179 -0
  201. package/src/runtime/appium-driver.ts +295 -0
  202. package/src/runtime/blocker-resolution.test.ts +113 -0
  203. package/src/runtime/blocker-resolution.ts +111 -0
  204. package/src/runtime/browser-matrix.integration.test.ts +212 -0
  205. package/src/runtime/browser-matrix.test.ts +143 -0
  206. package/src/runtime/browser-matrix.ts +200 -0
  207. package/src/runtime/canonical-flow.test.ts +52 -0
  208. package/src/runtime/config-validate.ts +185 -0
  209. package/src/runtime/continuous-execution.ts +291 -0
  210. package/src/runtime/cursor-applescript.ts +573 -0
  211. package/src/runtime/cursor-cli-driver.test.ts +78 -0
  212. package/src/runtime/cursor-cli-driver.ts +156 -0
  213. package/src/runtime/cursor-driver-example.ts +117 -0
  214. package/src/runtime/cursor-driver-index.ts +65 -0
  215. package/src/runtime/cursor-driver-init.ts +277 -0
  216. package/src/runtime/cursor-driver-run.test.ts +15 -0
  217. package/src/runtime/cursor-driver-run.ts +323 -0
  218. package/src/runtime/cursor-driver.ts +332 -0
  219. package/src/runtime/cursor-llm-example.ts +90 -0
  220. package/src/runtime/cursor-llm.ts +206 -0
  221. package/src/runtime/cursor-mcp-monitor.ts +386 -0
  222. package/src/runtime/device-clouds/browserstack.ts +73 -0
  223. package/src/runtime/device-clouds/device-farm.ts +52 -0
  224. package/src/runtime/device-clouds/index.ts +92 -0
  225. package/src/runtime/device-clouds/kobiton.ts +70 -0
  226. package/src/runtime/device-clouds/targets.ts +44 -0
  227. package/src/runtime/device-clouds/types.ts +62 -0
  228. package/src/runtime/diff-proposal.ts +84 -0
  229. package/src/runtime/discovery-task.test.ts +29 -0
  230. package/src/runtime/discovery-task.ts +284 -0
  231. package/src/runtime/driver-recovery.ts +69 -0
  232. package/src/runtime/driver.ts +79 -0
  233. package/src/runtime/environment.test.ts +104 -0
  234. package/src/runtime/environment.ts +137 -0
  235. package/src/runtime/executor.test.ts +509 -0
  236. package/src/runtime/executor.ts +921 -0
  237. package/src/runtime/explorer.test.ts +101 -0
  238. package/src/runtime/explorer.ts +1013 -0
  239. package/src/runtime/failure-analysis.test.ts +111 -0
  240. package/src/runtime/failure-analysis.ts +272 -0
  241. package/src/runtime/fixtures/fake-maestro.sh +36 -0
  242. package/src/runtime/flow-language.test.ts +268 -0
  243. package/src/runtime/flow-language.ts +414 -0
  244. package/src/runtime/init-wizard.ts +354 -0
  245. package/src/runtime/ios-screen-record.test.ts +68 -0
  246. package/src/runtime/ios-screen-record.ts +155 -0
  247. package/src/runtime/journey-editor.ts +452 -0
  248. package/src/runtime/journey-evidence.test.ts +161 -0
  249. package/src/runtime/journey-evidence.ts +180 -0
  250. package/src/runtime/journey-graph.test.ts +257 -0
  251. package/src/runtime/journey-graph.ts +170 -0
  252. package/src/runtime/legacy-names.ts +32 -0
  253. package/src/runtime/llm-example.ts +105 -0
  254. package/src/runtime/llm.ts +527 -0
  255. package/src/runtime/local-browser.test.ts +45 -0
  256. package/src/runtime/local-browser.ts +48 -0
  257. package/src/runtime/local-registry-stub.test.ts +325 -0
  258. package/src/runtime/local-registry-stub.ts +803 -0
  259. package/src/runtime/maestro-driver.test.ts +84 -0
  260. package/src/runtime/maestro-driver.ts +209 -0
  261. package/src/runtime/mole-voice.ts +21 -0
  262. package/src/runtime/nav-crawl.test.ts +100 -0
  263. package/src/runtime/nav-crawl.ts +153 -0
  264. package/src/runtime/pipeline.test.ts +405 -0
  265. package/src/runtime/pipeline.ts +833 -0
  266. package/src/runtime/planner.test.ts +37 -0
  267. package/src/runtime/planner.ts +274 -0
  268. package/src/runtime/platform-ai.ts +76 -0
  269. package/src/runtime/playwright-driver.test.ts +93 -0
  270. package/src/runtime/playwright-driver.ts +620 -0
  271. package/src/runtime/project-spec.ts +140 -0
  272. package/src/runtime/record-run-verdicts.ts +68 -0
  273. package/src/runtime/reporter.test.ts +56 -0
  274. package/src/runtime/reporter.ts +158 -0
  275. package/src/runtime/reset.test.ts +44 -0
  276. package/src/runtime/reset.ts +61 -0
  277. package/src/runtime/reviewer.test.ts +73 -0
  278. package/src/runtime/reviewer.ts +158 -0
  279. package/src/runtime/run-job.ts +136 -0
  280. package/src/runtime/run-once.test.ts +207 -0
  281. package/src/runtime/run-once.ts +168 -0
  282. package/src/runtime/run.ts +132 -0
  283. package/src/runtime/screen-recording.ts +34 -0
  284. package/src/runtime/serve-gateway.test.ts +74 -0
  285. package/src/runtime/serve-gateway.ts +164 -0
  286. package/src/runtime/serve-worker.test.ts +23 -0
  287. package/src/runtime/serve-worker.ts +278 -0
  288. package/src/runtime/site-discovery.test.ts +168 -0
  289. package/src/runtime/site-discovery.ts +308 -0
  290. package/src/runtime/target-runner.ts +144 -0
  291. package/src/runtime/test-case-verdicts.test.ts +94 -0
  292. package/src/runtime/test-case-verdicts.ts +120 -0
  293. package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
  294. package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
  295. package/src/runtime/ui-reverse-engineering/types.ts +197 -0
  296. package/src/runtime/web-suite.test.ts +97 -0
  297. package/src/runtime/web-suite.ts +176 -0
  298. package/src/storage/create-object-store.ts +144 -0
  299. package/src/storage/keys.ts +34 -0
  300. package/src/storage/local-artifact-server.test.ts +314 -0
  301. package/src/storage/local-artifact-server.ts +357 -0
  302. package/src/storage/object-store.test.ts +28 -0
  303. package/src/storage/object-store.ts +101 -0
  304. package/src/storage/registry-object-store.ts +88 -0
  305. package/src/storage/remote-object-store.ts +104 -0
  306. package/src/storage/storage-directory.test.ts +43 -0
  307. package/src/storage/storage-directory.ts +24 -0
  308. package/tsconfig.json +24 -0
@@ -0,0 +1,28 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { describe, it } from "node:test";
6
+ import { LocalObjectStore, normalizeKey } from "./object-store.js";
7
+
8
+ describe("local object store contract", () => {
9
+ it("writes, reads, lists, and deletes provider-neutral keys", async () => {
10
+ const root = await mkdtemp(path.join(os.tmpdir(), "bugmole-store-"));
11
+ try {
12
+ const store = new LocalObjectStore(root);
13
+ await store.put("projects/demo/runs/run-1/manifest.json", '{"runId":"run-1"}', "application/json");
14
+ await store.put("projects/demo/runs/run-1/evidence/shot.png", new Uint8Array([1, 2, 3]), "image/png");
15
+ assert.equal(await store.getText("projects/demo/runs/run-1/manifest.json"), '{"runId":"run-1"}');
16
+ assert.equal((await store.list("projects/demo/runs/run-1/")).length, 2);
17
+ await store.deletePrefix("projects/demo/runs/run-1");
18
+ assert.equal(await store.has("projects/demo/runs/run-1/manifest.json"), false);
19
+ } finally {
20
+ await rm(root, { recursive: true, force: true });
21
+ }
22
+ });
23
+
24
+ it("rejects traversal keys", () => {
25
+ assert.throws(() => normalizeKey("../secret"));
26
+ assert.throws(() => normalizeKey("projects/./secret"));
27
+ });
28
+ });
@@ -0,0 +1,101 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /** "bugmole" is Bugmole-managed storage, reached through the registry. */
5
+ export type StorageProvider = "local" | "gcs" | "s3" | "r2" | "bugmole";
6
+
7
+ export type StoredObject = {
8
+ key: string;
9
+ size: number;
10
+ modifiedAt: string;
11
+ contentType?: string;
12
+ };
13
+
14
+ export type ObjectStore = {
15
+ provider: StorageProvider;
16
+ put(key: string, body: string | Uint8Array, contentType?: string): Promise<StoredObject>;
17
+ get(key: string): Promise<Uint8Array>;
18
+ getText(key: string): Promise<string>;
19
+ has(key: string): Promise<boolean>;
20
+ list(prefix: string): Promise<StoredObject[]>;
21
+ deletePrefix(prefix: string): Promise<void>;
22
+ publicUrl?(key: string, expiresInSeconds?: number): Promise<string>;
23
+ };
24
+
25
+ export function normalizeKey(key: string): string {
26
+ const normalized = key.replaceAll("\\", "/").replace(/^\/+/, "");
27
+ if (!normalized || normalized.split("/").some((part) => part === ".." || part === ".")) {
28
+ throw new Error(`Invalid storage key: ${key}`);
29
+ }
30
+ return normalized;
31
+ }
32
+
33
+ export class LocalObjectStore implements ObjectStore {
34
+ readonly provider = "local" as const;
35
+
36
+ constructor(private readonly root: string) {}
37
+
38
+ private filePath(key: string): string {
39
+ return path.join(this.root, normalizeKey(key));
40
+ }
41
+
42
+ async put(key: string, body: string | Uint8Array, contentType?: string): Promise<StoredObject> {
43
+ const filePath = this.filePath(key);
44
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
45
+ await fs.writeFile(filePath, body);
46
+ const stat = await fs.stat(filePath);
47
+ return { key: normalizeKey(key), size: stat.size, modifiedAt: stat.mtime.toISOString(), contentType };
48
+ }
49
+
50
+ async get(key: string): Promise<Uint8Array> {
51
+ return fs.readFile(this.filePath(key));
52
+ }
53
+
54
+ async getText(key: string): Promise<string> {
55
+ return fs.readFile(this.filePath(key), "utf8");
56
+ }
57
+
58
+ async has(key: string): Promise<boolean> {
59
+ try {
60
+ await fs.access(this.filePath(key));
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ async list(prefix: string): Promise<StoredObject[]> {
68
+ const root = this.filePath(prefix);
69
+ const results: StoredObject[] = [];
70
+ const walk = async (directory: string) => {
71
+ let entries;
72
+ try {
73
+ entries = await fs.readdir(directory, { withFileTypes: true });
74
+ } catch {
75
+ return;
76
+ }
77
+ for (const entry of entries) {
78
+ const absolute = path.join(directory, entry.name);
79
+ if (entry.isDirectory()) await walk(absolute);
80
+ else {
81
+ const stat = await fs.stat(absolute);
82
+ results.push({
83
+ key: normalizeKey(path.relative(this.root, absolute)),
84
+ size: stat.size,
85
+ modifiedAt: stat.mtime.toISOString(),
86
+ });
87
+ }
88
+ }
89
+ };
90
+ await walk(root);
91
+ return results.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
92
+ }
93
+
94
+ async deletePrefix(prefix: string): Promise<void> {
95
+ await fs.rm(this.filePath(prefix), { recursive: true, force: true });
96
+ }
97
+ }
98
+
99
+ export function json(value: unknown): string {
100
+ return JSON.stringify(value, null, 2);
101
+ }
@@ -0,0 +1,88 @@
1
+ import type { ObjectStore, StoredObject } from "./object-store.js";
2
+ import { normalizeKey } from "./object-store.js";
3
+
4
+ export type RegistryObjectStoreOptions = {
5
+ registryUrl: string;
6
+ /** A project API key, or a hosted runner's run token. */
7
+ token: string;
8
+ projectId: string;
9
+ fetch?: typeof fetch;
10
+ };
11
+
12
+ /**
13
+ * Bugmole-managed storage. Objects live in Bugmole's bucket and are read and
14
+ * written through the registry, so neither the CLI nor a hosted runner holds
15
+ * bucket credentials.
16
+ */
17
+ export class RegistryObjectStore implements ObjectStore {
18
+ readonly provider = "bugmole" as const;
19
+ private readonly base: string;
20
+ private readonly doFetch: typeof fetch;
21
+
22
+ constructor(private readonly options: RegistryObjectStoreOptions) {
23
+ this.base = `${options.registryUrl.replace(/\/$/, "")}/api/projects/${encodeURIComponent(options.projectId)}/artifacts`;
24
+ this.doFetch = options.fetch ?? fetch;
25
+ }
26
+
27
+ private async request(path: string, params: Record<string, string>, init: RequestInit = {}): Promise<Response> {
28
+ const url = `${this.base}${path}?${new URLSearchParams(params)}`;
29
+ const headers = new Headers(init.headers);
30
+ headers.set("authorization", `Bearer ${this.options.token}`);
31
+ return this.doFetch(url, { ...init, headers });
32
+ }
33
+
34
+ private async fail(response: Response, action: string): Promise<never> {
35
+ const body = await response.json().catch(() => ({})) as { error?: string };
36
+ throw new Error(`Bugmole storage couldn't ${action} (${response.status})${body.error ? `: ${body.error}` : ""}`);
37
+ }
38
+
39
+ async put(key: string, body: string | Uint8Array, contentType?: string): Promise<StoredObject> {
40
+ const normalized = normalizeKey(key);
41
+ const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
42
+ const response = await this.request("/object", { key: normalized }, {
43
+ method: "PUT",
44
+ headers: { "content-type": contentType ?? "application/octet-stream" },
45
+ body: bytes,
46
+ });
47
+ if (!response.ok) await this.fail(response, `save ${normalized}`);
48
+ return { key: normalized, size: bytes.byteLength, modifiedAt: new Date().toISOString(), contentType };
49
+ }
50
+
51
+ async get(key: string): Promise<Uint8Array> {
52
+ const normalized = normalizeKey(key);
53
+ const response = await this.request("/object", { key: normalized });
54
+ if (!response.ok) await this.fail(response, `read ${normalized}`);
55
+ return new Uint8Array(await response.arrayBuffer());
56
+ }
57
+
58
+ async getText(key: string): Promise<string> {
59
+ return new TextDecoder().decode(await this.get(key));
60
+ }
61
+
62
+ async has(key: string): Promise<boolean> {
63
+ const response = await this.request("/object", { key: normalizeKey(key) });
64
+ if (response.status === 404) return false;
65
+ if (!response.ok) await this.fail(response, `check ${key}`);
66
+ await response.body?.cancel();
67
+ return true;
68
+ }
69
+
70
+ async list(prefix: string): Promise<StoredObject[]> {
71
+ const objects: StoredObject[] = [];
72
+ let cursor: string | undefined;
73
+ do {
74
+ const response = await this.request("", { prefix: normalizeKey(prefix), ...(cursor ? { cursor } : {}) });
75
+ if (!response.ok) await this.fail(response, `list ${prefix}`);
76
+ const page = await response.json() as { objects: StoredObject[]; cursor?: string };
77
+ objects.push(...page.objects);
78
+ cursor = page.cursor;
79
+ } while (cursor);
80
+ return objects;
81
+ }
82
+
83
+ async deletePrefix(prefix: string): Promise<void> {
84
+ const folder = normalizeKey(prefix).replace(/\/?$/, "/");
85
+ const response = await this.request("", { prefix: folder }, { method: "DELETE" });
86
+ if (!response.ok) await this.fail(response, `delete ${folder}`);
87
+ }
88
+ }
@@ -0,0 +1,104 @@
1
+ import {
2
+ GetObjectCommand,
3
+ HeadObjectCommand,
4
+ ListObjectsV2Command,
5
+ PutObjectCommand,
6
+ DeleteObjectsCommand,
7
+ S3Client,
8
+ } from "@aws-sdk/client-s3";
9
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
10
+ import type { ObjectStore, StorageProvider, StoredObject } from "./object-store.js";
11
+
12
+ type S3Options = {
13
+ provider: "s3" | "r2";
14
+ bucket: string;
15
+ region: string;
16
+ endpoint?: string;
17
+ accessKeyId: string;
18
+ secretAccessKey: string;
19
+ };
20
+
21
+ export class S3ObjectStore implements ObjectStore {
22
+ readonly provider: StorageProvider;
23
+ private readonly client: S3Client;
24
+
25
+ constructor(private readonly options: S3Options) {
26
+ this.provider = options.provider;
27
+ this.client = new S3Client({
28
+ region: options.region,
29
+ endpoint: options.endpoint,
30
+ forcePathStyle: Boolean(options.endpoint),
31
+ credentials: {
32
+ accessKeyId: options.accessKeyId,
33
+ secretAccessKey: options.secretAccessKey,
34
+ },
35
+ });
36
+ }
37
+
38
+ async put(key: string, body: string | Uint8Array, contentType?: string): Promise<StoredObject> {
39
+ const normalized = key.replaceAll("\\", "/").replace(/^\/+/, "");
40
+ await this.client.send(new PutObjectCommand({
41
+ Bucket: this.options.bucket,
42
+ Key: normalized,
43
+ Body: body,
44
+ ContentType: contentType,
45
+ }));
46
+ return { key: normalized, size: typeof body === "string" ? Buffer.byteLength(body) : body.byteLength, modifiedAt: new Date().toISOString(), contentType };
47
+ }
48
+
49
+ async get(key: string): Promise<Uint8Array> {
50
+ const result = await this.client.send(new GetObjectCommand({ Bucket: this.options.bucket, Key: key }));
51
+ if (!result.Body) throw new Error(`Object has no body: ${key}`);
52
+ return new Uint8Array(await result.Body.transformToByteArray());
53
+ }
54
+
55
+ async getText(key: string): Promise<string> {
56
+ return new TextDecoder().decode(await this.get(key));
57
+ }
58
+
59
+ async has(key: string): Promise<boolean> {
60
+ try {
61
+ await this.client.send(new HeadObjectCommand({ Bucket: this.options.bucket, Key: key }));
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ async list(prefix: string): Promise<StoredObject[]> {
69
+ const results: StoredObject[] = [];
70
+ let token: string | undefined;
71
+ do {
72
+ const page = await this.client.send(new ListObjectsV2Command({
73
+ Bucket: this.options.bucket,
74
+ Prefix: prefix,
75
+ ContinuationToken: token,
76
+ }));
77
+ for (const item of page.Contents ?? []) {
78
+ if (item.Key) results.push({
79
+ key: item.Key,
80
+ size: item.Size ?? 0,
81
+ modifiedAt: (item.LastModified ?? new Date(0)).toISOString(),
82
+ });
83
+ }
84
+ token = page.IsTruncated ? page.NextContinuationToken : undefined;
85
+ } while (token);
86
+ return results;
87
+ }
88
+
89
+ async deletePrefix(prefix: string): Promise<void> {
90
+ const objects = await this.list(prefix);
91
+ if (objects.length) {
92
+ await this.client.send(new DeleteObjectsCommand({
93
+ Bucket: this.options.bucket,
94
+ Delete: { Objects: objects.map(({ key }) => ({ Key: key })) },
95
+ }));
96
+ }
97
+ }
98
+
99
+ async publicUrl(key: string, expiresInSeconds = 300): Promise<string> {
100
+ return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.options.bucket, Key: key }), {
101
+ expiresIn: expiresInSeconds,
102
+ });
103
+ }
104
+ }
@@ -0,0 +1,43 @@
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 { createProjectObjectStore, resolveStorageConfig } from "./create-object-store.js";
7
+ import { resolveLocalStorageDirectory } from "./storage-directory.js";
8
+
9
+ test("a local store without a directory lives in runtime.artifacts_dir", () => {
10
+ const cfg = { storage: { provider: "local" }, runtime: { artifacts_dir: "/tmp/bugmole-artifacts" } };
11
+ assert.equal(resolveLocalStorageDirectory(cfg), "/tmp/bugmole-artifacts");
12
+ assert.equal(resolveStorageConfig(cfg).localDirectory, "/tmp/bugmole-artifacts");
13
+ // No storage block at all resolves the same way.
14
+ assert.equal(resolveLocalStorageDirectory({ runtime: { artifacts_dir: "/tmp/bugmole-artifacts" } }), "/tmp/bugmole-artifacts");
15
+ });
16
+
17
+ test("an explicit local directory wins over artifacts_dir", () => {
18
+ const runtime = { artifacts_dir: "/tmp/bugmole-artifacts" };
19
+ assert.equal(resolveLocalStorageDirectory({ storage: { provider: "local", local: { directory: "/data/store" } }, runtime }), "/data/store");
20
+ assert.equal(resolveLocalStorageDirectory({ storage: { provider: "local", localDirectory: "/data/flat" }, runtime }), "/data/flat");
21
+ });
22
+
23
+ test("falls back only when nothing is configured", () => {
24
+ assert.equal(resolveLocalStorageDirectory({}), "./runs");
25
+ assert.equal(resolveLocalStorageDirectory({}, "./artifacts"), "./artifacts");
26
+ });
27
+
28
+ test("remote providers are passed through untouched", () => {
29
+ const storage = { provider: "r2" as const, r2: { bucket: "evidence" } };
30
+ assert.equal(resolveStorageConfig({ storage, runtime: { artifacts_dir: "/tmp/x" } }), storage);
31
+ });
32
+
33
+ test("objects written through the project store land where the artifact bridge reads", async () => {
34
+ const artifactsDir = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-storage-dir-"));
35
+ try {
36
+ const cfg = { storage: { provider: "local" }, runtime: { artifacts_dir: artifactsDir } };
37
+ await createProjectObjectStore(cfg).put("projects/demo/runs/run-1/artifacts/chromium/final.png", "png", "image/png");
38
+ const written = path.join(resolveLocalStorageDirectory(cfg), "projects/demo/runs/run-1/artifacts/chromium/final.png");
39
+ assert.equal(await fs.readFile(written, "utf8"), "png");
40
+ } finally {
41
+ await fs.rm(artifactsDir, { recursive: true, force: true });
42
+ }
43
+ });
@@ -0,0 +1,24 @@
1
+ // Kept free of storage SDK imports so the CLI can resolve the directory
2
+ // without loading every provider at startup.
3
+
4
+ export type StorageSettings = {
5
+ storage?: {
6
+ provider?: string;
7
+ local?: { directory?: string };
8
+ localDirectory?: string;
9
+ [key: string]: unknown;
10
+ };
11
+ runtime?: { artifacts_dir?: string };
12
+ project?: { id?: string };
13
+ };
14
+
15
+ /**
16
+ * Where a local store keeps its objects. An explicit `storage.local.directory`
17
+ * (or `storage.localDirectory`) wins; otherwise it is `runtime.artifacts_dir`,
18
+ * so the executor that writes artifacts and the loopback bridge the dashboard
19
+ * reads through always agree on the same folder.
20
+ */
21
+ export function resolveLocalStorageDirectory(cfg: StorageSettings, fallback = "./runs"): string {
22
+ return cfg.storage?.local?.directory || cfg.storage?.localDirectory || cfg.runtime?.artifacts_dir || fallback;
23
+ }
24
+
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "nodenext",
5
+ // @types/node 26 uses iterator helpers and Symbol.dispose throughout its
6
+ // URL/stream signatures, so the lib has to be new enough to describe them.
7
+ "lib": ["ES2023"],
8
+ "moduleResolution": "nodenext",
9
+ "outDir": "./dist",
10
+ "rootDir": "./",
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "allowSyntheticDefaultImports": true,
14
+ "skipLibCheck": true,
15
+ "forceConsistentCasingInFileNames": true,
16
+ "resolveJsonModule": true,
17
+ "declaration": true,
18
+ "declarationMap": true,
19
+ "sourceMap": true
20
+ },
21
+ "include": ["scripts/**/*", "src/**/*"],
22
+ "typeRoots": ["./node_modules/@types", "./src/types"],
23
+ "exclude": ["node_modules", "dist", "runs"]
24
+ }