@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
package/LICENSE CHANGED
@@ -1,7 +1,21 @@
1
+ MIT License
2
+
1
3
  Copyright (c) 2026 The AI, Inc.
2
4
 
3
- All rights reserved.
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
4
14
 
5
- This software and its source code are proprietary and confidential. No license
6
- or right to use, copy, modify, distribute, sublicense, or sell this software
7
- is granted without prior written permission from The AI, Inc.
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bugmole/cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -27,7 +27,7 @@
27
27
  "bm": "scripts/bugmole.cjs"
28
28
  },
29
29
  "scripts": {
30
- "postinstall": "node scripts/ensure-maestro.cjs",
30
+ "postinstall": "node scripts/ensure-maestro.cjs && node scripts/ensure-playwright.cjs",
31
31
  "bugmole:mcp": "NODE_OPTIONS=--max-old-space-size=512 tsx --no-cache scripts/bugmole.ts --mcp",
32
32
  "bugmole:explore": "NODE_OPTIONS=--max-old-space-size=512 tsx scripts/bugmole.ts --mode explore",
33
33
  "bugmole:plan": "NODE_OPTIONS=--max-old-space-size=512 tsx scripts/bugmole.ts --mode plan",
@@ -46,7 +46,7 @@
46
46
  "runner:dev": "wrangler dev -c wrangler.runner.jsonc",
47
47
  "runner:deploy": "wrangler deploy -c wrangler.runner.jsonc",
48
48
  "sync:catalog": "node scripts/sync-plan-catalog.mjs",
49
- "test": "NODE_OPTIONS=--max-old-space-size=512 tsx --test --test-concurrency=1 scripts/bugmole.test.ts src/mcp/server.test.ts projects/registry.test.ts src/integrations/testflight-feedback.test.ts src/integrations/temp-email.test.ts src/storage/object-store.test.ts src/storage/local-artifact-server.test.ts src/storage/storage-directory.test.ts src/runtime/reset.test.ts src/runtime/serve-gateway.test.ts src/registry/task-scheduling.test.ts src/registry-worker/artifacts.test.ts src/registry-worker/runs.test.ts src/registry-worker/org/routes.test.ts src/registry-worker/billing/billing-core.test.ts src/registry-worker/billing/paypal.test.ts src/registry-worker/ai/routes.test.ts src/registry-worker/runner/runner.test.ts src/registry-worker/signup/signup.test.ts src/registry-worker/notifications/notifications.test.ts src/registry-worker/github/checks.test.ts src/registry-worker/gitlab/checks.test.ts src/registry-worker/personas/personas.test.ts src/registry-worker/feedback/feedback.test.ts src/registry-worker/devices/devices.test.ts src/registry-worker/domains/domains.test.ts src/registry-worker/sso/sso.test.ts src/registry-worker/mcp/mcp.test.ts src/integrations/github-app.test.ts src/runtime/run-once.test.ts src/runtime/appium-driver.test.ts src/integrations/aws-sigv4.test.ts src/runtime/ai-work.test.ts src/billing/plan-catalog.test.ts src/mcp/write-test-cases.test.ts src/runtime/pipeline.test.ts src/runtime/planner.test.ts src/runtime/test-case-verdicts.test.ts src/runtime/journey-graph.test.ts src/runtime/journey-evidence.test.ts src/runtime/local-browser.test.ts src/runtime/serve-worker.test.ts src/runtime/cursor-driver-run.test.ts src/runtime/explorer.test.ts src/runtime/nav-crawl.test.ts src/runtime/app-understanding.test.ts src/runtime/environment.test.ts src/runtime/blocker-resolution.test.ts src/runtime/ui-reverse-engineering/coordinator.test.ts src/runtime/maestro-driver.test.ts src/runtime/playwright-driver.test.ts src/runtime/flow-language.test.ts src/runtime/web-suite.test.ts src/runtime/canonical-flow.test.ts src/runtime/executor.test.ts src/runtime/ios-screen-record.test.ts src/runtime/android-screen-record.test.ts src/runtime/reviewer.test.ts src/runtime/reporter.test.ts src/runtime/cursor-cli-driver.test.ts src/runtime/local-registry-stub.test.ts src/runtime/browser-matrix.test.ts src/runtime/browser-matrix.integration.test.ts src/runtime/failure-analysis.test.ts src/runtime/site-discovery.test.ts src/runtime/ai-exploration.test.ts src/runtime/discovery-task.test.ts",
49
+ "test": "NODE_OPTIONS=--max-old-space-size=512 tsx --test --test-concurrency=1 scripts/bugmole.test.ts scripts/ensure-playwright.test.ts scripts/sync-launcher-version.test.ts src/mcp/server.test.ts projects/registry.test.ts src/integrations/testflight-feedback.test.ts src/integrations/temp-email.test.ts src/storage/object-store.test.ts src/storage/local-artifact-server.test.ts src/storage/storage-directory.test.ts src/storage/test-case-results.test.ts src/runtime/reset.test.ts src/runtime/run-job.test.ts src/runtime/apply-proposals.test.ts src/runtime/serve-gateway.test.ts src/registry/task-scheduling.test.ts src/registry/test-case-revisions.test.ts src/registry-worker/task-approval.test.ts src/registry-worker/proposals/proposals.test.ts src/registry-worker/artifacts.test.ts src/registry-worker/runs.test.ts src/registry-worker/org/routes.test.ts src/registry-worker/billing/billing-core.test.ts src/registry-worker/billing/paypal.test.ts src/registry-worker/billing/plan-gaps.test.ts src/registry-worker/ai/routes.test.ts src/registry-worker/runner/runner.test.ts src/registry-worker/signup/signup.test.ts src/registry-worker/signup/attribution.test.ts src/registry-worker/notifications/notifications.test.ts src/registry-worker/github/checks.test.ts src/registry-worker/gitlab/checks.test.ts src/registry-worker/personas/personas.test.ts src/registry-worker/feedback/feedback.test.ts src/registry-worker/devices/devices.test.ts src/registry-worker/domains/domains.test.ts src/registry-worker/sso/sso.test.ts src/registry-worker/mcp/mcp.test.ts src/registry-worker/feature-flags/feature-flags.test.ts scripts/bugmole-admin.test.mjs src/integrations/github-app.test.ts src/runtime/run-once.test.ts src/runtime/appium-driver.test.ts src/integrations/aws-sigv4.test.ts src/runtime/ai-work.test.ts src/billing/plan-catalog.test.ts src/runtime/scenario-matrix.test.ts src/mcp/write-test-cases.test.ts src/runtime/pipeline.test.ts src/runtime/planner.test.ts src/runtime/test-case-verdicts.test.ts src/runtime/journey-graph.test.ts src/runtime/journey-evidence.test.ts src/runtime/local-browser.test.ts src/runtime/serve-worker.test.ts src/runtime/cursor-driver-run.test.ts src/runtime/explorer.test.ts src/runtime/nav-crawl.test.ts src/runtime/app-understanding.test.ts src/runtime/environment.test.ts src/runtime/blocker-resolution.test.ts src/runtime/ui-reverse-engineering/coordinator.test.ts src/runtime/maestro-driver.test.ts src/runtime/playwright-driver.test.ts src/runtime/flow-language.test.ts src/runtime/web-suite.test.ts src/runtime/canonical-flow.test.ts src/runtime/executor.test.ts src/runtime/ios-screen-record.test.ts src/runtime/android-screen-record.test.ts src/runtime/reviewer.test.ts src/runtime/reporter.test.ts src/runtime/cursor-cli-driver.test.ts src/runtime/local-registry-stub.test.ts src/runtime/browser-matrix.test.ts src/runtime/browser-matrix.integration.test.ts src/runtime/failure-analysis.test.ts src/runtime/site-discovery.test.ts src/runtime/ai-exploration.test.ts src/registry-worker/task-resume.test.ts src/registry-worker/secrets/secrets.test.ts src/registry-worker/jira/jira.test.ts src/runtime/record-run-verdicts.test.ts src/runtime/cloud-secrets.test.ts src/runtime/secret-sources.test.ts src/runtime/vault-federation.test.ts src/registry-worker/identity/oidc.test.ts src/registry-worker/task-review.test.ts src/registry-worker/roles/roles.test.ts src/registry/roles.test.ts src/runtime/project-roles.test.ts src/runtime/roles-cli.test.ts src/mcp/roles-and-review.test.ts src/runtime/discovery-task.test.ts src/registry/project-identity.test.ts src/runtime/project-identity.test.ts src/registry-worker/projects/projects.test.ts",
50
50
  "test:mcp": "NODE_OPTIONS=--max-old-space-size=512 tsx --test --test-concurrency=1 scripts/bugmole.test.ts src/mcp/server.test.ts projects/registry.test.ts src/integrations/testflight-feedback.test.ts src/storage/object-store.test.ts src/runtime/maestro-driver.test.ts src/runtime/canonical-flow.test.ts",
51
51
  "typecheck": "NODE_OPTIONS=--max-old-space-size=512 tsc --noEmit"
52
52
  },
