@beryl-so/cli 0.11.1 → 0.17.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/README.md +11 -30
- package/dist/adapters/mcp.js +8 -1
- package/dist/beryl-test-skill.js +68 -12
- package/dist/commands/config-vars.js +23 -2
- package/dist/commands/inboxes.js +1 -22
- package/dist/commands/init.js +93 -122
- package/dist/commands/projects.js +21 -5
- package/dist/commands/runs.js +231 -66
- package/dist/commands/tests.js +305 -16
- package/dist/context.js +33 -2
- package/dist/email-extract.js +99 -0
- package/dist/email-pump.js +102 -0
- package/dist/local-exec.js +176 -0
- package/dist/local-run.js +145 -4
- package/dist/output.js +19 -0
- package/dist/progress.js +44 -0
- package/dist/registry/index.js +0 -2
- package/dist/schema.generated.js +2 -0
- package/package.json +3 -2
- package/dist/commands/credentials.js +0 -178
package/dist/commands/tests.js
CHANGED
|
@@ -1,10 +1,68 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { UsageError } from "../errors.js";
|
|
2
|
+
import { CliError, UsageError } from "../errors.js";
|
|
3
|
+
import { ApiError } from "../http.js";
|
|
3
4
|
import { lintPlan } from "../lint.js";
|
|
4
|
-
import {
|
|
5
|
+
import { buildImportForm, executeLocalSpec, toRunEntry, } from "../local-exec.js";
|
|
6
|
+
import { PlaywrightMissingError } from "../local-run.js";
|
|
7
|
+
import { dim, green, red, table, yellow } from "../output.js";
|
|
8
|
+
import { confirmInstall, installPlaywright } from "../playwright-install.js";
|
|
5
9
|
import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
|
|
6
10
|
import { arg, argList, flagBool, flagNum, flagStr, projectPath, readJsonFlag } from "./util.js";
|
|
7
11
|
const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
|
|
12
|
+
// A duplicate-title 409 after a green replay is idempotent when the existing row
|
|
13
|
+
// holds the same plan (a retry after a lost response) — success, not an error.
|
|
14
|
+
// A different plan under the same title is a real conflict.
|
|
15
|
+
function createConflictResult(err, planHash) {
|
|
16
|
+
if (!(err instanceof ApiError) || err.status !== 409)
|
|
17
|
+
return undefined;
|
|
18
|
+
const detail = err.detail;
|
|
19
|
+
if (typeof detail !== "object" || detail === null || detail.code !== "duplicate_title")
|
|
20
|
+
return undefined;
|
|
21
|
+
if (detail.existing_plan_hash && detail.existing_plan_hash === planHash) {
|
|
22
|
+
return {
|
|
23
|
+
data: { id: detail.existing_test_id, already_existed: true },
|
|
24
|
+
human: green(`✓ Already banked as ${detail.existing_test_id} (same plan) — nothing to do.`),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
human: (detail.message ?? "A test with this title already exists.") +
|
|
29
|
+
"\nIts plan differs from yours — use `tests set-plan` to update it, or pick a new title.",
|
|
30
|
+
exitCode: 1,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// The server transcodes a failure screenshot to WebP to fit it under the payload cap,
|
|
34
|
+
// so the format is not knowable up front — sniff it off the decoded magic bytes rather
|
|
35
|
+
// than asserting PNG.
|
|
36
|
+
export function sniffImageMime(b64) {
|
|
37
|
+
const head = Buffer.from(b64.slice(0, 24), "base64");
|
|
38
|
+
if (head.subarray(0, 4).toString("latin1") === "RIFF" && head.subarray(8, 12).toString("latin1") === "WEBP") {
|
|
39
|
+
return "image/webp";
|
|
40
|
+
}
|
|
41
|
+
if (head.subarray(0, 3).toString("hex") === "ffd8ff")
|
|
42
|
+
return "image/jpeg";
|
|
43
|
+
return "image/png";
|
|
44
|
+
}
|
|
45
|
+
// A verify-failure 422 carries evidence (a11y page state + failure screenshot).
|
|
46
|
+
// Returned as a CommandResult rather than thrown: a thrown error is text-only in
|
|
47
|
+
// the MCP adapter, and the screenshot only reaches the agent as image content.
|
|
48
|
+
function verifyFailureResult(err) {
|
|
49
|
+
if (!(err instanceof ApiError) || err.status !== 422)
|
|
50
|
+
return undefined;
|
|
51
|
+
const detail = err.detail;
|
|
52
|
+
if (typeof detail !== "object" || detail === null || !detail.message)
|
|
53
|
+
return undefined;
|
|
54
|
+
const parts = [detail.message];
|
|
55
|
+
if (detail.error_context) {
|
|
56
|
+
parts.push("", "----- Page state at failure (accessibility snapshot) -----", detail.error_context);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
human: parts.join("\n"),
|
|
60
|
+
images: detail.screenshot_b64
|
|
61
|
+
? [{ data: detail.screenshot_b64, mimeType: sniffImageMime(detail.screenshot_b64) }]
|
|
62
|
+
: undefined,
|
|
63
|
+
exitCode: 1,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
8
66
|
// The concise human table for `tests list` — the full TestResponse is a 24-column
|
|
9
67
|
// firehose of internal ids that wraps unreadably in a normal terminal. `--wide`
|
|
10
68
|
// (and `--json`) still expose every field.
|
|
@@ -94,10 +152,21 @@ export const testCommands = [
|
|
|
94
152
|
name: "tests create",
|
|
95
153
|
summary: "Create a test case from a JSON action plan — for tests authored locally, e.g. by your coding agent",
|
|
96
154
|
description: "The plan is a JSON object whose steps are {action, selector, url, value, ...}: the first " +
|
|
97
|
-
"EXECUTED step must be a goto, and at least one step must be an expect.
|
|
98
|
-
"is
|
|
99
|
-
"
|
|
100
|
-
"
|
|
155
|
+
"EXECUTED step must be a goto, and at least one step must be an expect. Before anything is " +
|
|
156
|
+
"banked, the plan is proven by replaying it in a browser ON YOUR MACHINE with your local " +
|
|
157
|
+
"@playwright/test: the server renders the spec (`tests/compile`), the CLI runs it — minting " +
|
|
158
|
+
"a run inbox for await_email steps and resolving the saved login exactly as a cloud run " +
|
|
159
|
+
"would — and only a green replay creates the test (bound to the replayed plan by its hash). " +
|
|
160
|
+
"This holds over MCP too: the replay runs on the machine hosting the MCP server, never on " +
|
|
161
|
+
"Beryl's; if @playwright/test is missing there the tool returns the install commands (on a " +
|
|
162
|
+
"terminal the CLI offers to install it). " +
|
|
163
|
+
"A red replay banks NOTHING: the failure evidence comes back (over MCP the screenshot is " +
|
|
164
|
+
"image content), you fix the plan file and re-run. The proving run is imported as the " +
|
|
165
|
+
"test's first run (--no-sync to skip). A plan that signs in with a captured session cannot " +
|
|
166
|
+
"replay locally (the session never leaves Beryl's cloud) — it falls back to server-side " +
|
|
167
|
+
"verification automatically. Optional `before` and `after` arrays hold setup and teardown " +
|
|
168
|
+
"steps: `after` runs even when a main step fails, which is how a create/update/delete test " +
|
|
169
|
+
"cleans up the record it made on the runs that go red.",
|
|
101
170
|
scope: "project",
|
|
102
171
|
flags: [
|
|
103
172
|
{ name: "title", type: "string", required: true, description: "Title for the new test" },
|
|
@@ -112,25 +181,214 @@ export const testCommands = [
|
|
|
112
181
|
{
|
|
113
182
|
name: "no-verify",
|
|
114
183
|
type: "boolean",
|
|
115
|
-
description: "Skip
|
|
184
|
+
description: "Skip verification entirely — bank the authored plan as-is, unproven",
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "url-override",
|
|
188
|
+
type: "string",
|
|
189
|
+
description: "Replay against this base URL instead of the environment's (e.g. http://localhost:3000). " +
|
|
190
|
+
"The banked test is then unproven against its real environment — the CLI says so.",
|
|
191
|
+
},
|
|
192
|
+
{ name: "env", type: "string", description: "Environment id to compile and prove against" },
|
|
193
|
+
{
|
|
194
|
+
name: "sync",
|
|
195
|
+
type: "boolean",
|
|
196
|
+
default: true,
|
|
197
|
+
description: "Import the green proving replay as the test's first run (--no-sync: bank only, " +
|
|
198
|
+
"no run recorded)",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: "dir",
|
|
202
|
+
type: "string",
|
|
203
|
+
description: "Keep the rendered spec, artifacts, and JSON report under this directory",
|
|
116
204
|
},
|
|
117
205
|
],
|
|
118
206
|
examples: [
|
|
119
207
|
'beryl tests create --title "Checkout happy path" --file plan.json',
|
|
120
208
|
'beryl tests create --title "Checkout happy path" --file plan.json --description "Proves a shopper can buy a product: after paying, an order-confirmation page with an order number appears."',
|
|
209
|
+
'beryl tests create --title "Checkout happy path" --file plan.json --url-override http://localhost:3000',
|
|
121
210
|
],
|
|
122
211
|
async run(ctx, input) {
|
|
123
212
|
const title = flagStr(input, "title");
|
|
124
213
|
if (!title)
|
|
125
214
|
throw new UsageError("--title is required");
|
|
126
215
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
216
|
+
const plan = readJsonFlag(input, "file");
|
|
217
|
+
const description = flagStr(input, "description");
|
|
218
|
+
const urlOverride = flagStr(input, "url-override");
|
|
219
|
+
const env = flagStr(input, "env");
|
|
220
|
+
const sync = input.flags.sync !== false;
|
|
221
|
+
const bank = (extra) => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
|
|
222
|
+
title,
|
|
223
|
+
plan,
|
|
224
|
+
description,
|
|
225
|
+
...extra,
|
|
226
|
+
});
|
|
227
|
+
if (flagBool(input, "no-verify")) {
|
|
228
|
+
const created = await bank({ verify: false });
|
|
229
|
+
return {
|
|
230
|
+
data: created,
|
|
231
|
+
human: yellow(`Banked UNPROVEN (--no-verify): ${created.id}`),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const compile = () => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/compile`, {
|
|
235
|
+
plan,
|
|
236
|
+
title,
|
|
237
|
+
base_url: urlOverride,
|
|
238
|
+
environment_id: env,
|
|
239
|
+
// The proof replay becomes the test's first run, so capture the filmstrip
|
|
240
|
+
// frames its replay view needs.
|
|
241
|
+
frames: sync ? true : undefined,
|
|
242
|
+
});
|
|
243
|
+
let compiled = await compile();
|
|
244
|
+
// A captured session lives encrypted in Beryl's cloud and is never handed to
|
|
245
|
+
// this machine — the cloud is the only place this plan can be proven.
|
|
246
|
+
if (compiled.requires_auth) {
|
|
247
|
+
ctx.err(yellow("! This plan signs in with a captured session, so it can only be verified " +
|
|
248
|
+
"in Beryl's cloud — verifying server-side instead."));
|
|
249
|
+
try {
|
|
250
|
+
return { data: await bank({ verify: true, plan_hash: compiled.plan_hash }) };
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
const failure = verifyFailureResult(err);
|
|
254
|
+
if (failure)
|
|
255
|
+
return failure;
|
|
256
|
+
const conflict = createConflictResult(err, compiled.plan_hash);
|
|
257
|
+
if (conflict)
|
|
258
|
+
return conflict;
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (compiled.login_config_error)
|
|
263
|
+
throw new CliError(compiled.login_config_error);
|
|
264
|
+
let loginPassword;
|
|
265
|
+
if (compiled.uses_login_password) {
|
|
266
|
+
try {
|
|
267
|
+
const secret = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/config/secrets/LOGIN_PASSWORD/value`, { environment_id: env }));
|
|
268
|
+
loginPassword = secret.value;
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
throw new CliError("Could not reveal the LOGIN_PASSWORD secret for the saved login: " +
|
|
272
|
+
(err instanceof Error ? err.message : String(err)));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const specOf = (content) => ({
|
|
276
|
+
id: "local-verify",
|
|
277
|
+
title,
|
|
278
|
+
content,
|
|
279
|
+
requiresAuth: false,
|
|
280
|
+
usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(content),
|
|
281
|
+
usesLoginPassword: compiled.uses_login_password,
|
|
282
|
+
});
|
|
283
|
+
const startedAt = new Date().toISOString();
|
|
284
|
+
const attempts = 1 + Math.max(0, compiled.policy.error_retries);
|
|
285
|
+
let outcome;
|
|
286
|
+
let runError;
|
|
287
|
+
let installOffered = false;
|
|
288
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
289
|
+
if (attempt > 1) {
|
|
290
|
+
// Fresh render per attempt: the errored attempt may already have consumed
|
|
291
|
+
// the {{unique}}/inbox handles its spec minted — replaying the same spec
|
|
292
|
+
// would collide on exactly the uniqueness those handles exist to dodge.
|
|
293
|
+
ctx.err(dim(`Replay errored — recompiling and retrying (${attempt}/${attempts})…`));
|
|
294
|
+
compiled = await compile();
|
|
295
|
+
}
|
|
296
|
+
ctx.err(dim(`Replaying "${title}" locally before banking…`));
|
|
297
|
+
try {
|
|
298
|
+
({ outcome, runError } = await executeLocalSpec({ client: ctx.client, workspaceId, projectId }, {
|
|
299
|
+
spec: specOf(compiled.content),
|
|
300
|
+
dir: flagStr(input, "dir"),
|
|
301
|
+
harvest: true,
|
|
302
|
+
loginPassword,
|
|
303
|
+
onEvent: (line) => ctx.err(dim(line)),
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
if (err instanceof PlaywrightMissingError) {
|
|
308
|
+
// On a TTY offer the install once and redo this attempt; over MCP or a
|
|
309
|
+
// pipe the hint is the answer.
|
|
310
|
+
if (ctx.interactive && !installOffered) {
|
|
311
|
+
installOffered = true;
|
|
312
|
+
if (await confirmInstall(ctx.prompt)) {
|
|
313
|
+
await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
|
|
314
|
+
attempt -= 1;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
throw new CliError(err.message);
|
|
319
|
+
}
|
|
320
|
+
throw err;
|
|
321
|
+
}
|
|
322
|
+
// A completed replay — green or red — is a verdict; only an errored one
|
|
323
|
+
// (the spec never ran) earns a retry.
|
|
324
|
+
if (outcome)
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
if (!outcome) {
|
|
328
|
+
return {
|
|
329
|
+
data: { banked: false, verified: false, error: runError },
|
|
330
|
+
human: red(`Replay errored after ${attempts} attempt(s) — nothing was banked.`) +
|
|
331
|
+
`\n${runError ?? "the spec did not run"}` +
|
|
332
|
+
`\nFix the environment (or retry), then re-run \`beryl tests create\`.`,
|
|
333
|
+
exitCode: 1,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
const result = outcome.results[0];
|
|
337
|
+
if (outcome.failed > 0 || outcome.passed === 0) {
|
|
338
|
+
const shot = outcome.harvested?.screenshot;
|
|
339
|
+
const parts = [
|
|
340
|
+
`Plan failed its local replay — nothing was banked.`,
|
|
341
|
+
...(result?.error ? ["", result.error] : []),
|
|
342
|
+
"",
|
|
343
|
+
"Fix the plan file and re-run `beryl tests create`. The replay artifacts " +
|
|
344
|
+
(flagStr(input, "dir")
|
|
345
|
+
? `are under ${outcome.directory}.`
|
|
346
|
+
: "can be kept with --dir."),
|
|
347
|
+
];
|
|
348
|
+
return {
|
|
349
|
+
data: { banked: false, verified: false, error: result?.error },
|
|
350
|
+
human: parts.join("\n"),
|
|
351
|
+
images: shot
|
|
352
|
+
? [{ data: shot.toString("base64"), mimeType: "image/png" }]
|
|
353
|
+
: undefined,
|
|
354
|
+
exitCode: 1,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
let created;
|
|
358
|
+
try {
|
|
359
|
+
created = await bank({ verify: false, plan_hash: compiled.plan_hash });
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
const conflict = createConflictResult(err, compiled.plan_hash);
|
|
363
|
+
if (conflict)
|
|
364
|
+
return conflict;
|
|
365
|
+
throw err;
|
|
366
|
+
}
|
|
367
|
+
let runId;
|
|
368
|
+
if (sync) {
|
|
369
|
+
const entry = toRunEntry({ ...specOf(compiled.content), id: String(created.id) }, outcome, undefined, startedAt, 0, loginPassword);
|
|
370
|
+
const imported = (await ctx.client.request("POST", `${projectPath(workspaceId, projectId)}/runs/import`, {
|
|
371
|
+
form: buildImportForm([entry], {
|
|
372
|
+
environmentId: env,
|
|
373
|
+
targetUrlOverride: urlOverride,
|
|
374
|
+
// The proof replay is authoring machinery, not a run the customer
|
|
375
|
+
// asked for — it must never email/Slack "your report is ready".
|
|
376
|
+
notifications: false,
|
|
377
|
+
startedAt,
|
|
378
|
+
completedAt: new Date().toISOString(),
|
|
379
|
+
onNote: (line) => ctx.err(yellow(`! ${line}`)),
|
|
380
|
+
}),
|
|
381
|
+
}));
|
|
382
|
+
runId = imported.id;
|
|
383
|
+
}
|
|
384
|
+
if (urlOverride) {
|
|
385
|
+
ctx.err(yellow(`! The proof ran against ${urlOverride}, not the environment's root URL — ` +
|
|
386
|
+
"the banked test is unproven against its real target until a run there passes."));
|
|
387
|
+
}
|
|
127
388
|
return {
|
|
128
|
-
data:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
verify: !flagBool(input, "no-verify"),
|
|
132
|
-
description: flagStr(input, "description"),
|
|
133
|
-
}),
|
|
389
|
+
data: { ...created, ...(runId ? { proof_run_id: runId } : {}) },
|
|
390
|
+
human: green(`✓ Replay passed locally — banked as ${created.id}`) +
|
|
391
|
+
(runId ? `\nProving run imported: \`beryl runs get ${runId}\`` : ""),
|
|
134
392
|
};
|
|
135
393
|
},
|
|
136
394
|
},
|
|
@@ -227,8 +485,14 @@ export const testCommands = [
|
|
|
227
485
|
],
|
|
228
486
|
async run(ctx, input) {
|
|
229
487
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
488
|
+
const res = (await ctx.client.post(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan/recompile`, { json_plan: readJsonFlag(input, "file") }));
|
|
489
|
+
// Base64 in the JSON output would flood the caller's context — hand the
|
|
490
|
+
// failure screenshot over as image content instead.
|
|
491
|
+
const shot = typeof res.screenshot_b64 === "string" ? res.screenshot_b64 : undefined;
|
|
492
|
+
delete res.screenshot_b64;
|
|
230
493
|
return {
|
|
231
|
-
data:
|
|
494
|
+
data: res,
|
|
495
|
+
images: shot ? [{ data: shot, mimeType: sniffImageMime(shot) }] : undefined,
|
|
232
496
|
};
|
|
233
497
|
},
|
|
234
498
|
},
|
|
@@ -342,11 +606,36 @@ export const testCommands = [
|
|
|
342
606
|
},
|
|
343
607
|
{
|
|
344
608
|
name: "tests script",
|
|
345
|
-
summary: "Print the rendered Playwright spec for a test",
|
|
609
|
+
summary: "Print the rendered Playwright spec for a test (or an unbanked plan file)",
|
|
610
|
+
description: "With a test id, fetches the banked test's rendered .spec.ts. With --file, compiles a " +
|
|
611
|
+
"plan JSON that has NOT been banked yet — the same render `tests create` proves locally — " +
|
|
612
|
+
"so you can inspect exactly what would run before creating anything.",
|
|
346
613
|
scope: "project",
|
|
347
|
-
args: [{ name: "test-id", description: "Test id
|
|
614
|
+
args: [{ name: "test-id", description: "Test id (omit when passing --file)" }],
|
|
615
|
+
flags: [
|
|
616
|
+
{ name: "file", type: "string", description: "Compile this plan JSON file instead of a banked test" },
|
|
617
|
+
{
|
|
618
|
+
name: "url-override",
|
|
619
|
+
type: "string",
|
|
620
|
+
description: "With --file: resolve relative gotos against this base URL",
|
|
621
|
+
},
|
|
622
|
+
{ name: "env", type: "string", description: "With --file: environment id to render against" },
|
|
623
|
+
],
|
|
624
|
+
examples: ["beryl tests script 4f…", "beryl tests script --file plan.json"],
|
|
348
625
|
async run(ctx, input) {
|
|
349
626
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
627
|
+
const id = input.args["test-id"];
|
|
628
|
+
const file = flagStr(input, "file");
|
|
629
|
+
if (Boolean(id) === Boolean(file))
|
|
630
|
+
throw new UsageError("Pass exactly one of <test-id> or --file");
|
|
631
|
+
if (file) {
|
|
632
|
+
const data = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/compile`, {
|
|
633
|
+
plan: readJsonFlag(input, "file"),
|
|
634
|
+
base_url: flagStr(input, "url-override"),
|
|
635
|
+
environment_id: flagStr(input, "env"),
|
|
636
|
+
}));
|
|
637
|
+
return { data: data, human: data.content };
|
|
638
|
+
}
|
|
350
639
|
const data = (await ctx.client.get(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/script`));
|
|
351
640
|
const script = typeof data === "string" ? data : (data.script ?? data.content ?? data);
|
|
352
641
|
return { data, human: typeof script === "string" ? script : JSON.stringify(data, null, 2) };
|
package/dist/context.js
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import readline from "node:readline/promises";
|
|
2
2
|
import { CliError, UsageError } from "./errors.js";
|
|
3
3
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4
|
+
// On a terminal, an ambiguous workspace/project becomes a numbered pick instead of an
|
|
5
|
+
// error the user answers by retyping the same command with a flag. Non-interactive
|
|
6
|
+
// callers (CI, MCP) still get the actionable UsageError — a prompt there would hang.
|
|
7
|
+
async function pickOne(ctx, candidates, kind, usageMessage) {
|
|
8
|
+
if (!ctx.interactive)
|
|
9
|
+
throw new UsageError(usageMessage);
|
|
10
|
+
ctx.err(`Multiple ${kind}s:`);
|
|
11
|
+
candidates.forEach((c, i) => {
|
|
12
|
+
ctx.err(` ${i + 1}. ${c.name ?? c.root_url ?? c.id} (${c.id})`);
|
|
13
|
+
});
|
|
14
|
+
const answer = await ctx.prompt(`Which ${kind}? [1-${candidates.length}] `);
|
|
15
|
+
const n = Number.parseInt(answer, 10);
|
|
16
|
+
if (!Number.isInteger(n) || n < 1 || n > candidates.length) {
|
|
17
|
+
throw new UsageError(usageMessage);
|
|
18
|
+
}
|
|
19
|
+
return candidates[n - 1].id;
|
|
20
|
+
}
|
|
4
21
|
function matchByName(candidates, value, kind) {
|
|
5
22
|
const lower = value.toLowerCase();
|
|
6
23
|
const matches = candidates.filter((c) => c.name?.toLowerCase() === lower ||
|
|
@@ -65,13 +82,27 @@ export function createContext(options) {
|
|
|
65
82
|
throw new CliError("You have no workspaces yet — create one with `beryl workspaces create`");
|
|
66
83
|
}
|
|
67
84
|
else {
|
|
68
|
-
|
|
85
|
+
workspaceCache = await pickOne(ctx, workspaces, "workspace", "Multiple workspaces — pass --workspace <id|name> or set BERYL_WORKSPACE:\n" +
|
|
69
86
|
workspaces.map((w) => ` ${w.id} ${w.name ?? ""}`).join("\n"));
|
|
70
87
|
}
|
|
71
88
|
return workspaceCache;
|
|
72
89
|
},
|
|
73
90
|
async requireProject(input) {
|
|
74
91
|
const requested = input.flags.project ?? config.project;
|
|
92
|
+
// A project id is globally unique, so it alone must be enough: when no workspace
|
|
93
|
+
// is flagged/configured, find the owner instead of demanding --workspace.
|
|
94
|
+
const requestedWorkspace = input.flags.workspace ?? config.workspace;
|
|
95
|
+
if (requested && UUID_RE.test(requested) && !requestedWorkspace && !workspaceCache) {
|
|
96
|
+
const workspaces = (await client.get("/workspaces/"));
|
|
97
|
+
for (const w of workspaces) {
|
|
98
|
+
const projects = (await client.get(`/workspaces/${w.id}/projects`));
|
|
99
|
+
if (projects.some((p) => p.id === requested)) {
|
|
100
|
+
workspaceCache = w.id;
|
|
101
|
+
return { workspaceId: w.id, projectId: requested };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw new CliError(`No project ${requested} found in any of your workspaces`);
|
|
105
|
+
}
|
|
75
106
|
const workspaceId = await ctx.requireWorkspace(input);
|
|
76
107
|
if (requested && UUID_RE.test(requested))
|
|
77
108
|
return { workspaceId, projectId: requested };
|
|
@@ -90,7 +121,7 @@ export function createContext(options) {
|
|
|
90
121
|
"`beryl projects create <url>` (add --no-explore to author tests yourself).");
|
|
91
122
|
}
|
|
92
123
|
else {
|
|
93
|
-
|
|
124
|
+
projectId = await pickOne(ctx, projects, "project", "Multiple projects — pass --project <id|name|url> or set BERYL_PROJECT:\n" +
|
|
94
125
|
projects.map((p) => ` ${p.id} ${p.name ?? p.root_url ?? ""}`).join("\n"));
|
|
95
126
|
}
|
|
96
127
|
projectCache = { workspaceId, projectId };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import vm from "node:vm";
|
|
2
|
+
// A labelled digit run ("your code is 654321") beats the bare fenced pattern, because a
|
|
3
|
+
// real sign-in mail is full of innocent 4-8 digit runs — "© 2026", a support number —
|
|
4
|
+
// and the bare fence would happily return the first of them.
|
|
5
|
+
const CODE_PATTERN = /(?<!\d)(\d{4,8})(?!\d)/;
|
|
6
|
+
const LABELLED_CODE_PATTERN = /(?:verification|security|one[\s-]?time|login|sign[\s-]?in|access|confirmation)?\s*(?:code|otp|passcode|pin)\b[^0-9]{0,20}(?<!\d)(\d{4,8})(?!\d)/i;
|
|
7
|
+
const LINK_PATTERN = /https:\/\/[^\s"'<>)\]]+/;
|
|
8
|
+
// An anchor's href is the click target in an HTML mail; a bare URL scan over raw markup
|
|
9
|
+
// would return a CSS background or tracking pixel instead.
|
|
10
|
+
const HREF_PATTERN = /<a\b[^>]*\bhref\s*=\s*["']?(https:\/\/[^\s"'>]+)/gi;
|
|
11
|
+
// Footer furniture a sign-in mail carries but nobody means.
|
|
12
|
+
const LINK_NOISE = [
|
|
13
|
+
"unsubscribe",
|
|
14
|
+
"/privacy",
|
|
15
|
+
"/terms",
|
|
16
|
+
"list-manage",
|
|
17
|
+
"mailchimp",
|
|
18
|
+
"sendgrid.net",
|
|
19
|
+
"/track/",
|
|
20
|
+
"/wf/open",
|
|
21
|
+
"twitter.com",
|
|
22
|
+
"facebook.com",
|
|
23
|
+
"linkedin.com",
|
|
24
|
+
];
|
|
25
|
+
// Matches the server's EMAIL_PATTERN_MAX_BODY_CHARS bound: backtracking cost scales with
|
|
26
|
+
// haystack length, and no real sign-in code lives past the first few KB of an email.
|
|
27
|
+
const PATTERN_MAX_BODY_CHARS = 20_000;
|
|
28
|
+
const PATTERN_TIMEOUT_MS = 2_000;
|
|
29
|
+
// An author-supplied regex against a sender-supplied body is the textbook ReDoS setup,
|
|
30
|
+
// and native RegExp cannot be interrupted once it starts. Running the match inside a vm
|
|
31
|
+
// context gives it a real wall-clock deadline — V8 services the timeout interrupt while
|
|
32
|
+
// backtracking — matching the server's regex-engine timeout, whatever the pattern is.
|
|
33
|
+
function matchWithDeadline(haystack, pattern) {
|
|
34
|
+
try {
|
|
35
|
+
return vm.runInNewContext("haystack.match(new RegExp(pattern))", { haystack, pattern }, { timeout: PATTERN_TIMEOUT_MS });
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
const code = err?.code;
|
|
39
|
+
if (code === "ERR_SCRIPT_EXECUTION_TIMEOUT" || /timed out/i.test(String(err))) {
|
|
40
|
+
throw new Error("the extract_pattern took too long to match this email — it backtracks " +
|
|
41
|
+
"explosively (an ambiguous quantifier like (a+)+ or (a|a)+ does this). " +
|
|
42
|
+
"Rewrite it to match unambiguously.");
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function visibleText(html) {
|
|
48
|
+
return html
|
|
49
|
+
.replace(/<(style|script|head)\b[\s\S]*?<\/\1>/gi, " ")
|
|
50
|
+
.replace(/<[^>]+>/g, " ");
|
|
51
|
+
}
|
|
52
|
+
function bodyOf(email) {
|
|
53
|
+
if (email.body_text)
|
|
54
|
+
return email.body_text;
|
|
55
|
+
if (email.body_html)
|
|
56
|
+
return visibleText(email.body_html);
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
export function extractCode(email) {
|
|
60
|
+
for (const pattern of [LABELLED_CODE_PATTERN, CODE_PATTERN]) {
|
|
61
|
+
for (const text of [bodyOf(email), email.subject ?? ""]) {
|
|
62
|
+
const match = text.match(pattern);
|
|
63
|
+
if (match)
|
|
64
|
+
return match[1];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
export function extractLink(email) {
|
|
70
|
+
const html = email.body_html ?? "";
|
|
71
|
+
const hrefs = [...html.matchAll(HREF_PATTERN)].map((m) => m[1]);
|
|
72
|
+
const candidates = hrefs.length > 0 ? hrefs : (bodyOf(email).match(LINK_PATTERN) ?? []);
|
|
73
|
+
for (const url of candidates) {
|
|
74
|
+
if (!LINK_NOISE.some((noise) => url.toLowerCase().includes(noise)))
|
|
75
|
+
return url;
|
|
76
|
+
}
|
|
77
|
+
if (candidates.length > 0) {
|
|
78
|
+
throw new Error("the email's only links look like footer/unsubscribe links, not a sign-in link");
|
|
79
|
+
}
|
|
80
|
+
throw new Error("the email arrived but carried no https link to follow");
|
|
81
|
+
}
|
|
82
|
+
export function extractValue(email, extract, pattern) {
|
|
83
|
+
if (extract === "link")
|
|
84
|
+
return extractLink(email);
|
|
85
|
+
const body = bodyOf(email);
|
|
86
|
+
if (extract === "pattern") {
|
|
87
|
+
if (!pattern)
|
|
88
|
+
throw new Error("extract=pattern needs an extract_pattern");
|
|
89
|
+
const match = matchWithDeadline(body.slice(0, PATTERN_MAX_BODY_CHARS), pattern);
|
|
90
|
+
if (!match) {
|
|
91
|
+
throw new Error("the email arrived but nothing in it matched the extract_pattern");
|
|
92
|
+
}
|
|
93
|
+
return match[1] ?? match[0];
|
|
94
|
+
}
|
|
95
|
+
const code = extractCode(email);
|
|
96
|
+
if (!code)
|
|
97
|
+
throw new Error("the email arrived but carried no 4-8 digit sign-in code");
|
|
98
|
+
return code;
|
|
99
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { extractValue } from "./email-extract.js";
|
|
3
|
+
import { ApiError } from "./http.js";
|
|
4
|
+
const SIDECAR_POLL_MS = 500;
|
|
5
|
+
// The server caps one blocking wait at 50s; stay under it and loop.
|
|
6
|
+
const SERVER_WAIT_MAX_S = 45;
|
|
7
|
+
const DEFAULT_WAIT_S = 30;
|
|
8
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9
|
+
export function startEmailPump(opts) {
|
|
10
|
+
let stopped = false;
|
|
11
|
+
const served = new Set();
|
|
12
|
+
const consumed = new Set();
|
|
13
|
+
let since = opts.since;
|
|
14
|
+
const fetchLatest = async (req, timeoutS) => {
|
|
15
|
+
try {
|
|
16
|
+
return (await opts.client.get(`/workspaces/${opts.workspaceId}/inboxes/${opts.inboxId}/emails/latest`, {
|
|
17
|
+
timeout_s: timeoutS,
|
|
18
|
+
since,
|
|
19
|
+
from_contains: req.from_contains,
|
|
20
|
+
subject_contains: req.subject_contains,
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
if (err instanceof ApiError && err.status === 404)
|
|
25
|
+
return null;
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const resolveRequest = async (req) => {
|
|
30
|
+
// Wait exactly wait_s, like the cloud resolver — the spec's own grace window
|
|
31
|
+
// covers this side's poll tick + round trip.
|
|
32
|
+
const waitS = Math.max(1, Number(req.wait_s) || DEFAULT_WAIT_S);
|
|
33
|
+
const deadline = Date.now() + waitS * 1000;
|
|
34
|
+
while (!stopped && Date.now() < deadline) {
|
|
35
|
+
const remainingS = Math.ceil((deadline - Date.now()) / 1000);
|
|
36
|
+
const email = await fetchLatest(req, Math.min(SERVER_WAIT_MAX_S, remainingS));
|
|
37
|
+
if (email === null)
|
|
38
|
+
continue;
|
|
39
|
+
if (consumed.has(email.id)) {
|
|
40
|
+
// The fence rides received_at, which comes from the sender's Date header — a
|
|
41
|
+
// lagging mailer clock can re-serve a consumed mail. Nudge past it instead.
|
|
42
|
+
since = new Date(new Date(email.received_at).getTime() + 1000).toISOString();
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
consumed.add(email.id);
|
|
46
|
+
since = email.received_at;
|
|
47
|
+
opts.onEvent?.(`await_email: matched "${email.subject ?? "(no subject)"}"`);
|
|
48
|
+
return extractValue(email, String(req.extract ?? "code"), req.pattern);
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`no matching email arrived in the run inbox within ${waitS}s`);
|
|
51
|
+
};
|
|
52
|
+
const writeResponse = (response) => {
|
|
53
|
+
let box = {};
|
|
54
|
+
try {
|
|
55
|
+
box = JSON.parse(fs.readFileSync(opts.sidecarPath, "utf8"));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// keep at least the response readable
|
|
59
|
+
}
|
|
60
|
+
box.response = response;
|
|
61
|
+
// Write-then-rename so the spec never reads a half-written response.
|
|
62
|
+
const tmp = `${opts.sidecarPath}.tmp`;
|
|
63
|
+
fs.writeFileSync(tmp, JSON.stringify(box), "utf8");
|
|
64
|
+
fs.renameSync(tmp, opts.sidecarPath);
|
|
65
|
+
};
|
|
66
|
+
void (async () => {
|
|
67
|
+
while (!stopped) {
|
|
68
|
+
await sleep(SIDECAR_POLL_MS);
|
|
69
|
+
let box;
|
|
70
|
+
try {
|
|
71
|
+
box = JSON.parse(fs.readFileSync(opts.sidecarPath, "utf8"));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
continue; // torn read or not yet written — the spec re-polls
|
|
75
|
+
}
|
|
76
|
+
const req = box.request;
|
|
77
|
+
if (!req || typeof req.id !== "string" || served.has(req.id))
|
|
78
|
+
continue;
|
|
79
|
+
served.add(req.id);
|
|
80
|
+
const response = { id: req.id };
|
|
81
|
+
try {
|
|
82
|
+
response.value = await resolveRequest(req);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
response.error = err instanceof Error ? err.message : String(err);
|
|
86
|
+
}
|
|
87
|
+
if (stopped)
|
|
88
|
+
return;
|
|
89
|
+
try {
|
|
90
|
+
writeResponse(response);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// run dir vanished mid-write — the run is over anyway
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
97
|
+
return {
|
|
98
|
+
stop: () => {
|
|
99
|
+
stopped = true;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|