@beryl-so/cli 0.1.0 → 0.5.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 +200 -145
- package/dist/adapters/cli.js +60 -17
- package/dist/adapters/mcp.js +26 -9
- package/dist/artifacts.js +132 -0
- package/dist/beryl-test-skill.js +136 -0
- package/dist/commands/account.js +3 -0
- package/dist/commands/auth.js +77 -5
- package/dist/commands/config-vars.js +1 -0
- package/dist/commands/credentials.js +2 -0
- package/dist/commands/environments.js +2 -0
- package/dist/commands/explorations.js +1 -0
- package/dist/commands/init.js +99 -16
- package/dist/commands/projects.js +47 -5
- package/dist/commands/runs.js +190 -9
- package/dist/commands/tests.js +113 -4
- package/dist/commands/watch.js +26 -5
- package/dist/commands/workspaces.js +3 -0
- package/dist/context.js +1 -0
- package/dist/detect.js +88 -0
- package/dist/lint.js +125 -0
- package/dist/local-run.js +168 -0
- package/dist/registry/index.js +17 -1
- package/dist/schema.generated.js +1269 -0
- package/package.json +1 -1
package/dist/adapters/mcp.js
CHANGED
|
@@ -11,6 +11,12 @@ export function toolName(spec) {
|
|
|
11
11
|
export function mcpTools() {
|
|
12
12
|
return commands.filter((c) => !c.interactive && !c.hidden && c.name !== "mcp");
|
|
13
13
|
}
|
|
14
|
+
const mcpToolNames = new Set(mcpTools().map(toolName));
|
|
15
|
+
/** The tool name a command is exposed as under `beryl mcp`, or undefined if it isn't exposed. */
|
|
16
|
+
export function mcpToolFor(spec) {
|
|
17
|
+
const name = toolName(spec);
|
|
18
|
+
return mcpToolNames.has(name) ? name : undefined;
|
|
19
|
+
}
|
|
14
20
|
export function toolInputSchema(spec) {
|
|
15
21
|
const properties = {};
|
|
16
22
|
const required = [];
|
|
@@ -31,6 +37,24 @@ export function toolInputSchema(spec) {
|
|
|
31
37
|
}
|
|
32
38
|
return { type: "object", properties, ...(required.length ? { required } : {}) };
|
|
33
39
|
}
|
|
40
|
+
export function toolResult(result, lines) {
|
|
41
|
+
const parts = [...lines];
|
|
42
|
+
if (result.data !== undefined)
|
|
43
|
+
parts.push(JSON.stringify(result.data, null, 2));
|
|
44
|
+
else if (result.human)
|
|
45
|
+
parts.push(result.human);
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{ type: "text", text: parts.join("\n") || "ok" },
|
|
49
|
+
...(result.images ?? []).map((image) => ({
|
|
50
|
+
type: "image",
|
|
51
|
+
data: image.data,
|
|
52
|
+
mimeType: image.mimeType,
|
|
53
|
+
})),
|
|
54
|
+
],
|
|
55
|
+
isError: result.exitCode !== undefined && result.exitCode !== 0,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
34
58
|
function toInput(spec, params) {
|
|
35
59
|
const args = {};
|
|
36
60
|
const flags = {};
|
|
@@ -76,20 +100,13 @@ export async function serveMcp(baseCtx) {
|
|
|
76
100
|
config: baseCtx.config,
|
|
77
101
|
json: true,
|
|
78
102
|
interactive: false,
|
|
103
|
+
mcp: true,
|
|
79
104
|
out: push,
|
|
80
105
|
err: push,
|
|
81
106
|
});
|
|
82
107
|
try {
|
|
83
108
|
const result = (await spec.run(ctx, toInput(spec, request.params.arguments ?? {}))) ?? {};
|
|
84
|
-
|
|
85
|
-
if (result.data !== undefined)
|
|
86
|
-
parts.push(JSON.stringify(result.data, null, 2));
|
|
87
|
-
else if (result.human)
|
|
88
|
-
parts.push(result.human);
|
|
89
|
-
return {
|
|
90
|
-
content: [{ type: "text", text: parts.join("\n") || "ok" }],
|
|
91
|
-
isError: result.exitCode !== undefined && result.exitCode !== 0,
|
|
92
|
-
};
|
|
109
|
+
return toolResult(result, lines);
|
|
93
110
|
}
|
|
94
111
|
catch (err) {
|
|
95
112
|
const message = err instanceof CliError ? err.message : String(err);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
// Base64 inflates by 4/3, and the block is spent from the agent's context window —
|
|
5
|
+
// a few MB of screenshot would blow it. Skipped shots are reported, never dropped.
|
|
6
|
+
export const MAX_IMAGE_BYTES = 1_500_000;
|
|
7
|
+
export const FAILING_STATUSES = new Set(["failed", "errored", "error"]);
|
|
8
|
+
export function isFailing(status) {
|
|
9
|
+
return FAILING_STATUSES.has((status ?? "").toLowerCase());
|
|
10
|
+
}
|
|
11
|
+
export function resultsOf(run) {
|
|
12
|
+
return run.tests ?? run.test_results ?? [];
|
|
13
|
+
}
|
|
14
|
+
/** Directory name for one result. Sanitised: the id reaches us over the wire, and it
|
|
15
|
+
* is joined onto a user-supplied path — a `../` in it must not escape the output dir. */
|
|
16
|
+
export function resultId(result) {
|
|
17
|
+
const raw = result.test_result_id ?? result.id ?? result.test_case_id ?? "result";
|
|
18
|
+
const safe = raw.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[.]+/, "");
|
|
19
|
+
return safe || "result";
|
|
20
|
+
}
|
|
21
|
+
function extensionFor(url, fallback) {
|
|
22
|
+
// Presigned S3 URLs carry the signature in the query string — the extension is
|
|
23
|
+
// on the path, so parse rather than regex the whole URL.
|
|
24
|
+
try {
|
|
25
|
+
const ext = path.extname(new URL(url).pathname);
|
|
26
|
+
return ext || fallback;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function mimeFor(url) {
|
|
33
|
+
const ext = extensionFor(url, ".png").toLowerCase();
|
|
34
|
+
if (ext === ".jpg" || ext === ".jpeg")
|
|
35
|
+
return "image/jpeg";
|
|
36
|
+
if (ext === ".webp")
|
|
37
|
+
return "image/webp";
|
|
38
|
+
return "image/png";
|
|
39
|
+
}
|
|
40
|
+
async function fetchBytes(url) {
|
|
41
|
+
// Presigned URLs authenticate via their own signature — never attach the API
|
|
42
|
+
// bearer token, and never route them through ApiClient (it forces the API base).
|
|
43
|
+
let response;
|
|
44
|
+
try {
|
|
45
|
+
response = await fetch(url);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
throw new CliError(`Could not fetch artifact: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new CliError(`Could not fetch artifact (HTTP ${response.status}) — presigned URLs are ` +
|
|
52
|
+
"short-lived; re-run the command to get fresh ones.");
|
|
53
|
+
}
|
|
54
|
+
return Buffer.from(await response.arrayBuffer());
|
|
55
|
+
}
|
|
56
|
+
export async function fetchImage(url) {
|
|
57
|
+
const bytes = await fetchBytes(url);
|
|
58
|
+
if (bytes.byteLength > MAX_IMAGE_BYTES) {
|
|
59
|
+
throw new CliError(`Screenshot is ${bytes.byteLength} bytes — too large to inline ` +
|
|
60
|
+
`(limit ${MAX_IMAGE_BYTES}); fetch it from its URL instead.`);
|
|
61
|
+
}
|
|
62
|
+
return { data: bytes.toString("base64"), mimeType: mimeFor(url) };
|
|
63
|
+
}
|
|
64
|
+
/** Failure screenshots as MCP image content. Never fails the command — an agent
|
|
65
|
+
* losing a picture must still get the run JSON — but it does say what it lost. */
|
|
66
|
+
export async function failureImages(run, limit) {
|
|
67
|
+
const failing = resultsOf(run).filter((r) => isFailing(r.status) && r.screenshot_url);
|
|
68
|
+
const skipped = [];
|
|
69
|
+
if (failing.length > limit) {
|
|
70
|
+
skipped.push(`${failing.length - limit} more failure screenshot(s) not attached (limit ${limit}) — ` +
|
|
71
|
+
"fetch them from the screenshot_url values above.");
|
|
72
|
+
}
|
|
73
|
+
const settled = await Promise.allSettled(failing.slice(0, limit).map((r) => fetchImage(r.screenshot_url)));
|
|
74
|
+
const images = [];
|
|
75
|
+
settled.forEach((s, i) => {
|
|
76
|
+
if (s.status === "fulfilled")
|
|
77
|
+
images.push(s.value);
|
|
78
|
+
else
|
|
79
|
+
skipped.push(`${resultId(failing[i])}: ${s.reason.message}`);
|
|
80
|
+
});
|
|
81
|
+
return { images, skipped };
|
|
82
|
+
}
|
|
83
|
+
function pendingFor(result, withFrames) {
|
|
84
|
+
const id = resultId(result);
|
|
85
|
+
const items = [];
|
|
86
|
+
const add = (url, kind, name) => {
|
|
87
|
+
if (url)
|
|
88
|
+
items.push({ url, file: path.join(id, name), kind, testResultId: id });
|
|
89
|
+
};
|
|
90
|
+
const ext = (url, fallback) => extensionFor(url ?? "", fallback);
|
|
91
|
+
add(result.screenshot_url, "screenshot", `screenshot${ext(result.screenshot_url, ".png")}`);
|
|
92
|
+
add(result.dom_snapshot_url, "dom_snapshot", `dom-snapshot${ext(result.dom_snapshot_url, ".html")}`);
|
|
93
|
+
add(result.trace_url, "trace", `trace${ext(result.trace_url, ".zip")}`);
|
|
94
|
+
if (withFrames) {
|
|
95
|
+
(result.frame_urls ?? []).forEach((url, i) => {
|
|
96
|
+
const n = String(i + 1).padStart(3, "0");
|
|
97
|
+
add(url, "frame", path.join("frames", `${n}${extensionFor(url, ".png")}`));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return items;
|
|
101
|
+
}
|
|
102
|
+
const DOWNLOAD_CONCURRENCY = 6;
|
|
103
|
+
export async function downloadRunArtifacts(run, directory, options = {}) {
|
|
104
|
+
const manifest = path.join(directory, "run.json");
|
|
105
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
106
|
+
fs.writeFileSync(manifest, JSON.stringify(run, null, 2) + "\n");
|
|
107
|
+
// A green test's filmstrip is bulk nobody reads, and every URL here expires within
|
|
108
|
+
// the hour — so by default only failing tests bring their frames down.
|
|
109
|
+
const pending = resultsOf(run).flatMap((r) => pendingFor(r, options.allFrames === true || isFailing(r.status)));
|
|
110
|
+
const artifacts = [];
|
|
111
|
+
const failures = [];
|
|
112
|
+
let next = 0;
|
|
113
|
+
const worker = async () => {
|
|
114
|
+
while (next < pending.length) {
|
|
115
|
+
const item = pending[next++];
|
|
116
|
+
const target = path.join(directory, item.file);
|
|
117
|
+
try {
|
|
118
|
+
const bytes = await fetchBytes(item.url);
|
|
119
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
120
|
+
fs.writeFileSync(target, bytes);
|
|
121
|
+
artifacts.push({ test_result_id: item.testResultId, kind: item.kind, path: target });
|
|
122
|
+
options.onProgress?.(`${item.file} (${bytes.byteLength} bytes)`);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
// One expired/missing artifact must not cost us the rest of the bundle.
|
|
126
|
+
failures.push({ url: item.url, error: err.message });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
await Promise.all(Array.from({ length: Math.min(DOWNLOAD_CONCURRENCY, pending.length) }, worker));
|
|
131
|
+
return { directory, manifest, artifacts, failures };
|
|
132
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The `beryl-test` authoring skill, installed by `beryl init` into
|
|
2
|
+
// `.agents/skills/beryl-test/SKILL.md` (vendor-neutral, editor-agnostic). Kept as an
|
|
3
|
+
// embedded string so it ships in the published package (`files: ["dist"]`) with no
|
|
4
|
+
// build-time asset copy, and so there is ONE source for the guidance — not a copy in
|
|
5
|
+
// the CLI and another in the docs. Edit here; `init` writes it verbatim.
|
|
6
|
+
export const BERYL_TEST_SKILL_FILENAME = "SKILL.md";
|
|
7
|
+
export const BERYL_TEST_SKILL_DIR = "beryl-test";
|
|
8
|
+
export const BERYL_TEST_SKILL = `---
|
|
9
|
+
name: beryl-test
|
|
10
|
+
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\`.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Authoring Beryl tests
|
|
14
|
+
|
|
15
|
+
Beryl is automated web-app testing: a test drives a real browser through a flow and
|
|
16
|
+
asserts the flow reached its meaningful outcome. \`beryl init\` has already wired two MCP
|
|
17
|
+
servers for you — **beryl** (create/list/run tests) and **playwright** (drive a real
|
|
18
|
+
browser). Your job is to author tests that keep passing as the app's markup drifts,
|
|
19
|
+
because Beryl can **heal** them — but only when you give it what it needs to.
|
|
20
|
+
|
|
21
|
+
Read this before authoring. The three ideas that make a test durable: a real **outcome
|
|
22
|
+
assertion**, a strong **natural-language intent**, and the **local run-fix loop**.
|
|
23
|
+
|
|
24
|
+
## 1. Author locally over the Playwright MCP
|
|
25
|
+
|
|
26
|
+
1. **Drive the flow in a real browser first.** Use the Playwright MCP to open the app and
|
|
27
|
+
walk the flow by hand — log in, fill the form, submit, whatever the flow is. You act on
|
|
28
|
+
elements by their accessibility ref from the latest page snapshot, not a guessed
|
|
29
|
+
selector. Watch what actually happens; don't author from imagination.
|
|
30
|
+
2. **Write it as an ActionPlan** — a JSON object whose \`steps\` are
|
|
31
|
+
\`{action, selector, url, value, ...}\`. Two structural rules the plan must satisfy:
|
|
32
|
+
- the **first executed step is a \`goto\`** (the flow has to start by navigating somewhere), and
|
|
33
|
+
- **at least one step is an \`expect\`** (a test that asserts nothing is not a test).
|
|
34
|
+
Optional \`before\` / \`after\` arrays hold setup and teardown; \`after\` runs even when a
|
|
35
|
+
main step fails, so a create/update/delete flow can clean up the record it made.
|
|
36
|
+
3. **Validate offline, then create:**
|
|
37
|
+
\`\`\`
|
|
38
|
+
beryl tests lint --file plan.json # check the plan against the schema, no network
|
|
39
|
+
beryl tests create --title "Log in" --file plan.json \\
|
|
40
|
+
--description "<the intent — see §3>"
|
|
41
|
+
\`\`\`
|
|
42
|
+
By default \`create\` verifies the plan in a real browser before accepting it. The full
|
|
43
|
+
ActionPlan JSON Schema is at
|
|
44
|
+
https://api.beryl.so/api/v1/schemas/action-plan.schema.json.
|
|
45
|
+
|
|
46
|
+
### The outcome assertion is the whole game
|
|
47
|
+
|
|
48
|
+
A flow is only worth banking if you can point at the **success signal** — the one
|
|
49
|
+
observable proof the flow worked. Get this right and everything else follows.
|
|
50
|
+
|
|
51
|
+
- The signal must be **true only if the flow succeeded**. A confirmation message that
|
|
52
|
+
appeared, an element that showed up or disappeared, content unique to where the flow
|
|
53
|
+
landed.
|
|
54
|
+
- **Never assert global chrome** — the nav bar, logo, footer, or cookie banner is on every
|
|
55
|
+
page, so asserting it tests nothing. "Was there anyway" means site-wide chrome, NOT the
|
|
56
|
+
destination's own distinctive content.
|
|
57
|
+
- For a **navigation** flow, the strongest signal is that the destination actually
|
|
58
|
+
**rendered**: assert its unique heading or a piece of content specific to that page (for
|
|
59
|
+
\`/pricing\`, the "Pricing" H1 or a plan name). Prefer that over the URL alone — a bare
|
|
60
|
+
"the URL is /pricing" passes even on a blank or broken page that never rendered.
|
|
61
|
+
Reserve a URL-only assertion for when the URL *is* the outcome (a form that lands on
|
|
62
|
+
\`/thank-you\`) and no distinctive destination content is available.
|
|
63
|
+
- \`expect\` kinds: \`text\` (a string is visible), \`url\` (the URL contains a value), \`gone\`
|
|
64
|
+
(an element disappeared — e.g. a spinner, or the item you just deleted).
|
|
65
|
+
- **If you can't name a success signal, the flow is not test-worthy.** Don't bank a test
|
|
66
|
+
that verifies nothing. Explore a different flow instead.
|
|
67
|
+
- **Don't work around a real app failure to make a test go green.** If the flow is
|
|
68
|
+
genuinely broken in the app, that's a finding to report — not something to paper over
|
|
69
|
+
with a weaker assertion.
|
|
70
|
+
|
|
71
|
+
## 2. What "durable" and "healable" mean here
|
|
72
|
+
|
|
73
|
+
Beryl's cloud runs your test on a schedule. When the app's markup drifts and a selector
|
|
74
|
+
stops matching, a heal-vs-fail agent decides whether to **heal** the test (silently
|
|
75
|
+
re-derive the selector/trajectory and keep it green) or **fail** it (surface a real
|
|
76
|
+
regression). It decides that against your test's **intent**:
|
|
77
|
+
|
|
78
|
+
- **The natural-language intent is the immutable anchor. Beryl never rewrites it.** It's
|
|
79
|
+
the description of what the test proves — the load-bearing statement the heal agent
|
|
80
|
+
judges every future run against.
|
|
81
|
+
- **Selectors and the trajectory are the healable "how".** A button moved, a class name
|
|
82
|
+
changed, a step needs an extra click — those are mechanics Beryl can re-derive on its
|
|
83
|
+
own, because your intent tells it what the flow was *for*.
|
|
84
|
+
- **A failed outcome assertion is a real regression Beryl will NOT silently heal green.**
|
|
85
|
+
If the success signal from §1 stops holding — the confirmation never appears, the page
|
|
86
|
+
never renders — that's the app breaking, and the test fails loudly. That is the point.
|
|
87
|
+
|
|
88
|
+
So a test is *healable* exactly when you gave it **a strong intent + a real outcome
|
|
89
|
+
assertion**. A test with a vague intent and a chrome-only assertion is brittle: Beryl
|
|
90
|
+
can't tell a real regression from cosmetic drift, so it either heals over real breakage or
|
|
91
|
+
fails on noise.
|
|
92
|
+
|
|
93
|
+
## 3. Writing the natural-language intent
|
|
94
|
+
|
|
95
|
+
Pass the intent as \`--description\` on \`beryl tests create\` (or \`tests set-plan\` when you
|
|
96
|
+
re-author). 1–3 sentences. This is the immutable anchor from §2 — write it well.
|
|
97
|
+
|
|
98
|
+
- **State the purpose, not the steps.** Not "clicks Sign in, types email and password,
|
|
99
|
+
clicks submit" — that's the trajectory, which Beryl already has and which will change.
|
|
100
|
+
Instead: *what does a green run prove is true about the app?*
|
|
101
|
+
- **Name the one observable outcome** that is true only if the flow worked — the same
|
|
102
|
+
success signal you asserted in §1, in words.
|
|
103
|
+
- **Never describe global chrome.** The intent is about the flow's destination and
|
|
104
|
+
outcome, not "the header is present".
|
|
105
|
+
|
|
106
|
+
Good:
|
|
107
|
+
> "Proves a returning user can sign in: after submitting valid credentials, the
|
|
108
|
+
> authenticated dashboard with the user's name in the header loads."
|
|
109
|
+
|
|
110
|
+
Weak (describes steps + asserts nothing meaningful):
|
|
111
|
+
> "Goes to /login, fills the form, and checks the page loaded."
|
|
112
|
+
|
|
113
|
+
## 4. The local run-fix loop
|
|
114
|
+
|
|
115
|
+
Iterate on your machine before you rely on the cloud. \`beryl runs local\` fetches the
|
|
116
|
+
test's rendered spec and runs it with your local \`@playwright/test\` — no cloud, no waiting
|
|
117
|
+
for a scheduled run.
|
|
118
|
+
|
|
119
|
+
\`\`\`
|
|
120
|
+
npm i -D @playwright/test && npx playwright install # once
|
|
121
|
+
beryl runs local <test-id> --url-override http://localhost:3000 --dir ./beryl-local
|
|
122
|
+
\`\`\`
|
|
123
|
+
|
|
124
|
+
- \`--url-override\` points the run at a local dev server or a preview deploy.
|
|
125
|
+
- \`--dir\` keeps the **spec, artifacts, and a JSON \`report.json\`** on disk so you (or your
|
|
126
|
+
coding agent) can read exactly what happened and iterate: read the report, see which step
|
|
127
|
+
or assertion failed and why, fix the plan, \`beryl tests set-plan\`, run again.
|
|
128
|
+
- It exits **0** if every test passed, **1** on a failure — so it drops straight into a
|
|
129
|
+
run-fix-run loop.
|
|
130
|
+
- **v1 is public / unauthenticated flows only.** A test that signs in first runs only in
|
|
131
|
+
Beryl's cloud (which holds the encrypted session — it's never handed to your disk);
|
|
132
|
+
\`runs local\` refuses it. Run those with \`beryl runs trigger\`.
|
|
133
|
+
|
|
134
|
+
Once the test passes locally against a real outcome, it's ready to bank and let Beryl run
|
|
135
|
+
and heal it.
|
|
136
|
+
`;
|
package/dist/commands/account.js
CHANGED
|
@@ -4,6 +4,7 @@ export const accountCommands = [
|
|
|
4
4
|
{
|
|
5
5
|
name: "account get",
|
|
6
6
|
summary: "Show your account profile",
|
|
7
|
+
groupSummary: "View and update your personal account profile.",
|
|
7
8
|
async run(ctx) {
|
|
8
9
|
return { data: await ctx.client.get("/account/") };
|
|
9
10
|
},
|
|
@@ -36,6 +37,7 @@ export const accountCommands = [
|
|
|
36
37
|
{
|
|
37
38
|
name: "feedback send",
|
|
38
39
|
summary: "Send product feedback to the Beryl team",
|
|
40
|
+
groupSummary: "Send product feedback to the Beryl team.",
|
|
39
41
|
args: [{ name: "message", description: "Your feedback", required: true }],
|
|
40
42
|
async run(ctx, input) {
|
|
41
43
|
return { data: await ctx.client.post("/feedback", { message: arg(input, "message") }) };
|
|
@@ -45,6 +47,7 @@ export const accountCommands = [
|
|
|
45
47
|
name: "billing usage",
|
|
46
48
|
summary: "Show plan usage: services and weekly AI units",
|
|
47
49
|
scope: "workspace",
|
|
50
|
+
groupSummary: "Review a workspace's plan usage, subscription, and invoices.",
|
|
48
51
|
async run(ctx, input) {
|
|
49
52
|
const ws = await ctx.requireWorkspace(input);
|
|
50
53
|
return { data: await ctx.client.get(`/workspaces/${ws}/billing/usage`) };
|
package/dist/commands/auth.js
CHANGED
|
@@ -1,9 +1,53 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import os from "node:os";
|
|
2
3
|
import { saveGlobalConfig } from "../config.js";
|
|
3
4
|
import { CliError, UsageError } from "../errors.js";
|
|
4
5
|
import { ApiClient } from "../http.js";
|
|
5
6
|
import { dim, green } from "../output.js";
|
|
6
7
|
import { arg, flagBool, flagStr } from "./util.js";
|
|
8
|
+
function gitEmail() {
|
|
9
|
+
try {
|
|
10
|
+
const email = execFileSync("git", ["config", "--get", "user.email"], {
|
|
11
|
+
encoding: "utf8",
|
|
12
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
13
|
+
}).trim();
|
|
14
|
+
return email.includes("@") ? email : undefined;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// The address the user most likely wants: whoever the stored token belongs to, else the
|
|
21
|
+
// git identity. Only ever a prompt default — never signs anyone in without a confirm.
|
|
22
|
+
async function suggestedEmail(ctx) {
|
|
23
|
+
if (ctx.config.token) {
|
|
24
|
+
try {
|
|
25
|
+
const me = (await ctx.client.get("/account/"));
|
|
26
|
+
if (me.email)
|
|
27
|
+
return me.email;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// a stale/invalid token is exactly why they're logging in again
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return gitEmail();
|
|
34
|
+
}
|
|
35
|
+
async function promptForEmail(ctx) {
|
|
36
|
+
const suggestion = await suggestedEmail(ctx);
|
|
37
|
+
const answer = await ctx.prompt(suggestion ? `Email [${suggestion}]: ` : "Email: ");
|
|
38
|
+
return answer || suggestion || "";
|
|
39
|
+
}
|
|
40
|
+
export async function promptForOtpCode(ctx) {
|
|
41
|
+
let code = "";
|
|
42
|
+
for (let attempt = 0; attempt < 3 && !/^\d{6}$/.test(code); attempt++) {
|
|
43
|
+
if (attempt > 0)
|
|
44
|
+
ctx.err("The code is the 6 digits from the email.");
|
|
45
|
+
code = (await ctx.prompt("Code: ")).replace(/\s/g, "");
|
|
46
|
+
}
|
|
47
|
+
if (!/^\d{6}$/.test(code))
|
|
48
|
+
throw new UsageError("No valid 6-digit code entered");
|
|
49
|
+
return code;
|
|
50
|
+
}
|
|
7
51
|
export const authCommands = [
|
|
8
52
|
{
|
|
9
53
|
name: "login",
|
|
@@ -14,7 +58,12 @@ export const authCommands = [
|
|
|
14
58
|
interactive: true,
|
|
15
59
|
flags: [
|
|
16
60
|
{ name: "token", type: "string", description: "Use an existing personal access token" },
|
|
17
|
-
{
|
|
61
|
+
{
|
|
62
|
+
name: "email",
|
|
63
|
+
type: "string",
|
|
64
|
+
description: "Email for the one-time code sign-in (default: your last sign-in, or your git " +
|
|
65
|
+
"user.email — offered as the prompt default)",
|
|
66
|
+
},
|
|
18
67
|
{
|
|
19
68
|
name: "token-name",
|
|
20
69
|
type: "string",
|
|
@@ -26,13 +75,15 @@ export const authCommands = [
|
|
|
26
75
|
const apiUrl = ctx.client.baseUrl;
|
|
27
76
|
let token = flagStr(input, "token");
|
|
28
77
|
if (!token) {
|
|
29
|
-
const email = flagStr(input, "email") ?? (await ctx
|
|
78
|
+
const email = flagStr(input, "email") ?? (await promptForEmail(ctx));
|
|
30
79
|
if (!email.includes("@"))
|
|
31
80
|
throw new UsageError(`"${email}" is not an email address`);
|
|
32
81
|
const anon = new ApiClient(apiUrl);
|
|
33
82
|
await anon.post("/auth/request-login-otp", { email });
|
|
34
83
|
ctx.err(dim(`Sent a 6-digit code to ${email}`));
|
|
35
|
-
|
|
84
|
+
ctx.err(dim("Not arriving? Check spam; if you're new to Beryl, sign up at https://beryl.so first — " +
|
|
85
|
+
"or use `beryl login --token` with a token from beryl.so → Account → API tokens."));
|
|
86
|
+
const code = await promptForOtpCode(ctx);
|
|
36
87
|
const login = (await anon.post("/auth/verify-otp", { email, code }));
|
|
37
88
|
if (!login.access_token)
|
|
38
89
|
throw new CliError("Login did not return an access token");
|
|
@@ -44,13 +95,33 @@ export const authCommands = [
|
|
|
44
95
|
}
|
|
45
96
|
const authed = new ApiClient(apiUrl, token);
|
|
46
97
|
const me = (await authed.get("/account/"));
|
|
98
|
+
let sole;
|
|
99
|
+
let workspaceCount = 0;
|
|
100
|
+
try {
|
|
101
|
+
const workspaces = (await authed.get("/workspaces/"));
|
|
102
|
+
workspaceCount = workspaces.length;
|
|
103
|
+
if (workspaces.length === 1)
|
|
104
|
+
sole = workspaces[0];
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// pinning a default is a nicety — never fail the login over it
|
|
108
|
+
}
|
|
47
109
|
const saved = saveGlobalConfig({
|
|
48
110
|
token,
|
|
49
111
|
api_url: apiUrl === "https://api.beryl.so" ? undefined : apiUrl,
|
|
112
|
+
...(sole && sole.id !== ctx.config.workspace
|
|
113
|
+
? { workspace: sole.id, project: undefined }
|
|
114
|
+
: {}),
|
|
50
115
|
});
|
|
116
|
+
let human = `${green("Logged in")} as ${me.name} <${me.email}>`;
|
|
117
|
+
if (sole)
|
|
118
|
+
human += `\n${dim(`Default workspace: ${sole.name} (${sole.id})`)}`;
|
|
119
|
+
else if (workspaceCount > 1 && !ctx.config.workspace)
|
|
120
|
+
human += `\n${dim("Pick a default workspace with `beryl workspaces use <id>`")}`;
|
|
121
|
+
human += `\n${dim(`Token saved to ${saved}`)}`;
|
|
51
122
|
return {
|
|
52
|
-
data: { email: me.email, name: me.name, config: saved },
|
|
53
|
-
human
|
|
123
|
+
data: { email: me.email, name: me.name, workspace: sole?.id ?? null, config: saved },
|
|
124
|
+
human,
|
|
54
125
|
};
|
|
55
126
|
},
|
|
56
127
|
},
|
|
@@ -98,6 +169,7 @@ export const authCommands = [
|
|
|
98
169
|
{
|
|
99
170
|
name: "tokens list",
|
|
100
171
|
summary: "List your personal access tokens",
|
|
172
|
+
groupSummary: "Manage the personal access tokens that authenticate the CLI and CI.",
|
|
101
173
|
async run(ctx) {
|
|
102
174
|
const tokens = (await ctx.client.get("/account/tokens"));
|
|
103
175
|
return { data: tokens };
|
|
@@ -17,6 +17,7 @@ export const configCommands = [
|
|
|
17
17
|
name: "config vars list",
|
|
18
18
|
summary: "List the project's config variables (visible to the agent during runs)",
|
|
19
19
|
scope: "project",
|
|
20
|
+
groupSummary: "Manage the variables, secrets, and files the agent can use while exploring and running.",
|
|
20
21
|
async run(ctx, input) {
|
|
21
22
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
22
23
|
return { data: await ctx.client.get(`${configPath(workspaceId, projectId)}/variables`) };
|
|
@@ -6,6 +6,7 @@ export const credentialCommands = [
|
|
|
6
6
|
name: "credentials list",
|
|
7
7
|
summary: "List the workspace's saved logins",
|
|
8
8
|
scope: "workspace",
|
|
9
|
+
groupSummary: "Manage saved logins Beryl reuses to test behind authentication, and attach them to projects.",
|
|
9
10
|
async run(ctx, input) {
|
|
10
11
|
const ws = await ctx.requireWorkspace(input);
|
|
11
12
|
return { data: await ctx.client.get(`/workspaces/${ws}/credentials`) };
|
|
@@ -103,6 +104,7 @@ export const credentialCommands = [
|
|
|
103
104
|
name: "auth-capture start",
|
|
104
105
|
summary: "Start a login-capture browser session for the project (non-interactive)",
|
|
105
106
|
scope: "project",
|
|
107
|
+
groupSummary: "Drive a browser session that captures a target-site login for Beryl to reuse.",
|
|
106
108
|
async run(ctx, input) {
|
|
107
109
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
108
110
|
return { data: await ctx.client.post(capturePath(workspaceId, projectId)) };
|
|
@@ -5,6 +5,7 @@ export const environmentCommands = [
|
|
|
5
5
|
name: "envs list",
|
|
6
6
|
summary: "List a project's environments",
|
|
7
7
|
scope: "project",
|
|
8
|
+
groupSummary: "Manage a project's environments — the URLs and auth Beryl runs tests against.",
|
|
8
9
|
async run(ctx, input) {
|
|
9
10
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
10
11
|
return { data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/environments`) };
|
|
@@ -95,6 +96,7 @@ export const environmentCommands = [
|
|
|
95
96
|
name: "schedule get",
|
|
96
97
|
summary: "Show the project's daily/weekly run schedule",
|
|
97
98
|
scope: "project",
|
|
99
|
+
groupSummary: "View and set the schedule on which Beryl runs a project's tests automatically.",
|
|
98
100
|
async run(ctx, input) {
|
|
99
101
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
100
102
|
return { data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/schedule`) };
|
|
@@ -3,6 +3,7 @@ import { watchExploration } from "./watch.js";
|
|
|
3
3
|
export const explorationCommands = [
|
|
4
4
|
{
|
|
5
5
|
name: "explorations list",
|
|
6
|
+
groupSummary: "Inspect the agent's exploration runs — how it crawled a site and authored its tests.",
|
|
6
7
|
summary: "List the agent's exploration passes for a project",
|
|
7
8
|
scope: "project",
|
|
8
9
|
async run(ctx, input) {
|