@runalabs/rill-cli 0.1.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 +27 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +939 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# @runalabs/rill-cli
|
|
2
|
+
|
|
3
|
+
Record, inspect, and share agent-controlled browser runs with Rill.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install --global @runalabs/rill-cli@0.1.0
|
|
9
|
+
rill doctor
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Rill requires Node.js 22 or newer, Google Chrome or Chromium, and `ffmpeg`.
|
|
13
|
+
The CLI defaults to the Rill private-alpha staging API. Set `RILL_API_URL` or
|
|
14
|
+
pass `--api-url` to target another environment.
|
|
15
|
+
|
|
16
|
+
## Record
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
rill login
|
|
20
|
+
rill doctor
|
|
21
|
+
rill record start --url https://staging.example.com --title "Agent reproduction"
|
|
22
|
+
# Connect browser automation to the returned cdpUrl.
|
|
23
|
+
rill record stop <recording-id>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Commands emit one schema-versioned JSON object to stdout. Human-readable
|
|
27
|
+
progress is written to stderr.
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { hostname } from "os";
|
|
6
|
+
import { basename, dirname as dirname2 } from "path";
|
|
7
|
+
import { readdir, rm as rm2, stat as stat2 } from "fs/promises";
|
|
8
|
+
|
|
9
|
+
// ../recorder/src/client.ts
|
|
10
|
+
import { request } from "http";
|
|
11
|
+
import { spawn } from "child_process";
|
|
12
|
+
|
|
13
|
+
// ../recorder/src/paths.ts
|
|
14
|
+
import { homedir, tmpdir } from "os";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
var rillDirectory = () => process.env.RILL_HOME ?? join(homedir(), ".rill");
|
|
17
|
+
var daemonSocketPath = () => process.env.RILL_SOCKET ?? join(rillDirectory(), "recorder.sock");
|
|
18
|
+
var daemonStatePath = () => join(rillDirectory(), "sessions.json");
|
|
19
|
+
var recordingTempRoot = () => process.env.RILL_TEMP ?? join(tmpdir(), "rill-recordings");
|
|
20
|
+
|
|
21
|
+
// ../recorder/src/client.ts
|
|
22
|
+
async function ensureDaemon(cliEntryPath) {
|
|
23
|
+
try {
|
|
24
|
+
await callDaemon("GET", "/health");
|
|
25
|
+
return;
|
|
26
|
+
} catch {
|
|
27
|
+
}
|
|
28
|
+
const child = spawn(process.execPath, [cliEntryPath, "daemon"], { detached: true, stdio: "ignore", env: process.env });
|
|
29
|
+
child.unref();
|
|
30
|
+
const deadline = Date.now() + 1e4;
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
try {
|
|
33
|
+
await callDaemon("GET", "/health");
|
|
34
|
+
return;
|
|
35
|
+
} catch {
|
|
36
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw new Error("The local recorder service did not start.");
|
|
40
|
+
}
|
|
41
|
+
function startRecording(input) {
|
|
42
|
+
return callDaemon("POST", "/sessions", input);
|
|
43
|
+
}
|
|
44
|
+
function stopRecording(recordingId) {
|
|
45
|
+
return callDaemon("POST", `/sessions/${encodeURIComponent(recordingId)}/stop`, {});
|
|
46
|
+
}
|
|
47
|
+
function recordingStatus(recordingId) {
|
|
48
|
+
return callDaemon("GET", `/sessions/${encodeURIComponent(recordingId)}`);
|
|
49
|
+
}
|
|
50
|
+
function callDaemon(method, path, body) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const payload = body === void 0 ? void 0 : Buffer.from(JSON.stringify(body));
|
|
53
|
+
const outgoing = request({ socketPath: daemonSocketPath(), method, path, headers: payload ? { "Content-Type": "application/json", "Content-Length": payload.length } : void 0 }, (incoming) => {
|
|
54
|
+
const chunks = [];
|
|
55
|
+
incoming.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
56
|
+
incoming.on("end", () => {
|
|
57
|
+
try {
|
|
58
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
59
|
+
if ((incoming.statusCode ?? 500) >= 400) reject(new Error(value.error?.message ?? `Recorder returned ${incoming.statusCode}.`));
|
|
60
|
+
else resolve(value);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
reject(error);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
outgoing.once("error", reject);
|
|
67
|
+
if (payload) outgoing.write(payload);
|
|
68
|
+
outgoing.end();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ../recorder/src/daemon.ts
|
|
73
|
+
import { createServer as createServer2 } from "http";
|
|
74
|
+
import { mkdir as mkdir3, readFile, unlink, writeFile as writeFile2 } from "fs/promises";
|
|
75
|
+
import { dirname, join as join4 } from "path";
|
|
76
|
+
|
|
77
|
+
// ../recorder/src/session.ts
|
|
78
|
+
import { chromium } from "playwright-core";
|
|
79
|
+
import { spawn as spawn3 } from "child_process";
|
|
80
|
+
import { createServer } from "net";
|
|
81
|
+
import { mkdir as mkdir2, mkdtemp, rm } from "fs/promises";
|
|
82
|
+
import { join as join3 } from "path";
|
|
83
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
84
|
+
|
|
85
|
+
// ../recorder/src/bundle.ts
|
|
86
|
+
import { createGzip } from "zlib";
|
|
87
|
+
import { createWriteStream } from "fs";
|
|
88
|
+
import { once } from "events";
|
|
89
|
+
async function writeBundle(bundle, outputPath) {
|
|
90
|
+
const output = createWriteStream(outputPath, { mode: 384 });
|
|
91
|
+
const gzip = createGzip({ level: 9 });
|
|
92
|
+
gzip.pipe(output);
|
|
93
|
+
const { console: consoleEvents, network, timeline, ...manifest } = bundle;
|
|
94
|
+
gzip.write(`${JSON.stringify({ kind: "manifest", ...manifest })}
|
|
95
|
+
`);
|
|
96
|
+
for (const event of consoleEvents) gzip.write(`${JSON.stringify({ kind: "console", ...event })}
|
|
97
|
+
`);
|
|
98
|
+
for (const event of network) gzip.write(`${JSON.stringify({ kind: "network", ...event })}
|
|
99
|
+
`);
|
|
100
|
+
for (const event of timeline) gzip.write(`${JSON.stringify({ kind: "timeline", ...event })}
|
|
101
|
+
`);
|
|
102
|
+
gzip.end();
|
|
103
|
+
await once(output, "close");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ../recorder/src/environment.ts
|
|
107
|
+
import { existsSync } from "fs";
|
|
108
|
+
function findChromeExecutable() {
|
|
109
|
+
if (process.env.RILL_CHROME_PATH) return process.env.RILL_CHROME_PATH;
|
|
110
|
+
if (process.platform === "darwin") return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
111
|
+
const candidates = ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
|
|
112
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? "google-chrome";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ../recorder/src/redaction.ts
|
|
116
|
+
var secretKeyPattern = /(?:pass(?:word)?|secret|token|api[_-]?key|authorization|cookie|session|access[_-]?token|refresh[_-]?token|client[_-]?secret)/i;
|
|
117
|
+
var tokenValuePattern = /\b(?:sk|pk|ghp|github_pat|xox[baprs]|eyJ)[-_a-zA-Z0-9.]{16,}\b/g;
|
|
118
|
+
function sanitizeUrl(input) {
|
|
119
|
+
try {
|
|
120
|
+
const url = new URL(input);
|
|
121
|
+
url.username = "";
|
|
122
|
+
url.password = "";
|
|
123
|
+
url.hash = "";
|
|
124
|
+
for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, "[REDACTED]");
|
|
125
|
+
return url.toString();
|
|
126
|
+
} catch {
|
|
127
|
+
return "[invalid-url]";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function redactText(value, maxLength = 8e3) {
|
|
131
|
+
return value.replace(tokenValuePattern, "[REDACTED]").slice(0, maxLength);
|
|
132
|
+
}
|
|
133
|
+
function redactBody(value, contentType, maxBytes = 65536) {
|
|
134
|
+
if (Buffer.byteLength(value) > maxBytes) return { body: void 0, omittedReason: "body_too_large" };
|
|
135
|
+
if (!isAllowedBodyType(contentType)) return { body: void 0, omittedReason: "unsupported_content_type" };
|
|
136
|
+
if (contentType?.includes("json")) {
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(value);
|
|
139
|
+
return { body: JSON.stringify(redactJson(parsed)) };
|
|
140
|
+
} catch {
|
|
141
|
+
return { body: redactText(value) };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { body: redactText(value) };
|
|
145
|
+
}
|
|
146
|
+
function isAllowedBodyType(contentType) {
|
|
147
|
+
if (!contentType) return false;
|
|
148
|
+
const normalized = contentType.toLowerCase();
|
|
149
|
+
return normalized.includes("application/json") || normalized.includes("application/problem+json") || normalized.includes("application/graphql-response+json") || normalized.includes("text/plain") || normalized.includes("application/xml") || normalized.includes("text/xml");
|
|
150
|
+
}
|
|
151
|
+
function redactJson(value, depth = 0) {
|
|
152
|
+
if (depth > 12) return "[TRUNCATED]";
|
|
153
|
+
if (Array.isArray(value)) return value.slice(0, 100).map((item) => redactJson(item, depth + 1));
|
|
154
|
+
if (value && typeof value === "object") {
|
|
155
|
+
return Object.fromEntries(Object.entries(value).slice(0, 200).map(([key, child]) => [key, secretKeyPattern.test(key) ? "[REDACTED]" : redactJson(child, depth + 1)]));
|
|
156
|
+
}
|
|
157
|
+
if (typeof value === "string") return redactText(value, 8e3);
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ../recorder/src/screencast.ts
|
|
162
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
163
|
+
import { spawn as spawn2 } from "child_process";
|
|
164
|
+
import { join as join2 } from "path";
|
|
165
|
+
import { once as once2 } from "events";
|
|
166
|
+
function createFfconcatManifest(frames, expectedDurationSeconds) {
|
|
167
|
+
if (!frames.length) throw new Error("No video frames were captured.");
|
|
168
|
+
const orderedFrames = frames.toSorted((left, right) => left.timestamp - right.timestamp);
|
|
169
|
+
const firstTimestamp = orderedFrames[0].timestamp;
|
|
170
|
+
const lines = ["ffconcat version 1.0"];
|
|
171
|
+
for (let index = 0; index < orderedFrames.length; index += 1) {
|
|
172
|
+
const frame = orderedFrames[index];
|
|
173
|
+
const next = orderedFrames[index + 1];
|
|
174
|
+
const elapsedAtFrame = Math.max(0, frame.timestamp - firstTimestamp);
|
|
175
|
+
const duration = next ? Math.max(1 / 60, next.timestamp - frame.timestamp) : Math.max(1 / 60, expectedDurationSeconds - elapsedAtFrame);
|
|
176
|
+
lines.push(`file '${frame.path.replaceAll("'", "'\\''")}'`, `duration ${duration.toFixed(6)}`);
|
|
177
|
+
}
|
|
178
|
+
lines.push(`file '${orderedFrames.at(-1).path.replaceAll("'", "'\\''")}'`);
|
|
179
|
+
return `${lines.join("\n")}
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
var ScreencastRecorder = class {
|
|
183
|
+
constructor(session, directory, width, height) {
|
|
184
|
+
this.session = session;
|
|
185
|
+
this.directory = directory;
|
|
186
|
+
this.width = width;
|
|
187
|
+
this.height = height;
|
|
188
|
+
}
|
|
189
|
+
frames = [];
|
|
190
|
+
pendingWrites = /* @__PURE__ */ new Set();
|
|
191
|
+
started = false;
|
|
192
|
+
frameCounter = 0;
|
|
193
|
+
async start() {
|
|
194
|
+
await mkdir(this.directory, { recursive: true, mode: 448 });
|
|
195
|
+
this.session.on("Page.screencastFrame", (payload) => {
|
|
196
|
+
const path = join2(this.directory, `frame-${String(this.frameCounter).padStart(6, "0")}.jpg`);
|
|
197
|
+
this.frameCounter += 1;
|
|
198
|
+
const write = writeFile(path, Buffer.from(payload.data, "base64"), { mode: 384 }).then(() => {
|
|
199
|
+
this.frames.push({ path, timestamp: payload.metadata.timestamp ?? Date.now() / 1e3 });
|
|
200
|
+
}).finally(() => {
|
|
201
|
+
this.pendingWrites.delete(write);
|
|
202
|
+
void this.session.send("Page.screencastFrameAck", { sessionId: payload.sessionId }).catch(() => void 0);
|
|
203
|
+
});
|
|
204
|
+
this.pendingWrites.add(write);
|
|
205
|
+
});
|
|
206
|
+
await this.session.send("Page.enable");
|
|
207
|
+
await this.session.send("Page.startScreencast", { format: "jpeg", quality: 82, maxWidth: this.width, maxHeight: this.height, everyNthFrame: 1 });
|
|
208
|
+
this.started = true;
|
|
209
|
+
}
|
|
210
|
+
async stop(outputPath, expectedDurationSeconds) {
|
|
211
|
+
if (this.started) await this.session.send("Page.stopScreencast").catch(() => void 0);
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
213
|
+
await Promise.all(this.pendingWrites);
|
|
214
|
+
if (!this.frames.length) throw new Error("No video frames were captured.");
|
|
215
|
+
const manifestPath = join2(this.directory, "frames.ffconcat");
|
|
216
|
+
await writeFile(manifestPath, createFfconcatManifest(this.frames, expectedDurationSeconds), { mode: 384 });
|
|
217
|
+
const ffmpeg = process.env.RILL_FFMPEG ?? "ffmpeg";
|
|
218
|
+
const processHandle = spawn2(ffmpeg, ["-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", manifestPath, "-vsync", "vfr", "-pix_fmt", "yuv420p", "-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "32", "-y", outputPath], { stdio: ["ignore", "ignore", "pipe"] });
|
|
219
|
+
let stderr = "";
|
|
220
|
+
processHandle.stderr.on("data", (chunk) => {
|
|
221
|
+
stderr += String(chunk);
|
|
222
|
+
});
|
|
223
|
+
const [exitCode] = await once2(processHandle, "exit");
|
|
224
|
+
if (exitCode !== 0) throw new Error(`ffmpeg could not finalize the recording: ${stderr.trim()}`);
|
|
225
|
+
return { frameCount: this.frames.length, outputPath };
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// ../recorder/src/session.ts
|
|
230
|
+
var severityMap = { debug: "debug", info: "info", log: "info", warning: "warning", warn: "warning", error: "error", assert: "error" };
|
|
231
|
+
var RecordingSession = class _RecordingSession {
|
|
232
|
+
recordingId;
|
|
233
|
+
startedAt = /* @__PURE__ */ new Date();
|
|
234
|
+
cdpUrl;
|
|
235
|
+
browser;
|
|
236
|
+
context;
|
|
237
|
+
page;
|
|
238
|
+
browserProcess;
|
|
239
|
+
profileDirectory;
|
|
240
|
+
screencast;
|
|
241
|
+
consoleEvents = [];
|
|
242
|
+
networkEvents = [];
|
|
243
|
+
timelineEvents = [];
|
|
244
|
+
requestStart = /* @__PURE__ */ new Map();
|
|
245
|
+
requestEvents = /* @__PURE__ */ new Map();
|
|
246
|
+
stopPromise;
|
|
247
|
+
maxTimer;
|
|
248
|
+
bodyBytes = 0;
|
|
249
|
+
truncated = false;
|
|
250
|
+
constructor(options, runtime) {
|
|
251
|
+
this.recordingId = options.recordingId;
|
|
252
|
+
this.cdpUrl = runtime.cdpUrl;
|
|
253
|
+
this.browser = runtime.browser;
|
|
254
|
+
this.context = runtime.context;
|
|
255
|
+
this.page = runtime.page;
|
|
256
|
+
this.browserProcess = runtime.browserProcess;
|
|
257
|
+
this.profileDirectory = runtime.profileDirectory;
|
|
258
|
+
this.screencast = runtime.screencast;
|
|
259
|
+
this.outputDirectory = options.outputDirectory;
|
|
260
|
+
}
|
|
261
|
+
static async start(options) {
|
|
262
|
+
const width = options.width ?? 1280;
|
|
263
|
+
const height = options.height ?? 720;
|
|
264
|
+
await mkdir2(options.outputDirectory, { recursive: true, mode: 448 });
|
|
265
|
+
const runtime = options.cdpUrl ? await connectAttached(options.cdpUrl, options.outputDirectory) : await launchManaged({ headed: options.headed ?? false, width, height, outputDirectory: options.outputDirectory });
|
|
266
|
+
const cdpSession = await runtime.context.newCDPSession(runtime.page);
|
|
267
|
+
const screencast = new ScreencastRecorder(cdpSession, join3(options.outputDirectory, "frames"), width, height);
|
|
268
|
+
const session = new _RecordingSession(options, { ...runtime, screencast });
|
|
269
|
+
await session.attachDiagnostics(options.captureNetworkBodies ?? false);
|
|
270
|
+
await screencast.start();
|
|
271
|
+
if (options.url) await session.page.goto(options.url, { waitUntil: "domcontentloaded" });
|
|
272
|
+
session.maxTimer = setTimeout(() => void session.stop("max_duration"), (options.maxDurationSeconds ?? 600) * 1e3);
|
|
273
|
+
return session;
|
|
274
|
+
}
|
|
275
|
+
status() {
|
|
276
|
+
return { recordingId: this.recordingId, cdpUrl: this.cdpUrl, startedAt: this.startedAt.toISOString(), status: this.stopPromise ? "finalizing" : "recording" };
|
|
277
|
+
}
|
|
278
|
+
async stop(reason = "agent_request") {
|
|
279
|
+
this.stopPromise ??= this.finalize(reason);
|
|
280
|
+
return this.stopPromise;
|
|
281
|
+
}
|
|
282
|
+
async finalize(reason) {
|
|
283
|
+
if (this.maxTimer) clearTimeout(this.maxTimer);
|
|
284
|
+
const durationSeconds = Math.max(0.1, (Date.now() - this.startedAt.getTime()) / 1e3);
|
|
285
|
+
const outputDirectory = this.outputDirectory;
|
|
286
|
+
const videoPath = join3(outputDirectory, "recording.webm");
|
|
287
|
+
const diagnosticsPath = join3(outputDirectory, "diagnostics-v1.ndjson.gz");
|
|
288
|
+
await this.screencast.stop(videoPath, durationSeconds);
|
|
289
|
+
const bundle = {
|
|
290
|
+
schemaVersion: 1,
|
|
291
|
+
capturePolicyVersion: "safe-default-v1",
|
|
292
|
+
recordingId: this.recordingId,
|
|
293
|
+
startedAt: this.startedAt.toISOString(),
|
|
294
|
+
durationMs: Math.round(durationSeconds * 1e3),
|
|
295
|
+
bodyCaptureEnabled: this.captureNetworkBodies,
|
|
296
|
+
bodyCaptureRedacted: this.captureNetworkBodies,
|
|
297
|
+
truncated: this.truncated,
|
|
298
|
+
console: this.consoleEvents,
|
|
299
|
+
network: this.networkEvents,
|
|
300
|
+
timeline: this.timelineEvents.toSorted((left, right) => left.offsetMs - right.offsetMs)
|
|
301
|
+
};
|
|
302
|
+
await writeBundle(bundle, diagnosticsPath);
|
|
303
|
+
if (this.browserProcess) {
|
|
304
|
+
await this.browser.close().catch(() => void 0);
|
|
305
|
+
this.browserProcess.kill("SIGTERM");
|
|
306
|
+
if (this.profileDirectory) await rm(this.profileDirectory, { recursive: true, force: true }).catch(() => void 0);
|
|
307
|
+
}
|
|
308
|
+
const consoleErrors = this.consoleEvents.filter((event) => event.severity === "error").length;
|
|
309
|
+
const failedRequests = this.networkEvents.filter((event) => event.failureReason).length;
|
|
310
|
+
const httpErrors = this.networkEvents.filter((event) => (event.status ?? 0) >= 400).length;
|
|
311
|
+
return { recordingId: this.recordingId, videoPath, diagnosticsPath, durationSeconds, stoppedReason: reason, diagnostics: { consoleErrors, failedRequests, httpErrors, truncated: this.truncated, bodyCaptureEnabled: this.captureNetworkBodies } };
|
|
312
|
+
}
|
|
313
|
+
captureNetworkBodies = false;
|
|
314
|
+
outputDirectory = "";
|
|
315
|
+
async attachDiagnostics(captureNetworkBodies) {
|
|
316
|
+
this.captureNetworkBodies = captureNetworkBodies;
|
|
317
|
+
const offset = () => Date.now() - this.startedAt.getTime();
|
|
318
|
+
const pushTimeline = (event) => {
|
|
319
|
+
if (this.timelineEvents.length >= 1e4) {
|
|
320
|
+
this.truncated = true;
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
this.timelineEvents.push({ id: `time_${this.timelineEvents.length + 1}`, ...event });
|
|
324
|
+
};
|
|
325
|
+
await this.page.exposeFunction("__rillClick", (payload) => pushTimeline({ offsetMs: offset(), type: "click", label: `${payload.label || "Element"} at ${Math.round(payload.x)},${Math.round(payload.y)}` })).catch(() => void 0);
|
|
326
|
+
await this.page.addInitScript(() => {
|
|
327
|
+
document.addEventListener("click", (event) => {
|
|
328
|
+
const target = event.target;
|
|
329
|
+
const label = target?.innerText?.trim().slice(0, 80) || target?.getAttribute("aria-label") || target?.tagName || "Element";
|
|
330
|
+
void window.__rillClick?.({ x: event.clientX, y: event.clientY, label });
|
|
331
|
+
}, { capture: true });
|
|
332
|
+
});
|
|
333
|
+
this.page.on("framenavigated", (frame) => {
|
|
334
|
+
if (frame === this.page.mainFrame()) pushTimeline({ offsetMs: offset(), type: "navigation", label: sanitizeUrl(frame.url()) });
|
|
335
|
+
});
|
|
336
|
+
this.page.on("console", async (message) => {
|
|
337
|
+
if (this.consoleEvents.length >= 1e4) {
|
|
338
|
+
this.truncated = true;
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const args = await Promise.all(message.args().slice(0, 20).map(async (arg) => {
|
|
342
|
+
try {
|
|
343
|
+
return JSON.stringify(await arg.jsonValue());
|
|
344
|
+
} catch {
|
|
345
|
+
return arg.toString();
|
|
346
|
+
}
|
|
347
|
+
}));
|
|
348
|
+
const severity = severityMap[message.type()] ?? "info";
|
|
349
|
+
const location = message.location();
|
|
350
|
+
const event = { id: `con_${this.consoleEvents.length + 1}`, offsetMs: offset(), severity, text: redactText(args.join(" ") || message.text()), sourceUrl: location.url ? sanitizeUrl(location.url) : void 0, lineNumber: location.lineNumber, stack: [] };
|
|
351
|
+
this.consoleEvents.push(event);
|
|
352
|
+
if (severity === "error" || severity === "warning") pushTimeline({ offsetMs: event.offsetMs, type: "console", label: event.text.slice(0, 160), severity });
|
|
353
|
+
});
|
|
354
|
+
this.page.on("pageerror", (error) => {
|
|
355
|
+
const event = { id: `con_${this.consoleEvents.length + 1}`, offsetMs: offset(), severity: "error", text: redactText(error.message), stack: redactText(error.stack ?? "").split("\n").slice(0, 20) };
|
|
356
|
+
this.consoleEvents.push(event);
|
|
357
|
+
pushTimeline({ offsetMs: event.offsetMs, type: "exception", label: event.text.slice(0, 160), severity: "error" });
|
|
358
|
+
});
|
|
359
|
+
this.page.on("request", (request2) => {
|
|
360
|
+
if (this.networkEvents.length >= 5e3) {
|
|
361
|
+
this.truncated = true;
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const started = offset();
|
|
365
|
+
this.requestStart.set(request2, started);
|
|
366
|
+
const event = { id: `net_${this.networkEvents.length + 1}`, offsetMs: started, method: request2.method(), url: sanitizeUrl(request2.url()), resourceType: request2.resourceType() };
|
|
367
|
+
if (captureNetworkBodies && request2.postData()) {
|
|
368
|
+
const redacted = redactBody(request2.postData(), request2.headers()["content-type"]);
|
|
369
|
+
event.requestBody = redacted.body;
|
|
370
|
+
event.bodyOmittedReason = redacted.omittedReason;
|
|
371
|
+
if (redacted.body) this.bodyBytes += Buffer.byteLength(redacted.body);
|
|
372
|
+
}
|
|
373
|
+
this.networkEvents.push(event);
|
|
374
|
+
this.requestEvents.set(request2, event);
|
|
375
|
+
});
|
|
376
|
+
this.page.on("response", (response) => this.updateResponse(response));
|
|
377
|
+
this.page.on("requestfailed", (request2) => {
|
|
378
|
+
const event = this.requestEvents.get(request2);
|
|
379
|
+
if (!event) return;
|
|
380
|
+
event.failureReason = request2.failure()?.errorText ?? "request_failed";
|
|
381
|
+
event.durationMs = Math.max(0, offset() - (this.requestStart.get(request2) ?? offset()));
|
|
382
|
+
pushTimeline({ offsetMs: event.offsetMs, type: "network", label: `${event.method} ${event.url} \u2192 ${event.failureReason}`, severity: "error" });
|
|
383
|
+
});
|
|
384
|
+
this.page.on("requestfinished", async (request2) => {
|
|
385
|
+
const response = await request2.response();
|
|
386
|
+
if (response) await this.captureResponseBody(response);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
updateResponse(response) {
|
|
390
|
+
const event = this.requestEvents.get(response.request());
|
|
391
|
+
if (!event) return;
|
|
392
|
+
event.status = response.status();
|
|
393
|
+
event.mimeType = response.headers()["content-type"];
|
|
394
|
+
event.durationMs = Math.max(0, Date.now() - this.startedAt.getTime() - event.offsetMs);
|
|
395
|
+
event.transferSize = Number(response.headers()["content-length"] ?? 0) || void 0;
|
|
396
|
+
if (response.status() >= 400) this.timelineEvents.push({ id: `time_${this.timelineEvents.length + 1}`, offsetMs: event.offsetMs, type: "network", label: `${event.method} ${event.url} \u2192 ${response.status()}`, severity: "error" });
|
|
397
|
+
}
|
|
398
|
+
async captureResponseBody(response) {
|
|
399
|
+
if (!this.captureNetworkBodies || this.bodyBytes >= 1e7) {
|
|
400
|
+
if (this.bodyBytes >= 1e7) this.truncated = true;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const event = this.requestEvents.get(response.request());
|
|
404
|
+
if (!event) return;
|
|
405
|
+
try {
|
|
406
|
+
const body = await response.text();
|
|
407
|
+
const redacted = redactBody(body, response.headers()["content-type"]);
|
|
408
|
+
event.responseBody = redacted.body;
|
|
409
|
+
event.bodyOmittedReason ??= redacted.omittedReason;
|
|
410
|
+
if (redacted.body) this.bodyBytes += Buffer.byteLength(redacted.body);
|
|
411
|
+
} catch {
|
|
412
|
+
event.bodyOmittedReason ??= "body_unavailable";
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
async function launchManaged(options) {
|
|
417
|
+
const port = await getAvailablePort();
|
|
418
|
+
const profileDirectory = await mkdtemp(join3(tmpdir2(), "rill-chrome-"));
|
|
419
|
+
const executable = findChromeExecutable();
|
|
420
|
+
const args = [
|
|
421
|
+
`--remote-debugging-port=${port}`,
|
|
422
|
+
"--remote-debugging-address=127.0.0.1",
|
|
423
|
+
`--user-data-dir=${profileDirectory}`,
|
|
424
|
+
`--window-size=${options.width},${options.height}`,
|
|
425
|
+
"--no-first-run",
|
|
426
|
+
"--no-default-browser-check",
|
|
427
|
+
"--disable-background-networking",
|
|
428
|
+
"--disable-component-update",
|
|
429
|
+
"--disable-sync",
|
|
430
|
+
"--metrics-recording-only",
|
|
431
|
+
...options.headed ? [] : ["--headless=new", "--hide-scrollbars"],
|
|
432
|
+
"about:blank"
|
|
433
|
+
];
|
|
434
|
+
const browserProcess = spawn3(executable, args, { stdio: ["ignore", "ignore", "pipe"], detached: false });
|
|
435
|
+
const cdpUrl = `http://127.0.0.1:${port}`;
|
|
436
|
+
await waitForCdp(cdpUrl, browserProcess);
|
|
437
|
+
const browser = await chromium.connectOverCDP(cdpUrl, { artifactsDir: options.outputDirectory, isLocal: true });
|
|
438
|
+
const context = browser.contexts()[0];
|
|
439
|
+
const page = context.pages()[0] ?? await context.newPage();
|
|
440
|
+
await page.setViewportSize({ width: options.width, height: options.height });
|
|
441
|
+
return { cdpUrl, browser, context, page, browserProcess, profileDirectory };
|
|
442
|
+
}
|
|
443
|
+
async function connectAttached(cdpUrl, outputDirectory) {
|
|
444
|
+
const browser = await chromium.connectOverCDP(cdpUrl, { artifactsDir: outputDirectory, isLocal: true, noDefaults: true });
|
|
445
|
+
const context = browser.contexts()[0];
|
|
446
|
+
const page = context.pages()[0];
|
|
447
|
+
if (!page) throw new Error("The attached browser has no page target to record.");
|
|
448
|
+
return { cdpUrl, browser, context, page };
|
|
449
|
+
}
|
|
450
|
+
async function getAvailablePort() {
|
|
451
|
+
const server = createServer();
|
|
452
|
+
await new Promise((resolve, reject) => server.listen(0, "127.0.0.1", resolve).once("error", reject));
|
|
453
|
+
const address = server.address();
|
|
454
|
+
if (!address || typeof address === "string") throw new Error("Could not allocate a browser debugging port.");
|
|
455
|
+
const port = address.port;
|
|
456
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
457
|
+
return port;
|
|
458
|
+
}
|
|
459
|
+
async function waitForCdp(cdpUrl, browserProcess) {
|
|
460
|
+
const deadline = Date.now() + 15e3;
|
|
461
|
+
while (Date.now() < deadline) {
|
|
462
|
+
if (browserProcess.exitCode !== null) throw new Error(`Chrome exited before its CDP endpoint became available (${browserProcess.exitCode}).`);
|
|
463
|
+
try {
|
|
464
|
+
const response = await fetch(`${cdpUrl}/json/version`);
|
|
465
|
+
if (response.ok) return;
|
|
466
|
+
} catch {
|
|
467
|
+
}
|
|
468
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
469
|
+
}
|
|
470
|
+
browserProcess.kill("SIGTERM");
|
|
471
|
+
throw new Error("Timed out waiting for Chrome DevTools Protocol.");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ../recorder/src/daemon.ts
|
|
475
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
476
|
+
var lastActivity = Date.now();
|
|
477
|
+
async function runDaemon() {
|
|
478
|
+
const socketPath = daemonSocketPath();
|
|
479
|
+
await mkdir3(dirname(socketPath), { recursive: true, mode: 448 });
|
|
480
|
+
await mkdir3(recordingTempRoot(), { recursive: true, mode: 448 });
|
|
481
|
+
await restoreRecoverableSessions();
|
|
482
|
+
await unlink(socketPath).catch((error) => {
|
|
483
|
+
if (error.code !== "ENOENT") throw error;
|
|
484
|
+
});
|
|
485
|
+
const server = createServer2((request2, response) => void route(request2, response));
|
|
486
|
+
server.listen(socketPath, () => process.platform !== "win32" && import("fs/promises").then(({ chmod }) => chmod(socketPath, 384)));
|
|
487
|
+
const idleTimer = setInterval(() => {
|
|
488
|
+
const hasActiveSession = [...sessions.values()].some((entry) => entry.state === "recording" || entry.state === "finalizing");
|
|
489
|
+
if (!hasActiveSession && Date.now() - lastActivity > 5 * 6e4) server.close(() => process.exit(0));
|
|
490
|
+
}, 3e4);
|
|
491
|
+
idleTimer.unref();
|
|
492
|
+
const close = () => {
|
|
493
|
+
clearInterval(idleTimer);
|
|
494
|
+
server.close();
|
|
495
|
+
void unlink(socketPath).catch(() => void 0);
|
|
496
|
+
};
|
|
497
|
+
process.once("SIGTERM", close);
|
|
498
|
+
process.once("SIGINT", close);
|
|
499
|
+
}
|
|
500
|
+
async function route(request2, response) {
|
|
501
|
+
lastActivity = Date.now();
|
|
502
|
+
const url = new URL(request2.url ?? "/", "http://daemon.local");
|
|
503
|
+
if (request2.method === "GET" && url.pathname === "/health") return json(response, 200, { ok: true, pid: process.pid });
|
|
504
|
+
if (request2.method === "POST" && url.pathname === "/sessions") {
|
|
505
|
+
if ([...sessions.values()].filter((entry) => entry.state === "recording").length >= 2) return json(response, 429, { error: { code: "quota_exceeded", message: "This recorder already has two active sessions." } });
|
|
506
|
+
try {
|
|
507
|
+
const input = await readJson(request2);
|
|
508
|
+
if (!input.recordingId) return json(response, 400, { error: { code: "validation_failed", message: "recordingId is required." } });
|
|
509
|
+
const outputDirectory = join4(recordingTempRoot(), input.recordingId);
|
|
510
|
+
const session = await RecordingSession.start({ ...input, outputDirectory });
|
|
511
|
+
const status = session.status();
|
|
512
|
+
const entry = { session, base: { recordingId: status.recordingId, cdpUrl: status.cdpUrl, startedAt: status.startedAt }, state: "recording" };
|
|
513
|
+
sessions.set(input.recordingId, entry);
|
|
514
|
+
const maxTimer = setTimeout(() => void finalizeEntry(entry, "max_duration"), (input.maxDurationSeconds ?? 600) * 1e3 + 250);
|
|
515
|
+
maxTimer.unref();
|
|
516
|
+
await persistState();
|
|
517
|
+
return json(response, 201, session.status());
|
|
518
|
+
} catch (error) {
|
|
519
|
+
return json(response, 500, { error: { code: "browser_unavailable", message: error instanceof Error ? error.message : "Could not start recording." } });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const match = url.pathname.match(/^\/sessions\/([^/]+)(?:\/(stop))?$/);
|
|
523
|
+
if (match) {
|
|
524
|
+
const entry = sessions.get(decodeURIComponent(match[1]));
|
|
525
|
+
if (!entry) return json(response, 404, { error: { code: "recording_not_found", message: "The local recording session was not found." } });
|
|
526
|
+
if (request2.method === "GET" && !match[2]) return json(response, 200, serializeEntry(entry));
|
|
527
|
+
if (request2.method === "POST" && match[2] === "stop") {
|
|
528
|
+
if (entry.state === "ready") return json(response, 200, serializeEntry(entry));
|
|
529
|
+
await finalizeEntry(entry, "agent_request");
|
|
530
|
+
return json(response, entry.result ? 200 : 500, serializeEntry(entry));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return json(response, 404, { error: { code: "recording_not_found", message: "Unknown recorder route." } });
|
|
534
|
+
}
|
|
535
|
+
async function finalizeEntry(entry, reason) {
|
|
536
|
+
if (entry.state === "ready" || entry.state === "failed") return;
|
|
537
|
+
entry.state = "finalizing";
|
|
538
|
+
try {
|
|
539
|
+
if (!entry.session) throw new Error("This recovered draft was not fully finalized. Start a new recording or purge the draft.");
|
|
540
|
+
entry.result = await entry.session.stop(reason);
|
|
541
|
+
entry.state = "ready";
|
|
542
|
+
} catch (error) {
|
|
543
|
+
entry.state = "failed";
|
|
544
|
+
entry.error = error instanceof Error ? error.message : "The recording could not be finalized.";
|
|
545
|
+
}
|
|
546
|
+
await persistState();
|
|
547
|
+
}
|
|
548
|
+
function serializeEntry(entry) {
|
|
549
|
+
return { ...entry.base, status: entry.state, result: entry.result, error: entry.error };
|
|
550
|
+
}
|
|
551
|
+
async function restoreRecoverableSessions() {
|
|
552
|
+
try {
|
|
553
|
+
const value = JSON.parse(await readFile(daemonStatePath(), "utf8"));
|
|
554
|
+
for (const [id, entry] of Object.entries(value)) {
|
|
555
|
+
if (entry.status !== "ready" && entry.status !== "failed") continue;
|
|
556
|
+
sessions.set(id, { base: { recordingId: entry.recordingId, cdpUrl: entry.cdpUrl, startedAt: entry.startedAt }, state: entry.status, result: entry.result, error: entry.error });
|
|
557
|
+
}
|
|
558
|
+
} catch {
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async function persistState() {
|
|
562
|
+
const state = Object.fromEntries([...sessions.entries()].map(([id, entry]) => [id, serializeEntry(entry)]));
|
|
563
|
+
await writeFile2(daemonStatePath(), `${JSON.stringify(state, null, 2)}
|
|
564
|
+
`, { mode: 384 });
|
|
565
|
+
}
|
|
566
|
+
async function readJson(request2) {
|
|
567
|
+
const chunks = [];
|
|
568
|
+
let size = 0;
|
|
569
|
+
for await (const chunk of request2) {
|
|
570
|
+
const buffer = Buffer.from(chunk);
|
|
571
|
+
size += buffer.length;
|
|
572
|
+
if (size > 1e6) throw new Error("Request body exceeds 1 MB.");
|
|
573
|
+
chunks.push(buffer);
|
|
574
|
+
}
|
|
575
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
576
|
+
}
|
|
577
|
+
function json(response, status, value) {
|
|
578
|
+
response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
|
|
579
|
+
response.end(JSON.stringify(value));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// src/control-plane.ts
|
|
583
|
+
import { createReadStream } from "fs";
|
|
584
|
+
import { readFile as readFile2, stat } from "fs/promises";
|
|
585
|
+
import { Upload } from "tus-js-client";
|
|
586
|
+
var ControlPlaneClient = class {
|
|
587
|
+
constructor(apiUrl, token) {
|
|
588
|
+
this.apiUrl = apiUrl;
|
|
589
|
+
this.token = token;
|
|
590
|
+
}
|
|
591
|
+
health() {
|
|
592
|
+
return this.request("/api/health", {}, false);
|
|
593
|
+
}
|
|
594
|
+
agentSession() {
|
|
595
|
+
return this.request("/api/agent/session");
|
|
596
|
+
}
|
|
597
|
+
createRecording(input) {
|
|
598
|
+
return this.request("/api/recordings", { method: "POST", body: JSON.stringify(input) });
|
|
599
|
+
}
|
|
600
|
+
provisionVideo(recordingId, input) {
|
|
601
|
+
return this.request(`/api/recordings/${recordingId}/video-upload`, { method: "POST", body: JSON.stringify(input) });
|
|
602
|
+
}
|
|
603
|
+
recordingStatus(recordingId) {
|
|
604
|
+
return this.request(`/api/recordings/${recordingId}`);
|
|
605
|
+
}
|
|
606
|
+
async waitUntilPlayable(recordingId, timeoutMs = 10 * 6e4) {
|
|
607
|
+
const deadline = Date.now() + timeoutMs;
|
|
608
|
+
let intervalMs = 1e3;
|
|
609
|
+
while (Date.now() < deadline) {
|
|
610
|
+
const recording = await this.recordingStatus(recordingId);
|
|
611
|
+
if (recording.status === "ready" && recording.shareUrl) {
|
|
612
|
+
return { ...recording, shareUrl: new URL(recording.shareUrl, this.apiUrl).toString() };
|
|
613
|
+
}
|
|
614
|
+
if (recording.status === "failed") throw new Error(`processing_failed: ${recording.failure_message ?? "Cloudflare Stream could not process the recording."}`);
|
|
615
|
+
process.stderr.write(`\rprocessing ${recording.status.padEnd(12)}`);
|
|
616
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
617
|
+
intervalMs = Math.min(5e3, Math.round(intervalMs * 1.4));
|
|
618
|
+
}
|
|
619
|
+
throw new Error("processing_failed: Timed out waiting for the recording to become playable. Run `rill record status <recording-id>` to continue checking.");
|
|
620
|
+
}
|
|
621
|
+
async uploadVideo(uploadUrl, filePath) {
|
|
622
|
+
const file = await stat(filePath);
|
|
623
|
+
if (uploadUrl.startsWith(this.apiUrl)) {
|
|
624
|
+
const response = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Length": String(file.size) }, body: createReadStream(filePath), duplex: "half" });
|
|
625
|
+
if (!response.ok) throw new Error(`Video upload failed (${response.status}).`);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
await new Promise((resolve, reject) => {
|
|
629
|
+
const upload = new Upload(createReadStream(filePath), {
|
|
630
|
+
uploadUrl,
|
|
631
|
+
uploadSize: file.size,
|
|
632
|
+
retryDelays: [0, 1e3, 3e3, 5e3, 1e4],
|
|
633
|
+
removeFingerprintOnSuccess: true,
|
|
634
|
+
onError: reject,
|
|
635
|
+
onSuccess: () => resolve(),
|
|
636
|
+
onProgress: (sent, total) => process.stderr.write(`\ruploading ${Math.round(sent / total * 100)}%`)
|
|
637
|
+
});
|
|
638
|
+
upload.start();
|
|
639
|
+
});
|
|
640
|
+
process.stderr.write("\n");
|
|
641
|
+
}
|
|
642
|
+
async uploadDiagnostics(recordingId, filePath, summary) {
|
|
643
|
+
const body = await readFile2(filePath);
|
|
644
|
+
return this.request(`/api/recordings/${recordingId}/diagnostics`, { method: "PUT", headers: { "Content-Encoding": "gzip", "Content-Type": "application/x-ndjson", "Content-Length": String(body.length), "X-Rill-Summary": JSON.stringify(summary) }, body });
|
|
645
|
+
}
|
|
646
|
+
createDeviceAuthorization(input) {
|
|
647
|
+
return this.request("/api/device/authorizations", { method: "POST", body: JSON.stringify(input) }, false);
|
|
648
|
+
}
|
|
649
|
+
pollDeviceAuthorization(deviceCode) {
|
|
650
|
+
return this.request("/api/device/token", { method: "POST", body: JSON.stringify({ deviceCode }) }, false);
|
|
651
|
+
}
|
|
652
|
+
inspect(shareUrl) {
|
|
653
|
+
const url = new URL(shareUrl);
|
|
654
|
+
const slug = url.pathname.split("/").filter(Boolean).at(-1);
|
|
655
|
+
if (!slug) throw new Error("The share URL does not contain a slug.");
|
|
656
|
+
return fetch(`${url.origin}/api/public/shares/${encodeURIComponent(slug)}/context`, { headers: { Accept: "application/json" } }).then(async (response) => {
|
|
657
|
+
if (!response.ok) throw new Error(`Could not inspect the share link (${response.status}).`);
|
|
658
|
+
return response.json();
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
async request(path, init = {}, authenticated = true) {
|
|
662
|
+
const headers = new Headers(init.headers);
|
|
663
|
+
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
|
664
|
+
if (authenticated) {
|
|
665
|
+
if (!this.token) throw new Error("authentication_required: Run `rill login` or set RILL_TOKEN.");
|
|
666
|
+
headers.set("Authorization", `Bearer ${this.token}`);
|
|
667
|
+
}
|
|
668
|
+
const response = await fetch(`${this.apiUrl}${path}`, { ...init, headers });
|
|
669
|
+
const value = await response.json().catch(() => null);
|
|
670
|
+
if (!response.ok) throw new Error([value?.error?.code, value?.error?.message, value?.error?.recovery].filter(Boolean).join(": ") || `Request failed (${response.status}).`);
|
|
671
|
+
return value;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
// src/keychain.ts
|
|
676
|
+
import { spawn as spawn4 } from "child_process";
|
|
677
|
+
var service = "rill";
|
|
678
|
+
async function saveCredential(apiUrl, token) {
|
|
679
|
+
if (process.platform === "darwin") {
|
|
680
|
+
await run("security", ["add-generic-password", "-U", "-a", apiUrl, "-s", service, "-w", token]);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (process.platform === "linux") {
|
|
684
|
+
await run("secret-tool", ["store", "--label=Rill agent credential", "service", service, "api-url", apiUrl], token);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
throw new Error("OS keychain storage is not supported on this platform yet. Use RILL_TOKEN.");
|
|
688
|
+
}
|
|
689
|
+
async function loadCredential(apiUrl) {
|
|
690
|
+
if (process.env.RILL_TOKEN) return process.env.RILL_TOKEN;
|
|
691
|
+
try {
|
|
692
|
+
if (process.platform === "darwin") return (await run("security", ["find-generic-password", "-a", apiUrl, "-s", service, "-w"])).trim();
|
|
693
|
+
if (process.platform === "linux") return (await run("secret-tool", ["lookup", "service", service, "api-url", apiUrl])).trim();
|
|
694
|
+
} catch {
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
function run(command, args, input) {
|
|
700
|
+
return new Promise((resolve, reject) => {
|
|
701
|
+
const child = spawn4(command, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
702
|
+
const stdout = [];
|
|
703
|
+
const stderr = [];
|
|
704
|
+
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
|
|
705
|
+
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
|
|
706
|
+
child.once("error", reject);
|
|
707
|
+
child.once("exit", (code) => code === 0 ? resolve(Buffer.concat(stdout).toString("utf8")) : reject(new Error(Buffer.concat(stderr).toString("utf8").trim() || `${command} exited with ${code}.`)));
|
|
708
|
+
if (input) child.stdin.end(input);
|
|
709
|
+
else child.stdin.end();
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// src/output.ts
|
|
714
|
+
function emit(value, pretty = false) {
|
|
715
|
+
process.stdout.write(`${JSON.stringify(value, null, pretty ? 2 : void 0)}
|
|
716
|
+
`);
|
|
717
|
+
}
|
|
718
|
+
function fail(error) {
|
|
719
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
720
|
+
const [candidate] = message.split(":", 1);
|
|
721
|
+
const knownCodes = /* @__PURE__ */ new Set(["authentication_required", "scope_denied", "quota_exceeded", "browser_unavailable", "recording_not_found", "upload_interrupted", "processing_failed", "share_revoked", "validation_failed"]);
|
|
722
|
+
emit({ error: { code: knownCodes.has(candidate) ? candidate : "internal_error", message } });
|
|
723
|
+
process.exitCode = 1;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// src/doctor.ts
|
|
727
|
+
import { spawn as spawn5 } from "child_process";
|
|
728
|
+
|
|
729
|
+
// src/version.ts
|
|
730
|
+
import { createRequire } from "module";
|
|
731
|
+
var packageJson = createRequire(import.meta.url)("../package.json");
|
|
732
|
+
var CLI_VERSION = packageJson.version;
|
|
733
|
+
|
|
734
|
+
// src/doctor.ts
|
|
735
|
+
var defaultDependencies = {
|
|
736
|
+
nodeVersion: process.version,
|
|
737
|
+
cliVersion: CLI_VERSION,
|
|
738
|
+
chromePath: findChromeExecutable(),
|
|
739
|
+
ffmpegPath: process.env.RILL_FFMPEG ?? "ffmpeg",
|
|
740
|
+
commandVersion,
|
|
741
|
+
loadCredential,
|
|
742
|
+
health: (apiUrl) => new ControlPlaneClient(apiUrl, null).health(),
|
|
743
|
+
agentSession: (apiUrl, token) => new ControlPlaneClient(apiUrl, token).agentSession()
|
|
744
|
+
};
|
|
745
|
+
async function runDoctor(apiUrl, overrides = {}) {
|
|
746
|
+
const dependencies = { ...defaultDependencies, ...overrides };
|
|
747
|
+
const checks = [];
|
|
748
|
+
if (compareVersions(normalizeVersion(dependencies.nodeVersion), "22.0.0") >= 0) {
|
|
749
|
+
checks.push({ name: "node", status: "pass", message: `Node.js ${dependencies.nodeVersion} is supported.`, version: dependencies.nodeVersion });
|
|
750
|
+
} else {
|
|
751
|
+
checks.push({ name: "node", status: "fail", message: `Node.js ${dependencies.nodeVersion} is unsupported.`, version: dependencies.nodeVersion, recovery: "Install Node.js 22 or newer, then reinstall the Rill CLI." });
|
|
752
|
+
}
|
|
753
|
+
let health = null;
|
|
754
|
+
try {
|
|
755
|
+
health = await dependencies.health(apiUrl);
|
|
756
|
+
if (!health.ok || health.service !== "rill") throw new Error("The endpoint did not identify itself as Rill.");
|
|
757
|
+
checks.push({ name: "api", status: "pass", message: `Rill API is reachable at ${apiUrl}.` });
|
|
758
|
+
} catch (error) {
|
|
759
|
+
checks.push({ name: "api", status: "fail", message: `Rill API is unreachable: ${errorMessage(error)}`, recovery: `Check the network and API URL, then rerun \`rill --api-url ${apiUrl} doctor\`.` });
|
|
760
|
+
}
|
|
761
|
+
if (!health?.minimumCliVersion || !health.recommendedCliVersion) {
|
|
762
|
+
checks.push({ name: "cli_version", status: "fail", message: `CLI ${dependencies.cliVersion} could not be checked against the API compatibility policy.`, version: dependencies.cliVersion, recovery: "Deploy a Rill API that advertises minimumCliVersion and recommendedCliVersion." });
|
|
763
|
+
} else if (compareVersions(dependencies.cliVersion, health.minimumCliVersion) < 0) {
|
|
764
|
+
checks.push({ name: "cli_version", status: "fail", message: `CLI ${dependencies.cliVersion} is older than the minimum supported version ${health.minimumCliVersion}.`, version: dependencies.cliVersion, recovery: `Run \`npm install --global @runalabs/rill-cli@${health.recommendedCliVersion}\`.` });
|
|
765
|
+
} else if (compareVersions(dependencies.cliVersion, health.recommendedCliVersion) !== 0) {
|
|
766
|
+
checks.push({ name: "cli_version", status: "warn", message: `CLI ${dependencies.cliVersion} is supported; ${health.recommendedCliVersion} is recommended.`, version: dependencies.cliVersion, recovery: `Run \`npm install --global @runalabs/rill-cli@${health.recommendedCliVersion}\`.` });
|
|
767
|
+
} else {
|
|
768
|
+
checks.push({ name: "cli_version", status: "pass", message: `CLI ${dependencies.cliVersion} is the recommended version.`, version: dependencies.cliVersion });
|
|
769
|
+
}
|
|
770
|
+
if (!health) {
|
|
771
|
+
checks.push({ name: "authentication", status: "skip", message: "Authentication was not checked because the API is unreachable.", recovery: `Restore API reachability, then run \`rill --api-url ${apiUrl} doctor\` again.` });
|
|
772
|
+
} else {
|
|
773
|
+
const token = await dependencies.loadCredential(apiUrl);
|
|
774
|
+
if (!token) {
|
|
775
|
+
checks.push({ name: "authentication", status: "fail", message: "No Rill agent credential is available.", recovery: `Run \`rill --api-url ${apiUrl} login\` or set RILL_TOKEN.` });
|
|
776
|
+
} else {
|
|
777
|
+
try {
|
|
778
|
+
const session = await dependencies.agentSession(apiUrl, token);
|
|
779
|
+
checks.push({ name: "authentication", status: "pass", message: `Credential is valid with ${session.scopes.length} scope${session.scopes.length === 1 ? "" : "s"}.` });
|
|
780
|
+
} catch (error) {
|
|
781
|
+
checks.push({ name: "authentication", status: "fail", message: `Credential validation failed: ${errorMessage(error)}`, recovery: `Run \`rill --api-url ${apiUrl} login\` to authorize a fresh credential.` });
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
checks.push(await executableCheck("chrome", dependencies.chromePath, ["--version"], dependencies.commandVersion, "Install Google Chrome or Chromium, or set RILL_CHROME_PATH to its executable."));
|
|
786
|
+
checks.push(await executableCheck("ffmpeg", dependencies.ffmpegPath, ["-version"], dependencies.commandVersion, process.platform === "darwin" ? "Install ffmpeg with `brew install ffmpeg`, or set RILL_FFMPEG." : "Install ffmpeg using the system package manager, or set RILL_FFMPEG."));
|
|
787
|
+
return { schemaVersion: 1, status: checks.some((check) => check.status === "fail") ? "error" : "ok", apiUrl, checks };
|
|
788
|
+
}
|
|
789
|
+
async function executableCheck(name, path, args, run2, recovery) {
|
|
790
|
+
try {
|
|
791
|
+
const version = firstLine(await run2(path, args));
|
|
792
|
+
return { name, status: "pass", message: `${name === "chrome" ? "Chrome/Chromium" : "ffmpeg"} is available.`, version, path };
|
|
793
|
+
} catch (error) {
|
|
794
|
+
return { name, status: "fail", message: `${name === "chrome" ? "Chrome/Chromium" : "ffmpeg"} is unavailable: ${errorMessage(error)}`, path, recovery };
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
function commandVersion(command, args) {
|
|
798
|
+
return new Promise((resolve, reject) => {
|
|
799
|
+
const child = spawn5(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
800
|
+
const stdout = [];
|
|
801
|
+
const stderr = [];
|
|
802
|
+
const timeout = setTimeout(() => {
|
|
803
|
+
child.kill("SIGTERM");
|
|
804
|
+
reject(new Error("version check timed out"));
|
|
805
|
+
}, 5e3);
|
|
806
|
+
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
|
|
807
|
+
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
|
|
808
|
+
child.once("error", (error) => {
|
|
809
|
+
clearTimeout(timeout);
|
|
810
|
+
reject(error);
|
|
811
|
+
});
|
|
812
|
+
child.once("exit", (code) => {
|
|
813
|
+
clearTimeout(timeout);
|
|
814
|
+
const output = `${Buffer.concat(stdout).toString("utf8")}
|
|
815
|
+
${Buffer.concat(stderr).toString("utf8")}`.trim();
|
|
816
|
+
if (code === 0) resolve(output);
|
|
817
|
+
else reject(new Error(output || `${command} exited with ${code}`));
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
function normalizeVersion(version) {
|
|
822
|
+
return version.replace(/^v/, "").split("-")[0];
|
|
823
|
+
}
|
|
824
|
+
function compareVersions(left, right) {
|
|
825
|
+
const leftParts = normalizeVersion(left).split(".").map(Number);
|
|
826
|
+
const rightParts = normalizeVersion(right).split(".").map(Number);
|
|
827
|
+
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
|
|
828
|
+
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
|
|
829
|
+
if (difference !== 0) return difference;
|
|
830
|
+
}
|
|
831
|
+
return 0;
|
|
832
|
+
}
|
|
833
|
+
function firstLine(value) {
|
|
834
|
+
return value.split(/\r?\n/, 1)[0].trim();
|
|
835
|
+
}
|
|
836
|
+
function errorMessage(error) {
|
|
837
|
+
return error instanceof Error ? error.message : String(error);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/cli.ts
|
|
841
|
+
var program = new Command();
|
|
842
|
+
program.name("rill").description("Record, inspect, and share agent browser runs.").version(CLI_VERSION).option("--api-url <url>", "Rill control plane", process.env.RILL_API_URL ?? "https://rill-staging.tight-shadow-8e12.workers.dev").option("--pretty", "Pretty-print JSON output", false);
|
|
843
|
+
program.command("daemon", { hidden: true }).action(() => runDaemon());
|
|
844
|
+
program.command("doctor").description("Validate Rill prerequisites before recording.").action(async () => {
|
|
845
|
+
const options = program.opts();
|
|
846
|
+
const result = await runDoctor(options.apiUrl);
|
|
847
|
+
emit(result, options.pretty);
|
|
848
|
+
if (result.status === "error") process.exitCode = 1;
|
|
849
|
+
});
|
|
850
|
+
program.command("login").description("Authorize this agent using a human-approved device code.").action(async () => {
|
|
851
|
+
const options = program.opts();
|
|
852
|
+
const client = new ControlPlaneClient(options.apiUrl, null);
|
|
853
|
+
const authorization = await client.createDeviceAuthorization({ deviceName: `${hostname()} \xB7 ${process.platform}` });
|
|
854
|
+
process.stderr.write(`Open ${authorization.verificationUri} and enter ${authorization.userCode}
|
|
855
|
+
`);
|
|
856
|
+
const deadline = Date.now() + authorization.expiresIn * 1e3;
|
|
857
|
+
while (Date.now() < deadline) {
|
|
858
|
+
await new Promise((resolve) => setTimeout(resolve, authorization.interval * 1e3));
|
|
859
|
+
const result = await client.pollDeviceAuthorization(authorization.deviceCode);
|
|
860
|
+
if (result.status === "pending") continue;
|
|
861
|
+
if (result.status !== "approved" || !result.token) throw new Error(`Device authorization ${result.status}.`);
|
|
862
|
+
await saveCredential(options.apiUrl, result.token);
|
|
863
|
+
emit({ schemaVersion: 1, status: "authenticated", expiresAt: result.expiresAt, apiUrl: options.apiUrl }, options.pretty);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
throw new Error("Device authorization expired.");
|
|
867
|
+
});
|
|
868
|
+
var record = program.command("record").description("Manage a browser recording session.");
|
|
869
|
+
record.command("start").option("--url <url>").option("--title <title>").option("--description <description>", "").option("--headed").option("--cdp-url <url>").option("--width <pixels>", "", "1280").option("--height <pixels>", "", "720").option("--max-duration <seconds>", "", "600").action(async (commandOptions) => {
|
|
870
|
+
const options = program.opts();
|
|
871
|
+
const token = await loadCredential(options.apiUrl);
|
|
872
|
+
const client = new ControlPlaneClient(options.apiUrl, token);
|
|
873
|
+
const width = Number(commandOptions.width);
|
|
874
|
+
const height = Number(commandOptions.height);
|
|
875
|
+
const remote = await client.createRecording({ title: commandOptions.title ?? `Browser recording \xB7 ${(/* @__PURE__ */ new Date()).toISOString()}`, description: commandOptions.description, sourceUrl: commandOptions.url ?? null, width, height });
|
|
876
|
+
await ensureDaemon(process.argv[1]);
|
|
877
|
+
const local = await startRecording({ recordingId: remote.recordingId, url: commandOptions.url, cdpUrl: commandOptions.cdpUrl, headed: Boolean(commandOptions.headed), width, height, maxDurationSeconds: Number(commandOptions.maxDuration), captureNetworkBodies: remote.bodyCaptureEnabled });
|
|
878
|
+
emit({ schemaVersion: 1, recordingId: remote.recordingId, status: local.status, cdpUrl: local.cdpUrl, bodyCaptureEnabled: remote.bodyCaptureEnabled, maxDurationSeconds: Number(commandOptions.maxDuration) }, options.pretty);
|
|
879
|
+
});
|
|
880
|
+
record.command("stop").argument("<recording-id>").option("--no-wait").action(async (recordingId, commandOptions) => {
|
|
881
|
+
const options = program.opts();
|
|
882
|
+
await ensureDaemon(process.argv[1]);
|
|
883
|
+
const local = await stopRecording(recordingId);
|
|
884
|
+
if (!local.result) throw new Error(local.error ?? "The recorder did not produce an artifact.");
|
|
885
|
+
const token = await loadCredential(options.apiUrl);
|
|
886
|
+
const client = new ControlPlaneClient(options.apiUrl, token);
|
|
887
|
+
const video = await stat2(local.result.videoPath);
|
|
888
|
+
const provisioned = await client.provisionVideo(recordingId, { sizeBytes: video.size, filename: basename(local.result.videoPath) });
|
|
889
|
+
await Promise.all([client.uploadVideo(provisioned.uploadUrl, local.result.videoPath), client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics)]);
|
|
890
|
+
const remote = commandOptions.wait === false ? null : await client.waitUntilPlayable(recordingId);
|
|
891
|
+
if (remote) process.stderr.write("\n");
|
|
892
|
+
const result = { schemaVersion: 1, recordingId, status: remote ? "ready" : "processing", shareUrl: remote?.shareUrl ?? null, durationSeconds: local.result.durationSeconds, stoppedReason: local.result.stoppedReason, diagnostics: local.result.diagnostics };
|
|
893
|
+
emit(result, options.pretty);
|
|
894
|
+
if (remote) await rm2(dirname2(local.result.videoPath), { recursive: true, force: true });
|
|
895
|
+
});
|
|
896
|
+
record.command("status").argument("<recording-id>").action(async (recordingId) => {
|
|
897
|
+
const options = program.opts();
|
|
898
|
+
await ensureDaemon(process.argv[1]);
|
|
899
|
+
emit({ schemaVersion: 1, ...await recordingStatus(recordingId) }, options.pretty);
|
|
900
|
+
});
|
|
901
|
+
program.command("upload").argument("<video-file>").option("--title <title>").option("--no-wait").action(async (videoFile, commandOptions) => {
|
|
902
|
+
const options = program.opts();
|
|
903
|
+
const token = await loadCredential(options.apiUrl);
|
|
904
|
+
const client = new ControlPlaneClient(options.apiUrl, token);
|
|
905
|
+
const file = await stat2(videoFile);
|
|
906
|
+
const recording = await client.createRecording({ title: commandOptions.title ?? basename(videoFile), sourceUrl: null });
|
|
907
|
+
const upload = await client.provisionVideo(recording.recordingId, { sizeBytes: file.size, filename: basename(videoFile) });
|
|
908
|
+
await client.uploadVideo(upload.uploadUrl, videoFile);
|
|
909
|
+
const remote = commandOptions.wait === false ? null : await client.waitUntilPlayable(recording.recordingId);
|
|
910
|
+
if (remote) process.stderr.write("\n");
|
|
911
|
+
emit({ schemaVersion: 1, recordingId: recording.recordingId, status: remote ? "ready" : "processing", shareUrl: remote?.shareUrl ?? null, diagnosticsAvailable: false }, options.pretty);
|
|
912
|
+
});
|
|
913
|
+
program.command("inspect").argument("<share-url>").action(async (shareUrl) => {
|
|
914
|
+
const options = program.opts();
|
|
915
|
+
emit(await new ControlPlaneClient(options.apiUrl, null).inspect(shareUrl), options.pretty);
|
|
916
|
+
});
|
|
917
|
+
var drafts = program.command("drafts");
|
|
918
|
+
drafts.command("list").action(async () => {
|
|
919
|
+
const options = program.opts();
|
|
920
|
+
const root = process.env.RILL_TEMP ?? `${process.env.TMPDIR ?? "/tmp"}/rill-recordings`;
|
|
921
|
+
const names = await readdir(root).catch(() => []);
|
|
922
|
+
emit({ schemaVersion: 1, drafts: names }, options.pretty);
|
|
923
|
+
});
|
|
924
|
+
drafts.command("purge").action(async () => {
|
|
925
|
+
const options = program.opts();
|
|
926
|
+
const root = process.env.RILL_TEMP ?? `${process.env.TMPDIR ?? "/tmp"}/rill-recordings`;
|
|
927
|
+
const names = await readdir(root).catch(() => []);
|
|
928
|
+
let purged = 0;
|
|
929
|
+
for (const name of names) {
|
|
930
|
+
const path = `${root}/${name}`;
|
|
931
|
+
const metadata = await stat2(path);
|
|
932
|
+
if (Date.now() - metadata.mtimeMs > 24 * 60 * 60 * 1e3) {
|
|
933
|
+
await rm2(path, { recursive: true, force: true });
|
|
934
|
+
purged += 1;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
emit({ schemaVersion: 1, purged }, options.pretty);
|
|
938
|
+
});
|
|
939
|
+
program.parseAsync().catch(fail);
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@runalabs/rill-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Record, inspect, and share agent-controlled browser runs with Rill.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/RunaLabsDev/rill-web.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/RunaLabsDev/rill-web#readme",
|
|
12
|
+
"bugs": "https://github.com/RunaLabsDev/rill-web/issues",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"rill": "dist/cli.js"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./dist/cli.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsup",
|
|
32
|
+
"prepack": "npm run build",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"typecheck": "tsc --noEmit"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"commander": "^14.0.0",
|
|
38
|
+
"playwright-core": "^1.55.0",
|
|
39
|
+
"tus-js-client": "^4.3.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@rill/contracts": "*",
|
|
43
|
+
"@rill/recorder": "*",
|
|
44
|
+
"@types/node": "^24.3.0",
|
|
45
|
+
"tsup": "^8.5.0",
|
|
46
|
+
"typescript": "^5.9.2",
|
|
47
|
+
"vitest": "^3.2.4"
|
|
48
|
+
}
|
|
49
|
+
}
|