@beryl-so/cli 0.67.0 → 0.77.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 +26 -14
- package/dist/adapters/mcp.js +6 -4
- package/dist/beryl-test-skill.js +129 -107
- package/dist/commands/accounts.js +92 -10
- package/dist/commands/config-vars.js +87 -16
- package/dist/commands/environments.js +18 -1
- package/dist/commands/init.js +42 -108
- package/dist/commands/jobs.js +129 -0
- package/dist/commands/projects.js +68 -45
- package/dist/commands/runs.js +5 -432
- package/dist/commands/tests.js +34 -409
- package/dist/commands/third-party-integrations.js +26 -3
- package/dist/commands/watch.js +36 -0
- package/dist/lint.js +120 -6
- package/dist/registry/index.js +2 -0
- package/dist/schema.generated.js +152 -27
- package/dist/skill-tables.js +3 -0
- package/package.json +2 -3
- package/dist/email-pump.js +0 -138
- package/dist/local-exec.js +0 -239
- package/dist/local-run.js +0 -340
- package/dist/playwright-install.js +0 -220
- package/dist/progress.js +0 -44
|
@@ -51,9 +51,9 @@ export const testAccountCommands = [
|
|
|
51
51
|
if (rows.length === 0)
|
|
52
52
|
return {
|
|
53
53
|
data: rows,
|
|
54
|
-
human: dim("No test accounts
|
|
55
|
-
"
|
|
56
|
-
"{{inbox_address}} and deletes the account in `after`."),
|
|
54
|
+
human: dim("No test accounts, so {{login_email}} and {{login_password}} have nothing to " +
|
|
55
|
+
"resolve to. Add one with `beryl accounts create`. A flow that must sign up " +
|
|
56
|
+
"fresh cites {{inbox_address}} and deletes the account in `after`."),
|
|
57
57
|
};
|
|
58
58
|
return { data: rows, human: accountTable(rows) };
|
|
59
59
|
},
|
|
@@ -81,7 +81,9 @@ export const testAccountCommands = [
|
|
|
81
81
|
"`--type provisioned` reserves one Beryl will sign up itself, addressed at the project " +
|
|
82
82
|
"mailbox; `--lifetime` says whether it is kept (permanent, the default) or a " +
|
|
83
83
|
"throwaway. It starts `pending` and becomes usable after `beryl accounts signup`. " +
|
|
84
|
-
"A captured account (a signed-in session from the Chrome
|
|
84
|
+
"A captured account (a signed-in session from the Beryl Chrome extension) is not " +
|
|
85
|
+
"created here: capture it with the extension, then give it a probe with " +
|
|
86
|
+
"`beryl accounts set-login --probe`. " +
|
|
85
87
|
"Works for Google-only apps too: bank a dedicated Google test account as provided " +
|
|
86
88
|
"and author its sign-in plan through Google's form. " +
|
|
87
89
|
"The first account an environment gets is its default whatever you pass.",
|
|
@@ -148,6 +150,54 @@ export const testAccountCommands = [
|
|
|
148
150
|
};
|
|
149
151
|
},
|
|
150
152
|
},
|
|
153
|
+
{
|
|
154
|
+
name: "accounts capture",
|
|
155
|
+
summary: "Store a signed-in session as a captured test account",
|
|
156
|
+
description: "Usually done by the Beryl Chrome extension from a signed-in tab; this takes the " +
|
|
157
|
+
"same session JSON from a file: {schema_version: 2, final_url, cookies (CDP " +
|
|
158
|
+
"shape), local_storage, session_storage, indexed_db}. Upserts by --label on the " +
|
|
159
|
+
"environment, so capturing an existing label replaces its session. Cookies for " +
|
|
160
|
+
"other domains are dropped and listed. A captured account cannot sign itself in: " +
|
|
161
|
+
"give it a probe with `beryl accounts set-login --probe`, then prove it with " +
|
|
162
|
+
"`beryl accounts check`. Its session is never served back to a client.",
|
|
163
|
+
scope: "project",
|
|
164
|
+
flags: [
|
|
165
|
+
{
|
|
166
|
+
name: "file",
|
|
167
|
+
type: "string",
|
|
168
|
+
required: true,
|
|
169
|
+
description: "The captured session, as JSON (path, or - for stdin)",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "label",
|
|
173
|
+
type: "string",
|
|
174
|
+
description: "Identity label a plan's auth_label names (default 'default')",
|
|
175
|
+
},
|
|
176
|
+
{ name: "email", type: "string", description: "The signed-in user's email" },
|
|
177
|
+
{ name: "env", type: "string", description: "Environment id (default: the project's)" },
|
|
178
|
+
{ name: "default", type: "boolean", description: "Make this the environment's default" },
|
|
179
|
+
],
|
|
180
|
+
examples: ["beryl accounts capture --file session.json --label admin"],
|
|
181
|
+
async run(ctx, input) {
|
|
182
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
183
|
+
const account = (await ctx.client.put(`${accountsPath(workspaceId, projectId)}/captured`, {
|
|
184
|
+
session: readJsonFlag(input, "file"),
|
|
185
|
+
identity_label: flagStr(input, "label") ?? "default",
|
|
186
|
+
email: flagStr(input, "email") ?? null,
|
|
187
|
+
environment_id: flagStr(input, "env") ?? null,
|
|
188
|
+
is_default: flagBool(input, "default"),
|
|
189
|
+
}));
|
|
190
|
+
const ignored = account.ignored_cookie_domains.length
|
|
191
|
+
? yellow(`\nDropped cookies for other domains: ${account.ignored_cookie_domains.join(", ")}`)
|
|
192
|
+
: "";
|
|
193
|
+
return {
|
|
194
|
+
data: account,
|
|
195
|
+
human: `${green("Captured")} ${account.identity_label} (${account.id})${ignored}\n` +
|
|
196
|
+
dim(`Next: beryl accounts set-login ${account.id} --probe probe.json, then ` +
|
|
197
|
+
`beryl accounts check ${account.id}`),
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
},
|
|
151
201
|
{
|
|
152
202
|
name: "accounts signup",
|
|
153
203
|
summary: "Store a test account's signup plan and sign it up on its environment",
|
|
@@ -201,6 +251,12 @@ export const testAccountCommands = [
|
|
|
201
251
|
"match on a redirect all pass against a logged-out page, so without a positive " +
|
|
202
252
|
"signal a dead session would run every test logged-out and still report the run " +
|
|
203
253
|
"green.\n\n" +
|
|
254
|
+
"A captured account (from the Beryl Chrome extension) takes --probe alone: it rides " +
|
|
255
|
+
"the captured session and cannot sign itself in, so there is no --file.\n\n" +
|
|
256
|
+
"--surface android stores the sign-in Android tests use instead, beside the web one; " +
|
|
257
|
+
"Beryl proves it on an Android worker the first time a signed-in android test runs. " +
|
|
258
|
+
"A test-creation job saves one itself when it meets the app's login screen, so this " +
|
|
259
|
+
"is for fixing or replacing it.\n\n" +
|
|
204
260
|
"Storing only stores. Run `beryl accounts check` to prove it against the live app.",
|
|
205
261
|
scope: "project",
|
|
206
262
|
args: [
|
|
@@ -210,8 +266,8 @@ export const testAccountCommands = [
|
|
|
210
266
|
{
|
|
211
267
|
name: "file",
|
|
212
268
|
type: "string",
|
|
213
|
-
|
|
214
|
-
|
|
269
|
+
description: "The sign-in ActionPlan, as JSON (path, or - for stdin). Required except for a " +
|
|
270
|
+
"captured account",
|
|
215
271
|
},
|
|
216
272
|
{
|
|
217
273
|
name: "probe",
|
|
@@ -226,23 +282,37 @@ export const testAccountCommands = [
|
|
|
226
282
|
description: "The login_plan_hash from `beryl accounts get-login`. The write is rejected if " +
|
|
227
283
|
"the stored plan changed since. Omit only when writing the first plan",
|
|
228
284
|
},
|
|
285
|
+
{
|
|
286
|
+
name: "surface",
|
|
287
|
+
type: "string",
|
|
288
|
+
enum: ["android", "ios"],
|
|
289
|
+
description: "An app surface: store (or read) the sign-in through the app's own screens. Both " +
|
|
290
|
+
"plans are plans for that device (device android or ios, starting with a launch); " +
|
|
291
|
+
"a signed-in app test restores the session this sign-in saved on the device",
|
|
292
|
+
},
|
|
229
293
|
],
|
|
230
294
|
examples: [
|
|
231
295
|
"beryl accounts set-login acc_123 --file signin.json --probe probe.json",
|
|
232
296
|
"beryl accounts set-login acc_123 --file signin.json --probe probe.json --base-hash 9f2c…",
|
|
297
|
+
"beryl accounts set-login acc_456 --probe probe.json # captured account",
|
|
298
|
+
"beryl accounts set-login acc_123 --surface android --file app-signin.json --probe app-probe.json",
|
|
233
299
|
],
|
|
234
300
|
async run(ctx, input) {
|
|
235
301
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
236
302
|
try {
|
|
237
303
|
const account = (await ctx.client.put(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/login`, {
|
|
238
|
-
plan: readJsonFlag(input, "file"),
|
|
304
|
+
plan: flagStr(input, "file") ? readJsonFlag(input, "file") : null,
|
|
239
305
|
probe: readJsonFlag(input, "probe"),
|
|
240
306
|
base_hash: flagStr(input, "base-hash") ?? null,
|
|
307
|
+
...(flagStr(input, "surface") ? { surface: flagStr(input, "surface") } : {}),
|
|
241
308
|
}));
|
|
309
|
+
const surface = flagStr(input, "surface");
|
|
242
310
|
return {
|
|
243
311
|
data: account,
|
|
244
312
|
human: `${green("Stored")} — ${account.email}\n` +
|
|
245
|
-
dim(
|
|
313
|
+
dim(surface
|
|
314
|
+
? `Beryl proves it the first time a signed-in ${surface} test runs.`
|
|
315
|
+
: "Now run `beryl accounts check " + account.id + "` to prove it works."),
|
|
246
316
|
};
|
|
247
317
|
}
|
|
248
318
|
catch (err) {
|
|
@@ -261,10 +331,22 @@ export const testAccountCommands = [
|
|
|
261
331
|
"--base-hash on the next `accounts set-login`.",
|
|
262
332
|
scope: "project",
|
|
263
333
|
args: [{ name: "account-id", description: "Account id", required: true }],
|
|
334
|
+
flags: [
|
|
335
|
+
{
|
|
336
|
+
name: "surface",
|
|
337
|
+
type: "string",
|
|
338
|
+
enum: ["android", "ios"],
|
|
339
|
+
description: "An app surface: store (or read) the sign-in through the app's own screens. Both " +
|
|
340
|
+
"plans are plans for that device (device android or ios, starting with a launch); " +
|
|
341
|
+
"a signed-in app test restores the session this sign-in saved on the device",
|
|
342
|
+
},
|
|
343
|
+
],
|
|
264
344
|
async run(ctx, input) {
|
|
265
345
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
266
|
-
const
|
|
267
|
-
|
|
346
|
+
const surface = flagStr(input, "surface");
|
|
347
|
+
const login = (await ctx.client.get(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/login` +
|
|
348
|
+
(surface ? `?surface=${encodeURIComponent(surface)}` : "")));
|
|
349
|
+
if (!login.plan && !login.probe)
|
|
268
350
|
return {
|
|
269
351
|
data: login,
|
|
270
352
|
human: dim("No login plan stored. Author one by driving the sign-in in a browser, then " +
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { UsageError } from "../errors.js";
|
|
3
|
+
import { CliError, UsageError } from "../errors.js";
|
|
4
|
+
import { ApiError } from "../http.js";
|
|
5
|
+
import { dim, green, table, timeAgo } from "../output.js";
|
|
4
6
|
import { arg, flagBool, flagStr, projectPath } from "./util.js";
|
|
7
|
+
const APK_RE = /\.apk$/i;
|
|
8
|
+
const BUILD_KINDS = new Set(["android_build", "ios_build"]);
|
|
9
|
+
const buildLabel = (f) => `${f.app_id ?? "?"} ${f.app_version ?? "?"} (${f.build_number ?? "?"})`;
|
|
5
10
|
const configPath = (ws, p) => `${projectPath(ws, p)}/config`;
|
|
6
11
|
function findByKey(rows, value, kind) {
|
|
7
12
|
const byId = rows.find((r) => r.id === value);
|
|
@@ -86,7 +91,7 @@ export const configCommands = [
|
|
|
86
91
|
},
|
|
87
92
|
{
|
|
88
93
|
name: "config secrets list",
|
|
89
|
-
summary: "List the project's secrets (values are never returned)",
|
|
94
|
+
summary: "List the project's secrets (values are never returned); a test account's password is managed on the account, not here",
|
|
90
95
|
scope: "project",
|
|
91
96
|
async run(ctx, input) {
|
|
92
97
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
@@ -165,11 +170,20 @@ export const configCommands = [
|
|
|
165
170
|
},
|
|
166
171
|
{
|
|
167
172
|
name: "config files list",
|
|
168
|
-
summary: "List files
|
|
173
|
+
summary: "List uploaded files: fixtures tests attach and Android and iOS app builds",
|
|
169
174
|
scope: "project",
|
|
170
175
|
async run(ctx, input) {
|
|
171
176
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
172
|
-
|
|
177
|
+
const rows = (await ctx.client.get(`${configPath(workspaceId, projectId)}/files`));
|
|
178
|
+
const human = table(rows.map((f) => ({
|
|
179
|
+
name: f.name,
|
|
180
|
+
kind: f.kind ?? "fixture",
|
|
181
|
+
app: BUILD_KINDS.has(String(f.kind)) ? buildLabel(f) : "",
|
|
182
|
+
env: f.environment_id ?? "",
|
|
183
|
+
created: timeAgo(f.created_at),
|
|
184
|
+
id: f.id,
|
|
185
|
+
})));
|
|
186
|
+
return { data: rows, human };
|
|
173
187
|
},
|
|
174
188
|
},
|
|
175
189
|
{
|
|
@@ -195,32 +209,89 @@ export const configCommands = [
|
|
|
195
209
|
},
|
|
196
210
|
{
|
|
197
211
|
name: "config files upload",
|
|
198
|
-
summary: "Upload a file",
|
|
212
|
+
summary: "Upload a file: a fixture an upload step attaches, or an Android or iOS app build",
|
|
213
|
+
description: "The bytes go straight to storage over a short-lived URL, never through the API, up to " +
|
|
214
|
+
"1 GB. An .apk uploads as an Android build (or pass --kind android_build): Beryl reads " +
|
|
215
|
+
"its package name, version and native ABIs, refuses one the emulators cannot run (an " +
|
|
216
|
+
".aab, a split APK, a too-new minSdk, ARM-only native code on x86 emulators). An iOS " +
|
|
217
|
+
"simulator build (the .app folder zipped, or EAS's .tar.gz) uploads with --kind " +
|
|
218
|
+
"ios_build: Beryl reads its bundle id and version and refuses an .ipa, a device build " +
|
|
219
|
+
"or one with no arm64 slice. App tests of an environment whose app id for that surface " +
|
|
220
|
+
"matches install the newest build. Beryl keeps the newest 5 builds per app per " +
|
|
221
|
+
"environment. Upload after each build, e.g. in CI.",
|
|
199
222
|
scope: "project",
|
|
200
223
|
args: [{ name: "file", description: "Path to the local file", required: true }],
|
|
201
|
-
flags: [
|
|
224
|
+
flags: [
|
|
225
|
+
{ name: "env", type: "string", description: "Scope to one environment id" },
|
|
226
|
+
{
|
|
227
|
+
name: "kind",
|
|
228
|
+
type: "string",
|
|
229
|
+
enum: ["fixture", "android_build", "ios_build"],
|
|
230
|
+
description: "fixture: a file tests attach. android_build: an Android APK (the default for .apk). " +
|
|
231
|
+
"ios_build: a zipped or tarred iOS simulator .app",
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
examples: [
|
|
235
|
+
"beryl config files upload ./fixtures/avatar.png",
|
|
236
|
+
"beryl config files upload ./android/app/build/outputs/apk/release/app-release.apk",
|
|
237
|
+
"beryl config files upload ./MyApp.zip --kind ios_build",
|
|
238
|
+
],
|
|
202
239
|
async run(ctx, input) {
|
|
203
240
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
204
241
|
const file = arg(input, "file");
|
|
205
|
-
|
|
242
|
+
const explicitKind = flagStr(input, "kind");
|
|
243
|
+
const kind = explicitKind ?? (APK_RE.test(file) ? "android_build" : "fixture");
|
|
244
|
+
const envId = flagStr(input, "env");
|
|
245
|
+
let blob;
|
|
206
246
|
try {
|
|
207
|
-
|
|
247
|
+
if (!fs.statSync(file).isFile())
|
|
248
|
+
throw new UsageError(`not a file: ${file}`);
|
|
249
|
+
blob = await fs.openAsBlob(file);
|
|
208
250
|
}
|
|
209
251
|
catch (err) {
|
|
252
|
+
if (err instanceof UsageError)
|
|
253
|
+
throw err;
|
|
210
254
|
const code = err.code;
|
|
211
255
|
if (code === "ENOENT")
|
|
212
256
|
throw new UsageError(`file not found: ${file}`);
|
|
213
257
|
throw new UsageError(`cannot read ${file} (${code ?? err.message})`);
|
|
214
258
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
259
|
+
if (blob.size === 0)
|
|
260
|
+
throw new UsageError(`${file} is empty`);
|
|
261
|
+
const base = `${configPath(workspaceId, projectId)}/files`;
|
|
262
|
+
const upload = (await ctx.client.post(`${base}/uploads`, {
|
|
263
|
+
name: path.basename(file),
|
|
264
|
+
size: blob.size,
|
|
265
|
+
kind,
|
|
266
|
+
...(envId ? { environment_id: envId } : {}),
|
|
267
|
+
}));
|
|
268
|
+
let put;
|
|
269
|
+
try {
|
|
270
|
+
put = await fetch(upload.upload_url, {
|
|
271
|
+
method: upload.method,
|
|
272
|
+
headers: upload.headers,
|
|
273
|
+
body: blob,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
throw new CliError(`Upload of ${file} failed: ${err.message}`);
|
|
278
|
+
}
|
|
279
|
+
if (!put.ok)
|
|
280
|
+
throw new CliError(`Upload of ${file} failed: storage answered HTTP ${put.status}`);
|
|
281
|
+
let done;
|
|
282
|
+
try {
|
|
283
|
+
done = (await ctx.client.post(`${base}/uploads/${upload.file_id}/complete`));
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
if (err instanceof ApiError && err.status === 422 && typeof err.detail === "string")
|
|
287
|
+
throw new CliError(`Build refused: ${err.detail}`);
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
220
290
|
return {
|
|
221
|
-
data:
|
|
222
|
-
|
|
223
|
-
|
|
291
|
+
data: done,
|
|
292
|
+
human: BUILD_KINDS.has(String(done.kind))
|
|
293
|
+
? `${green("Build uploaded")}: ${buildLabel(done)} ${dim(String(done.id))}`
|
|
294
|
+
: `${green("Uploaded")}: ${String(done.name)} ${dim(String(done.id))}`,
|
|
224
295
|
};
|
|
225
296
|
},
|
|
226
297
|
},
|
|
@@ -72,12 +72,17 @@ export const environmentCommands = [
|
|
|
72
72
|
},
|
|
73
73
|
{
|
|
74
74
|
name: "envs update",
|
|
75
|
-
summary: "Update an environment's name, URL, auth settings, Site Health screen sizes, schedule, CI and pages, or issue tracker settings",
|
|
75
|
+
summary: "Update an environment's name, URL, app ids, auth settings, Site Health screen sizes, schedule, CI and pages, or issue tracker settings",
|
|
76
76
|
scope: "project",
|
|
77
77
|
args: [{ name: "env-id", description: "Environment id", required: true }],
|
|
78
78
|
flags: [
|
|
79
79
|
{ name: "name", type: "string", description: "New name" },
|
|
80
80
|
{ name: "url", type: "string", description: "New root URL" },
|
|
81
|
+
{
|
|
82
|
+
name: "app-id",
|
|
83
|
+
type: "strings",
|
|
84
|
+
description: 'App id for an app surface, as surface=id (android=com.acme.app, ios=com.acme.App), repeatable: that surface\'s tests install the newest uploaded build of it; "ios=" clears it',
|
|
85
|
+
},
|
|
81
86
|
{ name: "auth", type: "string", enum: ["public", "gated"], description: "New auth choice" },
|
|
82
87
|
{
|
|
83
88
|
name: "viewport",
|
|
@@ -91,6 +96,7 @@ export const environmentCommands = [
|
|
|
91
96
|
],
|
|
92
97
|
examples: [
|
|
93
98
|
"beryl envs update <env-id> --tracker linear --tracker-team <team-id>",
|
|
99
|
+
"beryl envs update <env-id> --app-id android=com.acme.app --app-id ios=com.acme.App",
|
|
94
100
|
"beryl envs update <env-id> --tracker-threshold medium --tracker-max-tickets 5",
|
|
95
101
|
"beryl envs update <env-id> --tracker-priority high=urgent --tracker-pause on",
|
|
96
102
|
"beryl envs update <env-id> --health-schedule daily --health-time 06:00 --health-timezone Europe/Berlin",
|
|
@@ -104,6 +110,17 @@ export const environmentCommands = [
|
|
|
104
110
|
body.name = flagStr(input, "name");
|
|
105
111
|
if (flagStr(input, "url"))
|
|
106
112
|
body.root_url = flagStr(input, "url");
|
|
113
|
+
const appIds = flagStrings(input, "app-id");
|
|
114
|
+
if (appIds) {
|
|
115
|
+
const parsed = {};
|
|
116
|
+
for (const pair of appIds) {
|
|
117
|
+
const [surface, ...rest] = pair.split("=");
|
|
118
|
+
if (!surface || rest.length === 0)
|
|
119
|
+
throw new UsageError(`--app-id must be surface=id (android=com.acme.app), got ${pair}`);
|
|
120
|
+
parsed[surface] = rest.join("=");
|
|
121
|
+
}
|
|
122
|
+
body.app_ids = parsed;
|
|
123
|
+
}
|
|
107
124
|
if (flagStr(input, "auth"))
|
|
108
125
|
body.requires_auth_choice = flagStr(input, "auth");
|
|
109
126
|
const viewports = flagStrings(input, "viewport");
|
package/dist/commands/init.js
CHANGED
|
@@ -6,31 +6,22 @@ import { BERYL_COPILOT_MARK_BEGIN, BERYL_COPILOT_MARK_END, BERYL_COPILOT_SECTION
|
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
7
|
import { AuthError, CliError, UsageError } from "../errors.js";
|
|
8
8
|
import { ApiClient } from "../http.js";
|
|
9
|
-
import { bold, cyan, dim, green,
|
|
10
|
-
import { anyGap, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
|
|
9
|
+
import { bold, cyan, dim, green, yellow } from "../output.js";
|
|
11
10
|
import { aliasMachineToAccount, captureCliEvent, flushTelemetry, shutdownTelemetry, } from "../telemetry.js";
|
|
12
11
|
import { cliVersion, warnIfStale } from "../version-check.js";
|
|
13
12
|
import { authCommands } from "./auth.js";
|
|
14
13
|
import { flagStr } from "./util.js";
|
|
15
|
-
// Pinned to @latest
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
14
|
+
// Pinned to @latest: an unpinned spec lets npx serve whatever stale global/cache
|
|
15
|
+
// install already resolves, so the agent's MCP server would silently miss newer tools.
|
|
16
|
+
// mergeMcpConfig compares entries verbatim, so re-running init also upgrades an old
|
|
17
|
+
// unpinned entry in place.
|
|
19
18
|
const MCP_SERVER_ENTRY = {
|
|
20
19
|
command: "npx",
|
|
21
20
|
args: ["-y", "@beryl-so/cli@latest", "mcp"],
|
|
22
21
|
};
|
|
23
|
-
// Headless: the coding agent drives this browser to author tests — nobody watches the
|
|
24
|
-
// window, and a headed default breaks on CI / headless boxes. Matches how most devs
|
|
25
|
-
// already wire their own user-scoped playwright.
|
|
26
|
-
const PLAYWRIGHT_SERVER_ENTRY = {
|
|
27
|
-
command: "npx",
|
|
28
|
-
args: ["@playwright/mcp@latest", "--headless"],
|
|
29
|
-
};
|
|
30
22
|
// VS Code's `.vscode/mcp.json` uses a top-level `servers` key and tags each stdio server
|
|
31
|
-
// with `type`, unlike Cursor/Claude's `mcpServers`. Same pinned
|
|
23
|
+
// with `type`, unlike Cursor/Claude's `mcpServers`. Same pinned command, different shape.
|
|
32
24
|
const VSCODE_MCP_SERVER_ENTRY = { type: "stdio", ...MCP_SERVER_ENTRY };
|
|
33
|
-
const VSCODE_PLAYWRIGHT_SERVER_ENTRY = { type: "stdio", ...PLAYWRIGHT_SERVER_ENTRY };
|
|
34
25
|
// opencode flattens command + args into one array under `mcp`.
|
|
35
26
|
function opencodeEntry(entry) {
|
|
36
27
|
return { type: "local", command: [entry.command, ...entry.args], enabled: true };
|
|
@@ -45,14 +36,14 @@ function snippetEntry(name, entry) {
|
|
|
45
36
|
const MANUAL_CONFIG_SNIPPET = [
|
|
46
37
|
" {",
|
|
47
38
|
' "mcpServers": {',
|
|
48
|
-
|
|
49
|
-
snippetEntry("playwright", PLAYWRIGHT_SERVER_ENTRY),
|
|
39
|
+
snippetEntry("beryl", MCP_SERVER_ENTRY),
|
|
50
40
|
" }",
|
|
51
41
|
" }",
|
|
52
42
|
].join("\n");
|
|
53
43
|
// `key` is the container the editor expects: `mcpServers` for Claude/Cursor, `servers` for
|
|
54
|
-
// VS Code, `mcp` for opencode.
|
|
55
|
-
|
|
44
|
+
// VS Code, `mcp` for opencode. Only the beryl entry is ours; every other server (including
|
|
45
|
+
// a user-wired playwright) is left untouched.
|
|
46
|
+
function mergeMcpConfig(file, key, entry) {
|
|
56
47
|
let existing = {};
|
|
57
48
|
if (fs.existsSync(file)) {
|
|
58
49
|
try {
|
|
@@ -63,19 +54,14 @@ function mergeMcpConfig(file, key, entries) {
|
|
|
63
54
|
}
|
|
64
55
|
}
|
|
65
56
|
const servers = (existing[key] ?? {});
|
|
66
|
-
const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(
|
|
67
|
-
if (beryl)
|
|
68
|
-
servers.beryl =
|
|
69
|
-
// Never clobber a playwright server the user already wired up.
|
|
70
|
-
const playwright = servers.playwright === undefined;
|
|
71
|
-
if (playwright)
|
|
72
|
-
servers.playwright = entries.playwright;
|
|
73
|
-
if (beryl || playwright) {
|
|
57
|
+
const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(entry);
|
|
58
|
+
if (beryl) {
|
|
59
|
+
servers.beryl = entry;
|
|
74
60
|
existing[key] = servers;
|
|
75
61
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
76
62
|
fs.writeFileSync(file, JSON.stringify(existing, null, 2) + "\n");
|
|
77
63
|
}
|
|
78
|
-
return { beryl
|
|
64
|
+
return { beryl };
|
|
79
65
|
}
|
|
80
66
|
function claudeUserAdd(name, entry) {
|
|
81
67
|
try {
|
|
@@ -194,7 +180,7 @@ const outcomeLabel = (o) => o === "installed"
|
|
|
194
180
|
? "updated (refreshed to this CLI's version)"
|
|
195
181
|
: "already current";
|
|
196
182
|
const skillLeaf = (root) => path.join(root, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
|
|
197
|
-
// The
|
|
183
|
+
// The beryl-test skill follows the `--scope` flag, same as the MCP servers: under `user`
|
|
198
184
|
// (the default) it lands in the HOME `.agents/skills/` + `.claude/skills/` dirs, so the
|
|
199
185
|
// knowledge travels with the user into every session the user-scoped MCP tools do —
|
|
200
186
|
// otherwise an agent outside this repo has all the tools and none of the guide. Under
|
|
@@ -230,33 +216,6 @@ export function refreshInstalledSkills(cwd) {
|
|
|
230
216
|
}
|
|
231
217
|
return refreshed;
|
|
232
218
|
}
|
|
233
|
-
// Local authoring drives a real browser, and the whole authoring workflow (walk the flow
|
|
234
|
-
// first, then bank the plan) depends on it — so the install is mandatory, not offered:
|
|
235
|
-
// missing means install now, no prompt, no opt-out. The runner ships with the CLI; the
|
|
236
|
-
// browser is the per-machine half, fetched for the runner's own revision. Never throws — a
|
|
237
|
-
// failed install must not fail `init`, which has already done its wiring; it prints the
|
|
238
|
-
// exact commands instead.
|
|
239
|
-
async function ensureLocalPlaywright(ctx, cwd) {
|
|
240
|
-
const gaps = playwrightGaps(cwd);
|
|
241
|
-
if (!anyGap(gaps)) {
|
|
242
|
-
ctx.err(`${green("✓")} Playwright + Chromium already installed ${dim("(local runs ready)")}`);
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
try {
|
|
246
|
-
await installPlaywright(cwd, (line) => ctx.err(dim(line)));
|
|
247
|
-
// Believe the check, not the exit code — "local runs ready" is only honest if both
|
|
248
|
-
// halves are actually there afterwards.
|
|
249
|
-
const left = playwrightGaps(cwd);
|
|
250
|
-
if (anyGap(left))
|
|
251
|
-
throw new Error(`${describeGaps(left)} still missing afterwards`);
|
|
252
|
-
ctx.err(`${green("✓")} ${describeGaps(gaps)} installed ${dim("(local runs ready)")}`);
|
|
253
|
-
}
|
|
254
|
-
catch (err) {
|
|
255
|
-
ctx.err(`${red("✗")} Playwright install failed: ${err.message}`);
|
|
256
|
-
ctx.err(`${dim("•")} Finish the install by hand — local runs and browser authoring need it:\n` +
|
|
257
|
-
` ${cyan(installCommandsFor(gaps, cwd))}`);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
219
|
const CLIENTS = [
|
|
261
220
|
["claude", "Claude Code + Claude desktop app"],
|
|
262
221
|
["cursor", "Cursor"],
|
|
@@ -291,8 +250,8 @@ export const initCommands = [
|
|
|
291
250
|
{
|
|
292
251
|
name: "init",
|
|
293
252
|
summary: "Set up Beryl in this repo: sign in and wire up your coding agent",
|
|
294
|
-
description: "Signs you in, wires the beryl
|
|
295
|
-
"
|
|
253
|
+
description: "Signs you in, wires the beryl MCP server into the coding agent you pick, and writes " +
|
|
254
|
+
"the beryl-test skill. " +
|
|
296
255
|
"Pick an agent with --claude, --cursor, --vscode and/or --opencode. With no flag it " +
|
|
297
256
|
"prompts (Enter picks Claude); with no flag and no TTY it wires nothing and prints the " +
|
|
298
257
|
"flags. --claude also wires the Claude DESKTOP app when installed, which needs a quit " +
|
|
@@ -395,51 +354,35 @@ export const initCommands = [
|
|
|
395
354
|
if (clients.has("claude") && scope === "user") {
|
|
396
355
|
// Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
|
|
397
356
|
const beryl = claudeUserAdd("beryl", MCP_SERVER_ENTRY);
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
ctx.err(`${green("✓")} beryl + playwright MCP configured ${dim("(user scope)")}`);
|
|
357
|
+
if (beryl.ok) {
|
|
358
|
+
ctx.err(`${green("✓")} beryl MCP configured ${dim("(user scope)")}`);
|
|
401
359
|
}
|
|
402
360
|
else {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
: "• `claude` not on PATH; run these to wire user-scope MCP servers:"));
|
|
361
|
+
ctx.err(yellow(beryl.reason === "failed"
|
|
362
|
+
? `• \`claude mcp add\` failed (${beryl.detail}); run this to wire the user-scope MCP server:`
|
|
363
|
+
: "• `claude` not on PATH; run this to wire the user-scope MCP server:"));
|
|
407
364
|
ctx.err(` ${cyan(claudeAddHint("beryl", MCP_SERVER_ENTRY))}`);
|
|
408
|
-
ctx.err(` ${cyan(claudeAddHint("playwright", PLAYWRIGHT_SERVER_ENTRY))}`);
|
|
409
365
|
}
|
|
410
366
|
}
|
|
411
367
|
else if (clients.has("claude")) {
|
|
412
368
|
const file = path.join(cwd, ".mcp.json");
|
|
413
|
-
const wrote = mergeMcpConfig(file, "mcpServers",
|
|
414
|
-
|
|
415
|
-
playwright: PLAYWRIGHT_SERVER_ENTRY,
|
|
416
|
-
});
|
|
417
|
-
const where = path.relative(cwd, file);
|
|
418
|
-
report("beryl MCP", wrote.beryl, where);
|
|
419
|
-
report("playwright MCP", wrote.playwright, where);
|
|
369
|
+
const wrote = mergeMcpConfig(file, "mcpServers", MCP_SERVER_ENTRY);
|
|
370
|
+
report("beryl MCP", wrote.beryl, path.relative(cwd, file));
|
|
420
371
|
}
|
|
421
372
|
// Separate surface from Claude Code, read only at launch.
|
|
422
373
|
const desktopFile = clients.has("claude") && scope === "user" ? claudeDesktopConfigFile() : null;
|
|
423
374
|
if (desktopFile) {
|
|
424
|
-
const wrote = mergeMcpConfig(desktopFile, "mcpServers",
|
|
425
|
-
beryl: MCP_SERVER_ENTRY,
|
|
426
|
-
playwright: PLAYWRIGHT_SERVER_ENTRY,
|
|
427
|
-
});
|
|
375
|
+
const wrote = mergeMcpConfig(desktopFile, "mcpServers", MCP_SERVER_ENTRY);
|
|
428
376
|
report("Claude desktop beryl MCP", wrote.beryl, "(Claude desktop app)");
|
|
429
|
-
|
|
430
|
-
if (wrote.beryl || wrote.playwright) {
|
|
377
|
+
if (wrote.beryl) {
|
|
431
378
|
ctx.err(yellow("• Quit and reopen Claude desktop: it reads that config at launch."));
|
|
432
379
|
}
|
|
433
380
|
}
|
|
434
381
|
if (clients.has("cursor")) {
|
|
435
382
|
const file = scope === "user" ? cursorUserConfigPath() : path.join(cwd, ".cursor", "mcp.json");
|
|
436
|
-
const wrote = mergeMcpConfig(file, "mcpServers",
|
|
437
|
-
beryl: MCP_SERVER_ENTRY,
|
|
438
|
-
playwright: PLAYWRIGHT_SERVER_ENTRY,
|
|
439
|
-
});
|
|
383
|
+
const wrote = mergeMcpConfig(file, "mcpServers", MCP_SERVER_ENTRY);
|
|
440
384
|
const where = scope === "user" ? file : path.relative(cwd, file);
|
|
441
385
|
report("cursor beryl MCP", wrote.beryl, where);
|
|
442
|
-
report("cursor playwright MCP", wrote.playwright, where);
|
|
443
386
|
const rule = writeCursorRule(cwd);
|
|
444
387
|
ctx.err(`${green("✓")} cursor authoring rule ${outcomeLabel(rule.outcome)} ${dim(path.relative(cwd, rule.file))}`);
|
|
445
388
|
if (scope === "user") {
|
|
@@ -456,13 +399,9 @@ export const initCommands = [
|
|
|
456
399
|
const file = userDir
|
|
457
400
|
? path.join(userDir, "mcp.json")
|
|
458
401
|
: path.join(cwd, ".vscode", "mcp.json");
|
|
459
|
-
const wrote = mergeMcpConfig(file, "servers",
|
|
460
|
-
beryl: VSCODE_MCP_SERVER_ENTRY,
|
|
461
|
-
playwright: VSCODE_PLAYWRIGHT_SERVER_ENTRY,
|
|
462
|
-
});
|
|
402
|
+
const wrote = mergeMcpConfig(file, "servers", VSCODE_MCP_SERVER_ENTRY);
|
|
463
403
|
const where = userDir ? file : path.relative(cwd, file);
|
|
464
404
|
report("vscode beryl MCP", wrote.beryl, where);
|
|
465
|
-
report("vscode playwright MCP", wrote.playwright, where);
|
|
466
405
|
if (scope === "user" && !userDir) {
|
|
467
406
|
ctx.err(dim(" • VS Code's user config dir was not found, so this went to a repo-local " +
|
|
468
407
|
".vscode/mcp.json. Run --vscode from your project, or add the server via " +
|
|
@@ -480,19 +419,14 @@ export const initCommands = [
|
|
|
480
419
|
const { file, jsonc } = opencodeConfigFile(scope, cwd);
|
|
481
420
|
if (jsonc) {
|
|
482
421
|
ctx.err(yellow(`• ${file} is JSONC and may hold comments, so it was left alone.`));
|
|
483
|
-
ctx.err(cyan(` Add
|
|
422
|
+
ctx.err(cyan(` Add this under "mcp": ${JSON.stringify({
|
|
484
423
|
beryl: opencodeEntry(MCP_SERVER_ENTRY),
|
|
485
|
-
playwright: opencodeEntry(PLAYWRIGHT_SERVER_ENTRY),
|
|
486
424
|
})}`));
|
|
487
425
|
}
|
|
488
426
|
else {
|
|
489
|
-
const wrote = mergeMcpConfig(file, "mcp",
|
|
490
|
-
beryl: opencodeEntry(MCP_SERVER_ENTRY),
|
|
491
|
-
playwright: opencodeEntry(PLAYWRIGHT_SERVER_ENTRY),
|
|
492
|
-
});
|
|
427
|
+
const wrote = mergeMcpConfig(file, "mcp", opencodeEntry(MCP_SERVER_ENTRY));
|
|
493
428
|
const where = scope === "user" ? file : path.relative(cwd, file);
|
|
494
429
|
report("opencode beryl MCP", wrote.beryl, where);
|
|
495
|
-
report("opencode playwright MCP", wrote.playwright, where);
|
|
496
430
|
}
|
|
497
431
|
}
|
|
498
432
|
const skills = writeSkills(cwd, scope);
|
|
@@ -507,9 +441,8 @@ export const initCommands = [
|
|
|
507
441
|
const where = skill.outcome === "updated"
|
|
508
442
|
? `${skillWhere} (refreshed to this CLI's version)`
|
|
509
443
|
: skillWhere;
|
|
510
|
-
ctx.err(`${green("✓")}
|
|
444
|
+
ctx.err(`${green("✓")} beryl-test skill ${what} ${dim(where)}`);
|
|
511
445
|
}
|
|
512
|
-
await ensureLocalPlaywright(ctx, cwd);
|
|
513
446
|
// Name the agent actually wired, not the raw flag: the picker sets `clients` too.
|
|
514
447
|
const ed = clients.has("claude")
|
|
515
448
|
? { open: "open your editor", agent: "Claude" }
|
|
@@ -519,7 +452,7 @@ export const initCommands = [
|
|
|
519
452
|
? { open: "open VS Code", agent: "Copilot" }
|
|
520
453
|
: { open: "open opencode", agent: "opencode" };
|
|
521
454
|
const nextSteps = clients.size === 0
|
|
522
|
-
? `\n${bold("Beryl is signed in and the
|
|
455
|
+
? `\n${bold("Beryl is signed in and the beryl-test skill is installed.")}\n` +
|
|
523
456
|
` ${dim("• No MCP server is wired yet, so no agent can reach Beryl.")}\n` +
|
|
524
457
|
` ${dim(`• Using ${CLIENT_LABEL_LIST}? Re-run and pick it:`)}\n` +
|
|
525
458
|
` ${cyan("npx @beryl-so/cli@latest init")}\n` +
|
|
@@ -527,10 +460,10 @@ export const initCommands = [
|
|
|
527
460
|
`${cyan(MANUAL_CONFIG_SNIPPET)}\n` +
|
|
528
461
|
` ${dim("Some clients name that key `servers` or `mcp`.")}\n` +
|
|
529
462
|
` ${yellow(`Details: ${MANUAL_SETUP_DOCS}`)}\n`
|
|
530
|
-
: `\n${bold(`Beryl is set up. Now ${ed.open} and ask ${ed.agent} to
|
|
531
|
-
` ${dim(`• Say: "
|
|
532
|
-
` ${dim("
|
|
533
|
-
` ${dim(`•
|
|
463
|
+
: `\n${bold(`Beryl is set up. Now ${ed.open} and ask ${ed.agent} to create tests.`)}\n` +
|
|
464
|
+
` ${dim(`• Say: "create tests for https://example.com from docs/checkout.md" and ${ed.agent} starts`)}\n` +
|
|
465
|
+
` ${dim(" a cloud test-creation job (jobs create) that authors them from that document.")}\n` +
|
|
466
|
+
` ${dim(`• Then ask ${ed.agent} to run them in Beryl's cloud (runs trigger).`)}\n`;
|
|
534
467
|
// The .mcp.json entry is pinned to @latest, but a global install / old npx cache
|
|
535
468
|
// still wins resolution — so tell the user when the CLI they just ran is stale.
|
|
536
469
|
await warnIfStale(cliVersion(), (msg) => ctx.err(yellow(msg)));
|
|
@@ -549,11 +482,12 @@ export const initCommands = [
|
|
|
549
482
|
},
|
|
550
483
|
{
|
|
551
484
|
name: "guide",
|
|
552
|
-
summary: "Print the Beryl test
|
|
553
|
-
description: "The
|
|
554
|
-
"assertions, natural-language intent, test accounts and the
|
|
555
|
-
"({{login_email}}, {{mailbox_address}}, {{inbox_address}} +
|
|
556
|
-
"
|
|
485
|
+
summary: "Print the Beryl test guide",
|
|
486
|
+
description: "The guide to durable, healable tests: where new tests come from (`jobs create`), the " +
|
|
487
|
+
"ActionPlan shape, outcome assertions, natural-language intent, test accounts and the " +
|
|
488
|
+
"project mailbox ({{login_email}}, {{mailbox_address}}, {{inbox_address}} + " +
|
|
489
|
+
"await_email), and the run-fix loop. Same content as the beryl-test skill " +
|
|
490
|
+
"`beryl init` installs. " +
|
|
557
491
|
`Skip if a loaded beryl-test skill states v${cliVersion()}; else call this first ` +
|
|
558
492
|
"(works without logging in).",
|
|
559
493
|
examples: ["beryl guide"],
|