@beryl-so/cli 0.17.0 → 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.
- package/README.md +35 -7
- package/dist/adapters/cli.js +15 -1
- package/dist/adapters/mcp.js +44 -5
- package/dist/beryl-test-skill.js +265 -87
- package/dist/commands/accounts.js +331 -0
- package/dist/commands/environments.js +0 -2
- package/dist/commands/init.js +43 -27
- package/dist/commands/mailboxes.js +159 -0
- package/dist/commands/mcp.js +25 -0
- package/dist/commands/projects.js +0 -6
- package/dist/commands/runs.js +63 -22
- package/dist/commands/tests.js +6 -3
- package/dist/email-pump.js +5 -2
- package/dist/http.js +4 -1
- package/dist/local-exec.js +42 -20
- package/dist/playwright-install.js +118 -7
- package/dist/registry/index.js +4 -2
- package/dist/schema.generated.js +32 -0
- package/package.json +1 -1
- package/dist/commands/inboxes.js +0 -145
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { ApiError } from "../http.js";
|
|
2
|
+
import { dim, green, table, yellow } from "../output.js";
|
|
3
|
+
import { arg, flagBool, flagStr, projectPath, readJsonFlag } from "./util.js";
|
|
4
|
+
// The stored login plan heals, so a blind overwrite could silently clobber a repair the
|
|
5
|
+
// caller never read. A write cites the hash it read; this turns the server's refusal into
|
|
6
|
+
// the two-step recovery, and treats "you already wrote exactly this" as success.
|
|
7
|
+
function staleLoginResult(err) {
|
|
8
|
+
if (!(err instanceof ApiError) || err.status !== 409)
|
|
9
|
+
return undefined;
|
|
10
|
+
const detail = err.detail;
|
|
11
|
+
if (typeof detail !== "object" || detail === null || detail.code !== "stale_login_plan")
|
|
12
|
+
return undefined;
|
|
13
|
+
return {
|
|
14
|
+
data: {
|
|
15
|
+
stale: true,
|
|
16
|
+
current_hash: detail.current_hash,
|
|
17
|
+
message: detail.message,
|
|
18
|
+
},
|
|
19
|
+
human: (detail.message ?? "This account's login plan changed since you read it.") +
|
|
20
|
+
`\nCurrent hash: ${detail.current_hash ?? "unknown"}`,
|
|
21
|
+
exitCode: 1,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const accountsPath = (ws, p) => `${projectPath(ws, p)}/test-accounts`;
|
|
25
|
+
function accountTable(rows) {
|
|
26
|
+
return table(rows.map((a) => ({
|
|
27
|
+
label: a.identity_label + (a.is_default ? " *" : ""),
|
|
28
|
+
email: a.email,
|
|
29
|
+
type: a.type,
|
|
30
|
+
login: a.login_method,
|
|
31
|
+
status: a.status,
|
|
32
|
+
id: a.id,
|
|
33
|
+
})));
|
|
34
|
+
}
|
|
35
|
+
export const testAccountCommands = [
|
|
36
|
+
{
|
|
37
|
+
name: "accounts list",
|
|
38
|
+
summary: "List the test accounts an environment's tests sign in as",
|
|
39
|
+
groupDefault: true,
|
|
40
|
+
groupSummary: "Durable identities on the site under test — what an authenticated test signs in as.",
|
|
41
|
+
description: "Every test account on the project, with the identity label a plan's `auth_label` " +
|
|
42
|
+
"names. The row marked * is the default: what a plan gets when it sets " +
|
|
43
|
+
"requires_auth without an auth_label.",
|
|
44
|
+
scope: "project",
|
|
45
|
+
flags: [{ name: "env", type: "string", description: "Only accounts on this environment id" }],
|
|
46
|
+
async run(ctx, input) {
|
|
47
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
48
|
+
const rows = (await ctx.client.get(accountsPath(workspaceId, projectId), {
|
|
49
|
+
environment_id: flagStr(input, "env"),
|
|
50
|
+
}));
|
|
51
|
+
if (rows.length === 0)
|
|
52
|
+
return {
|
|
53
|
+
data: rows,
|
|
54
|
+
human: dim("No test accounts. Authenticated tests fall back to the LOGIN_EMAIL variable " +
|
|
55
|
+
"and LOGIN_PASSWORD secret, or mint a throwaway with {{inbox_address}}."),
|
|
56
|
+
};
|
|
57
|
+
return { data: rows, human: accountTable(rows) };
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "accounts create",
|
|
62
|
+
summary: "Add a test account — the customer's own, or one Beryl signs up",
|
|
63
|
+
description: "Two kinds. `--type user_provided` records a dedicated account you already have on " +
|
|
64
|
+
"the site: pass --email and --password, and it is usable immediately. `--type beryl` " +
|
|
65
|
+
"reserves one Beryl will sign up itself, addressed at the project mailbox — it starts " +
|
|
66
|
+
"`pending` and becomes usable after `beryl accounts provision`. " +
|
|
67
|
+
"The first account an environment gets is its default whatever you pass.",
|
|
68
|
+
scope: "project",
|
|
69
|
+
flags: [
|
|
70
|
+
{
|
|
71
|
+
name: "type",
|
|
72
|
+
type: "string",
|
|
73
|
+
enum: ["user_provided", "beryl"],
|
|
74
|
+
description: "Whose account this is (default: user_provided when --password is given)",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "label",
|
|
78
|
+
type: "string",
|
|
79
|
+
description: "Identity label a plan's auth_label names (default 'default')",
|
|
80
|
+
},
|
|
81
|
+
{ name: "email", type: "string", description: "user_provided only: the account's email" },
|
|
82
|
+
{
|
|
83
|
+
name: "password",
|
|
84
|
+
type: "string",
|
|
85
|
+
description: "user_provided only: stored encrypted as a project secret, never returned",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "login-method",
|
|
89
|
+
type: "string",
|
|
90
|
+
enum: ["password", "magic_link", "otp"],
|
|
91
|
+
description: "How tests sign in as it (default password)",
|
|
92
|
+
},
|
|
93
|
+
{ name: "env", type: "string", description: "Environment id (default: the project's)" },
|
|
94
|
+
{ name: "default", type: "boolean", description: "Make this the environment's default" },
|
|
95
|
+
],
|
|
96
|
+
examples: [
|
|
97
|
+
"beryl accounts create --email qa@acme.test --password 'hunter2'",
|
|
98
|
+
"beryl accounts create --type beryl --login-method otp",
|
|
99
|
+
"beryl accounts create --email admin@acme.test --password 'hunter2' --label admin",
|
|
100
|
+
],
|
|
101
|
+
async run(ctx, input) {
|
|
102
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
103
|
+
const password = flagStr(input, "password");
|
|
104
|
+
const account = (await ctx.client.post(accountsPath(workspaceId, projectId), {
|
|
105
|
+
type: flagStr(input, "type") ?? (password ? "user_provided" : "beryl"),
|
|
106
|
+
identity_label: flagStr(input, "label") ?? "default",
|
|
107
|
+
email: flagStr(input, "email") ?? null,
|
|
108
|
+
password: password ?? null,
|
|
109
|
+
login_method: flagStr(input, "login-method") ?? null,
|
|
110
|
+
environment_id: flagStr(input, "env") ?? null,
|
|
111
|
+
is_default: flagBool(input, "default"),
|
|
112
|
+
}));
|
|
113
|
+
const next = account.status === "pending"
|
|
114
|
+
? yellow(`\nNot proven yet — run:\n beryl accounts provision ${account.id} --file <plan.json>\n` +
|
|
115
|
+
`with a plan that ends logged in (a signup, or a sign-in if it already exists).`)
|
|
116
|
+
: "";
|
|
117
|
+
return {
|
|
118
|
+
data: account,
|
|
119
|
+
human: `${green("Created")} test account ${account.id}\n\n${accountTable([account])}${next}`,
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "accounts provision",
|
|
125
|
+
summary: "Prove a test account can get in, by replaying a plan that ends logged in",
|
|
126
|
+
description: "Runs the plan once in a real browser and, if it passes, marks the account ready. " +
|
|
127
|
+
"The plan is an ordinary ActionPlan that ends logged in — a SIGNUP when the account " +
|
|
128
|
+
"does not exist yet, or a SIGN-IN when it already does (you created it by hand, or " +
|
|
129
|
+
"the site already had it). Either proves the same thing, and a sign-in is what every " +
|
|
130
|
+
"later test will do anyway. Type {{mailbox_address}} into the email field and " +
|
|
131
|
+
"{{login_password}} into the password field — both resolve at replay time, and an " +
|
|
132
|
+
"`await_email` step reads the project mailbox, so a verification code works.",
|
|
133
|
+
scope: "project",
|
|
134
|
+
args: [{ name: "account-id", description: "Account id from `beryl accounts list`", required: true }],
|
|
135
|
+
flags: [
|
|
136
|
+
{
|
|
137
|
+
name: "file",
|
|
138
|
+
type: "string",
|
|
139
|
+
required: true,
|
|
140
|
+
description: "ActionPlan that ends logged in, as JSON (path, or - for stdin)",
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
examples: [
|
|
144
|
+
"beryl accounts provision acc_123 --file signup-plan.json",
|
|
145
|
+
"beryl accounts provision acc_123 --file signin-plan.json",
|
|
146
|
+
],
|
|
147
|
+
async run(ctx, input) {
|
|
148
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
149
|
+
const account = (await ctx.client.post(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/provision`, { plan: readJsonFlag(input, "file") }));
|
|
150
|
+
if (account.status !== "ready")
|
|
151
|
+
return {
|
|
152
|
+
data: account,
|
|
153
|
+
human: `${yellow("The account could not get in")}: ${account.last_error ?? account.status}`,
|
|
154
|
+
exitCode: 1,
|
|
155
|
+
};
|
|
156
|
+
return {
|
|
157
|
+
data: account,
|
|
158
|
+
human: `${green("Ready")} — ${account.email}\n\n${accountTable([account])}`,
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "accounts set-login",
|
|
164
|
+
summary: "Store the sign-in plan a run replays once, plus the probe that proves it",
|
|
165
|
+
description: "Session mode: instead of every authenticated test signing in for itself, the " +
|
|
166
|
+
"account signs in ONCE at the start of a run and every test rides that session. " +
|
|
167
|
+
"This stores the two plans that makes possible.\n\n" +
|
|
168
|
+
"--file is the SIGN-IN plan (not the signup): it must end logged in. Type " +
|
|
169
|
+
"{{login_email}} into the email field and {{login_password}} into the password " +
|
|
170
|
+
"field; an emailed code or magic link arrives at the account's own address, so an " +
|
|
171
|
+
"`await_email` step reads it with no human involved.\n\n" +
|
|
172
|
+
"--probe is the liveness check: a two-step plan (goto a gated page, then a POSITIVE " +
|
|
173
|
+
"assertion that only holds when signed in — the account menu, a 'Sign out' control). " +
|
|
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" +
|
|
178
|
+
"Storing only stores. Run `beryl accounts check` to prove it against the live app.",
|
|
179
|
+
scope: "project",
|
|
180
|
+
args: [
|
|
181
|
+
{ name: "account-id", description: "Account id from `beryl accounts list`", required: true },
|
|
182
|
+
],
|
|
183
|
+
flags: [
|
|
184
|
+
{
|
|
185
|
+
name: "file",
|
|
186
|
+
type: "string",
|
|
187
|
+
required: true,
|
|
188
|
+
description: "The sign-in ActionPlan, as JSON (path, or - for stdin)",
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "probe",
|
|
192
|
+
type: "string",
|
|
193
|
+
required: true,
|
|
194
|
+
description: "The liveness probe plan, as JSON (path, or - for stdin): goto a gated page, " +
|
|
195
|
+
"then assert something only a signed-in user sees",
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: "base-hash",
|
|
199
|
+
type: "string",
|
|
200
|
+
description: "The login_plan_hash from `beryl accounts get-login`. The write is rejected if " +
|
|
201
|
+
"the stored plan changed since. Omit only when writing the first plan",
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
examples: [
|
|
205
|
+
"beryl accounts set-login acc_123 --file signin.json --probe probe.json",
|
|
206
|
+
"beryl accounts set-login acc_123 --file signin.json --probe probe.json --base-hash 9f2c…",
|
|
207
|
+
],
|
|
208
|
+
async run(ctx, input) {
|
|
209
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
210
|
+
try {
|
|
211
|
+
const account = (await ctx.client.put(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/login`, {
|
|
212
|
+
plan: readJsonFlag(input, "file"),
|
|
213
|
+
probe: readJsonFlag(input, "probe"),
|
|
214
|
+
base_hash: flagStr(input, "base-hash") ?? null,
|
|
215
|
+
}));
|
|
216
|
+
return {
|
|
217
|
+
data: account,
|
|
218
|
+
human: `${green("Stored")} — ${account.email}\n` +
|
|
219
|
+
dim("Now run `beryl accounts check " + account.id + "` to prove it works."),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
const stale = staleLoginResult(err);
|
|
224
|
+
if (stale)
|
|
225
|
+
return stale;
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: "accounts get-login",
|
|
232
|
+
summary: "Read the stored sign-in plan, its probe, and the hash a safe write must cite",
|
|
233
|
+
description: "The login plan is readable, not just writable: it heals, so overwriting without " +
|
|
234
|
+
"reading first can clobber a repair you never saw. Pass login_plan_hash back as " +
|
|
235
|
+
"--base-hash on the next `accounts set-login`.",
|
|
236
|
+
scope: "project",
|
|
237
|
+
args: [{ name: "account-id", description: "Account id", required: true }],
|
|
238
|
+
async run(ctx, input) {
|
|
239
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
240
|
+
const login = (await ctx.client.get(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/login`));
|
|
241
|
+
if (!login.plan)
|
|
242
|
+
return {
|
|
243
|
+
data: login,
|
|
244
|
+
human: dim("No login plan stored. Author one by driving the sign-in in a browser, then " +
|
|
245
|
+
"`beryl accounts set-login " + login.account_id + " --file … --probe …`."),
|
|
246
|
+
};
|
|
247
|
+
return {
|
|
248
|
+
data: login,
|
|
249
|
+
human: `${login.identity_label} — session ${login.session_support}\n` +
|
|
250
|
+
`hash ${login.login_plan_hash}\n` +
|
|
251
|
+
(login.login_verified_at
|
|
252
|
+
? dim(`last proved ${login.login_verified_at}`)
|
|
253
|
+
: dim("never proved — run `beryl accounts check`")),
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
name: "accounts check",
|
|
259
|
+
summary: "Sign in now and prove the session survives into a fresh browser",
|
|
260
|
+
description: "Replays the stored sign-in plan, captures the session it produces, then injects " +
|
|
261
|
+
"that session into a clean browser and runs the probe. Green here is the same green " +
|
|
262
|
+
"a run gets, because it is the same code path.\n\n" +
|
|
263
|
+
"A failure tells you which half broke: SESSION_LOGIN_FAILED means the sign-in " +
|
|
264
|
+
"itself did not complete (fix the plan); SESSION_PROOF_FAILED means the sign-in " +
|
|
265
|
+
"worked but the session did not survive the move to a fresh browser, so this app " +
|
|
266
|
+
"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" +
|
|
269
|
+
"Blocks for two browser replays.",
|
|
270
|
+
scope: "project",
|
|
271
|
+
args: [{ name: "account-id", description: "Account id", required: true }],
|
|
272
|
+
examples: ["beryl accounts check acc_123"],
|
|
273
|
+
async run(ctx, input) {
|
|
274
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
275
|
+
const res = (await ctx.client.post(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/check`, {}));
|
|
276
|
+
if (!res.proved)
|
|
277
|
+
return {
|
|
278
|
+
data: res,
|
|
279
|
+
human: `${yellow("Not proved")}: ${res.error ?? res.session_support}`,
|
|
280
|
+
exitCode: 1,
|
|
281
|
+
};
|
|
282
|
+
return {
|
|
283
|
+
data: res,
|
|
284
|
+
human: `${green("Proved")} — ${res.identity_label} signs in and its session survives a fresh browser.`,
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: "accounts update",
|
|
290
|
+
summary: "Change a test account's password, login method, or default flag",
|
|
291
|
+
scope: "project",
|
|
292
|
+
args: [{ name: "account-id", description: "Account id", required: true }],
|
|
293
|
+
flags: [
|
|
294
|
+
{ name: "email", type: "string", description: "New email" },
|
|
295
|
+
{ name: "password", type: "string", description: "New password (re-marks the account ready)" },
|
|
296
|
+
{
|
|
297
|
+
name: "login-method",
|
|
298
|
+
type: "string",
|
|
299
|
+
enum: ["password", "magic_link", "otp"],
|
|
300
|
+
description: "How tests sign in as it",
|
|
301
|
+
},
|
|
302
|
+
{ name: "default", type: "boolean", description: "Make this the environment's default" },
|
|
303
|
+
],
|
|
304
|
+
async run(ctx, input) {
|
|
305
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
306
|
+
const account = (await ctx.client.patch(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}`, {
|
|
307
|
+
email: flagStr(input, "email") ?? null,
|
|
308
|
+
password: flagStr(input, "password") ?? null,
|
|
309
|
+
login_method: flagStr(input, "login-method") ?? null,
|
|
310
|
+
is_default: flagBool(input, "default") ? true : null,
|
|
311
|
+
}));
|
|
312
|
+
return { data: account, human: accountTable([account]) };
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: "accounts delete",
|
|
317
|
+
summary: "Delete a test account",
|
|
318
|
+
description: "Removes the identity. Tests naming it in auth_label start failing with a clear " +
|
|
319
|
+
"reason rather than silently signing in as somebody else.",
|
|
320
|
+
scope: "project",
|
|
321
|
+
args: [{ name: "account-id", description: "Account id", required: true }],
|
|
322
|
+
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
323
|
+
async run(ctx, input) {
|
|
324
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
325
|
+
const id = arg(input, "account-id");
|
|
326
|
+
await ctx.confirm(`Delete test account ${id}?`, flagBool(input, "force"));
|
|
327
|
+
await ctx.client.del(`${accountsPath(workspaceId, projectId)}/${id}`);
|
|
328
|
+
return { human: "Deleted." };
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
];
|
|
@@ -38,7 +38,6 @@ export const environmentCommands = [
|
|
|
38
38
|
enum: ["public", "gated"],
|
|
39
39
|
description: "Whether this environment needs a login",
|
|
40
40
|
},
|
|
41
|
-
{ name: "allow-mutations", type: "boolean", description: "Allow state-changing actions" },
|
|
42
41
|
],
|
|
43
42
|
async run(ctx, input) {
|
|
44
43
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
@@ -47,7 +46,6 @@ export const environmentCommands = [
|
|
|
47
46
|
name: arg(input, "name"),
|
|
48
47
|
root_url: arg(input, "url"),
|
|
49
48
|
requires_auth_choice: flagStr(input, "auth") ?? null,
|
|
50
|
-
mutation_choice: flagBool(input, "allow-mutations") ? "allow_mutations" : null,
|
|
51
49
|
}),
|
|
52
50
|
};
|
|
53
51
|
},
|
package/dist/commands/init.js
CHANGED
|
@@ -7,7 +7,7 @@ import { loadConfig } from "../config.js";
|
|
|
7
7
|
import { CliError } from "../errors.js";
|
|
8
8
|
import { ApiClient } from "../http.js";
|
|
9
9
|
import { bold, cyan, dim, green, red, yellow } from "../output.js";
|
|
10
|
-
import {
|
|
10
|
+
import { anyGap, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
|
|
11
11
|
import { cliVersion, warnIfStale } from "../version-check.js";
|
|
12
12
|
import { authCommands } from "./auth.js";
|
|
13
13
|
import { flagStr } from "./util.js";
|
|
@@ -82,22 +82,24 @@ function claudeAddHint(name, entry) {
|
|
|
82
82
|
function cursorUserConfigPath() {
|
|
83
83
|
return path.join(os.homedir(), ".cursor", "mcp.json");
|
|
84
84
|
}
|
|
85
|
-
// Write the skill to one `.../beryl-test/SKILL.md` file.
|
|
86
|
-
//
|
|
87
|
-
//
|
|
85
|
+
// Write the skill to one `.../beryl-test/SKILL.md` file. The skill is generated content
|
|
86
|
+
// that ships with the CLI and changes every release, so anything that differs is
|
|
87
|
+
// overwritten: a stale copy silently feeds agents months-old guidance, which is worse than
|
|
88
|
+
// losing a local edit the user can redo (and `beryl guide` still prints the same content).
|
|
88
89
|
function writeSkillFile(file) {
|
|
90
|
+
let outcome = "installed";
|
|
89
91
|
if (fs.existsSync(file)) {
|
|
90
|
-
// Compare with line endings normalized so a CRLF
|
|
91
|
-
//
|
|
92
|
+
// Compare with line endings normalized so a CRLF copy of our own content counts as
|
|
93
|
+
// current and isn't rewritten on every run — we always write LF.
|
|
92
94
|
const norm = (s) => s.replace(/\r\n/g, "\n");
|
|
93
95
|
const current = fs.readFileSync(file, "utf8");
|
|
94
96
|
if (norm(current) === norm(BERYL_TEST_SKILL))
|
|
95
97
|
return { outcome: "unchanged", file };
|
|
96
|
-
|
|
98
|
+
outcome = "updated";
|
|
97
99
|
}
|
|
98
100
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
99
101
|
fs.writeFileSync(file, BERYL_TEST_SKILL);
|
|
100
|
-
return { outcome
|
|
102
|
+
return { outcome, file };
|
|
101
103
|
}
|
|
102
104
|
const skillLeaf = (root) => path.join(root, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
|
|
103
105
|
// The authoring skill follows the `--scope` flag, same as the MCP servers: under `user`
|
|
@@ -107,8 +109,7 @@ const skillLeaf = (root) => path.join(root, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKI
|
|
|
107
109
|
// `project` both copies stay repo-local (committed, so teammates get them with the repo).
|
|
108
110
|
// `.agents/skills/` is the vendor-neutral location; Claude Code does NOT index it, so the
|
|
109
111
|
// same string also goes to `.claude/skills/`, which Claude Code auto-discovers. Both are
|
|
110
|
-
//
|
|
111
|
-
// repo-local copies from earlier inits are left untouched.
|
|
112
|
+
// refreshed to this CLI's version on every run.
|
|
112
113
|
function writeSkills(cwd, scope) {
|
|
113
114
|
const root = scope === "user" ? os.homedir() : cwd;
|
|
114
115
|
return [
|
|
@@ -119,21 +120,28 @@ function writeSkills(cwd, scope) {
|
|
|
119
120
|
// Local authoring drives a real browser via `@playwright/test` + chromium, and the whole
|
|
120
121
|
// authoring workflow (walk the flow first, then bank the plan) depends on it — so the
|
|
121
122
|
// install is mandatory, not offered: missing means install now, no prompt, no opt-out.
|
|
122
|
-
//
|
|
123
|
-
//
|
|
123
|
+
// Both halves count: the npm package without the browser binary is a real state, and it
|
|
124
|
+
// fails every local run with the same launch error. Never throws — a failed install must not
|
|
125
|
+
// fail `init`, which has already done its wiring; it prints the exact commands instead.
|
|
124
126
|
async function ensureLocalPlaywright(ctx, cwd) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
+
const gaps = playwrightGaps(cwd);
|
|
128
|
+
if (!anyGap(gaps)) {
|
|
129
|
+
ctx.err(`${green("✓")} @playwright/test + Chromium already installed ${dim("(local runs ready)")}`);
|
|
127
130
|
return;
|
|
128
131
|
}
|
|
129
132
|
try {
|
|
130
133
|
await installPlaywright(cwd, (line) => ctx.err(dim(line)));
|
|
131
|
-
|
|
134
|
+
// Believe the check, not the exit code — "local runs ready" is only honest if both
|
|
135
|
+
// halves are actually there afterwards.
|
|
136
|
+
const left = playwrightGaps(cwd);
|
|
137
|
+
if (anyGap(left))
|
|
138
|
+
throw new Error(`${describeGaps(left)} still missing afterwards`);
|
|
139
|
+
ctx.err(`${green("✓")} ${describeGaps(gaps)} installed ${dim("(local runs ready)")}`);
|
|
132
140
|
}
|
|
133
141
|
catch (err) {
|
|
134
142
|
ctx.err(`${red("✗")} Playwright install failed: ${err.message}`);
|
|
135
143
|
ctx.err(`${dim("•")} Finish the install by hand — local runs and browser authoring need it:\n` +
|
|
136
|
-
` ${cyan(
|
|
144
|
+
` ${cyan(installCommandsFor(gaps))}`);
|
|
137
145
|
}
|
|
138
146
|
}
|
|
139
147
|
export const initCommands = [
|
|
@@ -142,15 +150,17 @@ export const initCommands = [
|
|
|
142
150
|
summary: "Set up Beryl in this repo — sign in and wire up your coding agent",
|
|
143
151
|
description: "One-command onboarding: signs you in (emailed one-time code) and wires up your coding " +
|
|
144
152
|
"agent. Nothing is detected and nothing is conditional — every run wires the beryl AND " +
|
|
145
|
-
"playwright MCP servers,
|
|
153
|
+
"playwright MCP servers, writes the authoring skill (user scope: your home " +
|
|
146
154
|
".agents/skills/ + .claude/skills/, so it follows you into every session; --scope " +
|
|
147
155
|
"project: the repo's own dirs, committed for teammates), and installs @playwright/test " +
|
|
148
156
|
"+ chromium if missing — browser authoring and local runs depend on it. By default the " +
|
|
149
157
|
"servers are wired per-user (matching where your login token lives) via `claude mcp add " +
|
|
150
158
|
"-s user`; pass --scope project to write a committed .mcp.json for a shared repo " +
|
|
151
159
|
"instead. No workspace/project pin and no URL prompt — ask Claude to write tests for " +
|
|
152
|
-
"your site and it resolves the workspace, project, and URL. Safe to re-run
|
|
153
|
-
"
|
|
160
|
+
"your site and it resolves the workspace, project, and URL. Safe to re-run: every run " +
|
|
161
|
+
"refreshes the authoring skill to this CLI's version (overwriting an older or edited " +
|
|
162
|
+
"copy — `beryl guide` prints the same content), and skips whatever else is already set " +
|
|
163
|
+
"up.",
|
|
154
164
|
interactive: true,
|
|
155
165
|
flags: [
|
|
156
166
|
{
|
|
@@ -221,10 +231,15 @@ export const initCommands = [
|
|
|
221
231
|
for (const skill of skills) {
|
|
222
232
|
const rel = path.relative(cwd, skill.file);
|
|
223
233
|
const skillWhere = rel.startsWith("..") ? skill.file : rel;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
234
|
+
const what = skill.outcome === "installed"
|
|
235
|
+
? "installed"
|
|
236
|
+
: skill.outcome === "updated"
|
|
237
|
+
? "updated"
|
|
238
|
+
: "already current";
|
|
239
|
+
const where = skill.outcome === "updated"
|
|
240
|
+
? `${skillWhere} (refreshed to this CLI's version)`
|
|
241
|
+
: skillWhere;
|
|
242
|
+
ctx.err(`${green("✓")} Beryl authoring skill ${what} ${dim(where)}`);
|
|
228
243
|
}
|
|
229
244
|
await ensureLocalPlaywright(ctx, cwd);
|
|
230
245
|
const nextSteps = `\n${bold("Beryl is set up — now open your editor and ask Claude to write tests.")}\n` +
|
|
@@ -251,10 +266,11 @@ export const initCommands = [
|
|
|
251
266
|
name: "guide",
|
|
252
267
|
summary: "Print the Beryl test-authoring guide",
|
|
253
268
|
description: "The full guide to authoring durable, healable tests: the ActionPlan shape, outcome " +
|
|
254
|
-
"assertions, natural-language intent,
|
|
255
|
-
"({{inbox_address}} + await_email), and the
|
|
256
|
-
"`beryl init` installs as the beryl-test
|
|
257
|
-
"first plan when no skill is installed
|
|
269
|
+
"assertions, natural-language intent, test accounts and the project mailbox " +
|
|
270
|
+
"({{login_email}}, {{mailbox_address}}, {{inbox_address}} + await_email), and the " +
|
|
271
|
+
"local run-fix loop. The same content `beryl init` installs as the beryl-test " +
|
|
272
|
+
"skill — call this before authoring your first plan when no skill is installed " +
|
|
273
|
+
"(works without logging in).",
|
|
258
274
|
examples: ["beryl guide"],
|
|
259
275
|
async run() {
|
|
260
276
|
return { human: BERYL_TEST_SKILL };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { extractCode } from "../email-extract.js";
|
|
2
|
+
import { dim, green, table } from "../output.js";
|
|
3
|
+
import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
4
|
+
const mailboxPath = (ws) => `/workspaces/${ws}/mailboxes`;
|
|
5
|
+
export const mailboxCommands = [
|
|
6
|
+
{
|
|
7
|
+
name: "mailbox get",
|
|
8
|
+
summary: "The project's mailbox address",
|
|
9
|
+
groupDefault: true,
|
|
10
|
+
groupSummary: "The project's standing email addresses — where its tests receive sign-in mail.",
|
|
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.",
|
|
15
|
+
scope: "project",
|
|
16
|
+
async run(ctx, input) {
|
|
17
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
18
|
+
const mailbox = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/mailbox`));
|
|
19
|
+
return {
|
|
20
|
+
data: mailbox,
|
|
21
|
+
human: `${mailbox.address}\n\n` +
|
|
22
|
+
dim(`Read its mail with: beryl mailbox read ${mailbox.id}`),
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "mailbox list",
|
|
28
|
+
summary: "List the project's mailboxes",
|
|
29
|
+
scope: "project",
|
|
30
|
+
async run(ctx, input) {
|
|
31
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
32
|
+
const rows = (await ctx.client.get(mailboxPath(workspaceId), {
|
|
33
|
+
project_id: projectId,
|
|
34
|
+
}));
|
|
35
|
+
return {
|
|
36
|
+
data: rows,
|
|
37
|
+
human: table(rows.map((m) => ({
|
|
38
|
+
address: m.address + (m.is_default ? " *" : ""),
|
|
39
|
+
label: m.label ?? "",
|
|
40
|
+
id: m.id,
|
|
41
|
+
}))),
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "mailbox create",
|
|
47
|
+
summary: "Add a second mailbox to the project",
|
|
48
|
+
description: "Only needed when a flow requires two genuinely separate inboxes at the same time " +
|
|
49
|
+
"— both sides of an invite handshake. For an address the site has not seen before, " +
|
|
50
|
+
"cite {{inbox_address}} in the plan instead: it aliases the existing mailbox and " +
|
|
51
|
+
"costs nothing to manage.",
|
|
52
|
+
scope: "project",
|
|
53
|
+
flags: [
|
|
54
|
+
{ name: "label", type: "string", description: "What this mailbox is for, e.g. 'invitee'" },
|
|
55
|
+
{ name: "default", type: "boolean", description: "Make it the project's default" },
|
|
56
|
+
],
|
|
57
|
+
examples: ["beryl mailbox create --label invitee"],
|
|
58
|
+
async run(ctx, input) {
|
|
59
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
60
|
+
const mailbox = (await ctx.client.post(mailboxPath(workspaceId), {
|
|
61
|
+
project_id: projectId,
|
|
62
|
+
label: flagStr(input, "label") ?? null,
|
|
63
|
+
is_default: flagBool(input, "default"),
|
|
64
|
+
}));
|
|
65
|
+
return {
|
|
66
|
+
data: mailbox,
|
|
67
|
+
human: `${green("Created")} mailbox ${mailbox.id}\n\n ${mailbox.address}`,
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "mailbox delete",
|
|
73
|
+
summary: "Delete a mailbox and every email it has received",
|
|
74
|
+
description: "Destructive: the address is configured into the site under test, so removing it " +
|
|
75
|
+
"breaks every test that signs in through it.",
|
|
76
|
+
scope: "project",
|
|
77
|
+
args: [{ name: "mailbox-id", description: "Mailbox id from `beryl mailbox list`", required: true }],
|
|
78
|
+
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
79
|
+
async run(ctx, input) {
|
|
80
|
+
const { workspaceId } = await ctx.requireProject(input);
|
|
81
|
+
const id = arg(input, "mailbox-id");
|
|
82
|
+
await ctx.confirm(`Delete mailbox ${id} and its emails?`, flagBool(input, "force"));
|
|
83
|
+
await ctx.client.del(`${mailboxPath(workspaceId)}/${id}`);
|
|
84
|
+
return { human: "Deleted." };
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "mailbox read",
|
|
89
|
+
summary: "Read the latest email in a mailbox (waits for one to arrive)",
|
|
90
|
+
description: "Waits up to --timeout-s for a matching email and returns it (one blocking request; " +
|
|
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 " +
|
|
93
|
+
"--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
|
+
scope: "project",
|
|
96
|
+
args: [{ name: "mailbox-id", description: "Mailbox id from `beryl mailbox list`", required: true }],
|
|
97
|
+
flags: [
|
|
98
|
+
{
|
|
99
|
+
name: "timeout-s",
|
|
100
|
+
type: "number",
|
|
101
|
+
description: "Seconds to wait for a matching email (0 = don't wait; max 50, default 30)",
|
|
102
|
+
},
|
|
103
|
+
{ name: "since", type: "string", description: "Only emails received after this ISO timestamp" },
|
|
104
|
+
{ name: "from-contains", type: "string", description: "Only emails whose sender contains this" },
|
|
105
|
+
{
|
|
106
|
+
name: "subject-contains",
|
|
107
|
+
type: "string",
|
|
108
|
+
description: "Only emails whose subject contains this",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "recipient-contains",
|
|
112
|
+
type: "string",
|
|
113
|
+
description: "Only mail delivered to an address containing this (a +tag alias)",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "extract-code",
|
|
117
|
+
type: "boolean",
|
|
118
|
+
description: "Also return the one-time code found in the email as `code`",
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
examples: [
|
|
122
|
+
"beryl mailbox read mbx_123 --timeout-s 45 --json",
|
|
123
|
+
"beryl mailbox read mbx_123 --subject-contains code --extract-code --json",
|
|
124
|
+
],
|
|
125
|
+
async run(ctx, input) {
|
|
126
|
+
const { workspaceId } = await ctx.requireProject(input);
|
|
127
|
+
const email = (await ctx.client.get(`${mailboxPath(workspaceId)}/${arg(input, "mailbox-id")}/emails/latest`, {
|
|
128
|
+
timeout_s: flagNum(input, "timeout-s"),
|
|
129
|
+
since: flagStr(input, "since"),
|
|
130
|
+
from_contains: flagStr(input, "from-contains"),
|
|
131
|
+
subject_contains: flagStr(input, "subject-contains"),
|
|
132
|
+
recipient_contains: flagStr(input, "recipient-contains"),
|
|
133
|
+
}));
|
|
134
|
+
if (!flagBool(input, "extract-code"))
|
|
135
|
+
return { data: email };
|
|
136
|
+
return { data: { ...email, code: extractCode(email) } };
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "mailbox emails",
|
|
141
|
+
summary: "List the emails a mailbox has received",
|
|
142
|
+
scope: "project",
|
|
143
|
+
args: [{ name: "mailbox-id", description: "Mailbox id from `beryl mailbox list`", required: true }],
|
|
144
|
+
flags: [
|
|
145
|
+
{ name: "since", type: "string", description: "Only emails received after this ISO timestamp" },
|
|
146
|
+
{
|
|
147
|
+
name: "limit",
|
|
148
|
+
type: "number",
|
|
149
|
+
description: "Return only the most recent N emails (newest first)",
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
async run(ctx, input) {
|
|
153
|
+
const { workspaceId } = await ctx.requireProject(input);
|
|
154
|
+
return {
|
|
155
|
+
data: await ctx.client.get(`${mailboxPath(workspaceId)}/${arg(input, "mailbox-id")}/emails`, { since: flagStr(input, "since"), limit: flagNum(input, "limit") }),
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
];
|