@runalabs/rill-cli 0.1.0 → 0.1.2
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 +8 -4
- package/dist/cli.js +187 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,23 +5,27 @@ Record, inspect, and share agent-controlled browser runs with Rill.
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
npm install --global @runalabs/rill-cli@0.1.
|
|
8
|
+
npm install --global @runalabs/rill-cli@0.1.2
|
|
9
9
|
rill doctor
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
Rill requires Node.js 22 or newer, Google Chrome or Chromium, and `ffmpeg`.
|
|
13
|
-
The CLI
|
|
14
|
-
pass `--api-url` to target another environment.
|
|
13
|
+
The CLI connects to `https://userill.dev` by default.
|
|
15
14
|
|
|
16
15
|
## Record
|
|
17
16
|
|
|
18
17
|
```sh
|
|
19
18
|
rill login
|
|
20
19
|
rill doctor
|
|
21
|
-
rill record start --url https://
|
|
20
|
+
rill record start --url https://example.com --title "Agent reproduction"
|
|
22
21
|
# Connect browser automation to the returned cdpUrl.
|
|
23
22
|
rill record stop <recording-id>
|
|
24
23
|
```
|
|
25
24
|
|
|
25
|
+
If the local recorder is unavailable, `rill record status <recording-id>` falls
|
|
26
|
+
back to the control plane. Use `rill record cancel <recording-id>` to abandon an
|
|
27
|
+
active recording and release its quota reservation. A failed local startup
|
|
28
|
+
automatically cancels the reservation it just created.
|
|
29
|
+
|
|
26
30
|
Commands emit one schema-versioned JSON object to stdout. Human-readable
|
|
27
31
|
progress is written to stderr.
|
package/dist/cli.js
CHANGED
|
@@ -583,6 +583,15 @@ function json(response, status, value) {
|
|
|
583
583
|
import { createReadStream } from "fs";
|
|
584
584
|
import { readFile as readFile2, stat } from "fs/promises";
|
|
585
585
|
import { Upload } from "tus-js-client";
|
|
586
|
+
var ControlPlaneError = class extends Error {
|
|
587
|
+
constructor(code, message, recovery, details) {
|
|
588
|
+
super(`${code}: ${message}${recovery ? `: ${recovery}` : ""}`);
|
|
589
|
+
this.code = code;
|
|
590
|
+
this.recovery = recovery;
|
|
591
|
+
this.details = details;
|
|
592
|
+
this.name = "ControlPlaneError";
|
|
593
|
+
}
|
|
594
|
+
};
|
|
586
595
|
var ControlPlaneClient = class {
|
|
587
596
|
constructor(apiUrl, token) {
|
|
588
597
|
this.apiUrl = apiUrl;
|
|
@@ -594,15 +603,36 @@ var ControlPlaneClient = class {
|
|
|
594
603
|
agentSession() {
|
|
595
604
|
return this.request("/api/agent/session");
|
|
596
605
|
}
|
|
606
|
+
reportCliCommand(input) {
|
|
607
|
+
return this.request("/api/analytics/cli-command", { method: "POST", body: JSON.stringify(input) });
|
|
608
|
+
}
|
|
597
609
|
createRecording(input) {
|
|
598
610
|
return this.request("/api/recordings", { method: "POST", body: JSON.stringify(input) });
|
|
599
611
|
}
|
|
612
|
+
markRecordingStarted(recordingId, input) {
|
|
613
|
+
return this.request(`/api/recordings/${recordingId}/local-started`, {
|
|
614
|
+
method: "POST",
|
|
615
|
+
body: JSON.stringify(input)
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
markRecordingStopped(recordingId, input) {
|
|
619
|
+
return this.request(`/api/recordings/${recordingId}/local-stopped`, {
|
|
620
|
+
method: "POST",
|
|
621
|
+
body: JSON.stringify(input)
|
|
622
|
+
});
|
|
623
|
+
}
|
|
600
624
|
provisionVideo(recordingId, input) {
|
|
601
625
|
return this.request(`/api/recordings/${recordingId}/video-upload`, { method: "POST", body: JSON.stringify(input) });
|
|
602
626
|
}
|
|
603
627
|
recordingStatus(recordingId) {
|
|
604
628
|
return this.request(`/api/recordings/${recordingId}`);
|
|
605
629
|
}
|
|
630
|
+
cancelRecording(recordingId, reason = "cancelled_by_agent") {
|
|
631
|
+
return this.request(`/api/recordings/${recordingId}/cancel`, {
|
|
632
|
+
method: "POST",
|
|
633
|
+
body: JSON.stringify({ reason })
|
|
634
|
+
});
|
|
635
|
+
}
|
|
606
636
|
async waitUntilPlayable(recordingId, timeoutMs = 10 * 6e4) {
|
|
607
637
|
const deadline = Date.now() + timeoutMs;
|
|
608
638
|
let intervalMs = 1e3;
|
|
@@ -667,11 +697,41 @@ var ControlPlaneClient = class {
|
|
|
667
697
|
}
|
|
668
698
|
const response = await fetch(`${this.apiUrl}${path}`, { ...init, headers });
|
|
669
699
|
const value = await response.json().catch(() => null);
|
|
670
|
-
if (!response.ok
|
|
700
|
+
if (!response.ok && value?.error?.code && value.error.message) {
|
|
701
|
+
throw new ControlPlaneError(value.error.code, value.error.message, value.error.recovery, value.error.details);
|
|
702
|
+
}
|
|
703
|
+
if (!response.ok) throw new Error(`Request failed (${response.status}).`);
|
|
671
704
|
return value;
|
|
672
705
|
}
|
|
673
706
|
};
|
|
674
707
|
|
|
708
|
+
// src/analytics.ts
|
|
709
|
+
var commandCategories = /* @__PURE__ */ new Map([
|
|
710
|
+
["doctor", "doctor"],
|
|
711
|
+
["record start", "record_start"],
|
|
712
|
+
["record stop", "record_stop"],
|
|
713
|
+
["record status", "record_status"],
|
|
714
|
+
["record cancel", "record_cancel"],
|
|
715
|
+
["upload", "upload"],
|
|
716
|
+
["drafts list", "drafts_list"],
|
|
717
|
+
["drafts purge", "drafts_purge"]
|
|
718
|
+
]);
|
|
719
|
+
function cliCommandCategory(commandPath2) {
|
|
720
|
+
return commandCategories.get(commandPath2.join(" ")) ?? null;
|
|
721
|
+
}
|
|
722
|
+
function cliDurationBucket(milliseconds) {
|
|
723
|
+
if (milliseconds < 6e4) return "under_1m";
|
|
724
|
+
if (milliseconds < 5 * 6e4) return "1m_to_5m";
|
|
725
|
+
if (milliseconds < 15 * 6e4) return "5m_to_15m";
|
|
726
|
+
return "15m_plus";
|
|
727
|
+
}
|
|
728
|
+
function cliFailureCode(error) {
|
|
729
|
+
const directCode = typeof error === "object" && error !== null && "code" in error ? error.code : null;
|
|
730
|
+
if (typeof directCode === "string" && /^[a-z0-9_]{1,80}$/.test(directCode)) return directCode;
|
|
731
|
+
const message = error instanceof Error ? error.message : "";
|
|
732
|
+
return message.match(/^([a-z][a-z0-9_]{0,79}):/)?.[1] ?? "unexpected_error";
|
|
733
|
+
}
|
|
734
|
+
|
|
675
735
|
// src/keychain.ts
|
|
676
736
|
import { spawn as spawn4 } from "child_process";
|
|
677
737
|
var service = "rill";
|
|
@@ -715,11 +775,16 @@ function emit(value, pretty = false) {
|
|
|
715
775
|
process.stdout.write(`${JSON.stringify(value, null, pretty ? 2 : void 0)}
|
|
716
776
|
`);
|
|
717
777
|
}
|
|
718
|
-
function
|
|
778
|
+
function failureValue(error) {
|
|
719
779
|
const message = error instanceof Error ? error.message : String(error);
|
|
720
780
|
const [candidate] = message.split(":", 1);
|
|
721
781
|
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
|
-
|
|
782
|
+
const structured = error && typeof error === "object" ? error : {};
|
|
783
|
+
const code = structured.code && knownCodes.has(structured.code) ? structured.code : knownCodes.has(candidate) ? candidate : "internal_error";
|
|
784
|
+
return { error: { code, message, ...structured.recovery ? { recovery: structured.recovery } : {}, ...structured.details ? { details: structured.details } : {} } };
|
|
785
|
+
}
|
|
786
|
+
function fail(error) {
|
|
787
|
+
emit(failureValue(error));
|
|
723
788
|
process.exitCode = 1;
|
|
724
789
|
}
|
|
725
790
|
|
|
@@ -762,8 +827,10 @@ async function runDoctor(apiUrl, overrides = {}) {
|
|
|
762
827
|
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
828
|
} else if (compareVersions(dependencies.cliVersion, health.minimumCliVersion) < 0) {
|
|
764
829
|
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)
|
|
830
|
+
} else if (compareVersions(dependencies.cliVersion, health.recommendedCliVersion) < 0) {
|
|
766
831
|
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}\`.` });
|
|
832
|
+
} else if (compareVersions(dependencies.cliVersion, health.recommendedCliVersion) > 0) {
|
|
833
|
+
checks.push({ name: "cli_version", status: "pass", message: `CLI ${dependencies.cliVersion} is supported.`, version: dependencies.cliVersion });
|
|
767
834
|
} else {
|
|
768
835
|
checks.push({ name: "cli_version", status: "pass", message: `CLI ${dependencies.cliVersion} is the recommended version.`, version: dependencies.cliVersion });
|
|
769
836
|
}
|
|
@@ -837,9 +904,45 @@ function errorMessage(error) {
|
|
|
837
904
|
return error instanceof Error ? error.message : String(error);
|
|
838
905
|
}
|
|
839
906
|
|
|
907
|
+
// src/recording-lifecycle.ts
|
|
908
|
+
async function startRecordingWithCompensation(remote, dependencies) {
|
|
909
|
+
try {
|
|
910
|
+
return await dependencies.startLocal();
|
|
911
|
+
} catch (startupError) {
|
|
912
|
+
try {
|
|
913
|
+
await dependencies.cancelRemote(remote.recordingId, "local_start_failed");
|
|
914
|
+
} catch (cleanupError) {
|
|
915
|
+
throw new AggregateError(
|
|
916
|
+
[startupError, cleanupError],
|
|
917
|
+
"Local recording startup failed and the remote reservation could not be cancelled. Run `rill record cancel <recording-id>`."
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
throw startupError;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
async function remoteAwareRecordingStatus(recordingId, dependencies) {
|
|
924
|
+
try {
|
|
925
|
+
return { ...await dependencies.localStatus(recordingId), source: "local" };
|
|
926
|
+
} catch {
|
|
927
|
+
const remote = await dependencies.remoteStatus(recordingId);
|
|
928
|
+
return {
|
|
929
|
+
...remote,
|
|
930
|
+
recordingId: typeof remote.recordingId === "string" ? remote.recordingId : remote.id ?? recordingId,
|
|
931
|
+
source: "remote"
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// src/config.ts
|
|
937
|
+
var PRODUCTION_API_URL = "https://userill.dev";
|
|
938
|
+
function controlPlaneUrl(environmentUrl) {
|
|
939
|
+
return environmentUrl ?? PRODUCTION_API_URL;
|
|
940
|
+
}
|
|
941
|
+
|
|
840
942
|
// src/cli.ts
|
|
841
943
|
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
|
|
944
|
+
program.name("rill").description("Record, inspect, and share agent browser runs.").version(CLI_VERSION).option("--api-url <url>", "Rill control plane", controlPlaneUrl(process.env.RILL_API_URL)).option("--pretty", "Pretty-print JSON output", false);
|
|
945
|
+
var activeTelemetry = null;
|
|
843
946
|
program.command("daemon", { hidden: true }).action(() => runDaemon());
|
|
844
947
|
program.command("doctor").description("Validate Rill prerequisites before recording.").action(async () => {
|
|
845
948
|
const options = program.opts();
|
|
@@ -873,8 +976,20 @@ record.command("start").option("--url <url>").option("--title <title>").option("
|
|
|
873
976
|
const width = Number(commandOptions.width);
|
|
874
977
|
const height = Number(commandOptions.height);
|
|
875
978
|
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
|
|
877
|
-
|
|
979
|
+
const local = await startRecordingWithCompensation(remote, {
|
|
980
|
+
startLocal: async () => {
|
|
981
|
+
await ensureDaemon(process.argv[1]);
|
|
982
|
+
return startRecording({ recordingId: remote.recordingId, url: commandOptions.url, cdpUrl: commandOptions.cdpUrl, headed: Boolean(commandOptions.headed), width, height, maxDurationSeconds: Number(commandOptions.maxDuration), captureNetworkBodies: remote.bodyCaptureEnabled });
|
|
983
|
+
},
|
|
984
|
+
cancelRemote: (recordingId, reason) => client.cancelRecording(recordingId, reason)
|
|
985
|
+
});
|
|
986
|
+
await client.markRecordingStarted(remote.recordingId, {
|
|
987
|
+
cliVersion: CLI_VERSION,
|
|
988
|
+
os: process.platform,
|
|
989
|
+
arch: process.arch,
|
|
990
|
+
headed: Boolean(commandOptions.headed),
|
|
991
|
+
attached: Boolean(commandOptions.cdpUrl)
|
|
992
|
+
}).catch(() => void 0);
|
|
878
993
|
emit({ schemaVersion: 1, recordingId: remote.recordingId, status: local.status, cdpUrl: local.cdpUrl, bodyCaptureEnabled: remote.bodyCaptureEnabled, maxDurationSeconds: Number(commandOptions.maxDuration) }, options.pretty);
|
|
879
994
|
});
|
|
880
995
|
record.command("stop").argument("<recording-id>").option("--no-wait").action(async (recordingId, commandOptions) => {
|
|
@@ -884,8 +999,13 @@ record.command("stop").argument("<recording-id>").option("--no-wait").action(asy
|
|
|
884
999
|
if (!local.result) throw new Error(local.error ?? "The recorder did not produce an artifact.");
|
|
885
1000
|
const token = await loadCredential(options.apiUrl);
|
|
886
1001
|
const client = new ControlPlaneClient(options.apiUrl, token);
|
|
1002
|
+
await client.markRecordingStopped(recordingId, {
|
|
1003
|
+
durationSeconds: local.result.durationSeconds,
|
|
1004
|
+
stoppedReason: local.result.stoppedReason,
|
|
1005
|
+
diagnosticsAvailable: true
|
|
1006
|
+
}).catch(() => void 0);
|
|
887
1007
|
const video = await stat2(local.result.videoPath);
|
|
888
|
-
const provisioned = await client.provisionVideo(recordingId, { sizeBytes: video.size, filename: basename(local.result.videoPath) });
|
|
1008
|
+
const provisioned = await client.provisionVideo(recordingId, { sizeBytes: video.size, filename: basename(local.result.videoPath), uploadKind: "recorded_browser" });
|
|
889
1009
|
await Promise.all([client.uploadVideo(provisioned.uploadUrl, local.result.videoPath), client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics)]);
|
|
890
1010
|
const remote = commandOptions.wait === false ? null : await client.waitUntilPlayable(recordingId);
|
|
891
1011
|
if (remote) process.stderr.write("\n");
|
|
@@ -895,8 +1015,22 @@ record.command("stop").argument("<recording-id>").option("--no-wait").action(asy
|
|
|
895
1015
|
});
|
|
896
1016
|
record.command("status").argument("<recording-id>").action(async (recordingId) => {
|
|
897
1017
|
const options = program.opts();
|
|
898
|
-
await
|
|
899
|
-
|
|
1018
|
+
const token = await loadCredential(options.apiUrl);
|
|
1019
|
+
const client = new ControlPlaneClient(options.apiUrl, token);
|
|
1020
|
+
const status = await remoteAwareRecordingStatus(recordingId, {
|
|
1021
|
+
localStatus: async (id) => {
|
|
1022
|
+
await ensureDaemon(process.argv[1]);
|
|
1023
|
+
return recordingStatus(id);
|
|
1024
|
+
},
|
|
1025
|
+
remoteStatus: (id) => client.recordingStatus(id)
|
|
1026
|
+
});
|
|
1027
|
+
emit({ schemaVersion: 1, ...status }, options.pretty);
|
|
1028
|
+
});
|
|
1029
|
+
record.command("cancel").argument("<recording-id>").description("Cancel an active recording and release its remote quota slot.").action(async (recordingId) => {
|
|
1030
|
+
const options = program.opts();
|
|
1031
|
+
const token = await loadCredential(options.apiUrl);
|
|
1032
|
+
const client = new ControlPlaneClient(options.apiUrl, token);
|
|
1033
|
+
emit(await client.cancelRecording(recordingId), options.pretty);
|
|
900
1034
|
});
|
|
901
1035
|
program.command("upload").argument("<video-file>").option("--title <title>").option("--no-wait").action(async (videoFile, commandOptions) => {
|
|
902
1036
|
const options = program.opts();
|
|
@@ -904,7 +1038,7 @@ program.command("upload").argument("<video-file>").option("--title <title>").opt
|
|
|
904
1038
|
const client = new ControlPlaneClient(options.apiUrl, token);
|
|
905
1039
|
const file = await stat2(videoFile);
|
|
906
1040
|
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) });
|
|
1041
|
+
const upload = await client.provisionVideo(recording.recordingId, { sizeBytes: file.size, filename: basename(videoFile), uploadKind: "existing_video" });
|
|
908
1042
|
await client.uploadVideo(upload.uploadUrl, videoFile);
|
|
909
1043
|
const remote = commandOptions.wait === false ? null : await client.waitUntilPlayable(recording.recordingId);
|
|
910
1044
|
if (remote) process.stderr.write("\n");
|
|
@@ -936,4 +1070,45 @@ drafts.command("purge").action(async () => {
|
|
|
936
1070
|
}
|
|
937
1071
|
emit({ schemaVersion: 1, purged }, options.pretty);
|
|
938
1072
|
});
|
|
939
|
-
program.
|
|
1073
|
+
program.hook("preAction", async (_command, actionCommand) => {
|
|
1074
|
+
const command = cliCommandCategory(commandPath(actionCommand));
|
|
1075
|
+
if (!command) return;
|
|
1076
|
+
const options = program.opts();
|
|
1077
|
+
const token = await loadCredential(options.apiUrl).catch(() => null);
|
|
1078
|
+
if (!token) return;
|
|
1079
|
+
const client = new ControlPlaneClient(options.apiUrl, token);
|
|
1080
|
+
const session = await client.agentSession().catch(() => null);
|
|
1081
|
+
if (!session?.analyticsEnabled) return;
|
|
1082
|
+
activeTelemetry = { client, command, startedAt: Date.now() };
|
|
1083
|
+
await client.reportCliCommand({ phase: "started", command, cliVersion: CLI_VERSION, os: process.platform, arch: process.arch }).catch(() => void 0);
|
|
1084
|
+
});
|
|
1085
|
+
program.hook("postAction", async () => {
|
|
1086
|
+
await finishCliTelemetry(process.exitCode ? new Error("command_reported_failure: Command returned a failure status.") : null);
|
|
1087
|
+
});
|
|
1088
|
+
program.parseAsync().catch(async (error) => {
|
|
1089
|
+
await finishCliTelemetry(error);
|
|
1090
|
+
fail(error);
|
|
1091
|
+
});
|
|
1092
|
+
function commandPath(actionCommand) {
|
|
1093
|
+
const path = [];
|
|
1094
|
+
let command = actionCommand;
|
|
1095
|
+
while (command && command !== program) {
|
|
1096
|
+
path.unshift(command.name());
|
|
1097
|
+
command = command.parent;
|
|
1098
|
+
}
|
|
1099
|
+
return path;
|
|
1100
|
+
}
|
|
1101
|
+
async function finishCliTelemetry(error) {
|
|
1102
|
+
const telemetry = activeTelemetry;
|
|
1103
|
+
activeTelemetry = null;
|
|
1104
|
+
if (!telemetry) return;
|
|
1105
|
+
await telemetry.client.reportCliCommand({
|
|
1106
|
+
phase: error ? "failed" : "completed",
|
|
1107
|
+
command: telemetry.command,
|
|
1108
|
+
cliVersion: CLI_VERSION,
|
|
1109
|
+
os: process.platform,
|
|
1110
|
+
arch: process.arch,
|
|
1111
|
+
durationBucket: cliDurationBucket(Date.now() - telemetry.startedAt),
|
|
1112
|
+
...error ? { failureCode: cliFailureCode(error) } : {}
|
|
1113
|
+
}).catch(() => void 0);
|
|
1114
|
+
}
|