@beryl-so/cli 0.24.0 → 0.25.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.
@@ -172,9 +172,10 @@ export const testAccountCommands = [
172
172
  "--probe is the liveness check: a two-step plan (goto a gated page, then a POSITIVE " +
173
173
  "assertion that only holds when signed in — the account menu, a 'Sign out' control). " +
174
174
  "It is replayed in a fresh browser carrying only the captured session. It is " +
175
- "REQUIRED, and not a formality: assertions like `hidden` and `count 0` all pass " +
176
- "against a logged-out page, so without a positive signal a dead session would run " +
177
- "every test logged-out and still report the run green.\n\n" +
175
+ "REQUIRED, and not a formality: assertions like `hidden`, `count 0`, and a URL " +
176
+ "match on a redirect all pass against a logged-out page, so without a positive " +
177
+ "signal a dead session would run every test logged-out and still report the run " +
178
+ "green.\n\n" +
178
179
  "Storing only stores. Run `beryl accounts check` to prove it against the live app.",
179
180
  scope: "project",
180
181
  args: [
@@ -264,8 +265,10 @@ export const testAccountCommands = [
264
265
  "itself did not complete (fix the plan); SESSION_PROOF_FAILED means the sign-in " +
265
266
  "worked but the session did not survive the move to a fresh browser, so this app " +
266
267
  "keeps its credential somewhere that cannot be carried (a service worker, a " +
267
- "WebAuthn binding). In that case the account is marked unsupported and its tests " +
268
- "keep signing in inline degraded, not broken.\n\n" +
268
+ "WebAuthn binding). In that case the account is marked unsupported: its " +
269
+ "session-mode tests fail at setup with SESSION_UNSUPPORTED on every run until " +
270
+ "re-authored with auth_mode \"inline\" and their own sign-in steps; inline tests " +
271
+ "are unaffected.\n\n" +
269
272
  "Blocks for two browser replays.",
270
273
  scope: "project",
271
274
  args: [{ name: "account-id", description: "Account id", required: true }],
@@ -57,6 +57,9 @@ export const configCommands = [
57
57
  {
58
58
  name: "config vars get",
59
59
  summary: "Show one config variable",
60
+ description: "Returns the variable row with its value in plaintext (variables are not secret) — " +
61
+ "a sensitive value lives in `config secrets`, readable only via `config secrets " +
62
+ "get --reveal`.",
60
63
  scope: "project",
61
64
  args: [{ name: "key", description: "Variable key or id", required: true }],
62
65
  async run(ctx, input) {
@@ -113,6 +116,8 @@ export const configCommands = [
113
116
  {
114
117
  name: "config secrets get",
115
118
  summary: "Show one secret's metadata, or reveal its value with --reveal",
119
+ description: "Returns metadata only by default; --reveal is a logged, member-gated decrypt — " +
120
+ "unlike `config vars get`, which returns its value in plaintext.",
116
121
  scope: "project",
117
122
  args: [{ name: "key", description: "Secret key or id", required: true }],
118
123
  flags: [
@@ -285,8 +285,8 @@ export const initCommands = [
285
285
  description: "The full guide to authoring durable, healable tests: the ActionPlan shape, outcome " +
286
286
  "assertions, natural-language intent, test accounts and the project mailbox " +
287
287
  "({{login_email}}, {{mailbox_address}}, {{inbox_address}} + await_email), and the " +
288
- "local run-fix loop. The same content `beryl init` installs as the beryl-test " +
289
- "skill call this before authoring your first plan when no skill is installed " +
288
+ "local run-fix loop. Same content as the beryl-test skill `beryl init` installs " +
289
+ `skip if a loaded beryl-test skill states v${cliVersion()}; else call this first ` +
290
290
  "(works without logging in).",
291
291
  examples: ["beryl guide"],
292
292
  async run() {
@@ -1,4 +1,4 @@
1
- import { extractCode } from "../email-extract.js";
1
+ import { ApiError } from "../http.js";
2
2
  import { dim, green, table } from "../output.js";
3
3
  import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
4
4
  const mailboxPath = (ws) => `/workspaces/${ws}/mailboxes`;
@@ -89,9 +89,12 @@ export const mailboxCommands = [
89
89
  summary: "Read the latest email in a mailbox (waits for one to arrive)",
90
90
  description: "Waits up to --timeout-s for a matching email and returns it (one blocking request; " +
91
91
  "the server caps the wait at 50s — re-run to keep waiting). With --extract-code, " +
92
- "also pulls the one-time code (4-8 digits) out of the body/subject. Use " +
92
+ "also asks the server to pull the one-time code out of the email (AI-assisted when " +
93
+ "the email is ambiguous; `code` is null if none was found). Use " +
93
94
  "--recipient-contains to read only one `+tag` alias's mail when several identities " +
94
- "share the mailbox. Exits non-zero if nothing arrives before the timeout.",
95
+ "share the mailbox. Exits non-zero if nothing arrives before the timeout. Waits for " +
96
+ "and returns ONE latest matching email — `mailbox emails` lists what has already " +
97
+ "arrived, without waiting.",
95
98
  scope: "project",
96
99
  args: [{ name: "mailbox-id", description: "Mailbox id from `beryl mailbox list`", required: true }],
97
100
  flags: [
@@ -124,7 +127,8 @@ export const mailboxCommands = [
124
127
  ],
125
128
  async run(ctx, input) {
126
129
  const { workspaceId } = await ctx.requireProject(input);
127
- const email = (await ctx.client.get(`${mailboxPath(workspaceId)}/${arg(input, "mailbox-id")}/emails/latest`, {
130
+ const mailboxId = arg(input, "mailbox-id");
131
+ const email = (await ctx.client.get(`${mailboxPath(workspaceId)}/${mailboxId}/emails/latest`, {
128
132
  timeout_s: flagNum(input, "timeout-s"),
129
133
  since: flagStr(input, "since"),
130
134
  from_contains: flagStr(input, "from-contains"),
@@ -133,12 +137,25 @@ export const mailboxCommands = [
133
137
  }));
134
138
  if (!flagBool(input, "extract-code"))
135
139
  return { data: email };
136
- return { data: { ...email, code: extractCode(email) } };
140
+ // Extraction is the server's job (the same brain runs use, AI included); a 422
141
+ // means the email genuinely carries no code.
142
+ try {
143
+ const extracted = (await ctx.client.post(`${mailboxPath(workspaceId)}/${mailboxId}/emails/${email.id}/extract`, { extract: "code" }));
144
+ return { data: { ...email, code: extracted.value } };
145
+ }
146
+ catch (err) {
147
+ if (err instanceof ApiError && err.status === 422) {
148
+ return { data: { ...email, code: null } };
149
+ }
150
+ throw err;
151
+ }
137
152
  },
138
153
  },
139
154
  {
140
155
  name: "mailbox emails",
141
156
  summary: "List the emails a mailbox has received",
157
+ description: "Returns the already-received emails without waiting — `mailbox read` blocks for " +
158
+ "a matching one and returns just it.",
142
159
  scope: "project",
143
160
  args: [{ name: "mailbox-id", description: "Mailbox id from `beryl mailbox list`", required: true }],
144
161
  flags: [
@@ -102,17 +102,19 @@ export const runCommands = [
102
102
  "says so once instead of failing every test (on a terminal the CLI offers to install " +
103
103
  "whichever half is missing; over MCP it prints the exact install commands). " +
104
104
  "Signup/OTP flows work: the CLI answers the spec's await_email steps over the API " +
105
- "against the same mailbox the cloud runner uses. Authenticated tests work too: a plan " +
106
- "that signs itself in with {{login_email}}/{{login_password}} has its password fetched " +
105
+ "against the same mailbox the cloud runner uses. Authenticated tests work too: for a plan " +
106
+ "that signs itself in with {{login_email}}/{{login_password}}, the email is baked into " +
107
+ "the fetched spec and the password is fetched " +
107
108
  "once over the logged secret-reveal route, handed to the spec the way the cloud runner " +
108
- "does, and scrubbed from any error text or DOM snapshot before results upload. When the " +
109
+ "does, and scrubbed from any error text or DOM snapshot before results upload; a test " +
110
+ "whose account has no stored password is skipped with the exact fix-it command. When the " +
109
111
  "run finishes, the results and replay artifacts are imported into Beryl as a normal run " +
110
112
  "(trigger source `local`) — history, replay, and reports all work; pass --no-sync to " +
111
113
  "keep a run entirely off the record while iterating. Session-mode tests behave as " +
112
114
  "in the cloud: their account signs in once per invocation and every session-mode " +
113
115
  "test rides that session; a failed sign-in fails those tests with the same " +
114
116
  "SESSION_* reason a cloud run reports. Only a test that depends on a session Beryl " +
115
- "captured server-side is skipped, with a note. Point " +
117
+ "captured server-side is skipped, with a note — run those with `runs trigger`. Point " +
116
118
  "--url-override at a local dev server or preview, and --dir to keep specs, artifacts, " +
117
119
  "and reports on disk. Exits 0 only if every executed test passed.",
118
120
  scope: "project",
@@ -134,7 +136,12 @@ export const runCommands = [
134
136
  type: "string",
135
137
  description: "Run against this base URL instead of the environment's (e.g. http://localhost:3000)",
136
138
  },
137
- { name: "env", type: "string", description: "Environment id to attach the imported run to" },
139
+ {
140
+ name: "env",
141
+ type: "string",
142
+ description: "Environment id to run against and attach the imported run to — use for a " +
143
+ "standing environment; --url-override is for a throwaway host",
144
+ },
138
145
  {
139
146
  name: "sync",
140
147
  type: "boolean",
@@ -464,7 +471,9 @@ export const runCommands = [
464
471
  summary: "Show one run with its per-test results",
465
472
  description: "Over MCP the failure screenshots come back as viewable image content, so an agent can " +
466
473
  "look at the page that broke instead of guessing from the error string. Set screenshots " +
467
- "to false to skip fetching them. Ignored outside MCP — the terminal cannot show an image.",
474
+ "to false to skip fetching them. Ignored outside MCP — the terminal cannot show an image. " +
475
+ "Returns the run row with its per-test results — `runs report` returns the generated " +
476
+ "report document, `runs explain` an AI explanation of one failed result.",
468
477
  scope: "project",
469
478
  args: [{ name: "run-id", description: "Run id", required: true }],
470
479
  flags: [
@@ -516,6 +525,8 @@ export const runCommands = [
516
525
  {
517
526
  name: "runs report",
518
527
  summary: "Show the generated report for a run",
528
+ description: "Returns the run's stored generated report (404 until it has been generated) — " +
529
+ "`runs get` returns the raw run row with per-test results.",
519
530
  scope: "project",
520
531
  args: [{ name: "run-id", description: "Run id", required: true }],
521
532
  async run(ctx, input) {
@@ -595,6 +606,8 @@ export const runCommands = [
595
606
  {
596
607
  name: "runs explain",
597
608
  summary: "Explain, with AI, why a test result failed",
609
+ description: "Takes a single test-RESULT id (not a run id) and returns an AI failure " +
610
+ "explanation for that result — `runs get` lists a run's results and their ids.",
598
611
  scope: "project",
599
612
  args: [{ name: "result-id", description: "Test result id (from `beryl runs get`)", required: true }],
600
613
  async run(ctx, input) {
@@ -128,6 +128,9 @@ export const testCommands = [
128
128
  {
129
129
  name: "tests get",
130
130
  summary: "Show one test",
131
+ description: "Returns the test's metadata row (status, flags, per-environment last result), not " +
132
+ "the plan — `tests plan` prints the stored JSON plan, `tests script` the rendered " +
133
+ "Playwright spec.",
131
134
  scope: "project",
132
135
  args: [{ name: "test-id", description: "Test id", required: true }],
133
136
  async run(ctx, input) {
@@ -138,6 +141,8 @@ export const testCommands = [
138
141
  {
139
142
  name: "tests plan",
140
143
  summary: "Print a test's current step plan (JSON)",
144
+ description: "Returns the stored json_plan of the test's current version — `tests get` returns " +
145
+ "the metadata row, `tests script` the rendered Playwright spec.",
141
146
  scope: "project",
142
147
  args: [{ name: "test-id", description: "Test id", required: true }],
143
148
  async run(ctx, input) {
@@ -164,11 +169,16 @@ export const testCommands = [
164
169
  "image content), you fix the plan file and re-run. The proving run is imported as the " +
165
170
  "test's first run (--no-sync to skip). A plan that signs in with a session Beryl captured " +
166
171
  "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 " +
172
+ "back to server-side verification automatically, and says so. A session-mode plan replays locally " +
168
173
  "fine: the server renders it with its account's stored sign-in steps in front, so " +
169
174
  "the same identity is exercised on your machine. Optional `before` and `after` arrays hold setup and teardown " +
170
175
  "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.",
176
+ "cleans up the record it made on the runs that go red. " +
177
+ "Recovery: a 409 `duplicate_title` carries existing_test_id + existing_plan_hash — " +
178
+ "reconcile with that test (`tests get` / `tests set-plan`), don't rename-and-retry; a " +
179
+ "409 `plan_hash_mismatch` means the submitted plan is not the bytes that were replayed " +
180
+ "— re-run `tests create`; a 429 with Retry-After 30 means the verify slots are " +
181
+ "saturated — wait and retry.",
172
182
  scope: "project",
173
183
  flags: [
174
184
  { name: "title", type: "string", required: true, description: "Title for the new test" },
@@ -421,7 +431,9 @@ export const testCommands = [
421
431
  description: "Accepts the same plan shape as `tests create`, including the optional `before` and " +
422
432
  "`after` sections — `after` runs on pass and on fail, so cleanup happens even when the " +
423
433
  "test goes red. Pass `--description` when the re-authored plan changes what the test " +
424
- "proves; omit it to keep the test's existing intent.",
434
+ "proves; omit it to keep the test's existing intent. Saves the edit with NO replay — " +
435
+ "it rides into the next run unproven; `tests recompile` is the verify-first " +
436
+ "alternative.",
425
437
  scope: "project",
426
438
  args: [{ name: "test-id", description: "Test id", required: true }],
427
439
  flags: [
@@ -464,9 +476,12 @@ export const testCommands = [
464
476
  name: "tests quarantine",
465
477
  summary: "Mute a flaky test: it keeps running, but its failures stop failing the run",
466
478
  description: "A quarantined test still executes and its result is still recorded and visible — its " +
467
- "red just doesn't count towards the run's verdict, so it can't red-light a deploy. Use " +
479
+ "red lands in the run's quarantined_count and gates neither the run's verdict nor exit " +
480
+ "codes, so it can't red-light a deploy. Use " +
468
481
  "it on a persistently flaky test instead of deleting it (which destroys the history) or " +
469
- "asking support to deactivate it (which stops it running at all). `off` un-quarantines.",
482
+ "asking support to deactivate it (which stops it running at all). After 5 consecutive " +
483
+ "clean passes the test reports rehab_ready — advisory only, nothing un-quarantines " +
484
+ "itself. `off` un-quarantines.",
470
485
  scope: "project",
471
486
  args: [
472
487
  { name: "test-id", description: "Test id", required: true },
@@ -501,6 +516,13 @@ export const testCommands = [
501
516
  {
502
517
  name: "tests recompile",
503
518
  summary: "Validate + verify an edited plan against the live site before persisting",
519
+ description: "Unlike `tests set-plan` (which saves the edit and lets it ride into the next run), " +
520
+ "this replays the edited plan against the live site before anything persists. A " +
521
+ "deterministic replay failure (verdict `drop`) REJECTS the edit — persisted:false, " +
522
+ "the prior plan stays live — and returns the failure evidence (over MCP the " +
523
+ "screenshot is image content). Verdict `flag` (the runner errored, no verdict on " +
524
+ "the flow) persists the plan but reports it unverified. Returns 429 with " +
525
+ "Retry-After 30 when the 2 inline-verify slots are saturated — wait and retry.",
504
526
  scope: "project",
505
527
  args: [{ name: "test-id", description: "Test id", required: true }],
506
528
  flags: [
@@ -569,6 +591,9 @@ export const testCommands = [
569
591
  {
570
592
  name: "tests restore",
571
593
  summary: "Restore a test to an earlier version",
594
+ description: "Copies the named older version's plan forward as a NEW head version — unlike " +
595
+ "`tests reset`, which flips authored_by back to `system` and leaves the plan " +
596
+ "untouched.",
572
597
  scope: "project",
573
598
  args: [
574
599
  { name: "test-id", description: "Test id", required: true },
@@ -584,6 +609,9 @@ export const testCommands = [
584
609
  {
585
610
  name: "tests reset",
586
611
  summary: "Discard user edits and return the test to its latest system-authored version",
612
+ description: "Flips authored_by back to `system` WITHOUT changing the plan (the next " +
613
+ "regeneration overwrites it) — unlike `tests restore`, which copies an older " +
614
+ "version's plan forward as a new version.",
587
615
  scope: "project",
588
616
  args: [{ name: "test-id", description: "Test id", required: true }],
589
617
  async run(ctx, input) {
@@ -632,7 +660,9 @@ export const testCommands = [
632
660
  summary: "Print the rendered Playwright spec for a test (or an unbanked plan file)",
633
661
  description: "With a test id, fetches the banked test's rendered .spec.ts. With --file, compiles a " +
634
662
  "plan JSON that has NOT been banked yet — the same render `tests create` proves locally — " +
635
- "so you can inspect exactly what would run before creating anything.",
663
+ "so you can inspect exactly what would run before creating anything. This returns the " +
664
+ "executable spec — `tests plan` returns the stored JSON plan it is rendered from, " +
665
+ "`tests get` the metadata row.",
636
666
  scope: "project",
637
667
  args: [{ name: "test-id", description: "Test id (omit when passing --file)" }],
638
668
  flags: [
@@ -1,10 +1,10 @@
1
1
  import fs from "node:fs";
2
- import { extractValue } from "./email-extract.js";
3
2
  import { ApiError } from "./http.js";
4
3
  const SIDECAR_POLL_MS = 500;
5
4
  // The server caps one blocking wait at 50s; stay under it and loop.
6
5
  const SERVER_WAIT_MAX_S = 45;
7
6
  const DEFAULT_WAIT_S = 30;
7
+ const EXTRACT_RETRY_MS = 1000;
8
8
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
9
  export function startEmailPump(opts) {
10
10
  let stopped = false;
@@ -29,11 +29,36 @@ export function startEmailPump(opts) {
29
29
  throw err;
30
30
  }
31
31
  };
32
+ // A 422 is a real verdict — "this email carries no artifact" — and sends the wait on
33
+ // to the next email; anything else is transport trouble, retried until the step's own
34
+ // deadline does the failing.
35
+ const extractOnServer = async (emailId, req, deadline) => {
36
+ while (!stopped && Date.now() < deadline) {
37
+ try {
38
+ const res = (await opts.client.post(`/workspaces/${opts.workspaceId}/mailboxes/${opts.inboxId}/emails/${emailId}/extract`, { extract: req.extract ?? "code", extract_pattern: req.pattern }));
39
+ if (res.source === "ai")
40
+ opts.onEvent?.("await_email: the AI extractor answered");
41
+ return { ok: true, value: res.value };
42
+ }
43
+ catch (err) {
44
+ if (err instanceof ApiError && err.status === 422) {
45
+ const detail = typeof err.detail === "string" ? err.detail : err.message;
46
+ return {
47
+ ok: false,
48
+ error: detail.replace(/^EMAIL_EXTRACTION_FAILED:\s*/, ""),
49
+ };
50
+ }
51
+ await sleep(EXTRACT_RETRY_MS);
52
+ }
53
+ }
54
+ return { ok: false, error: "extraction did not complete before the wait deadline" };
55
+ };
32
56
  const resolveRequest = async (req) => {
33
57
  // Wait exactly wait_s, like the cloud resolver — the spec's own grace window
34
58
  // covers this side's poll tick + round trip.
35
59
  const waitS = Math.max(1, Number(req.wait_s) || DEFAULT_WAIT_S);
36
60
  const deadline = Date.now() + waitS * 1000;
61
+ let lastExtractError;
37
62
  while (!stopped && Date.now() < deadline) {
38
63
  const remainingS = Math.ceil((deadline - Date.now()) / 1000);
39
64
  const email = await fetchLatest(req, Math.min(SERVER_WAIT_MAX_S, remainingS));
@@ -48,9 +73,15 @@ export function startEmailPump(opts) {
48
73
  consumed.add(email.id);
49
74
  since = email.received_at;
50
75
  opts.onEvent?.(`await_email: matched "${email.subject ?? "(no subject)"}"`);
51
- return extractValue(email, String(req.extract ?? "code"), req.pattern);
76
+ const extracted = await extractOnServer(email.id, req, deadline);
77
+ if (extracted.ok)
78
+ return extracted.value;
79
+ // Same move as the cloud resolver: an email without the artifact (a welcome mail
80
+ // racing the code mail) is consumed and the wait continues for the next one.
81
+ lastExtractError = extracted.error;
82
+ opts.onEvent?.(`await_email: ${extracted.error} — waiting for the next email`);
52
83
  }
53
- throw new Error(`no matching email arrived in the run inbox within ${waitS}s`);
84
+ throw new Error(lastExtractError ?? `no matching email arrived in the run inbox within ${waitS}s`);
54
85
  };
55
86
  const writeResponse = (response) => {
56
87
  let box = {};
@@ -0,0 +1,74 @@
1
+ // Table rows derive from the schema snapshot at module load, so the skill can never
2
+ // drift from the plan language; only the one-liners are hand-written, and parity
3
+ // tests fail when a schema enum gains or loses a member the docs don't cover.
4
+ import { ACTION_PLAN_SCHEMA } from "./schema.generated.js";
5
+ const defs = ACTION_PLAN_SCHEMA.$defs;
6
+ export const ACTION_TYPES = defs.ActionType.enum;
7
+ export const EXPECT_KINDS = defs.ExpectKind.enum;
8
+ export const EMAIL_EXTRACTS = defs.EmailExtract.enum;
9
+ function actionRequires(action) {
10
+ const rule = defs.PlanStep.allOf.find((r) => r.if.properties.action?.const === action && !r.if.properties.extract);
11
+ return rule?.then.required ?? [];
12
+ }
13
+ function expectRequires(kind) {
14
+ const rule = defs.PlanStep.allOf.find((r) => r.if.properties.expect_kind?.const === kind);
15
+ return rule?.then.required ?? [];
16
+ }
17
+ export const ACTION_DOCS = {
18
+ goto: "Navigate to `url` — a path on your app, absolute only for another origin",
19
+ fill: "Type `value` into the `selector` element",
20
+ click: "Click the `selector` element",
21
+ press: "Press keyboard `key` (e.g. `Enter`) on the `selector` element",
22
+ check: "Check the `selector` checkbox",
23
+ uncheck: "Uncheck the `selector` checkbox",
24
+ select: "Choose `option` in the `selector` dropdown",
25
+ hover: "Hover the `selector` element",
26
+ scroll: "Scroll the page (no selector) or bring `selector` into view — see below",
27
+ wait_for: "Wait for the `selector` element to appear",
28
+ expect: "Assert — kinds in the expect table below",
29
+ capture_count: "Bank the live count of `selector` matches under `capture_as` for a later `count_delta`",
30
+ await_email: "Await mail in the run inbox, bank the extracted value under `capture_as` (§5)",
31
+ upload: "Set a file input: `value` names a project config file — see below",
32
+ dialog: "Arm a one-shot accept/dismiss handler for the NEXT step's dialog — see below",
33
+ switch_tab: "Make another open tab the active page; `value` picks it — see below",
34
+ close_tab: "Close the active tab (optional `value` picks one) and fall back to the previous",
35
+ drag: "Drag the `selector` element onto the `value` TARGET selector",
36
+ };
37
+ export const EXPECT_DOCS = {
38
+ visible: "The element is visible",
39
+ attached: "In the DOM, maybe not shown — for carousel/slider content where `visible` is timing-flaky",
40
+ hidden: "The element is not visible",
41
+ checked: "The checkbox/radio is checked",
42
+ enabled: "The element is enabled",
43
+ disabled: "The element is disabled",
44
+ have_text: "The element's text EXACTLY equals `expect_text`",
45
+ have_value: "The input's value equals `expect_text`",
46
+ have_url: "The page URL contains `expect_text` (page-level, no selector)",
47
+ have_title: "The page title contains `expect_text` (page-level, no selector)",
48
+ have_count: "Exactly `expect_count` elements match `selector`",
49
+ persisted: "The record just created is present — a visible match for the `{{unique}}`-named row",
50
+ gone: "Zero matches — absent from the DOM, stronger than `hidden`",
51
+ count_delta: "The match count moved by signed `expect_delta` vs the `capture_ref` baseline",
52
+ };
53
+ export const EXTRACT_DOCS = {
54
+ code: "the one-time code",
55
+ link: "the sign-in/verify URL",
56
+ pattern: "your own regex in `extract_pattern`, exactly one capture group",
57
+ };
58
+ function table(header, rows) {
59
+ return [
60
+ `| ${header} | required fields | meaning |`,
61
+ "|---|---|---|",
62
+ ...rows.map(([name, req, doc]) => `| \`${name}\` | ${req} | ${doc} |`),
63
+ ].join("\n");
64
+ }
65
+ const fields = (names) => names.map((f) => `\`${f}\``).join(", ") || "—";
66
+ export function actionTable() {
67
+ return table("action", ACTION_TYPES.map((a) => [a, fields(actionRequires(a)), ACTION_DOCS[a] ?? ""]));
68
+ }
69
+ export function expectTable() {
70
+ return table("expect_kind", EXPECT_KINDS.map((k) => [k, fields(expectRequires(k)), EXPECT_DOCS[k] ?? ""]));
71
+ }
72
+ export function extractLine() {
73
+ return EMAIL_EXTRACTS.map((e) => `\`${e}\` (${EXTRACT_DOCS[e] ?? ""})`).join(", ");
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,99 +0,0 @@
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
- }