@beryl-so/cli 0.44.0 → 0.45.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.
@@ -402,28 +402,27 @@ A flow that emails the user — a signup verification code, a magic sign-in link
402
402
  receipt — is testable with the \`await_email\` action. No setup, no configuration, no
403
403
  flag to turn on.
404
404
 
405
- **The project has one permanent mailbox and all of its mail arrives there.** Two
406
- handles put an address on the page; pick by what the test is about:
407
-
408
- | The test... | Cite | Then |
409
- |---|---|---|
410
- | signs in as the project's standing account, or only needs a code to arrive somewhere (a wrong code, a lockout, a resend countdown, the code screen) | \`{{mailbox_address}}\` | nothing to clean up |
411
- | must hand the site an address it has never seen but creates no account (a code sent to a new address, never verified) | \`{{inbox_address}}\` | nothing on Beryl's side; the site may still keep a row |
412
- | creates an account or an invitation (a signup, an accepted invite) | \`{{inbox_address}}\` | an \`after\` section that deletes it through the app, its steps marked \`optional\` |
413
-
414
- \`{{inbox_address}}\` renders a \`+tag\` alias of the mailbox, fresh every run, so a
415
- signup is repeatable; the mail lands in the same mailbox and Beryl reads only the
416
- alias's own mail. Beryl records every alias it mints (\`beryl mailbox identities\`)
417
- together with how the teardown ended. A plan that signs up with \`{{inbox_address}}\`
418
- and reads the mail is refused at save time unless \`after\` deletes the account; one
419
- that mints but never reads mail is flagged by \`tests lint\`. Mail is kept for 48 hours.
405
+ **The project has one permanent mailbox and all of its mail arrives there.** The
406
+ standing account is used one way only, \`requires_auth: true\` (its session is injected
407
+ before the first step); a test never types its address (the save refuses it), only the
408
+ account's own \`signup\` / \`set-login\` plans fill \`{{mailbox_address}}\`. Every flow that
409
+ sends mail (a signup, a sign-in code, a wrong code, a lockout, a resend timer, an
410
+ invitation) cites \`{{inbox_address}}\`, a \`+tag\` alias minted per execution and read by
411
+ this test alone.
412
+
413
+ | The test... | Then |
414
+ |---|---|
415
+ | creates an account, or signs in as one it created (a returning-user test: \`before\` signs up the alias and logs out, the steps sign in as it) | an \`after\` section that deletes it through the app, its steps marked \`optional\`; a plan that reads its signup mail is refused at save time without one |
416
+ | only makes mail arrive, read or not, no account created | nothing to clean up; mail is kept for 48 hours |
417
+
418
+ Every alias minted is listed by \`beryl mailbox identities\` with how its teardown ended.
420
419
 
421
420
  The wiring is a three-part chain:
422
421
 
423
- 1. **Type the address into the app** a \`fill\` with \`value: "{{inbox_address}}"\`
424
- (or \`{{mailbox_address}}\`). The alias is fresh every run, so a signup is
425
- repeatable by construction — no \`{{unique}}\` needed for the email itself; use it
426
- for other must-not-collide values like a username. The generator handles
422
+ 1. **Type the address into the app**: a \`fill\` with \`value: "{{inbox_address}}"\`.
423
+ The alias is fresh every run, so a signup is repeatable by construction, no
424
+ \`{{unique}}\` needed for the email itself; use it for other must-not-collide
425
+ values like a username. The generator handles
427
426
  \`{{unique}}\`, \`{{uuid}}\`, \`{{timestamp}}\` are each minted once per execution;
428
427
  \`{{timestamp}}\` is second-resolution (a time, NOT a uniqueness guarantee —
429
428
  \`{{unique}}\` is).
@@ -9,9 +9,11 @@ export const mailboxCommands = [
9
9
  groupDefault: true,
10
10
  groupSummary: "The project's standing email addresses, where its tests receive sign-in mail.",
11
11
  description: "Returns the address {{mailbox_address}} resolves to, creating it on first ask. " +
12
- "Every test that reads mail receives here. A test needing an address the site has " +
13
- "never seen cites {{inbox_address}} instead. That renders a `+tag` alias of this " +
14
- "same mailbox, so a signup stays repeatable without a second address to manage. " +
12
+ "Every test that reads mail receives here, at its own `+tag` alias of this mailbox: " +
13
+ "a plan cites {{inbox_address}}, minted per execution and read by that test alone, " +
14
+ "so parallel tests never consume each other's codes. A test never types the bare " +
15
+ "address (the save refuses it); only an account's own signup and sign-in plans " +
16
+ "(`accounts signup`, `accounts set-login`) may fill it. " +
15
17
  "Every alias minted is listed by `mailbox identities`, and a plan that signs up " +
16
18
  "with one must delete the account in its `after` section.",
17
19
  scope: "project",
@@ -84,17 +84,29 @@ export const testCommands = [
84
84
  "fields, plus the two structural rules (the first EXECUTED step must be a goto, and at " +
85
85
  "least one step across before + steps must be an expect). Also the identity rules: a " +
86
86
  "plan that signs up with {{inbox_address}} and reads the mail must delete the account " +
87
- "in `after` (an error; the server refuses it too), and one that mints an address it " +
88
- "never reads gets a warning to cite {{mailbox_address}}. Runs entirely locally, so a " +
89
- "malformed plan fails here instead of costing a server round-trip. " +
87
+ "in `after`, and no test plan may type {{mailbox_address}} (errors; the server refuses " +
88
+ "both too). Pass --provisioning for an account's signup or sign-in plan, which may fill " +
89
+ "{{mailbox_address}}. Runs entirely locally, so a malformed plan fails here instead of " +
90
+ "costing a server round-trip. " +
90
91
  `Schema: ${ACTION_PLAN_SCHEMA.$id}`,
91
92
  scope: "none",
92
93
  flags: [
93
94
  { name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
95
+ {
96
+ name: "provisioning",
97
+ type: "boolean",
98
+ description: "The file is an account's signup or sign-in plan (for `accounts signup` / " +
99
+ "`accounts set-login`), so it may fill {{mailbox_address}}",
100
+ },
101
+ ],
102
+ examples: [
103
+ "beryl tests lint --file plan.json",
104
+ "beryl tests lint --file signup-plan.json --provisioning",
94
105
  ],
95
- examples: ["beryl tests lint --file plan.json"],
96
106
  async run(_ctx, input) {
97
- const issues = lintPlan(readJsonFlag(input, "file"));
107
+ const issues = lintPlan(readJsonFlag(input, "file"), {
108
+ provisioning: flagBool(input, "provisioning"),
109
+ });
98
110
  const lines = issues.map((i) => `${i.severity === "warning" ? "warning" : "error"} ${i.path}: ${i.message}`);
99
111
  if (issues.every((i) => i.severity === "warning")) {
100
112
  return {
package/dist/lint.js CHANGED
@@ -3,14 +3,15 @@ import { ACTION_PLAN_SCHEMA } from "./schema.generated.js";
3
3
  // keep them identical.
4
4
  export const MINTED_IDENTITY_NEEDS_TEARDOWN = "This plan signs up with {{inbox_address}} and reads the mail it triggers, so it " +
5
5
  "leaves an account behind on the site. Add an 'after' section that deletes it " +
6
- "through the app (mark steps optional so a missing account ends the teardown " +
7
- "quietly), or cite {{mailbox_address}} if no new account is meant to exist.";
6
+ "through the app (mark steps optional so a missing account ends the teardown quietly).";
7
+ export const MAILBOX_ADDRESS_IN_TEST = "This plan types the standing account's address ({{mailbox_address}}) into the app. " +
8
+ "A test never signs in that way: one that needs to be signed in sets requires_auth " +
9
+ "and rides the account's session; one that needs an address the site can mail cites " +
10
+ "{{inbox_address}}, an alias only this test reads. Only the account's own signup and " +
11
+ "sign-in plans (accounts signup, accounts set-login) may fill {{mailbox_address}}.";
8
12
  export const OPTIONAL_TEARDOWN_ACTION_MUST_BE_LAST = (label, action) => `${label} (${action}) is optional but not the last step of 'after'. An optional step ` +
9
13
  "that misses ends the teardown, so the steps after it would silently not run. Guard " +
10
14
  "them with an optional 'wait_for' or 'expect' instead, or move this step to the end.";
11
- export const MINTED_IDENTITY_UNUSED = "This plan cites {{inbox_address}} but never reads the mail it triggers, so it mints " +
12
- "an identity it never uses. Cite {{mailbox_address}} instead, unless the site must be " +
13
- "handed an address it has never seen.";
14
15
  const typeOf = (value) => {
15
16
  if (value === null)
16
17
  return "null";
@@ -135,7 +136,9 @@ function validate(value, raw, root, path) {
135
136
  const steps = (section) => Array.isArray(section)
136
137
  ? section.filter((s) => typeof s === "object" && s !== null)
137
138
  : [];
138
- const citesInboxAddress = (step) => Object.values(step).some((v) => typeof v === "string" && v.includes("{{inbox_address}}"));
139
+ const cites = (handle) => (step) => Object.values(step).some((v) => typeof v === "string" && v.includes(`{{${handle}}}`));
140
+ const citesInboxAddress = cites("inbox_address");
141
+ const citesMailboxAddress = cites("mailbox_address");
139
142
  const TEARDOWN_GUARD_ACTIONS = new Set(["wait_for", "expect"]);
140
143
  function optionalPlacementIssues(after) {
141
144
  return after.flatMap((s, i) => s.optional === true && !TEARDOWN_GUARD_ACTIONS.has(String(s.action)) && i !== after.length - 1
@@ -147,7 +150,7 @@ function optionalPlacementIssues(after) {
147
150
  ]
148
151
  : []);
149
152
  }
150
- function mintedIdentityIssues(plan) {
153
+ function identityIssues(plan, provisioning) {
151
154
  if (typeof plan !== "object" || plan === null)
152
155
  return [];
153
156
  const { before, steps: main, after } = plan;
@@ -155,20 +158,22 @@ function mintedIdentityIssues(plan) {
155
158
  if (placement.length > 0)
156
159
  return placement;
157
160
  const body = [...steps(before), ...steps(main)];
158
- if (![...body, ...steps(after)].some(citesInboxAddress))
161
+ const all = [...body, ...steps(after)];
162
+ if (!provisioning && all.some(citesMailboxAddress)) {
163
+ return [{ path: "(root)", message: MAILBOX_ADDRESS_IN_TEST }];
164
+ }
165
+ if (!all.some(citesInboxAddress))
159
166
  return [];
160
167
  const readsMail = body.some((s) => s.action === "await_email");
161
168
  const tearsDown = steps(after).some((s) => s.action !== "expect");
162
- if (readsMail && !tearsDown) {
163
- return [{ path: "(root)", message: MINTED_IDENTITY_NEEDS_TEARDOWN }];
164
- }
165
- if (!readsMail) {
166
- return [{ path: "(root)", message: MINTED_IDENTITY_UNUSED, severity: "warning" }];
167
- }
168
- return [];
169
+ return readsMail && !tearsDown
170
+ ? [{ path: "(root)", message: MINTED_IDENTITY_NEEDS_TEARDOWN }]
171
+ : [];
169
172
  }
170
- export function lintPlan(plan) {
173
+ // `provisioning`: the plan is an account's own signup or sign-in plan, the one place
174
+ // {{mailbox_address}} is legal (mirrors the server's validate_editable_plan flag).
175
+ export function lintPlan(plan, opts = {}) {
171
176
  const issues = validate(plan, ACTION_PLAN_SCHEMA, ACTION_PLAN_SCHEMA, "");
172
177
  // The identity rules read a well-formed plan; a shape error is reported alone.
173
- return issues.length > 0 ? issues : mintedIdentityIssues(plan);
178
+ return issues.length > 0 ? issues : identityIssues(plan, opts.provisioning === true);
174
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.44.0",
3
+ "version": "0.45.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",