@runalabs/rill-cli 0.1.12 → 0.1.13
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 +49 -4
- package/dist/cli.js +217 -63
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,6 +40,17 @@ Do not use `npx rill`: the unscoped package belongs to a different product. Doct
|
|
|
40
40
|
|
|
41
41
|
## Authenticate
|
|
42
42
|
|
|
43
|
+
### Connect with pollable approval
|
|
44
|
+
|
|
45
|
+
`rill whoami` verifies the current account without starting sign-in. `rill connect --email you@example.com` waits for human approval by default. For agents that cannot hold a foreground command open:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
rill connect --email you@example.com --no-wait
|
|
49
|
+
rill connect status <authorization-id>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The first response contains `authorizationId`, `verificationUri`, `userCode`, `expiresAt`, `retryAfterSeconds`, and the exact `nextCommand`. The human opens the URL and approves their own request. Poll no faster than `retryAfterSeconds`; each status command returns once. States are `pending`, `authenticated` (approved and credentials saved), `expired`, or `denied` (including revoked claims). Expired and denied status commands exit 1. Pending state and polling backoff survive process exit in the OS credential store; claim/access tokens are never returned. Repeating connect with the same email resumes an unexpired pending request. Only the latest authorization ID for that API URL can be polled.
|
|
53
|
+
|
|
43
54
|
### Interactive login
|
|
44
55
|
|
|
45
56
|
`rill login` prints a verification URL and device code. Open the URL, sign in, and approve the request. The CLI waits for approval and stores the credential in macOS Keychain or Linux Secret Service using `secret-tool`. Linux needs an available Secret Service keyring; use token authentication on headless machines without one.
|
|
@@ -66,6 +77,8 @@ New browser recordings include a visible cursor and click pulses by default. In
|
|
|
66
77
|
|
|
67
78
|
### Connect Playwright
|
|
68
79
|
|
|
80
|
+
Start also returns `browserAttachment` with the CDP endpoint, protocol, Playwright method, and attachment instructions. Browser tools must explicitly support attaching to that endpoint; Rill does not automatically expose its browser to every tool.
|
|
81
|
+
|
|
69
82
|
In a project with `playwright-core` installed, save this as `rill-run.mjs`:
|
|
70
83
|
|
|
71
84
|
```js
|
|
@@ -86,6 +99,31 @@ try {
|
|
|
86
99
|
|
|
87
100
|
Run `node rill-run.mjs "<cdpUrl>"` with the exact endpoint from start. Then run `rill record stop <recording-id>`, including when your verification fails. A page title alone does not verify your app's behavior.
|
|
88
101
|
|
|
102
|
+
For a shell-only macOS/Linux agent with no project Playwright dependency, use a temporary directory. This installs the automation client without changing the project's manifest or launching another browser:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
rill_automation_dir=$(mktemp -d)
|
|
106
|
+
npm install --prefix "$rill_automation_dir" --no-save --package-lock=false playwright-core
|
|
107
|
+
cat > "$rill_automation_dir/run.mjs" <<'JS'
|
|
108
|
+
import { chromium } from 'playwright-core';
|
|
109
|
+
const browser = await chromium.connectOverCDP(process.argv[2]);
|
|
110
|
+
try {
|
|
111
|
+
const page = browser.contexts()[0]?.pages()[0];
|
|
112
|
+
if (!page) throw new Error('The recorded page is unavailable.');
|
|
113
|
+
await page.waitForLoadState('domcontentloaded');
|
|
114
|
+
// Add the agreed interactions and assertions on this page.
|
|
115
|
+
console.log(await page.title());
|
|
116
|
+
} finally {
|
|
117
|
+
await browser.close(); // Disconnect this CDP client; preserve the recorded browser.
|
|
118
|
+
}
|
|
119
|
+
JS
|
|
120
|
+
node "$rill_automation_dir/run.mjs" "<cdpUrl>"
|
|
121
|
+
rill record stop <recording-id> --no-wait
|
|
122
|
+
rill record status <recording-id>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Replace the placeholders with start's actual values. This snippet verifies attachment only until you add your flow assertions. Keep the directory for retries and remove it when finished. Playwright documents the [connected-browser disconnect behavior](https://playwright.dev/docs/api/class-browser#browser-close).
|
|
126
|
+
|
|
89
127
|
Capture follows the first page selected at recording start. Keep the flow in that page: new tabs and popups do not receive complete video and diagnostic coverage. Keep the recorded page open until Rill stops.
|
|
90
128
|
|
|
91
129
|
### Recording options
|
|
@@ -145,10 +183,13 @@ Use `rill --help` or `<command> --help` for supported flags.
|
|
|
145
183
|
| Command | Behavior |
|
|
146
184
|
| --- | --- |
|
|
147
185
|
| `rill login` | Authorize a device code and store a credential. |
|
|
186
|
+
| `rill whoami` | Verify the saved identity and account email. |
|
|
187
|
+
| `rill connect --email <email> [--no-wait]` | Start or resume account approval. |
|
|
188
|
+
| `rill connect status <authorization-id>` | Poll a saved approval once; obey `retryAfterSeconds`. |
|
|
148
189
|
| `rill doctor` | Check prerequisites, API compatibility, and authentication. |
|
|
149
190
|
| `rill record start [options]` | Start a managed or attached browser recording. |
|
|
150
191
|
| `rill record stop <recording-id>` | Finalize and upload; supports `--no-wait` and `--no-feedback`. |
|
|
151
|
-
| `rill record status <recording-id>` | Read
|
|
192
|
+
| `rill record status <recording-id>` | Read remote state first, then local capture for unsubmitted recordings; local completion is `captured`. |
|
|
152
193
|
| `rill record retry <recording-id>` | Resume a retained remote capture or retry a failed encode; supports `--no-wait`. |
|
|
153
194
|
| `rill record cancel <recording-id>` | Abandon a run created by this credential and release its remote quota reservation. |
|
|
154
195
|
| `rill upload <video-file>` | Upload video-only evidence; supports `--title` and `--no-wait`. |
|
|
@@ -182,14 +223,18 @@ Success exits 0; command failures exit 1. Runtime failures emit an `error` objec
|
|
|
182
223
|
|
|
183
224
|
Check the exit status first, then structured fields. Known codes include `authentication_required`, `scope_denied`, `quota_exceeded`, `browser_unavailable`, `recording_not_found`, `upload_interrupted`, `processing_failed`, `share_revoked`, `validation_failed`, and the fallback `internal_error`. Not every failure has a specific code; avoid branching on message text.
|
|
184
225
|
|
|
226
|
+
Lifecycle recovery also distinguishes `unsupported_platform`, `processing_pending`, `authorization_not_found`, `expired_token`, and `access_denied`. An elapsed playback wait is pending, while a confirmed remote failure remains `processing_failed`.
|
|
227
|
+
|
|
185
228
|
## Recovery and troubleshooting
|
|
186
229
|
|
|
187
230
|
- **Doctor fails:** read the failed check's recovery field. Correct the credential, API connectivity, or executable path before recording.
|
|
188
|
-
- **Quota exceeded:** inspect `error.details.quota.type` and `blockingRecordingId` when present.
|
|
231
|
+
- **Quota exceeded:** inspect `error.details.quota.type` and `blockingRecordingId` when present. Uploading and processing recordings still hold the concurrency slot. Run the returned status command and wait for `ready`; if it reports `captured`, run its stop command to upload. Cancel only an abandoned run. Review storage and daily allowance in Billing.
|
|
189
232
|
- **Remote upload interrupted or encoding failed:** keep the recording ID and local capture files. Run `rill record status <recording-id>`, then `rill record retry <recording-id>` with the original credential before capture expiry. Retry resumes accepted chunks or requeues the encode without repeating browser actions. A `ready` result needs a share URL before you return it as evidence.
|
|
190
|
-
- **Duration limit reached:** local capture finalizes automatically
|
|
233
|
+
- **Duration limit reached:** local capture finalizes automatically. Status reports `captured`, `source: "local"`, and the exact `nextCommand` to upload. It is not a playable share. Run `record stop <recording-id> --no-wait`, then poll status.
|
|
234
|
+
- **Upload reached 100%:** transfer has finished; encoding/playback processing may continue. `--no-wait` returns after submission with a status command. A polling timeout reports `processing_pending`, not a confirmed processing failure.
|
|
235
|
+
- **Windows:** doctor reports `recorder_transport` failure, and local record start rejects before reserving a recording. Use macOS/Linux for browser recording. Windows can use `RILL_TOKEN` with video-only `upload`; setting `RILL_SOCKET` does not add named-pipe/TCP support.
|
|
191
236
|
- **Terminal closed:** after upload acceptance, the server finishes independently; retrieve the result with `record status`. Before acceptance, retry from the original machine while its captured frames remain available.
|
|
192
|
-
- **Local session missing:** `record status` checks
|
|
237
|
+
- **Local session missing:** `record status` checks remote encoding and recording state. Remote processing/completion takes precedence over retained local artifacts. For captures not submitted, it checks the local recorder. Remote authorization/network errors are surfaced rather than being masked by local readiness. `drafts list` locates local directories, which may be incomplete. There is no `drafts resume` command.
|
|
193
238
|
- **Share cannot be inspected:** open the complete URL in a browser; ask the owner for a current link if needed.
|
|
194
239
|
|
|
195
240
|
A failed local startup automatically attempts to cancel the remote reservation it just created. `record cancel` changes remote state and does not stop the local browser; finish local capture before abandoning the remote reservation.
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,37 @@ import { hostname, homedir } from "os";
|
|
|
10
10
|
import { mkdir, rm, stat } from "fs/promises";
|
|
11
11
|
import { join } from "path";
|
|
12
12
|
import { createHash, randomBytes } from "crypto";
|
|
13
|
+
|
|
14
|
+
// src/recording-guidance.ts
|
|
15
|
+
function cliCommand(apiUrl, ...args) {
|
|
16
|
+
const quote = (value) => /^[a-zA-Z0-9_./:@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
17
|
+
return ["rill", "--api-url", apiUrl, ...args].map(quote).join(" ");
|
|
18
|
+
}
|
|
19
|
+
var transportRecovery = "Use macOS or Linux for browser recording. On Windows, use RILL_TOKEN with rill upload <video-file> for video-only sharing; diagnostics are not imported. RILL_SOCKET does not enable Windows named-pipe or TCP support in this release.";
|
|
20
|
+
function recorderTransportCheck(platform = process.platform) {
|
|
21
|
+
return platform === "win32" ? { name: "recorder_transport", status: "fail", message: "The local recorder transport does not support Windows.", recovery: transportRecovery } : { name: "recorder_transport", status: "pass", message: "The local recorder uses a Unix-domain socket." };
|
|
22
|
+
}
|
|
23
|
+
function assertRecorderTransport(platform = process.platform) {
|
|
24
|
+
const check2 = recorderTransportCheck(platform);
|
|
25
|
+
if (check2.status === "fail") throw Object.assign(new Error(`unsupported_platform: ${check2.message}`), { code: "unsupported_platform", recovery: check2.recovery });
|
|
26
|
+
}
|
|
27
|
+
function recordingNextStep(apiUrl, recordingId, status, options = {}) {
|
|
28
|
+
if (status === "uploading" && options.resumableFrames) return { nextCommand: cliCommand(apiUrl, "record", "retry", recordingId, "--no-wait"), message: "Frame upload is incomplete. If the uploader is still running, let it finish. If it exited or was interrupted, run nextCommand to resume missing parts and finalize the upload. Do not start a second uploader while the first is active." };
|
|
29
|
+
if (status === "captured") return { nextCommand: cliCommand(apiUrl, "record", "stop", recordingId, "--no-wait"), message: "Captured locally; upload required. This is not a playable share yet." };
|
|
30
|
+
if (["uploading", "queued", "encoding", "processing", "finalizing"].includes(status)) return { nextCommand: cliCommand(apiUrl, "record", "status", recordingId), message: "Finalizing recordings hold a concurrency slot. Wait for ready; cancel only if abandoning the recording." };
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
function browserAttachment(cdpUrl) {
|
|
34
|
+
return {
|
|
35
|
+
protocol: "cdp",
|
|
36
|
+
cdpUrl,
|
|
37
|
+
playwrightMethod: "chromium.connectOverCDP",
|
|
38
|
+
instructions: "Attach to this endpoint and use the existing browser context. A browser tool must explicitly support CDP attachment; do not drive a separate browser. Disconnect the automation client without closing the recorded browser, then run record stop.",
|
|
39
|
+
shellFallback: "See the shell-only Playwright example in the Rill CLI README; install playwright-core in a temporary directory outside the project."
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/agent-session.ts
|
|
13
44
|
var JWT_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
|
14
45
|
var CLAIM_GRANT = "urn:workos:agent-auth:grant-type:claim";
|
|
15
46
|
var AgentAuthError = class extends Error {
|
|
@@ -52,17 +83,7 @@ async function loadAgentCredential(apiUrl) {
|
|
|
52
83
|
if (process.env.RILL_TOKEN) return process.env.RILL_TOKEN;
|
|
53
84
|
const inFlight = pending.get(apiUrl);
|
|
54
85
|
if (inFlight) return inFlight;
|
|
55
|
-
const promise2 = (
|
|
56
|
-
const raw = await loadStoredCredential(apiUrl);
|
|
57
|
-
const saved = parseAuthorization(raw);
|
|
58
|
-
if (!saved) return raw;
|
|
59
|
-
if (saved.accessToken && (saved.accessExpiresAt ?? 0) > Date.now() + 6e4) return saved.accessToken;
|
|
60
|
-
if (!saved.identityAssertion && saved.anonymous && saved.registrationToken) return null;
|
|
61
|
-
if (!saved.identityAssertion) throw new Error("Connection is awaiting approval. Run rill connect --email <your-email>.");
|
|
62
|
-
const tokens = await request(apiUrl, "/oauth2/token", { grant_type: JWT_GRANT, assertion: saved.identityAssertion }, true);
|
|
63
|
-
await save(apiUrl, withTokens(saved, tokens));
|
|
64
|
-
return tokens.access_token;
|
|
65
|
-
})();
|
|
86
|
+
const promise2 = withAuthorizationLock(apiUrl, () => readAgentCredential(apiUrl));
|
|
66
87
|
pending.set(apiUrl, promise2);
|
|
67
88
|
try {
|
|
68
89
|
return await promise2;
|
|
@@ -70,7 +91,22 @@ async function loadAgentCredential(apiUrl) {
|
|
|
70
91
|
pending.delete(apiUrl);
|
|
71
92
|
}
|
|
72
93
|
}
|
|
94
|
+
async function readAgentCredential(apiUrl) {
|
|
95
|
+
if (process.env.RILL_TOKEN) return process.env.RILL_TOKEN;
|
|
96
|
+
const raw = await loadStoredCredential(apiUrl);
|
|
97
|
+
const saved = parseAuthorization(raw);
|
|
98
|
+
if (!saved) return raw;
|
|
99
|
+
if (saved.accessToken && (saved.accessExpiresAt ?? 0) > Date.now() + 6e4) return saved.accessToken;
|
|
100
|
+
if (!saved.identityAssertion && saved.anonymous && saved.registrationToken) return null;
|
|
101
|
+
if (!saved.identityAssertion) throw new Error(saved.connection ? `Connection is ${saved.connection.status}. Run ${cliCommand(apiUrl, "connect", "status", saved.connection.id)}.` : "Connection is awaiting approval. Run rill connect --email <your-email>.");
|
|
102
|
+
const tokens = await request(apiUrl, "/oauth2/token", { grant_type: JWT_GRANT, assertion: saved.identityAssertion }, true);
|
|
103
|
+
await save(apiUrl, withTokens(saved, tokens));
|
|
104
|
+
return tokens.access_token;
|
|
105
|
+
}
|
|
73
106
|
async function ensureAnonymousCredential(apiUrl) {
|
|
107
|
+
return withAuthorizationLock(apiUrl, () => createAnonymousCredential(apiUrl));
|
|
108
|
+
}
|
|
109
|
+
async function withAuthorizationLock(apiUrl, action) {
|
|
74
110
|
const root = join(homedir(), ".rill", "auth-locks");
|
|
75
111
|
await mkdir(root, { recursive: true, mode: 448 });
|
|
76
112
|
const lock = join(root, createHash("sha256").update(apiUrl).digest("hex"));
|
|
@@ -91,13 +127,13 @@ async function ensureAnonymousCredential(apiUrl) {
|
|
|
91
127
|
}
|
|
92
128
|
}
|
|
93
129
|
try {
|
|
94
|
-
return await
|
|
130
|
+
return await action();
|
|
95
131
|
} finally {
|
|
96
132
|
await rm(lock, { recursive: true, force: true });
|
|
97
133
|
}
|
|
98
134
|
}
|
|
99
135
|
async function createAnonymousCredential(apiUrl) {
|
|
100
|
-
const existing = await
|
|
136
|
+
const existing = await readAgentCredential(apiUrl);
|
|
101
137
|
if (existing) return existing;
|
|
102
138
|
const stored = parseAuthorization(await loadStoredCredential(apiUrl));
|
|
103
139
|
const initial = stored?.registrationToken ? stored : { version: 1, anonymous: true, registrationToken: randomBytes(32).toString("hex") };
|
|
@@ -106,11 +142,29 @@ async function createAnonymousCredential(apiUrl) {
|
|
|
106
142
|
const saved = { ...initial, registrationToken: void 0, identityAssertion: registered.identity_assertion, assertionExpires: registered.assertion_expires, claimToken: registered.claim_token };
|
|
107
143
|
await save(apiUrl, saved);
|
|
108
144
|
process.stderr.write("Recording anonymously: three recordings, up to five minutes each. Recordings and share links expire after seven days. Run rill connect --email <your-email> to keep them.\n");
|
|
109
|
-
return await
|
|
145
|
+
return await readAgentCredential(apiUrl);
|
|
146
|
+
}
|
|
147
|
+
async function connectAgent(apiUrl, email3, options = {}) {
|
|
148
|
+
let result = await withAuthorizationLock(apiUrl, () => beginConnection(apiUrl, email3));
|
|
149
|
+
if (options.wait === false) return result;
|
|
150
|
+
while (result.status === "pending") {
|
|
151
|
+
await new Promise((resolve) => setTimeout(resolve, result.retryAfterSeconds * 1e3));
|
|
152
|
+
result = await pollConnection(apiUrl, result.authorizationId);
|
|
153
|
+
}
|
|
154
|
+
if (result.status === "expired") throw new AgentAuthError("expired_token", "Connection request expired. Run rill connect again using the same saved identity.");
|
|
155
|
+
if (result.status === "denied") throw new AgentAuthError("access_denied", "Connection request was denied or revoked. Run rill connect again only when ready to request approval.");
|
|
156
|
+
return result;
|
|
110
157
|
}
|
|
111
|
-
async function
|
|
158
|
+
async function beginConnection(apiUrl, email3) {
|
|
112
159
|
if (process.env.RILL_TOKEN) throw new Error("Unset RILL_TOKEN before connecting a local identity.");
|
|
113
160
|
let saved = parseAuthorization(await loadStoredCredential(apiUrl));
|
|
161
|
+
if (saved?.connection?.status === "pending" && saved.connection.expiresAt > Date.now()) {
|
|
162
|
+
if (saved.connection.email !== email3) throw new Error("A connection for another email is pending. Let it expire before starting a different connection.");
|
|
163
|
+
process.stderr.write(`Open ${saved.connection.verificationUri}
|
|
164
|
+
Sign in as ${email3} and enter ${saved.connection.userCode}. Approve only the request you started.
|
|
165
|
+
`);
|
|
166
|
+
return connectionView(apiUrl, saved);
|
|
167
|
+
}
|
|
114
168
|
let claim;
|
|
115
169
|
if (saved?.claimToken && saved.anonymous) {
|
|
116
170
|
try {
|
|
@@ -130,26 +184,65 @@ async function connectAgent(apiUrl, email3) {
|
|
|
130
184
|
process.stderr.write(`Open ${claim.verification_uri}
|
|
131
185
|
Sign in as ${email3} and enter ${claim.user_code}. Approve only the request you started.
|
|
132
186
|
`);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
187
|
+
saved = { ...saved, connection: {
|
|
188
|
+
id: randomBytes(16).toString("hex"),
|
|
189
|
+
email: email3,
|
|
190
|
+
verificationUri: claim.verification_uri,
|
|
191
|
+
userCode: claim.user_code,
|
|
192
|
+
expiresAt: Date.now() + claim.expires_in * 1e3,
|
|
193
|
+
interval: claim.interval,
|
|
194
|
+
nextPollAt: Date.now() + claim.interval * 1e3,
|
|
195
|
+
status: "pending"
|
|
196
|
+
} };
|
|
197
|
+
await save(apiUrl, saved);
|
|
198
|
+
return connectionView(apiUrl, saved);
|
|
199
|
+
}
|
|
200
|
+
function connectionView(apiUrl, saved) {
|
|
201
|
+
const connection = saved.connection;
|
|
202
|
+
return {
|
|
203
|
+
status: connection.status,
|
|
204
|
+
authorizationId: connection.id,
|
|
205
|
+
apiUrl,
|
|
206
|
+
expiresAt: connection.status === "authenticated" ? saved.assertionExpires : new Date(connection.expiresAt).toISOString(),
|
|
207
|
+
retryAfterSeconds: connection.status === "pending" ? Math.max(0, Math.ceil((connection.nextPollAt - Date.now()) / 1e3)) : 0,
|
|
208
|
+
...connection.status === "pending" ? {
|
|
209
|
+
verificationUri: connection.verificationUri,
|
|
210
|
+
userCode: connection.userCode,
|
|
211
|
+
nextCommand: cliCommand(apiUrl, "connect", "status", connection.id)
|
|
212
|
+
} : connection.status === "authenticated" ? {} : { nextCommand: cliCommand(apiUrl, "connect", "--email", connection.email) }
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
async function pollConnection(apiUrl, authorizationId) {
|
|
216
|
+
return withAuthorizationLock(apiUrl, () => pollSavedConnection(apiUrl, authorizationId));
|
|
217
|
+
}
|
|
218
|
+
async function pollSavedConnection(apiUrl, authorizationId) {
|
|
219
|
+
if (process.env.RILL_TOKEN) throw new Error("Unset RILL_TOKEN before polling a local connection.");
|
|
220
|
+
let saved = parseAuthorization(await loadStoredCredential(apiUrl));
|
|
221
|
+
if (!saved?.connection || saved.connection.id !== authorizationId) throw new AgentAuthError("authorization_not_found", "No matching saved connection for this API URL. Use the latest connect response.");
|
|
222
|
+
const connection = saved.connection;
|
|
223
|
+
if (connection.status !== "pending") return connectionView(apiUrl, saved);
|
|
224
|
+
if (Date.now() >= connection.expiresAt) {
|
|
225
|
+
connection.status = "expired";
|
|
226
|
+
await save(apiUrl, saved);
|
|
227
|
+
return connectionView(apiUrl, saved);
|
|
228
|
+
}
|
|
229
|
+
if (Date.now() < connection.nextPollAt) return connectionView(apiUrl, saved);
|
|
230
|
+
connection.nextPollAt = Date.now() + connection.interval * 1e3;
|
|
231
|
+
await save(apiUrl, saved);
|
|
232
|
+
try {
|
|
233
|
+
const tokens = await request(apiUrl, "/oauth2/token", { grant_type: CLAIM_GRANT, claim_token: saved.claimToken }, true);
|
|
234
|
+
saved = withTokens({ version: 1, anonymous: false, connection: { ...connection, status: "authenticated", userCode: "" } }, tokens);
|
|
235
|
+
} catch (error51) {
|
|
236
|
+
if (!(error51 instanceof AgentAuthError)) throw error51;
|
|
237
|
+
if (error51.code === "expired_token" || error51.code === "claim_expired") connection.status = "expired";
|
|
238
|
+
else if (error51.code === "access_denied" || error51.code === "claim_revoked") connection.status = "denied";
|
|
239
|
+
else if (error51.code === "slow_down") {
|
|
240
|
+
connection.interval += 5;
|
|
241
|
+
connection.nextPollAt = Date.now() + connection.interval * 1e3;
|
|
242
|
+
} else if (error51.code !== "authorization_pending") throw error51;
|
|
151
243
|
}
|
|
152
|
-
|
|
244
|
+
await save(apiUrl, saved);
|
|
245
|
+
return connectionView(apiUrl, saved);
|
|
153
246
|
}
|
|
154
247
|
|
|
155
248
|
// src/keychain.ts
|
|
@@ -221,6 +314,9 @@ var ControlPlaneClient = class {
|
|
|
221
314
|
this.apiUrl = apiUrl;
|
|
222
315
|
this.token = token;
|
|
223
316
|
}
|
|
317
|
+
recordingStatusCommand(id) {
|
|
318
|
+
return cliCommand(this.apiUrl, "record", "status", id);
|
|
319
|
+
}
|
|
224
320
|
encodingCapabilities() {
|
|
225
321
|
return this.request("/api/encoding/capabilities");
|
|
226
322
|
}
|
|
@@ -299,17 +395,18 @@ var ControlPlaneClient = class {
|
|
|
299
395
|
return { ...recording, shareUrl: new URL(recording.shareUrl, this.apiUrl).toString() };
|
|
300
396
|
}
|
|
301
397
|
if (recording.status === "failed") throw new Error(`processing_failed: ${recording.failure_message ?? "Cloudflare Stream could not process the recording."}`);
|
|
302
|
-
process.stderr.write(
|
|
398
|
+
process.stderr.write("\rUpload complete; waiting for playable video. ");
|
|
303
399
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
304
400
|
intervalMs = Math.min(5e3, Math.round(intervalMs * 1.4));
|
|
305
401
|
}
|
|
306
|
-
throw new
|
|
402
|
+
throw new ControlPlaneError("processing_pending", "Upload complete; the recording is not playable yet.", `Run ${cliCommand(this.apiUrl, "record", "status", recordingId)} to continue checking.`, { recordingId, nextCommand: cliCommand(this.apiUrl, "record", "status", recordingId) });
|
|
307
403
|
}
|
|
308
404
|
async uploadVideo(uploadUrl, filePath) {
|
|
309
405
|
const file2 = await stat2(filePath);
|
|
310
406
|
if (uploadUrl.startsWith(this.apiUrl)) {
|
|
311
407
|
const response = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Length": String(file2.size) }, body: createReadStream(filePath), duplex: "half" });
|
|
312
408
|
if (!response.ok) throw new Error(`Video upload failed (${response.status}).`);
|
|
409
|
+
process.stderr.write("Upload complete; playback processing may continue.\n");
|
|
313
410
|
return;
|
|
314
411
|
}
|
|
315
412
|
await new Promise((resolve, reject) => {
|
|
@@ -324,7 +421,7 @@ var ControlPlaneClient = class {
|
|
|
324
421
|
});
|
|
325
422
|
upload.start();
|
|
326
423
|
});
|
|
327
|
-
process.stderr.write("\n");
|
|
424
|
+
process.stderr.write("\nUpload complete; playback processing may continue.\n");
|
|
328
425
|
}
|
|
329
426
|
async uploadDiagnostics(recordingId, filePath, summary) {
|
|
330
427
|
const body = await readFile(filePath);
|
|
@@ -339,16 +436,29 @@ var ControlPlaneClient = class {
|
|
|
339
436
|
pollDeviceAuthorization(deviceCode) {
|
|
340
437
|
return this.request("/api/device/token", { method: "POST", body: JSON.stringify({ deviceCode }) }, false);
|
|
341
438
|
}
|
|
342
|
-
inspect(shareUrl) {
|
|
439
|
+
inspect(shareUrl, cursor) {
|
|
343
440
|
if (/^rec_[a-zA-Z0-9_-]+$/.test(shareUrl)) return this.request(`/api/recordings/${encodeURIComponent(shareUrl)}`);
|
|
344
441
|
const url2 = new URL(shareUrl);
|
|
345
442
|
const slug = url2.pathname.split("/").filter(Boolean).at(-1);
|
|
346
443
|
if (!slug) throw new Error("The share URL does not contain a slug.");
|
|
347
|
-
return fetch(`${url2.origin}/api/public/shares/${encodeURIComponent(slug)}/context`, { headers: { Accept: "application/json" } }).then(async (response) => {
|
|
444
|
+
return fetch(`${url2.origin}/api/public/shares/${encodeURIComponent(slug)}/context${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`, { headers: { Accept: "application/json" } }).then(async (response) => {
|
|
348
445
|
if (!response.ok) throw new Error(`Could not inspect the share link (${response.status}).`);
|
|
349
446
|
return response.json();
|
|
350
447
|
});
|
|
351
448
|
}
|
|
449
|
+
reviewPath(shareUrl) {
|
|
450
|
+
const url2 = new URL(shareUrl);
|
|
451
|
+
if (url2.origin !== new URL(this.apiUrl).origin) throw new Error("The share URL must match --api-url before sending agent credentials.");
|
|
452
|
+
const match = /^\/s\/([^/]+)\/?$/.exec(url2.pathname);
|
|
453
|
+
if (!match) throw new Error("Expected a Rill /s/<slug> review link.");
|
|
454
|
+
return `/api/review/shares/${encodeURIComponent(decodeURIComponent(match[1]))}`;
|
|
455
|
+
}
|
|
456
|
+
reply(shareUrl, discussionId, body, requestId) {
|
|
457
|
+
return this.request(`${this.reviewPath(shareUrl)}/discussions/${encodeURIComponent(discussionId)}/replies`, { method: "POST", body: JSON.stringify({ body, requestId }) });
|
|
458
|
+
}
|
|
459
|
+
editReply(shareUrl, commentId, body) {
|
|
460
|
+
return this.request(`${this.reviewPath(shareUrl)}/comments/${encodeURIComponent(commentId)}`, { method: "PATCH", body: JSON.stringify({ body }) });
|
|
461
|
+
}
|
|
352
462
|
async request(path, init = {}, authenticated = true) {
|
|
353
463
|
const headers = new Headers(init.headers);
|
|
354
464
|
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
|
@@ -360,7 +470,11 @@ var ControlPlaneClient = class {
|
|
|
360
470
|
const response = await fetch(`${this.apiUrl}${path}`, { ...init, headers });
|
|
361
471
|
const value = await response.json().catch(() => null);
|
|
362
472
|
if (!response.ok && value?.error?.code && value.error.message) {
|
|
363
|
-
|
|
473
|
+
const quota = value.error.details?.quota;
|
|
474
|
+
const blockingId = quota?.blockingRecordingId;
|
|
475
|
+
const concurrency = quota?.type === "credential_concurrency" || quota?.type === "workspace_concurrency";
|
|
476
|
+
const recovery = value.error.code === "quota_exceeded" && concurrency && blockingId ? `Run ${cliCommand(this.apiUrl, "record", "status", blockingId)}. Uploading and processing recordings hold a concurrency slot: wait for ready. If captured locally, run record stop to upload. Cancel only an abandoned recording.` : value.error.recovery;
|
|
477
|
+
throw new ControlPlaneError(value.error.code, value.error.message, recovery, value.error.details, response.status);
|
|
364
478
|
}
|
|
365
479
|
if (!response.ok) throw new ControlPlaneHttpError(response.status);
|
|
366
480
|
return value;
|
|
@@ -14963,6 +15077,7 @@ async function submitFrameCapture(client, id, directory) {
|
|
|
14963
15077
|
} finally {
|
|
14964
15078
|
await file2.close();
|
|
14965
15079
|
}
|
|
15080
|
+
process.stderr.write("\nFrame upload complete; submitting for encoding.\n");
|
|
14966
15081
|
return client.completeEncoding(id);
|
|
14967
15082
|
}
|
|
14968
15083
|
async function waitForEncoding(client, id, timeoutMs = 30 * 6e4) {
|
|
@@ -14974,7 +15089,7 @@ async function waitForEncoding(client, id, timeoutMs = 30 * 6e4) {
|
|
|
14974
15089
|
process.stderr.write(`\r${status.status.padEnd(20)}`);
|
|
14975
15090
|
await new Promise((r) => setTimeout(r, 2e3));
|
|
14976
15091
|
}
|
|
14977
|
-
throw new
|
|
15092
|
+
throw new ControlPlaneError("processing_pending", "Encoding continues remotely.", `Run ${client.recordingStatusCommand(id)}.`, { recordingId: id, nextCommand: client.recordingStatusCommand(id) });
|
|
14978
15093
|
}
|
|
14979
15094
|
|
|
14980
15095
|
// src/cli.ts
|
|
@@ -15910,7 +16025,7 @@ function emit(value, pretty = false) {
|
|
|
15910
16025
|
function failureValue(error51) {
|
|
15911
16026
|
const message = error51 instanceof Error ? error51.message : String(error51);
|
|
15912
16027
|
const [candidate] = message.split(":", 1);
|
|
15913
|
-
const knownCodes = /* @__PURE__ */ new Set(["authentication_required", "scope_denied", "quota_exceeded", "browser_unavailable", "recording_not_found", "upload_interrupted", "processing_failed", "share_revoked", "validation_failed"]);
|
|
16028
|
+
const knownCodes = /* @__PURE__ */ new Set(["authentication_required", "authorization_not_found", "expired_token", "access_denied", "unsupported_platform", "scope_denied", "quota_exceeded", "browser_unavailable", "recording_not_found", "upload_interrupted", "processing_pending", "processing_failed", "share_revoked", "validation_failed"]);
|
|
15914
16029
|
const structured = error51 && typeof error51 === "object" ? error51 : {};
|
|
15915
16030
|
const code = structured.code && knownCodes.has(structured.code) ? structured.code : knownCodes.has(candidate) ? candidate : "internal_error";
|
|
15916
16031
|
return { error: { code, message, ...structured.recovery ? { recovery: structured.recovery } : {}, ...structured.details ? { details: structured.details } : {} } };
|
|
@@ -15986,6 +16101,7 @@ var CLI_VERSION = packageJson.version;
|
|
|
15986
16101
|
|
|
15987
16102
|
// src/doctor.ts
|
|
15988
16103
|
var defaultDependencies = {
|
|
16104
|
+
platform: process.platform,
|
|
15989
16105
|
encoding: "remote",
|
|
15990
16106
|
encodingCapabilities: (apiUrl, token) => new ControlPlaneClient(apiUrl, token).encodingCapabilities(),
|
|
15991
16107
|
nodeVersion: process.version,
|
|
@@ -16001,6 +16117,7 @@ async function runDoctor(apiUrl, overrides = {}) {
|
|
|
16001
16117
|
const dependencies = { ...defaultDependencies, ...overrides };
|
|
16002
16118
|
const checks = [];
|
|
16003
16119
|
let feedbackEnabled = false;
|
|
16120
|
+
checks.push(recorderTransportCheck(dependencies.platform));
|
|
16004
16121
|
if (compareVersions(normalizeVersion(dependencies.nodeVersion), "22.0.0") >= 0) {
|
|
16005
16122
|
checks.push({ name: "node", status: "pass", message: `Node.js ${dependencies.nodeVersion} is supported.`, version: dependencies.nodeVersion });
|
|
16006
16123
|
} else {
|
|
@@ -16028,8 +16145,16 @@ async function runDoctor(apiUrl, overrides = {}) {
|
|
|
16028
16145
|
if (!health) {
|
|
16029
16146
|
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.` });
|
|
16030
16147
|
} else {
|
|
16031
|
-
|
|
16032
|
-
|
|
16148
|
+
let token = null;
|
|
16149
|
+
let credentialError;
|
|
16150
|
+
try {
|
|
16151
|
+
token = await dependencies.loadCredential(apiUrl);
|
|
16152
|
+
} catch (error51) {
|
|
16153
|
+
credentialError = error51;
|
|
16154
|
+
}
|
|
16155
|
+
if (credentialError) {
|
|
16156
|
+
checks.push({ name: "authentication", status: "fail", message: errorMessage(credentialError), recovery: "Unlock the OS credential store, or use RILL_TOKEN. Windows local recording remains unsupported." });
|
|
16157
|
+
} else if (!token) {
|
|
16033
16158
|
checks.push({ name: "authentication", status: health.anonymousAccess ? "warn" : "fail", message: health.anonymousAccess ? "The first recording will create an anonymous identity in your OS credential store. Anonymous recordings expire after seven days." : "No Rill agent credential is available.", recovery: `Run \`rill --api-url ${apiUrl} login\` or set RILL_TOKEN.` });
|
|
16034
16159
|
} else {
|
|
16035
16160
|
try {
|
|
@@ -16142,16 +16267,21 @@ async function startRecordingWithCompensation(remote, dependencies) {
|
|
|
16142
16267
|
throw startupError;
|
|
16143
16268
|
}
|
|
16144
16269
|
}
|
|
16145
|
-
async function remoteAwareRecordingStatus(recordingId, dependencies) {
|
|
16270
|
+
async function remoteAwareRecordingStatus(recordingId, dependencies, apiUrl = "https://userill.dev") {
|
|
16271
|
+
const remote = await dependencies.remoteStatus(recordingId);
|
|
16272
|
+
const remoteView = {
|
|
16273
|
+
...remote,
|
|
16274
|
+
recordingId: typeof remote.recordingId === "string" ? remote.recordingId : remote.id ?? recordingId,
|
|
16275
|
+
source: "remote",
|
|
16276
|
+
...recordingNextStep(apiUrl, recordingId, String(remote.status))
|
|
16277
|
+
};
|
|
16278
|
+
if (["uploading", "processing", "ready", "failed", "expired", "cancelled"].includes(String(remote.status))) return remoteView;
|
|
16146
16279
|
try {
|
|
16147
|
-
|
|
16280
|
+
const local = await dependencies.localStatus(recordingId);
|
|
16281
|
+
const status = local.status === "ready" ? "captured" : local.status;
|
|
16282
|
+
return { ...local, status, source: "local", ...recordingNextStep(apiUrl, recordingId, String(status)) };
|
|
16148
16283
|
} catch {
|
|
16149
|
-
|
|
16150
|
-
return {
|
|
16151
|
-
...remote,
|
|
16152
|
-
recordingId: typeof remote.recordingId === "string" ? remote.recordingId : remote.id ?? recordingId,
|
|
16153
|
-
source: "remote"
|
|
16154
|
-
};
|
|
16284
|
+
return remoteView;
|
|
16155
16285
|
}
|
|
16156
16286
|
}
|
|
16157
16287
|
|
|
@@ -16211,9 +16341,16 @@ program.command("whoami").description("Show the current verified agent identity
|
|
|
16211
16341
|
emit(identity, options.pretty);
|
|
16212
16342
|
if (!identity.authenticated) process.exitCode = 1;
|
|
16213
16343
|
});
|
|
16214
|
-
program.command("connect").description("Connect this agent once, with renewable account access.").
|
|
16344
|
+
var connect = program.command("connect").description("Connect this agent once, with renewable account access.").option("--email <email>", "Email of the Rill account approving access").option("--no-wait", "Return approval instructions immediately; poll with connect status").action(async (input) => {
|
|
16345
|
+
if (!input.email) throw new Error("validation_failed: Supply --email <email> to start a connection.");
|
|
16215
16346
|
const options = program.opts();
|
|
16216
|
-
emit({ schemaVersion: 1, ...await connectAgent(options.apiUrl, input.email) }, options.pretty);
|
|
16347
|
+
emit({ schemaVersion: 1, ...await connectAgent(options.apiUrl, input.email, { wait: input.wait }) }, options.pretty);
|
|
16348
|
+
});
|
|
16349
|
+
connect.command("status").argument("<authorization-id>").description("Poll a saved connection once without starting another approval.").action(async (id) => {
|
|
16350
|
+
const options = program.opts();
|
|
16351
|
+
const result = await pollConnection(options.apiUrl, id);
|
|
16352
|
+
emit({ schemaVersion: 1, ...result }, options.pretty);
|
|
16353
|
+
if (result.status === "expired" || result.status === "denied") process.exitCode = 1;
|
|
16217
16354
|
});
|
|
16218
16355
|
program.command("login").description("Authorize Rill using the existing device login flow.").action(async () => {
|
|
16219
16356
|
await authorize();
|
|
@@ -16252,6 +16389,7 @@ for (const action of ["status", "cancel"]) cloudRun.command(action).argument("<r
|
|
|
16252
16389
|
});
|
|
16253
16390
|
record2.command("start").option("--encoding <mode>", "remote or local", "remote").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) => {
|
|
16254
16391
|
const options = program.opts();
|
|
16392
|
+
assertRecorderTransport();
|
|
16255
16393
|
await ensureAnonymousCredential(options.apiUrl);
|
|
16256
16394
|
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
16257
16395
|
const result = await withRunFeedback(() => client.agentSession(), async (rememberRecording) => {
|
|
@@ -16276,7 +16414,7 @@ record2.command("start").option("--encoding <mode>", "remote or local", "remote"
|
|
|
16276
16414
|
headed: Boolean(commandOptions.headed),
|
|
16277
16415
|
attached: Boolean(commandOptions.cdpUrl)
|
|
16278
16416
|
}).catch(() => void 0);
|
|
16279
|
-
return { schemaVersion: 1, recordingId: remote.recordingId, status: local.status, cdpUrl: local.cdpUrl, bodyCaptureEnabled: remote.bodyCaptureEnabled, anonymous: remote.anonymous ?? false, expiresAt: remote.expiresAt ?? null, maxDurationSeconds: Math.min(Number(commandOptions.maxDuration), remote.maxDurationSeconds ?? 600) };
|
|
16417
|
+
return { schemaVersion: 1, recordingId: remote.recordingId, status: local.status, cdpUrl: local.cdpUrl, browserAttachment: browserAttachment(local.cdpUrl), bodyCaptureEnabled: remote.bodyCaptureEnabled, anonymous: remote.anonymous ?? false, expiresAt: remote.expiresAt ?? null, maxDurationSeconds: Math.min(Number(commandOptions.maxDuration), remote.maxDurationSeconds ?? 600) };
|
|
16280
16418
|
});
|
|
16281
16419
|
emit(result, options.pretty);
|
|
16282
16420
|
});
|
|
@@ -16296,7 +16434,7 @@ record2.command("stop").argument("<recording-id>").option("--no-wait").option("-
|
|
|
16296
16434
|
await client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics);
|
|
16297
16435
|
const accepted = await submitFrameCapture(client, recordingId, local.result.frameDirectory);
|
|
16298
16436
|
const result2 = commandOptions.wait && record2.opts().wait !== false ? await waitForEncoding(client, recordingId) : accepted;
|
|
16299
|
-
emit({ schemaVersion: 1, ...result2, ...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false) }, options.pretty);
|
|
16437
|
+
emit({ schemaVersion: 1, ...result2, ...recordingNextStep(options.apiUrl, recordingId, result2.status, { resumableFrames: true }), ...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false) }, options.pretty);
|
|
16300
16438
|
if (result2.status === "ready") await rm5(dirname2(local.result.videoPath), { recursive: true, force: true });
|
|
16301
16439
|
else watchEncoding(options.apiUrl, recordingId);
|
|
16302
16440
|
return;
|
|
@@ -16315,6 +16453,7 @@ record2.command("stop").argument("<recording-id>").option("--no-wait").option("-
|
|
|
16315
16453
|
durationSeconds: local.result.durationSeconds,
|
|
16316
16454
|
stoppedReason: local.result.stoppedReason,
|
|
16317
16455
|
diagnostics: local.result.diagnostics,
|
|
16456
|
+
...recordingNextStep(options.apiUrl, recordingId, remote ? "ready" : "processing"),
|
|
16318
16457
|
...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false)
|
|
16319
16458
|
};
|
|
16320
16459
|
emit(result, options.pretty);
|
|
@@ -16338,7 +16477,7 @@ record2.command("retry").argument("<recording-id>").option("--no-wait").action(a
|
|
|
16338
16477
|
if (!accepted) throw new Error("Encoding job is unavailable.");
|
|
16339
16478
|
if (["expired", "cancelled"].includes(accepted.status)) throw new Error(`Recording ${accepted.status}.`);
|
|
16340
16479
|
const result = commandOptions.wait && record2.opts().wait !== false ? await waitForEncoding(client, recordingId) : accepted;
|
|
16341
|
-
emit({ schemaVersion: 1, ...result }, options.pretty);
|
|
16480
|
+
emit({ schemaVersion: 1, ...result, ...recordingNextStep(options.apiUrl, recordingId, result.status, { resumableFrames: true }) }, options.pretty);
|
|
16342
16481
|
if (result.status === "ready") await removeLocalCapture(recordingId);
|
|
16343
16482
|
else watchEncoding(options.apiUrl, recordingId);
|
|
16344
16483
|
});
|
|
@@ -16380,11 +16519,11 @@ record2.command("status").argument("<recording-id>").action(async (recordingId)
|
|
|
16380
16519
|
try {
|
|
16381
16520
|
const encoding = await client.encodingStatus(recordingId);
|
|
16382
16521
|
const remote = encoding.status === "ready" ? await client.recordingStatus(recordingId) : {};
|
|
16383
|
-
emit({ schemaVersion: 1, ...encoding, ...remote, recordingId, source: "remote" }, options.pretty);
|
|
16522
|
+
emit({ schemaVersion: 1, ...encoding, ...remote, recordingId, source: "remote", ...recordingNextStep(options.apiUrl, recordingId, encoding.status, { resumableFrames: true }) }, options.pretty);
|
|
16384
16523
|
if (encoding.status === "ready") await removeLocalCapture(recordingId);
|
|
16385
16524
|
return;
|
|
16386
16525
|
} catch (error51) {
|
|
16387
|
-
if (!(error51 instanceof
|
|
16526
|
+
if (!(error51 instanceof ControlPlaneError) || error51.code !== "recording_not_found") throw error51;
|
|
16388
16527
|
}
|
|
16389
16528
|
const status = await remoteAwareRecordingStatus(recordingId, {
|
|
16390
16529
|
localStatus: async (id) => {
|
|
@@ -16392,7 +16531,7 @@ record2.command("status").argument("<recording-id>").action(async (recordingId)
|
|
|
16392
16531
|
return recordingStatus(id);
|
|
16393
16532
|
},
|
|
16394
16533
|
remoteStatus: (id) => client.recordingStatus(id)
|
|
16395
|
-
});
|
|
16534
|
+
}, options.apiUrl);
|
|
16396
16535
|
emit({ schemaVersion: 1, ...status }, options.pretty);
|
|
16397
16536
|
});
|
|
16398
16537
|
record2.command("cancel").argument("<recording-id>").description("Cancel an active recording and release its remote quota slot.").action(async (recordingId) => {
|
|
@@ -16411,11 +16550,25 @@ program.command("upload").argument("<video-file>").option("--title <title>").opt
|
|
|
16411
16550
|
await client.uploadVideo(upload.uploadUrl, videoFile);
|
|
16412
16551
|
const remote = commandOptions.wait === false ? null : await client.waitUntilPlayable(recording.recordingId);
|
|
16413
16552
|
if (remote) process.stderr.write("\n");
|
|
16414
|
-
emit({ schemaVersion: 1, recordingId: recording.recordingId, status: remote ? "ready" : "processing", shareUrl: remote?.shareUrl ?? null, expiresAt: remote?.expires_at ?? recording.expiresAt ?? null, diagnosticsAvailable: false }, options.pretty);
|
|
16553
|
+
emit({ schemaVersion: 1, recordingId: recording.recordingId, status: remote ? "ready" : "processing", shareUrl: remote?.shareUrl ?? null, expiresAt: remote?.expires_at ?? recording.expiresAt ?? null, diagnosticsAvailable: false, ...recordingNextStep(options.apiUrl, recording.recordingId, remote ? "ready" : "processing") }, options.pretty);
|
|
16415
16554
|
});
|
|
16416
|
-
program.command("inspect").argument("<share-url>").action(async (shareUrl) => {
|
|
16555
|
+
program.command("inspect").argument("<share-url>").option("--cursor <cursor>", "Read the next page of discussions").action(async (shareUrl, commandOptions) => {
|
|
16417
16556
|
const options = program.opts();
|
|
16418
|
-
emit(await new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl)).inspect(shareUrl), options.pretty);
|
|
16557
|
+
emit(await new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl)).inspect(shareUrl, commandOptions.cursor), options.pretty);
|
|
16558
|
+
});
|
|
16559
|
+
var comments = program.command("comments").description("Reply to pinned review discussions.");
|
|
16560
|
+
comments.command("reply").argument("<share-url>").requiredOption("--discussion <id>").requiredOption("--body <text>").option("--request-id <uuid>", "Reuse on retry to prevent duplicate replies").action(async (shareUrl, input) => {
|
|
16561
|
+
const options = program.opts();
|
|
16562
|
+
const requestId = input.requestId ?? crypto.randomUUID();
|
|
16563
|
+
process.stderr.write(`Reply request ID: ${requestId} (reuse with --request-id when retrying)
|
|
16564
|
+
`);
|
|
16565
|
+
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
16566
|
+
emit(await client.reply(shareUrl, input.discussion, input.body, requestId), options.pretty);
|
|
16567
|
+
});
|
|
16568
|
+
comments.command("edit").argument("<share-url>").requiredOption("--comment <id>").requiredOption("--body <text>").action(async (shareUrl, input) => {
|
|
16569
|
+
const options = program.opts();
|
|
16570
|
+
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
16571
|
+
emit(await client.editReply(shareUrl, input.comment, input.body), options.pretty);
|
|
16419
16572
|
});
|
|
16420
16573
|
var drafts = program.command("drafts");
|
|
16421
16574
|
drafts.command("list").action(async () => {
|
|
@@ -16440,6 +16593,7 @@ drafts.command("purge").action(async () => {
|
|
|
16440
16593
|
emit({ schemaVersion: 1, purged }, options.pretty);
|
|
16441
16594
|
});
|
|
16442
16595
|
program.hook("preAction", async (_command, actionCommand) => {
|
|
16596
|
+
if (commandPath(actionCommand).join(" ") === "record start") assertRecorderTransport();
|
|
16443
16597
|
const command = cliCommandCategory(commandPath(actionCommand));
|
|
16444
16598
|
if (!command) return;
|
|
16445
16599
|
const options = program.opts();
|