@corvio/admin-cli 0.1.0-beta.4 → 0.1.0-beta.5

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.5";
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 = [];
@@ -47,14 +100,26 @@ export function parseArgv(argv) {
47
100
  ]);
48
101
  for (let index = 0; index < argv.length; index += 1) {
49
102
  const value = argv[index];
103
+ if (value === "--") {
104
+ positionals.push(...argv.slice(index + 1));
105
+ break;
106
+ }
50
107
  if (!value.startsWith("--")) {
51
108
  positionals.push(value);
52
109
  continue;
53
110
  }
54
111
  const separator = value.indexOf("=");
55
112
  const name = value.slice(2, separator === -1 ? undefined : separator);
113
+ if (!name) throw new CliError("Option name must not be empty.", { code: "invalid_option" });
114
+ if (Object.hasOwn(options, name)) {
115
+ throw new CliError(`Option --${name} was provided more than once.`, { code: "duplicate_option" });
116
+ }
56
117
  if (booleanOptions.has(name)) {
57
- options[name] = separator === -1 ? true : value.slice(separator + 1) !== "false";
118
+ const booleanValue = separator === -1 ? "true" : value.slice(separator + 1);
119
+ if (booleanValue !== "true" && booleanValue !== "false") {
120
+ throw new CliError(`Option --${name} must be true or false.`, { code: "invalid_boolean_option" });
121
+ }
122
+ options[name] = booleanValue === "true";
58
123
  continue;
59
124
  }
60
125
  if (separator !== -1) {
@@ -71,6 +136,21 @@ export function parseArgv(argv) {
71
136
  return { options, positionals };
72
137
  }
73
138
 
139
+ export function assertKnownOptions(options, allowed) {
140
+ const allowedSet = new Set(allowed);
141
+ for (const name of Object.keys(options)) {
142
+ if (!allowedSet.has(name)) {
143
+ throw new CliError(`Unknown option --${name}.`, { code: "unknown_option" });
144
+ }
145
+ }
146
+ }
147
+
148
+ export function assertPositionalCount(positionals, maximum) {
149
+ if (positionals.length > maximum) {
150
+ throw new CliError(`Unexpected argument: ${positionals[maximum]}`, { code: "unexpected_argument" });
151
+ }
152
+ }
153
+
74
154
  export function option(options, name, { required = false, fallback = undefined } = {}) {
75
155
  const value = options[name] ?? fallback;
76
156
  if (required && (value === undefined || String(value).trim() === "")) {
@@ -85,28 +165,43 @@ export function listOption(options, name, fallback = []) {
85
165
  return String(value).split(",").map((item) => item.trim()).filter(Boolean);
86
166
  }
87
167
 
88
- export function numberOption(options, name, fallback) {
168
+ export function numberOption(options, name, fallback, { minimum = 1, maximum = Number.MAX_SAFE_INTEGER } = {}) {
89
169
  const value = options[name];
90
170
  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" });
171
+ const normalized = String(value).trim();
172
+ const parsed = /^\d+$/.test(normalized) ? Number(normalized) : Number.NaN;
173
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
174
+ throw new CliError(
175
+ `Option --${name} must be an integer between ${minimum} and ${maximum}.`,
176
+ { code: "invalid_number_option" },
177
+ );
94
178
  }
95
179
  return parsed;
96
180
  }
97
181
 
98
182
  export function normalizeApiBase(value, fallback) {
99
183
  const raw = String(value || fallback).trim().replace(/\/+$/, "");
100
- if (!/^https:\/\//i.test(raw) && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?/i.test(raw)) {
184
+ let parsed;
185
+ try {
186
+ parsed = new URL(raw);
187
+ } catch {
188
+ throw new CliError("Corvio API base must be a valid URL.", { code: "invalid_api_base" });
189
+ }
190
+ const localhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
191
+ if (parsed.username || parsed.password || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && localhost))) {
101
192
  throw new CliError("Corvio API base must use HTTPS (localhost is allowed for development).", {
102
193
  code: "insecure_api_base",
103
194
  });
104
195
  }
105
- return raw.endsWith("/v1") ? raw : `${raw}/v1`;
196
+ if (parsed.search || parsed.hash) {
197
+ throw new CliError("Corvio API base must not include a query string or fragment.", { code: "invalid_api_base" });
198
+ }
199
+ const normalized = parsed.toString().replace(/\/+$/, "");
200
+ return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
106
201
  }
107
202
 
108
203
  function parseSemver(value) {
109
- const match = String(value || "").trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
204
+ 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
205
  if (!match) return null;
111
206
  return {
112
207
  core: match.slice(1, 4).map((item) => Number.parseInt(item, 10)),
@@ -187,15 +282,31 @@ export async function resolveNpmUpdateStatus({
187
282
  }
188
283
 
189
284
  export class ApiClient {
190
- constructor({ baseUrl, credential = null, workspaceId = null, userAgent, fetchImpl = globalThis.fetch }) {
285
+ constructor({
286
+ baseUrl,
287
+ credential = null,
288
+ workspaceId = null,
289
+ userAgent,
290
+ fetchImpl = globalThis.fetch,
291
+ timeoutMs = process.env.CORVIO_REQUEST_TIMEOUT_MS,
292
+ }) {
191
293
  this.baseUrl = baseUrl.replace(/\/+$/, "");
192
294
  this.credential = credential;
193
295
  this.workspaceId = String(workspaceId || "").trim() || null;
194
296
  this.userAgent = userAgent;
195
297
  this.fetchImpl = fetchImpl;
298
+ this.timeoutMs = normalizeRequestTimeout(timeoutMs);
196
299
  }
197
300
 
198
- async request(path, { method = "GET", body, query, headers = {}, auth = true } = {}) {
301
+ async request(path, {
302
+ method = "GET",
303
+ body,
304
+ query,
305
+ headers = {},
306
+ auth = true,
307
+ retrySafe = false,
308
+ timeoutMs = this.timeoutMs,
309
+ } = {}) {
199
310
  const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
200
311
  for (const [key, value] of Object.entries(query || {})) {
201
312
  if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
@@ -216,18 +327,56 @@ export class ApiClient {
216
327
  requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
217
328
  }
218
329
  }
330
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
219
331
  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
- });
332
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
333
+ try {
334
+ response = await this.fetchImpl(url, {
335
+ method,
336
+ headers: requestHeaders,
337
+ body: body === undefined ? undefined : JSON.stringify(body),
338
+ signal: AbortSignal.timeout(normalizedTimeout),
339
+ });
340
+ const safety = networkFailureDetails({
341
+ cause: null,
342
+ url,
343
+ method,
344
+ requestHeaders,
345
+ body,
346
+ retrySafe,
347
+ });
348
+ if (attempt === 1 && safety.safe_to_retry && RETRYABLE_HTTP_STATUSES.has(response.status)) {
349
+ await response.body?.cancel().catch(() => {});
350
+ const retryAfterValue = response.headers.get("retry-after");
351
+ const retryAfterSeconds = retryAfterValue === null ? Number.NaN : Number(retryAfterValue);
352
+ const delayMs = Number.isFinite(retryAfterSeconds)
353
+ ? Math.min(2_000, Math.max(0, retryAfterSeconds * 1000))
354
+ : 150;
355
+ await sleep(delayMs);
356
+ continue;
357
+ }
358
+ break;
359
+ } catch (cause) {
360
+ const details = networkFailureDetails({ cause, url, method, requestHeaders, body, retrySafe });
361
+ if (attempt === 1 && details.safe_to_retry) {
362
+ await sleep(150);
363
+ continue;
364
+ }
365
+ throw new CliError(
366
+ details.timed_out
367
+ ? `Corvio did not respond within ${normalizedTimeout} ms.`
368
+ : `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
369
+ {
370
+ code: details.timed_out ? "request_timeout" : "network_error",
371
+ exitCode: EXIT.REMOTE,
372
+ details: {
373
+ ...details,
374
+ attempts: attempt,
375
+ automatic_retry_attempted: attempt > 1,
376
+ },
377
+ },
378
+ );
379
+ }
231
380
  }
232
381
  const text = await response.text();
233
382
  let payload = null;
@@ -244,13 +393,18 @@ export class ApiClient {
244
393
  code: String(envelope.code || `http_${response.status}`),
245
394
  status: response.status,
246
395
  exitCode: exitCodeForStatus(response.status),
247
- details: envelope.details || null,
396
+ details: {
397
+ ...(envelope.details || {}),
398
+ ...(envelope.request_id || response.headers.get("x-request-id")
399
+ ? { request_id: envelope.request_id || response.headers.get("x-request-id") }
400
+ : {}),
401
+ },
248
402
  });
249
403
  }
250
404
  return payload;
251
405
  }
252
406
 
253
- async requestBytes(path, { query, headers = {}, auth = true } = {}) {
407
+ async requestBytes(path, { query, headers = {}, auth = true, timeoutMs = this.timeoutMs } = {}) {
254
408
  const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
255
409
  for (const [key, value] of Object.entries(query || {})) {
256
410
  if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
@@ -270,14 +424,38 @@ export class ApiClient {
270
424
  requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
271
425
  }
272
426
  }
427
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
273
428
  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
- });
429
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
430
+ try {
431
+ response = await this.fetchImpl(url, {
432
+ method: "GET",
433
+ headers: requestHeaders,
434
+ signal: AbortSignal.timeout(normalizedTimeout),
435
+ });
436
+ if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
437
+ await response.body?.cancel().catch(() => {});
438
+ await sleep(150);
439
+ continue;
440
+ }
441
+ break;
442
+ } catch (cause) {
443
+ const details = networkFailureDetails({ cause, url, method: "GET", requestHeaders });
444
+ if (attempt === 1) {
445
+ await sleep(150);
446
+ continue;
447
+ }
448
+ throw new CliError(
449
+ details.timed_out
450
+ ? `Corvio did not respond within ${normalizedTimeout} ms.`
451
+ : `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
452
+ {
453
+ code: details.timed_out ? "request_timeout" : "network_error",
454
+ exitCode: EXIT.REMOTE,
455
+ details: { ...details, attempts: attempt, automatic_retry_attempted: true },
456
+ },
457
+ );
458
+ }
281
459
  }
282
460
  const bytes = new Uint8Array(await response.arrayBuffer());
283
461
  if (!response.ok) {
@@ -293,25 +471,57 @@ export class ApiClient {
293
471
  code: String(envelope.code || `http_${response.status}`),
294
472
  status: response.status,
295
473
  exitCode: exitCodeForStatus(response.status),
296
- details: envelope.details || null,
474
+ details: {
475
+ ...(envelope.details || {}),
476
+ ...(envelope.request_id || response.headers.get("x-request-id")
477
+ ? { request_id: envelope.request_id || response.headers.get("x-request-id") }
478
+ : {}),
479
+ },
297
480
  });
298
481
  }
299
482
  return bytes;
300
483
  }
301
484
 
302
- async putUrl(url, content, { headers = {} } = {}) {
485
+ async putUrl(url, content, { headers = {}, timeoutMs = this.timeoutMs } = {}) {
486
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
303
487
  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
- });
488
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
489
+ try {
490
+ response = await this.fetchImpl(String(url), {
491
+ method: "PUT",
492
+ headers,
493
+ body: content,
494
+ signal: AbortSignal.timeout(normalizedTimeout),
495
+ });
496
+ if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
497
+ await response.body?.cancel().catch(() => {});
498
+ await sleep(150);
499
+ continue;
500
+ }
501
+ break;
502
+ } catch (cause) {
503
+ const details = networkFailureDetails({
504
+ cause,
505
+ url,
506
+ method: "PUT",
507
+ requestHeaders: headers,
508
+ retrySafe: true,
509
+ });
510
+ if (attempt === 1) {
511
+ await sleep(150);
512
+ continue;
513
+ }
514
+ throw new CliError(
515
+ details.timed_out
516
+ ? `The file upload did not respond within ${normalizedTimeout} ms.`
517
+ : `Unable to upload the file: ${cause instanceof Error ? cause.message : String(cause)}`,
518
+ {
519
+ code: details.timed_out ? "upload_timeout" : "upload_network_error",
520
+ exitCode: EXIT.REMOTE,
521
+ details: { ...details, attempts: attempt, automatic_retry_attempted: true },
522
+ },
523
+ );
524
+ }
315
525
  }
316
526
  if (!response.ok) {
317
527
  const message = (await response.text()).slice(0, 500);
@@ -534,7 +744,21 @@ export function writeError(error, { json = false, stderr = process.stderr } = {}
534
744
  ...(error instanceof CliError && error.details ? { details: error.details } : {}),
535
745
  },
536
746
  };
537
- stderr.write(json ? `${JSON.stringify(safe)}\n` : `Error: ${safe.error.message}\n`);
747
+ if (json) {
748
+ stderr.write(`${JSON.stringify(safe)}\n`);
749
+ return;
750
+ }
751
+ stderr.write(`Error: ${safe.error.message}\n`);
752
+ if (safe.error.details?.request_id) stderr.write(`Request ID: ${safe.error.details.request_id}\n`);
753
+ if (safe.error.details?.safe_to_retry && safe.error.details?.idempotency_key) {
754
+ stderr.write(
755
+ `Safe retry: rerun the command with --idempotency-key ${safe.error.details.idempotency_key}\n`,
756
+ );
757
+ } else if (safe.error.details?.retry_safety === "revision_guard") {
758
+ stderr.write("Safe retry: rerun with the same --expected-revision; Corvio will reject an already-committed replacement.\n");
759
+ } else if (safe.error.details?.retry_safety === "unknown_commit_state") {
760
+ stderr.write("Retry state is unknown; read the remote object before repeating this mutation.\n");
761
+ }
538
762
  }
539
763
 
540
764
  export async function runMain(main, argv = process.argv.slice(2)) {
@@ -551,8 +775,8 @@ export async function runMain(main, argv = process.argv.slice(2)) {
551
775
 
552
776
  export async function openBrowser(url) {
553
777
  const target = String(url);
554
- const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
555
- const args = platform() === "win32" ? ["/c", "start", "", target] : [target];
778
+ const command = platform() === "darwin" ? "open" : platform() === "win32" ? "rundll32.exe" : "xdg-open";
779
+ const args = platform() === "win32" ? ["url.dll,FileProtocolHandler", target] : [target];
556
780
  return new Promise((resolvePromise) => {
557
781
  let settled = false;
558
782
  const finish = (opened) => {
@@ -584,14 +808,28 @@ export async function readTextFile(path) {
584
808
  export async function writeTextFile(path, content) {
585
809
  const target = resolve(String(path));
586
810
  await mkdir(dirname(target), { recursive: true });
587
- await writeFile(target, content, "utf8");
811
+ const temporary = `${target}.${randomUUID()}.tmp`;
812
+ try {
813
+ await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
814
+ await rename(temporary, target);
815
+ } catch (error) {
816
+ await rm(temporary, { force: true }).catch(() => {});
817
+ throw error;
818
+ }
588
819
  return target;
589
820
  }
590
821
 
591
822
  export async function writeBinaryFile(path, content) {
592
823
  const target = resolve(String(path));
593
824
  await mkdir(dirname(target), { recursive: true });
594
- await writeFile(target, content);
825
+ const temporary = `${target}.${randomUUID()}.tmp`;
826
+ try {
827
+ await writeFile(temporary, content, { flag: "wx" });
828
+ await rename(temporary, target);
829
+ } catch (error) {
830
+ await rm(temporary, { force: true }).catch(() => {});
831
+ throw error;
832
+ }
595
833
  return target;
596
834
  }
597
835
 
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.5",
4
4
  "description": "Official read-only Corvio platform administrator CLI.",
5
5
  "type": "module",
6
6
  "bin": {