@beryl-so/cli 0.6.0 → 0.8.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 +8 -0
- package/dist/adapters/cli.js +35 -15
- package/dist/adapters/mcp.js +4 -1
- package/dist/beryl-test-skill.js +113 -2
- package/dist/commands/config-vars.js +68 -8
- package/dist/commands/inboxes.js +12 -0
- package/dist/commands/init.js +85 -9
- package/dist/commands/tests.js +27 -6
- package/dist/commands/util.js +14 -1
- package/dist/version-check.js +65 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,6 +26,10 @@ npx @beryl-so/cli@latest … # or run one-off without installing
|
|
|
26
26
|
|
|
27
27
|
From the monorepo (development): `cd cli && npm install && npm run build && npm link`.
|
|
28
28
|
|
|
29
|
+
`beryl init` and `beryl mcp` warn (stderr, best-effort) when the running CLI is behind the
|
|
30
|
+
latest npm release — a stale MCP server silently exposes fewer tools. Set
|
|
31
|
+
`BERYL_NO_UPDATE_CHECK=1` to opt out.
|
|
32
|
+
|
|
29
33
|
## Authenticate
|
|
30
34
|
|
|
31
35
|
```bash
|
|
@@ -251,11 +255,14 @@ Manage the variables, secrets, and files the agent can use while exploring and r
|
|
|
251
255
|
| --- | --- | --- |
|
|
252
256
|
| `beryl config vars list` | List the project's config variables (visible to the agent during runs) | `config_vars_list` |
|
|
253
257
|
| `beryl config vars set <key> <value>` | Create or update a config variable | `config_vars_set` |
|
|
258
|
+
| `beryl config vars get <key>` | Show one config variable | `config_vars_get` |
|
|
254
259
|
| `beryl config vars delete <key>` | Delete a config variable | `config_vars_delete` |
|
|
255
260
|
| `beryl config secrets list` | List the project's secrets (values are never returned) | `config_secrets_list` |
|
|
256
261
|
| `beryl config secrets set <key> <value>` | Create a secret (write-only; re-setting a key replaces it) | `config_secrets_set` |
|
|
262
|
+
| `beryl config secrets get <key>` | Show one secret's metadata (the value is never returned) | `config_secrets_get` |
|
|
257
263
|
| `beryl config secrets delete <key>` | Delete a secret | `config_secrets_delete` |
|
|
258
264
|
| `beryl config files list` | List files uploaded for the agent to use (e.g. CSVs, upload fixtures) | `config_files_list` |
|
|
265
|
+
| `beryl config files get <file>` | Show one uploaded file's metadata | `config_files_get` |
|
|
259
266
|
| `beryl config files upload <file>` | Upload a file | `config_files_upload` |
|
|
260
267
|
| `beryl config files download <file-id>` | Get a short-lived download URL for a file | `config_files_download` |
|
|
261
268
|
| `beryl config files delete <file-id>` | Delete an uploaded file | `config_files_delete` |
|
|
@@ -307,6 +314,7 @@ Disposable email inboxes for testing flows that send mail — signups, OTPs, rec
|
|
|
307
314
|
| Command | Summary | MCP tool |
|
|
308
315
|
| --- | --- | --- |
|
|
309
316
|
| `beryl inbox create` | Mint a disposable email inbox that Beryl receives mail for | `inbox_create` |
|
|
317
|
+
| `beryl inbox list` | List the workspace's inboxes, newest first | `inbox_list` |
|
|
310
318
|
| `beryl inbox read <inbox-id>` | Read the latest email from an inbox (waits for one to arrive) | `inbox_read` |
|
|
311
319
|
| `beryl inbox emails <inbox-id>` | List the emails an inbox has received | `inbox_emails` |
|
|
312
320
|
|
package/dist/adapters/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import { loadConfig } from "../config.js";
|
|
3
2
|
import { createContext } from "../context.js";
|
|
4
3
|
import { CliError, EXIT_OK, EXIT_USAGE, UsageError } from "../errors.js";
|
|
5
4
|
import { ApiClient } from "../http.js";
|
|
6
5
|
import { autoFormat, bold, cyan, dim } from "../output.js";
|
|
7
6
|
import { commandGroups, findCommand, groupSummary } from "../registry/index.js";
|
|
7
|
+
import { cliVersion } from "../version-check.js";
|
|
8
8
|
import { mcpToolFor } from "./mcp.js";
|
|
9
9
|
export const GLOBAL_FLAGS = [
|
|
10
10
|
{ name: "version", description: "Print the CLI version and exit", alias: "V" },
|
|
@@ -13,15 +13,6 @@ export const GLOBAL_FLAGS = [
|
|
|
13
13
|
{ name: "token", description: "Personal access token (overrides config/BERYL_API_KEY)", value: true },
|
|
14
14
|
{ name: "help", description: "Show help", alias: "h" },
|
|
15
15
|
];
|
|
16
|
-
export function cliVersion() {
|
|
17
|
-
try {
|
|
18
|
-
const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
19
|
-
return pkg.version;
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
return "unknown";
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
16
|
export function parseArgv(spec, tokens) {
|
|
26
17
|
const flags = {};
|
|
27
18
|
const positional = [];
|
|
@@ -222,21 +213,47 @@ function groupHelp(group, specs) {
|
|
|
222
213
|
lines.push("", `Run ${cyan(`beryl ${group} <subcommand> --help`)} for details.`);
|
|
223
214
|
return lines.join("\n");
|
|
224
215
|
}
|
|
216
|
+
// Whether argv[i] is a recognised global flag, and how many tokens it spans (2 when it
|
|
217
|
+
// takes its value from the next token, 1 for boolean/alias/inline `--name=value` forms).
|
|
218
|
+
function globalFlagSpan(argv, i) {
|
|
219
|
+
const tok = argv[i];
|
|
220
|
+
const eq = tok.indexOf("=");
|
|
221
|
+
const name = (eq === -1 ? tok : tok.slice(0, eq)).replace(/^--?/, "");
|
|
222
|
+
const flag = GLOBAL_FLAGS.find((g) => (tok.startsWith("--") ? g.name === name : "alias" in g && g.alias === name));
|
|
223
|
+
if (!flag)
|
|
224
|
+
return 0;
|
|
225
|
+
return "value" in flag && flag.value && eq === -1 ? 2 : 1;
|
|
226
|
+
}
|
|
225
227
|
export async function runCli(argv) {
|
|
228
|
+
// Global flags are position-independent: collect command words by skipping over
|
|
229
|
+
// recognised global flags (leaving them in place for parseArgv), and stop only at a
|
|
230
|
+
// flag we don't recognise as global — that one belongs to the command.
|
|
226
231
|
const words = [];
|
|
227
|
-
|
|
228
|
-
for (
|
|
229
|
-
|
|
232
|
+
const wordIndices = [];
|
|
233
|
+
for (let i = 0; i < argv.length; i++) {
|
|
234
|
+
const tok = argv[i];
|
|
235
|
+
if (tok === "--")
|
|
230
236
|
break;
|
|
237
|
+
if (tok.startsWith("-") && tok !== "-") {
|
|
238
|
+
const span = globalFlagSpan(argv, i);
|
|
239
|
+
if (span === 0)
|
|
240
|
+
break;
|
|
241
|
+
i += span - 1;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
231
244
|
words.push(tok);
|
|
245
|
+
wordIndices.push(i);
|
|
232
246
|
}
|
|
233
|
-
|
|
247
|
+
let rest = argv;
|
|
248
|
+
let restWordIndices = wordIndices;
|
|
249
|
+
if (argv.includes("--version") || argv.includes("-V") || words[0] === "version") {
|
|
234
250
|
process.stdout.write(cliVersion() + "\n");
|
|
235
251
|
return EXIT_OK;
|
|
236
252
|
}
|
|
237
253
|
if (words[0] === "help") {
|
|
238
254
|
words.shift();
|
|
239
255
|
rest = [...words, "--help"];
|
|
256
|
+
restWordIndices = words.map((_, i) => i);
|
|
240
257
|
}
|
|
241
258
|
if (words.length === 0) {
|
|
242
259
|
process.stdout.write(rootHelp() + "\n");
|
|
@@ -266,7 +283,10 @@ export async function runCli(argv) {
|
|
|
266
283
|
return EXIT_USAGE;
|
|
267
284
|
}
|
|
268
285
|
const { spec } = found;
|
|
269
|
-
|
|
286
|
+
// Drop the consumed command words wherever they sit — global flags before or between
|
|
287
|
+
// them stay in place for parseArgv.
|
|
288
|
+
const consumedIndices = new Set(restWordIndices.slice(0, found.consumed));
|
|
289
|
+
const tokens = rest.filter((_, i) => !consumedIndices.has(i));
|
|
270
290
|
let parsed;
|
|
271
291
|
try {
|
|
272
292
|
parsed = parseArgv(spec, tokens);
|
package/dist/adapters/mcp.js
CHANGED
|
@@ -4,7 +4,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
|
|
|
4
4
|
import { createContext } from "../context.js";
|
|
5
5
|
import { CliError } from "../errors.js";
|
|
6
6
|
import { commands } from "../registry/index.js";
|
|
7
|
-
import { cliVersion } from "
|
|
7
|
+
import { cliVersion, warnIfStale } from "../version-check.js";
|
|
8
8
|
export function toolName(spec) {
|
|
9
9
|
return spec.name.replace(/ /g, "_").replace(/-/g, "_");
|
|
10
10
|
}
|
|
@@ -72,6 +72,9 @@ function toInput(spec, params) {
|
|
|
72
72
|
return { args, flags };
|
|
73
73
|
}
|
|
74
74
|
export async function serveMcp(baseCtx) {
|
|
75
|
+
// Fire-and-forget staleness warning: a stale MCP server silently exposes fewer
|
|
76
|
+
// tools, and stderr is the one channel a stdio MCP server can safely log to.
|
|
77
|
+
void warnIfStale(cliVersion(), (msg) => console.error(msg));
|
|
75
78
|
const server = new Server({ name: "beryl", version: cliVersion() }, { capabilities: { tools: {} } });
|
|
76
79
|
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
77
80
|
tools: mcpTools().map((spec) => ({
|
package/dist/beryl-test-skill.js
CHANGED
|
@@ -5,6 +5,40 @@
|
|
|
5
5
|
// the CLI and another in the docs. Edit here; `init` writes it verbatim.
|
|
6
6
|
export const BERYL_TEST_SKILL_FILENAME = "SKILL.md";
|
|
7
7
|
export const BERYL_TEST_SKILL_DIR = "beryl-test";
|
|
8
|
+
// The example plan the skill shows verbatim. Exported so the test suite lints it with
|
|
9
|
+
// lintPlan — the skill's documented shape must pass `beryl tests lint` on the first try.
|
|
10
|
+
export const BERYL_TEST_SKILL_EXAMPLE_PLAN = {
|
|
11
|
+
steps: [
|
|
12
|
+
{ action: "goto", url: "https://app.example.com/pricing" },
|
|
13
|
+
{
|
|
14
|
+
action: "expect",
|
|
15
|
+
expect_kind: "have_text",
|
|
16
|
+
selector: "h1",
|
|
17
|
+
expect_text: "Pricing",
|
|
18
|
+
},
|
|
19
|
+
{ action: "expect", expect_kind: "visible", selector: "text=Pro plan" },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
// The OTP/signup example the skill shows verbatim (§5). Exported so the test suite lints
|
|
23
|
+
// it with lintPlan, same as the minimal example — the documented await_email shape must
|
|
24
|
+
// pass `beryl tests lint` on the first try.
|
|
25
|
+
export const BERYL_TEST_SKILL_OTP_EXAMPLE_PLAN = {
|
|
26
|
+
steps: [
|
|
27
|
+
{ action: "goto", url: "https://app.example.com/signup" },
|
|
28
|
+
{ action: "fill", selector: "input[name=email]", value: "{{inbox_address}}" },
|
|
29
|
+
{ action: "click", selector: "button[type=submit]" },
|
|
30
|
+
{
|
|
31
|
+
action: "await_email",
|
|
32
|
+
extract: "code",
|
|
33
|
+
subject_contains: "verification code",
|
|
34
|
+
capture_as: "otp",
|
|
35
|
+
wait_s: 45,
|
|
36
|
+
},
|
|
37
|
+
{ action: "fill", selector: "input[name=code]", value: "{{otp}}" },
|
|
38
|
+
{ action: "click", selector: "text=Verify" },
|
|
39
|
+
{ action: "expect", expect_kind: "visible", selector: "text=Welcome" },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
8
42
|
export const BERYL_TEST_SKILL = `---
|
|
9
43
|
name: beryl-test
|
|
10
44
|
description: Author durable, healable end-to-end tests for a web app with Beryl. Use when writing, running, or fixing a Beryl test locally with your own coding agent — drafting the plan over the Playwright MCP, writing the natural-language intent, and running it with \`beryl runs local\`.
|
|
@@ -33,6 +67,28 @@ assertion**, a strong **natural-language intent**, and the **local run-fix loop*
|
|
|
33
67
|
- **at least one step is an \`expect\`** (a test that asserts nothing is not a test).
|
|
34
68
|
Optional \`before\` / \`after\` arrays hold setup and teardown; \`after\` runs even when a
|
|
35
69
|
main step fails, so a create/update/delete flow can clean up the record it made.
|
|
70
|
+
|
|
71
|
+
An \`expect\` step's shape is \`{action: "expect", expect_kind, selector, expect_text}\`:
|
|
72
|
+
- \`expect_kind\` (required) is one of \`visible\`, \`attached\`, \`hidden\`, \`checked\`,
|
|
73
|
+
\`enabled\`, \`disabled\`, \`have_text\`, \`have_value\`, \`have_url\`, \`have_title\`,
|
|
74
|
+
\`have_count\`, \`persisted\`, \`gone\`, \`count_delta\`.
|
|
75
|
+
- \`selector\` is required for every kind except the page-level \`have_url\` /
|
|
76
|
+
\`have_title\` (those assert on the page, not an element).
|
|
77
|
+
- \`expect_text\` carries the expected string for \`have_text\` / \`have_value\` (exact
|
|
78
|
+
match on the element) and \`have_url\` / \`have_title\` (substring match on the page).
|
|
79
|
+
There is no \`value\` field on an expect and no bare \`text\` / \`url\` kind.
|
|
80
|
+
- Two kinds take other fields instead: \`have_count\` needs \`expect_count\`, and
|
|
81
|
+
\`count_delta\` needs \`capture_ref\` + \`expect_delta\` (vs a baseline banked by an
|
|
82
|
+
earlier \`capture_count\` step).
|
|
83
|
+
|
|
84
|
+
A fully valid minimal plan ("the pricing page renders"):
|
|
85
|
+
|
|
86
|
+
\`\`\`json
|
|
87
|
+
${JSON.stringify(BERYL_TEST_SKILL_EXAMPLE_PLAN, null, 2)
|
|
88
|
+
.split("\n")
|
|
89
|
+
.map((line) => ` ${line}`)
|
|
90
|
+
.join("\n")}
|
|
91
|
+
\`\`\`
|
|
36
92
|
3. **Validate offline, then create:**
|
|
37
93
|
\`\`\`
|
|
38
94
|
beryl tests lint --file plan.json # check the plan against the schema, no network
|
|
@@ -60,8 +116,15 @@ observable proof the flow worked. Get this right and everything else follows.
|
|
|
60
116
|
"the URL is /pricing" passes even on a blank or broken page that never rendered.
|
|
61
117
|
Reserve a URL-only assertion for when the URL *is* the outcome (a form that lands on
|
|
62
118
|
\`/thank-you\`) and no distinctive destination content is available.
|
|
63
|
-
-
|
|
64
|
-
|
|
119
|
+
- The usual outcome kinds: \`visible\` (the success element showed up), \`have_text\` (an
|
|
120
|
+
element's text matches), \`have_url\` (the URL contains a value), \`gone\` (an element
|
|
121
|
+
disappeared — e.g. a spinner, or the item you just deleted). §1 has the full
|
|
122
|
+
\`expect_kind\` list and the step shape.
|
|
123
|
+
- **\`have_text\` is an EXACT full-text match on the selector's element** — asserting
|
|
124
|
+
\`have_text: "Documentation"\` on \`body\` fails, because \`body\`'s text includes all the
|
|
125
|
+
nav chrome. Target the specific element that carries the text (the \`h1\`, the toast),
|
|
126
|
+
or assert the page instead (\`have_title\`, which is a substring match). To check "this
|
|
127
|
+
string is visible somewhere", use \`expect_kind: "visible"\` with a \`text=…\` selector.
|
|
65
128
|
- **If you can't name a success signal, the flow is not test-worthy.** Don't bank a test
|
|
66
129
|
that verifies nothing. Explore a different flow instead.
|
|
67
130
|
- **Don't work around a real app failure to make a test go green.** If the flow is
|
|
@@ -133,4 +196,52 @@ beryl runs local <test-id> --url-override http://localhost:3000 --dir ./beryl-lo
|
|
|
133
196
|
|
|
134
197
|
Once the test passes locally against a real outcome, it's ready to bank and let Beryl run
|
|
135
198
|
and heal it.
|
|
199
|
+
|
|
200
|
+
## 5. Testing an OTP / signup flow (\`await_email\`)
|
|
201
|
+
|
|
202
|
+
A flow that emails the user — a signup verification code, a magic sign-in link, a receipt
|
|
203
|
+
— is testable with the \`await_email\` action. Beryl mints a **run-scoped inbox**
|
|
204
|
+
automatically whenever a plan contains an \`await_email\` step (or cites
|
|
205
|
+
\`{{inbox_address}}\`): no setup, no environment configuration, no flag to turn on. The
|
|
206
|
+
minted address is in scope from step 1 as the reserved \`{{inbox_address}}\` handle.
|
|
207
|
+
|
|
208
|
+
The wiring is a three-part chain:
|
|
209
|
+
|
|
210
|
+
1. **Type the minted address into the app** — a \`fill\` with \`value: "{{inbox_address}}"\`.
|
|
211
|
+
Every run gets a fresh address, so a signup flow is repeatable by construction (no
|
|
212
|
+
\`{{unique}}\` needed for the email itself; use \`{{unique}}\` for other must-not-collide
|
|
213
|
+
values like a username).
|
|
214
|
+
2. **Await the mail and bank the extracted value** — an \`await_email\` step with:
|
|
215
|
+
- \`extract\` (required): \`code\` (an OTP), \`link\` (the sign-in/verify URL), or
|
|
216
|
+
\`pattern\` (your own regex in \`extract_pattern\`, exactly one capture group).
|
|
217
|
+
- \`capture_as\` (required): the handle name the extracted string is banked under.
|
|
218
|
+
- \`subject_contains\` / \`from_contains\` (optional): match the right mail when the app
|
|
219
|
+
sends more than one.
|
|
220
|
+
- \`wait_s\` (optional, 1–50, default 30): how many seconds the step blocks waiting
|
|
221
|
+
for the mail to land.
|
|
222
|
+
3. **Use the banked value** — cite \`{{<capture_as>}}\` in a later step's \`value\` (fill the
|
|
223
|
+
code) or \`url\` (goto the magic link). A captured handle is legal **only** in
|
|
224
|
+
\`value\`/\`url\`; in a \`selector\`, \`option\`, or \`expect_text\` it would be used as
|
|
225
|
+
literal text, and the linter rejects it there.
|
|
226
|
+
|
|
227
|
+
A fully valid signup-with-OTP plan:
|
|
228
|
+
|
|
229
|
+
\`\`\`json
|
|
230
|
+
${JSON.stringify(BERYL_TEST_SKILL_OTP_EXAMPLE_PLAN, null, 2)}
|
|
231
|
+
\`\`\`
|
|
232
|
+
|
|
233
|
+
For a magic-link flow, replace the code steps with
|
|
234
|
+
\`{ "action": "await_email", "extract": "link", "capture_as": "signin_link" }\` followed by
|
|
235
|
+
\`{ "action": "goto", "url": "{{signin_link}}" }\`.
|
|
236
|
+
|
|
237
|
+
Two caveats:
|
|
238
|
+
|
|
239
|
+
- \`beryl tests create\` verifies an \`await_email\` plan like any other — the replay mints
|
|
240
|
+
its own inbox, so the app's mail really is received and extracted before the test is
|
|
241
|
+
accepted. (Note the replay signs up / sends mail for real; pass \`--no-verify\` only if
|
|
242
|
+
that side effect is unwanted.) \`runs local\` cannot serve \`await_email\` — the inbox
|
|
243
|
+
lives in Beryl's cloud — so iterate on these flows with
|
|
244
|
+
\`beryl runs trigger --test <id> --watch\`.
|
|
245
|
+
- The outcome assertion discipline from §1 still applies: the green signal is the
|
|
246
|
+
post-verification state (the welcome screen, the dashboard), not "an email arrived".
|
|
136
247
|
`;
|
|
@@ -3,13 +3,16 @@ import path from "node:path";
|
|
|
3
3
|
import { UsageError } from "../errors.js";
|
|
4
4
|
import { arg, flagBool, flagStr, projectPath } from "./util.js";
|
|
5
5
|
const configPath = (ws, p) => `${projectPath(ws, p)}/config`;
|
|
6
|
-
|
|
6
|
+
function findByKey(rows, value, kind) {
|
|
7
7
|
const byId = rows.find((r) => r.id === value);
|
|
8
8
|
if (byId)
|
|
9
|
-
return byId
|
|
9
|
+
return byId;
|
|
10
10
|
const byKey = rows.filter((r) => r.key === value);
|
|
11
11
|
if (byKey.length === 1)
|
|
12
|
-
return byKey[0]
|
|
12
|
+
return byKey[0];
|
|
13
|
+
if (byKey.length > 1) {
|
|
14
|
+
throw new UsageError(`Multiple ${kind}s have key "${value}" (per-environment copies) — use the id instead`);
|
|
15
|
+
}
|
|
13
16
|
throw new UsageError(`No ${kind} with key or id "${value}"`);
|
|
14
17
|
}
|
|
15
18
|
export const configCommands = [
|
|
@@ -51,17 +54,30 @@ export const configCommands = [
|
|
|
51
54
|
};
|
|
52
55
|
},
|
|
53
56
|
},
|
|
57
|
+
{
|
|
58
|
+
name: "config vars get",
|
|
59
|
+
summary: "Show one config variable",
|
|
60
|
+
scope: "project",
|
|
61
|
+
args: [{ name: "key", description: "Variable key or id", required: true }],
|
|
62
|
+
async run(ctx, input) {
|
|
63
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
64
|
+
const rows = (await ctx.client.get(`${configPath(workspaceId, projectId)}/variables`));
|
|
65
|
+
return { data: findByKey(rows, arg(input, "key"), "variable") };
|
|
66
|
+
},
|
|
67
|
+
},
|
|
54
68
|
{
|
|
55
69
|
name: "config vars delete",
|
|
56
70
|
summary: "Delete a config variable",
|
|
57
71
|
scope: "project",
|
|
58
72
|
args: [{ name: "key", description: "Variable key or id", required: true }],
|
|
73
|
+
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
59
74
|
async run(ctx, input) {
|
|
60
75
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
61
76
|
const base = `${configPath(workspaceId, projectId)}/variables`;
|
|
62
77
|
const rows = (await ctx.client.get(base));
|
|
63
|
-
const
|
|
64
|
-
await ctx.
|
|
78
|
+
const row = findByKey(rows, arg(input, "key"), "variable");
|
|
79
|
+
await ctx.confirm(`Delete config variable ${row.key}?`, flagBool(input, "force"));
|
|
80
|
+
await ctx.client.del(`${base}/${row.id}`);
|
|
65
81
|
return { human: "Deleted." };
|
|
66
82
|
},
|
|
67
83
|
},
|
|
@@ -94,17 +110,30 @@ export const configCommands = [
|
|
|
94
110
|
return { data: await ctx.client.post(base, { key, value, environment_id: envId }) };
|
|
95
111
|
},
|
|
96
112
|
},
|
|
113
|
+
{
|
|
114
|
+
name: "config secrets get",
|
|
115
|
+
summary: "Show one secret's metadata (the value is never returned)",
|
|
116
|
+
scope: "project",
|
|
117
|
+
args: [{ name: "key", description: "Secret key or id", required: true }],
|
|
118
|
+
async run(ctx, input) {
|
|
119
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
120
|
+
const rows = (await ctx.client.get(`${configPath(workspaceId, projectId)}/secrets`));
|
|
121
|
+
return { data: findByKey(rows, arg(input, "key"), "secret") };
|
|
122
|
+
},
|
|
123
|
+
},
|
|
97
124
|
{
|
|
98
125
|
name: "config secrets delete",
|
|
99
126
|
summary: "Delete a secret",
|
|
100
127
|
scope: "project",
|
|
101
128
|
args: [{ name: "key", description: "Secret key or id", required: true }],
|
|
129
|
+
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
102
130
|
async run(ctx, input) {
|
|
103
131
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
104
132
|
const base = `${configPath(workspaceId, projectId)}/secrets`;
|
|
105
133
|
const rows = (await ctx.client.get(base));
|
|
106
|
-
const
|
|
107
|
-
await ctx.
|
|
134
|
+
const row = findByKey(rows, arg(input, "key"), "secret");
|
|
135
|
+
await ctx.confirm(`Delete secret ${row.key}?`, flagBool(input, "force"));
|
|
136
|
+
await ctx.client.del(`${base}/${row.id}`);
|
|
108
137
|
return { human: "Deleted." };
|
|
109
138
|
},
|
|
110
139
|
},
|
|
@@ -117,6 +146,27 @@ export const configCommands = [
|
|
|
117
146
|
return { data: await ctx.client.get(`${configPath(workspaceId, projectId)}/files`) };
|
|
118
147
|
},
|
|
119
148
|
},
|
|
149
|
+
{
|
|
150
|
+
name: "config files get",
|
|
151
|
+
summary: "Show one uploaded file's metadata",
|
|
152
|
+
scope: "project",
|
|
153
|
+
args: [{ name: "file", description: "File name or id", required: true }],
|
|
154
|
+
async run(ctx, input) {
|
|
155
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
156
|
+
const rows = (await ctx.client.get(`${configPath(workspaceId, projectId)}/files`));
|
|
157
|
+
const value = arg(input, "file");
|
|
158
|
+
const byId = rows.find((r) => r.id === value);
|
|
159
|
+
if (byId)
|
|
160
|
+
return { data: byId };
|
|
161
|
+
const byName = rows.filter((r) => r.name === value);
|
|
162
|
+
if (byName.length === 1)
|
|
163
|
+
return { data: byName[0] };
|
|
164
|
+
if (byName.length > 1) {
|
|
165
|
+
throw new UsageError(`Multiple files are named "${value}" (per-environment copies) — use the id instead`);
|
|
166
|
+
}
|
|
167
|
+
throw new UsageError(`No file with name or id "${value}"`);
|
|
168
|
+
},
|
|
169
|
+
},
|
|
120
170
|
{
|
|
121
171
|
name: "config files upload",
|
|
122
172
|
summary: "Upload a file",
|
|
@@ -126,8 +176,18 @@ export const configCommands = [
|
|
|
126
176
|
async run(ctx, input) {
|
|
127
177
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
128
178
|
const file = arg(input, "file");
|
|
179
|
+
let bytes;
|
|
180
|
+
try {
|
|
181
|
+
bytes = new Uint8Array(fs.readFileSync(file));
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
const code = err.code;
|
|
185
|
+
if (code === "ENOENT")
|
|
186
|
+
throw new UsageError(`file not found: ${file}`);
|
|
187
|
+
throw new UsageError(`cannot read ${file} (${code ?? err.message})`);
|
|
188
|
+
}
|
|
129
189
|
const form = new FormData();
|
|
130
|
-
form.set("file", new Blob([
|
|
190
|
+
form.set("file", new Blob([bytes]), path.basename(file));
|
|
131
191
|
const envId = flagStr(input, "env");
|
|
132
192
|
if (envId)
|
|
133
193
|
form.set("environment_id", envId);
|
package/dist/commands/inboxes.js
CHANGED
|
@@ -53,6 +53,18 @@ export const inboxCommands = [
|
|
|
53
53
|
};
|
|
54
54
|
},
|
|
55
55
|
},
|
|
56
|
+
{
|
|
57
|
+
name: "inbox list",
|
|
58
|
+
summary: "List the workspace's inboxes, newest first",
|
|
59
|
+
description: "Every inbox the workspace has minted with `beryl inbox create`. Expired inboxes " +
|
|
60
|
+
"stop receiving and are hard-deleted by a background sweep, so they drop off " +
|
|
61
|
+
"this list shortly after their TTL.",
|
|
62
|
+
scope: "workspace",
|
|
63
|
+
async run(ctx, input) {
|
|
64
|
+
const ws = await ctx.requireWorkspace(input);
|
|
65
|
+
return { data: await ctx.client.get(`/workspaces/${ws}/inboxes`) };
|
|
66
|
+
},
|
|
67
|
+
},
|
|
56
68
|
{
|
|
57
69
|
name: "inbox read",
|
|
58
70
|
summary: "Read the latest email from an inbox (waits for one to arrive)",
|
package/dist/commands/init.js
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } from "../beryl-test-skill.js";
|
|
4
6
|
import { LOCAL_CONFIG_FILENAME, loadConfig } from "../config.js";
|
|
5
7
|
import { CliError, UsageError } from "../errors.js";
|
|
6
8
|
import { ApiClient } from "../http.js";
|
|
7
|
-
import { bold, cyan, dim, green } from "../output.js";
|
|
9
|
+
import { bold, cyan, dim, green, yellow } from "../output.js";
|
|
10
|
+
import { cliVersion, warnIfStale } from "../version-check.js";
|
|
8
11
|
import { authCommands } from "./auth.js";
|
|
9
12
|
import { flagBool, flagStr } from "./util.js";
|
|
13
|
+
// Pinned to @latest (like the Playwright entry below) — an unpinned spec lets npx
|
|
14
|
+
// serve whatever stale global/cache install already resolves, so the agent's MCP
|
|
15
|
+
// server would silently miss newer tools. mergeMcpConfig compares entries verbatim,
|
|
16
|
+
// so re-running init also upgrades an old unpinned entry in place.
|
|
10
17
|
const MCP_SERVER_ENTRY = {
|
|
11
18
|
command: "npx",
|
|
12
|
-
args: ["-y", "@beryl-so/cli", "mcp"],
|
|
19
|
+
args: ["-y", "@beryl-so/cli@latest", "mcp"],
|
|
13
20
|
};
|
|
21
|
+
// Headless: the coding agent drives this browser to author tests — nobody watches the
|
|
22
|
+
// window, and a headed default breaks on CI / headless boxes. Matches how most devs
|
|
23
|
+
// already wire their own user-scoped playwright.
|
|
14
24
|
const PLAYWRIGHT_SERVER_ENTRY = {
|
|
15
25
|
command: "npx",
|
|
16
|
-
args: ["@playwright/mcp@latest"],
|
|
26
|
+
args: ["@playwright/mcp@latest", "--headless"],
|
|
17
27
|
};
|
|
18
28
|
const ACTION_PLAN_SCHEMA_URL = "https://api.beryl.so/api/v1/schemas/action-plan.schema.json";
|
|
19
29
|
// The authoring fork only appears when we create a fresh project with no tests yet; an
|
|
@@ -71,6 +81,36 @@ function mergeMcpConfig(file, withPlaywright) {
|
|
|
71
81
|
}
|
|
72
82
|
return { beryl, playwright };
|
|
73
83
|
}
|
|
84
|
+
// A tool's config lives per-user (same as the PAT it needs, in ~/.config/beryl) — so wire the
|
|
85
|
+
// MCP servers per-user too, not in a committed .mcp.json that 401s for every teammate until
|
|
86
|
+
// they run `beryl login` anyway. We DON'T hand-edit ~/.claude.json (Claude Code owns it; a
|
|
87
|
+
// corrupt write breaks the user's whole CLI) — we shell out to `claude mcp add` and let Claude
|
|
88
|
+
// own the edit. Returns false when the `claude` binary isn't on PATH so the caller can fall
|
|
89
|
+
// back to a copy-paste command.
|
|
90
|
+
function claudeUserAdd(name, entry) {
|
|
91
|
+
try {
|
|
92
|
+
execFileSync("claude", ["mcp", "add", name, "-s", "user", "--", entry.command, ...entry.args], {
|
|
93
|
+
stdio: "ignore",
|
|
94
|
+
});
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
// A non-zero exit ALSO covers "already exists" — but so does a missing binary. Distinguish:
|
|
99
|
+
// ENOENT means no `claude` on PATH (fall back), anything else means it ran and declined
|
|
100
|
+
// (already configured — treat as success, nothing to do).
|
|
101
|
+
if (err.code === "ENOENT")
|
|
102
|
+
return false;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function claudeAddHint(name, entry) {
|
|
107
|
+
return `claude mcp add ${name} -s user -- ${entry.command} ${entry.args.join(" ")}`;
|
|
108
|
+
}
|
|
109
|
+
// ~/.cursor/mcp.json is Cursor's user-scope config — small and ours to write safely, unlike
|
|
110
|
+
// ~/.claude.json — so we merge it directly rather than shelling out.
|
|
111
|
+
function cursorUserConfigPath() {
|
|
112
|
+
return path.join(os.homedir(), ".cursor", "mcp.json");
|
|
113
|
+
}
|
|
74
114
|
function detectEditors(cwd) {
|
|
75
115
|
const editors = [];
|
|
76
116
|
if (fs.existsSync(path.join(cwd, ".claude")) || fs.existsSync(path.join(cwd, "CLAUDE.md")))
|
|
@@ -103,9 +143,11 @@ export const initCommands = [
|
|
|
103
143
|
name: "init",
|
|
104
144
|
summary: "Set up Beryl in this repo — sign in, pin a project, wire up your coding agent",
|
|
105
145
|
description: "One-command onboarding: signs you in (emailed one-time code), pins this repo to a " +
|
|
106
|
-
"workspace and project via .beryl.json (offering to create the project), and
|
|
107
|
-
"MCP
|
|
108
|
-
"
|
|
146
|
+
"workspace and project via .beryl.json (offering to create the project), and wires the " +
|
|
147
|
+
"MCP servers for your coding agent. By default they're wired per-user (matching where your " +
|
|
148
|
+
"login token lives) — via `claude mcp add -s user` for Claude Code, ~/.cursor/mcp.json for " +
|
|
149
|
+
"Cursor; pass --scope project to write a committed .mcp.json for a shared repo instead. " +
|
|
150
|
+
"When creating a fresh project it asks how you want to author tests — locally with " +
|
|
109
151
|
"your own coding agent or by hand (the default), or by letting Beryl's agent explore and " +
|
|
110
152
|
"author them for you. Safe to re-run; every step skips what is already set up.",
|
|
111
153
|
interactive: true,
|
|
@@ -133,6 +175,15 @@ export const initCommands = [
|
|
|
133
175
|
description: "Which coding agent to write MCP config for (default: auto-detect)",
|
|
134
176
|
},
|
|
135
177
|
{ name: "no-pin", type: "boolean", description: "Skip writing .beryl.json" },
|
|
178
|
+
{
|
|
179
|
+
name: "scope",
|
|
180
|
+
type: "string",
|
|
181
|
+
enum: ["user", "project"],
|
|
182
|
+
description: "Where to wire the MCP servers. `user` (default) configures them per-user (matching " +
|
|
183
|
+
"where your Beryl login token lives) via `claude mcp add -s user` / ~/.cursor/mcp.json. " +
|
|
184
|
+
"`project` writes a committed .mcp.json for a shared repo — every teammate still runs " +
|
|
185
|
+
"`beryl login` to authenticate",
|
|
186
|
+
},
|
|
136
187
|
{
|
|
137
188
|
name: "local",
|
|
138
189
|
type: "boolean",
|
|
@@ -144,6 +195,7 @@ export const initCommands = [
|
|
|
144
195
|
examples: [
|
|
145
196
|
"npx @beryl-so/cli@latest init",
|
|
146
197
|
"beryl init --editor-tools claude-code",
|
|
198
|
+
"beryl init --scope project",
|
|
147
199
|
"beryl init --project https://app.example.com --authoring agent",
|
|
148
200
|
"beryl init --project https://app.example.com --authoring local --editor-tools none",
|
|
149
201
|
],
|
|
@@ -227,14 +279,35 @@ export const initCommands = [
|
|
|
227
279
|
// and local authoring needs the Playwright MCP — so default it on. `--no-local` (parsed
|
|
228
280
|
// as an explicit false) opts out.
|
|
229
281
|
const local = input.flags.local ?? editors.length > 0;
|
|
282
|
+
const scope = (flagStr(input, "scope") ?? "user");
|
|
230
283
|
for (const editor of editors) {
|
|
284
|
+
if (scope === "user" && editor === "claude-code") {
|
|
285
|
+
// Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
|
|
286
|
+
const berylOk = claudeUserAdd("beryl", MCP_SERVER_ENTRY);
|
|
287
|
+
const playwrightOk = local ? claudeUserAdd("playwright", PLAYWRIGHT_SERVER_ENTRY) : undefined;
|
|
288
|
+
if (berylOk) {
|
|
289
|
+
ctx.err(`${green("✓")} claude-code MCP configured ${dim("(user scope)")}`);
|
|
290
|
+
if (playwrightOk)
|
|
291
|
+
ctx.err(`${green("✓")} claude-code Playwright MCP configured ${dim("(user scope)")}`);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
ctx.err(yellow("• `claude` not on PATH — run these to wire user-scope MCP servers:"));
|
|
295
|
+
ctx.err(` ${cyan(claudeAddHint("beryl", MCP_SERVER_ENTRY))}`);
|
|
296
|
+
if (local)
|
|
297
|
+
ctx.err(` ${cyan(claudeAddHint("playwright", PLAYWRIGHT_SERVER_ENTRY))}`);
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
231
301
|
const file = editor === "claude-code"
|
|
232
302
|
? path.join(cwd, ".mcp.json")
|
|
233
|
-
:
|
|
303
|
+
: scope === "user"
|
|
304
|
+
? cursorUserConfigPath()
|
|
305
|
+
: path.join(cwd, ".cursor", "mcp.json");
|
|
234
306
|
const wrote = mergeMcpConfig(file, local);
|
|
235
|
-
|
|
307
|
+
const where = scope === "user" ? file : path.relative(cwd, file);
|
|
308
|
+
ctx.err(`${green("✓")} ${editor} MCP ${wrote.beryl ? "configured" : "already configured"} ${dim(where)}`);
|
|
236
309
|
if (wrote.playwright !== undefined)
|
|
237
|
-
ctx.err(`${green("✓")} ${editor} Playwright MCP ${wrote.playwright ? "configured" : "already configured"} ${dim(
|
|
310
|
+
ctx.err(`${green("✓")} ${editor} Playwright MCP ${wrote.playwright ? "configured" : "already configured"} ${dim(where)}`);
|
|
238
311
|
}
|
|
239
312
|
if (choice === "auto" && editors.length === 0)
|
|
240
313
|
ctx.err(dim("No coding agent detected — pass --editor-tools claude-code|cursor to wire one."));
|
|
@@ -263,6 +336,9 @@ export const initCommands = [
|
|
|
263
336
|
? `\n\nPlaywright MCP is wired — your coding agent can explore the site locally ` +
|
|
264
337
|
`and push tests with ${cyan("beryl tests create")}.`
|
|
265
338
|
: "");
|
|
339
|
+
// The .mcp.json entry is pinned to @latest, but a global install / old npx cache
|
|
340
|
+
// still wins resolution — so tell the user when the CLI they just ran is stale.
|
|
341
|
+
await warnIfStale(cliVersion(), (msg) => ctx.err(yellow(msg)));
|
|
266
342
|
return {
|
|
267
343
|
data: { workspace: workspaceId ?? null, project: projectId ?? null, editors },
|
|
268
344
|
human: nextSteps,
|
package/dist/commands/tests.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { UsageError } from "../errors.js";
|
|
3
3
|
import { lintPlan } from "../lint.js";
|
|
4
|
+
import { table } from "../output.js";
|
|
4
5
|
import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
|
|
5
6
|
import { arg, argList, flagBool, flagNum, flagStr, projectPath, readJsonFlag } from "./util.js";
|
|
6
7
|
const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
|
|
8
|
+
// The concise human table for `tests list` — the full TestResponse is a 24-column
|
|
9
|
+
// firehose of internal ids that wraps unreadably in a normal terminal. `--wide`
|
|
10
|
+
// (and `--json`) still expose every field.
|
|
11
|
+
const LIST_COLUMNS = ["title", "status", "id", "last_result_status", "last_run_at"];
|
|
12
|
+
function conciseTestRow(row) {
|
|
13
|
+
return {
|
|
14
|
+
title: row.nl_title,
|
|
15
|
+
status: row.quarantined ? "quarantined" : row.is_active ? "active" : "inactive",
|
|
16
|
+
id: row.id,
|
|
17
|
+
last_result_status: row.last_result_status,
|
|
18
|
+
last_run_at: row.last_run_at,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
7
21
|
export const testCommands = [
|
|
8
22
|
{
|
|
9
23
|
name: "tests lint",
|
|
@@ -33,17 +47,24 @@ export const testCommands = [
|
|
|
33
47
|
{
|
|
34
48
|
name: "tests list",
|
|
35
49
|
summary: "List the project's tests with their latest result",
|
|
50
|
+
description: "Prints a concise table by default (title / status / id / last result / last run). " +
|
|
51
|
+
"Pass --wide for every field, or --json for the raw records.",
|
|
36
52
|
scope: "project",
|
|
37
53
|
groupDefault: true,
|
|
38
54
|
groupSummary: "Author, inspect, version, and heal a project's tests — the checks Beryl runs on each run.",
|
|
39
|
-
flags: [
|
|
55
|
+
flags: [
|
|
56
|
+
{ name: "env", type: "string", description: "Filter by environment id" },
|
|
57
|
+
{ name: "wide", type: "boolean", description: "Show all columns, not the concise default" },
|
|
58
|
+
],
|
|
40
59
|
async run(ctx, input) {
|
|
41
60
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
61
|
+
const data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
|
|
62
|
+
environment_id: flagStr(input, "env"),
|
|
63
|
+
}));
|
|
64
|
+
if (flagBool(input, "wide"))
|
|
65
|
+
return { data };
|
|
66
|
+
// `data` stays the full records so --json is unchanged; only the human table is trimmed.
|
|
67
|
+
return { data, human: table(data.map(conciseTestRow), LIST_COLUMNS) };
|
|
47
68
|
},
|
|
48
69
|
},
|
|
49
70
|
{
|
package/dist/commands/util.js
CHANGED
|
@@ -29,11 +29,24 @@ export function flagNum(input, name) {
|
|
|
29
29
|
throw new UsageError(`--${name} must be a number`);
|
|
30
30
|
return n;
|
|
31
31
|
}
|
|
32
|
+
/** Read a local file for a flag/arg, turning fs errors (ENOENT, EACCES, EISDIR…) into a
|
|
33
|
+
* clean usage message instead of a raw Node stack. */
|
|
34
|
+
export function readFileArg(path, label) {
|
|
35
|
+
try {
|
|
36
|
+
return fs.readFileSync(path === "-" ? 0 : path, "utf8");
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
const code = err.code;
|
|
40
|
+
if (code === "ENOENT")
|
|
41
|
+
throw new UsageError(`${label}: file not found: ${path}`);
|
|
42
|
+
throw new UsageError(`${label}: cannot read ${path} (${code ?? err.message})`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
32
45
|
export function readJsonFlag(input, name) {
|
|
33
46
|
const file = flagStr(input, name);
|
|
34
47
|
if (!file)
|
|
35
48
|
throw new UsageError(`--${name} <file> is required`);
|
|
36
|
-
const text =
|
|
49
|
+
const text = readFileArg(file, `--${name}`);
|
|
37
50
|
try {
|
|
38
51
|
return JSON.parse(text);
|
|
39
52
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
// Best-effort "are we stale?" check against the npm registry. The `.mcp.json` entry
|
|
3
|
+
// `init` writes pins `@beryl-so/cli@latest`, but a user with a global install (or an
|
|
4
|
+
// old npx cache) can still be running a stale CLI — and an MCP server that's behind
|
|
5
|
+
// silently exposes fewer tools. So `init` and `beryl mcp` warn when the running
|
|
6
|
+
// version is behind the registry's `latest`. Never fatal: offline, a slow registry,
|
|
7
|
+
// or an unparseable response just skips the warning.
|
|
8
|
+
const REGISTRY_LATEST_URL = "https://registry.npmjs.org/@beryl-so/cli/latest";
|
|
9
|
+
const TIMEOUT_MS = 1500;
|
|
10
|
+
export function cliVersion() {
|
|
11
|
+
try {
|
|
12
|
+
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
13
|
+
return pkg.version;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return "unknown";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Numeric-triple compare; returns true only when `current` is strictly behind
|
|
20
|
+
// `latest`. Anything non-numeric (dev builds, "unknown", dist-tags) compares as
|
|
21
|
+
// not-behind so we never nag on unparseable versions.
|
|
22
|
+
export function isBehind(current, latest) {
|
|
23
|
+
const parse = (v) => {
|
|
24
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
|
25
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
26
|
+
};
|
|
27
|
+
const cur = parse(current);
|
|
28
|
+
const lat = parse(latest);
|
|
29
|
+
if (!cur || !lat)
|
|
30
|
+
return false;
|
|
31
|
+
for (let i = 0; i < 3; i++) {
|
|
32
|
+
if (cur[i] !== lat[i])
|
|
33
|
+
return cur[i] < lat[i];
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
/** The registry's `latest` version, or null when it can't be fetched quickly. */
|
|
38
|
+
export async function fetchLatestVersion() {
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(REGISTRY_LATEST_URL, {
|
|
41
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
return null;
|
|
45
|
+
const body = (await res.json());
|
|
46
|
+
return typeof body.version === "string" ? body.version : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Warn (via `warn`) when `current` is behind the registry's `latest`.
|
|
54
|
+
* Best-effort and quiet on any failure — safe to await on every startup.
|
|
55
|
+
* BERYL_NO_UPDATE_CHECK=1 opts out entirely (no network call).
|
|
56
|
+
*/
|
|
57
|
+
export async function warnIfStale(current, warn) {
|
|
58
|
+
if (process.env.BERYL_NO_UPDATE_CHECK)
|
|
59
|
+
return;
|
|
60
|
+
const latest = await fetchLatestVersion();
|
|
61
|
+
if (latest && isBehind(current, latest)) {
|
|
62
|
+
warn(`@beryl-so/cli ${current} is behind the latest release ${latest} — ` +
|
|
63
|
+
`some commands/MCP tools may be missing. Upgrade with: npm i -g @beryl-so/cli@latest`);
|
|
64
|
+
}
|
|
65
|
+
}
|
package/package.json
CHANGED