@corvio/admin-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.
package/README.md CHANGED
@@ -10,3 +10,5 @@ corvio-admin catalog --json
10
10
  ```
11
11
 
12
12
  For Agents and CI, inject `CORVIO_ADMIN_API_KEY='cva_...'`; it takes precedence over the stored device-link credential. Every data query requires a purpose and returns an auditable receipt. This package contains no ordinary Workspace commands. See [the administrator CLI guide](https://corvio.ai/developers/admin-cli).
13
+
14
+ Unknown/duplicate options, malformed bounds, and invalid JSON fail locally. API calls use the same bounded transport/runtime safety policy as the Workspace CLI. `corvio-admin skill download` reads the protected manifest first and verifies the archive SHA-256 before writing the zip.
package/dist/cli.js CHANGED
@@ -1,9 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { createHash } from "node:crypto";
4
+
3
5
  import {
4
6
  ApiClient,
5
7
  CliError,
6
8
  EXIT,
9
+ assertKnownOptions,
10
+ assertPositionalCount,
7
11
  clearAdminCredential,
8
12
  listOption,
9
13
  normalizeApiBase,
@@ -20,9 +24,29 @@ import {
20
24
  writeResult,
21
25
  } from "./core.js";
22
26
 
23
- const VERSION = "0.1.0-beta.4";
27
+ const VERSION = "0.1.0-beta.6";
24
28
  const DEFAULT_API_BASE = "https://api.corvio.ai/v1";
25
29
  const USER_AGENT = `corvio-admin-cli/${VERSION}`;
30
+ const GLOBAL_OPTIONS = Object.freeze(["api-base", "json", "no-input", "help", "version"]);
31
+ const COMMAND_OPTIONS = Object.freeze({
32
+ "auth:login": ["force", "scopes"],
33
+ "auth:status": [],
34
+ "auth:logout": [],
35
+ "capabilities:": [],
36
+ "update:check": [],
37
+ "skill:download": ["output"],
38
+ "catalog:": [],
39
+ "query:": ["file", "purpose", "dataset", "fields", "group-by", "metrics", "filters", "order-by", "limit", "cursor"],
40
+ "data:query": ["file", "purpose", "dataset", "fields", "group-by", "metrics", "filters", "order-by", "limit", "cursor"],
41
+ "workspaces:list": ["purpose", "workspace-id", "status", "limit", "cursor"],
42
+ "users:list": ["purpose", "workspace-id", "status", "limit", "cursor"],
43
+ "worker-jobs:list": ["purpose", "workspace-id", "status", "limit", "cursor"],
44
+ "runs:list": ["purpose", "workspace-id", "status", "limit", "cursor"],
45
+ "runs:content": ["id", "purpose"],
46
+ "usage:events": ["purpose", "workspace-id", "status", "limit", "cursor"],
47
+ "usage:summary": ["purpose", "workspace-id", "status", "limit", "cursor"],
48
+ "receipts:get": ["id"],
49
+ });
26
50
 
27
51
  const HELP = `Corvio Platform Administrator CLI ${VERSION}
28
52
 
@@ -47,6 +71,47 @@ Authentication:
47
71
  This package contains no Workspace ask, search, docs, files, sync, or share commands.
