@beryl-so/cli 0.14.1 → 0.21.4

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.
@@ -1,11 +1,35 @@
1
1
  import fs from "node:fs";
2
- import { UsageError } from "../errors.js";
2
+ import { CliError, UsageError } from "../errors.js";
3
3
  import { ApiError } from "../http.js";
4
4
  import { lintPlan } from "../lint.js";
5
- import { table } from "../output.js";
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";
6
9
  import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
7
10
  import { arg, argList, flagBool, flagNum, flagStr, projectPath, readJsonFlag } from "./util.js";
8
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
+ }
9
33
  // The server transcodes a failure screenshot to WebP to fit it under the payload cap,
10
34
  // so the format is not knowable up front — sniff it off the decoded magic bytes rather
11
35
  // than asserting PNG.
@@ -128,10 +152,23 @@ export const testCommands = [
128
152
  name: "tests create",
129
153
  summary: "Create a test case from a JSON action plan — for tests authored locally, e.g. by your coding agent",
130
154
  description: "The plan is a JSON object whose steps are {action, selector, url, value, ...}: the first " +
131
- "EXECUTED step must be a goto, and at least one step must be an expect. By default the plan " +
132
- "is verified in a real browser before the test is accepted. Optional `before` and `after` " +
133
- "arrays hold setup and teardown steps: `after` runs even when a main step fails, which is " +
134
- "how a create/update/delete test cleans up the record it made on the runs that go red.",
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 session Beryl captured " +
166
+ "server-side cannot replay locally (that session never leaves Beryl's cloud) — it falls " +
167
+ "back to server-side verification automatically. A session-mode plan replays locally " +
168
+ "fine: the server renders it with its account's stored sign-in steps in front, so " +
169
+ "the same identity is exercised on your machine. Optional `before` and `after` arrays hold setup and teardown " +
170
+ "steps: `after` runs even when a main step fails, which is how a create/update/delete test " +
171
+ "cleans up the record it made on the runs that go red.",
135
172
  scope: "project",
136
173
  flags: [
137
174
  { name: "title", type: "string", required: true, description: "Title for the new test" },
@@ -146,34 +183,216 @@ export const testCommands = [
146
183
  {
147
184
  name: "no-verify",
148
185
  type: "boolean",
149
- description: "Skip the compile-time browser/AI verification trust the authored plan as-is",
186
+ description: "Skip verification entirelybank the authored plan as-is, unproven",
187
+ },
188
+ {
189
+ name: "url-override",
190
+ type: "string",
191
+ description: "Replay against this base URL instead of the environment's (e.g. http://localhost:3000). " +
192
+ "The banked test is then unproven against its real environment — the CLI says so.",
193
+ },
194
+ { name: "env", type: "string", description: "Environment id to compile and prove against" },
195
+ {
196
+ name: "sync",
197
+ type: "boolean",
198
+ default: true,
199
+ description: "Import the green proving replay as the test's first run (--no-sync: bank only, " +
200
+ "no run recorded)",
201
+ },
202
+ {
203
+ name: "dir",
204
+ type: "string",
205
+ description: "Keep the rendered spec, artifacts, and JSON report under this directory",
150
206
  },
151
207
  ],
152
208
  examples: [
153
209
  'beryl tests create --title "Checkout happy path" --file plan.json',
154
210
  '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."',
211
+ 'beryl tests create --title "Checkout happy path" --file plan.json --url-override http://localhost:3000',
155
212
  ],
156
213
  async run(ctx, input) {
157
214
  const title = flagStr(input, "title");
158
215
  if (!title)
159
216
  throw new UsageError("--title is required");
160
217
  const { workspaceId, projectId } = await ctx.requireProject(input);
161
- try {
218
+ const plan = readJsonFlag(input, "file");
219
+ const description = flagStr(input, "description");
220
+ const urlOverride = flagStr(input, "url-override");
221
+ const env = flagStr(input, "env");
222
+ const sync = input.flags.sync !== false;
223
+ const bank = (extra) => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
224
+ title,
225
+ plan,
226
+ description,
227
+ ...extra,
228
+ });
229
+ if (flagBool(input, "no-verify")) {
230
+ const created = await bank({ verify: false });
162
231
  return {
163
- data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
164
- title,
165
- plan: readJsonFlag(input, "file"),
166
- verify: !flagBool(input, "no-verify"),
167
- description: flagStr(input, "description"),
168
- }),
232
+ data: created,
233
+ human: yellow(`Banked UNPROVEN (--no-verify): ${created.id}`),
234
+ };
235
+ }
236
+ const compile = () => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/compile`, {
237
+ plan,
238
+ title,
239
+ base_url: urlOverride,
240
+ environment_id: env,
241
+ // The proof replay becomes the test's first run, so capture the filmstrip
242
+ // frames its replay view needs.
243
+ frames: sync ? true : undefined,
244
+ });
245
+ let compiled = await compile();
246
+ // A captured session lives encrypted in Beryl's cloud and is never handed to
247
+ // this machine — the cloud is the only place this plan can be proven.
248
+ if (compiled.requires_auth) {
249
+ ctx.err(yellow("! This plan signs in with a captured session, so it can only be verified " +
250
+ "in Beryl's cloud — verifying server-side instead."));
251
+ try {
252
+ return { data: await bank({ verify: true, plan_hash: compiled.plan_hash }) };
253
+ }
254
+ catch (err) {
255
+ const failure = verifyFailureResult(err);
256
+ if (failure)
257
+ return failure;
258
+ const conflict = createConflictResult(err, compiled.plan_hash);
259
+ if (conflict)
260
+ return conflict;
261
+ throw err;
262
+ }
263
+ }
264
+ if (compiled.login_config_error)
265
+ throw new CliError(compiled.login_config_error);
266
+ let loginPassword;
267
+ if (compiled.uses_login_password) {
268
+ try {
269
+ const secret = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/config/secrets/LOGIN_PASSWORD/value`, { environment_id: env }));
270
+ loginPassword = secret.value;
271
+ }
272
+ catch (err) {
273
+ throw new CliError("Could not reveal the LOGIN_PASSWORD secret for the saved login: " +
274
+ (err instanceof Error ? err.message : String(err)));
275
+ }
276
+ }
277
+ const specOf = (content) => ({
278
+ id: "local-verify",
279
+ title,
280
+ content,
281
+ requiresAuth: false,
282
+ usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(content),
283
+ usesLoginPassword: compiled.uses_login_password,
284
+ email: compiled.email ?? undefined,
285
+ });
286
+ const startedAt = new Date().toISOString();
287
+ const attempts = 1 + Math.max(0, compiled.policy.error_retries);
288
+ let outcome;
289
+ let runError;
290
+ let installOffered = false;
291
+ for (let attempt = 1; attempt <= attempts; attempt++) {
292
+ if (attempt > 1) {
293
+ // Fresh render per attempt: the errored attempt may already have consumed
294
+ // the {{unique}}/inbox handles its spec minted — replaying the same spec
295
+ // would collide on exactly the uniqueness those handles exist to dodge.
296
+ ctx.err(dim(`Replay errored — recompiling and retrying (${attempt}/${attempts})…`));
297
+ compiled = await compile();
298
+ }
299
+ ctx.err(dim(`Replaying "${title}" locally before banking…`));
300
+ try {
301
+ ({ outcome, runError } = await executeLocalSpec({ client: ctx.client, workspaceId, projectId }, {
302
+ spec: specOf(compiled.content),
303
+ dir: flagStr(input, "dir"),
304
+ harvest: true,
305
+ loginPassword,
306
+ onEvent: (line) => ctx.err(dim(line)),
307
+ }));
308
+ }
309
+ catch (err) {
310
+ if (err instanceof PlaywrightMissingError) {
311
+ // On a TTY offer the install once and redo this attempt; over MCP or a
312
+ // pipe the hint is the answer.
313
+ if (ctx.interactive && !installOffered) {
314
+ installOffered = true;
315
+ if (await confirmInstall(ctx.prompt)) {
316
+ await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
317
+ attempt -= 1;
318
+ continue;
319
+ }
320
+ }
321
+ throw new CliError(err.message);
322
+ }
323
+ throw err;
324
+ }
325
+ // A completed replay — green or red — is a verdict; only an errored one
326
+ // (the spec never ran) earns a retry.
327
+ if (outcome)
328
+ break;
329
+ }
330
+ if (!outcome) {
331
+ return {
332
+ data: { banked: false, verified: false, error: runError },
333
+ human: red(`Replay errored after ${attempts} attempt(s) — nothing was banked.`) +
334
+ `\n${runError ?? "the spec did not run"}` +
335
+ `\nFix the environment (or retry), then re-run \`beryl tests create\`.`,
336
+ exitCode: 1,
169
337
  };
170
338
  }
