@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.
- package/LICENSE +18 -4
- package/package.json +7 -4
- 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 +290 -43
- package/scripts/ensure-playwright.cjs +87 -0
- package/scripts/ensure-playwright.test.ts +36 -0
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-launcher-version.cjs +24 -0
- package/scripts/sync-launcher-version.test.ts +28 -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/0034_signup_attribution.sql +19 -0
- package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
- 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 +24 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/domains/domains.test.ts +64 -1
- package/src/registry-worker/domains/routes.ts +32 -15
- package/src/registry-worker/domains.ts +40 -2
- 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 +11 -0
- package/src/registry-worker/feedback/feedback.test.ts +47 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/feedback/website.ts +77 -0
- 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 +494 -173
- 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.test.ts +24 -1
- package/src/registry-worker/org/routes.ts +9 -8
- 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 +59 -0
- package/src/registry-worker/runner/tokens.ts +12 -1
- 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/attribution.test.ts +147 -0
- package/src/registry-worker/signup/attribution.ts +194 -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/ai-exploration.test.ts +34 -1
- package/src/runtime/ai-exploration.ts +97 -3
- package/src/runtime/appium-driver.ts +7 -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 +87 -7
- 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 +43 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +78 -2
- package/src/runtime/journey-graph.test.ts +60 -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 +164 -0
- package/src/runtime/playwright-driver.ts +286 -15
- 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-job.test.ts +21 -0
- package/src/runtime/run-job.ts +8 -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 +181 -4
- package/src/runtime/site-discovery.ts +248 -29
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.test.ts +65 -0
- package/src/runtime/web-suite.ts +8 -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
|
@@ -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
|
}
|
|
@@ -82,6 +82,15 @@ function idPart(value: string): string {
|
|
|
82
82
|
return value.trim().replace(/[^a-zA-Z0-9._:-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Step and transition IDs keep the route in them (screen:/settings,
|
|
87
|
+
* screen:/->screen:/settings:dom-3), as exploration writes them; rewriting
|
|
88
|
+
* the slashes made the agent's next call name a step that didn't exist.
|
|
89
|
+
*/
|
|
90
|
+
function graphIdPart(value: string): string {
|
|
91
|
+
return value.trim().replace(/[^a-zA-Z0-9._:/>\[\]-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
92
|
+
}
|
|
93
|
+
|
|
85
94
|
function requireJourney(graph: JourneyGraph, journeyId: string): UserJourney {
|
|
86
95
|
const journey = graph.journeys.find((item) => item.id === journeyId);
|
|
87
96
|
if (!journey) throw new Error(`Journey not found: ${journeyId}`);
|
|
@@ -98,6 +107,45 @@ function nextRevision(journey: UserJourney): UserJourney {
|
|
|
98
107
|
};
|
|
99
108
|
}
|
|
100
109
|
|
|
110
|
+
/**
|
|
111
|
+
* A run that passed proves the flow it ran. Discovery saves what it finds as
|
|
112
|
+
* draft journeys, and only stable ones are drawn in the dashboard's journey
|
|
113
|
+
* graph, but nothing promoted them: a cloud run has no agent to call
|
|
114
|
+
* stabilizeJourney. This does it for the drafts discovery wrote, reading the
|
|
115
|
+
* stored graph (a cloud container has no local copy). Journeys an agent or a
|
|
116
|
+
* person made are left for them to stabilize. Returns the journey promoted.
|
|
117
|
+
*/
|
|
118
|
+
export async function stabilizeAfterPassingRun(
|
|
119
|
+
cfg: JourneyConfig,
|
|
120
|
+
run: { id: string; planId?: string | null; journeyId?: string | null },
|
|
121
|
+
): Promise<string | null> {
|
|
122
|
+
const store = storeFor(cfg);
|
|
123
|
+
const key = `${projectPrefix(projectId(cfg))}/journeys/graph.json`;
|
|
124
|
+
if (!(await store.has(key))) return null;
|
|
125
|
+
const parsed = JSON.parse(await store.getText(key)) as JourneyGraph;
|
|
126
|
+
const graph = repairJourneyGraph({
|
|
127
|
+
...emptyJourneyGraph(projectId(cfg)),
|
|
128
|
+
...parsed,
|
|
129
|
+
journeys: Array.isArray(parsed.journeys) ? parsed.journeys : [],
|
|
130
|
+
nodes: Array.isArray(parsed.nodes) ? parsed.nodes : [],
|
|
131
|
+
edges: Array.isArray(parsed.edges) ? parsed.edges : [],
|
|
132
|
+
}, projectId(cfg));
|
|
133
|
+
const current = graph.journeys.find((journey) =>
|
|
134
|
+
(run.journeyId && journey.id === run.journeyId) || (run.planId && journey.metadata?.planId === run.planId));
|
|
135
|
+
if (!current || current.stability === "stable" || current.metadata?.generatedBy !== "site-discovery") return null;
|
|
136
|
+
const journey: UserJourney = {
|
|
137
|
+
...current,
|
|
138
|
+
status: "published",
|
|
139
|
+
stability: "stable",
|
|
140
|
+
linkedToGraph: true,
|
|
141
|
+
verificationRunId: run.id,
|
|
142
|
+
verifiedAt: new Date().toISOString(),
|
|
143
|
+
};
|
|
144
|
+
graph.journeys = graph.journeys.map((item) => item.id === current.id ? journey : item);
|
|
145
|
+
await writeJourneyGraph(cfg, graph);
|
|
146
|
+
return journey.id;
|
|
147
|
+
}
|
|
148
|
+
|
|
101
149
|
export async function stabilizeJourney(
|
|
102
150
|
cfg: JourneyConfig,
|
|
103
151
|
input: { journeyId: string; verificationRunId: string; verifiedAt?: string },
|
|
@@ -152,7 +200,7 @@ export async function addJourneyStep(
|
|
|
152
200
|
) {
|
|
153
201
|
const graph = readJourneyGraph(cfg);
|
|
154
202
|
const journey = requireJourney(graph, input.journeyId);
|
|
155
|
-
const nodeId =
|
|
203
|
+
const nodeId = graphIdPart(input.node.id || `step-${Date.now()}`);
|
|
156
204
|
if (!nodeId || graph.nodes.some((node) => node.id === nodeId)) throw new Error(`Node already exists: ${nodeId}`);
|
|
157
205
|
const node = { ...input.node, id: nodeId } as JourneyNode;
|
|
158
206
|
graph.nodes.push(node);
|
|
@@ -202,7 +250,7 @@ export async function addJourneyTransition(
|
|
|
202
250
|
if (!graph.nodes.some((node) => node.id === input.from) || !graph.nodes.some((node) => node.id === input.to)) {
|
|
203
251
|
throw new Error("Transition endpoints must reference existing nodes");
|
|
204
252
|
}
|
|
205
|
-
const edgeId =
|
|
253
|
+
const edgeId = graphIdPart(input.edge?.id || `${input.from}->${input.to}:${Date.now()}`);
|
|
206
254
|
if (graph.edges.some((edge) => edge.id === edgeId)) throw new Error(`Edge already exists: ${edgeId}`);
|
|
207
255
|
const edge: JourneyEdge = { ...input.edge, id: edgeId, from: input.from, to: input.to };
|
|
208
256
|
graph.edges.push(edge);
|
|
@@ -262,6 +310,34 @@ export async function removeJourneyItem(
|
|
|
262
310
|
export function nextJourneyTask(cfg: JourneyConfig, journeyId: string): JourneyNextTask {
|
|
263
311
|
const graph = readJourneyGraph(cfg);
|
|
264
312
|
const journey = requireJourney(graph, journeyId);
|
|
313
|
+
// An empty journey, or steps nothing connects, is not finished: there is
|
|
314
|
+
// nothing to plan from yet.
|
|
315
|
+
if (!journey.nodeIds.length) {
|
|
316
|
+
return {
|
|
317
|
+
status: "actionable",
|
|
318
|
+
journeyId,
|
|
319
|
+
reason: "The journey has no steps yet.",
|
|
320
|
+
action: "Add the screen the journey starts on",
|
|
321
|
+
nextSteps: [
|
|
322
|
+
"Open the app where this journey starts and record that screen with bugmole_journey_add_step",
|
|
323
|
+
"Call bugmole_journey_next again after recording it",
|
|
324
|
+
],
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (journey.nodeIds.length > 1 && !journey.edgeIds.length) {
|
|
328
|
+
return {
|
|
329
|
+
status: "actionable",
|
|
330
|
+
journeyId,
|
|
331
|
+
reason: "The journey's steps aren't connected: no transition says how a person gets from one to the next.",
|
|
332
|
+
nodeId: journey.nodeIds[0],
|
|
333
|
+
route: graph.nodes.find((node) => node.id === journey.nodeIds[0])?.route,
|
|
334
|
+
action: `Connect ${journey.nodeIds[0]} to the next step`,
|
|
335
|
+
nextSteps: [
|
|
336
|
+
"Perform the action that leads from each step to the next and record it with bugmole_journey_add_transition",
|
|
337
|
+
"Call bugmole_journey_next again after recording it",
|
|
338
|
+
],
|
|
339
|
+
};
|
|
340
|
+
}
|
|
265
341
|
const included = new Set(journey.nodeIds);
|
|
266
342
|
const includedEdges = new Set(journey.edgeIds);
|
|
267
343
|
const coveredTransitions = new Set(
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
addJourneyTransition,
|
|
10
10
|
createJourney,
|
|
11
11
|
reuseJourneyStep,
|
|
12
|
+
stabilizeAfterPassingRun,
|
|
12
13
|
stabilizeJourney,
|
|
13
14
|
nextJourneyTask,
|
|
14
15
|
readJourneyGraph,
|
|
@@ -255,3 +256,62 @@ test("requires runtime transition and terminal-goal completion for source paths"
|
|
|
255
256
|
assert.equal(nextJourneyTask(cfg, "top-up-flow").status, "exhausted");
|
|
256
257
|
await fs.promises.rm(root, { recursive: true, force: true });
|
|
257
258
|
});
|
|
259
|
+
|
|
260
|
+
test("a passing run promotes the explored draft it proved, from the stored graph alone", async () => {
|
|
261
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-promote-"));
|
|
262
|
+
const storage = { provider: "local", localDirectory: path.join(root, "artifacts") };
|
|
263
|
+
const writer = { project: { id: "demo" }, runtime: { spec_dir: path.join(root, "spec"), artifacts_dir: path.join(root, "artifacts") }, storage };
|
|
264
|
+
// A cloud container: same project storage, no local copy of the graph.
|
|
265
|
+
const container = { project: { id: "demo" }, runtime: { spec_dir: path.join(root, "container"), artifacts_dir: path.join(root, "artifacts") }, storage };
|
|
266
|
+
try {
|
|
267
|
+
const graph = emptyJourneyGraph("demo");
|
|
268
|
+
graph.nodes = [{ id: "screen:/login", title: "Welcome back", kind: "screen", route: "/login" }];
|
|
269
|
+
const draft = (id: string, generatedBy?: string) => ({
|
|
270
|
+
id,
|
|
271
|
+
name: id,
|
|
272
|
+
actor: "visitor",
|
|
273
|
+
startNodeId: "screen:/login",
|
|
274
|
+
nodeIds: ["screen:/login"],
|
|
275
|
+
edgeIds: [],
|
|
276
|
+
revision: 1,
|
|
277
|
+
status: "draft" as const,
|
|
278
|
+
sourceOfTruth: "canonical_flow" as const,
|
|
279
|
+
stability: "discovered" as const,
|
|
280
|
+
linkedToGraph: false,
|
|
281
|
+
metadata: generatedBy ? { generatedBy, planId: id } : {},
|
|
282
|
+
});
|
|
283
|
+
graph.journeys = [draft("discovered-welcome-back", "site-discovery"), draft("hand-made")];
|
|
284
|
+
await writeJourneyGraph(writer, graph);
|
|
285
|
+
|
|
286
|
+
assert.equal(await stabilizeAfterPassingRun(container, { id: "run_1", planId: "discovered-welcome-back", journeyId: "discovered-welcome-back" }), "discovered-welcome-back");
|
|
287
|
+
const stored = JSON.parse(fs.readFileSync(path.join(root, "artifacts", "projects", "demo", "journeys", "graph.json"), "utf8"));
|
|
288
|
+
const promoted = stored.journeys.find((journey: { id: string }) => journey.id === "discovered-welcome-back");
|
|
289
|
+
assert.equal(promoted.stability, "stable");
|
|
290
|
+
assert.equal(promoted.linkedToGraph, true);
|
|
291
|
+
assert.equal(promoted.verificationRunId, "run_1");
|
|
292
|
+
|
|
293
|
+
assert.equal(await stabilizeAfterPassingRun(container, { id: "run_2", journeyId: "hand-made" }), null, "journeys a person or agent made are theirs to stabilize");
|
|
294
|
+
assert.equal(await stabilizeAfterPassingRun(container, { id: "run_3", journeyId: "discovered-welcome-back" }), null, "already stable");
|
|
295
|
+
assert.equal(await stabilizeAfterPassingRun(container, { id: "run_4", journeyId: "missing" }), null);
|
|
296
|
+
} finally {
|
|
297
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("route-style step IDs are kept, and an empty or unconnected journey is not exhausted", async () => {
|
|
302
|
+
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "bugmole-flow-ids-"));
|
|
303
|
+
const cfg = {
|
|
304
|
+
project: { id: "demo" },
|
|
305
|
+
runtime: { spec_dir: path.join(root, "spec"), artifacts_dir: path.join(root, "artifacts") },
|
|
306
|
+
storage: { provider: "local", localDirectory: path.join(root, "artifacts") },
|
|
307
|
+
};
|
|
308
|
+
await createJourney(cfg, { id: "sign-up", name: "Sign up", actor: "guest" });
|
|
309
|
+
assert.equal(nextJourneyTask(cfg, "sign-up").status, "actionable");
|
|
310
|
+
const home = await addJourneyStep(cfg, { journeyId: "sign-up", node: { id: "screen:/", title: "Home", kind: "screen", route: "/" } });
|
|
311
|
+
const signup = await addJourneyStep(cfg, { journeyId: "sign-up", node: { id: "screen:/signup", title: "Sign up", kind: "screen", route: "/signup" } });
|
|
312
|
+
assert.deepEqual(signup.journey.nodeIds, ["screen:/", "screen:/signup"]);
|
|
313
|
+
assert.ok(home);
|
|
314
|
+
assert.match(nextJourneyTask(cfg, "sign-up").reason, /aren't connected/);
|
|
315
|
+
await addJourneyTransition(cfg, { journeyId: "sign-up", from: "screen:/", to: "screen:/signup" });
|
|
316
|
+
assert.match(readJourneyGraph(cfg).edges[0].id, /^screen:\/->screen:\/signup:/);
|
|
317
|
+
});
|
|
@@ -323,3 +323,67 @@ test("personas: adding, renaming, and removing, with at least one always kept",
|
|
|
323
323
|
assert.equal(removeLast.status, 409, "the last persona in a project can't be removed");
|
|
324
324
|
});
|
|
325
325
|
});
|
|
326
|
+
|
|
327
|
+
test("completion review mirrors the registry: in review, then accepted or sent back with a note", async () => {
|
|
328
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-local-registry-"));
|
|
329
|
+
const stub = await startLocalRegistryStub({ directory, reviewMode: "auto" });
|
|
330
|
+
const origin = stub.origin;
|
|
331
|
+
try {
|
|
332
|
+
const create = async (taskType: string, key: string) => {
|
|
333
|
+
const created = await postJson(origin, "/api/projects/lumis/tasks", { title: "t", description: "d", taskType, idempotencyKey: key });
|
|
334
|
+
const id = (created.body as { task: { id: string } }).task.id;
|
|
335
|
+
await postJson(origin, `/api/tasks/${id}/claim`, { workerId: "w" });
|
|
336
|
+
return id;
|
|
337
|
+
};
|
|
338
|
+
const investigation = await create("investigation", "a");
|
|
339
|
+
const done = await postJson(origin, `/api/tasks/${investigation}`, { workerId: "w", status: "completed" });
|
|
340
|
+
assert.equal((done.body as { task: { status: string } }).task.status, "completed", "auto doesn't review an investigation");
|
|
341
|
+
|
|
342
|
+
const code = await create("code", "b");
|
|
343
|
+
const review = await postJson(origin, `/api/tasks/${code}`, { workerId: "w", status: "completed" });
|
|
344
|
+
const reviewed = review.body as { inReview: boolean; message: string; task: { status: string } };
|
|
345
|
+
assert.equal(reviewed.task.status, "in_review");
|
|
346
|
+
assert.equal(reviewed.inReview, true);
|
|
347
|
+
assert.equal((await postJson(origin, `/api/tasks/${code}`, { workerId: "w", status: "running" })).status, 409);
|
|
348
|
+
assert.equal((await postJson(origin, `/api/tasks/${code}/resume`, {})).status, 409);
|
|
349
|
+
|
|
350
|
+
assert.equal((await postJson(origin, `/api/tasks/${code}/send-back`, {})).status, 422, "sending back needs a note");
|
|
351
|
+
const sent = await postJson(origin, `/api/tasks/${code}/send-back`, { note: "Also cover the empty cart" });
|
|
352
|
+
const sentTask = (sent.body as { task: { status: string; reviewNote: string; attempts: number } }).task;
|
|
353
|
+
assert.equal(sentTask.status, "queued");
|
|
354
|
+
assert.equal(sentTask.reviewNote, "Also cover the empty cart");
|
|
355
|
+
assert.equal(sentTask.attempts, 0);
|
|
356
|
+
|
|
357
|
+
await postJson(origin, `/api/tasks/${code}/claim`, { workerId: "w" });
|
|
358
|
+
await postJson(origin, `/api/tasks/${code}`, { workerId: "w", status: "completed" });
|
|
359
|
+
const accepted = await postJson(origin, `/api/tasks/${code}/accept`, {});
|
|
360
|
+
assert.equal((accepted.body as { task: { status: string; reviewedBy: string } }).task.status, "completed");
|
|
361
|
+
assert.equal((await postJson(origin, `/api/tasks/${code}/accept`, {})).status, 409);
|
|
362
|
+
} finally {
|
|
363
|
+
await stub.close();
|
|
364
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("roles: suggestions wait for a decision; the dashboard's add form confirms", async () => {
|
|
369
|
+
await withStub(async (origin) => {
|
|
370
|
+
const suggested = await postJson(origin, "/api/projects/lumis/roles", { name: "Admin", description: "Admin area.", source: "role_gated_route", evidence: "route /admin" });
|
|
371
|
+
assert.equal(suggested.status, 201);
|
|
372
|
+
const role = (suggested.body as { role: { id: string; name: string; status: string } }).role;
|
|
373
|
+
assert.equal(role.name, "admin");
|
|
374
|
+
assert.equal(role.status, "suggested");
|
|
375
|
+
assert.equal((await postJson(origin, "/api/projects/lumis/roles", { name: "x", description: "x" })).status, 422, "a suggestion needs evidence");
|
|
376
|
+
|
|
377
|
+
const rejected = await fetch(`${origin}/api/projects/lumis/roles/${role.id}`, {
|
|
378
|
+
method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "reject" }),
|
|
379
|
+
});
|
|
380
|
+
assert.equal(((await rejected.json()) as { role: { status: string } }).role.status, "rejected");
|
|
381
|
+
const again = await postJson(origin, "/api/projects/lumis/roles", { name: "admin", description: "Admin area.", source: "role_gated_menu", evidence: "menu Admin" });
|
|
382
|
+
assert.equal((again.body as { role: { status: string } }).role.status, "rejected", "a rejected role stays rejected");
|
|
383
|
+
|
|
384
|
+
const added = await postJson(origin, "/api/projects/lumis/roles", { name: "guest", description: "Not signed in.", source: "person" });
|
|
385
|
+
assert.equal((added.body as { role: { status: string } }).role.status, "confirmed");
|
|
386
|
+
const listed = await (await fetch(`${origin}/api/projects/lumis/roles?status=confirmed`)).json() as { roles: Array<{ name: string }> };
|
|
387
|
+
assert.deepEqual(listed.roles.map((item) => item.name), ["guest"]);
|
|
388
|
+
});
|
|
389
|
+
});
|