@@ -59,7 +59,7 @@
59
59
  "automation"
60
60
  ],
61
61
  "author": "",
62
- "license": "UNLICENSED",
62
+ "license": "MIT",
63
63
  "dependencies": {
64
64
  "@aws-sdk/client-s3": "^3.1085.0",
65
65
  "@aws-sdk/s3-request-presigner": "^3.1085.0",
@@ -81,5 +81,8 @@
81
81
  "gaxios": {
82
82
  "uuid": "^11.1.1"
83
83
  }
84
+ },
85
+ "optionalDependencies": {
86
+ "@theaiinc/arcana-vault": "^0.1.0"
84
87
  }
85
88
  }
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ // Platform-operator CLI for feature overrides, beta opt-ins and contract plans.
3
+ // It calls the registry's /api/platform routes (src/registry-worker/feature-flags/admin.ts).
4
+ //
5
+ // PLATFORM_ADMIN_TOKEN=… node scripts/bugmole-admin.mjs flags list [workspace]
6
+ // node scripts/bugmole-admin.mjs flags set <workspace> <feature> on|off [--until 2026-12-31] [--reason "…"]
7
+ // node scripts/bugmole-admin.mjs flags clear <workspace> <feature>
8
+ // node scripts/bugmole-admin.mjs flags beta <workspace> <feature> on|off|clear
9
+ // node scripts/bugmole-admin.mjs plan show <workspace>
10
+ // node scripts/bugmole-admin.mjs plan set <workspace> enterprise [--projects N] [--minutes N] [--credits N]
11
+ // [--parallel N] [--retention-days N] [--feature scim=off ...] [--period-end 2027-09-30] [--note "…"]
12
+ // node scripts/bugmole-admin.mjs plan set <workspace> free
13
+ //
14
+ // Environment: PLATFORM_ADMIN_TOKEN (required; never printed), BUGMOLE_REGISTRY_URL
15
+ // (default https://api.bugmole.com), BUGMOLE_OPERATOR (who you are, for the audit log;
16
+ // defaults to $USER). Limits accept "unlimited". See docs/feature-flags.md.
17
+ import os from "node:os";
18
+ import { pathToFileURL } from "node:url";
19
+
20
+ export const USAGE = `Usage:
21
+ bugmole-admin flags list [workspace]
22
+ bugmole-admin flags set <workspace> <feature> on|off [--until <date>] [--reason <text>]
23
+ bugmole-admin flags clear <workspace> <feature>
24
+ bugmole-admin flags beta <workspace> <feature> on|off|clear
25
+ bugmole-admin plan show <workspace>
26
+ bugmole-admin plan set <workspace> enterprise [--projects N] [--minutes N] [--credits N] [--parallel N]
27
+ [--retention-days N] [--feature <name>=on|off]... [--period-end <date>] [--note <text>]
28
+ bugmole-admin plan set <workspace> free`;
29
+
30
+ const LIMIT_OPTIONS = {
31
+ projects: "projects",
32
+ minutes: "cloudMinutes",
33
+ credits: "aiCredits",
34
+ parallel: "parallel",
35
+ "retention-days": "retentionDays",
36
+ };
37
+
38
+ class UsageError extends Error {}
39
+
40
+ function splitOptions(args) {
41
+ const positional = [];
42
+ const options = {};
43
+ for (let index = 0; index < args.length; index++) {
44
+ const arg = args[index];
45
+ if (!arg.startsWith("--")) {
46
+ positional.push(arg);
47
+ continue;
48
+ }
49
+ const [rawName, inline] = arg.slice(2).split(/=(.*)/s, 2);
50
+ const value = inline ?? args[++index];
51
+ if (value === undefined) throw new UsageError(`--${rawName} needs a value`);
52
+ if (rawName === "feature") (options.feature ??= []).push(value);
53
+ else options[rawName] = value;
54
+ }
55
+ return { positional, options };
56
+ }
57
+
58
+ function onOff(value, what) {
59
+ if (value === "on") return true;
60
+ if (value === "off") return false;
61
+ throw new UsageError(`${what} must be on or off`);
62
+ }
63
+
64
+ function limitValue(name, value) {
65
+ if (value === "unlimited") return null;
66
+ if (!/^\d+$/.test(value)) throw new UsageError(`--${name} must be a whole number or "unlimited"`);
67
+ return Number(value);
68
+ }
69
+
70
+ const segment = (value) => encodeURIComponent(value);
71
+
72
+ /** Turns command-line arguments into one registry request. Exported for tests. */
73
+ export function buildRequest(argv) {
74
+ const { positional, options } = splitOptions(argv);
75
+ const [group, action, workspace, ...rest] = positional;
76
+ const need = (value, name) => {
77
+ if (!value) throw new UsageError(`Missing ${name}`);
78
+ return value;
79
+ };
80
+ const known = (allowed) => {
81
+ for (const name of Object.keys(options)) if (!allowed.includes(name)) throw new UsageError(`Unknown option --${name}`);
82
+ };
83
+
84
+ if (group === "flags") {
85
+ if (action === "list") {
86
+ known([]);
87
+ return workspace
88
+ ? { method: "GET", path: `/api/platform/workspaces/${segment(workspace)}/features` }
89
+ : { method: "GET", path: "/api/platform/feature-overrides" };
90
+ }
91
+ const feature = need(rest[0], "feature");
92
+ const base = `/api/platform/workspaces/${segment(need(workspace, "workspace"))}`;
93
+ if (action === "set") {
94
+ known(["until", "reason"]);
95
+ const body = { enabled: onOff(need(rest[1], "on|off"), "The value") };
96
+ if (options.until) body.expiresAt = options.until;
97
+ if (options.reason) body.reason = options.reason;
98
+ return { method: "PUT", path: `${base}/features/${segment(feature)}`, body };
99
+ }
100
+ if (action === "clear") {
101
+ known([]);
102
+ return { method: "DELETE", path: `${base}/features/${segment(feature)}` };
103
+ }
104
+ if (action === "beta") {
105
+ known([]);
106
+ const value = need(rest[1], "on|off|clear");
107
+ if (value === "clear") return { method: "DELETE", path: `${base}/beta/${segment(feature)}` };
108
+ return { method: "PUT", path: `${base}/beta/${segment(feature)}`, body: { enabled: onOff(value, "The value") } };
109
+ }
110
+ }
111
+
112
+ if (group === "plan") {
113
+ const base = `/api/platform/workspaces/${segment(need(workspace, "workspace"))}`;
114
+ if (action === "show") {
115
+ known([]);
116
+ return { method: "GET", path: `${base}/features` };
117
+ }
118
+ if (action === "set") {
119
+ const plan = need(rest[0], "plan (enterprise or free)");
120
+ if (plan === "free") {
121
+ known(["note"]);
122
+ return { method: "PUT", path: `${base}/plan`, body: { plan, ...(options.note ? { note: options.note } : {}) } };
123
+ }
124
+ if (plan !== "enterprise") throw new UsageError("plan must be enterprise or free; Team and Business are bought through checkout");
125
+ known([...Object.keys(LIMIT_OPTIONS), "feature", "period-end", "note"]);
126
+ const limits = {};
127
+ for (const [name, key] of Object.entries(LIMIT_OPTIONS)) {
128
+ if (options[name] !== undefined) limits[key] = limitValue(name, options[name]);
129
+ }
130
+ const features = {};
131
+ for (const entry of options.feature ?? []) {
132
+ const [name, value] = entry.split("=");
133
+ if (!name || value === undefined) throw new UsageError("--feature takes <name>=on|off");
134
+ features[name] = onOff(value, `--feature ${name}`);
135
+ }
136
+ const body = { plan, limits, features };
137
+ if (options["period-end"]) body.periodEnd = options["period-end"];
138
+ if (options.note) body.note = options.note;
139
+ return { method: "PUT", path: `${base}/plan`, body };
140
+ }
141
+ }
142
+ throw new UsageError("Unknown command");
143
+ }
144
+
145
+ export async function main(argv = process.argv.slice(2), env = process.env, doFetch = fetch, out = console) {
146
+ if (!argv.length || argv[0] === "help" || argv.includes("--help")) {
147
+ out.log(USAGE);
148
+ return 0;
149
+ }
150
+ let request;
151
+ try {
152
+ request = buildRequest(argv);
153
+ } catch (error) {
154
+ if (!(error instanceof UsageError)) throw error;
155
+ out.error(`${error.message}\n\n${USAGE}`);
156
+ return 2;
157
+ }
158
+ const token = env.PLATFORM_ADMIN_TOKEN?.trim();
159
+ if (!token) {
160
+ out.error("Set PLATFORM_ADMIN_TOKEN (the registry's platform secret) in the environment.");
161
+ return 2;
162
+ }
163
+ const origin = (env.BUGMOLE_REGISTRY_URL || "https://api.bugmole.com").replace(/\/+$/, "");
164
+ const operator = env.BUGMOLE_OPERATOR || env.USER || os.userInfo().username || "unknown";
165
+ const result = await doFetch(`${origin}${request.path}`, {
166
+ method: request.method,
167
+ headers: {
168
+ authorization: `Bearer ${token}`,
169
+ "x-bugmole-operator": operator,
170
+ ...(request.body ? { "content-type": "application/json" } : {}),
171
+ },
172
+ body: request.body ? JSON.stringify(request.body) : undefined,
173
+ });
174
+ const text = await result.text();
175
+ let body = text;
176
+ try {
177
+ body = JSON.stringify(JSON.parse(text), null, 2);
178
+ } catch {
179
+ // not JSON; print as is
180
+ }
181
+ if (!result.ok) {
182
+ out.error(`${request.method} ${request.path} → ${result.status}\n${body}`);
183
+ return 1;
184
+ }
185
+ out.log(body);
186
+ return 0;
187
+ }
188
+
189
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
190
+ main().then((code) => { process.exitCode = code; }, (error) => {
191
+ console.error(error instanceof Error ? error.message : error);
192
+ process.exitCode = 1;
193
+ });
194
+ }
@@ -0,0 +1,62 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { buildRequest, main } from "./bugmole-admin.mjs";
4
+
5
+ test("flags commands map to the platform routes", () => {
6
+ assert.deepEqual(buildRequest(["flags", "list"]), { method: "GET", path: "/api/platform/feature-overrides" });
7
+ assert.deepEqual(buildRequest(["flags", "list", "acme"]), { method: "GET", path: "/api/platform/workspaces/acme/features" });
8
+ assert.deepEqual(buildRequest(["flags", "set", "acme", "sso", "on", "--until", "2026-12-31", "--reason", "Trial"]), {
9
+ method: "PUT",
10
+ path: "/api/platform/workspaces/acme/features/sso",
11
+ body: { enabled: true, expiresAt: "2026-12-31", reason: "Trial" },
12
+ });
13
+ assert.deepEqual(buildRequest(["flags", "set", "acme", "slack", "off"]).body, { enabled: false });
14
+ assert.deepEqual(buildRequest(["flags", "clear", "acme", "sso"]), { method: "DELETE", path: "/api/platform/workspaces/acme/features/sso" });
15
+ assert.deepEqual(buildRequest(["flags", "beta", "acme", "mcp", "on"]).body, { enabled: true });
16
+ assert.equal(buildRequest(["flags", "beta", "acme", "mcp", "clear"]).method, "DELETE");
17
+ assert.throws(() => buildRequest(["flags", "set", "acme", "sso", "maybe"]), /on or off/);
18
+ assert.throws(() => buildRequest(["flags", "set", "acme", "sso", "on", "--forever", "1"]), /Unknown option/);
19
+ });
20
+
21
+ test("plan set builds a contract", () => {
22
+ const request = buildRequest([
23
+ "plan", "set", "acme", "enterprise",
24
+ "--projects", "40", "--minutes", "unlimited", "--credits=20000", "--parallel", "60", "--retention-days", "400",
25
+ "--feature", "scim=off", "--feature", "self_hosted_workers=on", "--period-end", "2027-09-30", "--note", "C-17",
26
+ ]);
27
+ assert.deepEqual(request, {
28
+ method: "PUT",
29
+ path: "/api/platform/workspaces/acme/plan",
30
+ body: {
31
+ plan: "enterprise",
32
+ limits: { projects: 40, cloudMinutes: null, aiCredits: 20000, parallel: 60, retentionDays: 400 },
33
+ features: { scim: false, self_hosted_workers: true },
34
+ periodEnd: "2027-09-30",
35
+ note: "C-17",
36
+ },
37
+ });
38
+ assert.deepEqual(buildRequest(["plan", "set", "acme", "free"]).body, { plan: "free" });
39
+ assert.throws(() => buildRequest(["plan", "set", "acme", "team"]), /checkout/);
40
+ assert.throws(() => buildRequest(["plan", "set", "acme", "enterprise", "--projects", "-3"]), /whole number/);
41
+ });
42
+
43
+ test("main sends the token and operator, and refuses without a token", async () => {
44
+ const logs = [];
45
+ const out = { log: (line) => logs.push(line), error: (line) => logs.push(line) };
46
+ assert.equal(await main(["flags", "list"], {}, fetch, out), 2);
47
+ assert.match(logs.pop(), /PLATFORM_ADMIN_TOKEN/);
48
+
49
+ let seen;
50
+ const fakeFetch = async (url, init) => {
51
+ seen = { url, init };
52
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
53
+ };
54
+ const code = await main(["flags", "set", "acme", "sso", "on"], {
55
+ PLATFORM_ADMIN_TOKEN: "tok", BUGMOLE_REGISTRY_URL: "https://registry.test/", BUGMOLE_OPERATOR: "steve",
56
+ }, fakeFetch, out);
57
+ assert.equal(code, 0);
58
+ assert.equal(seen.url, "https://registry.test/api/platform/workspaces/acme/features/sso");
59
+ assert.equal(seen.init.headers.authorization, "Bearer tok");
60
+ assert.equal(seen.init.headers["x-bugmole-operator"], "steve");
61
+ assert.ok(!logs.some((line) => line.includes("tok\"")), "the token is never printed");
62
+ });
@@ -1,8 +1,9 @@
1
1
  import { strict as assert } from "node:assert";
