@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.
- package/package.json +5 -2
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +233 -46
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +22 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +9 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +477 -174
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.ts +4 -4
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +21 -0
- package/src/registry-worker/runner/tokens.ts +8 -0
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/appium-driver.ts +1 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +67 -6
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +22 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +39 -2
- package/src/runtime/journey-graph.test.ts +18 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +21 -2
- package/src/runtime/playwright-driver.ts +49 -7
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +47 -4
- package/src/runtime/site-discovery.ts +79 -9
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.ts +5 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- package/src/vendor/byok.ts +371 -0
|
@@ -6,9 +6,10 @@ import { createProjectObjectStore } from "../storage/create-object-store.js";
|
|
|
6
6
|
import { projectPrefix } from "../storage/keys.js";
|
|
7
7
|
import { readJourneyGraph, writeJourneyGraph } from "./journey-editor.js";
|
|
8
8
|
import type { JourneyEdge, JourneyNode, UserJourney } from "./journey-graph.js";
|
|
9
|
-
import { discoverSite, stoppedAtSignIn, type DiscoveryResult } from "./site-discovery.js";
|
|
9
|
+
import { discoverSite, stoppedAtSignIn, type DiscoveryLimits, type DiscoveryResult } from "./site-discovery.js";
|
|
10
10
|
import { explorationPrompt, parseCheckedExploration, type ExplorationPersona } from "./ai-exploration.js";
|
|
11
11
|
import { modelCaller } from "./platform-ai.js";
|
|
12
|
+
import { proposeRoleSuggestions } from "./project-roles.js";
|
|
12
13
|
|
|
13
14
|
/** Discovery tasks queued as "Explore with AI …" also have a model write multi-page journeys. */
|
|
14
15
|
export function discoveryUsesAi(task: Pick<RegistryTask, "title">): boolean {
|
|
@@ -69,6 +70,8 @@ export type DiscoveryProgress = {
|
|
|
69
70
|
events: DiscoveryResult["events"];
|
|
70
71
|
pages: Array<{ route: string; title: string; heading?: string; forms: number }>;
|
|
71
72
|
flows: Array<{ id: string; name: string; kind: "page" | "form" | "journey"; route: string; steps: string[]; planId?: string }>;
|
|
73
|
+
/** Present once the crawl has finished: the bounds it ran with and what they left out. */
|
|
74
|
+
limits?: Omit<DiscoveryLimits, "notFollowed"> & { notFollowed: number; notFollowedRoutes: string[] };
|
|
72
75
|
};
|
|
73
76
|
|
|
74
77
|
export function progressOf(url: string, result: DiscoveryResult, planIds: Record<string, string> = {}): DiscoveryProgress {
|
|
@@ -79,6 +82,18 @@ export function progressOf(url: string, result: DiscoveryResult, planIds: Record
|
|
|
79
82
|
events: result.events.slice(-40),
|
|
80
83
|
pages: result.pages.map((page) => ({ route: page.route, title: page.title, ...(page.heading ? { heading: page.heading } : {}), forms: page.forms.length })),
|
|
81
84
|
flows: result.flows.map((flow) => ({ id: flow.id, name: flow.name, kind: flow.kind, route: flow.route, steps: flow.steps, ...(planIds[flow.id] ? { planId: planIds[flow.id] } : {}) })),
|
|
85
|
+
...(result.limits
|
|
86
|
+
? {
|
|
87
|
+
limits: {
|
|
88
|
+
maxPages: result.limits.maxPages,
|
|
89
|
+
maxDepth: result.limits.maxDepth,
|
|
90
|
+
stoppedAtPageLimit: result.limits.stoppedAtPageLimit,
|
|
91
|
+
notFollowed: result.limits.notFollowed.length,
|
|
92
|
+
// A sample is enough to show which; every update carries it.
|
|
93
|
+
notFollowedRoutes: result.limits.notFollowed.slice(0, 20),
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
: {}),
|
|
82
97
|
};
|
|
83
98
|
}
|
|
84
99
|
|
|
@@ -191,7 +206,7 @@ export async function saveDiscovery(cfg: WorkerConfig, client: Pick<ControlPlane
|
|
|
191
206
|
*/
|
|
192
207
|
export async function runDiscoveryTask(
|
|
193
208
|
cfg: WorkerConfig,
|
|
194
|
-
client: Pick<ControlPlaneClient, "claimTask" | "updateTask" | "publishPlan"
|
|
209
|
+
client: Pick<ControlPlaneClient, "claimTask" | "updateTask" | "publishPlan"> & Partial<Pick<ControlPlaneClient, "proposeRole">>,
|
|
195
210
|
task: RegistryTask,
|
|
196
211
|
workerId: string,
|
|
197
212
|
discover: typeof discoverSite = discoverSite,
|
|
@@ -200,6 +215,16 @@ export async function runDiscoveryTask(
|
|
|
200
215
|
claimed?: boolean;
|
|
201
216
|
/** Called once if the registry answers an update with `stop`. */
|
|
202
217
|
onStop?: (signal: { reason: string; message: string }) => void;
|
|
218
|
+
/**
|
|
219
|
+
* Signs in before exploring (a cloud runner with the project's sign-in
|
|
220
|
+
* flow) and returns the saved browser session to explore in.
|
|
221
|
+
*/
|
|
222
|
+
signIn?: (url: string) => Promise<string>;
|
|
223
|
+
/**
|
|
224
|
+
* How far to explore: the workspace plan's bounds on Bugmole Cloud, the
|
|
225
|
+
* machine's own settings on a project worker. Discovery's defaults otherwise.
|
|
226
|
+
*/
|
|
227
|
+
limits?: { maxPages?: number; maxDepth?: number };
|
|
203
228
|
} = {},
|
|
204
229
|
): Promise<void> {
|
|
205
230
|
const url = discoveryUrl(task);
|
|
@@ -242,12 +267,27 @@ export async function runDiscoveryTask(
|
|
|
242
267
|
heartbeat.unref?.();
|
|
243
268
|
try {
|
|
244
269
|
await send("running");
|
|
270
|
+
// Sign-in happens before discovery has a result to report into, so its
|
|
271
|
+
// events are kept here and put in front of discovery's own.
|
|
272
|
+
const before: DiscoveryResult["events"] = [];
|
|
273
|
+
let storageState: string | undefined;
|
|
274
|
+
if (options.signIn) {
|
|
275
|
+
before.push({ at: new Date().toISOString(), kind: "visit", text: "Signing in with the project's sign-in flow…" });
|
|
276
|
+
latest = progressOf(url, { baseUrl: url, pages: [], flows: [], events: before });
|
|
277
|
+
await send("running");
|
|
278
|
+
storageState = await options.signIn(url);
|
|
279
|
+
before.push({ at: new Date().toISOString(), kind: "visit", text: "Signed in; exploring as a signed-in user" });
|
|
280
|
+
}
|
|
245
281
|
const result = await discover(url, {
|
|
282
|
+
...(storageState ? { storageState } : {}),
|
|
283
|
+
...(options.limits?.maxPages ? { maxPages: options.limits.maxPages } : {}),
|
|
284
|
+
...(options.limits?.maxDepth ? { maxDepth: options.limits.maxDepth } : {}),
|
|
246
285
|
onEvent: async (_event, snapshot) => {
|
|
247
|
-
latest = progressOf(url, snapshot);
|
|
286
|
+
latest = progressOf(url, { ...snapshot, events: [...before, ...snapshot.events] });
|
|
248
287
|
await send("running");
|
|
249
288
|
},
|
|
250
289
|
});
|
|
290
|
+
result.events.unshift(...before);
|
|
251
291
|
// Everything reachable was a sign-in screen. Say so plainly, and don't
|
|
252
292
|
// ask AI to imagine the app behind it: that spends credits on journeys
|
|
253
293
|
// through pages nobody has seen.
|
|
@@ -256,11 +296,29 @@ export async function runDiscoveryTask(
|
|
|
256
296
|
result.events.push({
|
|
257
297
|
at: new Date().toISOString(),
|
|
258
298
|
kind: "skip",
|
|
259
|
-
text:
|
|
260
|
-
|
|
299
|
+
text: storageState
|
|
300
|
+
? "Stopped at a sign-in page even after the sign-in flow passed, so everything behind it is unexplored. "
|
|
301
|
+
+ "Check that the sign-in flow ends signed in (assert something only a signed-in user sees)."
|
|
302
|
+
: "Stopped at a sign-in page, so everything behind it is unexplored. To explore signed in, add a sign-in flow and its secrets "
|
|
303
|
+
+ "under Settings → Sign-in for cloud runs, or run discovery from your machine or CI: bugmole --mode discover --sign-in <flow>.",
|
|
261
304
|
});
|
|
262
305
|
latest = progressOf(url, result);
|
|
263
306
|
await send("running");
|
|
307
|
+
// A sign-in wall says the app has signed-out and signed-in people:
|
|
308
|
+
// suggest both roles for a person to confirm on the Roles page.
|
|
309
|
+
if (client.proposeRole && !storageState) {
|
|
310
|
+
const evidence = `Exploring ${url} reached only a sign-in page`;
|
|
311
|
+
const proposed = await proposeRoleSuggestions([
|
|
312
|
+
{ name: "guest", description: "Visitor who is not signed in and only sees public screens.", source: "sign_in_wall", evidence },
|
|
313
|
+
{ name: "member", description: "Signed-in user who gets past the sign-in wall.", source: "sign_in_wall", evidence },
|
|
314
|
+
], { proposeRole: client.proposeRole.bind(client) }).catch(() => []);
|
|
315
|
+
const sent = proposed.filter((role) => role.status === "suggested" && !role.alreadyDecided).map((role) => role.name);
|
|
316
|
+
if (sent.length) {
|
|
317
|
+
result.events.push({ at: new Date().toISOString(), kind: "flows", text: `Suggested the roles ${sent.join(" and ")} for you to confirm under Settings → Roles` });
|
|
318
|
+
latest = progressOf(url, result);
|
|
319
|
+
await send("running");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
264
322
|
}
|
|
265
323
|
if (discoveryUsesAi(task) && !signInWall) {
|
|
266
324
|
const caller = modelCaller(cfg, { projectId: cfg.project?.id ?? "default", requestId: task.id });
|
|
@@ -292,7 +350,10 @@ export async function runDiscoveryTask(
|
|
|
292
350
|
latest = progressOf(url, result, planIds);
|
|
293
351
|
await send("completed", {
|
|
294
352
|
completionSummary: `Explored ${result.pages.length} page(s) of ${url} and discovered ${result.flows.length} flow(s).`
|
|
295
|
-
+ (signInWall ? " Stopped at a sign-in page; the app behind it is unexplored." : "")
|
|
353
|
+
+ (signInWall ? " Stopped at a sign-in page; the app behind it is unexplored." : "")
|
|
354
|
+
+ (result.limits?.notFollowed.length
|
|
355
|
+
? ` ${result.limits.notFollowed.length} linked page(s) were not opened (limit: ${result.limits.maxPages} pages, ${result.limits.maxDepth} clicks deep).`
|
|
356
|
+
: ""),
|
|
296
357
|
});
|
|
297
358
|
} catch (error) {
|
|
298
359
|
await send("error", { errorMessage: error instanceof Error ? error.message : String(error) });
|
package/src/runtime/executor.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { journeyScreenshotKey } from "./journey-evidence.js";
|
|
|
11
11
|
import type { AppDriver, DriverPlatform } from "./driver.js";
|
|
12
12
|
import { MaestroDriver } from "./maestro-driver.js";
|
|
13
13
|
import { PlaywrightDriver } from "./playwright-driver.js";
|
|
14
|
+
import { withFlowSecrets } from "./secret-driver.js";
|
|
15
|
+
import { secretMap } from "./secret-sources.js";
|
|
14
16
|
import { recoverDriverFailure } from "./driver-recovery.js";
|
|
15
17
|
import { startAndroidScreenRecording } from "./android-screen-record.js";
|
|
16
18
|
import { startIosScreenRecording } from "./ios-screen-record.js";
|
|
@@ -115,16 +117,27 @@ export interface ExecutionResult {
|
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
function configuredDriver(cfg: any, driverId: string): AppDriver {
|
|
120
|
+
// Flows type {{secret.NAME}} from wherever the project keeps it (its
|
|
121
|
+
// config's secrets: map, or this machine's keychain).
|
|
122
|
+
const secrets = (driver: AppDriver) => {
|
|
123
|
+
const { map } = secretMap(cfg);
|
|
124
|
+
return withFlowSecrets(driver, {
|
|
125
|
+
map,
|
|
126
|
+
projectId: cfg.project?.id ?? "default",
|
|
127
|
+
// A cloud runner has the project's released secrets already; it has no keychain.
|
|
128
|
+
localFallback: !process.env.BUGMOLE_RUN_TOKEN,
|
|
129
|
+
});
|
|
130
|
+
};
|
|
118
131
|
if (driverId === "maestro") {
|
|
119
|
-
return new MaestroDriver({
|
|
132
|
+
return secrets(new MaestroDriver({
|
|
120
133
|
command: cfg.execution?.maestro?.command,
|
|
121
134
|
extraArgs: Array.isArray(cfg.execution?.maestro?.args) ? cfg.execution.maestro.args : undefined,
|
|
122
|
-
});
|
|
135
|
+
}));
|
|
123
136
|
}
|
|
124
137
|
if (driverId === "playwright") {
|
|
125
|
-
return new PlaywrightDriver({
|
|
138
|
+
return secrets(new PlaywrightDriver({
|
|
126
139
|
headless: cfg.execution?.playwright?.headless,
|
|
127
|
-
});
|
|
140
|
+
}));
|
|
128
141
|
}
|
|
129
142
|
throw new Error(`No app driver adapter is registered for "${driverId}"`);
|
|
130
143
|
}
|
|
@@ -400,12 +413,7 @@ export async function execute(cfg: any, runOptions: RunOptions): Promise<Executi
|
|
|
400
413
|
stepsPassed: 0,
|
|
401
414
|
stepsFailed: 0,
|
|
402
415
|
error:
|
|
403
|
-
`
|
|
404
|
-
`Plan ID provided: ${planId}\n` +
|
|
405
|
-
`Spec dir: ${specDir}\n` +
|
|
406
|
-
`Plans dir: ${plansDir}\n` +
|
|
407
|
-
`Current working directory: ${process.cwd()}\n` +
|
|
408
|
-
`Environment variables: CURSOR_PROJECT_DIRECTORY=${process.env.CURSOR_PROJECT_DIRECTORY || "not set"}, BUGMOLE_PROJECT_DIR=${process.env.BUGMOLE_PROJECT_DIR || "not set"}`,
|
|
416
|
+
`No plan "${planId}" at ${planPath}. Create it with bugmole_plan (or bugmole --mode plan), or check the plan ID and that Bugmole runs in your project folder (now ${process.cwd()}).`,
|
|
409
417
|
};
|
|
410
418
|
}
|
|
411
419
|
|
|
@@ -99,3 +99,35 @@ test("manage hub links resolve against optional catch-all resource routes", asyn
|
|
|
99
99
|
assert.ok(targets.has(route), `expected /manage to link to ${route}`);
|
|
100
100
|
}
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
test("an app with no route files is explored by crawling it, and recorded transitions survive re-exploring", async () => {
|
|
104
|
+
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-explorer-plain-"));
|
|
105
|
+
await fs.writeFile(path.join(projectDir, "server.mjs"), "// a plain http server\n");
|
|
106
|
+
const specDir = path.join(projectDir, "spec");
|
|
107
|
+
const cfg = {
|
|
108
|
+
runtime: { spec_dir: specDir, artifacts_dir: path.join(projectDir, "runs") },
|
|
109
|
+
cursor_driver: { project_directory: projectDir },
|
|
110
|
+
project: { id: "plain" },
|
|
111
|
+
};
|
|
112
|
+
const page = (route: string, title: string, links: Array<{ label: string; route: string }>) => ({ route, url: `http://127.0.0.1:1${route}`, title, links });
|
|
113
|
+
const discover = async () => ({
|
|
114
|
+
pages: [
|
|
115
|
+
page("/", "Home", [{ label: "Pricing", route: "/pricing" }, { label: "Sign up", route: "/signup" }]),
|
|
116
|
+
page("/pricing", "Pricing", [{ label: "Home", route: "/" }]),
|
|
117
|
+
page("/signup", "Sign up", []),
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
const first = await explore(cfg, { baseUrl: "http://127.0.0.1:1", captureScreenshots: false, discover });
|
|
121
|
+
assert.equal(first.status, "success");
|
|
122
|
+
let graph = JSON.parse(await fs.readFile(path.join(specDir, "journeys.graph.json"), "utf-8"));
|
|
123
|
+
assert.deepEqual(graph.nodes.map((node: { route: string }) => node.route).sort(), ["/", "/pricing", "/signup"]);
|
|
124
|
+
assert.ok(graph.edges.some((edge: { from: string; to: string; label?: string }) => edge.from === "screen:/" && edge.to === "screen:/signup" && edge.label === "Sign up"));
|
|
125
|
+
|
|
126
|
+
// A transition an agent recorded by hand, for its own journey.
|
|
127
|
+
graph.edges.push({ id: "screen:/signup->screen:/:manual", from: "screen:/signup", to: "screen:/", label: "Back home" });
|
|
128
|
+
graph.journeys.push({ id: "manual", name: "Manual", actor: "visitor", nodeIds: ["screen:/signup", "screen:/"], edgeIds: ["screen:/signup->screen:/:manual"], revision: 1, status: "draft", startNodeId: "screen:/signup" });
|
|
129
|
+
await fs.writeFile(path.join(specDir, "journeys.graph.json"), JSON.stringify(graph));
|
|
130
|
+
await explore(cfg, { baseUrl: "http://127.0.0.1:1", captureScreenshots: false, discover });
|
|
131
|
+
graph = JSON.parse(await fs.readFile(path.join(specDir, "journeys.graph.json"), "utf-8"));
|
|
132
|
+
assert.ok(graph.edges.some((edge: { id: string }) => edge.id === "screen:/signup->screen:/:manual"));
|
|
133
|
+
});
|
package/src/runtime/explorer.ts
CHANGED
|
@@ -21,6 +21,10 @@ interface ExploreOptions {
|
|
|
21
21
|
accessMethod?: "configured" | "handoff" | "manual" | "unknown";
|
|
22
22
|
journeyId?: string;
|
|
23
23
|
actor?: string;
|
|
24
|
+
/** false: never crawl the running app when the source has no route files. */
|
|
25
|
+
crawl?: boolean;
|
|
26
|
+
/** The crawler, replaceable in tests. */
|
|
27
|
+
discover?: (baseUrl: string, options: { maxPages?: number; maxDepth?: number }) => Promise<{ pages: Array<{ route: string; url: string; title: string; heading?: string; links: Array<{ label: string; route: string }> }> }>;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
interface ExploreResult {
|
|
@@ -572,10 +576,43 @@ export async function explore(cfg: any, options: ExploreOptions): Promise<Explor
|
|
|
572
576
|
sourcePaths: entry.source ? [entry.source] : [],
|
|
573
577
|
});
|
|
574
578
|
}
|
|
579
|
+
// No route files (Express, Fastify, a plain http server, a framework the
|
|
580
|
+
// walk doesn't know): learn the screens from the running app instead,
|
|
581
|
+
// by clicking through it the way site discovery does.
|
|
582
|
+
const crawledEdges: any[] = [];
|
|
583
|
+
if (discoveredNodes.size === 0 && options.crawl !== false) {
|
|
584
|
+
const { discoverSite } = await import("./site-discovery.js");
|
|
585
|
+
const site = await (options.discover ?? discoverSite)(baseUrl, { maxPages: 30, maxDepth: 3 });
|
|
586
|
+
for (const page of site.pages) {
|
|
587
|
+
const route = normalizeRoute(page.route) || "/";
|
|
588
|
+
const nodeId = `screen:${route}`;
|
|
589
|
+
if (discoveredNodes.has(nodeId)) continue;
|
|
590
|
+
discoveredNodes.set(nodeId, {
|
|
591
|
+
id: nodeId,
|
|
592
|
+
title: page.heading || page.title || semanticScreenTitle(route),
|
|
593
|
+
kind: "screen",
|
|
594
|
+
signature: `${projectRoot}@route:${route}`,
|
|
595
|
+
route,
|
|
596
|
+
source: "running app",
|
|
597
|
+
sourcePaths: [],
|
|
598
|
+
runtimeProbe: { status: "reachable", url: page.url },
|
|
599
|
+
metadata: { discoveredBy: "site-discovery" },
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
for (const page of site.pages) {
|
|
603
|
+
const from = `screen:${normalizeRoute(page.route) || "/"}`;
|
|
604
|
+
for (const [index, link] of page.links.entries()) {
|
|
605
|
+
const to = `screen:${normalizeRoute(link.route) || "/"}`;
|
|
606
|
+
if (to === from || !discoveredNodes.has(to)) continue;
|
|
607
|
+
crawledEdges.push({ id: `${from}->${to}:crawl-${index}`, from, to, label: link.label, action: `navigate:${normalizeRoute(link.route) || "/"}`, source: "running app" });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
575
611
|
graph.nodes = [...discoveredNodes.values()];
|
|
576
612
|
// Resolve links after all route files are known so source order cannot
|
|
577
613
|
// make a valid link look unresolved.
|
|
578
614
|
const routeNodes = graph.nodes as any[];
|
|
615
|
+
const previousEdges: any[] = Array.isArray(graph.edges) ? graph.edges : [];
|
|
579
616
|
graph.edges = [];
|
|
580
617
|
for (const filePath of sourceFiles) {
|
|
581
618
|
const relativePath = path.relative(projectRoot, filePath);
|
|
@@ -619,6 +656,17 @@ export async function explore(cfg: any, options: ExploreOptions): Promise<Explor
|
|
|
619
656
|
});
|
|
620
657
|
}
|
|
621
658
|
}
|
|
659
|
+
for (const edge of crawledEdges) {
|
|
660
|
+
if (!graph.edges.some((existing: any) => existing.from === edge.from && existing.to === edge.to)) graph.edges.push(edge);
|
|
661
|
+
}
|
|
662
|
+
// Transitions a person or an agent recorded for a journey aren't in the
|
|
663
|
+
// source or the crawl: keep them while both of their screens still exist,
|
|
664
|
+
// or rebuilding the sitemap breaks those journeys.
|
|
665
|
+
const recorded = new Set(existingJourneys.flatMap((journey: any) => Array.isArray(journey.edgeIds) ? journey.edgeIds : []));
|
|
666
|
+
for (const edge of previousEdges) {
|
|
667
|
+
if (!recorded.has(edge?.id) || graph.edges.some((existing: any) => existing.id === edge.id)) continue;
|
|
668
|
+
if (discoveredNodes.has(edge.from) && discoveredNodes.has(edge.to)) graph.edges.push(edge);
|
|
669
|
+
}
|
|
622
670
|
for (const [index, link] of (routesManifest.links ?? []).entries()) {
|
|
623
671
|
if (!link?.from || !link?.to) continue;
|
|
624
672
|
const fromNode = routeNodes.find((candidate: any) =>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import YAML from "yaml";
|
|
2
|
+
import { registerSecretValue } from "./secret-redaction.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The Maestro-style flow subset this project's web plans author.
|
|
@@ -310,6 +311,13 @@ export type InterpolateOptions = {
|
|
|
310
311
|
env?: Readonly<Record<string, string | undefined>>;
|
|
311
312
|
};
|
|
312
313
|
|
|
314
|
+
/** The secret names a flow's text references as `{{secret.NAME}}`, sorted and unique. */
|
|
315
|
+
export function referencedSecretNames(content: string): string[] {
|
|
316
|
+
const names = new Set<string>();
|
|
317
|
+
for (const match of content.matchAll(/\{\{\s*secret\.([a-zA-Z0-9_]+)\s*\}\}/g)) names.add(match[1]);
|
|
318
|
+
return [...names].sort();
|
|
319
|
+
}
|
|
320
|
+
|
|
313
321
|
/**
|
|
314
322
|
* Substitutes `{{name}}` tokens with a value bound earlier in the same flow
|
|
315
323
|
* (currently, only requestTempEmail binds one). Plain text with no template
|
|
@@ -335,6 +343,8 @@ export function interpolateTemplate(
|
|
|
335
343
|
if (value === undefined || value === "") {
|
|
336
344
|
throw new Error(`Flow references "{{secret.${name}}}", but ${variable} is not set on this worker.`);
|
|
337
345
|
}
|
|
346
|
+
// Typed from here on, so it must never show up in a log or result.
|
|
347
|
+
registerSecretValue(value);
|
|
338
348
|
return value;
|
|
339
349
|
}
|
|
340
350
|
if (!(name in context)) {
|
|
@@ -403,22 +413,26 @@ export function describeAddressForLog(address: string): string {
|
|
|
403
413
|
* have templated. `retry`'s nested steps are deliberately left alone here --
|
|
404
414
|
* the driver interpolates each one individually as it actually executes it.
|
|
405
415
|
*/
|
|
406
|
-
export function interpolateStep(
|
|
416
|
+
export function interpolateStep(
|
|
417
|
+
step: FlowStep,
|
|
418
|
+
context: Readonly<Record<string, string>>,
|
|
419
|
+
options: Pick<InterpolateOptions, "env"> = {},
|
|
420
|
+
): FlowStep {
|
|
407
421
|
switch (step.type) {
|
|
408
422
|
case "tapOn":
|
|
409
|
-
return { ...step, text: interpolateTemplate(step.text, context) };
|
|
423
|
+
return { ...step, text: interpolateTemplate(step.text, context, options) };
|
|
410
424
|
case "inputText":
|
|
411
|
-
return { ...step, text: interpolateTemplate(step.text, context, { allowSecrets: true }) };
|
|
425
|
+
return { ...step, text: interpolateTemplate(step.text, context, { ...options, allowSecrets: true }) };
|
|
412
426
|
case "assertVisible":
|
|
413
|
-
return { ...step, text: interpolateTemplate(step.text, context) };
|
|
427
|
+
return { ...step, text: interpolateTemplate(step.text, context, options) };
|
|
414
428
|
case "assertNotVisible":
|
|
415
|
-
return { ...step, text: interpolateTemplate(step.text, context) };
|
|
429
|
+
return { ...step, text: interpolateTemplate(step.text, context, options) };
|
|
416
430
|
case "assertClickable":
|
|
417
|
-
return { ...step, text: interpolateTemplate(step.text, context) };
|
|
431
|
+
return { ...step, text: interpolateTemplate(step.text, context, options) };
|
|
418
432
|
case "scrollUntilVisible":
|
|
419
|
-
return { ...step, text: interpolateTemplate(step.text, context) };
|
|
433
|
+
return { ...step, text: interpolateTemplate(step.text, context, options) };
|
|
420
434
|
case "openLink":
|
|
421
|
-
return { ...step, url: interpolateTemplate(step.url, context) };
|
|
435
|
+
return { ...step, url: interpolateTemplate(step.url, context, options) };
|
|
422
436
|
default:
|
|
423
437
|
return step;
|
|
424
438
|
}
|
|
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import readline from "node:readline/promises";
|
|
5
5
|
import { stdin as input, stdout as output } from "node:process";
|
|
6
6
|
import YAML from "yaml";
|
|
7
|
+
import { describeDetection, detectProjectIdentity, type ProjectIdentityDetection } from "./project-identity.js";
|
|
7
8
|
|
|
8
9
|
export type StorageProvider = "local" | "gcs" | "s3" | "r2" | "bugmole";
|
|
9
10
|
|
|
@@ -30,6 +31,10 @@ export type InitOptions = {
|
|
|
30
31
|
endpoint?: string;
|
|
31
32
|
cloudProjectId?: string;
|
|
32
33
|
accountId?: string;
|
|
34
|
+
/** The app's folder inside the repository, for a repository with several apps. */
|
|
35
|
+
appPath?: string;
|
|
36
|
+
/** The app's URL (a public one identifies the project by domain when there is no git remote). */
|
|
37
|
+
appUrl?: string;
|
|
33
38
|
};
|
|
34
39
|
|
|
35
40
|
export type InitConfig = {
|
|
@@ -50,8 +55,40 @@ export type InitConfig = {
|
|
|
50
55
|
endpoint?: string;
|
|
51
56
|
cloudProjectId?: string;
|
|
52
57
|
accountId?: string;
|
|
58
|
+
/** Which app of the repository this project is ("." for the root). */
|
|
59
|
+
appPath?: string;
|
|
53
60
|
};
|
|
54
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The registry refused a project or workspace because of the plan (402
|
|
64
|
+
* plan_limit or free_workspace_exists). Carries the registry's own message
|
|
65
|
+
* and the Billing link, so the CLI prints those instead of a stack trace.
|
|
66
|
+
*/
|
|
67
|
+
export class PlanLimitError extends Error {
|
|
68
|
+
constructor(message: string, readonly billingUrl: string, readonly code: string) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = "PlanLimitError";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A PlanLimitError for a registry refusal about the plan; null for anything else. */
|
|
75
|
+
export function planLimitFromRegistry(
|
|
76
|
+
status: number,
|
|
77
|
+
body: { error?: string; code?: string; upgradeUrl?: string; billingUrl?: string } | null,
|
|
78
|
+
dashboardUrl: string,
|
|
79
|
+
): PlanLimitError | null {
|
|
80
|
+
if (!body || (body.code !== "plan_limit" && body.code !== "free_workspace_exists" && body.code !== "identity_in_use" && status !== 402)) return null;
|
|
81
|
+
const base = dashboardUrl.replace(/\/$/, "");
|
|
82
|
+
const relative = typeof body.upgradeUrl === "string" && body.upgradeUrl.startsWith("/billing") ? body.upgradeUrl : "/billing";
|
|
83
|
+
const billingUrl = typeof body.billingUrl === "string" && /^https?:\/\//.test(body.billingUrl) ? body.billingUrl : `${base}${relative}`;
|
|
84
|
+
return new PlanLimitError(body.error || "Your Bugmole plan doesn't allow this.", billingUrl, body.code ?? "plan_limit");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What `bugmole init` prints for a plan refusal. */
|
|
88
|
+
export function describePlanLimit(error: PlanLimitError): string {
|
|
89
|
+
return `${error.message}\nSee plans and upgrade: ${error.billingUrl}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
55
92
|
const providers = new Set<StorageProvider>(["local", "gcs", "s3", "r2", "bugmole"]);
|
|
56
93
|
|
|
57
94
|
export function validateStorageProvider(value: string): StorageProvider {
|
|
@@ -90,6 +127,7 @@ function existingDefaults(cwd: string, configPath: string): Partial<InitConfig>
|
|
|
90
127
|
endpoint: storage[provider]?.endpoint,
|
|
91
128
|
cloudProjectId: storage[provider]?.project_id,
|
|
92
129
|
accountId: storage[provider]?.account_id,
|
|
130
|
+
appPath: project.app_path ?? project.appPath,
|
|
93
131
|
};
|
|
94
132
|
}
|
|
95
133
|
|
|
@@ -134,7 +172,7 @@ project:
|
|
|
134
172
|
workspace_id: ${config.workspaceId}
|
|
135
173
|
workspace_name: ${JSON.stringify(config.workspaceName)}
|
|
136
174
|
environment: ${config.environment}
|
|
137
|
-
spec_dir: ${config.specDir}
|
|
175
|
+
${config.appPath ? ` app_path: ${JSON.stringify(config.appPath)}\n` : ""} spec_dir: ${config.specDir}
|
|
138
176
|
evidence_manifest: ${config.evidenceManifest}
|
|
139
177
|
evidence_screenshots: ${config.evidenceScreenshots}
|
|
140
178
|
|
|
@@ -167,7 +205,8 @@ function renderEnv(config: InitConfig): string {
|
|
|
167
205
|
`BUGMOLE_ENVIRONMENT=${config.environment}`,
|
|
168
206
|
`BUGMOLE_STORAGE_PROVIDER=${config.storageProvider}`,
|
|
169
207
|
`BUGMOLE_LOCAL_ARTIFACTS_DIR=${config.artifactsDir}`,
|
|
170
|
-
|
|
208
|
+
// The email you sign in with, remembered to prefill the next sign-in.
|
|
209
|
+
...(process.env.BUGMOLE_AEGIS_EMAIL?.trim() ? [`BUGMOLE_AEGIS_EMAIL=${JSON.stringify(process.env.BUGMOLE_AEGIS_EMAIL.trim())}`] : []),
|
|
171
210
|
"",
|
|
172
211
|
];
|
|
173
212
|
if (config.storageProvider === "gcs") {
|
|
@@ -199,11 +238,50 @@ function renderEnv(config: InitConfig): string {
|
|
|
199
238
|
return `${lines.join("\n")}\n`;
|
|
200
239
|
}
|
|
201
240
|
|
|
241
|
+
/** The registry matched this app to a project that already exists (a teammate's): use its id from now on. */
|
|
242
|
+
function adoptProjectId(configFile: string, envFile: string, config: InitConfig, projectId: string): void {
|
|
243
|
+
if (projectId === config.projectId) return;
|
|
244
|
+
if (fs.existsSync(configFile)) {
|
|
245
|
+
const text = fs.readFileSync(configFile, "utf8");
|
|
246
|
+
fs.writeFileSync(configFile, text.replace(/^(project:\r?\n id: ).*$/m, `$1${projectId}`), "utf8");
|
|
247
|
+
}
|
|
248
|
+
if (fs.existsSync(envFile)) writeEnvValue(envFile, "BUGMOLE_PROJECT_ID", projectId);
|
|
249
|
+
config.projectId = projectId;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Which app this is; never fails init (detection is best-effort). */
|
|
253
|
+
function detectIdentity(cwd: string, options: InitOptions, defaults: Partial<InitConfig>): ProjectIdentityDetection | null {
|
|
254
|
+
try {
|
|
255
|
+
return detectProjectIdentity({
|
|
256
|
+
cwd,
|
|
257
|
+
appPath: options.appPath ?? defaults.appPath,
|
|
258
|
+
appUrl: options.appUrl ?? (process.env.BUGMOLE_BASE_URL?.trim() || process.env.PLAYWRIGHT_BASE_URL?.trim() || undefined),
|
|
259
|
+
});
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
202
265
|
async function ask(rl: readline.Interface, label: string, fallback: string): Promise<string> {
|
|
203
266
|
const answer = await rl.question(`${label} [${fallback}]: `);
|
|
204
267
|
return valueOr(answer, fallback);
|
|
205
268
|
}
|
|
206
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Adds the env file to the repository's .gitignore (when the workspace is a
|
|
272
|
+
* git repository and doesn't ignore it yet). Returns whether it added it.
|
|
273
|
+
*/
|
|
274
|
+
export function ensureGitignored(cwd: string, file: string): boolean {
|
|
275
|
+
if (!fs.existsSync(path.join(cwd, ".git"))) return false;
|
|
276
|
+
const gitignore = path.join(cwd, ".gitignore");
|
|
277
|
+
const entry = `/${file.replace(/^\.\//, "")}`;
|
|
278
|
+
const current = fs.existsSync(gitignore) ? fs.readFileSync(gitignore, "utf8") : "";
|
|
279
|
+
const lines = current.split(/\r?\n/).map((line) => line.trim());
|
|
280
|
+
if (lines.includes(entry) || lines.includes(entry.slice(1)) || lines.includes(".bugmole.env*") || lines.includes("*.env")) return false;
|
|
281
|
+
fs.writeFileSync(gitignore, `${current}${current && !current.endsWith("\n") ? "\n" : ""}${entry}\n`, "utf8");
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
|
|
207
285
|
export async function runInitWizard(
|
|
208
286
|
options: InitOptions = {},
|
|
209
287
|
): Promise<{ configPath: string; envPath: string; config: InitConfig }> {
|
|
@@ -232,6 +310,12 @@ export async function runInitWizard(
|
|
|
232
310
|
await choose(options.storageProvider, "Storage provider (local|gcs|s3|r2|bugmole)", valueOr(defaults.storageProvider, "local")),
|
|
233
311
|
),
|
|
234
312
|
};
|
|
313
|
+
const detection = detectIdentity(cwd, options, defaults);
|
|
314
|
+
if (detection) {
|
|
315
|
+
for (const line of describeDetection(detection)) console.log(line);
|
|
316
|
+
if (detection.app && detection.app.path !== ".") config.appPath = detection.app.path;
|
|
317
|
+
}
|
|
318
|
+
const identity = detection?.identity ?? undefined;
|
|
235
319
|
if (config.storageProvider !== "local" && config.storageProvider !== "bugmole") {
|
|
236
320
|
config.bucket = await choose(options.bucket, "Bucket", valueOr(defaults.bucket, ""));
|
|
237
321
|
if (config.storageProvider === "gcs")
|
|
@@ -256,6 +340,7 @@ export async function runInitWizard(
|
|
|
256
340
|
if (options.force || !fs.existsSync(envFile)) {
|
|
257
341
|
fs.writeFileSync(envFile, renderEnv(config), "utf8");
|
|
258
342
|
console.log(`Generated ${configPath} and ${envPath}.`);
|
|
343
|
+
if (ensureGitignored(cwd, envPath)) console.log(`Added ${envPath} to .gitignore: it will hold your API key.`);
|
|
259
344
|
} else {
|
|
260
345
|
console.log(`Generated ${configPath}; kept existing ${envPath}.`);
|
|
261
346
|
}
|
|
@@ -264,6 +349,8 @@ export async function runInitWizard(
|
|
|
264
349
|
}
|
|
265
350
|
const registryUrl =
|
|
266
351
|
process.env.BUGMOLE_REGISTRY_URL?.trim() || "https://api.bugmole.com";
|
|
352
|
+
const dashboardUrl =
|
|
353
|
+
process.env.BUGMOLE_DASHBOARD_URL?.trim() || "https://app.bugmole.com";
|
|
267
354
|
const apiKey = process.env.BUGMOLE_API_KEY?.trim();
|
|
268
355
|
if (registryUrl && apiKey && shouldWrite) {
|
|
269
356
|
const headers: Record<string, string> = {
|
|
@@ -282,12 +369,22 @@ export async function runInitWizard(
|
|
|
282
369
|
workspaceName: config.workspaceName,
|
|
283
370
|
environment: config.environment,
|
|
284
371
|
storageProvider: config.storageProvider,
|
|
372
|
+
identity,
|
|
285
373
|
}),
|
|
286
374
|
});
|
|
287
375
|
if (!response.ok) {
|
|
288
|
-
|
|
376
|
+
const refusal = await response.json().catch(() => null) as { error?: string; code?: string; upgradeUrl?: string; billingUrl?: string } | null;
|
|
377
|
+
const planLimit = planLimitFromRegistry(response.status, refusal, dashboardUrl);
|
|
378
|
+
if (planLimit) throw planLimit;
|
|
379
|
+
throw new Error(`Bugmole registry rejected project registration (${response.status})${refusal?.error ? `: ${refusal.error}` : ""}`);
|
|
380
|
+
}
|
|
381
|
+
const registered = await response.json().catch(() => null) as { project?: { id?: string }; existing?: boolean } | null;
|
|
382
|
+
if (registered?.existing && registered.project?.id) {
|
|
383
|
+
adoptProjectId(configFile, envFile, config, registered.project.id);
|
|
384
|
+
console.log(`This app is already project ${config.projectId} in the workspace; joined it instead of creating another.`);
|
|
385
|
+
} else {
|
|
386
|
+
console.log(`Registered ${config.projectId} with the Bugmole registry.`);
|
|
289
387
|
}
|
|
290
|
-
console.log(`Registered ${config.projectId} with the Bugmole registry.`);
|
|
291
388
|
} else if (registryUrl && !apiKey && shouldWrite && !nonInteractive) {
|
|
292
389
|
const createResponse = await fetch(`${registryUrl.replace(/\/$/, "")}/api/device-authorizations`, {
|
|
293
390
|
method: "POST",
|
|
@@ -297,6 +394,7 @@ export async function runInitWizard(
|
|
|
297
394
|
projectName: config.projectName,
|
|
298
395
|
environment: config.environment,
|
|
299
396
|
storageProvider: config.storageProvider,
|
|
397
|
+
identity,
|
|
300
398
|
}),
|
|
301
399
|
});
|
|
302
400
|
const attempt = await createResponse.json().catch(() => null) as {
|
|
@@ -309,8 +407,6 @@ export async function runInitWizard(
|
|
|
309
407
|
if (!createResponse.ok || !attempt?.attemptId || !attempt.code || !attempt.pollSecret) {
|
|
310
408
|
throw new Error(attempt?.error ?? "Unable to start Bugmole device registration");
|
|
311
409
|
}
|
|
312
|
-
const dashboardUrl =
|
|
313
|
-
process.env.BUGMOLE_DASHBOARD_URL?.trim() || "https://app.bugmole.com";
|
|
314
410
|
const registrationUrl =
|
|
315
411
|
`${dashboardUrl.replace(/\/$/, "")}/register?attemptId=${encodeURIComponent(attempt.attemptId)}&projectId=${encodeURIComponent(config.projectId)}${process.env.BUGMOLE_AEGIS_EMAIL?.trim() ? `&email=${encodeURIComponent(process.env.BUGMOLE_AEGIS_EMAIL.trim())}` : ""}`;
|
|
316
412
|
console.log(`Open ${registrationUrl} and enter code: ${attempt.code}`);
|
|
@@ -327,14 +423,24 @@ export async function runInitWizard(
|
|
|
327
423
|
status?: string;
|
|
328
424
|
token?: string;
|
|
329
425
|
email?: string;
|
|
426
|
+
projectId?: string;
|
|
330
427
|
alreadyInitialized?: boolean;
|
|
331
428
|
error?: string;
|
|
429
|
+
code?: string;
|
|
430
|
+
upgradeUrl?: string;
|
|
431
|
+
billingUrl?: string;
|
|
332
432
|
} | null;
|
|
333
433
|
if (result?.status === "pending") continue;
|
|
334
434
|
if (!pollResponse.ok) {
|
|
435
|
+
const planLimit = planLimitFromRegistry(pollResponse.status, result, dashboardUrl);
|
|
436
|
+
if (planLimit) throw planLimit;
|
|
335
437
|
const detail = result?.error ?? result?.status ?? `HTTP ${pollResponse.status}`;
|
|
336
438
|
throw new Error(`Bugmole device registration failed: ${detail}`);
|
|
337
439
|
}
|
|
440
|
+
if (result?.projectId && result.projectId !== config.projectId) {
|
|
441
|
+
adoptProjectId(configFile, envFile, config, result.projectId);
|
|
442
|
+
console.log(`This app is already project ${config.projectId}; this machine joined it.`);
|
|
443
|
+
}
|
|
338
444
|
if (result?.alreadyInitialized) {
|
|
339
445
|
throw new Error("This Aegis account already has an API key; revoke it or use that key in .bugmole.env.");
|
|
340
446
|
}
|