@corvio/cli 0.1.0-beta.4 → 0.1.0-beta.6

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 (4) hide show
  1. package/README.md +34 -0
  2. package/dist/cli.js +743 -37
  3. package/dist/core.js +306 -48
  4. package/package.json +1 -1
package/dist/core.js CHANGED
@@ -32,6 +32,59 @@ function exitCodeForStatus(status) {
32
32
  return EXIT.REMOTE;
33
33
  }
34
34
 
35
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
36
+ const MAX_REQUEST_TIMEOUT_MS = 10 * 60_000;
37
+ const RETRYABLE_HTTP_STATUSES = new Set([408, 429, 502, 503, 504]);
38
+
39
+ function normalizeRequestTimeout(value, fallback = DEFAULT_REQUEST_TIMEOUT_MS) {
40
+ const raw = value === undefined || value === null || value === "" ? fallback : Number(value);
41
+ if (!Number.isSafeInteger(raw) || raw < 1 || raw > MAX_REQUEST_TIMEOUT_MS) {
42
+ throw new CliError(
43
+ `Request timeout must be an integer between 1 and ${MAX_REQUEST_TIMEOUT_MS} milliseconds.`,
44
+ { code: "invalid_request_timeout" },
45
+ );
46
+ }
47
+ return raw;
48
+ }
49
+
50
+ function networkFailureDetails({
51
+ cause,
52
+ url,
53
+ method,
54
+ requestHeaders = {},
55
+ body = undefined,
56
+ retrySafe = false,
57
+ }) {
58
+ const nested = cause && typeof cause === "object" ? cause.cause : null;
59
+ const causeCode = String(nested?.code || cause?.code || "").trim() || null;
60
+ const syscall = String(nested?.syscall || cause?.syscall || "").trim() || null;
61
+ const normalizedMethod = String(method || "GET").toUpperCase();
62
+ const operationKey = String(requestHeaders["idempotency-key"] || requestHeaders["Idempotency-Key"] || "").trim();
63
+ const revision = body?.expected_content_revision;
64
+ const revisionGuarded = normalizedMethod === "PUT" && Number.isSafeInteger(revision) && revision >= 0;
65
+ const safeToRetry = retrySafe || ["GET", "HEAD"].includes(normalizedMethod) || Boolean(operationKey) || revisionGuarded;
66
+ const timedOut = cause?.name === "TimeoutError" || cause?.name === "AbortError";
67
+ return {
68
+ method: normalizedMethod,
69
+ origin: new URL(String(url)).origin,
70
+ response_received: false,
71
+ timed_out: timedOut,
72
+ safe_to_retry: safeToRetry,
73
+ retry_safety: operationKey
74
+ ? "idempotency_key"
75
+ : revisionGuarded
76
+ ? "revision_guard"
77
+ : retrySafe
78
+ ? "operation_contract"
79
+ : safeToRetry
80
+ ? "read_only"
81
+ : "unknown_commit_state",
82
+ ...(operationKey ? { idempotency_key: operationKey } : {}),
83
+ ...(causeCode ? { cause_code: causeCode } : {}),
84
+ ...(syscall ? { syscall } : {}),
85
+ };
86
+ }
87
+
35
88
  export function parseArgv(argv) {
36
89
  const options = Object.create(null);
37
90
  const positionals = [];
@@ -42,19 +95,34 @@ export function parseArgv(argv) {
42
95
  "allow-actions",
43
96
  "private-link",
44
97
  "force",
98
+ "once",
99
+ "include-revoked",
100
+ "start-new-chain",
45
101
  "help",
46
102
  "version",
47
103
  ]);