2
2
  import { fileURLToPath } from "node:url";
3
- import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
4
4
  import { describe, it } from "node:test";
5
5
  import {
6
+ applyApprovalFlag,
6
7
  buildDashboardHandoffUrl,
7
8
  parseArgs,
8
9
  printHelp,
@@ -17,7 +18,7 @@ import {
17
18
  } from "./bugmole.js";
18
19
  import os from "node:os";
19
20
  import path from "node:path";
20
- import { runInitWizard, validateStorageProvider } from "../src/runtime/init-wizard.js";
21
+ import { PlanLimitError, describePlanLimit, planLimitFromRegistry, runInitWizard, validateStorageProvider } from "../src/runtime/init-wizard.js";
21
22
 
22
23
  describe("Bugmole CLI contract", () => {
23
24
  it("starts MCP for the canonical --mcp flag", () => {
@@ -65,6 +66,7 @@ describe("Bugmole CLI contract", () => {
65
66
  it("leaves other modes' exit code alone regardless of their result", () => {
66
67
  assert.equal(runModeExitCode("explore", { status: "partial" }), 0);
67
68
  assert.equal(runModeExitCode("report", undefined), 0);
69
+ assert.equal(runModeExitCode("explore", { status: "error" }), 1);
68
70
  });
69
71
 
70
72
  it("accepts a dashboard URL override for serve handoffs", () => {
@@ -189,6 +191,53 @@ describe("Bugmole CLI contract", () => {
189
191
  }
190
192
  });
191
193
 
194
+ it("turns a registry plan refusal into its message and a billing link", () => {
195
+ const refusal = planLimitFromRegistry(402, {
196
+ error: "Your plan includes 1 project and this workspace has 1. Upgrade to Team to add more.",
197
+ code: "plan_limit",
198
+ upgradeUrl: "/billing?workspace=acme&plan=team",
199
+ }, "https://dash.test/");
200
+ assert.ok(refusal instanceof PlanLimitError);
201
+ assert.equal(refusal.billingUrl, "https://dash.test/billing?workspace=acme&plan=team");
202
+ assert.equal(
203
+ describePlanLimit(refusal),
204
+ "Your plan includes 1 project and this workspace has 1. Upgrade to Team to add more.\nSee plans and upgrade: https://dash.test/billing?workspace=acme&plan=team",
205
+ );
206
+ assert.equal(
207
+ planLimitFromRegistry(402, { error: "x", code: "free_workspace_exists", billingUrl: "https://app.bugmole.com/billing?plan=team" }, "https://dash.test")?.billingUrl,
208
+ "https://app.bugmole.com/billing?plan=team",
209
+ "the registry's absolute link wins",
210
+ );
211
+ assert.equal(planLimitFromRegistry(409, { error: "A project with this id already exists", code: "project_exists" }, "https://dash.test"), null);
212
+ assert.equal(planLimitFromRegistry(402, { code: "plan_limit", upgradeUrl: "https://evil.test/" }, "https://dash.test")?.billingUrl, "https://dash.test/billing", "only a relative Billing path is joined");
213
+ });
214
+
215
+ it("stops init with the plan message instead of a generic registration failure", async () => {
216
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "bugmole-init-limit-"));
217
+ const saved = { key: process.env.BUGMOLE_API_KEY, registry: process.env.BUGMOLE_REGISTRY_URL, dashboard: process.env.BUGMOLE_DASHBOARD_URL };
218
+ const realFetch = globalThis.fetch;
219
+ process.env.BUGMOLE_API_KEY = "bm_live_test";
220
+ process.env.BUGMOLE_REGISTRY_URL = "https://registry.test";
221
+ process.env.BUGMOLE_DASHBOARD_URL = "https://dash.test";
222
+ globalThis.fetch = (async () => Response.json({
223
+ error: "Your plan includes 1 project and this workspace has 1. Upgrade to Team to add more.",
224
+ code: "plan_limit",
225
+ limit: "projects",
226
+ upgradeUrl: "/billing?workspace=acme&plan=team",
227
+ }, { status: 402 })) as typeof fetch;
228
+ try {
229
+ await assert.rejects(runInitWizard({ cwd, nonInteractive: true }), (error: unknown) =>
230
+ error instanceof PlanLimitError && error.billingUrl === "https://dash.test/billing?workspace=acme&plan=team" && /Upgrade to Team/.test(error.message));
231
+ } finally {
232
+ globalThis.fetch = realFetch;
233
+ for (const [name, value] of [["BUGMOLE_API_KEY", saved.key], ["BUGMOLE_REGISTRY_URL", saved.registry], ["BUGMOLE_DASHBOARD_URL", saved.dashboard]] as const) {
234
+ if (value === undefined) delete process.env[name];
235
+ else process.env[name] = value;
236
+ }
237
+ await rm(cwd, { recursive: true, force: true });
238
+ }
239
+ });
240
+
192
241
  it("accepts config alongside a boolean MCP flag", () => {
193
242
  assert.deepEqual(parseArgs(["node", "bugmole", "--mcp", "--config", "custom.yaml"]), {
194
243
  mcp: true,
@@ -342,3 +391,46 @@ describe("Bugmole CLI contract", () => {
342
391
  }
343
392
  });
344
393
  });
394
+
395
+ describe("bugmole serve --approval", () => {
396
+ it("sets BUGMOLE_APPROVAL for the whole process, and refuses an unknown mode", () => {
397
+ const env: Record<string, string | undefined> = {};
398
+ assert.equal(applyApprovalFlag({ approval: "manual" }, env), "manual");
399
+ assert.equal(env.BUGMOLE_APPROVAL, "manual");
400
+ assert.equal(applyApprovalFlag({ approval: "yolo" }, {}), null);
401
+ assert.equal(applyApprovalFlag({}, { BUGMOLE_APPROVAL: "extreme" }), "extreme", "no flag: the environment decides");
402
+ assert.equal(applyApprovalFlag({}, {}), undefined, "neither: the project decides");
403
+ });
404
+ });
405
+
406
+ it("flag values that look like numbers stay names unless the flag is a count", () => {
407
+ const args = parseArgs(["node", "bugmole", "--mode", "suite", "--grep", "404", "--project", "2024", "--max-pages", "20", "--timeout", "5000"]);
408
+ assert.equal(args.grep, "404");
409
+ assert.equal(args.project, "2024");
410
+ assert.equal(args.maxpages, 20);
411
+ assert.equal(args.timeout, 5000);
412
+ });
413
+
414
+ it("dashed flags are readable in both spellings", () => {
415
+ const args = parseArgs(["node", "bugmole", "--mode", "review", "--file-path", "a.ts", "--change-summary", "b", "--diff", "c"]);
416
+ assert.equal(args.filepath, "a.ts");
417
+ assert.equal(args.filePath, "a.ts");
418
+ assert.equal(args.changeSummary, "b");
419
+ });
420
+
421
+ describe("init output", () => {
422
+ it("keeps .bugmole.env out of git and writes a config that passes validation", async () => {
423
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "bugmole-init-git-"));
424
+ try {
425
+ await mkdir(path.join(cwd, ".git"));
426
+ const result = await runInitWizard({ cwd, nonInteractive: true });
427
+ assert.match(await readFile(path.join(cwd, ".gitignore"), "utf8"), /^\/\.bugmole\.env$/m);
428
+ const YAML = (await import("yaml")).default;
429
+ const { validateConfig } = await import("../src/runtime/config-validate.js");
430
+ const outcome = validateConfig(YAML.parse(await readFile(path.join(cwd, result.configPath), "utf8")));
431
+ assert.deepEqual(outcome.errors, []);
432
+ } finally {
433
+ await rm(cwd, { recursive: true, force: true });
434
+ }
435
+ });
436
+ });