@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
|
@@ -185,6 +185,42 @@ async function withCaseSet(cfg: any) {
|
|
|
185
185
|
}, cfg);
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
test("names the scenario categories a case set still misses, and accepts reasoned exclusions", async () => {
|
|
189
|
+
const { directory, cfg } = await setup();
|
|
190
|
+
const write = (cases: object[], extra: object = {}) => callToolInternal("bugmole_write_test_cases", {
|
|
191
|
+
caseSet: { schemaVersion: 1, caseSetId: "flow-login", journeyId: "flow:login", journeyRevision: 2, cases, ...extra },
|
|
192
|
+
}, cfg);
|
|
193
|
+
try {
|
|
194
|
+
const first = await write([{ ...validCase, category: "happy_path" }, { ...validCase, caseId: "TC-legacy" }]);
|
|
195
|
+
assert.deepEqual(first.scenarios.missing, [
|
|
196
|
+
{ category: "happy_path", nodeId: "screen:/" },
|
|
197
|
+
{ category: "validation" },
|
|
198
|
+
{ category: "error_state" },
|
|
199
|
+
{ category: "permission" },
|
|
200
|
+
{ category: "edge_case" },
|
|
201
|
+
]);
|
|
202
|
+
assert.deepEqual(first.scenarios.uncategorized, ["TC-legacy"]);
|
|
203
|
+
assert.ok(first.nextSteps.some((step: string) => step.startsWith("Step screen:/ has no happy_path case")));
|
|
204
|
+
assert.ok(first.nextSteps.some((step: string) => step.includes("TC-legacy")));
|
|
205
|
+
|
|
206
|
+
const complete = await write([
|
|
207
|
+
{ ...validCase, caseId: "TC-home", nodeId: "screen:/", edgeId: undefined, category: "happy_path" },
|
|
208
|
+
{ ...validCase, category: "happy_path" },
|
|
209
|
+
{ ...validCase, caseId: "TC-bad-password", category: "validation" },
|
|
210
|
+
{ ...validCase, caseId: "TC-auth-down", category: "error_state" },
|
|
211
|
+
{ ...validCase, caseId: "TC-double-submit", category: "edge_case" },
|
|
212
|
+
], { scenarioExclusions: [{ category: "permission", reason: "Login is the one screen every visitor may open." }] });
|
|
213
|
+
assert.deepEqual(complete.scenarios.missing, []);
|
|
214
|
+
assert.match(complete.nextSteps[0], /^Plan the journey/);
|
|
215
|
+
|
|
216
|
+
await assert.rejects(write([{ ...validCase, category: "smoke" }]), /category must be one of happy_path/);
|
|
217
|
+
await assert.rejects(write([validCase], { scenarioExclusions: [{ category: "permission", reason: " " }] }), /reason is required/);
|
|
218
|
+
await assert.rejects(write([validCase], { scenarioExclusions: [{ category: "permission", nodeId: "screen:/nope", reason: "x" }] }), /is not a step of journey/);
|
|
219
|
+
} finally {
|
|
220
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
188
224
|
test("records a run's verdict against a case set that exists", async () => {
|
|
189
225
|
const { directory, cfg } = await setup();
|
|
190
226
|
try {
|
|
@@ -210,6 +246,38 @@ test("records a run's verdict against a case set that exists", async () => {
|
|
|
210
246
|
}
|
|
211
247
|
});
|
|
212
248
|
|
|
249
|
+
test("keeps every write's verdicts in history while the latest file is replaced", async () => {
|
|
250
|
+
const { directory, cfg } = await setup();
|
|
251
|
+
try {
|
|
252
|
+
await withCaseSet(cfg);
|
|
253
|
+
const write = (status: string, runId: string, recordedAt: string) => callToolInternal("bugmole_write_test_case_results", {
|
|
254
|
+
resultSet: {
|
|
255
|
+
schemaVersion: 1,
|
|
256
|
+
caseSetId: "flow-login",
|
|
257
|
+
runId,
|
|
258
|
+
recordedAt,
|
|
259
|
+
results: [{ caseId: "TC-001", status, detail: `${status} on ${runId}` }],
|
|
260
|
+
},
|
|
261
|
+
}, cfg);
|
|
262
|
+
const first = await write("passed", "run-1", "2026-09-01T10:00:00.000Z");
|
|
263
|
+
const second = await write("failed", "run-2", "2026-09-02T10:00:00.000Z");
|
|
264
|
+
assert.equal(first.historyKey, "projects/demo/test-case-history/flow-login/20260901T100000000Z-run-1.json");
|
|
265
|
+
assert.notEqual(first.historyKey, second.historyKey);
|
|
266
|
+
|
|
267
|
+
const latest = JSON.parse(await fs.readFile(path.join(directory, "projects", "demo", "test-case-results", "flow-login.json"), "utf8"));
|
|
268
|
+
assert.equal(latest.results[0].status, "failed");
|
|
269
|
+
const historyDir = path.join(directory, "projects", "demo", "test-case-history", "flow-login");
|
|
270
|
+
const files = (await fs.readdir(historyDir)).sort();
|
|
271
|
+
assert.equal(files.length, 2);
|
|
272
|
+
const statuses = await Promise.all(files.map(async (file) => JSON.parse(await fs.readFile(path.join(historyDir, file), "utf8")).results[0]));
|
|
273
|
+
assert.deepEqual(statuses.map((item) => [item.status, item.caseRevision]), [["passed", 1], ["failed", 1]]);
|
|
274
|
+
// History stays out of the latest-results prefix every reader lists.
|
|
275
|
+
assert.deepEqual(await fs.readdir(path.join(directory, "projects", "demo", "test-case-results")), ["flow-login.json"]);
|
|
276
|
+
} finally {
|
|
277
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
213
281
|
test("refuses a verdict for a case nobody ever wrote", async () => {
|
|
214
282
|
const { directory, cfg } = await setup();
|
|
215
283
|
try {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
|
+
import { redactSecrets } from "../runtime/secret-redaction.js";
|
|
2
3
|
|
|
3
4
|
export type RegistryRun = {
|
|
4
5
|
id: string;
|
|
@@ -19,11 +20,33 @@ export type RegistryRun = {
|
|
|
19
20
|
targetResults?: Array<Record<string, unknown>> | null;
|
|
20
21
|
/** Re-run with the fix suggested by this earlier run/target. */
|
|
21
22
|
fixFrom?: { runId: string; targetId: string; mode?: "suggested" | "ai" } | null;
|
|
23
|
+
/** A retest limited to these cases (a Jira ticket's case): only they are judged. */
|
|
24
|
+
caseScope?: { caseSetId: string; caseIds: string[]; jiraIssueKey?: string } | null;
|
|
22
25
|
durationMs?: number | null;
|
|
23
26
|
/** "cloud" runs belong to Bugmole's hosted runners; a customer's worker skips them. */
|
|
24
27
|
executionMode?: "self" | "cloud";
|
|
25
28
|
};
|
|
26
29
|
|
|
30
|
+
export type RegistryProposal = {
|
|
31
|
+
id: string;
|
|
32
|
+
projectId: string;
|
|
33
|
+
kind: "test_case";
|
|
34
|
+
caseSetId: string;
|
|
35
|
+
caseId: string;
|
|
36
|
+
action: "add" | "change" | "retire";
|
|
37
|
+
baseRevision: number | null;
|
|
38
|
+
proposed: Record<string, unknown> | null;
|
|
39
|
+
summary: string;
|
|
40
|
+
source: string;
|
|
41
|
+
proposedBy: string;
|
|
42
|
+
status: "pending" | "accepted" | "rejected" | "outdated";
|
|
43
|
+
decidedBy: string | null;
|
|
44
|
+
decisionNote: string | null;
|
|
45
|
+
appliedAt: string | null;
|
|
46
|
+
appliedRevision: number | null;
|
|
47
|
+
createdAt: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
27
50
|
export type RegistryTask = {
|
|
28
51
|
id: string;
|
|
29
52
|
projectId: string;
|
|
@@ -45,9 +68,27 @@ export type RegistryTask = {
|
|
|
45
68
|
leaseExpiresAt?: string | null;
|
|
46
69
|
/** "cloud" discovery belongs to Bugmole's hosted runners; a customer's worker skips it. */
|
|
47
70
|
executionMode?: "self" | "cloud";
|
|
71
|
+
/** Null while the task waits for approval; see ApprovalMode. */
|
|
72
|
+
approvedAt?: string | null;
|
|
73
|
+
approvedBy?: string | null;
|
|
74
|
+
/** Set once a person accepted the task or sent it back from review. */
|
|
75
|
+
reviewedAt?: string | null;
|
|
76
|
+
reviewedBy?: string | null;
|
|
77
|
+
/** What the reviewer asked for when they sent the task back. */
|
|
78
|
+
reviewNote?: string | null;
|
|
48
79
|
};
|
|
49
80
|
|
|
50
|
-
export
|
|
81
|
+
export type RegistryRole = {
|
|
82
|
+
id: string;
|
|
83
|
+
name: string;
|
|
84
|
+
description: string;
|
|
85
|
+
status: "suggested" | "confirmed" | "rejected";
|
|
86
|
+
source: string;
|
|
87
|
+
evidence: string | null;
|
|
88
|
+
decidedBy?: string | null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export { MAX_TASK_ATTEMPTS, approvalModeOf, backoffUntil, isClaimableTask, workerApprovalMode, type ApprovalMode } from "./task-scheduling.js";
|
|
51
92
|
|
|
52
93
|
export type ControlPlaneClientOptions = {
|
|
53
94
|
registryUrl: string;
|
|
@@ -56,14 +97,29 @@ export type ControlPlaneClientOptions = {
|
|
|
56
97
|
workspaceId?: string;
|
|
57
98
|
};
|
|
58
99
|
|
|
59
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Carries the HTTP status, so callers can tell a lost claim (409) from a real
|
|
102
|
+
* failure, and the registry's code and message for refusals a person must act
|
|
103
|
+
* on (a paused project's activation link, for one).
|
|
104
|
+
*/
|
|
60
105
|
export class RegistryRequestError extends Error {
|
|
61
|
-
constructor(readonly status: number) {
|
|
62
|
-
super(`Registry request failed (${status})`);
|
|
106
|
+
constructor(readonly status: number, readonly code?: string, readonly detail?: string, readonly body?: Record<string, unknown>) {
|
|
107
|
+
super(detail ? `${detail} (${status}${code ? ` ${code}` : ""})` : `Registry request failed (${status})`);
|
|
63
108
|
this.name = "RegistryRequestError";
|
|
64
109
|
}
|
|
65
110
|
}
|
|
66
111
|
|
|
112
|
+
/** The project is paused for inactivity: nothing runs until a member activates it. */
|
|
113
|
+
export function isProjectInactive(error: unknown): error is RegistryRequestError {
|
|
114
|
+
return error instanceof RegistryRequestError && error.code === "project_inactive";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What the CLI prints for a paused project, with the link to activate it. */
|
|
118
|
+
export function projectInactiveMessage(projectId: string, activateUrl?: string | null): string {
|
|
119
|
+
return `Project ${projectId} is paused because nobody used it for a while, so it won't run tests or tasks. `
|
|
120
|
+
+ `A project member can activate it${activateUrl ? `: ${activateUrl}` : " from its page in the Bugmole dashboard"}.`;
|
|
121
|
+
}
|
|
122
|
+
|
|
67
123
|
export class ControlPlaneClient {
|
|
68
124
|
private readonly baseUrl: string;
|
|
69
125
|
|
|
@@ -74,17 +130,35 @@ export class ControlPlaneClient {
|
|
|
74
130
|
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
75
131
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
76
132
|
...init,
|
|
133
|
+
// Progress and error messages can quote what a flow typed.
|
|
134
|
+
...(typeof init.body === "string" ? { body: redactSecrets(init.body) } : {}),
|
|
77
135
|
headers: {
|
|
78
136
|
authorization: `Bearer ${this.options.apiKey}`,
|
|
79
137
|
"content-type": "application/json",
|
|
80
138
|
...(init.headers ?? {}),
|
|
81
139
|
},
|
|
82
140
|
});
|
|
83
|
-
const body = await response.json().catch(() => ({}))
|
|
84
|
-
if (!response.ok)
|
|
141
|
+
const body = await response.json().catch(() => ({})) as Record<string, unknown>;
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
const code = typeof body.code === "string" ? body.code : undefined;
|
|
144
|
+
// Only refusals meant for a person carry the registry's message.
|
|
145
|
+
const detail = code && typeof body.error === "string" ? body.error : undefined;
|
|
146
|
+
throw new RegistryRequestError(response.status, code, detail, body);
|
|
147
|
+
}
|
|
85
148
|
return body as T;
|
|
86
149
|
}
|
|
87
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Tells the registry which app this project is (a git remote plus the app's
|
|
153
|
+
* folder); set once, then it must match. The reply says if the project is paused.
|
|
154
|
+
*/
|
|
155
|
+
async reportIdentity(identity: Record<string, unknown>): Promise<{ identity: unknown; inactiveAt?: string | null; activateUrl?: string }> {
|
|
156
|
+
return this.request(`/api/projects/${encodeURIComponent(this.options.projectId)}/identity`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
body: JSON.stringify({ identity }),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
88
162
|
async publishPlan(input: { planId: string; journeyId: string; objectKey: string }) {
|
|
89
163
|
return this.request<{ plan: unknown }>(`/api/projects/${encodeURIComponent(this.options.projectId)}/plans`, {
|
|
90
164
|
method: "POST",
|
|
@@ -162,6 +236,47 @@ export class ControlPlaneClient {
|
|
|
162
236
|
});
|
|
163
237
|
}
|
|
164
238
|
|
|
239
|
+
/** Proposes adding, changing or retiring one test case; see src/registry-worker/proposals/routes.ts. */
|
|
240
|
+
async proposeTestCaseChange(input: {
|
|
241
|
+
caseSetId: string;
|
|
242
|
+
caseId: string;
|
|
243
|
+
action: "add" | "change" | "retire";
|
|
244
|
+
baseRevision?: number;
|
|
245
|
+
case?: Record<string, unknown>;
|
|
246
|
+
summary: string;
|
|
247
|
+
source: "mcp" | "cli";
|
|
248
|
+
}): Promise<{ proposal: RegistryProposal }> {
|
|
249
|
+
return this.request(`/api/projects/${encodeURIComponent(this.options.projectId)}/proposals`, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
body: JSON.stringify(input),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Accepted proposals nobody has written to this project's own storage yet. */
|
|
256
|
+
async listUnappliedProposals(): Promise<{ proposals: RegistryProposal[] }> {
|
|
257
|
+
return this.request(`/api/projects/${encodeURIComponent(this.options.projectId)}/proposals?unapplied=1`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async reportProposalApplied(proposalId: string, outcome: { outcome: "applied"; revision: number } | { outcome: "outdated"; message: string }) {
|
|
261
|
+
return this.request<{ proposal: RegistryProposal }>(`/api/proposals/${encodeURIComponent(proposalId)}/applied`, {
|
|
262
|
+
method: "POST",
|
|
263
|
+
body: JSON.stringify(outcome),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The project's roles; only confirmed ones may be planned for. See src/registry/roles.ts. */
|
|
268
|
+
async listRoles(): Promise<{ roles: RegistryRole[] }> {
|
|
269
|
+
return this.request(`/api/projects/${encodeURIComponent(this.options.projectId)}/roles`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Suggests a role for a person to confirm, rename or reject; see src/registry-worker/roles/routes.ts. */
|
|
273
|
+
async proposeRole(input: { name: string; description: string; source: string; evidence: string }): Promise<{ role: RegistryRole; alreadyDecided?: boolean }> {
|
|
274
|
+
return this.request(`/api/projects/${encodeURIComponent(this.options.projectId)}/roles`, {
|
|
275
|
+
method: "POST",
|
|
276
|
+
body: JSON.stringify(input),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
165
280
|
async listJourneyRevisions() {
|
|
166
281
|
return this.request<{ revisions: Array<Record<string, unknown>> }>(
|
|
167
282
|
`/api/projects/${encodeURIComponent(this.options.projectId)}/journeys`,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- Which affiliate code and campaign brought each new user. bugmole.com keeps
|
|
2
|
+
-- the first visit's ref/utm_* tags in the bm_attr cookie; the dashboard sends
|
|
3
|
+
-- them once after sign-in (POST /api/attribution). First touch wins: a row is
|
|
4
|
+
-- written once and never overwritten. Scents reads this table.
|
|
5
|
+
CREATE TABLE IF NOT EXISTS signup_attribution (
|
|
6
|
+
aegis_id TEXT PRIMARY KEY REFERENCES aegis_users(id) ON DELETE CASCADE,
|
|
7
|
+
ref TEXT,
|
|
8
|
+
utm_source TEXT,
|
|
9
|
+
utm_medium TEXT,
|
|
10
|
+
utm_campaign TEXT,
|
|
11
|
+
utm_content TEXT,
|
|
12
|
+
utm_term TEXT,
|
|
13
|
+
landing_path TEXT,
|
|
14
|
+
referrer_host TEXT,
|
|
15
|
+
first_seen_at TEXT,
|
|
16
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
17
|
+
);
|
|
18
|
+
CREATE INDEX IF NOT EXISTS idx_signup_attribution_ref ON signup_attribution(ref);
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_signup_attribution_campaign ON signup_attribution(utm_campaign);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- How a domain was proven: 'dns' (a TXT record, which covers the domain and
|
|
2
|
+
-- its subdomains) or 'file' (a file published on the site, which proves
|
|
3
|
+
-- control of that one host only). Rows verified before this column existed
|
|
4
|
+
-- were all DNS.
|
|
5
|
+
ALTER TABLE workspace_domains ADD COLUMN verified_by TEXT;
|
|
6
|
+
UPDATE workspace_domains SET verified_by = 'dns' WHERE status = 'verified';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Agent tasks can wait for a person's approval before any worker takes them.
|
|
2
|
+
-- A project's task_approval_mode decides which ones wait (see
|
|
3
|
+
-- src/registry/task-scheduling.ts): manual (all), auto (code-changing tasks),
|
|
4
|
+
-- extreme (none).
|
|
5
|
+
ALTER TABLE agent_tasks ADD COLUMN approved_at TEXT;
|
|
6
|
+
ALTER TABLE agent_tasks ADD COLUMN approved_by TEXT;
|
|
7
|
+
ALTER TABLE projects ADD COLUMN task_approval_mode TEXT NOT NULL DEFAULT 'auto';
|
|
8
|
+
|
|
9
|
+
-- Everything already queued was queued under "start straight away"; keep it
|
|
10
|
+
-- moving rather than stalling it behind a step that didn't exist.
|
|
11
|
+
UPDATE agent_tasks SET approved_at = COALESCE(requested_at, CURRENT_TIMESTAMP), approved_by = 'auto:existing';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- Proposed changes to a project's spec: today, test cases (add, change or
|
|
2
|
+
-- retire one). A proposal is small control-plane data; the case set it
|
|
3
|
+
-- changes lives in the project's storage. Accepting follows the project's
|
|
4
|
+
-- task_approval_mode; applying writes a new revision and marks the old one
|
|
5
|
+
-- obsolete (src/registry/test-case-revisions.ts).
|
|
6
|
+
CREATE TABLE spec_proposals (
|
|
7
|
+
id TEXT PRIMARY KEY,
|
|
8
|
+
project_id TEXT NOT NULL,
|
|
9
|
+
kind TEXT NOT NULL,
|
|
10
|
+
case_set_id TEXT NOT NULL,
|
|
11
|
+
case_id TEXT NOT NULL,
|
|
12
|
+
action TEXT NOT NULL,
|
|
13
|
+
base_revision INTEGER,
|
|
14
|
+
proposed_json TEXT,
|
|
15
|
+
summary TEXT NOT NULL,
|
|
16
|
+
source TEXT NOT NULL,
|
|
17
|
+
proposed_by TEXT NOT NULL,
|
|
18
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
19
|
+
decided_by TEXT,
|
|
20
|
+
decided_at TEXT,
|
|
21
|
+
decision_note TEXT,
|
|
22
|
+
applied_at TEXT,
|
|
23
|
+
applied_revision INTEGER,
|
|
24
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
CREATE INDEX spec_proposals_project_status ON spec_proposals (project_id, status, created_at);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- When a worker on someone's machine (`bugmole serve`, or a coding agent using
|
|
2
|
+
-- the Bugmole MCP with an API key) last listed this project's tasks. Resume
|
|
3
|
+
-- uses it to refuse queuing a machine-only task on a cloud project that has
|
|
4
|
+
-- no machine connected (see src/registry/task-scheduling.ts).
|
|
5
|
+
ALTER TABLE projects ADD COLUMN local_worker_seen_at TEXT;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- Sign-in for cloud runs: per-project secrets a flow types as {{secret.NAME}},
|
|
2
|
+
-- and a sign-in flow cloud exploration runs before it explores.
|
|
3
|
+
--
|
|
4
|
+
-- Values are AES-256-GCM ciphertext under a per-project key derived (HKDF-
|
|
5
|
+
-- SHA256) from the registry secret PROJECT_SECRETS_KEY; the additional data
|
|
6
|
+
-- binds each value to its project, environment and name. No route returns a
|
|
7
|
+
-- value to a browser or an API key: only a cloud runner, for a run or
|
|
8
|
+
-- exploration it holds, reads them (src/registry-worker/secrets/).
|
|
9
|
+
CREATE TABLE IF NOT EXISTS project_secrets (
|
|
10
|
+
id TEXT PRIMARY KEY,
|
|
11
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
12
|
+
-- NULL: every environment of the project. A value for one environment
|
|
13
|
+
-- overrides the project-wide one for runs in that environment.
|
|
14
|
+
environment_id TEXT,
|
|
15
|
+
name TEXT NOT NULL,
|
|
16
|
+
ciphertext TEXT NOT NULL,
|
|
17
|
+
iv TEXT NOT NULL,
|
|
18
|
+
key_version INTEGER NOT NULL DEFAULT 1,
|
|
19
|
+
created_by TEXT NOT NULL,
|
|
20
|
+
updated_by TEXT NOT NULL,
|
|
21
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
22
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
23
|
+
last_used_at TEXT,
|
|
24
|
+
UNIQUE (project_id, environment_id, name)
|
|
25
|
+
);
|
|
26
|
+
-- SQLite treats NULLs as distinct in UNIQUE, so the project-wide value of a
|
|
27
|
+
-- name needs its own guard.
|
|
28
|
+
CREATE UNIQUE INDEX IF NOT EXISTS project_secrets_scope
|
|
29
|
+
ON project_secrets(project_id, COALESCE(environment_id, ''), name);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS project_sign_in_flows (
|
|
32
|
+
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
|
|
33
|
+
-- Flow YAML. It references secrets by name and never holds a value.
|
|
34
|
+
content TEXT NOT NULL,
|
|
35
|
+
updated_by TEXT NOT NULL,
|
|
36
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
37
|
+
);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- Completion review: when an AI worker reports a task completed, the
|
|
2
|
+
-- project's task_approval_mode decides whether a person reviews it first
|
|
3
|
+
-- (src/registry/task-scheduling.ts completionStatus): manual reviews every
|
|
4
|
+
-- task, auto reviews code and ui tasks, extreme none. A task waiting for
|
|
5
|
+
-- review has status 'in_review'. Accepting records who and when; sending it
|
|
6
|
+
-- back queues it again with the reviewer's note. Existing rows keep their
|
|
7
|
+
-- status: nothing already completed is pulled back into review.
|
|
8
|
+
ALTER TABLE agent_tasks ADD COLUMN reviewed_at TEXT;
|
|
9
|
+
ALTER TABLE agent_tasks ADD COLUMN reviewed_by TEXT;
|
|
10
|
+
ALTER TABLE agent_tasks ADD COLUMN review_note TEXT;
|
|
11
|
+
|
|
12
|
+
-- Roles a journey is run as (guest, member, admin…). Bugmole suggests them
|
|
13
|
+
-- from sign-in walls, role-gated routes and menus, spec/roles.yaml and
|
|
14
|
+
-- discovered journeys; a person confirms, renames or rejects each one. Only
|
|
15
|
+
-- confirmed roles are used for planning test cases and runs (src/registry/roles.ts).
|
|
16
|
+
CREATE TABLE IF NOT EXISTS project_roles (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
19
|
+
name TEXT NOT NULL,
|
|
20
|
+
description TEXT NOT NULL DEFAULT '',
|
|
21
|
+
status TEXT NOT NULL DEFAULT 'suggested',
|
|
22
|
+
source TEXT NOT NULL DEFAULT 'agent',
|
|
23
|
+
evidence TEXT,
|
|
24
|
+
suggested_by TEXT NOT NULL,
|
|
25
|
+
decided_by TEXT,
|
|
26
|
+
decided_at TEXT,
|
|
27
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
28
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
29
|
+
UNIQUE (project_id, name)
|
|
30
|
+
);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS project_roles_project_status ON project_roles(project_id, status);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
-- Jira Cloud: a Bugmole project links one Jira site and project key.
|
|
2
|
+
-- Bugmole proposes its test plan as a Jira issue, creates one ticket per
|
|
3
|
+
-- approved test case, moves tickets to Done or Blocked from run results, and
|
|
4
|
+
-- retests one ticket's case when someone mentions Bugmole in a comment.
|
|
5
|
+
--
|
|
6
|
+
-- Credentials are AES-256-GCM ciphertext under a key derived (HKDF-SHA256,
|
|
7
|
+
-- info "bugmole:jira:<projectId>") from the registry secret
|
|
8
|
+
-- PROJECT_SECRETS_KEY, with additional data binding each value to its
|
|
9
|
+
-- project and field. No route returns them (src/registry-worker/jira/).
|
|
10
|
+
CREATE TABLE IF NOT EXISTS project_jira_connections (
|
|
11
|
+
id TEXT PRIMARY KEY,
|
|
12
|
+
project_id TEXT NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
|
|
13
|
+
-- https://<site>.atlassian.net
|
|
14
|
+
site_url TEXT NOT NULL,
|
|
15
|
+
jira_project_key TEXT NOT NULL,
|
|
16
|
+
-- 'api_token' today; 'oauth' (3LO) needs an Atlassian developer app.
|
|
17
|
+
auth_type TEXT NOT NULL DEFAULT 'api_token',
|
|
18
|
+
account_email TEXT,
|
|
19
|
+
-- The Jira account Bugmole acts as: its own comments never trigger a retest.
|
|
20
|
+
account_id TEXT,
|
|
21
|
+
credential_ciphertext TEXT NOT NULL,
|
|
22
|
+
credential_iv TEXT NOT NULL,
|
|
23
|
+
credential_key_version INTEGER NOT NULL DEFAULT 1,
|
|
24
|
+
webhook_secret_ciphertext TEXT NOT NULL,
|
|
25
|
+
webhook_secret_iv TEXT NOT NULL,
|
|
26
|
+
webhook_key_version INTEGER NOT NULL DEFAULT 1,
|
|
27
|
+
-- Text in a comment that asks Bugmole to retest (case-insensitive).
|
|
28
|
+
mention_keyword TEXT NOT NULL DEFAULT '@Bugmole',
|
|
29
|
+
execution_mode TEXT NOT NULL DEFAULT 'self',
|
|
30
|
+
environment_id TEXT,
|
|
31
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
32
|
+
last_error TEXT,
|
|
33
|
+
created_by TEXT NOT NULL,
|
|
34
|
+
updated_by TEXT NOT NULL,
|
|
35
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
36
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
-- One Jira ticket per test case (not per revision: a revised case keeps its
|
|
40
|
+
-- ticket, which records the revision it now describes).
|
|
41
|
+
CREATE TABLE IF NOT EXISTS jira_issue_links (
|
|
42
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
43
|
+
case_set_id TEXT NOT NULL,
|
|
44
|
+
case_id TEXT NOT NULL,
|
|
45
|
+
case_revision INTEGER NOT NULL DEFAULT 1,
|
|
46
|
+
case_title TEXT,
|
|
47
|
+
journey_id TEXT,
|
|
48
|
+
jira_issue_key TEXT,
|
|
49
|
+
jira_issue_id TEXT,
|
|
50
|
+
-- passed | failed | blocked | needs_setup | retired, or NULL before any result.
|
|
51
|
+
last_result TEXT,
|
|
52
|
+
last_run_id TEXT,
|
|
53
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
54
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
55
|
+
PRIMARY KEY (project_id, case_set_id, case_id)
|
|
56
|
+
);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS jira_issue_links_issue ON jira_issue_links(project_id, jira_issue_key);
|
|
58
|
+
|
|
59
|
+
-- A proposed test plan posted to Jira for review (label bugmole-plan).
|
|
60
|
+
CREATE TABLE IF NOT EXISTS jira_plan_issues (
|
|
61
|
+
id TEXT PRIMARY KEY,
|
|
62
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
63
|
+
jira_issue_key TEXT NOT NULL,
|
|
64
|
+
proposal_ids_json TEXT NOT NULL,
|
|
65
|
+
-- proposed | approved | partially_approved | rejected
|
|
66
|
+
status TEXT NOT NULL DEFAULT 'proposed',
|
|
67
|
+
created_by TEXT NOT NULL,
|
|
68
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
69
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
70
|
+
);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS jira_plan_issues_project ON jira_plan_issues(project_id, status);
|
|
72
|
+
|
|
73
|
+
-- A retest limited to some cases of a case set (a Jira ticket's case).
|
|
74
|
+
-- JSON: {"caseSetId": "...", "caseIds": ["..."], "jiraIssueKey": "PROJ-1"}.
|
|
75
|
+
ALTER TABLE runs ADD COLUMN case_scope_json TEXT;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Which projects stay writable when a workspace has more projects than its
|
|
2
|
+
-- plan allows. By default the oldest ones do; an owner can pick instead, and
|
|
3
|
+
-- the pick is remembered here so the next plan change honors it.
|
|
4
|
+
-- (src/registry-worker/billing/subscriptions.ts applyProjectLimit)
|
|
5
|
+
ALTER TABLE projects ADD COLUMN kept_at TEXT;
|
|
6
|
+
|
|
7
|
+
-- Why a CLI device registration was refused (a plan limit, for example), so
|
|
8
|
+
-- the waiting CLI can print the reason and the billing link instead of
|
|
9
|
+
-- polling until the attempt expires. JSON: { error, code, upgradeUrl, billingUrl }.
|
|
10
|
+
ALTER TABLE device_authorizations ADD COLUMN refusal_json TEXT;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- Per-workspace feature overrides set by platform operators (trials, comps,
|
|
2
|
+
-- incidents). An unexpired row forces a feature on or off for one workspace,
|
|
3
|
+
-- after the global kill switch and beta opt-in and before the plan. See
|
|
4
|
+
-- src/registry-worker/feature-access.ts and docs/feature-flags.md.
|
|
5
|
+
CREATE TABLE IF NOT EXISTS workspace_feature_overrides (
|
|
6
|
+
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
7
|
+
feature TEXT NOT NULL,
|
|
8
|
+
enabled INTEGER NOT NULL,
|
|
9
|
+
-- ISO timestamp; NULL means until cleared.
|
|
10
|
+
expires_at TEXT,
|
|
11
|
+
reason TEXT,
|
|
12
|
+
-- The operator named by the admin CLI (x-bugmole-operator).
|
|
13
|
+
set_by TEXT NOT NULL,
|
|
14
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
15
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
16
|
+
PRIMARY KEY (workspace_id, feature)
|
|
17
|
+
);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- One app is one project (src/registry/project-identity.ts).
|
|
2
|
+
--
|
|
3
|
+
-- identity_key is the normalized, credential-free key: `domain:<host>` for an
|
|
4
|
+
-- app on the internet, `repo:<host>/<path>#<appPath>` for a repository and the
|
|
5
|
+
-- app's folder in it. identity_hash is its SHA-256, which lookups use: the
|
|
6
|
+
-- same identity in one workspace resolves to the existing project, and on
|
|
7
|
+
-- Free it can't be claimed by a second Free workspace.
|
|
8
|
+
ALTER TABLE projects ADD COLUMN identity_kind TEXT;
|
|
9
|
+
ALTER TABLE projects ADD COLUMN identity_key TEXT;
|
|
10
|
+
ALTER TABLE projects ADD COLUMN identity_hash TEXT;
|
|
11
|
+
-- The app's origin when the CLI or dashboard knew it; informational only.
|
|
12
|
+
ALTER TABLE projects ADD COLUMN identity_origin TEXT;
|
|
13
|
+
|
|
14
|
+
CREATE UNIQUE INDEX IF NOT EXISTS projects_workspace_identity
|
|
15
|
+
ON projects(workspace_id, identity_hash)
|
|
16
|
+
WHERE identity_hash IS NOT NULL AND deleted_at IS NULL;
|
|
17
|
+
CREATE INDEX IF NOT EXISTS projects_identity_hash
|
|
18
|
+
ON projects(identity_hash)
|
|
19
|
+
WHERE identity_hash IS NOT NULL;
|
|
20
|
+
|
|
21
|
+
-- Projects paused after the plan's inactiveProjectDays without activity
|
|
22
|
+
-- (jobs/inactivity.ts). A paused project is read-only, takes no runs, tasks
|
|
23
|
+
-- or claims, and doesn't count toward the project limit until activated.
|
|
24
|
+
ALTER TABLE projects ADD COLUMN inactive_at TEXT;
|
|
25
|
+
-- When someone last activated it, which also counts as activity.
|
|
26
|
+
ALTER TABLE projects ADD COLUMN activated_at TEXT;
|
|
27
|
+
|
|
28
|
+
-- A CLI pairing carries the identity it computed, and the claim may resolve
|
|
29
|
+
-- it to a teammate's existing project; the CLI learns which from the poll.
|
|
30
|
+
ALTER TABLE device_authorizations ADD COLUMN identity_json TEXT;
|
|
31
|
+
ALTER TABLE device_authorizations ADD COLUMN resolved_project_id TEXT;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
describeIdentity,
|
|
5
|
+
domainIdentityFromUrl,
|
|
6
|
+
identityHash,
|
|
7
|
+
normalizeAppPath,
|
|
8
|
+
normalizeGitRemote,
|
|
9
|
+
normalizeIdentityHost,
|
|
10
|
+
parseProjectIdentity,
|
|
11
|
+
repoIdentity,
|
|
12
|
+
} from "./project-identity.js";
|
|
13
|
+
|
|
14
|
+
test("every way git writes a remote normalizes to one host/path, without credentials", () => {
|
|
15
|
+
const same = [
|
|
16
|
+
"https://github.com/Acme/Shop.git",
|
|
17
|
+
"https://github.com/acme/shop",
|
|
18
|
+
"https://github.com/acme/shop/",
|
|
19
|
+
"http://GitHub.com/acme/shop.git",
|
|
20
|
+
"https://ada:ghp_secretToken123@github.com/acme/shop.git",
|
|
21
|
+
"https://x-access-token:ghs_abc@github.com/acme/shop.git",
|
|
22
|
+
"git@github.com:acme/shop.git",
|
|
23
|
+
"git@github.com:Acme/Shop",
|
|
24
|
+
"ssh://git@github.com/acme/shop.git",
|
|
25
|
+
"ssh://git@github.com:22/acme/shop.git",
|
|
26
|
+
"git+ssh://git@github.com/acme/shop.git",
|
|
27
|
+
"git://github.com/acme/shop.git",
|
|
28
|
+
"github.com/acme/shop",
|
|
29
|
+
];
|
|
30
|
+
for (const remote of same) {
|
|
31
|
+
const normalized = normalizeGitRemote(remote);
|
|
32
|
+
assert.equal(normalized, "github.com/acme/shop", remote);
|
|
33
|
+
assert.doesNotMatch(normalized!, /secret|ghp_|ghs_|ada|token|@|:/, `${remote} leaks credentials`);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("self-hosted remotes keep their path's case and nested groups; ports and users are dropped", () => {
|
|
38
|
+
assert.equal(normalizeGitRemote("ssh://git@gitlab.example.com:2222/Platform/Web/Shop.git"), "gitlab.example.com/Platform/Web/Shop");
|
|
39
|
+
assert.equal(normalizeGitRemote("https://deploy:pw@git.EXAMPLE.com:8443/team/app.git"), "git.example.com/team/app");
|
|
40
|
+
assert.equal(normalizeGitRemote("git@ssh.dev.azure.com:v3/acme/proj/shop"), "dev.azure.com/acme/proj/shop");
|
|
41
|
+
assert.equal(normalizeGitRemote("https://gitlab.com/Acme/Sub/Shop.git"), "gitlab.com/acme/sub/shop");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("local paths and junk aren't remotes", () => {
|
|
45
|
+
for (const value of ["", " ", "/srv/git/shop.git", "../shop", "file:///srv/git/shop.git", "C:\\repos\\shop", "https://github.com/", "git@github.com:../x", 42, null]) {
|
|
46
|
+
assert.equal(normalizeGitRemote(value), null, String(value));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("app paths are clean relative folders; the root is '.'", () => {
|
|
51
|
+
assert.equal(normalizeAppPath(undefined), ".");
|
|
52
|
+
assert.equal(normalizeAppPath(""), ".");
|
|
53
|
+
assert.equal(normalizeAppPath("./"), ".");
|
|
54
|
+
assert.equal(normalizeAppPath("./apps/web/"), "apps/web");
|
|
55
|
+
assert.equal(normalizeAppPath("apps\\mobile"), "apps/mobile");
|
|
56
|
+
assert.equal(normalizeAppPath("/apps//web"), "apps/web");
|
|
57
|
+
assert.equal(normalizeAppPath("apps/../secrets"), null);
|
|
58
|
+
assert.equal(normalizeAppPath(7), null);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("each app of a monorepo is its own identity; the same app from two clones is one", async () => {
|
|
62
|
+
const web = repoIdentity("git@github.com:acme/shop.git", "apps/web");
|
|
63
|
+
const mobile = repoIdentity("https://github.com/acme/shop", "apps/mobile");
|
|
64
|
+
const webAgain = repoIdentity("https://tok@github.com/Acme/Shop.git", "./apps/web/", "http://localhost:3000/login");
|
|
65
|
+
assert.equal(web?.key, "repo:github.com/acme/shop#apps/web");
|
|
66
|
+
assert.equal(mobile?.key, "repo:github.com/acme/shop#apps/mobile");
|
|
67
|
+
assert.equal(webAgain?.key, web?.key, "the port a teammate runs on isn't part of the identity");
|
|
68
|
+
assert.equal(webAgain?.origin, "http://localhost:3000");
|
|
69
|
+
assert.notEqual(await identityHash(web!.key), await identityHash(mobile!.key));
|
|
70
|
+
assert.equal(await identityHash(web!.key), await identityHash(webAgain!.key));
|
|
71
|
+
assert.match(await identityHash(web!.key), /^[0-9a-f]{64}$/);
|
|
72
|
+
assert.equal(describeIdentity(web!), "github.com/acme/shop (apps/web)");
|
|
73
|
+
assert.equal(describeIdentity(repoIdentity("github.com/acme/shop", ".")!), "github.com/acme/shop");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a cloud project is its public domain; local and private hosts aren't identities", () => {
|
|
77
|
+
assert.equal(domainIdentityFromUrl("https://www.Shop.example.com:8443/cart?x=1")?.key, "domain:shop.example.com");
|
|
78
|
+
assert.equal(domainIdentityFromUrl("https://user:pw@shop.example.com/")?.origin, "https://shop.example.com");
|
|
79
|
+
for (const url of ["http://localhost:3000", "http://127.0.0.1:8080", "http://192.168.1.4", "http://10.0.0.2", "http://172.20.1.1", "http://app.localhost", "http://intranet", "http://[::1]:3000", "ftp://shop.example.com", "not a url"]) {
|
|
80
|
+
assert.equal(domainIdentityFromUrl(url), null, url);
|
|
81
|
+
}
|
|
82
|
+
assert.equal(normalizeIdentityHost("WWW.Example.COM."), "example.com");
|
|
83
|
+
assert.equal(normalizeIdentityHost("https://ada@example.com:443/path"), "example.com");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the registry re-reads what a client sends and refuses what it can't use", () => {
|
|
87
|
+
assert.equal(parseProjectIdentity(undefined), null);
|
|
88
|
+
assert.equal(parseProjectIdentity({}), null);
|
|
89
|
+
assert.deepEqual(parseProjectIdentity({ kind: "repo", remote: "https://u:p@github.com/acme/shop.git", appPath: "apps/web" }), {
|
|
90
|
+
kind: "repo", key: "repo:github.com/acme/shop#apps/web", origin: null,
|
|
91
|
+
});
|
|
92
|
+
assert.equal((parseProjectIdentity({ remote: "github.com/acme/shop" }) as { key: string }).key, "repo:github.com/acme/shop#.");
|
|
93
|
+
assert.equal((parseProjectIdentity({ appUrl: "https://shop.example.com/x" }) as { key: string }).key, "domain:shop.example.com");
|
|
94
|
+
assert.equal((parseProjectIdentity({ kind: "domain", domain: "Shop.Example.com" }) as { key: string }).key, "domain:shop.example.com");
|
|
95
|
+
assert.equal(parseProjectIdentity({ appUrl: "http://localhost:3000" }), null, "a local URL identifies nothing");
|
|
96
|
+
for (const bad of ["github.com/acme/shop", [], { kind: "svn" }, { kind: "repo", remote: "/srv/shop" }, { remote: "github.com/acme/shop", appPath: "../x" }, { kind: "domain", domain: "localhost" }, { kind: "domain" }, { appUrl: "javascript:alert(1)" }]) {
|
|
97
|
+
assert.equal(parseProjectIdentity(bad), "invalid", JSON.stringify(bad));
|
|
98
|
+
}
|
|
99
|
+
});
|