@beryl-so/cli 0.22.0 → 0.24.1
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 +7 -5
- package/dist/adapters/mcp.js +111 -15
- package/dist/beryl-test-skill.js +241 -264
- package/dist/commands/accounts.js +8 -5
- package/dist/commands/auth.js +80 -24
- package/dist/commands/config-vars.js +5 -0
- package/dist/commands/init.js +27 -10
- package/dist/commands/mailboxes.js +5 -1
- package/dist/commands/runs.js +19 -6
- package/dist/commands/tests.js +36 -6
- package/dist/device-login.js +132 -0
- package/dist/skill-tables.js +74 -0
- package/package.json +2 -2
|
@@ -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
|
|
176
|
-
"against a logged-out page, so without a positive
|
|
177
|
-
"every test logged-out and still report the run
|
|
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
|
|
268
|
-
"
|
|
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 }],
|
package/dist/commands/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import { saveGlobalConfig } from "../config.js";
|
|
4
|
+
import { deviceLogin, SWITCH_TO_OTP } from "../device-login.js";
|
|
4
5
|
import { CliError, UsageError } from "../errors.js";
|
|
5
6
|
import { ApiClient } from "../http.js";
|
|
6
7
|
import { dim, green } from "../output.js";
|
|
@@ -48,18 +49,67 @@ export async function promptForOtpCode(ctx) {
|
|
|
48
49
|
throw new UsageError("No valid 6-digit code entered");
|
|
49
50
|
return code;
|
|
50
51
|
}
|
|
52
|
+
// The emailed-code flow, returning a raw PAT. With a --code the email already has a
|
|
53
|
+
// code, so nothing is sent (and check-email is skipped entirely).
|
|
54
|
+
async function otpLogin(ctx, input, codeFlag) {
|
|
55
|
+
const apiUrl = ctx.client.baseUrl;
|
|
56
|
+
const email = flagStr(input, "email") ?? (await promptForEmail(ctx));
|
|
57
|
+
if (!email.includes("@"))
|
|
58
|
+
throw new UsageError(`"${email}" is not an email address`);
|
|
59
|
+
const anon = new ApiClient(apiUrl);
|
|
60
|
+
let code = codeFlag;
|
|
61
|
+
if (!code) {
|
|
62
|
+
const check = (await anon.post("/auth/check-email", { email }));
|
|
63
|
+
if (!check.exists) {
|
|
64
|
+
await anon.post("/auth/signup", { email });
|
|
65
|
+
ctx.err(dim(`No Beryl account for ${email} yet — creating one. Sent a 6-digit code to ${email}`));
|
|
66
|
+
}
|
|
67
|
+
else if ((check.auth_methods ?? []).includes("otp")) {
|
|
68
|
+
await anon.post("/auth/request-login-otp", { email });
|
|
69
|
+
ctx.err(dim(`Sent a 6-digit code to ${email}`));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const why = `${email} signs in with Google, so there is no emailed code`;
|
|
73
|
+
if (!ctx.interactive) {
|
|
74
|
+
throw new UsageError(`${why} — run \`beryl login\` in a terminal to sign in via the browser, ` +
|
|
75
|
+
"or pass --token with a token from beryl.so → Account → API tokens");
|
|
76
|
+
}
|
|
77
|
+
ctx.err(`${why} — confirm the sign-in in your browser instead.`);
|
|
78
|
+
const fromBrowser = await deviceLogin(ctx, process.stdin, flagStr(input, "token-name"));
|
|
79
|
+
if (fromBrowser === SWITCH_TO_OTP)
|
|
80
|
+
throw new UsageError(why);
|
|
81
|
+
return fromBrowser;
|
|
82
|
+
}
|
|
83
|
+
code = await promptForOtpCode(ctx);
|
|
84
|
+
}
|
|
85
|
+
const login = (await anon.post("/auth/verify-otp", { email, code }));
|
|
86
|
+
if (!login.access_token)
|
|
87
|
+
throw new CliError("Login did not return an access token");
|
|
88
|
+
const session = new ApiClient(apiUrl, login.access_token);
|
|
89
|
+
const minted = (await session.post("/account/tokens", {
|
|
90
|
+
name: flagStr(input, "token-name") ?? `CLI on ${os.hostname()}`,
|
|
91
|
+
}));
|
|
92
|
+
return minted.token;
|
|
93
|
+
}
|
|
51
94
|
export const authCommands = [
|
|
52
95
|
{
|
|
53
96
|
name: "login",
|
|
54
97
|
summary: "Authenticate the CLI with your Beryl account",
|
|
55
|
-
description: "Signs in
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
98
|
+
description: "Signs in via your browser (a code you confirm at beryl.so) and stores a personal " +
|
|
99
|
+
"access token in the CLI config. Pass --otp (or --email) to skip the browser and " +
|
|
100
|
+
"sign in with an emailed one-time code instead — a new email gets an account " +
|
|
101
|
+
"created automatically. Pass --token to use an existing token from Account → API " +
|
|
102
|
+
"tokens. Pass --email plus --code (the 6 digits from the email, e.g. read from a " +
|
|
103
|
+
"`beryl inbox`) to complete the OTP flow without a prompt. In CI, prefer the " +
|
|
104
|
+
"BERYL_API_KEY environment variable.",
|
|
60
105
|
interactive: true,
|
|
61
106
|
flags: [
|
|
62
107
|
{ name: "token", type: "string", description: "Use an existing personal access token" },
|
|
108
|
+
{
|
|
109
|
+
name: "otp",
|
|
110
|
+
type: "boolean",
|
|
111
|
+
description: "Skip the browser — sign in with an emailed code",
|
|
112
|
+
},
|
|
63
113
|
{
|
|
64
114
|
name: "email",
|
|
65
115
|
type: "string",
|
|
@@ -81,6 +131,7 @@ export const authCommands = [
|
|
|
81
131
|
],
|
|
82
132
|
examples: [
|
|
83
133
|
"beryl login",
|
|
134
|
+
"beryl login --otp",
|
|
84
135
|
"beryl login --token beryl_pat_…",
|
|
85
136
|
"beryl login --email you@example.com",
|
|
86
137
|
"beryl login --email agent@example.com --code 123456 --json",
|
|
@@ -96,26 +147,22 @@ export const authCommands = [
|
|
|
96
147
|
if (!flagStr(input, "email"))
|
|
97
148
|
throw new UsageError("--code requires --email");
|
|
98
149
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
150
|
+
// Guard before any network call — the OTP flow's check-email/signup would create
|
|
151
|
+
// a real account and send an email before dying on the interactive code prompt.
|
|
152
|
+
if (!ctx.interactive && codeFlag === undefined) {
|
|
153
|
+
throw new UsageError("Cannot prompt for a code without a terminal — pass --email with --code, " +
|
|
154
|
+
"--token, or set BERYL_API_KEY");
|
|
155
|
+
}
|
|
156
|
+
const wantsOtp = flagBool(input, "otp") ||
|
|
157
|
+
flagStr(input, "email") !== undefined ||
|
|
158
|
+
codeFlag !== undefined;
|
|
159
|
+
if (!wantsOtp && ctx.interactive) {
|
|
160
|
+
const fromBrowser = await deviceLogin(ctx, process.stdin, flagStr(input, "token-name"));
|
|
161
|
+
token = fromBrowser === SWITCH_TO_OTP ? await otpLogin(ctx, input) : fromBrowser;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
token = await otpLogin(ctx, input, codeFlag);
|
|
110
165
|
}
|
|
111
|
-
const login = (await anon.post("/auth/verify-otp", { email, code }));
|
|
112
|
-
if (!login.access_token)
|
|
113
|
-
throw new CliError("Login did not return an access token");
|
|
114
|
-
const session = new ApiClient(apiUrl, login.access_token);
|
|
115
|
-
const minted = (await session.post("/account/tokens", {
|
|
116
|
-
name: flagStr(input, "token-name") ?? `CLI on ${os.hostname()}`,
|
|
117
|
-
}));
|
|
118
|
-
token = minted.token;
|
|
119
166
|
}
|
|
120
167
|
const authed = new ApiClient(apiUrl, token);
|
|
121
168
|
const me = (await authed.get("/account/"));
|
|
@@ -240,4 +287,13 @@ export const authCommands = [
|
|
|
240
287
|
return { human: "Revoked." };
|
|
241
288
|
},
|
|
242
289
|
},
|
|
290
|
+
{
|
|
291
|
+
name: "tokens dismiss",
|
|
292
|
+
summary: "Remove an already-revoked token from your list",
|
|
293
|
+
args: [{ name: "token-id", description: "Token id from `beryl tokens list`", required: true }],
|
|
294
|
+
async run(ctx, input) {
|
|
295
|
+
await ctx.client.post(`/account/tokens/${arg(input, "token-id")}/dismiss`, {});
|
|
296
|
+
return { human: "Dismissed." };
|
|
297
|
+
},
|
|
298
|
+
},
|
|
243
299
|
];
|
|
@@ -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: [
|
package/dist/commands/init.js
CHANGED
|
@@ -4,7 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } from "../beryl-test-skill.js";
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
|
-
import { CliError } from "../errors.js";
|
|
7
|
+
import { AuthError, CliError } from "../errors.js";
|
|
8
8
|
import { ApiClient } from "../http.js";
|
|
9
9
|
import { bold, cyan, dim, green, red, yellow } from "../output.js";
|
|
10
10
|
import { anyGap, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
|
|
@@ -148,7 +148,8 @@ export const initCommands = [
|
|
|
148
148
|
{
|
|
149
149
|
name: "init",
|
|
150
150
|
summary: "Set up Beryl in this repo — sign in and wire up your coding agent",
|
|
151
|
-
description: "One-command onboarding: signs you in (emailed one-time code)
|
|
151
|
+
description: "One-command onboarding: signs you in (browser confirm, or an emailed one-time code) " +
|
|
152
|
+
"and wires up your coding " +
|
|
152
153
|
"agent. Nothing is detected and nothing is conditional — every run wires the beryl AND " +
|
|
153
154
|
"playwright MCP servers, writes the authoring skill (user scope: your home " +
|
|
154
155
|
".agents/skills/ + .claude/skills/, so it follows you into every session; --scope " +
|
|
@@ -186,17 +187,33 @@ export const initCommands = [
|
|
|
186
187
|
async run(ctx, input) {
|
|
187
188
|
const cwd = process.cwd();
|
|
188
189
|
let { client, config } = ctx;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
190
|
+
const signIn = async () => {
|
|
191
|
+
// Non-interactive skips the picker and lets `login` fail with its own
|
|
192
|
+
// "interactive input required" message, exactly as before.
|
|
193
|
+
const answer = ctx.interactive
|
|
194
|
+
? await ctx.prompt("Sign in: [1] Browser (opens beryl.so) [2] Email me a code — [1]: ")
|
|
195
|
+
: "";
|
|
194
196
|
const login = authCommands.find((c) => c.name === "login");
|
|
195
|
-
await login.run(ctx, { args: {}, flags: {} });
|
|
197
|
+
await login.run(ctx, { args: {}, flags: answer.trim() === "2" ? { otp: true } : {} });
|
|
196
198
|
config = loadConfig();
|
|
197
199
|
if (!config.token)
|
|
198
200
|
throw new CliError("Login did not persist a token");
|
|
199
201
|
client = new ApiClient(config.apiUrl, config.token);
|
|
202
|
+
};
|
|
203
|
+
if (config.token) {
|
|
204
|
+
try {
|
|
205
|
+
const me = (await client.get("/account/"));
|
|
206
|
+
ctx.err(`${green("✓")} Signed in as ${me.email}`);
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
if (!(err instanceof AuthError))
|
|
210
|
+
throw err;
|
|
211
|
+
ctx.err(yellow("Your session token is invalid or expired."));
|
|
212
|
+
await signIn();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
await signIn();
|
|
200
217
|
}
|
|
201
218
|
const scope = (flagStr(input, "scope") ?? "user");
|
|
202
219
|
const report = (label, fresh, where) => ctx.err(`${green("✓")} ${label} ${fresh ? "configured" : "already configured"} ${dim(where)}`);
|
|
@@ -268,8 +285,8 @@ export const initCommands = [
|
|
|
268
285
|
description: "The full guide to authoring durable, healable tests: the ActionPlan shape, outcome " +
|
|
269
286
|
"assertions, natural-language intent, test accounts and the project mailbox " +
|
|
270
287
|
"({{login_email}}, {{mailbox_address}}, {{inbox_address}} + await_email), and the " +
|
|
271
|
-
"local run-fix loop.
|
|
272
|
-
|
|
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 ` +
|
|
273
290
|
"(works without logging in).",
|
|
274
291
|
examples: ["beryl guide"],
|
|
275
292
|
async run() {
|
|
@@ -91,7 +91,9 @@ export const mailboxCommands = [
|
|
|
91
91
|
"the server caps the wait at 50s — re-run to keep waiting). With --extract-code, " +
|
|
92
92
|
"also pulls the one-time code (4-8 digits) out of the body/subject. Use " +
|
|
93
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."
|
|
94
|
+
"share the mailbox. Exits non-zero if nothing arrives before the timeout. Waits for " +
|
|
95
|
+
"and returns ONE latest matching email — `mailbox emails` lists what has already " +
|
|
96
|
+
"arrived, without waiting.",
|
|
95
97
|
scope: "project",
|
|
96
98
|
args: [{ name: "mailbox-id", description: "Mailbox id from `beryl mailbox list`", required: true }],
|
|
97
99
|
flags: [
|
|
@@ -139,6 +141,8 @@ export const mailboxCommands = [
|
|
|
139
141
|
{
|
|
140
142
|
name: "mailbox emails",
|
|
141
143
|
summary: "List the emails a mailbox has received",
|
|
144
|
+
description: "Returns the already-received emails without waiting — `mailbox read` blocks for " +
|
|
145
|
+
"a matching one and returns just it.",
|
|
142
146
|
scope: "project",
|
|
143
147
|
args: [{ name: "mailbox-id", description: "Mailbox id from `beryl mailbox list`", required: true }],
|
|
144
148
|
flags: [
|
package/dist/commands/runs.js
CHANGED
|
@@ -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}}
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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) {
|
package/dist/commands/tests.js
CHANGED
|
@@ -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
|
|
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).
|
|
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: [
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
import { ApiClient, ApiError } from "./http.js";
|
|
5
|
+
import { dim } from "./output.js";
|
|
6
|
+
/** Returned when the user presses Enter to fall back to the emailed-code flow. */
|
|
7
|
+
export const SWITCH_TO_OTP = Symbol("switch-to-otp");
|
|
8
|
+
function openBrowser(url) {
|
|
9
|
+
const [cmd, args] = process.platform === "darwin"
|
|
10
|
+
? ["open", [url]]
|
|
11
|
+
: process.platform === "win32"
|
|
12
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
13
|
+
: ["xdg-open", [url]];
|
|
14
|
+
try {
|
|
15
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
|
16
|
+
child.on("error", () => { });
|
|
17
|
+
child.unref();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// no browser is fine — the URL is printed for copy-paste
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Browser device-code sign-in: opens beryl.so, polls until the user confirms the code
|
|
24
|
+
* there, and resolves to the raw PAT — or to SWITCH_TO_OTP on Enter. */
|
|
25
|
+
export async function deviceLogin(ctx, stdin = process.stdin, tokenName) {
|
|
26
|
+
const anon = new ApiClient(ctx.client.baseUrl);
|
|
27
|
+
const start = (await anon.post("/auth/cli/start", {
|
|
28
|
+
token_name: tokenName ?? `CLI on ${os.hostname()}`,
|
|
29
|
+
}));
|
|
30
|
+
openBrowser(start.verification_url);
|
|
31
|
+
ctx.err(start.verification_url);
|
|
32
|
+
ctx.err(`Confirm this code in the browser: ${start.user_code}`);
|
|
33
|
+
ctx.err(dim("Press Enter to sign in with an emailed code instead"));
|
|
34
|
+
let timer;
|
|
35
|
+
let onData;
|
|
36
|
+
let done = false;
|
|
37
|
+
try {
|
|
38
|
+
return await new Promise((resolve, reject) => {
|
|
39
|
+
let interval = start.interval;
|
|
40
|
+
let failures = 0;
|
|
41
|
+
let pollInFlight = false;
|
|
42
|
+
let switchRequested = false;
|
|
43
|
+
const finish = (token) => {
|
|
44
|
+
done = true;
|
|
45
|
+
resolve(token);
|
|
46
|
+
};
|
|
47
|
+
const fail = (err) => {
|
|
48
|
+
done = true;
|
|
49
|
+
reject(err);
|
|
50
|
+
};
|
|
51
|
+
const switchToOtp = () => {
|
|
52
|
+
done = true;
|
|
53
|
+
resolve(SWITCH_TO_OTP);
|
|
54
|
+
};
|
|
55
|
+
onData = (chunk) => {
|
|
56
|
+
if (!/[\r\n]/.test(chunk.toString()))
|
|
57
|
+
return;
|
|
58
|
+
if (done || switchRequested)
|
|
59
|
+
return;
|
|
60
|
+
// An in-flight poll may already be approved server-side (the session is consumed);
|
|
61
|
+
// its result must win or the minted token is lost forever.
|
|
62
|
+
if (pollInFlight)
|
|
63
|
+
switchRequested = true;
|
|
64
|
+
else
|
|
65
|
+
switchToOtp();
|
|
66
|
+
};
|
|
67
|
+
stdin.on("data", onData);
|
|
68
|
+
stdin.resume();
|
|
69
|
+
const poll = async () => {
|
|
70
|
+
pollInFlight = true;
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = (await anon.post("/auth/cli/poll", {
|
|
74
|
+
device_code: start.device_code,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
pollInFlight = false;
|
|
79
|
+
if (done)
|
|
80
|
+
return;
|
|
81
|
+
if (switchRequested) {
|
|
82
|
+
switchToOtp();
|
|
83
|
+
}
|
|
84
|
+
else if (++failures >= 5) {
|
|
85
|
+
fail(err);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
if (err instanceof ApiError && err.status === 429)
|
|
89
|
+
interval *= 2;
|
|
90
|
+
timer = setTimeout(() => void poll(), interval * 1000);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
pollInFlight = false;
|
|
95
|
+
failures = 0;
|
|
96
|
+
if (done)
|
|
97
|
+
return;
|
|
98
|
+
if (res.status === "approved" && res.token) {
|
|
99
|
+
if (switchRequested)
|
|
100
|
+
ctx.err("Already approved in the browser — signed in");
|
|
101
|
+
finish(res.token);
|
|
102
|
+
}
|
|
103
|
+
else if (switchRequested) {
|
|
104
|
+
switchToOtp();
|
|
105
|
+
}
|
|
106
|
+
else if (res.status === "approved") {
|
|
107
|
+
fail(new CliError("Sign-in approved but no token was returned — try again"));
|
|
108
|
+
}
|
|
109
|
+
else if (res.status === "denied") {
|
|
110
|
+
fail(new CliError("Sign-in was denied in the browser"));
|
|
111
|
+
}
|
|
112
|
+
else if (res.status !== "pending") {
|
|
113
|
+
fail(new CliError("The sign-in request expired — run `beryl login` again"));
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
timer = setTimeout(() => void poll(), interval * 1000);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
timer = setTimeout(() => void poll(), interval * 1000);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
// Both racers are torn down whichever wins — a live timer or stdin listener would
|
|
124
|
+
// keep the process alive after login.
|
|
125
|
+
done = true;
|
|
126
|
+
if (timer)
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
if (onData)
|
|
129
|
+
stdin.off("data", onData);
|
|
130
|
+
stdin.pause();
|
|
131
|
+
}
|
|
132
|
+
}
|