@beryl-so/cli 0.27.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -2
- package/dist/adapters/cli.js +2 -1
- package/dist/adapters/mcp.js +66 -3
- package/dist/beryl-test-skill.js +3 -0
- package/dist/commands/environments.js +38 -6
- package/dist/commands/groups.js +69 -0
- package/dist/commands/health.js +43 -0
- package/dist/commands/runs.js +73 -13
- package/dist/commands/tests.js +85 -5
- package/dist/commands/util.js +20 -0
- package/dist/commands/watch.js +2 -1
- package/dist/http.js +6 -1
- package/dist/registry/index.js +4 -0
- package/dist/telemetry.js +272 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -191,13 +191,14 @@ Manage a project's environments — the URLs and auth Beryl runs tests against.
|
|
|
191
191
|
|
|
192
192
|
### schedule
|
|
193
193
|
|
|
194
|
-
View and set the schedule on which Beryl runs a project's tests automatically.
|
|
194
|
+
View and set the schedule on which Beryl runs a project's tests automatically — the whole project, or one group with --group.
|
|
195
195
|
|
|
196
196
|
| Command | Summary | MCP tool |
|
|
197
197
|
| --- | --- | --- |
|
|
198
|
-
| `beryl schedule get` | Show the project's
|
|
198
|
+
| `beryl schedule get` | Show the project's run schedule and any per-group schedules | `schedule_get` |
|
|
199
199
|
| `beryl schedule set` | Enable scheduled runs (daily, or weekly on a given day) | `schedule_set` |
|
|
200
200
|
| `beryl schedule disable` | Turn scheduled runs off | `schedule_disable` |
|
|
201
|
+
| `beryl schedule remove` | Remove a group's schedule entirely (the group itself stays) | `schedule_remove` |
|
|
201
202
|
|
|
202
203
|
### tests
|
|
203
204
|
|
|
@@ -214,6 +215,7 @@ Author, inspect, version, and heal a project's tests — the checks Beryl runs o
|
|
|
214
215
|
| `beryl tests create` | Create a test case from a JSON action plan — for tests authored locally, e.g. by your coding agent | `tests_create` |
|
|
215
216
|
| `beryl tests set-plan <test-id>` | Replace a test's step plan from a JSON file (creates a new version) | `tests_set_plan` |
|
|
216
217
|
| `beryl tests rename <test-id> <title>` | Rename a test | `tests_rename` |
|
|
218
|
+
| `beryl tests set-groups <test-id>` | Replace the groups a test belongs to | `tests_set_groups` |
|
|
217
219
|
| `beryl tests quarantine <test-id> <state>` | Mute a flaky test: it keeps running, but its failures stop failing the run | `tests_quarantine` |
|
|
218
220
|
| `beryl tests delete <test-id>` | Delete a test, its version history, and its results | `tests_delete` |
|
|
219
221
|
| `beryl tests recompile <test-id>` | Validate + verify an edited plan against the live site before persisting | `tests_recompile` |
|
|
@@ -227,6 +229,19 @@ Author, inspect, version, and heal a project's tests — the checks Beryl runs o
|
|
|
227
229
|
| `beryl tests script [test-id]` | Print the rendered Playwright spec for a test (or an unbanked plan file) | `tests_script` |
|
|
228
230
|
| `beryl tests export <test-ids...>` | Export tests as Playwright .spec.ts files in a ZIP | `tests_export` |
|
|
229
231
|
|
|
232
|
+
### groups
|
|
233
|
+
|
|
234
|
+
Manage a project's test groups — labels a test can carry any number of, used to filter, run, or schedule a slice of the suite. Groups are created only here (or in Settings → Groups); assigning a test to an unknown name is an error.
|
|
235
|
+
|
|
236
|
+
`beryl groups` with no subcommand runs `groups list`.
|
|
237
|
+
|
|
238
|
+
| Command | Summary | MCP tool |
|
|
239
|
+
| --- | --- | --- |
|
|
240
|
+
| `beryl groups list` | List the project's test groups and how many tests each holds | `groups_list` |
|
|
241
|
+
| `beryl groups create <name>` | Create a group | `groups_create` |
|
|
242
|
+
| `beryl groups rename <group> <name>` | Rename a group (tests keep their membership) | `groups_rename` |
|
|
243
|
+
| `beryl groups delete <group>` | Delete a group — its tests stay, its schedule (if any) goes with it | `groups_delete` |
|
|
244
|
+
|
|
230
245
|
### runs
|
|
231
246
|
|
|
232
247
|
Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and download results.
|
|
@@ -245,6 +260,17 @@ Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and downlo
|
|
|
245
260
|
| `beryl runs download <run-id>` | Download a run's results, with its artifacts, to disk | `runs_download` |
|
|
246
261
|
| `beryl runs explain <result-id>` | Explain, with AI, why a test result failed | `runs_explain` |
|
|
247
262
|
|
|
263
|
+
### health
|
|
264
|
+
|
|
265
|
+
Site Health — how your site reads to search engines and visitors: content, speed, mobile, links and security, graded from a real check of your live pages.
|
|
266
|
+
|
|
267
|
+
`beryl health` with no subcommand runs `health get`.
|
|
268
|
+
|
|
269
|
+
| Command | Summary | MCP tool |
|
|
270
|
+
| --- | --- | --- |
|
|
271
|
+
| `beryl health get` | Show the latest Site Health report for a project environment | `health_get` |
|
|
272
|
+
| `beryl health run` | Run a fresh Site Health check for a project environment | `health_run` |
|
|
273
|
+
|
|
248
274
|
### explorations
|
|
249
275
|
|
|
250
276
|
Inspect the agent's exploration runs — how it crawled a site and authored its tests.
|
package/dist/adapters/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { CliError, EXIT_OK, EXIT_USAGE, UsageError } from "../errors.js";
|
|
|
4
4
|
import { ApiClient } from "../http.js";
|
|
5
5
|
import { autoFormat, bold, cyan, dim } from "../output.js";
|
|
6
6
|
import { commandGroups, findCommand, groupSummary } from "../registry/index.js";
|
|
7
|
+
import { withTool } from "../telemetry.js";
|
|
7
8
|
import { cliVersion } from "../version-check.js";
|
|
8
9
|
import { mcpToolFor } from "./mcp.js";
|
|
9
10
|
export const GLOBAL_FLAGS = [
|
|
@@ -324,7 +325,7 @@ export async function runCli(argv) {
|
|
|
324
325
|
const client = new ApiClient(config.apiUrl, config.token);
|
|
325
326
|
const ctx = createContext({ client, config, json: parsed.json, mcp: false });
|
|
326
327
|
try {
|
|
327
|
-
const result = (await spec.run(ctx, parsed.input)) ?? {};
|
|
328
|
+
const result = (await withTool(spec.name, () => spec.run(ctx, parsed.input))) ?? {};
|
|
328
329
|
if (parsed.json) {
|
|
329
330
|
if (result.data !== undefined)
|
|
330
331
|
process.stdout.write(JSON.stringify(result.data) + "\n");
|
package/dist/adapters/mcp.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { instrument } from "@posthog/mcp";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import { loadConfig } from "../config.js";
|
|
6
7
|
import { createContext } from "../context.js";
|
|
7
|
-
import { CliError } from "../errors.js";
|
|
8
|
-
import { ApiClient } from "../http.js";
|
|
8
|
+
import { AuthError, CliError } from "../errors.js";
|
|
9
|
+
import { ApiClient, ApiError } from "../http.js";
|
|
9
10
|
import { parseArgv } from "./cli.js";
|
|
10
11
|
import { commands } from "../registry/index.js";
|
|
12
|
+
import { analyticsOptions, createPostHogClient, setClient, setSurface, shutdownTelemetry, withTool, } from "../telemetry.js";
|
|
11
13
|
import { cliVersion, warnIfStale } from "../version-check.js";
|
|
12
14
|
export function toolName(spec) {
|
|
13
15
|
return spec.name.replace(/ /g, "_").replace(/-/g, "_");
|
|
@@ -190,6 +192,48 @@ export function currentAuth(fallback) {
|
|
|
190
192
|
}
|
|
191
193
|
export function __resetAuthCacheForTests() {
|
|
192
194
|
authCache = undefined;
|
|
195
|
+
identifyCache = undefined;
|
|
196
|
+
}
|
|
197
|
+
let identifyCache;
|
|
198
|
+
/** Resolves the signed-in account once per token so MCP events land on the same PostHog
|
|
199
|
+
* person as that user's webapp sessions. Anonymous (null) when unauthenticated: the
|
|
200
|
+
* session is still counted, just not attributed. */
|
|
201
|
+
export function identifyUser(baseCtx) {
|
|
202
|
+
return async () => {
|
|
203
|
+
const auth = currentAuth(baseCtx);
|
|
204
|
+
const cached = identifyCache;
|
|
205
|
+
if (cached && cached.token === auth.config.token)
|
|
206
|
+
return cached.identity;
|
|
207
|
+
let identity = null;
|
|
208
|
+
if (auth.config.token) {
|
|
209
|
+
try {
|
|
210
|
+
const me = (await auth.client.get("/account/"));
|
|
211
|
+
if (me?.id) {
|
|
212
|
+
identity = {
|
|
213
|
+
distinctId: me.id,
|
|
214
|
+
// Internal dogfooding is stamped rather than dropped: unlike the webapp we
|
|
215
|
+
// want our own MCP sessions visible when debugging, so dashboards exclude
|
|
216
|
+
// them by cohort instead.
|
|
217
|
+
properties: {
|
|
218
|
+
email: me.email,
|
|
219
|
+
name: me.name,
|
|
220
|
+
is_internal: Boolean(me.is_vibemonitor),
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
// A rejected token is a settled answer worth caching; a network blip or a 5xx is
|
|
227
|
+
// not. This server outlives the blip, so caching one would leave every later tool
|
|
228
|
+
// call in the session anonymous with no way back.
|
|
229
|
+
const settled = err instanceof AuthError || (err instanceof ApiError && err.status < 500);
|
|
230
|
+
if (!settled)
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
identifyCache = { token: auth.config.token, identity };
|
|
235
|
+
return identity;
|
|
236
|
+
};
|
|
193
237
|
}
|
|
194
238
|
// The running version is stated up front because this server is long-lived and never
|
|
195
239
|
// hot-reloads: a session can sit on a days-old build while `@latest` has moved, and
|
|
@@ -210,6 +254,7 @@ export function mcpInstructions() {
|
|
|
210
254
|
"call `guide` before authoring your first test plan.");
|
|
211
255
|
}
|
|
212
256
|
export async function serveMcp(baseCtx) {
|
|
257
|
+
setSurface("mcp");
|
|
213
258
|
// Fire-and-forget staleness warning: a stale MCP server silently exposes fewer
|
|
214
259
|
// tools, and stderr is the one channel a stdio MCP server can safely log to.
|
|
215
260
|
void warnIfStale(cliVersion(), (msg) => console.error(msg));
|
|
@@ -250,7 +295,8 @@ export async function serveMcp(baseCtx) {
|
|
|
250
295
|
err: push,
|
|
251
296
|
});
|
|
252
297
|
try {
|
|
253
|
-
const
|
|
298
|
+
const input = toInput(spec, request.params.arguments ?? {});
|
|
299
|
+
const result = (await withTool(request.params.name, () => spec.run(ctx, input))) ?? {};
|
|
254
300
|
return toolResult(result, lines);
|
|
255
301
|
}
|
|
256
302
|
catch (err) {
|
|
@@ -261,8 +307,25 @@ export async function serveMcp(baseCtx) {
|
|
|
261
307
|
};
|
|
262
308
|
}
|
|
263
309
|
});
|
|
310
|
+
server.oninitialized = () => setClient(server.getClientVersion());
|
|
311
|
+
instrument(server, createPostHogClient(), analyticsOptions(baseCtx.config.apiUrl, identifyUser(baseCtx)));
|
|
312
|
+
// A coding agent ends an MCP server either by closing the pipe or by signalling it, and
|
|
313
|
+
// only the first path reaches onclose — without these the last batch of events dies with
|
|
314
|
+
// the process. Bounded inside shutdownTelemetry, so a wedged flush can't hold up exit.
|
|
315
|
+
const flushAndExit = (signal) => {
|
|
316
|
+
void shutdownTelemetry().then(() => {
|
|
317
|
+
process.kill(process.pid, signal);
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
321
|
+
process.once(signal, () => {
|
|
322
|
+
process.removeAllListeners(signal);
|
|
323
|
+
flushAndExit(signal);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
264
326
|
await server.connect(new StdioServerTransport());
|
|
265
327
|
await new Promise((resolve) => {
|
|
266
328
|
server.onclose = resolve;
|
|
267
329
|
});
|
|
330
|
+
await shutdownTelemetry();
|
|
268
331
|
}
|
package/dist/beryl-test-skill.js
CHANGED
|
@@ -289,6 +289,9 @@ ${JSON.stringify(BERYL_TEST_SKILL_EXAMPLE_PLAN, null, 2)
|
|
|
289
289
|
description. Inspect the exact spec with \`beryl tests script --file plan.json\`.
|
|
290
290
|
Full ActionPlan JSON Schema:
|
|
291
291
|
https://api.beryl.so/api/v1/schemas/action-plan.schema.json.
|
|
292
|
+
Groups are optional and never block: after the test is banked, if \`beryl groups list\`
|
|
293
|
+
shows any, offer them once (\`tests set-groups <id> --group <name>\`); no answer = leave
|
|
294
|
+
it ungrouped and move on. Never create a group yourself — that's the user's call.
|
|
292
295
|
|
|
293
296
|
### The outcome assertion is the whole game
|
|
294
297
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { UsageError } from "../errors.js";
|
|
2
|
-
import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
2
|
+
import { arg, findGroup, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
3
|
+
const SCHEDULE_GROUP_FLAG = "Schedule one group (by name or id) instead of the whole project; the run covers the group's active tests at fire time";
|
|
3
4
|
export const environmentCommands = [
|
|
4
5
|
{
|
|
5
6
|
name: "envs list",
|
|
@@ -92,9 +93,9 @@ export const environmentCommands = [
|
|
|
92
93
|
},
|
|
93
94
|
{
|
|
94
95
|
name: "schedule get",
|
|
95
|
-
summary: "Show the project's
|
|
96
|
+
summary: "Show the project's run schedule and any per-group schedules",
|
|
96
97
|
scope: "project",
|
|
97
|
-
groupSummary: "View and set the schedule on which Beryl runs a project's tests automatically.",
|
|
98
|
+
groupSummary: "View and set the schedule on which Beryl runs a project's tests automatically — the whole project, or one group with --group.",
|
|
98
99
|
async run(ctx, input) {
|
|
99
100
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
100
101
|
return { data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/schedule`) };
|
|
@@ -115,12 +116,16 @@ export const environmentCommands = [
|
|
|
115
116
|
{ name: "hour", type: "number", description: "Hour of day 0-23" },
|
|
116
117
|
{ name: "minute", type: "number", description: "Minute 0-59" },
|
|
117
118
|
{ name: "tz", type: "string", description: "IANA timezone (e.g. America/Los_Angeles)" },
|
|
119
|
+
{ name: "group", type: "string", description: SCHEDULE_GROUP_FLAG },
|
|
120
|
+
],
|
|
121
|
+
examples: [
|
|
122
|
+
"beryl schedule set --frequency daily --hour 6 --tz UTC",
|
|
123
|
+
"beryl schedule set --group Smoke --frequency weekly --day 0 --hour 9 --tz UTC",
|
|
118
124
|
],
|
|
119
|
-
examples: ["beryl schedule set --frequency daily --hour 6 --tz UTC"],
|
|
120
125
|
async run(ctx, input) {
|
|
121
126
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
122
127
|
return {
|
|
123
|
-
data: await ctx.client.put(
|
|
128
|
+
data: await ctx.client.put(await schedulePath(ctx, input, workspaceId, projectId), {
|
|
124
129
|
enabled: true,
|
|
125
130
|
frequency: flagStr(input, "frequency") ?? "daily",
|
|
126
131
|
day_of_week: flagNum(input, "day") ?? null,
|
|
@@ -135,13 +140,40 @@ export const environmentCommands = [
|
|
|
135
140
|
name: "schedule disable",
|
|
136
141
|
summary: "Turn scheduled runs off",
|
|
137
142
|
scope: "project",
|
|
143
|
+
flags: [{ name: "group", type: "string", description: SCHEDULE_GROUP_FLAG }],
|
|
138
144
|
async run(ctx, input) {
|
|
139
145
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
140
146
|
return {
|
|
141
|
-
data: await ctx.client.put(
|
|
147
|
+
data: await ctx.client.put(await schedulePath(ctx, input, workspaceId, projectId), {
|
|
142
148
|
enabled: false,
|
|
143
149
|
}),
|
|
144
150
|
};
|
|
145
151
|
},
|
|
146
152
|
},
|
|
153
|
+
{
|
|
154
|
+
name: "schedule remove",
|
|
155
|
+
summary: "Remove a group's schedule entirely (the group itself stays)",
|
|
156
|
+
scope: "project",
|
|
157
|
+
flags: [{ name: "group", type: "string", description: "Group name or id", required: true }],
|
|
158
|
+
async run(ctx, input) {
|
|
159
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
160
|
+
if (!flagStr(input, "group"))
|
|
161
|
+
throw new UsageError("--group is required");
|
|
162
|
+
await ctx.client.del(await schedulePath(ctx, input, workspaceId, projectId));
|
|
163
|
+
return { human: "Removed." };
|
|
164
|
+
},
|
|
165
|
+
},
|
|
147
166
|
];
|
|
167
|
+
async function schedulePath(ctx, input, workspaceId, projectId) {
|
|
168
|
+
const base = `${projectPath(workspaceId, projectId)}/schedule`;
|
|
169
|
+
const ref = flagStr(input, "group");
|
|
170
|
+
if (!ref)
|
|
171
|
+
return base;
|
|
172
|
+
const groups = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/groups`));
|
|
173
|
+
const group = findGroup(groups, ref);
|
|
174
|
+
if (!group) {
|
|
175
|
+
const names = groups.map((g) => g.name).join(", ") || "none yet";
|
|
176
|
+
throw new UsageError(`No group '${ref}' in this project (existing: ${names}).`);
|
|
177
|
+
}
|
|
178
|
+
return `${projectPath(workspaceId, projectId)}/groups/${group.id}/schedule`;
|
|
179
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { UsageError } from "../errors.js";
|
|
2
|
+
import { arg, findGroup, flagBool, projectPath } from "./util.js";
|
|
3
|
+
async function resolveGroup(ctx, workspaceId, projectId, ref) {
|
|
4
|
+
const groups = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/groups`));
|
|
5
|
+
const match = findGroup(groups, ref);
|
|
6
|
+
if (!match) {
|
|
7
|
+
const names = groups.map((g) => g.name).join(", ") || "none yet";
|
|
8
|
+
throw new UsageError(`No group '${ref}' in this project (existing: ${names}).`);
|
|
9
|
+
}
|
|
10
|
+
return match;
|
|
11
|
+
}
|
|
12
|
+
export const groupCommands = [
|
|
13
|
+
{
|
|
14
|
+
name: "groups list",
|
|
15
|
+
summary: "List the project's test groups and how many tests each holds",
|
|
16
|
+
scope: "project",
|
|
17
|
+
groupDefault: true,
|
|
18
|
+
groupSummary: "Manage a project's test groups — labels a test can carry any number of, used to filter, run, or schedule a slice of the suite. Groups are created only here (or in Settings → Groups); assigning a test to an unknown name is an error.",
|
|
19
|
+
async run(ctx, input) {
|
|
20
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
21
|
+
return { data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/groups`) };
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "groups create",
|
|
26
|
+
summary: "Create a group",
|
|
27
|
+
scope: "project",
|
|
28
|
+
args: [{ name: "name", description: "Group name (unique per project)", required: true }],
|
|
29
|
+
examples: ["beryl groups create Smoke"],
|
|
30
|
+
async run(ctx, input) {
|
|
31
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
32
|
+
return {
|
|
33
|
+
data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/groups`, {
|
|
34
|
+
name: arg(input, "name"),
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "groups rename",
|
|
41
|
+
summary: "Rename a group (tests keep their membership)",
|
|
42
|
+
scope: "project",
|
|
43
|
+
args: [
|
|
44
|
+
{ name: "group", description: "Current group name or id", required: true },
|
|
45
|
+
{ name: "name", description: "New name", required: true },
|
|
46
|
+
],
|
|
47
|
+
async run(ctx, input) {
|
|
48
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
49
|
+
const group = await resolveGroup(ctx, workspaceId, projectId, arg(input, "group"));
|
|
50
|
+
return {
|
|
51
|
+
data: await ctx.client.patch(`${projectPath(workspaceId, projectId)}/groups/${group.id}`, { name: arg(input, "name") }),
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "groups delete",
|
|
57
|
+
summary: "Delete a group — its tests stay, its schedule (if any) goes with it",
|
|
58
|
+
scope: "project",
|
|
59
|
+
args: [{ name: "group", description: "Group name or id", required: true }],
|
|
60
|
+
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
61
|
+
async run(ctx, input) {
|
|
62
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
63
|
+
const group = await resolveGroup(ctx, workspaceId, projectId, arg(input, "group"));
|
|
64
|
+
await ctx.confirm(`Delete group '${group.name}' (${group.test_count ?? 0} tests leave it)?`, flagBool(input, "force"));
|
|
65
|
+
await ctx.client.del(`${projectPath(workspaceId, projectId)}/groups/${group.id}`);
|
|
66
|
+
return { human: "Deleted." };
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { flagStr, projectPath } from "./util.js";
|
|
2
|
+
const ENV_FLAG = {
|
|
3
|
+
name: "env",
|
|
4
|
+
type: "string",
|
|
5
|
+
description: "Environment id (defaults to the project's default environment)",
|
|
6
|
+
};
|
|
7
|
+
export const healthCommands = [
|
|
8
|
+
{
|
|
9
|
+
name: "health get",
|
|
10
|
+
groupDefault: true,
|
|
11
|
+
groupSummary: "Site Health — how your site reads to search engines and visitors: content, " +
|
|
12
|
+
"speed, mobile, links and security, graded from a real check of your live pages.",
|
|
13
|
+
summary: "Show the latest Site Health report for a project environment",
|
|
14
|
+
description: "A check runs automatically when a project or environment gets its URL. While one " +
|
|
15
|
+
"is in flight the report comes back with status queued/running and no grades yet; " +
|
|
16
|
+
"call again to pick up the finished result.",
|
|
17
|
+
scope: "project",
|
|
18
|
+
flags: [ENV_FLAG],
|
|
19
|
+
async run(ctx, input) {
|
|
20
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
21
|
+
return {
|
|
22
|
+
data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/seo-report`, {
|
|
23
|
+
environment_id: flagStr(input, "env"),
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "health run",
|
|
30
|
+
summary: "Run a fresh Site Health check for a project environment",
|
|
31
|
+
description: "Queues a new check and returns immediately. Returns the in-flight report instead " +
|
|
32
|
+
"of stacking a second one when a check is already running.",
|
|
33
|
+
scope: "project",
|
|
34
|
+
flags: [ENV_FLAG],
|
|
35
|
+
examples: ["beryl health run", "beryl health run --env 4f…"],
|
|
36
|
+
async run(ctx, input) {
|
|
37
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
38
|
+
return {
|
|
39
|
+
data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/seo-report`, undefined, { environment_id: flagStr(input, "env") }),
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
];
|
package/dist/commands/runs.js
CHANGED
|
@@ -7,7 +7,7 @@ import { countPlannedFrames, countWrittenFrames, PlaywrightMissingError, } from
|
|
|
7
7
|
import { dim, green, red, yellow } from "../output.js";
|
|
8
8
|
import { anyGap, confirmInstall, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
|
|
9
9
|
import { ProgressBar } from "../progress.js";
|
|
10
|
-
import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
10
|
+
import { arg, flagBool, flagNum, flagStr, idsInGroup, projectPath } from "./util.js";
|
|
11
11
|
import { watchRun } from "./watch.js";
|
|
12
12
|
const MAX_FAILURE_SCREENSHOTS = 5;
|
|
13
13
|
// One filesystem check before a single spec is fetched. A missing browser binary otherwise
|
|
@@ -50,10 +50,19 @@ export const runCommands = [
|
|
|
50
50
|
name: "runs trigger",
|
|
51
51
|
summary: "Trigger a test run (whole suite, a subset, or one environment)",
|
|
52
52
|
description: "Runs execute in Beryl's cloud. With --watch the CLI streams live progress and " +
|
|
53
|
-
"exits 0 only if every test passed — wire it straight into CI."
|
|
53
|
+
"exits 0 only if every test passed — wire it straight into CI. A run with a " +
|
|
54
|
+
"heal-eligible failure completes only after Beryl has tried to heal it: a repaired " +
|
|
55
|
+
"test is re-run inside the same run and counts as passed (reported as `healed`), so " +
|
|
56
|
+
"the final counts, the report and the completion email all reflect the repair.",
|
|
54
57
|
scope: "project",
|
|
55
58
|
flags: [
|
|
56
59
|
{ name: "test", type: "strings", description: "Run only these test ids (repeatable)" },
|
|
60
|
+
{
|
|
61
|
+
name: "group",
|
|
62
|
+
type: "string",
|
|
63
|
+
description: "Run only the active tests in this group (by name) — resolved to ids before the run, " +
|
|
64
|
+
"so the run records exactly what it ran. Cannot be combined with --test.",
|
|
65
|
+
},
|
|
57
66
|
{ name: "env", type: "string", description: "Environment id to run against" },
|
|
58
67
|
{ name: "url-override", type: "string", description: "Replace the base URL (preview deploys)" },
|
|
59
68
|
{
|
|
@@ -75,11 +84,22 @@ export const runCommands = [
|
|
|
75
84
|
"beryl runs trigger --url-override https://preview-123.example.com --watch --timeout 30",
|
|
76
85
|
"beryl runs trigger --url-override https://preview-123.example.com --header x-vercel-protection-bypass=<token> --watch",
|
|
77
86
|
"beryl runs trigger --test 4f… --test 9a…",
|
|
87
|
+
"beryl runs trigger --group Smoke --watch",
|
|
78
88
|
"beryl runs trigger --retries 0 --watch",
|
|
79
89
|
],
|
|
80
90
|
async run(ctx, input) {
|
|
81
91
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
82
|
-
|
|
92
|
+
let tests = input.flags.test;
|
|
93
|
+
const group = flagStr(input, "group");
|
|
94
|
+
if (group && tests && tests.length > 0)
|
|
95
|
+
throw new UsageError("--group cannot be combined with --test");
|
|
96
|
+
if (group) {
|
|
97
|
+
const listed = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`));
|
|
98
|
+
tests = idsInGroup(listed, group);
|
|
99
|
+
// Exiting 0 here would be a green CI run that tested nothing.
|
|
100
|
+
if (tests.length === 0)
|
|
101
|
+
throw new UsageError(`No active tests in group '${group}'.`);
|
|
102
|
+
}
|
|
83
103
|
const extraHeaders = parseHeaders(input.flags.header);
|
|
84
104
|
const created = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/runs`, {
|
|
85
105
|
test_case_ids: tests && tests.length > 0 ? tests : null,
|
|
@@ -131,6 +151,11 @@ export const runCommands = [
|
|
|
131
151
|
type: "boolean",
|
|
132
152
|
description: "Run every active test in the project (the default when no ids are given)",
|
|
133
153
|
},
|
|
154
|
+
{
|
|
155
|
+
name: "group",
|
|
156
|
+
type: "string",
|
|
157
|
+
description: "Run only the active tests in this group (by name). Cannot be combined with test ids.",
|
|
158
|
+
},
|
|
134
159
|
{
|
|
135
160
|
name: "url-override",
|
|
136
161
|
type: "string",
|
|
@@ -162,15 +187,23 @@ export const runCommands = [
|
|
|
162
187
|
"beryl runs local 4f… --no-sync --dir ./beryl-local",
|
|
163
188
|
],
|
|
164
189
|
async run(ctx, input) {
|
|
165
|
-
|
|
166
|
-
|
|
190
|
+
// Argument validation runs BEFORE the Chromium/Playwright precondition: a bad flag
|
|
191
|
+
// combination should say so on any machine, not report a missing browser.
|
|
167
192
|
// Deduped: a repeated id would put the same test twice in one imported run,
|
|
168
193
|
// which the import manifest rejects.
|
|
169
194
|
const explicitIds = [...new Set(input.args["test-ids"] ?? [])];
|
|
170
|
-
|
|
171
|
-
|
|
195
|
+
const group = flagStr(input, "group");
|
|
196
|
+
if (group && explicitIds.length > 0)
|
|
197
|
+
throw new UsageError("--group cannot be combined with explicit test ids");
|
|
172
198
|
if (explicitIds.length > 0 && flagBool(input, "all"))
|
|
173
199
|
throw new UsageError("--all cannot be combined with explicit test ids");
|
|
200
|
+
if (group && flagBool(input, "all"))
|
|
201
|
+
throw new UsageError("--all cannot be combined with --group");
|
|
202
|
+
// No ids and no group means the whole suite — `beryl runs local` alone is a
|
|
203
|
+
// complete local run.
|
|
204
|
+
const all = explicitIds.length === 0 && !group;
|
|
205
|
+
await ensureRunnableLocally(ctx);
|
|
206
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
174
207
|
const sync = input.flags.sync !== false;
|
|
175
208
|
const urlOverride = flagStr(input, "url-override");
|
|
176
209
|
const dir = flagStr(input, "dir");
|
|
@@ -180,9 +213,15 @@ export const runCommands = [
|
|
|
180
213
|
// The list rows carry the authored name as nl_title (title is the customer's
|
|
181
214
|
// rename, usually unset) — fall through so the terminal shows names, not ids.
|
|
182
215
|
const titles = new Map(listed.map((t) => [t.id, t.title || t.nl_title || t.id.slice(0, 8)]));
|
|
183
|
-
const ids = all
|
|
216
|
+
const ids = all
|
|
217
|
+
? listed.filter((t) => t.is_active !== false).map((t) => t.id)
|
|
218
|
+
: group
|
|
219
|
+
? idsInGroup(listed, group)
|
|
220
|
+
: explicitIds;
|
|
184
221
|
if (ids.length === 0)
|
|
185
|
-
throw
|
|
222
|
+
throw group
|
|
223
|
+
? new UsageError(`No active tests in group '${group}'.`)
|
|
224
|
+
: new CliError("This project has no tests to run.");
|
|
186
225
|
const fetchScript = (id) => ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/${id}/script`, {
|
|
187
226
|
// frames only matter when the run will be imported: they become the replay.
|
|
188
227
|
// environment_id keeps the baked login_email on the same environment the
|
|
@@ -454,15 +493,36 @@ export const runCommands = [
|
|
|
454
493
|
{
|
|
455
494
|
name: "runs list",
|
|
456
495
|
summary: "List recent runs",
|
|
496
|
+
description: "Returns the 50 most recent runs unless --page is given; pass --page to walk the " +
|
|
497
|
+
"full history a slice at a time.",
|
|
457
498
|
scope: "project",
|
|
458
499
|
groupDefault: true,
|
|
459
500
|
groupSummary: "Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and download results.",
|
|
460
|
-
flags: [
|
|
501
|
+
flags: [
|
|
502
|
+
{ name: "env", type: "string", description: "Filter by environment id" },
|
|
503
|
+
{
|
|
504
|
+
name: "page",
|
|
505
|
+
type: "number",
|
|
506
|
+
description: "Return only this 1-indexed page instead of the 50 most recent runs",
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
name: "page-size",
|
|
510
|
+
type: "number",
|
|
511
|
+
description: "Runs per page when --page is given (default 8, max 100)",
|
|
512
|
+
},
|
|
513
|
+
],
|
|
461
514
|
async run(ctx, input) {
|
|
462
515
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
516
|
+
const page = flagNum(input, "page");
|
|
517
|
+
const rows = page === undefined
|
|
518
|
+
? (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs`, {
|
|
519
|
+
environment_id: flagStr(input, "env"),
|
|
520
|
+
}))
|
|
521
|
+
: (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/page`, {
|
|
522
|
+
page,
|
|
523
|
+
page_size: flagNum(input, "page-size"),
|
|
524
|
+
environment_id: flagStr(input, "env"),
|
|
525
|
+
})).items;
|
|
466
526
|
return { data: rows.map(({ test_results: _omit, ...row }) => row) };
|
|
467
527
|
},
|
|
468
528
|
},
|
package/dist/commands/tests.js
CHANGED
|
@@ -7,7 +7,7 @@ import { PlaywrightMissingError } from "../local-run.js";
|
|
|
7
7
|
import { dim, green, red, table, yellow } from "../output.js";
|
|
8
8
|
import { confirmInstall, installPlaywright } from "../playwright-install.js";
|
|
9
9
|
import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
|
|
10
|
-
import { arg, argList, flagBool, flagNum, flagStr, projectPath, readJsonFlag } from "./util.js";
|
|
10
|
+
import { arg, argList, flagBool, flagNum, flagStr, flagStrings, inGroup, projectPath, readJsonFlag, } from "./util.js";
|
|
11
11
|
const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
|
|
12
12
|
// A duplicate-title 409 after a green replay is idempotent when the existing row
|
|
13
13
|
// holds the same plan (a retry after a lost response) — success, not an error.
|
|
@@ -106,19 +106,62 @@ export const testCommands = [
|
|
|
106
106
|
name: "tests list",
|
|
107
107
|
summary: "List the project's tests with their latest result",
|
|
108
108
|
description: "Prints a concise table by default (title / status / id / last result / last run). " +
|
|
109
|
-
"Pass --wide for every field, or --json for the raw records."
|
|
109
|
+
"Pass --wide for every field, or --json for the raw records. Returns every test " +
|
|
110
|
+
"unless --page is given; pass --page to walk a large project a slice at a time.",
|
|
110
111
|
scope: "project",
|
|
111
112
|
groupDefault: true,
|
|
112
113
|
groupSummary: "Author, inspect, version, and heal a project's tests — the checks Beryl runs on each run.",
|
|
113
114
|
flags: [
|
|
114
115
|
{ name: "env", type: "string", description: "Filter by environment id" },
|
|
116
|
+
{
|
|
117
|
+
name: "group",
|
|
118
|
+
type: "string",
|
|
119
|
+
description: "Show only tests in this group (by name; see `beryl groups list`)",
|
|
120
|
+
},
|
|
115
121
|
{ name: "wide", type: "boolean", description: "Show all columns, not the concise default" },
|
|
122
|
+
{
|
|
123
|
+
name: "page",
|
|
124
|
+
type: "number",
|
|
125
|
+
description: "Return only this 1-indexed page instead of every test",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "page-size",
|
|
129
|
+
type: "number",
|
|
130
|
+
description: "Tests per page when --page is given (default 20, max 100)",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "status",
|
|
134
|
+
type: "string",
|
|
135
|
+
description: "With --page, show only this bucket: passed, failed, or blocked",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "ungrouped",
|
|
139
|
+
type: "boolean",
|
|
140
|
+
description: "With --page, show only tests carrying no group",
|
|
141
|
+
},
|
|
116
142
|
],
|
|
117
143
|
async run(ctx, input) {
|
|
118
144
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
145
|
+
const page = flagNum(input, "page");
|
|
146
|
+
const group = flagStr(input, "group");
|
|
147
|
+
let data;
|
|
148
|
+
if (page === undefined) {
|
|
149
|
+
data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
|
|
150
|
+
environment_id: flagStr(input, "env"),
|
|
151
|
+
}));
|
|
152
|
+
// GET /tests takes no group param; the paged route below filters server-side.
|
|
153
|
+
if (group)
|
|
154
|
+
data = data.filter((t) => inGroup(t, group));
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/page`, {
|
|
158
|
+
page,
|
|
159
|
+
page_size: flagNum(input, "page-size"),
|
|
160
|
+
status: flagStr(input, "status"),
|
|
161
|
+
group: flagBool(input, "ungrouped") ? "__ungrouped__" : group,
|
|
162
|
+
environment_id: flagStr(input, "env"),
|
|
163
|
+
})).items;
|
|
164
|
+
}
|
|
122
165
|
if (flagBool(input, "wide"))
|
|
123
166
|
return { data };
|
|
124
167
|
// `data` stays the full records so --json is unchanged; only the human table is trimmed.
|
|
@@ -201,6 +244,12 @@ export const testCommands = [
|
|
|
201
244
|
description: "Replay against this base URL instead of the environment's (e.g. http://localhost:3000). " +
|
|
202
245
|
"The banked test is then unproven against its real environment — the CLI says so.",
|
|
203
246
|
},
|
|
247
|
+
{
|
|
248
|
+
name: "group",
|
|
249
|
+
type: "strings",
|
|
250
|
+
description: "Put the test in an existing project group (by name), repeatable. An unknown " +
|
|
251
|
+
"name is an error — create groups with `beryl groups create`. Omit for no group.",
|
|
252
|
+
},
|
|
204
253
|
{ name: "env", type: "string", description: "Environment id to compile and prove against" },
|
|
205
254
|
{
|
|
206
255
|
name: "sync",
|
|
@@ -230,10 +279,12 @@ export const testCommands = [
|
|
|
230
279
|
const urlOverride = flagStr(input, "url-override");
|
|
231
280
|
const env = flagStr(input, "env");
|
|
232
281
|
const sync = input.flags.sync !== false;
|
|
282
|
+
const groups = flagStrings(input, "group");
|
|
233
283
|
const bank = (extra) => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
|
|
234
284
|
title,
|
|
235
285
|
plan,
|
|
236
286
|
description,
|
|
287
|
+
groups,
|
|
237
288
|
...extra,
|
|
238
289
|
});
|
|
239
290
|
if (flagBool(input, "no-verify")) {
|
|
@@ -472,6 +523,35 @@ export const testCommands = [
|
|
|
472
523
|
};
|
|
473
524
|
},
|
|
474
525
|
},
|
|
526
|
+
{
|
|
527
|
+
name: "tests set-groups",
|
|
528
|
+
summary: "Replace the groups a test belongs to",
|
|
529
|
+
description: "Groups are project-defined labels used to filter, run, or schedule a slice of the " +
|
|
530
|
+
"suite (`beryl runs trigger --group <name>`). This REPLACES the whole set — pass every " +
|
|
531
|
+
"group you want, or none to clear it. Names must already exist in the project " +
|
|
532
|
+
"(`beryl groups list`); an unknown name is an error, never a new group.",
|
|
533
|
+
scope: "project",
|
|
534
|
+
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
535
|
+
flags: [
|
|
536
|
+
{
|
|
537
|
+
name: "group",
|
|
538
|
+
type: "strings",
|
|
539
|
+
description: "Group name, repeatable. Omit entirely to clear the set.",
|
|
540
|
+
},
|
|
541
|
+
],
|
|
542
|
+
examples: [
|
|
543
|
+
"beryl tests set-groups 4f… --group Checkout --group Smoke",
|
|
544
|
+
"beryl tests set-groups 4f…",
|
|
545
|
+
],
|
|
546
|
+
async run(ctx, input) {
|
|
547
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
548
|
+
return {
|
|
549
|
+
data: await ctx.client.patch(testPath(workspaceId, projectId, arg(input, "test-id")), {
|
|
550
|
+
groups: flagStrings(input, "group") ?? [],
|
|
551
|
+
}),
|
|
552
|
+
};
|
|
553
|
+
},
|
|
554
|
+
},
|
|
475
555
|
{
|
|
476
556
|
name: "tests quarantine",
|
|
477
557
|
summary: "Mute a flaky test: it keeps running, but its failures stop failing the run",
|
package/dist/commands/util.js
CHANGED
|
@@ -54,3 +54,23 @@ export function readJsonFlag(input, name) {
|
|
|
54
54
|
throw new UsageError(`--${name}: ${file} is not valid JSON (${err.message})`);
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
export function flagStrings(input, name) {
|
|
58
|
+
const v = input.flags[name];
|
|
59
|
+
return v && v.length > 0 ? v : undefined;
|
|
60
|
+
}
|
|
61
|
+
export function inGroup(row, group) {
|
|
62
|
+
const want = group.trim().toLowerCase();
|
|
63
|
+
return (row.groups ?? []).some((g) => g.toLowerCase() === want);
|
|
64
|
+
}
|
|
65
|
+
/** Resolve a --group name to the ids of the project's ACTIVE tests in it.
|
|
66
|
+
* Client-side on purpose: the run endpoint keeps one selection mechanism
|
|
67
|
+
* (test_case_ids), so a run always records the exact ids it ran even if group
|
|
68
|
+
* membership changes later. */
|
|
69
|
+
export function idsInGroup(rows, group) {
|
|
70
|
+
return rows.filter((t) => t.is_active !== false && inGroup(t, group)).map((t) => t.id);
|
|
71
|
+
}
|
|
72
|
+
/** Match a `--group` value against the project's groups by name (case-insensitive) or id. */
|
|
73
|
+
export function findGroup(groups, ref) {
|
|
74
|
+
const want = ref.trim().toLowerCase();
|
|
75
|
+
return groups.find((g) => g.id === ref || g.name.toLowerCase() === want);
|
|
76
|
+
}
|
package/dist/commands/watch.js
CHANGED
|
@@ -68,7 +68,8 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes) {
|
|
|
68
68
|
// is reported too, so a suite that only stays green by retrying can't hide it.
|
|
69
69
|
const extra = (counters.cancelled ? yellow(`, ${counters.cancelled} cancelled`) : "") +
|
|
70
70
|
(counters.quarantined ? yellow(`, ${counters.quarantined} quarantined`) : "") +
|
|
71
|
-
(counters.flaky ? yellow(`, ${counters.flaky} flaky`) : "")
|
|
71
|
+
(counters.flaky ? yellow(`, ${counters.flaky} flaky`) : "") +
|
|
72
|
+
(counters.healed ? yellow(`, ${counters.healed} healed`) : "");
|
|
72
73
|
const summary = failed > 0
|
|
73
74
|
? red(`${failed} failed`) + `, ${passed} passed` + extra
|
|
74
75
|
: green(`${passed} passed`) + extra;
|
package/dist/http.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AuthError, CliError } from "./errors.js";
|
|
2
|
+
import { attributionHeaders } from "./telemetry.js";
|
|
2
3
|
export const API_PREFIX = "/api/v1";
|
|
3
4
|
export class ApiError extends CliError {
|
|
4
5
|
status;
|
|
@@ -29,7 +30,11 @@ export class ApiClient {
|
|
|
29
30
|
return this.token ? { Authorization: `Bearer ${this.token}` } : {};
|
|
30
31
|
}
|
|
31
32
|
async request(method, path, opts = {}) {
|
|
32
|
-
const headers = {
|
|
33
|
+
const headers = {
|
|
34
|
+
...attributionHeaders(),
|
|
35
|
+
...this.authHeaders(),
|
|
36
|
+
...opts.headers,
|
|
37
|
+
};
|
|
33
38
|
let body;
|
|
34
39
|
if (opts.form) {
|
|
35
40
|
body = opts.form;
|
package/dist/registry/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import { authCommands } from "../commands/auth.js";
|
|
|
4
4
|
import { configCommands } from "../commands/config-vars.js";
|
|
5
5
|
import { environmentCommands } from "../commands/environments.js";
|
|
6
6
|
import { explorationCommands } from "../commands/explorations.js";
|
|
7
|
+
import { groupCommands } from "../commands/groups.js";
|
|
8
|
+
import { healthCommands } from "../commands/health.js";
|
|
7
9
|
import { mailboxCommands } from "../commands/mailboxes.js";
|
|
8
10
|
import { initCommands } from "../commands/init.js";
|
|
9
11
|
import { mcpCommands } from "../commands/mcp.js";
|
|
@@ -56,7 +58,9 @@ export const commands = [
|
|
|
56
58
|
...projectCommands,
|
|
57
59
|
...environmentCommands,
|
|
58
60
|
...testCommands,
|
|
61
|
+
...groupCommands,
|
|
59
62
|
...runCommands,
|
|
63
|
+
...healthCommands,
|
|
60
64
|
...explorationCommands,
|
|
61
65
|
...configCommands,
|
|
62
66
|
...mailboxCommands,
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { PostHog } from "posthog-node";
|
|
4
|
+
import { cliVersion } from "./version-check.js";
|
|
5
|
+
// The webapp already ships this public (write-only) project key in its JS bundle, and
|
|
6
|
+
// dev/prod deploys share it — environments are separated by the `environment` property
|
|
7
|
+
// on every event, not by project. Nothing here is configurable on a customer machine.
|
|
8
|
+
const POSTHOG_KEY = "phc_JRs3q08uFEuLKGR6llGAEBpwAuVHtPoEXAj9IcFFhRH";
|
|
9
|
+
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
10
|
+
// One process = one session, for both surfaces: a CLI invocation is a one-shot session,
|
|
11
|
+
// an MCP server lives as long as the coding agent keeps it spawned.
|
|
12
|
+
export const sessionId = randomUUID();
|
|
13
|
+
let surface = "cli";
|
|
14
|
+
let client;
|
|
15
|
+
export function setSurface(value) {
|
|
16
|
+
surface = value;
|
|
17
|
+
}
|
|
18
|
+
export function setClient(value) {
|
|
19
|
+
client = value;
|
|
20
|
+
}
|
|
21
|
+
export function currentSurface() {
|
|
22
|
+
return surface;
|
|
23
|
+
}
|
|
24
|
+
export function currentClient() {
|
|
25
|
+
return client;
|
|
26
|
+
}
|
|
27
|
+
// The MCP SDK dispatches tool calls concurrently, so "which tool is running" has to be
|
|
28
|
+
// per-async-context, not a module global.
|
|
29
|
+
const toolStore = new AsyncLocalStorage();
|
|
30
|
+
export function withTool(tool, fn) {
|
|
31
|
+
return toolStore.run(tool, fn);
|
|
32
|
+
}
|
|
33
|
+
export function currentTool() {
|
|
34
|
+
return toolStore.getStore();
|
|
35
|
+
}
|
|
36
|
+
export function userAgent() {
|
|
37
|
+
const parts = [surface];
|
|
38
|
+
if (client)
|
|
39
|
+
parts.push(client.version ? `${client.name}/${client.version}` : client.name);
|
|
40
|
+
parts.push(`${process.platform} ${process.arch}`, `node/${process.versions.node}`);
|
|
41
|
+
return `beryl-cli/${cliVersion()} (${parts.join("; ")})`;
|
|
42
|
+
}
|
|
43
|
+
export function attributionHeaders() {
|
|
44
|
+
const headers = {
|
|
45
|
+
"User-Agent": userAgent(),
|
|
46
|
+
"X-Beryl-Session": sessionId,
|
|
47
|
+
};
|
|
48
|
+
const tool = currentTool();
|
|
49
|
+
if (tool)
|
|
50
|
+
headers["X-Beryl-Tool"] = tool;
|
|
51
|
+
return headers;
|
|
52
|
+
}
|
|
53
|
+
export function analyticsEnvironment(apiUrl) {
|
|
54
|
+
let host;
|
|
55
|
+
try {
|
|
56
|
+
host = new URL(apiUrl).hostname;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return "local";
|
|
60
|
+
}
|
|
61
|
+
if (host === "api.beryl.so")
|
|
62
|
+
return "prod";
|
|
63
|
+
if (host === "dev.beryl.so")
|
|
64
|
+
return "dev";
|
|
65
|
+
return "local";
|
|
66
|
+
}
|
|
67
|
+
// Values kept verbatim: ids, references, enum choices and other low-cardinality
|
|
68
|
+
// selectors that make the Sessions view readable. Everything else is reduced to a
|
|
69
|
+
// shape, so free-text arguments (plans, credentials, mail bodies) never leave the
|
|
70
|
+
// machine. Keys are the MCP tool's parameter names, hyphens and all.
|
|
71
|
+
const KEEP_VALUE_KEYS = new Set([
|
|
72
|
+
"account",
|
|
73
|
+
"auth",
|
|
74
|
+
"env",
|
|
75
|
+
"env-id",
|
|
76
|
+
"environment",
|
|
77
|
+
"format",
|
|
78
|
+
"frequency",
|
|
79
|
+
"id",
|
|
80
|
+
"limit",
|
|
81
|
+
"login-method",
|
|
82
|
+
"mailbox",
|
|
83
|
+
"name",
|
|
84
|
+
"project",
|
|
85
|
+
"role",
|
|
86
|
+
"run",
|
|
87
|
+
"scope",
|
|
88
|
+
"status",
|
|
89
|
+
"test",
|
|
90
|
+
"type",
|
|
91
|
+
"url",
|
|
92
|
+
"workspace",
|
|
93
|
+
]);
|
|
94
|
+
// Catch-all for keys we never enumerated. Deliberately a loose substring match, which
|
|
95
|
+
// makes it fire on innocent names too (`login-method`, `--force-new-login`, `--otp`) —
|
|
96
|
+
// so the allowlist above and booleans, both settled decisions, are checked first, and
|
|
97
|
+
// this only decides the keys nobody classified. It still guards a real `--otp 123456`,
|
|
98
|
+
// because numbers are kept only after it has had its say.
|
|
99
|
+
const SECRET_KEY = /pass|secret|token|key|otp|code|credential|login|value|blob|cookie|email/i;
|
|
100
|
+
const PAT_PREFIX = "beryl_pat_";
|
|
101
|
+
const REDACTED = "<redacted>";
|
|
102
|
+
function shapeOf(value) {
|
|
103
|
+
if (typeof value === "string")
|
|
104
|
+
return `<string:${value.length}>`;
|
|
105
|
+
if (Array.isArray(value))
|
|
106
|
+
return `<array:${value.length}>`;
|
|
107
|
+
if (typeof value === "object")
|
|
108
|
+
return "<object>";
|
|
109
|
+
return `<${typeof value}>`;
|
|
110
|
+
}
|
|
111
|
+
function containsSecret(value) {
|
|
112
|
+
if (typeof value === "string")
|
|
113
|
+
return value.includes(PAT_PREFIX);
|
|
114
|
+
if (Array.isArray(value))
|
|
115
|
+
return value.some(containsSecret);
|
|
116
|
+
if (value && typeof value === "object")
|
|
117
|
+
return Object.values(value).some(containsSecret);
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
function isRecord(value) {
|
|
121
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
122
|
+
}
|
|
123
|
+
export function redactArguments(args) {
|
|
124
|
+
if (!isRecord(args))
|
|
125
|
+
return shapeOf(args);
|
|
126
|
+
const out = {};
|
|
127
|
+
for (const [key, value] of Object.entries(args)) {
|
|
128
|
+
if (value === null || value === undefined)
|
|
129
|
+
continue;
|
|
130
|
+
if (containsSecret(value)) {
|
|
131
|
+
out[key] = REDACTED;
|
|
132
|
+
}
|
|
133
|
+
else if (KEEP_VALUE_KEYS.has(key) && typeof value === "string") {
|
|
134
|
+
out[key] = value;
|
|
135
|
+
}
|
|
136
|
+
else if (typeof value === "boolean") {
|
|
137
|
+
// true/false cannot carry a credential, whatever the key is called.
|
|
138
|
+
out[key] = value;
|
|
139
|
+
}
|
|
140
|
+
else if (SECRET_KEY.test(key)) {
|
|
141
|
+
out[key] = shapeOf(value);
|
|
142
|
+
}
|
|
143
|
+
else if (typeof value === "number") {
|
|
144
|
+
out[key] = value;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
out[key] = shapeOf(value);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
// The SDK reports parameters as the JSON-RPC envelope — {request:{id,jsonrpc,method,
|
|
153
|
+
// params:{name,arguments,_meta}}} — so the caller's values live at request.params.arguments.
|
|
154
|
+
// An envelope we don't recognise is treated as arguments wholesale rather than passed
|
|
155
|
+
// through: an unfamiliar shape must fail closed, not leak.
|
|
156
|
+
//
|
|
157
|
+
// `_meta` gets the same treatment as arguments. It is the protocol's open extension point,
|
|
158
|
+
// so its contents are whatever the connected client decided to put there (today, Claude
|
|
159
|
+
// Code's tool-use and progress ids) — unknown by definition, and not ours to forward.
|
|
160
|
+
export function redactCapturedParameters(captured) {
|
|
161
|
+
if (!isRecord(captured))
|
|
162
|
+
return shapeOf(captured);
|
|
163
|
+
const request = captured.request;
|
|
164
|
+
const params = isRecord(request) ? request.params : undefined;
|
|
165
|
+
if (!isRecord(request) || !isRecord(params))
|
|
166
|
+
return redactArguments(captured);
|
|
167
|
+
const redacted = { ...params };
|
|
168
|
+
for (const key of ["arguments", "_meta"]) {
|
|
169
|
+
if (params[key] !== undefined)
|
|
170
|
+
redacted[key] = redactArguments(params[key]);
|
|
171
|
+
}
|
|
172
|
+
return { ...captured, request: { ...request, params: redacted } };
|
|
173
|
+
}
|
|
174
|
+
// How much a tool answered with, never what. Reads lengths off strings the SDK already
|
|
175
|
+
// built — constant time whatever the payload — so this must not serialize the result.
|
|
176
|
+
export function responseShape(response) {
|
|
177
|
+
if (!isRecord(response))
|
|
178
|
+
return undefined;
|
|
179
|
+
const content = response.content;
|
|
180
|
+
if (!Array.isArray(content))
|
|
181
|
+
return undefined;
|
|
182
|
+
let bytes = 0;
|
|
183
|
+
for (const part of content) {
|
|
184
|
+
if (!isRecord(part))
|
|
185
|
+
continue;
|
|
186
|
+
// text / image+audio / embedded resource, whose payload hangs one level deeper.
|
|
187
|
+
const resource = isRecord(part.resource) ? part.resource : undefined;
|
|
188
|
+
const payload = part.text ?? part.data ?? resource?.text ?? resource?.blob;
|
|
189
|
+
if (typeof payload === "string")
|
|
190
|
+
bytes += payload.length;
|
|
191
|
+
}
|
|
192
|
+
return { response_parts: content.length, response_bytes: bytes };
|
|
193
|
+
}
|
|
194
|
+
// Agent-written free text: it is told not to include secrets, but that is a prompt, not a
|
|
195
|
+
// guarantee — so it gets the same PAT check as any argument, and a hard length cap.
|
|
196
|
+
const MAX_INTENT = 500;
|
|
197
|
+
function redactIntent(intent) {
|
|
198
|
+
if (typeof intent !== "string")
|
|
199
|
+
return undefined;
|
|
200
|
+
if (intent.includes(PAT_PREFIX))
|
|
201
|
+
return REDACTED;
|
|
202
|
+
return intent.length > MAX_INTENT ? intent.slice(0, MAX_INTENT) : intent;
|
|
203
|
+
}
|
|
204
|
+
// Tool responses carry plans, mailbox contents and run artifacts wholesale — there is no
|
|
205
|
+
// key-level policy that makes them safe, so only their size survives.
|
|
206
|
+
export const beforeSend = (event) => {
|
|
207
|
+
const props = event.properties;
|
|
208
|
+
Object.assign(props, responseShape(props.$mcp_response));
|
|
209
|
+
delete props.$mcp_response;
|
|
210
|
+
if (props.$mcp_parameters !== undefined) {
|
|
211
|
+
props.$mcp_parameters = redactCapturedParameters(props.$mcp_parameters);
|
|
212
|
+
}
|
|
213
|
+
if (props.$mcp_intent !== undefined) {
|
|
214
|
+
props.$mcp_intent = redactIntent(props.$mcp_intent);
|
|
215
|
+
}
|
|
216
|
+
return event;
|
|
217
|
+
};
|
|
218
|
+
export function baseEventProperties(apiUrl) {
|
|
219
|
+
return {
|
|
220
|
+
environment: analyticsEnvironment(apiUrl),
|
|
221
|
+
cli_version: cliVersion(),
|
|
222
|
+
os: process.platform,
|
|
223
|
+
arch: process.arch,
|
|
224
|
+
node_version: process.versions.node,
|
|
225
|
+
api_url: apiUrl,
|
|
226
|
+
// Same uuid as the X-Beryl-Session header, so a PostHog session and the Sentry tags
|
|
227
|
+
// on the API requests it caused describe the same thing.
|
|
228
|
+
beryl_session_id: sessionId,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
let posthog;
|
|
232
|
+
export function createPostHogClient() {
|
|
233
|
+
posthog ??= new PostHog(POSTHOG_KEY, { host: POSTHOG_HOST, disableGeoip: false });
|
|
234
|
+
return posthog;
|
|
235
|
+
}
|
|
236
|
+
export function analyticsOptions(apiUrl, identify) {
|
|
237
|
+
const properties = baseEventProperties(apiUrl);
|
|
238
|
+
return {
|
|
239
|
+
identify,
|
|
240
|
+
beforeSend,
|
|
241
|
+
eventProperties: () => properties,
|
|
242
|
+
// Worth its cost: without the agent's own reason for a call, a session reads as a list
|
|
243
|
+
// of tool names and you cannot tell deliberate work from a loop. The description is
|
|
244
|
+
// ours and deliberately terse — the SDK's default one is ~4x longer, and every word is
|
|
245
|
+
// paid for on all 86 advertised tools on every request (measured: +12.8k tokens vs +3k).
|
|
246
|
+
context: {
|
|
247
|
+
description: "Why this call is being made and how it serves the user's goal, in under 15 words. " +
|
|
248
|
+
"Third person. Never include credentials, tokens or personal data.",
|
|
249
|
+
},
|
|
250
|
+
// Would inject a second parameter into every tool to survive reconnects. A stdio
|
|
251
|
+
// process is already one session, and a user's work is stitched by distinct_id
|
|
252
|
+
// anyway — not worth another schema rewrite for tidier grouping.
|
|
253
|
+
enableConversationId: false,
|
|
254
|
+
reportMissing: false,
|
|
255
|
+
// Tool failures are already returned to the agent as isError and reported by the
|
|
256
|
+
// API's own Sentry; a second $exception stream would double-count them.
|
|
257
|
+
enableExceptionAutocapture: false,
|
|
258
|
+
logger: (message) => process.stderr.write(`[beryl telemetry] ${message}\n`),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
export async function shutdownTelemetry() {
|
|
262
|
+
if (!posthog)
|
|
263
|
+
return;
|
|
264
|
+
const client = posthog;
|
|
265
|
+
posthog = undefined;
|
|
266
|
+
try {
|
|
267
|
+
await client.shutdown(2000);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// Telemetry must never delay or fail the process it is observing.
|
|
271
|
+
}
|
|
272
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beryl-so/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
"docs": "tsx scripts/gen-docs.ts"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
35
|
+
"@posthog/mcp": "^0.11.6",
|
|
36
|
+
"posthog-node": "^5.49.1"
|
|
35
37
|
},
|
|
36
38
|
"devDependencies": {
|
|
37
39
|
"@playwright/test": "^1.61.1",
|