@beryl-so/cli 0.11.1 → 0.17.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.
@@ -0,0 +1,176 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { startEmailPump } from "./email-pump.js";
4
+ import { PlaywrightMissingError, runSpecLocally, } from "./local-run.js";
5
+ // Client-side mirrors of the server's RUN_IMPORT_* caps: stay under them rather than
6
+ // discover them as a 413/422.
7
+ export const IMPORT_MAX_ERROR_LEN = 5000;
8
+ export const IMPORT_MAX_FILE_BYTES = 14 * 1024 * 1024;
9
+ export const IMPORT_MAX_TOTAL_BYTES = 180 * 1024 * 1024;
10
+ export function toRunEntry(spec, outcome, runError, startedAt, ordinal, redact) {
11
+ // Mirrors the cloud runner's redact_result: the spec types the secret into the
12
+ // page, so error text and the DOM snapshot can echo it back — scrub before the
13
+ // bytes leave this machine. Frames and screenshots are pixels; nothing to scrub.
14
+ const scrub = (text) => (redact ? text.split(redact).join("***") : text);
15
+ const scrubBytes = (bytes) => redact && bytes.includes(redact)
16
+ ? Buffer.from(bytes.toString("utf8").split(redact).join("***"), "utf8")
17
+ : bytes;
18
+ const completedAt = new Date().toISOString();
19
+ const base = {
20
+ test_case_id: spec.id,
21
+ started_at: startedAt,
22
+ completed_at: completedAt,
23
+ frames: [],
24
+ frame_urls: [],
25
+ frame_durations_ms: [],
26
+ files: [],
27
+ };
28
+ if (!outcome) {
29
+ return {
30
+ ...base,
31
+ status: "errored",
32
+ phase: "main",
33
+ error_message: scrub(runError ?? "the spec did not run").slice(0, IMPORT_MAX_ERROR_LEN),
34
+ };
35
+ }
36
+ const result = outcome.results[0];
37
+ const status = result && (result.status === "passed" || result.status === "expected")
38
+ ? "passed"
39
+ : "failed";
40
+ const harvested = outcome.harvested;
41
+ const entry = {
42
+ ...base,
43
+ status,
44
+ phase: status === "failed" ? (harvested?.phase ?? "main") : "main",
45
+ ...(result?.error
46
+ ? { error_message: scrub(result.error).slice(0, IMPORT_MAX_ERROR_LEN) }
47
+ : {}),
48
+ ...(result?.duration_ms !== undefined ? { duration_ms: Math.max(0, result.duration_ms) } : {}),
49
+ };
50
+ if (!harvested)
51
+ return entry;
52
+ // A frame over the per-file cap would force dropping mid-list and shift the
53
+ // index-aligned url/duration sidecars — drop the whole filmstrip instead.
54
+ if (harvested.frames.every((f) => f.length <= IMPORT_MAX_FILE_BYTES)) {
55
+ harvested.frames.forEach((bytes, i) => {
56
+ const name = `r${ordinal}-frame-${String(i).padStart(3, "0")}.png`;
57
+ entry.frames.push(name);
58
+ entry.files.push({ name, bytes });
59
+ });
60
+ entry.frame_urls = harvested.frameUrls;
61
+ entry.frame_durations_ms = harvested.frameDurationsMs;
62
+ }
63
+ if (harvested.screenshot && harvested.screenshot.length <= IMPORT_MAX_FILE_BYTES) {
64
+ entry.screenshot = `r${ordinal}-screenshot.png`;
65
+ entry.files.push({ name: entry.screenshot, bytes: harvested.screenshot });
66
+ }
67
+ if (harvested.domSnapshot && harvested.domSnapshot.length <= IMPORT_MAX_FILE_BYTES) {
68
+ entry.dom_snapshot = `r${ordinal}-dom.html`;
69
+ entry.files.push({ name: entry.dom_snapshot, bytes: scrubBytes(harvested.domSnapshot) });
70
+ }
71
+ return entry;
72
+ }
73
+ export function buildImportForm(entries, opts) {
74
+ // Total-size budget: when a big suite would blow past the server's cap, shed whole
75
+ // filmstrips (largest droppable payload, replay-only) test by test — never
76
+ // mid-list, and never silently.
77
+ let total = entries.flatMap((e) => e.files).reduce((n, f) => n + f.bytes.length, 0);
78
+ for (const entry of entries) {
79
+ if (total <= IMPORT_MAX_TOTAL_BYTES)
80
+ break;
81
+ const frameBytes = entry.files
82
+ .filter((f) => entry.frames.includes(f.name))
83
+ .reduce((n, f) => n + f.bytes.length, 0);
84
+ if (frameBytes === 0)
85
+ continue;
86
+ entry.files = entry.files.filter((f) => !entry.frames.includes(f.name));
87
+ entry.frames = [];
88
+ entry.frame_urls = [];
89
+ entry.frame_durations_ms = [];
90
+ total -= frameBytes;
91
+ opts.onNote?.(`replay frames for test ${entry.test_case_id} dropped (upload size cap)`);
92
+ }
93
+ const manifest = {
94
+ environment_id: opts.environmentId ?? null,
95
+ target_url_override: opts.targetUrlOverride ?? null,
96
+ ...(opts.notifications !== undefined ? { notifications: opts.notifications } : {}),
97
+ started_at: opts.startedAt,
98
+ completed_at: opts.completedAt,
99
+ results: entries.map(({ files: _files, ...rest }) => rest),
100
+ };
101
+ const form = new FormData();
102
+ form.append("manifest", JSON.stringify(manifest));
103
+ for (const entry of entries) {
104
+ for (const file of entry.files) {
105
+ form.append("files", new Blob([new Uint8Array(file.bytes)]), file.name);
106
+ }
107
+ }
108
+ return form;
109
+ }
110
+ /**
111
+ * Run one rendered spec on this machine with its full service harness: mint a run
112
+ * inbox when the spec awaits email (deleted after, best-effort), write the
113
+ * inbox/login sidecars, pump `await_email` requests over the API while Playwright
114
+ * runs. The one local-execution seam shared by `runs local` and the local-verify
115
+ * `tests create`. {@link PlaywrightMissingError} propagates (environmental — the
116
+ * caller decides whether to offer an install); any other throw is captured as
117
+ * `runError` (the spec never ran: an errored result, not a crashed command).
118
+ */
119
+ export async function executeLocalSpec(deps, opts) {
120
+ const { spec } = opts;
121
+ const inbox = spec.usesEmail
122
+ ? (await deps.client.post(`/workspaces/${deps.workspaceId}/inboxes`, {
123
+ ttl_hours: 1,
124
+ project_id: deps.projectId,
125
+ }))
126
+ : undefined;
127
+ try {
128
+ const outcome = await runSpecLocally({
129
+ spec: spec.content,
130
+ testName: spec.title,
131
+ dir: opts.dir,
132
+ harvest: opts.harvest,
133
+ redact: spec.usesLoginPassword ? opts.loginPassword : undefined,
134
+ setup: inbox || (spec.usesLoginPassword && opts.loginPassword !== undefined)
135
+ ? (runDir) => {
136
+ if (inbox)
137
+ fs.writeFileSync(path.join(runDir, "email-inbox.json"), JSON.stringify({ address: inbox.address }), "utf8");
138
+ if (spec.usesLoginPassword && opts.loginPassword !== undefined)
139
+ fs.writeFileSync(path.join(runDir, "run-config.json"), JSON.stringify({ login_password: opts.loginPassword }), "utf8");
140
+ }
141
+ : undefined,
142
+ during: (runDir) => {
143
+ const pump = inbox
144
+ ? startEmailPump({
145
+ sidecarPath: path.join(runDir, "email-inbox.json"),
146
+ client: deps.client,
147
+ workspaceId: deps.workspaceId,
148
+ inboxId: inbox.id,
149
+ since: inbox.created_at,
150
+ onEvent: opts.onEvent,
151
+ })
152
+ : undefined;
153
+ const extra = opts.onSpawn?.(runDir);
154
+ return () => {
155
+ extra?.();
156
+ pump?.stop();
157
+ };
158
+ },
159
+ });
160
+ return { outcome };
161
+ }
162
+ catch (err) {
163
+ if (err instanceof PlaywrightMissingError)
164
+ throw err;
165
+ // The spec never ran (compile error, filtered away) — an errored result the
166
+ // caller records, not an aborted command.
167
+ return { runError: err instanceof Error ? err.message : String(err) };
168
+ }
169
+ finally {
170
+ if (inbox) {
171
+ await deps.client
172
+ .del(`/workspaces/${deps.workspaceId}/inboxes/${inbox.id}`)
173
+ .catch(() => undefined);
174
+ }
175
+ }
176
+ }
package/dist/local-run.js CHANGED
@@ -21,6 +21,13 @@ const ISOLATING_CONFIG = (testDir, artifactsDir) => `import { defineConfig } fro
21
21
  ` outputDir: ${JSON.stringify(artifactsDir)},\n` +
22
22
  ` fullyParallel: false,\n` +
23
23
  `});\n`;
24
+ export function copyTextScrubbed(src, dest, redact) {
25
+ if (!redact) {
26
+ fs.copyFileSync(src, dest);
27
+ return;
28
+ }
29
+ fs.writeFileSync(dest, fs.readFileSync(src, "utf8").split(redact).join("***"));
30
+ }
24
31
  const PASSING = new Set(["passed", "expected"]);
25
32
  const SKIPPED = new Set(["skipped"]);
26
33
  export class PlaywrightMissingError extends Error {
@@ -95,6 +102,105 @@ export function tally(results) {
95
102
  function parseReport(reportPath) {
96
103
  return parsePlaywrightReport(JSON.parse(fs.readFileSync(reportPath, "utf8")));
97
104
  }
105
+ /** How many filmstrip frames the rendered spec will write — the step total a live
106
+ * progress bar counts against. 0 when the spec was rendered without frame capture. */
107
+ export function countPlannedFrames(spec) {
108
+ return spec.match(/frames\/step-\d+\.png/g)?.length ?? 0;
109
+ }
110
+ export function countWrittenFrames(runDir) {
111
+ try {
112
+ return fs
113
+ .readdirSync(path.join(runDir, "frames"))
114
+ .filter((f) => /^step-\d+\.png$/.test(f)).length;
115
+ }
116
+ catch {
117
+ return 0;
118
+ }
119
+ }
120
+ const readIfExists = (p) => {
121
+ try {
122
+ return fs.readFileSync(p);
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ };
128
+ function findFirstFile(base, test) {
129
+ let entries;
130
+ try {
131
+ entries = fs.readdirSync(base, { withFileTypes: true });
132
+ }
133
+ catch {
134
+ return undefined;
135
+ }
136
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
137
+ const full = path.join(base, entry.name);
138
+ if (entry.isFile() && test(entry.name))
139
+ return readIfExists(full);
140
+ if (entry.isDirectory()) {
141
+ const nested = findFirstFile(full, test);
142
+ if (nested)
143
+ return nested;
144
+ }
145
+ }
146
+ return undefined;
147
+ }
148
+ // Mirrors the cloud runner's workdir harvest (_harvest_frames/_harvest_frame_urls/
149
+ // _harvest_frame_step_durations/_harvest_dom_snapshot/_harvest_phase): the same
150
+ // sorted step-*.png order drives all three frame lists so they stay index-aligned.
151
+ function harvestArtifacts(runDir, artifactsDir) {
152
+ const framesDir = path.join(runDir, "frames");
153
+ let framePaths = [];
154
+ try {
155
+ framePaths = fs
156
+ .readdirSync(framesDir)
157
+ .filter((f) => /^step-\d+\.png$/.test(f))
158
+ .sort()
159
+ .map((f) => path.join(framesDir, f));
160
+ }
161
+ catch {
162
+ // no frames dir — spec rendered without capture, or it never got that far
163
+ }
164
+ const frames = [];
165
+ const frameUrls = [];
166
+ const stamps = [];
167
+ for (const p of framePaths) {
168
+ const png = readIfExists(p);
169
+ if (!png)
170
+ continue;
171
+ frames.push(png);
172
+ frameUrls.push(readIfExists(p.replace(/\.png$/, ".url"))?.toString("utf8").trim() ?? "");
173
+ const raw = readIfExists(p.replace(/\.png$/, ".ts"))?.toString("utf8").trim();
174
+ const stamp = raw ? Number(raw) : NaN;
175
+ stamps.push(Number.isFinite(stamp) ? stamp : null);
176
+ }
177
+ const frameDurationsMs = stamps.map((stamp, i) => {
178
+ if (i === 0)
179
+ return 0;
180
+ const prev = stamps[i - 1];
181
+ return stamp !== null && prev !== null && prev !== undefined
182
+ ? Math.max(0, stamp - prev)
183
+ : null;
184
+ });
185
+ let phase = "main";
186
+ try {
187
+ const parsed = JSON.parse(fs.readFileSync(path.join(runDir, "phase.json"), "utf8"));
188
+ if (parsed.phase && ["setup", "main", "teardown"].includes(parsed.phase)) {
189
+ phase = parsed.phase;
190
+ }
191
+ }
192
+ catch {
193
+ // no phase sidecar — an unsectioned spec
194
+ }
195
+ return {
196
+ frames,
197
+ frameUrls,
198
+ frameDurationsMs,
199
+ screenshot: findFirstFile(artifactsDir, (n) => n.endsWith(".png")),
200
+ domSnapshot: readIfExists(path.join(runDir, "dom-snapshot.html")),
201
+ phase,
202
+ };
203
+ }
98
204
  /**
99
205
  * Write `spec` next to the caller's project, run it with their local @playwright/test, and
100
206
  * parse the JSON report into a structured pass/fail summary. Artifacts + the spec + report land
@@ -109,6 +215,11 @@ export async function runSpecLocally(opts) {
109
215
  // /tmp finds no node_modules and every run dies with "Cannot find module '@playwright/test'".
110
216
  // A run dir under cwd walks up into the project's node_modules; it's always cleaned up.
111
217
  const runDir = fs.mkdtempSync(path.join(cwd, ".beryl-local-"));
218
+ // The spec's sidecar helpers (__vmInbox/__vmRunConfig) read files via require(),
219
+ // which dies with "require is not defined" when the surrounding project is
220
+ // "type": "module" (the spec then compiles as ESM). Scoping the run dir back to
221
+ // CommonJS matches the cloud workdir; node_modules resolution still walks up.
222
+ fs.writeFileSync(path.join(runDir, "package.json"), '{"type":"commonjs"}\n');
112
223
  // Where the user-facing outputs (artifacts, spec copy, report) go: --dir if asked, else the
113
224
  // ephemeral run dir.
114
225
  const outDir = opts.dir ? path.resolve(opts.dir) : runDir;
@@ -123,8 +234,19 @@ export async function runSpecLocally(opts) {
123
234
  const { command, args: base } = playwrightBase(cwd);
124
235
  const args = [...base, `--config=${configPath}`, "--reporter=json"];
125
236
  opts.onProgress?.(`Running ${command} ${base.join(" ")} on ${opts.testName}…`);
237
+ await opts.setup?.(runDir);
238
+ const stop = opts.during?.(runDir);
126
239
  const env = { ...process.env, PLAYWRIGHT_JSON_OUTPUT_NAME: reportPath };
127
- const result = await runProcess(command, args, cwd, env);
240
+ // cwd is the RUN dir, not the project: the spec's relative writes (frames/,
241
+ // email-inbox.json, dom-snapshot.html, phase.json) must land where we harvest and
242
+ // clean up, exactly as the cloud runner keys them to its per-run workdir.
243
+ let result;
244
+ try {
245
+ result = await runProcess(command, args, runDir, env);
246
+ }
247
+ finally {
248
+ stop?.();
249
+ }
128
250
  if (looksLikePlaywrightMissing(result)) {
129
251
  cleanup();
130
252
  throw new PlaywrightMissingError(PLAYWRIGHT_INSTALL_HINT);
@@ -148,14 +270,25 @@ export async function runSpecLocally(opts) {
148
270
  cleanup();
149
271
  throw new Error(`Playwright ran no tests from the rendered spec.\n${detail}`);
150
272
  }
273
+ const harvested = opts.harvest ? harvestArtifacts(runDir, artifactsDir) : undefined;
151
274
  let keptSpec = specPath;
152
275
  let keptReport = reportPath;
153
276
  if (opts.dir) {
154
- // Persist the exact spec + report next to the artifacts before the run dir is removed.
277
+ // Persist the exact spec + report next to the artifacts before the run dir is removed,
278
+ // plus the spec's relative writes (filmstrip frames, failure DOM) that live in it.
279
+ // Text files are scrubbed like the uploaded bytes; frames are pixels.
155
280
  keptSpec = path.join(outDir, "beryl-local.spec.ts");
156
281
  keptReport = path.join(outDir, "report.json");
157
282
  fs.copyFileSync(specPath, keptSpec);
158
- fs.copyFileSync(reportPath, keptReport);
283
+ copyTextScrubbed(reportPath, keptReport, opts.redact);
284
+ const framesSrc = path.join(runDir, "frames");
285
+ if (fs.existsSync(framesSrc) && framesSrc !== path.join(outDir, "frames")) {
286
+ fs.cpSync(framesSrc, path.join(outDir, "frames"), { recursive: true, force: true });
287
+ }
288
+ const domSrc = path.join(runDir, "dom-snapshot.html");
289
+ if (fs.existsSync(domSrc) && domSrc !== path.join(outDir, "dom-snapshot.html")) {
290
+ copyTextScrubbed(domSrc, path.join(outDir, "dom-snapshot.html"), opts.redact);
291
+ }
159
292
  }
160
293
  else {
161
294
  // Without --dir the artifacts lived in the run dir we're about to delete, so their paths
@@ -165,5 +298,13 @@ export async function runSpecLocally(opts) {
165
298
  }
166
299
  cleanup();
167
300
  const { passed, failed } = tally(results);
168
- return { passed, failed, results, directory: outDir, spec: keptSpec, report: keptReport };
301
+ return {
302
+ passed,
303
+ failed,
304
+ results,
305
+ directory: outDir,
306
+ spec: keptSpec,
307
+ report: keptReport,
308
+ ...(harvested ? { harvested } : {}),
309
+ };
169
310
  }
package/dist/output.js CHANGED
@@ -16,6 +16,25 @@ export function statusColor(status) {
16
16
  return yellow(status);
17
17
  return status;
18
18
  }
19
+ // Humans read "2h ago", not "2026-07-28T09:51:45.921815Z". Past a month the
20
+ // relative form stops being informative, so it falls back to the plain date.
21
+ export function timeAgo(value) {
22
+ if (!value)
23
+ return "";
24
+ const t = new Date(String(value)).getTime();
25
+ if (Number.isNaN(t))
26
+ return "";
27
+ const s = Math.max(0, (Date.now() - t) / 1000);
28
+ if (s < 60)
29
+ return "just now";
30
+ if (s < 3600)
31
+ return `${Math.floor(s / 60)}m ago`;
32
+ if (s < 86400)
33
+ return `${Math.floor(s / 3600)}h ago`;
34
+ if (s < 30 * 86400)
35
+ return `${Math.floor(s / 86400)}d ago`;
36
+ return new Date(t).toISOString().slice(0, 10);
37
+ }
19
38
  const MAX_CELL = 60;
20
39
  function cell(value) {
21
40
  if (value === null || value === undefined)
@@ -0,0 +1,44 @@
1
+ import { dim } from "./output.js";
2
+ const BAR_WIDTH = 18;
3
+ export function renderProgressLine(state) {
4
+ const stepFraction = state.stepTotal && state.stepTotal > 0
5
+ ? Math.min(1, (state.step ?? 0) / state.stepTotal)
6
+ : 0;
7
+ const fraction = state.total > 0 ? Math.min(1, (state.done + stepFraction) / state.total) : 0;
8
+ const filled = Math.round(fraction * BAR_WIDTH);
9
+ const bar = "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled);
10
+ const pct = `${Math.round(fraction * 100)}%`.padStart(4);
11
+ const steps = state.stepTotal && state.stepTotal > 0
12
+ ? ` · step ${Math.min(state.step ?? 0, state.stepTotal)}/${state.stepTotal}`
13
+ : "";
14
+ const tally = state.failed > 0 ? ` · ✓${state.passed} ✗${state.failed}` : ` · ✓${state.passed}`;
15
+ return `[${bar}]${pct} · test ${Math.min(state.done + 1, state.total)}/${state.total}${tally} · ${state.title}${steps}`;
16
+ }
17
+ /** A single rewriting status line on a TTY; quiet elsewhere (the caller prints
18
+ * per-test boundary lines instead, so MCP/CI logs stay readable). */
19
+ export class ProgressBar {
20
+ stream;
21
+ lastLen = 0;
22
+ tty;
23
+ constructor(stream = process.stderr) {
24
+ this.stream = stream;
25
+ this.tty = Boolean(stream.isTTY);
26
+ }
27
+ get active() {
28
+ return this.tty;
29
+ }
30
+ update(state) {
31
+ if (!this.tty)
32
+ return;
33
+ const line = renderProgressLine(state);
34
+ const padded = line.padEnd(this.lastLen);
35
+ this.lastLen = line.length;
36
+ this.stream.write(`\r${dim(padded)}`);
37
+ }
38
+ clear() {
39
+ if (!this.tty || this.lastLen === 0)
40
+ return;
41
+ this.stream.write(`\r${" ".repeat(this.lastLen)}\r`);
42
+ this.lastLen = 0;
43
+ }
44
+ }
@@ -1,7 +1,6 @@
1
1
  import { accountCommands } from "../commands/account.js";
2
2
  import { authCommands } from "../commands/auth.js";
3
3
  import { configCommands } from "../commands/config-vars.js";
4
- import { credentialCommands } from "../commands/credentials.js";
5
4
  import { environmentCommands } from "../commands/environments.js";
6
5
  import { explorationCommands } from "../commands/explorations.js";
7
6
  import { inboxCommands } from "../commands/inboxes.js";
@@ -62,7 +61,6 @@ export const commands = [
62
61
  ...explorationCommands,
63
62
  ...configCommands,
64
63
  ...slackCommands,
65
- ...credentialCommands,
66
64
  ...inboxCommands,
67
65
  ...accountCommands,
68
66
  ...mcpCommands,
@@ -863,6 +863,8 @@ export const ACTION_PLAN_SCHEMA = {
863
863
  "not": {
864
864
  "enum": [
865
865
  "inbox_address",
866
+ "login_email",
867
+ "login_password",
866
868
  "timestamp",
867
869
  "unique",
868
870
  "uuid"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.11.1",
3
+ "version": "0.17.0",
4
4
  "description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,13 +27,14 @@
27
27
  "prepublishOnly": "npm run typecheck && npm test && npm run build",
28
28
  "typecheck": "tsc --noEmit",
29
29
  "test": "vitest run",
30
- "dev": "tsx src/index.ts",
30
+ "dev": "BERYL_API_URL=http://localhost:8000 BERYL_CONFIG_DIR=/tmp/beryl-dev tsx src/index.ts",
31
31
  "docs": "tsx scripts/gen-docs.ts"
32
32
  },
33
33
  "dependencies": {
34
34
  "@modelcontextprotocol/sdk": "^1.29.0"
35
35
  },
36
36
  "devDependencies": {
37
+ "@playwright/test": "^1.61.1",
37
38
  "@types/node": "^26.1.1",
38
39
  "tsx": "^4.23.1",
39
40
  "typescript": "^7.0.2",
@@ -1,178 +0,0 @@
1
- import { UsageError } from "../errors.js";
2
- import { dim, green, yellow } from "../output.js";
3
- import { arg, flagBool, flagStr } from "./util.js";
4
- const capturePath = (ws, p) => `/auth-capture/workspaces/${ws}/projects/${p}/sessions`;
5
- export const credentialCommands = [
6
- {
7
- name: "credentials list",
8
- summary: "List the workspace's saved logins",
9
- scope: "workspace",
10
- groupSummary: "Manage saved logins Beryl reuses to test behind authentication, and attach them to projects.",
11
- async run(ctx, input) {
12
- const ws = await ctx.requireWorkspace(input);
13
- return { data: await ctx.client.get(`/workspaces/${ws}/credentials`) };
14
- },
15
- },
16
- {
17
- name: "credentials get",
18
- summary: "Show one saved login (status and freshness — never the session itself)",
19
- args: [{ name: "credential-id", description: "Credential id", required: true }],
20
- async run(ctx, input) {
21
- return { data: await ctx.client.get(`/credentials/${arg(input, "credential-id")}`) };
22
- },
23
- },
24
- {
25
- name: "credentials projects",
26
- summary: "List the projects using a saved login",
27
- args: [{ name: "credential-id", description: "Credential id", required: true }],
28
- async run(ctx, input) {
29
- return {
30
- data: await ctx.client.get(`/credentials/${arg(input, "credential-id")}/projects`),
31
- };
32
- },
33
- },
34
- {
35
- name: "credentials delete",
36
- summary: "Delete a saved login",
37
- args: [{ name: "credential-id", description: "Credential id", required: true }],
38
- flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
39
- async run(ctx, input) {
40
- const id = arg(input, "credential-id");
41
- await ctx.confirm(`Delete credential ${id}?`, flagBool(input, "force"));
42
- await ctx.client.del(`/credentials/${id}`);
43
- return { human: "Deleted." };
44
- },
45
- },
46
- {
47
- name: "credentials attach",
48
- summary: "Attach a saved login to a project",
49
- scope: "project",
50
- args: [{ name: "credential-id", description: "Credential id", required: true }],
51
- async run(ctx, input) {
52
- const { projectId } = await ctx.requireProject(input);
53
- return {
54
- data: await ctx.client.put(`/projects/${projectId}/credential`, {
55
- credential_id: arg(input, "credential-id"),
56
- }),
57
- };
58
- },
59
- },
60
- {
61
- name: "credentials detach",
62
- summary: "Detach the project's saved login",
63
- scope: "project",
64
- async run(ctx, input) {
65
- const { projectId } = await ctx.requireProject(input);
66
- await ctx.client.del(`/projects/${projectId}/credential`);
67
- return { human: "Detached." };
68
- },
69
- },
70
- {
71
- name: "credentials recapture",
72
- summary: "Start a re-capture for an expiring saved login (returns a live browser URL)",
73
- args: [{ name: "credential-id", description: "Credential id", required: true }],
74
- async run(ctx, input) {
75
- return {
76
- data: await ctx.client.post(`/credentials/${arg(input, "credential-id")}/recaptures`),
77
- };
78
- },
79
- },
80
- {
81
- name: "credentials capture",
82
- summary: "Capture a login for the project interactively: log in once in a real browser",
83
- description: "Opens a live cloud-browser session on the project's site. Log in there like a normal " +
84
- "user, come back, and press Enter — Beryl captures the session (encrypted at rest, " +
85
- "never shown to anyone) so the agent can test the authenticated app.",
86
- scope: "project",
87
- interactive: true,
88
- async run(ctx, input) {
89
- const { workspaceId, projectId } = await ctx.requireProject(input);
90
- const session = (await ctx.client.post(capturePath(workspaceId, projectId)));
91
- ctx.err(`\nOpen this URL and log in to the site:\n\n ${yellow(session.live_view_url)}\n`);
92
- await ctx.prompt("Press Enter once you are fully logged in… ");
93
- try {
94
- await ctx.client.post(`${capturePath(workspaceId, projectId)}/${session.session_id}/capture`);
95
- }
96
- finally {
97
- await ctx.client
98
- .del(`${capturePath(workspaceId, projectId)}/${session.session_id}`)
99
- .catch(() => { });
100
- }
101
- return { human: `${green("Login captured.")} ${dim("The agent can now test the gated app.")}` };
102
- },
103
- },
104
- {
105
- name: "auth-capture start",
106
- summary: "Start a login-capture browser session for the project (non-interactive)",
107
- scope: "project",
108
- groupSummary: "Drive a browser session that captures a target-site login for Beryl to reuse.",
109
- async run(ctx, input) {
110
- const { workspaceId, projectId } = await ctx.requireProject(input);
111
- return { data: await ctx.client.post(capturePath(workspaceId, projectId)) };
112
- },
113
- },
114
- {
115
- name: "auth-capture login",
116
- summary: "Log into the target site headlessly with credentials (no human at the browser)",
117
- description: "Drives the login inside the capture session started by `auth-capture start`, so " +
118
- "an agent can complete start → login → capture with zero human intervention. The " +
119
- "credentials are sent to the server, typed into the target site over the wire, and " +
120
- "never stored, logged, or returned — the captured session stays encrypted " +
121
- "server-side. Follow with `auth-capture capture` to snapshot the authenticated session.",
122
- scope: "project",
123
- args: [
124
- { name: "session-id", description: "Session id from auth-capture start", required: true },
125
- ],
126
- flags: [
127
- { name: "username", type: "string", description: "Login username / email", required: true },
128
- { name: "password", type: "string", description: "Login password", required: true },
129
- {
130
- name: "login-url",
131
- type: "string",
132
- description: "Explicit login page URL (defaults to the session's current page)",
133
- },
134
- ],
135
- async run(ctx, input) {
136
- const { workspaceId, projectId } = await ctx.requireProject(input);
137
- const username = flagStr(input, "username");
138
- const password = flagStr(input, "password");
139
- if (!username || !password)
140
- throw new UsageError("--username and --password are required");
141
- await ctx.client.post(`${capturePath(workspaceId, projectId)}/${arg(input, "session-id")}/login`, { username, password, login_url: flagStr(input, "login-url") ?? null });
142
- return { human: "Logged in." };
143
- },
144
- },
145
- {
146
- name: "auth-capture capture",
147
- summary: "Capture the session after the user has logged in via the live-view URL",
148
- scope: "project",
149
- args: [{ name: "session-id", description: "Session id from auth-capture start", required: true }],
150
- async run(ctx, input) {
151
- const { workspaceId, projectId } = await ctx.requireProject(input);
152
- await ctx.client.post(`${capturePath(workspaceId, projectId)}/${arg(input, "session-id")}/capture`);
153
- return { human: "Captured." };
154
- },
155
- },
156
- {
157
- name: "auth-capture refresh",
158
- summary: "Capture a refreshed session for a project whose login is expiring",
159
- scope: "project",
160
- args: [{ name: "session-id", description: "Session id from auth-capture start", required: true }],
161
- async run(ctx, input) {
162
- const { workspaceId, projectId } = await ctx.requireProject(input);
163
- await ctx.client.post(`${capturePath(workspaceId, projectId)}/${arg(input, "session-id")}/capture-refresh`);
164
- return { human: "Captured." };
165
- },
166
- },
167
- {
168
- name: "auth-capture release",
169
- summary: "Release a login-capture browser session without capturing",
170
- scope: "project",
171
- args: [{ name: "session-id", description: "Session id from auth-capture start", required: true }],
172
- async run(ctx, input) {
173
- const { workspaceId, projectId } = await ctx.requireProject(input);
174
- await ctx.client.del(`${capturePath(workspaceId, projectId)}/${arg(input, "session-id")}`);
175
- return { human: "Released." };
176
- },
177
- },
178
- ];