48
104
  for (let index = 0; index < argv.length; index += 1) {
49
105
  const value = argv[index];
106
+ if (value === "--") {
107
+ positionals.push(...argv.slice(index + 1));
108
+ break;
109
+ }
50
110
  if (!value.startsWith("--")) {
51
111
  positionals.push(value);
52
112
  continue;
53
113
  }
54
114
  const separator = value.indexOf("=");
55
115
  const name = value.slice(2, separator === -1 ? undefined : separator);
116
+ if (!name) throw new CliError("Option name must not be empty.", { code: "invalid_option" });
117
+ if (Object.hasOwn(options, name)) {
118
+ throw new CliError(`Option --${name} was provided more than once.`, { code: "duplicate_option" });
119
+ }
56
120
  if (booleanOptions.has(name)) {
57
- options[name] = separator === -1 ? true : value.slice(separator + 1) !== "false";
121
+ const booleanValue = separator === -1 ? "true" : value.slice(separator + 1);
122
+ if (booleanValue !== "true" && booleanValue !== "false") {
123
+ throw new CliError(`Option --${name} must be true or false.`, { code: "invalid_boolean_option" });
124
+ }
125
+ options[name] = booleanValue === "true";
58
126
  continue;
59
127
  }
60
128
  if (separator !== -1) {
@@ -71,6 +139,21 @@ export function parseArgv(argv) {
71
139
  return { options, positionals };
72
140
  }
73
141
 
142
+ export function assertKnownOptions(options, allowed) {
143
+ const allowedSet = new Set(allowed);
144
+ for (const name of Object.keys(options)) {
145
+ if (!allowedSet.has(name)) {
146
+ throw new CliError(`Unknown option --${name}.`, { code: "unknown_option" });
147
+ }
148
+ }
149
+ }
150
+
151
+ export function assertPositionalCount(positionals, maximum) {
152
+ if (positionals.length > maximum) {
153
+ throw new CliError(`Unexpected argument: ${positionals[maximum]}`, { code: "unexpected_argument" });
154
+ }
155
+ }
156
+
74
157
  export function option(options, name, { required = false, fallback = undefined } = {}) {
75
158
  const value = options[name] ?? fallback;
76
159
  if (required && (value === undefined || String(value).trim() === "")) {
@@ -85,28 +168,43 @@ export function listOption(options, name, fallback = []) {
85
168
  return String(value).split(",").map((item) => item.trim()).filter(Boolean);
86
169
  }
87
170
 
88
- export function numberOption(options, name, fallback) {
171
+ export function numberOption(options, name, fallback, { minimum = 1, maximum = Number.MAX_SAFE_INTEGER } = {}) {
89
172
  const value = options[name];
90
173
  if (value === undefined) return fallback;
91
- const parsed = Number.parseInt(String(value), 10);
92
- if (!Number.isFinite(parsed) || parsed < 1) {
93
- throw new CliError(`Option --${name} must be a positive integer.`, { code: "invalid_number_option" });
174
+ const normalized = String(value).trim();
175
+ const parsed = /^\d+$/.test(normalized) ? Number(normalized) : Number.NaN;
176
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
177
+ throw new CliError(
178
+ `Option --${name} must be an integer between ${minimum} and ${maximum}.`,
179
+ { code: "invalid_number_option" },
180
+ );
94
181
  }
95
182
  return parsed;
96
183
  }
97
184
 
98
185
  export function normalizeApiBase(value, fallback) {
99
186
  const raw = String(value || fallback).trim().replace(/\/+$/, "");
100
- if (!/^https:\/\//i.test(raw) && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?/i.test(raw)) {
187
+ let parsed;
188
+ try {
189
+ parsed = new URL(raw);
190
+ } catch {
191
+ throw new CliError("Corvio API base must be a valid URL.", { code: "invalid_api_base" });
192
+ }
193
+ const localhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
194
+ if (parsed.username || parsed.password || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && localhost))) {
101
195
  throw new CliError("Corvio API base must use HTTPS (localhost is allowed for development).", {
102
196
  code: "insecure_api_base",
103
197
  });
104
198
  }
105
- return raw.endsWith("/v1") ? raw : `${raw}/v1`;
199
+ if (parsed.search || parsed.hash) {
200
+ throw new CliError("Corvio API base must not include a query string or fragment.", { code: "invalid_api_base" });
201
+ }
202
+ const normalized = parsed.toString().replace(/\/+$/, "");
203
+ return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
106
204
  }
107
205
 
108
206
  function parseSemver(value) {
109
- const match = String(value || "").trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
207
+ const match = String(value || "").trim().match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/);
110
208
  if (!match) return null;
111
209
  return {
112
210
  core: match.slice(1, 4).map((item) => Number.parseInt(item, 10)),
@@ -187,15 +285,31 @@ export async function resolveNpmUpdateStatus({
187
285
  }
188
286
 
189
287
  export class ApiClient {
190
- constructor({ baseUrl, credential = null, workspaceId = null, userAgent, fetchImpl = globalThis.fetch }) {
288
+ constructor({
289
+ baseUrl,
290
+ credential = null,
291
+ workspaceId = null,
292
+ userAgent,
293
+ fetchImpl = globalThis.fetch,
294
+ timeoutMs = process.env.CORVIO_REQUEST_TIMEOUT_MS,
295
+ }) {
191
296
  this.baseUrl = baseUrl.replace(/\/+$/, "");
192
297
  this.credential = credential;
193
298
  this.workspaceId = String(workspaceId || "").trim() || null;
194
299
  this.userAgent = userAgent;
195
300
  this.fetchImpl = fetchImpl;
301
+ this.timeoutMs = normalizeRequestTimeout(timeoutMs);
196
302
  }
197
303
 
198
- async request(path, { method = "GET", body, query, headers = {}, auth = true } = {}) {
304
+ async request(path, {
305
+ method = "GET",
306
+ body,
307
+ query,
308
+ headers = {},
309
+ auth = true,
310
+ retrySafe = false,
311
+ timeoutMs = this.timeoutMs,
312
+ } = {}) {
199
313
  const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
200
314
  for (const [key, value] of Object.entries(query || {})) {
201
315
  if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
@@ -216,18 +330,56 @@ export class ApiClient {
216
330
  requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
217
331
  }
218
332
  }
333
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
219
334
  let response;
220
- try {
221
- response = await this.fetchImpl(url, {
222
- method,
223
- headers: requestHeaders,
224
- body: body === undefined ? undefined : JSON.stringify(body),
225
- });
226
- } catch (cause) {
227
- throw new CliError(`Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`, {
228
- code: "network_error",
229
- exitCode: EXIT.REMOTE,
230
- });
335
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
336
+ try {
337
+ response = await this.fetchImpl(url, {
338
+ method,
339
+ headers: requestHeaders,
340
+ body: body === undefined ? undefined : JSON.stringify(body),
341
+ signal: AbortSignal.timeout(normalizedTimeout),
342
+ });
343
+ const safety = networkFailureDetails({
344
+ cause: null,
345
+ url,
346
+ method,
347
+ requestHeaders,
348
+ body,
349
+ retrySafe,
350
+ });
351
+ if (attempt === 1 && safety.safe_to_retry && RETRYABLE_HTTP_STATUSES.has(response.status)) {
352
+ await response.body?.cancel().catch(() => {});
353
+ const retryAfterValue = response.headers.get("retry-after");
354
+ const retryAfterSeconds = retryAfterValue === null ? Number.NaN : Number(retryAfterValue);
355
+ const delayMs = Number.isFinite(retryAfterSeconds)
356
+ ? Math.min(2_000, Math.max(0, retryAfterSeconds * 1000))
357
+ : 150;
358
+ await sleep(delayMs);
359
+ continue;
360
+ }
361
+ break;
362
+ } catch (cause) {
363
+ const details = networkFailureDetails({ cause, url, method, requestHeaders, body, retrySafe });
364
+ if (attempt === 1 && details.safe_to_retry) {
365
+ await sleep(150);
366
+ continue;
367
+ }
368
+ throw new CliError(
369
+ details.timed_out
370
+ ? `Corvio did not respond within ${normalizedTimeout} ms.`
371
+ : `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
372
+ {
373
+ code: details.timed_out ? "request_timeout" : "network_error",
374
+ exitCode: EXIT.REMOTE,
375
+ details: {
376
+ ...details,
377
+ attempts: attempt,
378
+ automatic_retry_attempted: attempt > 1,
379
+ },
380
+ },
381
+ );
382
+ }
231
383
  }
232
384
  const text = await response.text();
233
385
  let payload = null;
@@ -244,13 +396,18 @@ export class ApiClient {
244
396
  code: String(envelope.code || `http_${response.status}`),
245
397
  status: response.status,
246
398
  exitCode: exitCodeForStatus(response.status),
247
- details: envelope.details || null,
399
+ details: {
400
+ ...(envelope.details || {}),
401
+ ...(envelope.request_id || response.headers.get("x-request-id")
402
+ ? { request_id: envelope.request_id || response.headers.get("x-request-id") }
403
+ : {}),
404
+ },
248
405
  });
249
406
  }
250
407
  return payload;
251
408
  }
252
409
 
253
- async requestBytes(path, { query, headers = {}, auth = true } = {}) {
410
+ async requestBytes(path, { query, headers = {}, auth = true, timeoutMs = this.timeoutMs } = {}) {
254
411
  const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
255
412
  for (const [key, value] of Object.entries(query || {})) {
256
413
  if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
@@ -270,14 +427,38 @@ export class ApiClient {
270
427
  requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
271
428
  }
272
429
  }
430
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
273
431
  let response;
274
- try {
275
- response = await this.fetchImpl(url, { method: "GET", headers: requestHeaders });
276
- } catch (cause) {
277
- throw new CliError(`Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`, {
278
- code: "network_error",
279
- exitCode: EXIT.REMOTE,
280
- });
432
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
433
+ try {
434
+ response = await this.fetchImpl(url, {
435
+ method: "GET",
436
+ headers: requestHeaders,
437
+ signal: AbortSignal.timeout(normalizedTimeout),
438
+ });
439
+ if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
440
+ await response.body?.cancel().catch(() => {});
441
+ await sleep(150);
442
+ continue;
443
+ }
444
+ break;
445
+ } catch (cause) {
446
+ const details = networkFailureDetails({ cause, url, method: "GET", requestHeaders });
447
+ if (attempt === 1) {
448
+ await sleep(150);
449
+ continue;
450
+ }
451
+ throw new CliError(
452
+ details.timed_out
453
+ ? `Corvio did not respond within ${normalizedTimeout} ms.`
454
+ : `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
455
+ {
456
+ code: details.timed_out ? "request_timeout" : "network_error",
457
+ exitCode: EXIT.REMOTE,
458
+ details: { ...details, attempts: attempt, automatic_retry_attempted: true },
459
+ },
460
+ );
461
+ }
281
462
  }
282
463
  const bytes = new Uint8Array(await response.arrayBuffer());
283
464
  if (!response.ok) {
@@ -293,25 +474,57 @@ export class ApiClient {
293
474
  code: String(envelope.code || `http_${response.status}`),
294
475
  status: response.status,
295
476
  exitCode: exitCodeForStatus(response.status),
296
- details: envelope.details || null,
477
+ details: {
478
+ ...(envelope.details || {}),
479
+ ...(envelope.request_id || response.headers.get("x-request-id")
480
+ ? { request_id: envelope.request_id || response.headers.get("x-request-id") }
481
+ : {}),
482
+ },
297
483
  });
298
484
  }
299
485
  return bytes;
300
486
  }
301
487
 
302
- async putUrl(url, content, { headers = {} } = {}) {
488
+ async putUrl(url, content, { headers = {}, timeoutMs = this.timeoutMs } = {}) {
489
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
303
490
  let response;
304
- try {
305
- response = await this.fetchImpl(String(url), {
306
- method: "PUT",
307
- headers,
308
- body: content,
309
- });
310
- } catch (cause) {
311
- throw new CliError(`Unable to upload the file: ${cause instanceof Error ? cause.message : String(cause)}`, {
312
- code: "upload_network_error",
313
- exitCode: EXIT.REMOTE,
314
- });
491
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
492
+ try {
493
+ response = await this.fetchImpl(String(url), {
494
+ method: "PUT",
495
+ headers,
496
+ body: content,
497
+ signal: AbortSignal.timeout(normalizedTimeout),
498
+ });
499
+ if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
500
+ await response.body?.cancel().catch(() => {});
501
+ await sleep(150);
502
+ continue;
503
+ }
504
+ break;
505
+ } catch (cause) {
506
+ const details = networkFailureDetails({
507
+ cause,
508
+ url,
509
+ method: "PUT",
510
+ requestHeaders: headers,
511
+ retrySafe: true,
512
+ });
513
+ if (attempt === 1) {
514
+ await sleep(150);
515
+ continue;
516
+ }
517
+ throw new CliError(
518
+ details.timed_out
519
+ ? `The file upload did not respond within ${normalizedTimeout} ms.`
520
+ : `Unable to upload the file: ${cause instanceof Error ? cause.message : String(cause)}`,
521
+ {
522
+ code: details.timed_out ? "upload_timeout" : "upload_network_error",
523
+ exitCode: EXIT.REMOTE,
524
+ details: { ...details, attempts: attempt, automatic_retry_attempted: true },
525
+ },
526
+ );
527
+ }
315
528
  }
316
529
  if (!response.ok) {
317
530
  const message = (await response.text()).slice(0, 500);
@@ -375,6 +588,23 @@ export async function readWorkspaceCredential(env = process.env) {
375
588
  return { key, source: "device_link", profile: profileName, metadata };
376
589
  }
377
590
 
591
+ export function readAgentCredential(env = process.env) {
592
+ const key = String(env.CORVIO_AGENT_API_KEY || "").trim();
593
+ if (!key) {
594
+ throw new CliError(
595
+ "No Agent credential found. Set CORVIO_AGENT_API_KEY to the cvg_ secret returned when the Agent was created.",
596
+ { code: "agent_auth_required", exitCode: EXIT.AUTH },
597
+ );
598
+ }
599
+ if (!key.startsWith("cvg_")) {
600
+ throw new CliError("CORVIO_AGENT_API_KEY must be an Agent cvg_ key.", {
601
+ code: "invalid_agent_key",
602
+ exitCode: EXIT.AUTH,
603
+ });
604
+ }
605
+ return { key, source: "environment", profile: null, metadata: null };
606
+ }
607
+
378
608
  export async function storeWorkspaceCredential(payload, env = process.env) {
379
609
  const path = workspaceCredentialPath(env);
380
610
  const root = dirname(path);
@@ -534,7 +764,21 @@ export function writeError(error, { json = false, stderr = process.stderr } = {}
534
764
  ...(error instanceof CliError && error.details ? { details: error.details } : {}),
535
765
  },
536
766
  };
537
- stderr.write(json ? `${JSON.stringify(safe)}\n` : `Error: ${safe.error.message}\n`);
767
+ if (json) {
768
+ stderr.write(`${JSON.stringify(safe)}\n`);
769
+ return;
770
+ }
771
+ stderr.write(`Error: ${safe.error.message}\n`);
772
+ if (safe.error.details?.request_id) stderr.write(`Request ID: ${safe.error.details.request_id}\n`);
773
+ if (safe.error.details?.safe_to_retry && safe.error.details?.idempotency_key) {
774
+ stderr.write(
775
+ `Safe retry: rerun the command with --idempotency-key ${safe.error.details.idempotency_key}\n`,
776
+ );
777
+ } else if (safe.error.details?.retry_safety === "revision_guard") {
778
+ stderr.write("Safe retry: rerun with the same --expected-revision; Corvio will reject an already-committed replacement.\n");
779
+ } else if (safe.error.details?.retry_safety === "unknown_commit_state") {
780
+ stderr.write("Retry state is unknown; read the remote object before repeating this mutation.\n");
781
+ }
538
782
  }
539
783
 
540
784
  export async function runMain(main, argv = process.argv.slice(2)) {
@@ -551,8 +795,8 @@ export async function runMain(main, argv = process.argv.slice(2)) {
551
795
 
552
796
  export async function openBrowser(url) {
553
797
  const target = String(url);
554
- const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
555
- const args = platform() === "win32" ? ["/c", "start", "", target] : [target];
798
+ const command = platform() === "darwin" ? "open" : platform() === "win32" ? "rundll32.exe" : "xdg-open";
799
+ const args = platform() === "win32" ? ["url.dll,FileProtocolHandler", target] : [target];
556
800
  return new Promise((resolvePromise) => {
557
801
  let settled = false;
558
802
  const finish = (opened) => {
@@ -584,14 +828,28 @@ export async function readTextFile(path) {
584
828
  export async function writeTextFile(path, content) {
585
829
  const target = resolve(String(path));
586
830
  await mkdir(dirname(target), { recursive: true });
587
- await writeFile(target, content, "utf8");
831
+ const temporary = `${target}.${randomUUID()}.tmp`;
832
+ try {
833
+ await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
834
+ await rename(temporary, target);
835
+ } catch (error) {
836
+ await rm(temporary, { force: true }).catch(() => {});
837
+ throw error;
838
+ }
588
839
  return target;
589
840
  }
590
841
 
591
842
  export async function writeBinaryFile(path, content) {
592
843
  const target = resolve(String(path));
593
844
  await mkdir(dirname(target), { recursive: true });
594
- await writeFile(target, content);
845
+ const temporary = `${target}.${randomUUID()}.tmp`;
846
+ try {
847
+ await writeFile(temporary, content, { flag: "wx" });
848
+ await rename(temporary, target);
849
+ } catch (error) {
850
+ await rm(temporary, { force: true }).catch(() => {});
851
+ throw error;
852
+ }
595
853
  return target;
596
854
  }
597
855
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@corvio/cli",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.6",
4
4
  "description": "Official Corvio Workspace CLI for questions, knowledge search, documents, and Agent workflows.",
5
5
  "type": "module",
6
6
  "bin": {