48
72
  `;
49
73
 
74
+ function resourcePath(value) {
75
+ return encodeURIComponent(String(value));
76
+ }
77
+
78
+ function validateInvocation(options, positionals) {
79
+ const [command, rawAction] = positionals;
80
+ if (!command || command === "help" || command === "version" || options.help || options.version) return;
81
+ const defaultActions = {
82
+ update: "check",
83
+ workspaces: "list",
84
+ users: "list",
85
+ "worker-jobs": "list",
86
+ };
87
+ const action = ["capabilities", "catalog", "query"].includes(command)
88
+ ? ""
89
+ : rawAction || defaultActions[command] || "";
90
+ const schema = COMMAND_OPTIONS[`${command}:${action}`];
91
+ if (!schema) {
92
+ throw new CliError(`Unknown administrator command: ${positionals.join(" ")}`, {
93
+ code: "unknown_command",
94
+ });
95
+ }
96
+ assertKnownOptions(options, [...GLOBAL_OPTIONS, ...schema]);
97
+ const maximum = new Set(["runs:content", "receipts:get"]).has(`${command}:${action}`)
98
+ ? 3
99
+ : action
100
+ ? 2
101
+ : 1;
102
+ assertPositionalCount(positionals, maximum);
103
+ }
104
+
105
+ function parseJsonValue(value, optionName) {
106
+ try {
107
+ return JSON.parse(String(value));
108
+ } catch {
109
+ throw new CliError(`Option --${optionName} must contain valid JSON.`, {
110
+ code: "invalid_json_option",
111
+ });
112
+ }
113
+ }
114
+
50
115
  function baseUrl(options, credential = null) {
51
116
  return normalizeApiBase(
52
117
  options["api-base"]
@@ -76,7 +141,7 @@ function listQuery(options) {
76
141
  purpose: option(options, "purpose", { required: true }),
77
142
  workspace_id: options["workspace-id"],
78
143
  status: options.status,
79
- limit: numberOption(options, "limit", 100),
144
+ limit: numberOption(options, "limit", 100, { maximum: 200 }),
80
145
  cursor: options.cursor,
81
146
  };
82
147
  }
@@ -216,7 +281,15 @@ async function catalog(options) {
216
281
  async function query(options) {
217
282
  let body;
218
283
  if (options.file) {
219
- body = JSON.parse(await readTextFile(options.file));
284
+ try {
285
+ body = JSON.parse(await readTextFile(options.file));
286
+ } catch (error) {
287
+ if (error?.code === "ENOENT") throw error;
288
+ throw new CliError("--file must contain a valid JSON request object.", { code: "invalid_json_file" });
289
+ }
290
+ if (!body || Array.isArray(body) || typeof body !== "object") {
291
+ throw new CliError("--file must contain a JSON object.", { code: "invalid_json_file" });
292
+ }
220
293
  if (options.purpose) body.purpose = options.purpose;
221
294
  } else {
222
295
  body = {
@@ -224,10 +297,10 @@ async function query(options) {
224
297
  purpose: option(options, "purpose", { required: true }),
225
298
  fields: listOption(options, "fields"),
226
299
  group_by: listOption(options, "group-by"),
227
- metrics: options.metrics ? JSON.parse(options.metrics) : [],
228
- filters: options.filters ? JSON.parse(options.filters) : [],
229
- order_by: options["order-by"] ? JSON.parse(options["order-by"]) : [],
230
- limit: numberOption(options, "limit", 100),
300
+ metrics: options.metrics ? parseJsonValue(options.metrics, "metrics") : [],
301
+ filters: options.filters ? parseJsonValue(options.filters, "filters") : [],
302
+ order_by: options["order-by"] ? parseJsonValue(options["order-by"], "order-by") : [],
303
+ limit: numberOption(options, "limit", 100, { maximum: 200 }),
231
304
  cursor: options.cursor || null,
232
305
  };
233
306
  }
@@ -243,7 +316,15 @@ async function listResource(options, resource) {
243
316
  async function downloadSkill(options) {
244
317
  const { api } = await client(options);
245
318
  const output = String(options.output || "corvio-admin-data.zip");
319
+ const manifest = await api.request("/admin/skill/manifest.json");
246
320
  const bytes = await api.requestBytes("/admin/skill/corvio-admin-data.zip");
321
+ const actualHash = createHash("sha256").update(bytes).digest("hex");
322
+ if (!manifest.archive_sha256 || actualHash !== manifest.archive_sha256) {
323
+ throw new CliError("Administrator Skill archive did not match its manifest SHA-256.", {
324
+ code: "admin_skill_integrity_mismatch",
325
+ exitCode: EXIT.REMOTE,
326
+ });
327
+ }
247
328
  const path = await writeBinaryFile(output, bytes);
248
329
  writeResult({
249
330
  downloaded: true,
@@ -256,6 +337,7 @@ async function main({ options, positionals }) {
256
337
  const [command, action, id] = positionals;
257
338
  if (options.version || command === "version") return writeResult(VERSION, { json: options.json });
258
339
  if (options.help || !command || command === "help") return writeResult(HELP.trimEnd(), { json: false });
340
+ validateInvocation(options, positionals);
259
341
  if (command === "auth") {
260
342
  if (action === "login") return authLogin(options);
261
343
  if (action === "status") return authStatus(options);
@@ -276,7 +358,7 @@ async function main({ options, positionals }) {
276
358
  const runId = id || option(options, "id", { required: true });
277
359
  const purpose = option(options, "purpose", { required: true });
278
360
  const { api } = await client(options);
279
- return writeResult(await api.request(`/admin/runs/${runId}/content`, { query: { purpose } }), { json: options.json });
361
+ return writeResult(await api.request(`/admin/runs/${resourcePath(runId)}/content`, { query: { purpose } }), { json: options.json });
280
362
  }
281
363
  if (command === "usage" && action === "events") return listResource(options, "usage/events");
282
364
  if (command === "usage" && action === "summary") {
@@ -286,7 +368,7 @@ async function main({ options, positionals }) {
286
368
  if (command === "receipts" && action === "get") {
287
369
  const receiptId = id || option(options, "id", { required: true });
288
370
  const { api } = await client(options);
289
- return writeResult(await api.request(`/admin/data/query-receipts/${receiptId}`), { json: options.json });
371
+ return writeResult(await api.request(`/admin/data/query-receipts/${resourcePath(receiptId)}`), { json: options.json });
290
372
  }
291
373
  throw new CliError(`Unknown administrator command: ${positionals.join(" ")}`);
292
374
  }
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/admin-cli",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.6",
4
4
  "description": "Official read-only Corvio platform administrator CLI.",
5
5
  "type": "module",
6
6
  "bin": {