@beryl-so/cli 0.14.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.
- package/README.md +3 -28
- package/dist/beryl-test-skill.js +68 -12
- package/dist/commands/config-vars.js +23 -2
- package/dist/commands/inboxes.js +1 -22
- package/dist/commands/projects.js +21 -5
- package/dist/commands/runs.js +231 -66
- package/dist/commands/tests.js +260 -19
- package/dist/context.js +33 -2
- package/dist/email-extract.js +99 -0
- package/dist/email-pump.js +102 -0
- package/dist/local-exec.js +176 -0
- package/dist/local-run.js +145 -4
- package/dist/output.js +19 -0
- package/dist/progress.js +44 -0
- package/dist/registry/index.js +0 -2
- package/dist/schema.generated.js +2 -0
- package/package.json +2 -2
- package/dist/commands/credentials.js +0 -135
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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)
|
package/dist/progress.js
ADDED
|
@@ -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
|
+
}
|
package/dist/registry/index.js
CHANGED
|
@@ -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,
|
package/dist/schema.generated.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beryl-so/cli",
|
|
3
|
-
"version": "0.
|
|
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,7 +27,7 @@
|
|
|
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": {
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
import { dim, green, yellow } from "../output.js";
|
|
2
|
-
import { arg, flagBool } from "./util.js";
|
|
3
|
-
const capturePath = (ws, p) => `/auth-capture/workspaces/${ws}/projects/${p}/sessions`;
|
|
4
|
-
export const credentialCommands = [
|
|
5
|
-
{
|
|
6
|
-
name: "credentials list",
|
|
7
|
-
summary: "List the workspace's saved logins",
|
|
8
|
-
scope: "workspace",
|
|
9
|
-
groupSummary: "Manage saved logins Beryl reuses to test behind authentication, and attach them to projects.",
|
|
10
|
-
async run(ctx, input) {
|
|
11
|
-
const ws = await ctx.requireWorkspace(input);
|
|
12
|
-
return { data: await ctx.client.get(`/workspaces/${ws}/credentials`) };
|
|
13
|
-
},
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
name: "credentials get",
|
|
17
|
-
summary: "Show one saved login (status and freshness — never the session itself)",
|
|
18
|
-
args: [{ name: "credential-id", description: "Credential id", required: true }],
|
|
19
|
-
async run(ctx, input) {
|
|
20
|
-
return { data: await ctx.client.get(`/credentials/${arg(input, "credential-id")}`) };
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
name: "credentials projects",
|
|
25
|
-
summary: "List the projects using a saved login",
|
|
26
|
-
args: [{ name: "credential-id", description: "Credential id", required: true }],
|
|
27
|
-
async run(ctx, input) {
|
|
28
|
-
return {
|
|
29
|
-
data: await ctx.client.get(`/credentials/${arg(input, "credential-id")}/projects`),
|
|
30
|
-
};
|
|
31
|
-
},
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
name: "credentials delete",
|
|
35
|
-
summary: "Delete a saved login",
|
|
36
|
-
args: [{ name: "credential-id", description: "Credential id", required: true }],
|
|
37
|
-
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
38
|
-
async run(ctx, input) {
|
|
39
|
-
const id = arg(input, "credential-id");
|
|
40
|
-
await ctx.confirm(`Delete credential ${id}?`, flagBool(input, "force"));
|
|
41
|
-
await ctx.client.del(`/credentials/${id}`);
|
|
42
|
-
return { human: "Deleted." };
|
|
43
|
-
},
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
name: "credentials attach",
|
|
47
|
-
summary: "Attach a saved login to a project",
|
|
48
|
-
scope: "project",
|
|
49
|
-
args: [{ name: "credential-id", description: "Credential id", required: true }],
|
|
50
|
-
async run(ctx, input) {
|
|
51
|
-
const { projectId } = await ctx.requireProject(input);
|
|
52
|
-
return {
|
|
53
|
-
data: await ctx.client.put(`/projects/${projectId}/credential`, {
|
|
54
|
-
credential_id: arg(input, "credential-id"),
|
|
55
|
-
}),
|
|
56
|
-
};
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
name: "credentials detach",
|
|
61
|
-
summary: "Detach the project's saved login",
|
|
62
|
-
scope: "project",
|
|
63
|
-
async run(ctx, input) {
|
|
64
|
-
const { projectId } = await ctx.requireProject(input);
|
|
65
|
-
await ctx.client.del(`/projects/${projectId}/credential`);
|
|
66
|
-
return { human: "Detached." };
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
name: "credentials recapture",
|
|
71
|
-
summary: "Start a re-capture for an expiring saved login (returns a live browser URL)",
|
|
72
|
-
args: [{ name: "credential-id", description: "Credential id", required: true }],
|
|
73
|
-
async run(ctx, input) {
|
|
74
|
-
return {
|
|
75
|
-
data: await ctx.client.post(`/credentials/${arg(input, "credential-id")}/recaptures`),
|
|
76
|
-
};
|
|
77
|
-
},
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
name: "credentials capture",
|
|
81
|
-
summary: "Capture a login for the project interactively: log in once in a real browser",
|
|
82
|
-
description: "Opens a live cloud-browser session on the project's site. Log in there like a normal " +
|
|
83
|
-
"user, come back, and press Enter — Beryl captures the session (encrypted at rest, " +
|
|
84
|
-
"never shown to anyone) so the agent can test the authenticated app.",
|
|
85
|
-
scope: "project",
|
|
86
|
-
interactive: true,
|
|
87
|
-
async run(ctx, input) {
|
|
88
|
-
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
89
|
-
const session = (await ctx.client.post(capturePath(workspaceId, projectId)));
|
|
90
|
-
ctx.err(`\nOpen this URL and log in to the site:\n\n ${yellow(session.live_view_url)}\n`);
|
|
91
|
-
await ctx.prompt("Press Enter once you are fully logged in… ");
|
|
92
|
-
try {
|
|
93
|
-
await ctx.client.post(`${capturePath(workspaceId, projectId)}/${session.session_id}/capture`);
|
|
94
|
-
}
|
|
95
|
-
finally {
|
|
96
|
-
await ctx.client
|
|
97
|
-
.del(`${capturePath(workspaceId, projectId)}/${session.session_id}`)
|
|
98
|
-
.catch(() => { });
|
|
99
|
-
}
|
|
100
|
-
return { human: `${green("Login captured.")} ${dim("The agent can test the gated app with it.")}` };
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
name: "auth-capture start",
|
|
105
|
-
summary: "Start a login-capture browser session for the project (non-interactive)",
|
|
106
|
-
scope: "project",
|
|
107
|
-
groupSummary: "Drive a browser session that captures a target-site login for Beryl to reuse.",
|
|
108
|
-
async run(ctx, input) {
|
|
109
|
-
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
110
|
-
return { data: await ctx.client.post(capturePath(workspaceId, projectId)) };
|
|
111
|
-
},
|
|
112
|
-
},
|
|
113
|
-
{
|
|
114
|
-
name: "auth-capture capture",
|
|
115
|
-
summary: "Save the session after the user has logged in via the live-view URL (first login or re-login)",
|
|
116
|
-
scope: "project",
|
|
117
|
-
args: [{ name: "session-id", description: "Session id from auth-capture start", required: true }],
|
|
118
|
-
async run(ctx, input) {
|
|
119
|
-
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
120
|
-
await ctx.client.post(`${capturePath(workspaceId, projectId)}/${arg(input, "session-id")}/capture`);
|
|
121
|
-
return { human: "Captured." };
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
name: "auth-capture release",
|
|
126
|
-
summary: "Release a login-capture browser session without capturing",
|
|
127
|
-
scope: "project",
|
|
128
|
-
args: [{ name: "session-id", description: "Session id from auth-capture start", required: true }],
|
|
129
|
-
async run(ctx, input) {
|
|
130
|
-
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
131
|
-
await ctx.client.del(`${capturePath(workspaceId, projectId)}/${arg(input, "session-id")}`);
|
|
132
|
-
return { human: "Released." };
|
|
133
|
-
},
|
|
134
|
-
},
|
|
135
|
-
];
|