@beryl-so/cli 0.1.0 → 0.2.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 +11 -1
- package/dist/adapters/cli.js +21 -6
- package/dist/adapters/mcp.js +20 -9
- package/dist/artifacts.js +132 -0
- package/dist/commands/auth.js +76 -5
- package/dist/commands/init.js +6 -2
- package/dist/commands/projects.js +46 -5
- package/dist/commands/runs.js +87 -8
- package/dist/commands/tests.js +91 -2
- package/dist/commands/watch.js +26 -5
- package/dist/context.js +1 -0
- package/dist/detect.js +88 -0
- package/dist/lint.js +125 -0
- package/dist/registry/index.js +14 -1
- package/dist/schema.generated.js +1118 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -122,6 +122,8 @@ Every non-interactive command below is exposed as an MCP tool with the same name
|
|
|
122
122
|
|
|
123
123
|
### projects
|
|
124
124
|
|
|
125
|
+
`beryl projects` with no subcommand runs `projects list`.
|
|
126
|
+
|
|
125
127
|
| Command | Summary |
|
|
126
128
|
| --- | --- |
|
|
127
129
|
| `beryl projects list` | List projects in the workspace |
|
|
@@ -154,13 +156,19 @@ Every non-interactive command below is exposed as an MCP tool with the same name
|
|
|
154
156
|
|
|
155
157
|
### tests
|
|
156
158
|
|
|
159
|
+
`beryl tests` with no subcommand runs `tests list`.
|
|
160
|
+
|
|
157
161
|
| Command | Summary |
|
|
158
162
|
| --- | --- |
|
|
163
|
+
| `beryl tests lint` | Validate a plan JSON file offline, before sending it to the server |
|
|
159
164
|
| `beryl tests list` | List the project's tests with their latest result |
|
|
160
165
|
| `beryl tests get <test-id>` | Show one test |
|
|
161
166
|
| `beryl tests plan <test-id>` | Print a test's current step plan (JSON) |
|
|
162
167
|
| `beryl tests create` | Create a test case from a JSON action plan — for tests authored locally, e.g. by your coding agent |
|
|
163
168
|
| `beryl tests set-plan <test-id>` | Replace a test's step plan from a JSON file (creates a new version) |
|
|
169
|
+
| `beryl tests rename <test-id> <title>` | Rename a test |
|
|
170
|
+
| `beryl tests quarantine <test-id> <state>` | Mute a flaky test: it keeps running, but its failures stop failing the run |
|
|
171
|
+
| `beryl tests delete <test-id>` | Delete a test, its version history, and its results |
|
|
164
172
|
| `beryl tests recompile <test-id>` | Validate + verify an edited plan against the live site before persisting |
|
|
165
173
|
| `beryl tests versions <test-id>` | List a test's version history |
|
|
166
174
|
| `beryl tests version <test-id> <version-no>` | Show one specific version of a test (including its plan) |
|
|
@@ -174,6 +182,8 @@ Every non-interactive command below is exposed as an MCP tool with the same name
|
|
|
174
182
|
|
|
175
183
|
### runs
|
|
176
184
|
|
|
185
|
+
`beryl runs` with no subcommand runs `runs list`.
|
|
186
|
+
|
|
177
187
|
| Command | Summary |
|
|
178
188
|
| --- | --- |
|
|
179
189
|
| `beryl runs trigger` | Trigger a test run (whole suite, a subset, or one environment) |
|
|
@@ -182,7 +192,7 @@ Every non-interactive command below is exposed as an MCP tool with the same name
|
|
|
182
192
|
| `beryl runs watch <run-id>` | Attach to a run and stream progress until it finishes |
|
|
183
193
|
| `beryl runs cancel <run-id>` | Cancel an in-flight run |
|
|
184
194
|
| `beryl runs report <run-id>` | Show the generated report for a run |
|
|
185
|
-
| `beryl runs download <run-id>` | Download a run's
|
|
195
|
+
| `beryl runs download <run-id>` | Download a run's results, with its artifacts, to disk |
|
|
186
196
|
| `beryl runs explain <result-id>` | AI explanation of why a test result failed |
|
|
187
197
|
|
|
188
198
|
### explorations
|
package/dist/adapters/cli.js
CHANGED
|
@@ -90,6 +90,10 @@ export function parseArgv(spec, tokens) {
|
|
|
90
90
|
throw new UsageError(`Unknown flag --${name} for \`beryl ${spec.name}\``);
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
+
// `--help` wins over every completeness check below: you ask for help precisely when you
|
|
94
|
+
// don't yet know which flags/args the command needs.
|
|
95
|
+
if (help)
|
|
96
|
+
return { input: { args: {}, flags }, json, apiUrl, token, help };
|
|
93
97
|
for (const f of specFlags) {
|
|
94
98
|
if (flags[f.name] === undefined && f.default !== undefined)
|
|
95
99
|
flags[f.name] = f.default;
|
|
@@ -184,7 +188,8 @@ export function rootHelp() {
|
|
|
184
188
|
function groupHelp(group, specs) {
|
|
185
189
|
const lines = [`${bold("beryl " + group)} — subcommands:`, ""];
|
|
186
190
|
for (const spec of specs) {
|
|
187
|
-
|
|
191
|
+
const suffix = spec.groupDefault ? dim(` (default — \`beryl ${group}\` runs this)`) : "";
|
|
192
|
+
lines.push(` ${cyan(spec.name.padEnd(28))} ${spec.summary}${suffix}`);
|
|
188
193
|
}
|
|
189
194
|
lines.push("", `Run ${cyan(`beryl ${group} <subcommand> --help`)} for details.`);
|
|
190
195
|
return lines.join("\n");
|
|
@@ -207,15 +212,25 @@ export async function runCli(argv) {
|
|
|
207
212
|
}
|
|
208
213
|
if (words.length === 0) {
|
|
209
214
|
process.stdout.write(rootHelp() + "\n");
|
|
210
|
-
return
|
|
215
|
+
return rest.length === 0 || rest.includes("--help") || rest.includes("-h")
|
|
211
216
|
? EXIT_OK
|
|
212
217
|
: EXIT_USAGE;
|
|
213
218
|
}
|
|
219
|
+
// A group of subcommands, as opposed to a single-word command like `init`, which
|
|
220
|
+
// commandGroups() also keys under its own name.
|
|
221
|
+
const groupSpecs = words.length === 1 ? commandGroups().get(words[0]) : undefined;
|
|
222
|
+
const group = groupSpecs && !(groupSpecs.length === 1 && groupSpecs[0].name === words[0])
|
|
223
|
+
? groupSpecs
|
|
224
|
+
: undefined;
|
|
225
|
+
// `beryl tests --help` asks about the group, not about the subcommand `beryl tests`
|
|
226
|
+
// happens to default to — so group help wins over the default's own help.
|
|
227
|
+
if (group && (rest.includes("--help") || rest.includes("-h"))) {
|
|
228
|
+
process.stdout.write(groupHelp(words[0], group) + "\n");
|
|
229
|
+
return EXIT_OK;
|
|
230
|
+
}
|
|
214
231
|
const found = findCommand(words);
|
|
215
232
|
if (!found) {
|
|
216
|
-
|
|
217
|
-
const group = groups.get(words[0]);
|
|
218
|
-
if (group && words.length === 1) {
|
|
233
|
+
if (group) {
|
|
219
234
|
process.stdout.write(groupHelp(words[0], group) + "\n");
|
|
220
235
|
return EXIT_OK;
|
|
221
236
|
}
|
|
@@ -245,7 +260,7 @@ export async function runCli(argv) {
|
|
|
245
260
|
if (parsed.token)
|
|
246
261
|
config.token = parsed.token;
|
|
247
262
|
const client = new ApiClient(config.apiUrl, config.token);
|
|
248
|
-
const ctx = createContext({ client, config, json: parsed.json });
|
|
263
|
+
const ctx = createContext({ client, config, json: parsed.json, mcp: false });
|
|
249
264
|
try {
|
|
250
265
|
const result = (await spec.run(ctx, parsed.input)) ?? {};
|
|
251
266
|
if (parsed.json) {
|
package/dist/adapters/mcp.js
CHANGED
|
@@ -31,6 +31,24 @@ export function toolInputSchema(spec) {
|
|
|
31
31
|
}
|
|
32
32
|
return { type: "object", properties, ...(required.length ? { required } : {}) };
|
|
33
33
|
}
|
|
34
|
+
export function toolResult(result, lines) {
|
|
35
|
+
const parts = [...lines];
|
|
36
|
+
if (result.data !== undefined)
|
|
37
|
+
parts.push(JSON.stringify(result.data, null, 2));
|
|
38
|
+
else if (result.human)
|
|
39
|
+
parts.push(result.human);
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{ type: "text", text: parts.join("\n") || "ok" },
|
|
43
|
+
...(result.images ?? []).map((image) => ({
|
|
44
|
+
type: "image",
|
|
45
|
+
data: image.data,
|
|
46
|
+
mimeType: image.mimeType,
|
|
47
|
+
})),
|
|
48
|
+
],
|
|
49
|
+
isError: result.exitCode !== undefined && result.exitCode !== 0,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
34
52
|
function toInput(spec, params) {
|
|
35
53
|
const args = {};
|
|
36
54
|
const flags = {};
|
|
@@ -76,20 +94,13 @@ export async function serveMcp(baseCtx) {
|
|
|
76
94
|
config: baseCtx.config,
|
|
77
95
|
json: true,
|
|
78
96
|
interactive: false,
|
|
97
|
+
mcp: true,
|
|
79
98
|
out: push,
|
|
80
99
|
err: push,
|
|
81
100
|
});
|
|
82
101
|
try {
|
|
83
102
|
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
|
-
};
|
|
103
|
+
return toolResult(result, lines);
|
|
93
104
|
}
|
|
94
105
|
catch (err) {
|
|
95
106
|
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
|
+
}
|
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
|
},
|
package/dist/commands/init.js
CHANGED
|
@@ -86,7 +86,8 @@ export const initCommands = [
|
|
|
86
86
|
name: "local",
|
|
87
87
|
type: "boolean",
|
|
88
88
|
description: "Also wire the Playwright MCP so your coding agent can drive a local browser " +
|
|
89
|
-
"(for authoring tests yourself)"
|
|
89
|
+
"(for authoring tests yourself). Default: on whenever a coding agent is wired; " +
|
|
90
|
+
"pass --no-local to skip it",
|
|
90
91
|
},
|
|
91
92
|
],
|
|
92
93
|
examples: [
|
|
@@ -161,7 +162,10 @@ export const initCommands = [
|
|
|
161
162
|
: choice === "none"
|
|
162
163
|
? []
|
|
163
164
|
: [choice];
|
|
164
|
-
|
|
165
|
+
// Wiring an editor's MCP config at all implies the user wants to author tests there,
|
|
166
|
+
// and local authoring needs the Playwright MCP — so default it on. `--no-local` (parsed
|
|
167
|
+
// as an explicit false) opts out.
|
|
168
|
+
const local = input.flags.local ?? editors.length > 0;
|
|
165
169
|
for (const editor of editors) {
|
|
166
170
|
const file = editor === "claude-code"
|
|
167
171
|
? path.join(cwd, ".mcp.json")
|
|
@@ -1,13 +1,43 @@
|
|
|
1
1
|
import { saveGlobalConfig } from "../config.js";
|
|
2
|
+
import { detectAuthGating } from "../detect.js";
|
|
2
3
|
import { UsageError } from "../errors.js";
|
|
3
4
|
import { dim, green } from "../output.js";
|
|
4
5
|
import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
5
6
|
import { pollCurrentExploration, watchExploration } from "./watch.js";
|
|
7
|
+
// The API treats the auth choice as the visitor's answer and never infers it — a null
|
|
8
|
+
// choice leaves the project waiting on a login capture that never comes — so resolve it
|
|
9
|
+
// here. A saved login for the host settles it; otherwise probe the site.
|
|
10
|
+
async function resolveAuthChoice(ctx, workspaceId, url) {
|
|
11
|
+
let reusable;
|
|
12
|
+
try {
|
|
13
|
+
reusable = (await ctx.client.get(`/workspaces/${workspaceId}/projects/reusable-auth`, {
|
|
14
|
+
url,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// a probe is still worth trying if the lookup fails
|
|
19
|
+
}
|
|
20
|
+
if (reusable?.available) {
|
|
21
|
+
ctx.err(dim("Detected a gated site — a saved login for this host is already on file."));
|
|
22
|
+
return "gated";
|
|
23
|
+
}
|
|
24
|
+
const detected = await detectAuthGating(url);
|
|
25
|
+
if (detected.choice) {
|
|
26
|
+
ctx.err(dim(`Detected a ${detected.choice} site — ${detected.reason}.`));
|
|
27
|
+
return detected.choice;
|
|
28
|
+
}
|
|
29
|
+
const question = `Could not tell whether ${url} needs a login — ${detected.reason}.`;
|
|
30
|
+
if (!ctx.interactive)
|
|
31
|
+
throw new UsageError(`${question}\n\nPass --auth public or --auth gated to say which it is.`);
|
|
32
|
+
const answer = await ctx.prompt(`${question}\nDoes testing it require signing in? [y/N] `);
|
|
33
|
+
return /^y(es)?$/i.test(answer) ? "gated" : "public";
|
|
34
|
+
}
|
|
6
35
|
export const projectCommands = [
|
|
7
36
|
{
|
|
8
37
|
name: "projects list",
|
|
9
38
|
summary: "List projects in the workspace",
|
|
10
39
|
scope: "workspace",
|
|
40
|
+
groupDefault: true,
|
|
11
41
|
async run(ctx, input) {
|
|
12
42
|
const ws = await ctx.requireWorkspace(input);
|
|
13
43
|
return { data: await ctx.client.get(`/workspaces/${ws}/projects`) };
|
|
@@ -32,7 +62,8 @@ export const projectCommands = [
|
|
|
32
62
|
name: "auth",
|
|
33
63
|
type: "string",
|
|
34
64
|
enum: ["public", "gated"],
|
|
35
|
-
description: "Whether the site needs a login (gated) or not (public)"
|
|
65
|
+
description: "Whether the site needs a login (gated) or not (public). Default: detected from the " +
|
|
66
|
+
"site — only asked when detection is genuinely unsure",
|
|
36
67
|
},
|
|
37
68
|
{
|
|
38
69
|
name: "allow-mutations",
|
|
@@ -50,7 +81,7 @@ export const projectCommands = [
|
|
|
50
81
|
{ name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
|
|
51
82
|
],
|
|
52
83
|
examples: [
|
|
53
|
-
"beryl projects create https://app.example.com --
|
|
84
|
+
"beryl projects create https://app.example.com --watch",
|
|
54
85
|
"beryl projects create https://app.example.com --auth gated",
|
|
55
86
|
"beryl projects create https://app.example.com --auth public --no-explore",
|
|
56
87
|
],
|
|
@@ -59,10 +90,11 @@ export const projectCommands = [
|
|
|
59
90
|
if (noExplore && flagBool(input, "watch"))
|
|
60
91
|
throw new UsageError("--no-explore cannot be combined with --watch");
|
|
61
92
|
const ws = await ctx.requireWorkspace(input);
|
|
62
|
-
const
|
|
93
|
+
const url = arg(input, "url");
|
|
94
|
+
const auth = flagStr(input, "auth") ?? (await resolveAuthChoice(ctx, ws, url));
|
|
63
95
|
const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
|
|
64
|
-
root_url:
|
|
65
|
-
requires_auth_choice: auth
|
|
96
|
+
root_url: url,
|
|
97
|
+
requires_auth_choice: auth,
|
|
66
98
|
mutation_choice: flagBool(input, "allow-mutations") ? "allow_mutations" : null,
|
|
67
99
|
force_new_login: flagBool(input, "force-new-login"),
|
|
68
100
|
skip_exploration: noExplore,
|
|
@@ -73,6 +105,15 @@ export const projectCommands = [
|
|
|
73
105
|
human: `${green("Project created")}: ${created.project_id} ${dim("(no exploration started)")}\n` +
|
|
74
106
|
`Ready for your own tests — author a plan and push it with \`beryl tests create\`.`,
|
|
75
107
|
};
|
|
108
|
+
// The agent cannot start until a login is captured, so there is nothing to watch.
|
|
109
|
+
if (created.auth_status === "needs_capture")
|
|
110
|
+
return {
|
|
111
|
+
data: created,
|
|
112
|
+
human: `${green("Project created")}: ${created.project_id}\n` +
|
|
113
|
+
`The site needs a login before the agent can explore it. Capture one with ` +
|
|
114
|
+
`\`beryl credentials capture --project ${created.project_id}\` — the exploration ` +
|
|
115
|
+
`starts as soon as you do.`,
|
|
116
|
+
};
|
|
76
117
|
if (!flagBool(input, "watch"))
|
|
77
118
|
return { data: created };
|
|
78
119
|
ctx.err(dim(`project ${created.project_id} created — waiting for the agent to start…`));
|