@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.
Files changed (3) hide show
  1. package/README.md +8 -4
  2. package/dist/cli.js +187 -12
  3. 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.0
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 defaults to the Rill private-alpha staging API. Set `RILL_API_URL` or
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://staging.example.com --title "Agent reproduction"
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) throw new Error([value?.error?.code, value?.error?.message, value?.error?.recovery].filter(Boolean).join(": ") || `Request failed (${response.status}).`);
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 fail(error) {
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
- emit({ error: { code: knownCodes.has(candidate) ? candidate : "internal_error", message } });
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) !== 0) {
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 ?? "https://rill-staging.tight-shadow-8e12.workers.dev").option("--pretty", "Pretty-print JSON output", false);
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 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 });
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 ensureDaemon(process.argv[1]);
899
- emit({ schemaVersion: 1, ...await recordingStatus(recordingId) }, options.pretty);
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.parseAsync().catch(fail);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runalabs/rill-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Record, inspect, and share agent-controlled browser runs with Rill.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {