@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/dist/commands/runs.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
|
|
3
|
+
import { dim, yellow } from "../output.js";
|
|
2
4
|
import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
3
5
|
import { watchRun } from "./watch.js";
|
|
6
|
+
const MAX_FAILURE_SCREENSHOTS = 5;
|
|
4
7
|
export const runCommands = [
|
|
5
8
|
{
|
|
6
9
|
name: "runs trigger",
|
|
@@ -14,11 +17,17 @@ export const runCommands = [
|
|
|
14
17
|
{ name: "url-override", type: "string", description: "Replace the base URL (preview deploys)" },
|
|
15
18
|
{ name: "watch", type: "boolean", description: "Stream progress and exit non-zero on failure" },
|
|
16
19
|
{ name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
|
|
20
|
+
{
|
|
21
|
+
name: "retries",
|
|
22
|
+
type: "number",
|
|
23
|
+
description: "Retry a failing test up to N times (0 disables); omit for the default",
|
|
24
|
+
},
|
|
17
25
|
],
|
|
18
26
|
examples: [
|
|
19
27
|
"beryl runs trigger --watch",
|
|
20
28
|
"beryl runs trigger --url-override https://preview-123.example.com --watch --timeout 30",
|
|
21
29
|
"beryl runs trigger --test 4f… --test 9a…",
|
|
30
|
+
"beryl runs trigger --retries 0 --watch",
|
|
22
31
|
],
|
|
23
32
|
async run(ctx, input) {
|
|
24
33
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
@@ -27,6 +36,7 @@ export const runCommands = [
|
|
|
27
36
|
test_case_ids: tests && tests.length > 0 ? tests : null,
|
|
28
37
|
environment_id: flagStr(input, "env") ?? null,
|
|
29
38
|
target_url_override: flagStr(input, "url-override") ?? null,
|
|
39
|
+
max_retries: flagNum(input, "retries") ?? null,
|
|
30
40
|
}));
|
|
31
41
|
if (!flagBool(input, "watch"))
|
|
32
42
|
return { data: created };
|
|
@@ -37,6 +47,7 @@ export const runCommands = [
|
|
|
37
47
|
name: "runs list",
|
|
38
48
|
summary: "List recent runs",
|
|
39
49
|
scope: "project",
|
|
50
|
+
groupDefault: true,
|
|
40
51
|
flags: [{ name: "env", type: "string", description: "Filter by environment id" }],
|
|
41
52
|
async run(ctx, input) {
|
|
42
53
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
@@ -49,13 +60,31 @@ export const runCommands = [
|
|
|
49
60
|
{
|
|
50
61
|
name: "runs get",
|
|
51
62
|
summary: "Show one run with its per-test results",
|
|
63
|
+
description: "Over MCP the failure screenshots come back as viewable image content, so an agent can " +
|
|
64
|
+
"look at the page that broke instead of guessing from the error string. Set screenshots " +
|
|
65
|
+
"to false to skip fetching them. Ignored outside MCP — the terminal cannot show an image.",
|
|
52
66
|
scope: "project",
|
|
53
67
|
args: [{ name: "run-id", description: "Run id", required: true }],
|
|
68
|
+
flags: [
|
|
69
|
+
{
|
|
70
|
+
name: "screenshots",
|
|
71
|
+
type: "boolean",
|
|
72
|
+
default: true,
|
|
73
|
+
description: "Attach failure screenshots as image content (MCP only; default true)",
|
|
74
|
+
},
|
|
75
|
+
],
|
|
54
76
|
async run(ctx, input) {
|
|
55
77
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
78
|
+
const data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}`));
|
|
79
|
+
// Only the MCP adapter renders images; fetching bytes for a plain CLI run
|
|
80
|
+
// would be wasted network.
|
|
81
|
+
if (!ctx.mcp || input.flags.screenshots === false)
|
|
82
|
+
return { data };
|
|
83
|
+
const { images, skipped } = await failureImages(data, MAX_FAILURE_SCREENSHOTS);
|
|
84
|
+
// A silently-missing picture reads as "there was none" — say what was lost.
|
|
85
|
+
for (const note of skipped)
|
|
86
|
+
ctx.err(yellow(`! ${note}`));
|
|
87
|
+
return { data, images };
|
|
59
88
|
},
|
|
60
89
|
},
|
|
61
90
|
{
|
|
@@ -96,18 +125,68 @@ export const runCommands = [
|
|
|
96
125
|
},
|
|
97
126
|
{
|
|
98
127
|
name: "runs download",
|
|
99
|
-
summary: "Download a run's
|
|
128
|
+
summary: "Download a run's results, with its artifacts, to disk",
|
|
129
|
+
description: "With --dir, fetches the artifact bytes — screenshots, DOM snapshots, the Playwright " +
|
|
130
|
+
"trace zip, and the filmstrip frames of the failing tests — into <dir>/<test-result-id>/ " +
|
|
131
|
+
"alongside a run.json manifest. Artifact URLs are short-lived, so download rather than " +
|
|
132
|
+
"stash them. With --out (or neither), writes only the JSON manifest.",
|
|
100
133
|
scope: "project",
|
|
101
134
|
args: [{ name: "run-id", description: "Run id", required: true }],
|
|
102
|
-
flags: [
|
|
135
|
+
flags: [
|
|
136
|
+
{
|
|
137
|
+
name: "dir",
|
|
138
|
+
type: "string",
|
|
139
|
+
description: "Write run.json plus the artifact files into this directory",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: "all-frames",
|
|
143
|
+
type: "boolean",
|
|
144
|
+
description: "With --dir: also fetch the filmstrip frames of passing tests",
|
|
145
|
+
},
|
|
146
|
+
{ name: "out", type: "string", description: "Write the JSON to a file instead of stdout" },
|
|
147
|
+
],
|
|
148
|
+
examples: [
|
|
149
|
+
"beryl runs download 7c1… --dir ./beryl-run",
|
|
150
|
+
"beryl runs download 7c1… --out run.json",
|
|
151
|
+
],
|
|
103
152
|
async run(ctx, input) {
|
|
104
153
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
105
|
-
const data = await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}/download`);
|
|
154
|
+
const data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}/download`));
|
|
155
|
+
// --out and --dir are not alternatives: one says where the JSON goes, the other
|
|
156
|
+
// asks for the bytes as well. Honour both when both are given.
|
|
106
157
|
const out = flagStr(input, "out");
|
|
107
|
-
if (out)
|
|
158
|
+
if (out)
|
|
108
159
|
fs.writeFileSync(out, JSON.stringify(data, null, 2) + "\n");
|
|
109
|
-
|
|
160
|
+
const dir = flagStr(input, "dir");
|
|
161
|
+
if (dir) {
|
|
162
|
+
const report = await downloadRunArtifacts(data, dir, {
|
|
163
|
+
allFrames: flagBool(input, "all-frames"),
|
|
164
|
+
onProgress: (line) => ctx.err(dim(line)),
|
|
165
|
+
});
|
|
166
|
+
for (const failure of report.failures) {
|
|
167
|
+
ctx.err(yellow(`! could not fetch ${failure.url}: ${failure.error}`));
|
|
168
|
+
}
|
|
169
|
+
const failed = resultsOf(data).filter((r) => isFailing(r.status)).length;
|
|
170
|
+
// Exit codes are a CI contract: a bundle with zero artifacts on disk when the
|
|
171
|
+
// run had some is a failed download, not a success with a caveat.
|
|
172
|
+
const lostEverything = report.artifacts.length === 0 && report.failures.length > 0;
|
|
173
|
+
return {
|
|
174
|
+
data: {
|
|
175
|
+
directory: report.directory,
|
|
176
|
+
manifest: report.manifest,
|
|
177
|
+
artifacts: report.artifacts,
|
|
178
|
+
failed_tests: failed,
|
|
179
|
+
skipped: report.failures.length,
|
|
180
|
+
...(out ? { written: out } : {}),
|
|
181
|
+
},
|
|
182
|
+
human: `Wrote ${report.manifest} and ${report.artifacts.length} artifact file(s) to ${dir}` +
|
|
183
|
+
(report.failures.length ? ` (${report.failures.length} could not be fetched)` : "") +
|
|
184
|
+
(out ? `\nWrote ${out}` : ""),
|
|
185
|
+
...(lostEverything ? { exitCode: 1 } : {}),
|
|
186
|
+
};
|
|
110
187
|
}
|
|
188
|
+
if (out)
|
|
189
|
+
return { data: { written: out }, human: `Wrote ${out}` };
|
|
111
190
|
return { data, human: JSON.stringify(data, null, 2) };
|
|
112
191
|
},
|
|
113
192
|
},
|
package/dist/commands/tests.js
CHANGED
|
@@ -1,12 +1,40 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { UsageError } from "../errors.js";
|
|
3
|
+
import { lintPlan } from "../lint.js";
|
|
4
|
+
import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
|
|
3
5
|
import { arg, argList, flagBool, flagNum, flagStr, projectPath, readJsonFlag } from "./util.js";
|
|
4
6
|
const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
|
|
5
7
|
export const testCommands = [
|
|
8
|
+
{
|
|
9
|
+
name: "tests lint",
|
|
10
|
+
summary: "Validate a plan JSON file offline, before sending it to the server",
|
|
11
|
+
description: "Checks a plan against the published ActionPlan JSON Schema — every action's required " +
|
|
12
|
+
"fields, plus the two structural rules (the first EXECUTED step must be a goto, and at " +
|
|
13
|
+
"least one step across before + steps must be an expect). Runs entirely locally, so a " +
|
|
14
|
+
"malformed plan fails here instead of costing a server round-trip. " +
|
|
15
|
+
`Schema: ${ACTION_PLAN_SCHEMA.$id}`,
|
|
16
|
+
scope: "none",
|
|
17
|
+
flags: [
|
|
18
|
+
{ name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
|
|
19
|
+
],
|
|
20
|
+
examples: ["beryl tests lint --file plan.json"],
|
|
21
|
+
async run(_ctx, input) {
|
|
22
|
+
const issues = lintPlan(readJsonFlag(input, "file"));
|
|
23
|
+
if (issues.length === 0) {
|
|
24
|
+
return { data: { valid: true, issues: [] }, human: "Plan is valid." };
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
data: { valid: false, issues },
|
|
28
|
+
human: issues.map((i) => `${i.path}: ${i.message}`).join("\n"),
|
|
29
|
+
exitCode: 1,
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
},
|
|
6
33
|
{
|
|
7
34
|
name: "tests list",
|
|
8
35
|
summary: "List the project's tests with their latest result",
|
|
9
36
|
scope: "project",
|
|
37
|
+
groupDefault: true,
|
|
10
38
|
flags: [{ name: "env", type: "string", description: "Filter by environment id" }],
|
|
11
39
|
async run(ctx, input) {
|
|
12
40
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
@@ -44,8 +72,10 @@ export const testCommands = [
|
|
|
44
72
|
name: "tests create",
|
|
45
73
|
summary: "Create a test case from a JSON action plan — for tests authored locally, e.g. by your coding agent",
|
|
46
74
|
description: "The plan is a JSON object whose steps are {action, selector, url, value, ...}: the first " +
|
|
47
|
-
"step must be a goto, and at least one step must be an expect. By default the plan
|
|
48
|
-
"verified in a real browser before the test is accepted."
|
|
75
|
+
"EXECUTED step must be a goto, and at least one step must be an expect. By default the plan " +
|
|
76
|
+
"is verified in a real browser before the test is accepted. Optional `before` and `after` " +
|
|
77
|
+
"arrays hold setup and teardown steps: `after` runs even when a main step fails, which is " +
|
|
78
|
+
"how a create/update/delete test cleans up the record it made on the runs that go red.",
|
|
49
79
|
scope: "project",
|
|
50
80
|
flags: [
|
|
51
81
|
{ name: "title", type: "string", required: true, description: "Title for the new test" },
|
|
@@ -74,6 +104,9 @@ export const testCommands = [
|
|
|
74
104
|
{
|
|
75
105
|
name: "tests set-plan",
|
|
76
106
|
summary: "Replace a test's step plan from a JSON file (creates a new version)",
|
|
107
|
+
description: "Accepts the same plan shape as `tests create`, including the optional `before` and " +
|
|
108
|
+
"`after` sections — `after` runs on pass and on fail, so cleanup happens even when the " +
|
|
109
|
+
"test goes red.",
|
|
77
110
|
scope: "project",
|
|
78
111
|
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
79
112
|
flags: [
|
|
@@ -87,6 +120,62 @@ export const testCommands = [
|
|
|
87
120
|
};
|
|
88
121
|
},
|
|
89
122
|
},
|
|
123
|
+
{
|
|
124
|
+
name: "tests rename",
|
|
125
|
+
summary: "Rename a test",
|
|
126
|
+
scope: "project",
|
|
127
|
+
args: [
|
|
128
|
+
{ name: "test-id", description: "Test id", required: true },
|
|
129
|
+
{ name: "title", description: "New title", required: true },
|
|
130
|
+
],
|
|
131
|
+
examples: ['beryl tests rename 4f… "Checkout happy path"'],
|
|
132
|
+
async run(ctx, input) {
|
|
133
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
134
|
+
return {
|
|
135
|
+
data: await ctx.client.patch(testPath(workspaceId, projectId, arg(input, "test-id")), {
|
|
136
|
+
title: arg(input, "title"),
|
|
137
|
+
}),
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: "tests quarantine",
|
|
143
|
+
summary: "Mute a flaky test: it keeps running, but its failures stop failing the run",
|
|
144
|
+
description: "A quarantined test still executes and its result is still recorded and visible — its " +
|
|
145
|
+
"red just doesn't count towards the run's verdict, so it can't red-light a deploy. Use " +
|
|
146
|
+
"it on a persistently flaky test instead of deleting it (which destroys the history) or " +
|
|
147
|
+
"asking support to deactivate it (which stops it running at all). `off` un-quarantines.",
|
|
148
|
+
scope: "project",
|
|
149
|
+
args: [
|
|
150
|
+
{ name: "test-id", description: "Test id", required: true },
|
|
151
|
+
{ name: "state", description: "on | off", required: true },
|
|
152
|
+
],
|
|
153
|
+
examples: ["beryl tests quarantine 4f… on", "beryl tests quarantine 4f… off"],
|
|
154
|
+
async run(ctx, input) {
|
|
155
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
156
|
+
const state = arg(input, "state").toLowerCase();
|
|
157
|
+
if (state !== "on" && state !== "off") {
|
|
158
|
+
throw new UsageError(`Expected "on" or "off", got "${arg(input, "state")}"`);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
data: await ctx.client.patch(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/quarantine`, { quarantined: state === "on" }),
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "tests delete",
|
|
167
|
+
summary: "Delete a test, its version history, and its results",
|
|
168
|
+
scope: "project",
|
|
169
|
+
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
170
|
+
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
171
|
+
async run(ctx, input) {
|
|
172
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
173
|
+
const testId = arg(input, "test-id");
|
|
174
|
+
await ctx.confirm(`Delete test ${testId} and all its history?`, flagBool(input, "force"));
|
|
175
|
+
await ctx.client.del(testPath(workspaceId, projectId, testId));
|
|
176
|
+
return { human: "Deleted." };
|
|
177
|
+
},
|
|
178
|
+
},
|
|
90
179
|
{
|
|
91
180
|
name: "tests recompile",
|
|
92
181
|
summary: "Validate + verify an edited plan against the live site before persisting",
|
package/dist/commands/watch.js
CHANGED
|
@@ -23,15 +23,28 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes) {
|
|
|
23
23
|
if (kind === "run_started") {
|
|
24
24
|
ctx.err(dim(`run ${runId} started`));
|
|
25
25
|
}
|
|
26
|
+
else if (kind === "test_retrying") {
|
|
27
|
+
ctx.err(yellow(`↻ retrying ${dim(String(ev.test_result_id))} (attempt ${ev.attempt_no} failed)`));
|
|
28
|
+
}
|
|
26
29
|
else if (kind === "test_completed") {
|
|
27
30
|
const key = `${ev.test_result_id}:${ev.status}`;
|
|
28
31
|
if (!seen.has(key)) {
|
|
29
32
|
seen.add(key);
|
|
30
33
|
const ok = ev.status === "passed";
|
|
31
|
-
|
|
34
|
+
// A quarantined red still prints — the result is recorded and visible —
|
|
35
|
+
// but marked as muted so it doesn't read as a build-breaking failure.
|
|
36
|
+
const muted = Boolean(ev.quarantined) && !ok;
|
|
37
|
+
const mark = ok ? green("✓") : muted ? yellow("⚠") : red("✗");
|
|
38
|
+
const label = muted
|
|
39
|
+
? yellow(`${String(ev.status)} (quarantined)`)
|
|
40
|
+
: statusColor(String(ev.status));
|
|
32
41
|
const duration = ev.duration_ms ? dim(` ${Math.round(Number(ev.duration_ms) / 1000)}s`) : "";
|
|
33
|
-
const
|
|
34
|
-
|
|
42
|
+
const errorText = ev.error_message ? ` — ${String(ev.error_message).slice(0, 120)}` : "";
|
|
43
|
+
const error = errorText ? (muted ? dim(errorText) : red(errorText)) : "";
|
|
44
|
+
// A pass that took a retry is still a pass, but say so — a flake absorbed in
|
|
45
|
+
// silence is how a suite that gates deploys stops being trusted.
|
|
46
|
+
const flaky = ev.flaky ? yellow(` flaky (passed on attempt ${ev.attempt_no})`) : "";
|
|
47
|
+
ctx.err(`${mark} ${label} ${dim(String(ev.test_result_id))}${duration}${flaky}${error}`);
|
|
35
48
|
}
|
|
36
49
|
}
|
|
37
50
|
}
|
|
@@ -46,11 +59,19 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes) {
|
|
|
46
59
|
}
|
|
47
60
|
if (signal?.aborted)
|
|
48
61
|
throw new CliError(`Timed out after ${timeoutMinutes} minutes`, 1);
|
|
62
|
+
// Quarantined reds are excluded from `failed` by the API (they land in their own
|
|
63
|
+
// counter), so they never reach this sum and never flip the exit code — but they
|
|
64
|
+
// are always reported, so a green build never silently hides a muted failure.
|
|
49
65
|
const failed = (counters.failed ?? 0) + (counters.errored ?? 0);
|
|
50
66
|
const passed = counters.passed ?? 0;
|
|
67
|
+
// A flaky pass likewise exits 0 — absorbing a transient blip is the point — but it
|
|
68
|
+
// is reported too, so a suite that only stays green by retrying can't hide it.
|
|
69
|
+
const extra = (counters.cancelled ? yellow(`, ${counters.cancelled} cancelled`) : "") +
|
|
70
|
+
(counters.quarantined ? yellow(`, ${counters.quarantined} quarantined`) : "") +
|
|
71
|
+
(counters.flaky ? yellow(`, ${counters.flaky} flaky`) : "");
|
|
51
72
|
const summary = failed > 0
|
|
52
|
-
? red(`${failed} failed`) + `, ${passed} passed`
|
|
53
|
-
: green(`${passed} passed`) +
|
|
73
|
+
? red(`${failed} failed`) + `, ${passed} passed` + extra
|
|
74
|
+
: green(`${passed} passed`) + extra;
|
|
54
75
|
if (!ctx.json)
|
|
55
76
|
ctx.err(`\n${statusColor(finalStatus)}: ${summary}`);
|
|
56
77
|
const exitCode = finalStatus === "completed" && failed === 0 ? 0 : 1;
|
package/dist/context.js
CHANGED
|
@@ -23,6 +23,7 @@ export function createContext(options) {
|
|
|
23
23
|
config,
|
|
24
24
|
json,
|
|
25
25
|
interactive: options.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
26
|
+
mcp: options.mcp,
|
|
26
27
|
out: options.out ?? ((text) => process.stdout.write(text + "\n")),
|
|
27
28
|
err: options.err ?? ((text) => process.stderr.write(text + "\n")),
|
|
28
29
|
async prompt(question) {
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Login-wall detection for `projects create`. The API takes the visitor's answer as
|
|
2
|
+
// authoritative and never infers it (a null choice there strands the project in
|
|
3
|
+
// needs_capture), so the guess has to happen here, before the request.
|
|
4
|
+
//
|
|
5
|
+
// The asymmetry that shapes this: "gated" can be proven from the response, "public"
|
|
6
|
+
// cannot. An app that renders its login screen client-side serves the same 200 + empty
|
|
7
|
+
// shell as a public landing page, so absence of a login wall is not evidence of a public
|
|
8
|
+
// site. We therefore only claim `public` on positive evidence of real content, and leave
|
|
9
|
+
// everything else undecided — the caller prompts on a TTY and fails loudly in CI.
|
|
10
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
11
|
+
const PASSWORD_FIELD = /<input[^>]+type\s*=\s*["']?password["']?/i;
|
|
12
|
+
// Sign-in copy anywhere in the markup. A public marketing page carries this too (its
|
|
13
|
+
// header "Log in" link), so it never proves gated — it only withholds a confident public.
|
|
14
|
+
const LOGIN_COPY = /\b(sign in|signin|log in|login|sign up|create (an )?account)\b/i;
|
|
15
|
+
// Body text with the markup stripped. An SPA shell collapses to ~nothing here, which is
|
|
16
|
+
// exactly the signal that we cannot see the real page and must not guess.
|
|
17
|
+
function visibleText(html) {
|
|
18
|
+
return html
|
|
19
|
+
.replace(/<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
|
20
|
+
.replace(/<[^>]+>/g, " ")
|
|
21
|
+
.replace(/&[a-z#0-9]+;/gi, " ")
|
|
22
|
+
.replace(/\s+/g, " ")
|
|
23
|
+
.trim();
|
|
24
|
+
}
|
|
25
|
+
const MIN_PUBLIC_TEXT = 200;
|
|
26
|
+
const isLoginish = (url) => /(^|\/)(login|signin|sign-in|auth|authorize|sso|account\/login|users\/sign_in)(\/|$)/i.test(url.pathname);
|
|
27
|
+
function classify(root, final, status, body) {
|
|
28
|
+
if (status === 401 || status === 403)
|
|
29
|
+
return { choice: "gated", reason: `the site answered HTTP ${status}` };
|
|
30
|
+
if (status < 200 || status >= 300)
|
|
31
|
+
return { reason: `the site answered HTTP ${status}` };
|
|
32
|
+
const redirected = final.href !== root.href;
|
|
33
|
+
const hasPasswordField = PASSWORD_FIELD.test(body);
|
|
34
|
+
if (redirected && isLoginish(final))
|
|
35
|
+
return { choice: "gated", reason: `it redirects to a sign-in page (${final.href})` };
|
|
36
|
+
if (redirected && hasPasswordField)
|
|
37
|
+
return {
|
|
38
|
+
choice: "gated",
|
|
39
|
+
reason: `it redirects to a page with a password field (${final.href})`,
|
|
40
|
+
};
|
|
41
|
+
// The root itself is a sign-in page: either the URL says so, or it serves a password
|
|
42
|
+
// field and nothing else of substance.
|
|
43
|
+
const text = visibleText(body);
|
|
44
|
+
if (isLoginish(final) && hasPasswordField)
|
|
45
|
+
return { choice: "gated", reason: "its root URL is a sign-in page" };
|
|
46
|
+
if (hasPasswordField && text.length < MIN_PUBLIC_TEXT)
|
|
47
|
+
return { choice: "gated", reason: "the page is a sign-in form" };
|
|
48
|
+
// Below here nothing proves gated — but only real, login-free content proves public.
|
|
49
|
+
if (hasPasswordField)
|
|
50
|
+
return { reason: "the page serves both a password field and other content" };
|
|
51
|
+
if (text.length < MIN_PUBLIC_TEXT)
|
|
52
|
+
return {
|
|
53
|
+
reason: "the page renders its content in the browser, so its markup does not show whether " +
|
|
54
|
+
"there is a login wall",
|
|
55
|
+
};
|
|
56
|
+
if (LOGIN_COPY.test(text))
|
|
57
|
+
return { reason: "the page mentions signing in but shows no login form" };
|
|
58
|
+
return { choice: "public", reason: "the page serves content with no sign-in wall" };
|
|
59
|
+
}
|
|
60
|
+
export async function detectAuthGating(rootUrl) {
|
|
61
|
+
let root;
|
|
62
|
+
try {
|
|
63
|
+
root = new URL(rootUrl);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return { reason: `"${rootUrl}" is not a URL we can fetch` };
|
|
67
|
+
}
|
|
68
|
+
if (root.protocol !== "http:" && root.protocol !== "https:")
|
|
69
|
+
return { reason: `"${rootUrl}" is not an http(s) URL` };
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(root, {
|
|
74
|
+
redirect: "follow",
|
|
75
|
+
signal: controller.signal,
|
|
76
|
+
headers: { "User-Agent": "beryl-cli", Accept: "text/html,*/*" },
|
|
77
|
+
});
|
|
78
|
+
const body = await res.text();
|
|
79
|
+
return classify(root, new URL(res.url), res.status, body);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
return { reason: `the site could not be fetched (${err.message})` };
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export { classify as classifyForTest };
|
package/dist/lint.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { ACTION_PLAN_SCHEMA } from "./schema.generated.js";
|
|
2
|
+
const typeOf = (value) => {
|
|
3
|
+
if (value === null)
|
|
4
|
+
return "null";
|
|
5
|
+
if (Array.isArray(value))
|
|
6
|
+
return "array";
|
|
7
|
+
if (Number.isInteger(value))
|
|
8
|
+
return "integer";
|
|
9
|
+
return typeof value;
|
|
10
|
+
};
|
|
11
|
+
const typeMatches = (value, type) => type === "number" ? typeof value === "number" : typeOf(value) === type;
|
|
12
|
+
function resolve(schema, root) {
|
|
13
|
+
const ref = schema.$ref;
|
|
14
|
+
if (typeof ref !== "string")
|
|
15
|
+
return schema;
|
|
16
|
+
const key = ref.replace("#/$defs/", "");
|
|
17
|
+
return { ...(root.$defs?.[key] ?? {}), ...schema, $ref: undefined };
|
|
18
|
+
}
|
|
19
|
+
function matches(value, schema, root) {
|
|
20
|
+
return validate(value, schema, root, "").length === 0;
|
|
21
|
+
}
|
|
22
|
+
function validate(value, raw, root, path) {
|
|
23
|
+
const schema = resolve(raw, root);
|
|
24
|
+
const issues = [];
|
|
25
|
+
const at = (message) => issues.push({ path: path || "(root)", message });
|
|
26
|
+
if (schema.anyOf && !schema.anyOf.some((s) => matches(value, s, root))) {
|
|
27
|
+
// Optional fields are emitted as anyOf[<real type>, null]. When the value isn't
|
|
28
|
+
// null, the null branch is noise — report the real branch's own failure ("must
|
|
29
|
+
// match ^[A-Za-z0-9_]+$") instead of a useless "expected string or null".
|
|
30
|
+
const real = schema.anyOf.filter((s) => resolve(s, root).type !== "null");
|
|
31
|
+
if (value !== null && real.length === 1) {
|
|
32
|
+
return validate(value, real[0], root, path);
|
|
33
|
+
}
|
|
34
|
+
const types = schema.anyOf
|
|
35
|
+
.map((s) => resolve(s, root).type ?? resolve(s, root).enum?.join("|"))
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.join(" or ");
|
|
38
|
+
at(types ? `expected ${types}` : "does not match any allowed shape");
|
|
39
|
+
return issues;
|
|
40
|
+
}
|
|
41
|
+
if (typeof schema.type === "string" && !typeMatches(value, schema.type)) {
|
|
42
|
+
at(`expected ${schema.type}, got ${typeOf(value)}`);
|
|
43
|
+
return issues;
|
|
44
|
+
}
|
|
45
|
+
if (schema.not && matches(value, schema.not, root)) {
|
|
46
|
+
at(schema.not.type === "null" ? "must not be null" : "is not allowed here");
|
|
47
|
+
return issues;
|
|
48
|
+
}
|
|
49
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
50
|
+
at(`must be one of: ${schema.enum.join(", ")}`);
|
|
51
|
+
}
|
|
52
|
+
if (schema.const !== undefined && value !== schema.const) {
|
|
53
|
+
at(`must be ${JSON.stringify(schema.const)}`);
|
|
54
|
+
}
|
|
55
|
+
if (typeof value === "string") {
|
|
56
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
|
|
57
|
+
at(`must match ${schema.pattern}`);
|
|
58
|
+
}
|
|
59
|
+
// The server reads "" as absent for the truthiness-guarded fields (url, selector,
|
|
60
|
+
// key, capture_as/ref), so minLength is what keeps "" from linting clean and 422ing.
|
|
61
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
62
|
+
at("must not be empty");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (typeof value === "number") {
|
|
66
|
+
if (schema.minimum !== undefined && value < schema.minimum)
|
|
67
|
+
at(`must be >= ${schema.minimum}`);
|
|
68
|
+
if (schema.maximum !== undefined && value > schema.maximum)
|
|
69
|
+
at(`must be <= ${schema.maximum}`);
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
73
|
+
at(`must have at least ${schema.minItems} item(s)`);
|
|
74
|
+
}
|
|
75
|
+
if (schema.contains && !value.some((v) => matches(v, schema.contains, root))) {
|
|
76
|
+
at(schema.$comment ?? "is missing a required entry");
|
|
77
|
+
}
|
|
78
|
+
schema.prefixItems?.forEach((s, i) => {
|
|
79
|
+
if (i < value.length)
|
|
80
|
+
issues.push(...validate(value[i], s, root, `${path}[${i}]`));
|
|
81
|
+
});
|
|
82
|
+
if (schema.items) {
|
|
83
|
+
value.forEach((v, i) => issues.push(...validate(v, schema.items, root, `${path}[${i}]`)));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
87
|
+
const object = value;
|
|
88
|
+
for (const key of schema.required ?? []) {
|
|
89
|
+
if (object[key] === undefined)
|
|
90
|
+
at(`missing required field "${key}"`);
|
|
91
|
+
}
|
|
92
|
+
for (const [key, sub] of Object.entries(schema.properties ?? {})) {
|
|
93
|
+
if (object[key] !== undefined) {
|
|
94
|
+
issues.push(...validate(object[key], sub, root, path ? `${path}.${key}` : key));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const sub of schema.allOf ?? []) {
|
|
99
|
+
// if/then[/else] carries the per-action rules ("a fill needs a value") and the
|
|
100
|
+
// section-dependent plan rules (the first EXECUTED step is before[0] when there is a
|
|
101
|
+
// setup section, else steps[0]); a bare allOf entry carries the unconditional
|
|
102
|
+
// plan-level ones. Dropping `else` would silently skip the flat-plan branch — a plan
|
|
103
|
+
// that lints clean here and then 422s on the server, the exact hole this file closes.
|
|
104
|
+
if (sub.if) {
|
|
105
|
+
const branch = matches(value, sub.if, root) ? sub.then : sub.else;
|
|
106
|
+
if (branch) {
|
|
107
|
+
const found = validate(value, branch, root, path);
|
|
108
|
+
if (found.length && sub.$comment)
|
|
109
|
+
at(sub.$comment);
|
|
110
|
+
else
|
|
111
|
+
issues.push(...found);
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const found = validate(value, sub, root, path);
|
|
116
|
+
if (found.length && sub.$comment)
|
|
117
|
+
at(sub.$comment);
|
|
118
|
+
else
|
|
119
|
+
issues.push(...found);
|
|
120
|
+
}
|
|
121
|
+
return issues;
|
|
122
|
+
}
|
|
123
|
+
export function lintPlan(plan) {
|
|
124
|
+
return validate(plan, ACTION_PLAN_SCHEMA, ACTION_PLAN_SCHEMA, "");
|
|
125
|
+
}
|
package/dist/registry/index.js
CHANGED
|
@@ -58,7 +58,20 @@ export function findCommand(words) {
|
|
|
58
58
|
best = { spec, consumed: parts.length };
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
if (best)
|
|
62
|
+
return best;
|
|
63
|
+
// A bare group word runs its default subcommand — `beryl tests` is `beryl tests list`.
|
|
64
|
+
// Only when the group is the whole command: `beryl tests bogus` must stay an unknown
|
|
65
|
+
// command, not silently list tests and drop the typo'd subcommand as a stray argument.
|
|
66
|
+
if (words.length === 1) {
|
|
67
|
+
const fallback = groupDefault(words[0]);
|
|
68
|
+
if (fallback)
|
|
69
|
+
return { spec: fallback, consumed: 1 };
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
export function groupDefault(group) {
|
|
74
|
+
return commands.find((spec) => spec.groupDefault && spec.name.split(" ")[0] === group);
|
|
62
75
|
}
|
|
63
76
|
export function commandGroups() {
|
|
64
77
|
const groups = new Map();
|