@vecteur/cli 0.8.0 → 0.9.1
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 +124 -0
- package/bin/vecteur.js +71 -19
- package/package.json +24 -3
- package/src/blocks.js +40 -0
- package/src/browser.js +18 -0
- package/src/client.js +216 -81
- package/src/commands.js +161 -0
- package/src/contract.js +18 -1
- package/src/credentials.js +56 -63
- package/src/diagnostics.js +39 -0
- package/src/download.js +33 -2
- package/src/folders.js +270 -0
- package/src/format.js +39 -29
- package/src/input.js +148 -0
- package/src/markdown.js +226 -0
- package/src/preferences.js +29 -0
- package/src/session.js +689 -0
- package/src/store.js +82 -0
- package/src/terminal.js +335 -0
- package/src/tui/view.js +319 -0
- package/src/update.js +313 -0
- package/src/vendors.js +44 -0
package/src/client.js
CHANGED
|
@@ -2,9 +2,9 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
ARTIFACT_MEDIA_TYPES, EVENT_KINDS, OWNER_CONTRACT_IDENTITY, RETIRED_EVENT_KINDS, validateAdmitted,
|
|
5
|
-
validateCustomerCostProjection, validateDeviceAuthorization, validateLimitDenial,
|
|
5
|
+
validateCliRelease, validateCustomerCostProjection, validateDeviceAuthorization, validateLimitDenial,
|
|
6
6
|
validatePatCreated, validateProject, validateRunAck, validateAccount, validateQuotaDenial,
|
|
7
|
-
RECOVERY_ACTIONS,
|
|
7
|
+
RECOVERY_ACTIONS, validateRunControl, validateSource, validateSourceOffer,
|
|
8
8
|
} from "./contract.js";
|
|
9
9
|
import { readCredentials } from "./credentials.js";
|
|
10
10
|
import { CliError } from "./errors.js";
|
|
@@ -149,11 +149,18 @@ export function shouldOpenBrowser(noBrowser, env = process.env) {
|
|
|
149
149
|
return true;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
export function delay(ms) {
|
|
153
|
-
return new Promise((resolve) =>
|
|
152
|
+
export function delay(ms, signal = undefined) {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const finish = () => { signal?.removeEventListener("abort", cancel); resolve(); };
|
|
155
|
+
const timer = setTimeout(finish, ms);
|
|
156
|
+
const cancel = () => { clearTimeout(timer); signal?.removeEventListener("abort", cancel);
|
|
157
|
+
reject(new CliError("request_cancelled", "Waiting was cancelled")); };
|
|
158
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
159
|
+
if (signal?.aborted) cancel();
|
|
160
|
+
});
|
|
154
161
|
}
|
|
155
162
|
|
|
156
|
-
export async function waitForDeviceAuthorization(client, grant, { sleep = delay, now = Date.now } = {}) {
|
|
163
|
+
export async function waitForDeviceAuthorization(client, grant, { sleep = delay, now = Date.now, signal } = {}) {
|
|
157
164
|
if (!validateDeviceAuthorization(grant)) {
|
|
158
165
|
throw new CliError("device_authorization_invalid", "Server returned malformed owner data");
|
|
159
166
|
}
|
|
@@ -163,16 +170,16 @@ export async function waitForDeviceAuthorization(client, grant, { sleep = delay,
|
|
|
163
170
|
throw new CliError("device_expired", "This code has expired");
|
|
164
171
|
}
|
|
165
172
|
try {
|
|
166
|
-
const polled = await client.devicePoll(grant.device_code);
|
|
173
|
+
const polled = await client.devicePoll(grant.device_code, signal);
|
|
167
174
|
if (validatePatCreated(polled)) return polled;
|
|
168
175
|
if (polled && polled.status === "authorization_pending") {
|
|
169
|
-
await sleep(intervalMs);
|
|
176
|
+
await sleep(intervalMs, signal);
|
|
170
177
|
continue;
|
|
171
178
|
}
|
|
172
179
|
throw new CliError("device_poll_invalid", "Server returned malformed owner data");
|
|
173
180
|
} catch (error) {
|
|
174
181
|
if (error instanceof CliError && error.code === "device_slow_down") {
|
|
175
|
-
await sleep(intervalMs);
|
|
182
|
+
await sleep(intervalMs, signal);
|
|
176
183
|
continue;
|
|
177
184
|
}
|
|
178
185
|
throw error;
|
|
@@ -228,7 +235,10 @@ export function validateEvidenceDag(value, runId) {
|
|
|
228
235
|
return value;
|
|
229
236
|
}
|
|
230
237
|
|
|
231
|
-
export async function parseSse(response, runId, onEvent = null) {
|
|
238
|
+
export async function parseSse(response, runId, onEvent = null, startSeq = 0) {
|
|
239
|
+
if (!Number.isSafeInteger(startSeq) || startSeq < 0 || startSeq >= MAX_EVENTS) {
|
|
240
|
+
throw new CliError("cursor_invalid", "Event cursor is outside the supported history bound");
|
|
241
|
+
}
|
|
232
242
|
if (!response.body) throw new CliError("sse_invalid", "Event response has no body");
|
|
233
243
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
234
244
|
let buffer = "";
|
|
@@ -237,59 +247,65 @@ export async function parseSse(response, runId, onEvent = null) {
|
|
|
237
247
|
let terminalSeen = false;
|
|
238
248
|
const reader = response.body.getReader();
|
|
239
249
|
const signal = response["vecteurAbortSignal"];
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
rejectAbort
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
250
|
+
try {
|
|
251
|
+
while (true) {
|
|
252
|
+
let result;
|
|
253
|
+
if (signal) {
|
|
254
|
+
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
255
|
+
let rejectAbort;
|
|
256
|
+
const aborted = new Promise((_, reject) => {
|
|
257
|
+
rejectAbort = () => reject(new DOMException("Aborted", "AbortError"));
|
|
258
|
+
signal.addEventListener("abort", rejectAbort, { once: true });
|
|
259
|
+
});
|
|
260
|
+
try { result = await Promise.race([reader.read(), aborted]); }
|
|
261
|
+
finally { signal.removeEventListener("abort", rejectAbort); }
|
|
262
|
+
} else result = await reader.read();
|
|
263
|
+
if (result.done) break;
|
|
264
|
+
const chunk = result.value;
|
|
265
|
+
response[RESPONSE_ACTIVITY]?.();
|
|
266
|
+
total += chunk.byteLength;
|
|
267
|
+
if (total > MAX_RESPONSE_BYTES) throw new CliError("response_too_large", "Event response exceeds limit");
|
|
268
|
+
try { buffer += decoder.decode(chunk, { stream: true }); }
|
|
269
|
+
catch { throw new CliError("sse_invalid", "Event stream is not UTF-8"); }
|
|
270
|
+
buffer = buffer.replace(/\r\n/g, "\n");
|
|
271
|
+
let boundary;
|
|
272
|
+
while ((boundary = buffer.indexOf("\n\n")) >= 0) {
|
|
273
|
+
const frame = buffer.slice(0, boundary).replace(/\r/g, "");
|
|
274
|
+
if (Buffer.byteLength(frame, "utf8") > MAX_FRAME_BYTES) {
|
|
275
|
+
throw new CliError("sse_invalid", "SSE frame exceeds limit");
|
|
276
|
+
}
|
|
277
|
+
buffer = buffer.slice(boundary + 2);
|
|
278
|
+
if (!frame || frame.startsWith(":")) continue;
|
|
279
|
+
if (terminalSeen) throw new CliError("terminal_invalid", "Event stream contains data after terminal");
|
|
280
|
+
const lines = frame.split("\n");
|
|
281
|
+
if (lines.length !== 3 || !lines[0].startsWith("id:") ||
|
|
282
|
+
!lines[1].startsWith("event:") || !lines[2].startsWith("data:")) {
|
|
283
|
+
throw new CliError("sse_invalid", "Event stream contains malformed framing");
|
|
284
|
+
}
|
|
285
|
+
const id = lines[0].slice(3).trim();
|
|
286
|
+
const kind = lines[1].slice(6).trim();
|
|
287
|
+
let value;
|
|
288
|
+
try { value = JSON.parse(lines[2].slice(5).trimStart()); }
|
|
289
|
+
catch { throw new CliError("event_invalid", "Event stream contains malformed JSON"); }
|
|
290
|
+
if (id !== String(startSeq + events.length) || kind !== value.kind) {
|
|
291
|
+
throw new CliError("sse_invalid", "SSE metadata disagrees with event data");
|
|
292
|
+
}
|
|
293
|
+
events.push(validateEvent(refuseSecretValue(value), runId, startSeq + events.length));
|
|
294
|
+
await onEvent?.(events.at(-1));
|
|
295
|
+
if (startSeq + events.length > MAX_EVENTS) throw new CliError("response_too_large", "Event count exceeds limit");
|
|
296
|
+
terminalSeen = events.at(-1).kind === "terminal";
|
|
272
297
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
let value;
|
|
276
|
-
try { value = JSON.parse(lines[2].slice(5).trimStart()); }
|
|
277
|
-
catch { throw new CliError("event_invalid", "Event stream contains malformed JSON"); }
|
|
278
|
-
if (id !== String(events.length) || kind !== value.kind) {
|
|
279
|
-
throw new CliError("sse_invalid", "SSE metadata disagrees with event data");
|
|
298
|
+
if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
|
|
299
|
+
throw new CliError("sse_invalid", "SSE partial frame exceeds limit");
|
|
280
300
|
}
|
|
281
|
-
events.push(validateEvent(refuseSecretValue(value), runId, events.length));
|
|
282
|
-
await onEvent?.(events.at(-1));
|
|
283
|
-
if (events.length > MAX_EVENTS) throw new CliError("response_too_large", "Event count exceeds limit");
|
|
284
|
-
terminalSeen = events.at(-1).kind === "terminal";
|
|
285
|
-
}
|
|
286
|
-
if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
|
|
287
|
-
throw new CliError("sse_invalid", "SSE partial frame exceeds limit");
|
|
288
301
|
}
|
|
302
|
+
buffer += decoder.decode();
|
|
303
|
+
if (buffer.trim()) throw new CliError("sse_invalid", "Event stream ended with a partial frame");
|
|
304
|
+
return events;
|
|
305
|
+
} finally {
|
|
306
|
+
try { await reader.cancel(); } catch { /* Preserve the read/validation error from an errored stream. */ }
|
|
307
|
+
finally { reader.releaseLock(); }
|
|
289
308
|
}
|
|
290
|
-
buffer += decoder.decode();
|
|
291
|
-
if (buffer.trim()) throw new CliError("sse_invalid", "Event stream ended with a partial frame");
|
|
292
|
-
return events;
|
|
293
309
|
}
|
|
294
310
|
|
|
295
311
|
export class VecteurClient {
|
|
@@ -298,7 +314,12 @@ export class VecteurClient {
|
|
|
298
314
|
this.fetchImpl = fetchImpl;
|
|
299
315
|
}
|
|
300
316
|
|
|
301
|
-
async request(method, path, { body, idempotencyKey, accept, acceptEncoding, signal
|
|
317
|
+
async request(method, path, { body, rawBody, contentType, idempotencyKey, accept, acceptEncoding, signal,
|
|
318
|
+
lastEventId, anonymousReadiness = false } = {}) {
|
|
319
|
+
if (anonymousReadiness && (method !== "GET" || path !== "/api/ready" || body !== undefined || rawBody !== undefined)) {
|
|
320
|
+
throw new CliError("request_invalid", "Anonymous compatibility inspection is restricted to readiness");
|
|
321
|
+
}
|
|
322
|
+
if (body !== undefined && rawBody !== undefined) throw new CliError("request_invalid", "Choose JSON or raw bytes");
|
|
302
323
|
const controller = new AbortController();
|
|
303
324
|
const cancel = () => controller.abort();
|
|
304
325
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
@@ -331,14 +352,16 @@ export class VecteurClient {
|
|
|
331
352
|
redirect: "manual",
|
|
332
353
|
signal: controller.signal,
|
|
333
354
|
headers: {
|
|
334
|
-
"X-Vecteur-Client": OWNER_CONTRACT_IDENTITY,
|
|
335
|
-
...(this.config.token ? { Authorization: `Bearer ${this.config.token}` } : {}),
|
|
355
|
+
...(!anonymousReadiness ? { "X-Vecteur-Client": OWNER_CONTRACT_IDENTITY } : {}),
|
|
356
|
+
...(!anonymousReadiness && this.config.token ? { Authorization: `Bearer ${this.config.token}` } : {}),
|
|
336
357
|
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
358
|
+
...(rawBody === undefined ? {} : { "Content-Type": contentType ?? "application/octet-stream" }),
|
|
337
359
|
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
|
|
338
360
|
...(accept ? { Accept: accept } : {}),
|
|
339
361
|
...(acceptEncoding ? { "Accept-Encoding": acceptEncoding } : {}),
|
|
362
|
+
...(lastEventId === undefined ? {} : { "Last-Event-ID": String(lastEventId) }),
|
|
340
363
|
},
|
|
341
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
364
|
+
body: rawBody ?? (body === undefined ? undefined : JSON.stringify(body)),
|
|
342
365
|
});
|
|
343
366
|
const abortBody = () => response.body?.cancel().catch(() => {});
|
|
344
367
|
controller.signal.addEventListener("abort", abortBody, { once: true });
|
|
@@ -362,6 +385,7 @@ export class VecteurClient {
|
|
|
362
385
|
let code = `http_${response.status}`;
|
|
363
386
|
let ownerMessage = "";
|
|
364
387
|
let refused = null;
|
|
388
|
+
let admission = null;
|
|
365
389
|
try {
|
|
366
390
|
// THE GUARD RUNS ON REFUSALS TOO. It sat on the success path alone, so a 401 whose
|
|
367
391
|
// body carried `vct_…` was read for its `error.code` and reported as `pat_revoked`:
|
|
@@ -374,6 +398,8 @@ export class VecteurClient {
|
|
|
374
398
|
if (typeof candidate === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(candidate)) {
|
|
375
399
|
code = candidate;
|
|
376
400
|
}
|
|
401
|
+
// Only the owner's explicit marker makes a refusal final for its submission key.
|
|
402
|
+
if (error.admission === "refused") admission = "refused";
|
|
377
403
|
if (candidate === "project_quota_exhausted") {
|
|
378
404
|
if (!validateQuotaDenial(error.quota) || typeof error.message !== "string"
|
|
379
405
|
|| !error.message.length || /[\r\n\0]/.test(error.message)) {
|
|
@@ -423,10 +449,12 @@ export class VecteurClient {
|
|
|
423
449
|
].includes(error.code)) refused = error;
|
|
424
450
|
}
|
|
425
451
|
if (refused) throw refused;
|
|
426
|
-
|
|
452
|
+
const failure = new CliError(
|
|
427
453
|
code,
|
|
428
454
|
ownerMessage || `Public API refused request with HTTP ${response.status}`,
|
|
429
455
|
);
|
|
456
|
+
if (admission) failure.admission = admission;
|
|
457
|
+
throw failure;
|
|
430
458
|
}
|
|
431
459
|
return response;
|
|
432
460
|
} catch (error) {
|
|
@@ -451,9 +479,39 @@ export class VecteurClient {
|
|
|
451
479
|
return value;
|
|
452
480
|
}
|
|
453
481
|
|
|
454
|
-
async
|
|
482
|
+
async readiness(signal = undefined) {
|
|
483
|
+
const response = await this.request("GET", "/api/ready", { signal, anonymousReadiness: true });
|
|
484
|
+
try {
|
|
485
|
+
const body = refuseSecretValue(parseOwnerJson(await boundedText(response)));
|
|
486
|
+
if (body.status !== "ready") throw new CliError("origin_not_ready", "Origin did not report readiness");
|
|
487
|
+
const contract = response.headers.get("X-Vecteur-Contract");
|
|
488
|
+
if (!/^api-sha256:[a-f0-9]{64};run-wire-sha256:[a-f0-9]{64}$/.test(contract)) throw new CliError("origin_not_vecteur", "Invalid origin contract");
|
|
489
|
+
const cli = body.cli ?? null;
|
|
490
|
+
if (cli && (!validateCliRelease(cli) || !/^\d+\.\d+\.\d+$/.test(cli.version)
|
|
491
|
+
|| cli.contract !== contract || (cli.integrity !== null && !/^sha512-[A-Za-z0-9+/]{86}==$/.test(cli.integrity)))) {
|
|
492
|
+
throw new CliError("release_invalid", "Origin returned invalid CLI release metadata");
|
|
493
|
+
}
|
|
494
|
+
const compatible = contract === OWNER_CONTRACT_IDENTITY;
|
|
495
|
+
let requiredVersion = cli?.version ?? null;
|
|
496
|
+
if (!compatible && !cli) {
|
|
497
|
+
// Older releases expose their pin only in the bounded 426 recovery. Inspect it
|
|
498
|
+
// through the same read-only route, with client identity but NEVER a credential.
|
|
499
|
+
const publicClient = new VecteurClient({ ...this.config, token: undefined }, this.fetchImpl);
|
|
500
|
+
try {
|
|
501
|
+
const legacy = await publicClient.request("GET", "/api/ready", { signal });
|
|
502
|
+
await legacy.body?.cancel(); legacy[RELEASE_TIMEOUT]?.();
|
|
503
|
+
} catch (error) {
|
|
504
|
+
if (error.code !== "client_outdated") throw error;
|
|
505
|
+
requiredVersion = /This origin requires Vecteur CLI (\d+\.\d+\.\d+)\./.exec(error.message)?.[1] ?? null;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { contract, compatible, cli, requiredVersion };
|
|
509
|
+
} finally { response[RELEASE_TIMEOUT]?.(); }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async projectCreate(name, workspaceId, signal = undefined, idempotencyKey = undefined) {
|
|
455
513
|
const value = await this.#json("POST", "/api/projects", {
|
|
456
|
-
body: { name, workspace_id: workspaceId }, signal,
|
|
514
|
+
body: { name, workspace_id: workspaceId }, signal, idempotencyKey,
|
|
457
515
|
});
|
|
458
516
|
if (!validateProject(value) || value.workspace_id !== workspaceId) {
|
|
459
517
|
throw new CliError("project_invalid", "Server returned malformed project data");
|
|
@@ -469,6 +527,65 @@ export class VecteurClient {
|
|
|
469
527
|
return value;
|
|
470
528
|
}
|
|
471
529
|
|
|
530
|
+
async projectRename(projectId, name, signal = undefined) {
|
|
531
|
+
const value = await this.#json("PATCH", `/api/projects/${encodeURIComponent(projectId)}`, { body: { name }, signal });
|
|
532
|
+
if (!validateProject(value) || value.id !== projectId) throw new CliError("project_invalid", "Malformed renamed Project");
|
|
533
|
+
return value;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Body and key are frozen by the caller before any admission I/O. */
|
|
537
|
+
async runAdmit(projectId, body, idempotencyKey, signal = undefined) {
|
|
538
|
+
const value = await this.#json("POST", `/api/projects/${encodeURIComponent(projectId)}/runs`, {
|
|
539
|
+
body, idempotencyKey, signal,
|
|
540
|
+
});
|
|
541
|
+
if (!validateRunAck(value)) throw new CliError("run_ack_invalid", "Server returned malformed run acknowledgement");
|
|
542
|
+
return value;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async runCancel(runId, signal = undefined) {
|
|
546
|
+
const value = await this.#json("POST", `/api/runs/${encodeURIComponent(runId)}/cancel`, { signal });
|
|
547
|
+
if (!validateRunAck(value) || value.run_id !== runId) throw new CliError("run_ack_invalid", "Malformed cancellation acknowledgement");
|
|
548
|
+
return value;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async runControl(runId, control, signal = undefined) {
|
|
552
|
+
const value = await this.#json("POST", `/api/runs/${encodeURIComponent(runId)}/controls`, { body: control, signal });
|
|
553
|
+
if (!validateRunControl(value) || value.id !== control.id) throw new CliError("control_invalid", "Malformed control acknowledgement");
|
|
554
|
+
return value;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async clarificationAnswer(runId, questionId, answers, signal = undefined) {
|
|
558
|
+
const value = await this.#json("POST", `/api/runs/${encodeURIComponent(runId)}/clarifications/${encodeURIComponent(questionId)}/answer`,
|
|
559
|
+
{ body: { answers }, signal });
|
|
560
|
+
if (!validateRunAck(value) || value.run_id !== runId) throw new CliError("run_ack_invalid", "Malformed answer acknowledgement");
|
|
561
|
+
return value;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async inputAdd(projectId, bytes, contentType, signal = undefined) {
|
|
565
|
+
const value = await this.#json("POST", `/api/projects/${encodeURIComponent(projectId)}/inputs`, {
|
|
566
|
+
rawBody: bytes, contentType, signal,
|
|
567
|
+
});
|
|
568
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
569
|
+
if (!validateSource(value) || value.sha256 !== digest || value.cas_ref !== `sha256:${digest}`
|
|
570
|
+
|| value.size !== bytes.byteLength) throw new CliError("source_invalid", "Uploaded source identity differs from the selected bytes");
|
|
571
|
+
if (value.offer) this.validateOffer(projectId, value, value.offer);
|
|
572
|
+
return value;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async inputOffer(projectId, source, signal = undefined) {
|
|
576
|
+
const query = new URLSearchParams({ cas_ref: source.cas_ref, acquisition_ref: source.acquisition_ref });
|
|
577
|
+
const offer = await this.#json("GET", `/api/projects/${encodeURIComponent(projectId)}/inputs/extraction-offer?${query}`, { signal });
|
|
578
|
+
return this.validateOffer(projectId, source, offer);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
validateOffer(projectId, source, offer) {
|
|
582
|
+
if (!validateSourceOffer(offer) || offer.project_id !== projectId
|
|
583
|
+
|| offer.source_handle.cas_ref !== source.cas_ref || offer.source_handle.acquisition_ref !== source.acquisition_ref) {
|
|
584
|
+
throw new CliError("source_offer_invalid", "Extraction offer differs from the selected source");
|
|
585
|
+
}
|
|
586
|
+
return offer;
|
|
587
|
+
}
|
|
588
|
+
|
|
472
589
|
async deviceMint(signal = undefined) {
|
|
473
590
|
const value = await this.#json("POST", "/api/auth/device", { body: {}, signal });
|
|
474
591
|
if (!validateDeviceAuthorization(value)) {
|
|
@@ -612,21 +729,30 @@ export class VecteurClient {
|
|
|
612
729
|
* Project, so it refuses rather than let the caller guess null.
|
|
613
730
|
*/
|
|
614
731
|
async projectNewestRun(projectId, signal = undefined) {
|
|
615
|
-
const page = await this
|
|
616
|
-
`/api/projects/${encodeURIComponent(projectId)}/runs?limit=1`, { signal });
|
|
732
|
+
const page = await this.projectHistory(projectId, { limit: 1, signal });
|
|
617
733
|
const newest = page?.runs?.[0];
|
|
618
|
-
if (!exactKeys(page, ["runs", "next_before"]) || !Array.isArray(page.runs) || page.runs.length > 1
|
|
619
|
-
|| (page.next_before !== null && typeof page.next_before !== "string")
|
|
620
|
-
|| (newest !== undefined && (newest?.request?.project_id !== projectId
|
|
621
|
-
|| !validateAdmitted(newest.response) || !isStateHash(newest.response.state_hash)))) {
|
|
622
|
-
throw new CliError("project_runs_invalid", "Server returned malformed Project run history");
|
|
623
|
-
}
|
|
624
734
|
if (!newest && page.next_before !== null) {
|
|
625
735
|
throw new CliError("project_head_hidden",
|
|
626
736
|
`the newest Run in Project ${projectId} is not visible to this login; nothing was submitted; `
|
|
627
737
|
+ "next: continue in the web app, or pass --parent <state-hash>");
|
|
628
738
|
}
|
|
629
|
-
|
|
739
|
+
// Client-local presentation overlay, as in the web transcript. Never write this
|
|
740
|
+
// timestamp into the immutable RunWire response or send it back to the server.
|
|
741
|
+
return newest ? { ...newest.response, accepted_at: newest.accepted_at } : null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async projectHistory(projectId, { limit = 25, before, signal } = {}) {
|
|
745
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 25) throw new CliError("limit_invalid", "History page must contain 1–25 Runs");
|
|
746
|
+
const query = new URLSearchParams({ limit: String(limit), ...(before ? { before } : {}) });
|
|
747
|
+
const page = await this.#json("GET", `/api/projects/${encodeURIComponent(projectId)}/runs?${query}`, { signal });
|
|
748
|
+
if (!exactKeys(page, ["runs", "next_before"]) || !Array.isArray(page.runs) || page.runs.length > limit
|
|
749
|
+
|| (page.next_before !== null && typeof page.next_before !== "string")
|
|
750
|
+
|| page.runs.some(row => row?.request?.project_id !== projectId || typeof row.request.ask !== "string"
|
|
751
|
+
|| !validateAdmitted(row.response) || !isStateHash(row.response.state_hash))) {
|
|
752
|
+
throw new CliError("project_runs_invalid", "Server returned malformed Project run history");
|
|
753
|
+
}
|
|
754
|
+
for (const row of page.runs) row.response.events.forEach((event, seq) => validateEvent(event, row.response.run_id, seq));
|
|
755
|
+
return page;
|
|
630
756
|
}
|
|
631
757
|
|
|
632
758
|
/**
|
|
@@ -642,11 +768,8 @@ export class VecteurClient {
|
|
|
642
768
|
: parentStateHash;
|
|
643
769
|
let value;
|
|
644
770
|
try {
|
|
645
|
-
value = await this
|
|
646
|
-
|
|
647
|
-
idempotencyKey: randomUUID(),
|
|
648
|
-
signal,
|
|
649
|
-
});
|
|
771
|
+
value = await this.runAdmit(projectId,
|
|
772
|
+
{ ask, attachments: [], parent_state_hash: parent, ui_context: null }, randomUUID(), signal);
|
|
650
773
|
} catch (error) {
|
|
651
774
|
if (error instanceof CliError && (error.code === "state_fork" || error.code === "state_busy")) {
|
|
652
775
|
throw await this.#admissionRefused(error.code, projectId, parent, signal);
|
|
@@ -693,10 +816,20 @@ export class VecteurClient {
|
|
|
693
816
|
return value;
|
|
694
817
|
}
|
|
695
818
|
|
|
696
|
-
async runEvents(runId, onEvent = null, signal = undefined) {
|
|
819
|
+
async runEvents(runId, onEvent = null, signal = undefined, priorEvents = []) {
|
|
820
|
+
priorEvents.forEach((event, seq) => validateEvent(event, runId, seq));
|
|
821
|
+
if (priorEvents.length > MAX_EVENTS) throw new CliError("response_too_large", "Event count exceeds limit");
|
|
822
|
+
if (priorEvents.at(-1)?.kind === "terminal") {
|
|
823
|
+
const direct = await this.runGet(runId, signal);
|
|
824
|
+
if (JSON.stringify(priorEvents) !== JSON.stringify(direct.events)) {
|
|
825
|
+
throw new CliError("record_mismatch", "Event stream disagrees with direct replay");
|
|
826
|
+
}
|
|
827
|
+
return direct;
|
|
828
|
+
}
|
|
697
829
|
const response = await this.request("GET", `/api/runs/${encodeURIComponent(runId)}/events`, {
|
|
698
830
|
accept: "text/event-stream",
|
|
699
831
|
signal,
|
|
832
|
+
lastEventId: priorEvents.length ? priorEvents.length - 1 : undefined,
|
|
700
833
|
});
|
|
701
834
|
let events;
|
|
702
835
|
try {
|
|
@@ -704,12 +837,14 @@ export class VecteurClient {
|
|
|
704
837
|
if (type !== "text/event-stream") {
|
|
705
838
|
throw new CliError("sse_invalid", "Event route is not an SSE stream");
|
|
706
839
|
}
|
|
707
|
-
events = await parseSse(response, runId, onEvent);
|
|
840
|
+
events = [...priorEvents, ...await parseSse(response, runId, onEvent, priorEvents.length)];
|
|
708
841
|
} catch (error) {
|
|
709
842
|
if (error?.name === "AbortError") {
|
|
843
|
+
if (signal?.aborted) throw new CliError("request_cancelled", "Following the Run was detached");
|
|
710
844
|
throw new CliError("request_timeout",
|
|
711
845
|
`origin ${this.config.origin}; cause=timeout; next: check the network and retry`);
|
|
712
846
|
}
|
|
847
|
+
if (error instanceof TypeError) throw new CliError("request_failed", "Event connection lost; reconnect to the same Run");
|
|
713
848
|
throw error;
|
|
714
849
|
} finally {
|
|
715
850
|
response[RELEASE_TIMEOUT]?.();
|
package/src/commands.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
import { runView } from "./blocks.js";
|
|
3
|
+
import { openBrowser } from "./browser.js";
|
|
4
|
+
import { diagnose } from "./diagnostics.js";
|
|
5
|
+
import { humanCost } from "./format.js";
|
|
6
|
+
import { preferences, savePreferences } from "./preferences.js";
|
|
7
|
+
|
|
8
|
+
export const SHORTCUTS = `Enter sends when ready; while running it offers steering or keeps the draft.
|
|
9
|
+
Alt+Enter / Ctrl+J newline; backslash then Enter is the portable fallback.
|
|
10
|
+
Paste never sends. Tab completes commands; arrows select; Esc closes a menu.
|
|
11
|
+
Home/End or Ctrl+A/E: line boundaries. Alt+B/F: words. Ctrl+W: cut word.
|
|
12
|
+
Ctrl+K/U: cut line tail/head. Ctrl+Y: restore cut. Ctrl+_: undo edit.
|
|
13
|
+
Up/Down: multiline movement, then history at its boundary. Ctrl+R: search history.
|
|
14
|
+
? on empty input: shortcuts. Ctrl+O: step details. Ctrl+L: redraw.
|
|
15
|
+
Ctrl+C with text clears it (Ctrl+_ restores). Empty idle Ctrl+C twice exits.
|
|
16
|
+
Esc twice or empty Ctrl+C twice requests cancellation of the same cloud Run.
|
|
17
|
+
Ctrl+D on empty input saves and detaches; the cloud Run continues. /quit also exits.`;
|
|
18
|
+
|
|
19
|
+
const rows = [];
|
|
20
|
+
const add = (name, description, run, options = {}) => rows.push({ name, description, run, ...options });
|
|
21
|
+
add("help", "Find commands and their purpose", (s, query) => s.message(help(query, s)), { argument: "[query]" });
|
|
22
|
+
add("shortcuts", "All editing, navigation and Run keys", s => s.message(SHORTCUTS));
|
|
23
|
+
add("status", "Account, Project, connection and current state", s => s.message(s.statusDetails()));
|
|
24
|
+
add("login", "Sign in through your browser", s => s.initialize({ login: true }));
|
|
25
|
+
add("logout", "Sign out here; preserve account drafts", s => s.logout(), { requires: "login" });
|
|
26
|
+
add("offline", "Draft without any network activity", s => {
|
|
27
|
+
if (s.busy && !s.loginController) throw new CliError("session_busy", "Wait for this action to finish before disconnecting; your draft is kept");
|
|
28
|
+
s.workOffline();
|
|
29
|
+
});
|
|
30
|
+
add("resume", "Reconnect or reconcile an interrupted ask", s => s.resume());
|
|
31
|
+
add("quit", "Save draft and detach; cloud Run continues", s => s.close(), { aliases: ["exit"] });
|
|
32
|
+
add("drafts", "Recover a saved draft", s => s.recoverDraft());
|
|
33
|
+
add("clear", "Clear this draft; Ctrl+_ restores the edit", s => { s.lastClearedDraft = s.draft; s.setDraft(""); });
|
|
34
|
+
add("mode", "Lead, model, execution location and payer", s => s.message(s.modeDetails()));
|
|
35
|
+
add("model", "Choose the next Run's server model or route", s => s.modelChoice(), { requires: "login" });
|
|
36
|
+
add("usage", "Read the Project's server-authored usage", async s => {
|
|
37
|
+
if (!s.project) throw new CliError("project_unavailable", "Choose a Project first with /project");
|
|
38
|
+
const client = s.client, project = s.project;
|
|
39
|
+
const value = await client.customerCost(project.id);
|
|
40
|
+
if (s.client === client && s.project === project) s.message(humanCost(value));
|
|
41
|
+
}, { requires: "login" });
|
|
42
|
+
add("settings", "Plain display, reduced motion and updates", async s => {
|
|
43
|
+
const key = await s.choose("Settings", [
|
|
44
|
+
{ label: "Display: rich or plain", value: "plain" }, { label: "Reduced motion", value: "reducedMotion" },
|
|
45
|
+
{ label: "Automatic updates for this installation", value: "updates" },
|
|
46
|
+
]);
|
|
47
|
+
if (key === "updates") { const { updatePreference } = await import("./update.js"); return updatePreference(s); }
|
|
48
|
+
const value = await s.choose(key === "plain" ? "Display" : "Reduced motion", [
|
|
49
|
+
{ label: key === "plain" ? "Rich terminal" : "Enabled", value: key !== "plain" },
|
|
50
|
+
{ label: key === "plain" ? "Plain / screen reader" : "Disabled", value: key === "plain" },
|
|
51
|
+
]);
|
|
52
|
+
savePreferences({ [key]: value }, s.env); s.emit("preferences", preferences(s.env)); s.message("Preference saved.");
|
|
53
|
+
});
|
|
54
|
+
add("update", "Check or update this exact installation", async (s, argument) => {
|
|
55
|
+
if (argument && argument !== "check") throw new CliError("usage_invalid", "Use /update or /update check");
|
|
56
|
+
const { interactiveUpdate } = await import("./update.js"); return interactiveUpdate(s, { check: argument === "check" });
|
|
57
|
+
}, { argument: "[check]" });
|
|
58
|
+
add("project", "Choose a Project or start a new study draft", async (s, argument) => {
|
|
59
|
+
if (argument === "list") {
|
|
60
|
+
const client = s.client, account = s.account;
|
|
61
|
+
const projects = await client.projectList();
|
|
62
|
+
if (s.client === client && s.account === account) s.message(projects.map(p => `${p.name} · ${p.role} · ${p.id}`).join("\n") || "No Projects yet.");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (argument) throw new CliError("usage_invalid", "Use /project or /project list");
|
|
66
|
+
const action = await s.choose("Project", [
|
|
67
|
+
{ label: "Open an existing Project without linking", value: "open" },
|
|
68
|
+
{ label: "Link an existing Project to this folder", value: "link" },
|
|
69
|
+
{ label: "Start a new study on next send", value: "new" },
|
|
70
|
+
]);
|
|
71
|
+
if (action !== "new") return s.link({ persist: action === "link" });
|
|
72
|
+
if (s.run?.open || s.registry.state().pendingRun || s.registry.state().pendingCreate) throw new CliError("run_busy", "Resolve or finish the current Run before starting a new study");
|
|
73
|
+
s.registry.unlink(); s.binding = null; s.project = null; s.run = null; s.attachments = []; s.status = "New Project on first send"; s.change();
|
|
74
|
+
}, { argument: "[list]", requires: "login" });
|
|
75
|
+
add("link", "Link an existing Project to this folder", s => s.link(), { requires: "login" });
|
|
76
|
+
add("unlink", "Remove this folder's Project binding", s => {
|
|
77
|
+
if (s.run?.open || s.busy || s.registry.state().pendingRun || s.registry.state().pendingCreate) throw new CliError("run_busy", "Resolve or finish the current Run before unlinking");
|
|
78
|
+
s.followController?.abort(); s.generation++; s.registry.unlink(); s.binding = null; s.project = null; s.run = null; s.status = "Folder unlinked"; s.change();
|
|
79
|
+
}, { requires: "login" });
|
|
80
|
+
add("history", "Read earlier Runs in this Project", s => s.history(), { requires: "login" });
|
|
81
|
+
add("name", "Name the Project", (s, value) => s.transition(async () => {
|
|
82
|
+
if (s.project) { s.requireLogin(); s.project = await s.client.projectRename(s.project.id, value); } else s.projectName = value;
|
|
83
|
+
s.change();
|
|
84
|
+
}), { argument: "<name>", required: true });
|
|
85
|
+
add("attach", "Select one file; preview its upload destination", async (s, path) => {
|
|
86
|
+
if (!path) path = await s.choose("File path (only this file is read; blank cancels)", [], { typed: true });
|
|
87
|
+
if (path.trim()) return s.attach(path.replace(/^(['"])(.*)\1$/, "$2"));
|
|
88
|
+
}, { argument: "<path>" });
|
|
89
|
+
add("clear-files", "Remove pending attachments", s => {
|
|
90
|
+
if (s.busy || s.registry?.state().pendingRun) throw new CliError("session_busy", "Resolve the pending action before clearing files");
|
|
91
|
+
s.attachments = []; s.change();
|
|
92
|
+
});
|
|
93
|
+
add("files", "Selected inputs and downloadable results", s => s.message([
|
|
94
|
+
...s.attachments.map(item => `Pending: ${item.path} (${item.bytes.length} bytes; not uploaded)`),
|
|
95
|
+
...runView(s.run).artifacts.map((item, i) => `${i + 1}. ${item.filename} · ${item.kind} · /download ${i + 1} · /web`),
|
|
96
|
+
].join("\n") || "No selected files or artifacts."));
|
|
97
|
+
add("download", "Save and verify a result file", (s, argument) => {
|
|
98
|
+
const match = /^(\d+)(?:\s+(.+))?$/.exec(argument);
|
|
99
|
+
if (!match) throw new CliError("usage_invalid", "Use /download <artifact number> [output path]");
|
|
100
|
+
return s.download(Number(match[1]), match[2]);
|
|
101
|
+
}, { argument: "<number> [path]", required: true, requires: "login" });
|
|
102
|
+
add("export", "Export the answer, Run record and verified files to a new folder", (s, path) => s.exportRun(path || undefined), { argument: "[folder]", requires: "login" });
|
|
103
|
+
add("copy", "Copy the exact answer or save it to a new file", (s, path) => s.copy(path || undefined), { argument: "[path]" });
|
|
104
|
+
add("answer", "Answer the current clarification", s => s.answer(), { requires: "question" });
|
|
105
|
+
add("steer", "Send an instruction to the running Run", (s, text) => s.steer(text), { argument: "<instruction>", required: true, requires: "run" });
|
|
106
|
+
add("answer-now", "Ask the running Run to conclude", s => s.steer(""), { requires: "run" });
|
|
107
|
+
add("cancel", "Request cancellation; keep completed work", s => s.cancel(), { requires: "run" });
|
|
108
|
+
add("details", "Read the current or numbered step", (s, argument) => {
|
|
109
|
+
const steps = runView(s.run).steps;
|
|
110
|
+
const event = argument ? steps[Number(argument) - 1] : steps.at(-1);
|
|
111
|
+
s.message(event ? JSON.stringify(event.payload, null, 2) : "No step details yet.");
|
|
112
|
+
}, { argument: "[number]" });
|
|
113
|
+
add("handoff", "Open your Claude Code or Codex here with Vecteur attached", (s, vendor) => {
|
|
114
|
+
if (!["claude", "codex"].includes(vendor)) throw new CliError("usage_invalid", "Use /handoff claude or /handoff codex");
|
|
115
|
+
if (s.busy) throw new CliError("session_busy", "Wait for this action to finish before handing off");
|
|
116
|
+
s.emit("handoff", vendor);
|
|
117
|
+
}, { argument: "<claude|codex>", required: true });
|
|
118
|
+
add("web", "Open this Project in the browser", s => {
|
|
119
|
+
const url = s.project ? `${s.origin}/dashboard/${encodeURIComponent(s.project.id)}` : s.origin;
|
|
120
|
+
s.message(url); if (!s.noBrowser && !s.offline) void openBrowser(url);
|
|
121
|
+
});
|
|
122
|
+
add("doctor", "Diagnose connection and installation", async s => {
|
|
123
|
+
const client = s.client, account = s.account;
|
|
124
|
+
const result = await diagnose({ env: s.env, origin: s.origin, client, offline: s.offline });
|
|
125
|
+
if (s.client === client && s.account === account) s.message(result.text);
|
|
126
|
+
});
|
|
127
|
+
export const COMMANDS = Object.freeze(rows.map(Object.freeze));
|
|
128
|
+
export function unavailable(row, session) {
|
|
129
|
+
if (!row.requires) return "";
|
|
130
|
+
if (session.offline) return "Offline — /resume";
|
|
131
|
+
if (!session.account) return "Sign in — /login";
|
|
132
|
+
if (row.requires === "login") return "";
|
|
133
|
+
if (session.project?.role === "VIEWER") return "Read-only Project";
|
|
134
|
+
if (row.requires === "question") return runView(session.run).question ? "" : "No pending question";
|
|
135
|
+
return session.run?.open ? "" : "No running Run";
|
|
136
|
+
}
|
|
137
|
+
export function completions(query = "", session) {
|
|
138
|
+
const text = query.replace(/^\//, "").toLowerCase();
|
|
139
|
+
return COMMANDS.filter(row => `${row.name} ${row.description}`.toLowerCase().includes(text))
|
|
140
|
+
.map(row => ({ ...row, disabled: session ? unavailable(row, session) : "" }))
|
|
141
|
+
.sort((a, b) => Number(Boolean(a.disabled)) - Number(Boolean(b.disabled)) || Number(b.name === text) - Number(a.name === text));
|
|
142
|
+
}
|
|
143
|
+
export function help(query = "", session) {
|
|
144
|
+
return completions(query, session).map(row => `/${row.name}${row.argument ? " " + row.argument : ""} — ${row.description}${row.disabled ? ` (${row.disabled})` : ""}`).join("\n") + "\n/shortcuts lists every key. Esc twice cancels the current Run.";
|
|
145
|
+
}
|
|
146
|
+
export const HELP = help();
|
|
147
|
+
export async function command(session, input) {
|
|
148
|
+
if (input.startsWith("//")) return session.submit(input.slice(1));
|
|
149
|
+
const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(input);
|
|
150
|
+
if (!match) return session.submit(input);
|
|
151
|
+
const [, name, argument = ""] = match;
|
|
152
|
+
if (name === "device") { session.message("Computer connection is not available in this version."); return; }
|
|
153
|
+
const row = COMMANDS.find(row => row.name === name || row.aliases?.includes(name));
|
|
154
|
+
if (!row) throw new CliError("command_unknown", `Unknown /${name}; /help searches commands. Use // to send literal text starting with /.`);
|
|
155
|
+
const reason = unavailable(row, session);
|
|
156
|
+
if (reason) throw new CliError("action_unavailable", reason);
|
|
157
|
+
let value = argument;
|
|
158
|
+
if (row.required && !value.trim()) value = await session.choose(`/${row.name} ${row.argument} (blank cancels)`, [], { typed: true });
|
|
159
|
+
if (row.required && !value.trim()) return;
|
|
160
|
+
return row.run(session, value);
|
|
161
|
+
}
|