339
+ const result = outcome.results[0];
340
+ if (outcome.failed > 0 || outcome.passed === 0) {
341
+ const shot = outcome.harvested?.screenshot;
342
+ const parts = [
343
+ `Plan failed its local replay — nothing was banked.`,
344
+ ...(result?.error ? ["", result.error] : []),
345
+ "",
346
+ "Fix the plan file and re-run `beryl tests create`. The replay artifacts " +
347
+ (flagStr(input, "dir")
348
+ ? `are under ${outcome.directory}.`
349
+ : "can be kept with --dir."),
350
+ ];
351
+ return {
352
+ data: { banked: false, verified: false, error: result?.error },
353
+ human: parts.join("\n"),
354
+ images: shot
355
+ ? [{ data: shot.toString("base64"), mimeType: "image/png" }]
356
+ : undefined,
357
+ exitCode: 1,
358
+ };
359
+ }
360
+ let created;
361
+ try {
362
+ created = await bank({ verify: false, plan_hash: compiled.plan_hash });
363
+ }
171
364
  catch (err) {
172
- const failure = verifyFailureResult(err);
173
- if (failure)
174
- return failure;
365
+ const conflict = createConflictResult(err, compiled.plan_hash);
366
+ if (conflict)
367
+ return conflict;
175
368
  throw err;
176
369
  }
370
+ let runId;
371
+ if (sync) {
372
+ const entry = toRunEntry({ ...specOf(compiled.content), id: String(created.id) }, outcome, undefined, startedAt, 0, loginPassword);
373
+ const imported = (await ctx.client.request("POST", `${projectPath(workspaceId, projectId)}/runs/import`, {
374
+ form: buildImportForm([entry], {
375
+ environmentId: env,
376
+ targetUrlOverride: urlOverride,
377
+ // The proof replay is authoring machinery, not a run the customer
378
+ // asked for — it must never email/Slack "your report is ready".
379
+ notifications: false,
380
+ startedAt,
381
+ completedAt: new Date().toISOString(),
382
+ onNote: (line) => ctx.err(yellow(`! ${line}`)),
383
+ }),
384
+ }));
385
+ runId = imported.id;
386
+ }
387
+ if (urlOverride) {
388
+ ctx.err(yellow(`! The proof ran against ${urlOverride}, not the environment's root URL — ` +
389
+ "the banked test is unproven against its real target until a run there passes."));
390
+ }
391
+ return {
392
+ data: { ...created, ...(runId ? { proof_run_id: runId } : {}) },
393
+ human: green(`✓ Replay passed locally — banked as ${created.id}`) +
394
+ (runId ? `\nProving run imported: \`beryl runs get ${runId}\`` : ""),
395
+ };
177
396
  },
178
397
  },
179
398
  {
@@ -390,11 +609,36 @@ export const testCommands = [
390
609
  },
391
610
  {
392
611
  name: "tests script",
393
- summary: "Print the rendered Playwright spec for a test",
612
+ summary: "Print the rendered Playwright spec for a test (or an unbanked plan file)",
613
+ description: "With a test id, fetches the banked test's rendered .spec.ts. With --file, compiles a " +
614
+ "plan JSON that has NOT been banked yet — the same render `tests create` proves locally — " +
615
+ "so you can inspect exactly what would run before creating anything.",
394
616
  scope: "project",
395
- args: [{ name: "test-id", description: "Test id", required: true }],
617
+ args: [{ name: "test-id", description: "Test id (omit when passing --file)" }],
618
+ flags: [
619
+ { name: "file", type: "string", description: "Compile this plan JSON file instead of a banked test" },
620
+ {
621
+ name: "url-override",
622
+ type: "string",
623
+ description: "With --file: resolve relative gotos against this base URL",
624
+ },
625
+ { name: "env", type: "string", description: "With --file: environment id to render against" },
626
+ ],
627
+ examples: ["beryl tests script 4f…", "beryl tests script --file plan.json"],
396
628
  async run(ctx, input) {
397
629
  const { workspaceId, projectId } = await ctx.requireProject(input);
630
+ const id = input.args["test-id"];
631
+ const file = flagStr(input, "file");
632
+ if (Boolean(id) === Boolean(file))
633
+ throw new UsageError("Pass exactly one of <test-id> or --file");
634
+ if (file) {
635
+ const data = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/compile`, {
636
+ plan: readJsonFlag(input, "file"),
637
+ base_url: flagStr(input, "url-override"),
638
+ environment_id: flagStr(input, "env"),
639
+ }));
640
+ return { data: data, human: data.content };
641
+ }
398
642
  const data = (await ctx.client.get(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/script`));
399
643
  const script = typeof data === "string" ? data : (data.script ?? data.content ?? data);
400
644
  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
- throw new UsageError("Multiple workspaces — pass --workspace <id|name> or set BERYL_WORKSPACE:\n" +
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
- throw new UsageError("Multiple projects — pass --project <id|name|url> or set BERYL_PROJECT:\n" +
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,105 @@
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 storedAfter = opts.storedAfter;
14
+ let since;
15
+ const fetchLatest = async (req, timeoutS) => {
16
+ try {
17
+ return (await opts.client.get(`/workspaces/${opts.workspaceId}/mailboxes/${opts.inboxId}/emails/latest`, {
18
+ timeout_s: timeoutS,
19
+ since,
20
+ stored_after: storedAfter,
21
+ from_contains: req.from_contains,
22
+ subject_contains: req.subject_contains,
23
+ recipient_contains: opts.recipientContains,
24
+ }));
25
+ }
26
+ catch (err) {
27
+ if (err instanceof ApiError && err.status === 404)
28
+ return null;
29
+ throw err;
30
+ }
31
+ };
32
+ const resolveRequest = async (req) => {
33
+ // Wait exactly wait_s, like the cloud resolver — the spec's own grace window
34
+ // covers this side's poll tick + round trip.
35
+ const waitS = Math.max(1, Number(req.wait_s) || DEFAULT_WAIT_S);
36
+ const deadline = Date.now() + waitS * 1000;
37
+ while (!stopped && Date.now() < deadline) {
38
+ const remainingS = Math.ceil((deadline - Date.now()) / 1000);
39
+ const email = await fetchLatest(req, Math.min(SERVER_WAIT_MAX_S, remainingS));
40
+ if (email === null)
41
+ continue;
42
+ if (consumed.has(email.id)) {
43
+ // The fence rides received_at, which comes from the sender's Date header — a
44
+ // lagging mailer clock can re-serve a consumed mail. Nudge past it instead.
45
+ since = new Date(new Date(email.received_at).getTime() + 1000).toISOString();
46
+ continue;
47
+ }
48
+ consumed.add(email.id);
49
+ since = email.received_at;
50
+ opts.onEvent?.(`await_email: matched "${email.subject ?? "(no subject)"}"`);
51
+ return extractValue(email, String(req.extract ?? "code"), req.pattern);
52
+ }
53
+ throw new Error(`no matching email arrived in the run inbox within ${waitS}s`);
54
+ };
55
+ const writeResponse = (response) => {
56
+ let box = {};
57
+ try {
58
+ box = JSON.parse(fs.readFileSync(opts.sidecarPath, "utf8"));
59
+ }
60
+ catch {
61
+ // keep at least the response readable
62
+ }
63
+ box.response = response;
64
+ // Write-then-rename so the spec never reads a half-written response.
65
+ const tmp = `${opts.sidecarPath}.tmp`;
66
+ fs.writeFileSync(tmp, JSON.stringify(box), "utf8");
67
+ fs.renameSync(tmp, opts.sidecarPath);
68
+ };
69
+ void (async () => {
70
+ while (!stopped) {
71
+ await sleep(SIDECAR_POLL_MS);
72
+ let box;
73
+ try {
74
+ box = JSON.parse(fs.readFileSync(opts.sidecarPath, "utf8"));
75
+ }
76
+ catch {
77
+ continue; // torn read or not yet written — the spec re-polls
78
+ }
79
+ const req = box.request;
80
+ if (!req || typeof req.id !== "string" || served.has(req.id))
81
+ continue;
82
+ served.add(req.id);
83
+ const response = { id: req.id };
84
+ try {
85
+ response.value = await resolveRequest(req);
86
+ }
87
+ catch (err) {
88
+ response.error = err instanceof Error ? err.message : String(err);
89
+ }
90
+ if (stopped)
91
+ return;
92
+ try {
93
+ writeResponse(response);
94
+ }
95
+ catch {
96
+ // run dir vanished mid-write — the run is over anyway
97
+ }
98
+ }
99
+ })();
100
+ return {
101
+ stop: () => {
102
+ stopped = true;
103
+ },
104
+ };
105
+ }
package/dist/http.js CHANGED
@@ -52,7 +52,10 @@ export class ApiClient {
52
52
  }
53
53
  if (response.status === 401) {
54
54
  throw new AuthError(this.token
55
- ? "Authentication failed — the token is invalid, expired, or revoked. Run `beryl login`."
55
+ ? "Authentication failed — the token is invalid, expired, or revoked. Run " +
56
+ "`beryl login`. If you just did, and this is an MCP server started with an " +
57
+ "explicit BERYL_API_KEY/BERYL_TOKEN, that env var wins over the login — " +
58
+ "re-register the server to pick up the new token."
56
59
  : "Not logged in. Run `beryl login` or set BERYL_API_KEY.");
57
60
  }
58
61
  if (!response.ok) {