@corvio/admin-cli 0.1.0-beta.3 → 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,
@@ -12,6 +16,7 @@ import {
12
16
  option,
13
17
  readAdminCredential,
14
18
  readTextFile,
19
+ resolveNpmUpdateStatus,
15
20
  runMain,
16
21
  sleep,
17
22
  storeAdminCredential,
@@ -19,9 +24,29 @@ import {
19
24
  writeResult,
20
25
  } from "./core.js";
21
26
 
22
- const VERSION = "0.1.0-beta.3";
27
+ const VERSION = "0.1.0-beta.5";
23
28
  const DEFAULT_API_BASE = "https://api.corvio.ai/v1";
24
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
+ });
25
50
 
26
51
  const HELP = `Corvio Platform Administrator CLI ${VERSION}
27
52
 
@@ -37,6 +62,7 @@ Usage:
37
62
  corvio-admin worker-jobs list --purpose <reason>
38
63
  corvio-admin usage events|summary --purpose <reason>
39
64
  corvio-admin receipts get <id>
65
+ corvio-admin update check
40
66
 
41
67
  Authentication:
42
68
  CORVIO_ADMIN_API_KEY=cva_... takes precedence over the stored device-link login.
@@ -45,6 +71,47 @@ Authentication:
45
71
  This package contains no Workspace ask, search, docs, files, sync, or share commands.
46
72
  `;
47
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
+
48
115
  function baseUrl(options, credential = null) {
49
116
  return normalizeApiBase(
50
117
  options["api-base"]
@@ -74,7 +141,7 @@ function listQuery(options) {
74
141
  purpose: option(options, "purpose", { required: true }),
75
142
  workspace_id: options["workspace-id"],
76
143
  status: options.status,
77
- limit: numberOption(options, "limit", 100),
144
+ limit: numberOption(options, "limit", 100, { maximum: 200 }),
78
145
  cursor: options.cursor,
79
146
  };
80
147
  }
@@ -192,6 +259,20 @@ async function capabilities(options) {
192
259
  writeResult({ cli_version: VERSION, auth_source: credential.source, api: index, principal: identity }, { json: options.json });
193
260
  }
194
261
 
262
+ async function updateCheck(options) {
263
+ const { api } = await client(options);
264
+ const index = await api.request("/admin");
265
+ const result = await resolveNpmUpdateStatus({
266
+ currentVersion: VERSION,
267
+ policy: index?.distribution?.admin_cli || {
268
+ package: "@corvio/admin-cli",
269
+ dist_tag: "beta",
270
+ },
271
+ userAgent: USER_AGENT,
272
+ });
273
+ writeResult(result, { json: options.json });
274
+ }
275
+
195
276
  async function catalog(options) {
196
277
  const { api } = await client(options);
197
278
  writeResult(await api.request("/admin/data/catalog"), { json: options.json });
@@ -200,7 +281,15 @@ async function catalog(options) {
200
281
  async function query(options) {
201
282
  let body;
202
283
  if (options.file) {
203
- 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
+ }
204
293
  if (options.purpose) body.purpose = options.purpose;
205
294
  } else {
206
295
  body = {
@@ -208,10 +297,10 @@ async function query(options) {
208
297
  purpose: option(options, "purpose", { required: true }),
209
298
  fields: listOption(options, "fields"),
210
299
  group_by: listOption(options, "group-by"),
211
- metrics: options.metrics ? JSON.parse(options.metrics) : [],
212
- filters: options.filters ? JSON.parse(options.filters) : [],
213
- order_by: options["order-by"] ? JSON.parse(options["order-by"]) : [],
214
- 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 }),
215
304
  cursor: options.cursor || null,
216
305
  };
217
306
  }
@@ -227,7 +316,15 @@ async function listResource(options, resource) {
227
316
  async function downloadSkill(options) {
228
317
  const { api } = await client(options);
229
318
  const output = String(options.output || "corvio-admin-data.zip");
319
+ const manifest = await api.request("/admin/skill/manifest.json");
230
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
+ }
231
328
  const path = await writeBinaryFile(output, bytes);
232
329
  writeResult({
233
330
  downloaded: true,
@@ -240,6 +337,7 @@ async function main({ options, positionals }) {
240
337
  const [command, action, id] = positionals;
241
338
  if (options.version || command === "version") return writeResult(VERSION, { json: options.json });
242
339
  if (options.help || !command || command === "help") return writeResult(HELP.trimEnd(), { json: false });
340
+ validateInvocation(options, positionals);
243
341
  if (command === "auth") {
244
342
  if (action === "login") return authLogin(options);
245
343
  if (action === "status") return authStatus(options);
@@ -247,6 +345,7 @@ async function main({ options, positionals }) {
247
345
  throw new CliError("auth action must be login, status, or logout.");
248
346
  }
249
347
  if (command === "capabilities") return capabilities(options);
348
+ if (command === "update" && (action || "check") === "check") return updateCheck(options);
250
349
  if (command === "skill" && action === "download") return downloadSkill(options);
251
350
  if (command === "catalog") return catalog(options);
252
351
  if (command === "query") return query(options);
@@ -259,7 +358,7 @@ async function main({ options, positionals }) {
259
358
  const runId = id || option(options, "id", { required: true });
260
359
  const purpose = option(options, "purpose", { required: true });
261
360
  const { api } = await client(options);
262
- 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 });
263
362
  }
264
363
  if (command === "usage" && action === "events") return listResource(options, "usage/events");
265
364
  if (command === "usage" && action === "summary") {
@@ -269,7 +368,7 @@ async function main({ options, positionals }) {
269
368
  if (command === "receipts" && action === "get") {
270
369
  const receiptId = id || option(options, "id", { required: true });
271
370
  const { api } = await client(options);
272
- 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 });
273
372
  }
274
373
  throw new CliError(`Unknown administrator command: ${positionals.join(" ")}`);
275
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,35 +165,148 @@ 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`;
201
+ }
202
+
203
+ function parseSemver(value) {
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-]+)*)?$/);
205
+ if (!match) return null;
206
+ return {
207
+ core: match.slice(1, 4).map((item) => Number.parseInt(item, 10)),
208
+ pre: match[4] ? match[4].split(".") : [],
209
+ };
210
+ }
211
+
212
+ export function compareVersions(left, right) {
213
+ const a = parseSemver(left);
214
+ const b = parseSemver(right);
215
+ if (!a || !b) return 0;
216
+ for (let index = 0; index < 3; index += 1) {
217
+ if (a.core[index] !== b.core[index]) return a.core[index] > b.core[index] ? 1 : -1;
218
+ }
219
+ if (a.pre.length === 0 && b.pre.length > 0) return 1;
220
+ if (b.pre.length === 0 && a.pre.length > 0) return -1;
221
+ for (let index = 0; index < Math.max(a.pre.length, b.pre.length); index += 1) {
222
+ if (a.pre[index] === undefined) return -1;
223
+ if (b.pre[index] === undefined) return 1;
224
+ if (a.pre[index] === b.pre[index]) continue;
225
+ const aNumber = /^\d+$/.test(a.pre[index]) ? Number.parseInt(a.pre[index], 10) : null;
226
+ const bNumber = /^\d+$/.test(b.pre[index]) ? Number.parseInt(b.pre[index], 10) : null;
227
+ if (aNumber !== null && bNumber !== null) return aNumber > bNumber ? 1 : -1;
228
+ if (aNumber !== null) return -1;
229
+ if (bNumber !== null) return 1;
230
+ return a.pre[index] > b.pre[index] ? 1 : -1;
231
+ }
232
+ return 0;
233
+ }
234
+
235
+ export async function resolveNpmUpdateStatus({
236
+ currentVersion,
237
+ policy,
238
+ userAgent,
239
+ allowRegistryFailure = false,
240
+ fetchImpl = globalThis.fetch,
241
+ registryBase = process.env.CORVIO_NPM_REGISTRY_BASE || "https://registry.npmjs.org",
242
+ }) {
243
+ const packageName = String(policy?.package || "").trim();
244
+ const distTag = String(policy?.dist_tag || "beta").trim();
245
+ if (!packageName) throw new CliError("The API did not advertise an npm package.", { code: "update_policy_invalid" });
246
+ const encoded = encodeURIComponent(packageName).replace("%40", "@");
247
+ let latestVersion = null;
248
+ let registryError = null;
249
+ try {
250
+ const registry = String(registryBase).replace(/\/+$/, "");
251
+ const response = await fetchImpl(`${registry}/${encoded}`, {
252
+ headers: { accept: "application/json", "user-agent": userAgent },
253
+ signal: AbortSignal.timeout(2500),
254
+ });
255
+ if (!response.ok) throw new Error(`npm registry returned ${response.status}`);
256
+ const metadata = await response.json();
257
+ latestVersion = String(metadata?.["dist-tags"]?.[distTag] || "").trim() || null;
258
+ } catch (error) {
259
+ registryError = error instanceof Error ? error.message : String(error);
260
+ if (!allowRegistryFailure) {
261
+ throw new CliError(`Unable to check npm for updates: ${registryError}`, {
262
+ code: "update_check_failed",
263
+ exitCode: EXIT.REMOTE,
264
+ });
265
+ }
266
+ }
267
+ const minimum = String(policy.minimum_supported_version || "").trim() || null;
268
+ return {
269
+ package: packageName,
270
+ channel: String(policy.channel || distTag),
271
+ dist_tag: distTag,
272
+ current_version: currentVersion,
273
+ latest_published_version: latestVersion,
274
+ reviewed_version: String(policy.reviewed_version || "").trim() || null,
275
+ minimum_supported_version: minimum,
276
+ update_available: Boolean(latestVersion && compareVersions(latestVersion, currentVersion) > 0),
277
+ supported: minimum ? compareVersions(currentVersion, minimum) >= 0 : true,
278
+ install_command: String(policy.install_command || `npm install --global ${packageName}@${distTag}`),
279
+ automatic_install: false,
280
+ ...(registryError ? { registry_error: registryError } : {}),
281
+ };
106
282
  }
107
283
 
108
284
  export class ApiClient {
109
- constructor({ baseUrl, credential = 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
+ }) {
110
293
  this.baseUrl = baseUrl.replace(/\/+$/, "");
111
294
  this.credential = credential;
295
+ this.workspaceId = String(workspaceId || "").trim() || null;
112
296
  this.userAgent = userAgent;
113
297
  this.fetchImpl = fetchImpl;
298
+ this.timeoutMs = normalizeRequestTimeout(timeoutMs);
114
299
  }
115
300
 
116
- 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
+ } = {}) {
117
310
  const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
118
311
  for (const [key, value] of Object.entries(query || {})) {
119
312
  if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
@@ -129,19 +322,61 @@ export class ApiClient {
129
322
  throw new CliError("Corvio authentication is required.", { code: "auth_required", exitCode: EXIT.AUTH });
130
323
  }
131
324
  requestHeaders.authorization = `Bearer ${this.credential}`;
325
+ if (this.workspaceId) {
326
+ requestHeaders["X-Corvio-Workspace-Id"] = this.workspaceId;
327
+ requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
328
+ }
132
329
  }
330
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
133
331
  let response;
134
- try {
135
- response = await this.fetchImpl(url, {
136
- method,
137
- headers: requestHeaders,
138
- body: body === undefined ? undefined : JSON.stringify(body),
139
- });
140
- } catch (cause) {
141
- throw new CliError(`Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`, {
142
- code: "network_error",
143
- exitCode: EXIT.REMOTE,
144
- });
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
+ }
145
380
  }
146
381
  const text = await response.text();
147
382
  let payload = null;
@@ -158,13 +393,18 @@ export class ApiClient {
158
393
  code: String(envelope.code || `http_${response.status}`),
159
394
  status: response.status,
160
395
  exitCode: exitCodeForStatus(response.status),
161
- 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
+ },
162
402
  });
163
403
  }
164
404
  return payload;
165
405
  }
166
406
 
167
- async requestBytes(path, { query, headers = {}, auth = true } = {}) {
407
+ async requestBytes(path, { query, headers = {}, auth = true, timeoutMs = this.timeoutMs } = {}) {
168
408
  const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
169
409
  for (const [key, value] of Object.entries(query || {})) {
170
410
  if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
@@ -179,15 +419,43 @@ export class ApiClient {
179
419
  throw new CliError("Corvio authentication is required.", { code: "auth_required", exitCode: EXIT.AUTH });
180
420
  }
181
421
  requestHeaders.authorization = `Bearer ${this.credential}`;
422
+ if (this.workspaceId) {
423
+ requestHeaders["X-Corvio-Workspace-Id"] = this.workspaceId;
424
+ requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
425
+ }
182
426
  }
427
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
183
428
  let response;
184
- try {
185
- response = await this.fetchImpl(url, { method: "GET", headers: requestHeaders });
186
- } catch (cause) {
187
- throw new CliError(`Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`, {
188
- code: "network_error",
189
- exitCode: EXIT.REMOTE,
190
- });
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
+ }
191
459
  }
192
460
  const bytes = new Uint8Array(await response.arrayBuffer());
193
461
  if (!response.ok) {
@@ -203,11 +471,68 @@ export class ApiClient {
203
471
  code: String(envelope.code || `http_${response.status}`),
204
472
  status: response.status,
205
473
  exitCode: exitCodeForStatus(response.status),
206
- 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
+ },
207
480
  });
208
481
  }
209
482
  return bytes;
210
483
  }
484
+
485
+ async putUrl(url, content, { headers = {}, timeoutMs = this.timeoutMs } = {}) {
486
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
487
+ let response;
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
+ }
525
+ }
526
+ if (!response.ok) {
527
+ const message = (await response.text()).slice(0, 500);
528
+ throw new CliError(message || `File upload failed (${response.status}).`, {
529
+ code: "upload_failed",
530
+ status: response.status,
531
+ exitCode: EXIT.REMOTE,
532
+ });
533
+ }
534
+ return { ok: true, status: response.status };
535
+ }
211
536
  }
212
537
 
213
538
  function configRoot(env = process.env) {
@@ -220,6 +545,10 @@ export function workspaceCredentialPath(env = process.env) {
220
545
  return join(configRoot(env), "credentials.json");
221
546
  }
222
547
 
548
+ export function workspaceContextPath(env = process.env) {
549
+ return join(configRoot(env), "workspace-context.json");
550
+ }
551
+
223
552
  export function adminCredentialPath(env = process.env) {
224
553
  return join(configRoot(env), "admin-credentials.json");
225
554
  }
@@ -227,8 +556,8 @@ export function adminCredentialPath(env = process.env) {
227
556
  export async function readWorkspaceCredential(env = process.env) {
228
557
  const environmentKey = String(env.CORVIO_API_KEY || "").trim();
229
558
  if (environmentKey) {
230
- if (!environmentKey.startsWith("cvk_")) {
231
- throw new CliError("CORVIO_API_KEY must be a Workspace cvk_ key.", {
559
+ if (!environmentKey.startsWith("cvu_") && !environmentKey.startsWith("cvk_")) {
560
+ throw new CliError("CORVIO_API_KEY must be a user cvu_ key or legacy Workspace cvk_ key.", {
232
561
  code: "invalid_workspace_key",
233
562
  exitCode: EXIT.AUTH,
234
563
  });
@@ -245,14 +574,15 @@ export async function readWorkspaceCredential(env = process.env) {
245
574
  }
246
575
  const profileName = String(parsed.active_profile || "default");
247
576
  const profile = parsed.profiles?.[profileName];
248
- const key = String(profile?.api_key || "").trim();
249
- if (!key.startsWith("cvk_")) {
577
+ const metadata = String(parsed.api_key || "").trim() ? parsed : profile;
578
+ const key = String(metadata?.api_key || "").trim();
579
+ if (!key.startsWith("cvu_") && !key.startsWith("cvk_")) {
250
580
  throw new CliError("Stored Corvio credentials are invalid; run `corvio auth login` again.", {
251
581
  code: "credential_store_invalid",
252
582
  exitCode: EXIT.AUTH,
253
583
  });
254
584
  }
255
- return { key, source: "device_link", profile: profileName, metadata: profile };
585
+ return { key, source: "device_link", profile: profileName, metadata };
256
586
  }
257
587
 
258
588
  export async function storeWorkspaceCredential(payload, env = process.env) {
@@ -260,28 +590,73 @@ export async function storeWorkspaceCredential(payload, env = process.env) {
260
590
  const root = dirname(path);
261
591
  await mkdir(root, { recursive: true, mode: 0o700 });
262
592
  await chmod(root, 0o700).catch(() => {});
263
- const profileName = String(payload.workspace_id || "default");
593
+ const document = {
594
+ version: 2,
595
+ api_key: payload.secret,
596
+ key_id: payload.key_id,
597
+ audience: payload.audience || "user",
598
+ scopes: payload.scopes || [],
599
+ expires_at: payload.expires_at || null,
600
+ created_at: payload.created_at || new Date().toISOString(),
601
+ source: "device_link",
602
+ };
603
+ const temporary = `${path}.${randomUUID()}.tmp`;
604
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
605
+ await chmod(temporary, 0o600);
606
+ await rename(temporary, path);
607
+ await chmod(path, 0o600);
608
+ return { path, profile: "default" };
609
+ }
610
+
611
+ export async function readWorkspaceContext(env = process.env) {
612
+ const environmentWorkspaceId = String(env.CORVIO_WORKSPACE_ID || "").trim();
613
+ if (environmentWorkspaceId) {
614
+ return { workspaceId: environmentWorkspaceId, workspaceName: null, source: "environment" };
615
+ }
616
+ const path = workspaceContextPath(env);
617
+ try {
618
+ const parsed = JSON.parse(await readFile(path, "utf8"));
619
+ const workspaceId = String(parsed.active_workspace_id || "").trim();
620
+ if (!workspaceId) return null;
621
+ return {
622
+ workspaceId,
623
+ workspaceName: String(parsed.active_workspace_name || "").trim() || null,
624
+ source: "stored",
625
+ };
626
+ } catch (cause) {
627
+ if (cause?.code === "ENOENT") return null;
628
+ throw new CliError("Stored Corvio Workspace selection is unreadable.", {
629
+ code: "workspace_context_store_invalid",
630
+ exitCode: EXIT.AUTH,
631
+ });
632
+ }
633
+ }
634
+
635
+ export async function storeWorkspaceContext({ workspaceId, workspaceName = null }, env = process.env) {
636
+ const normalizedId = String(workspaceId || "").trim();
637
+ if (!normalizedId) throw new CliError("Workspace ID is required.", { code: "workspace_selection_required" });
638
+ const path = workspaceContextPath(env);
639
+ const root = dirname(path);
640
+ await mkdir(root, { recursive: true, mode: 0o700 });
641
+ await chmod(root, 0o700).catch(() => {});
264
642
  const document = {
265
643
  version: 1,
266
- active_profile: profileName,
267
- profiles: {
268
- [profileName]: {
269
- api_key: payload.secret,
270
- key_id: payload.key_id,
271
- workspace_id: payload.workspace_id,
272
- workspace_name: payload.workspace_name,
273
- scopes: payload.scopes || [],
274
- created_at: payload.created_at || new Date().toISOString(),
275
- source: "device_link",
276
- },
277
- },
644
+ active_workspace_id: normalizedId,
645
+ active_workspace_name: String(workspaceName || "").trim() || null,
646
+ updated_at: new Date().toISOString(),
278
647
  };
279
648
  const temporary = `${path}.${randomUUID()}.tmp`;
280
649
  await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
281
650
  await chmod(temporary, 0o600);
282
651
  await rename(temporary, path);
283
652
  await chmod(path, 0o600);
284
- return { path, profile: profileName };
653
+ return { path, workspaceId: normalizedId };
654
+ }
655
+
656
+ export async function clearWorkspaceContext(env = process.env) {
657
+ const path = workspaceContextPath(env);
658
+ await rm(path, { force: true });
659
+ return path;
285
660
  }
286
661
 
287
662
  export async function clearWorkspaceCredential(env = process.env) {
@@ -369,7 +744,21 @@ export function writeError(error, { json = false, stderr = process.stderr } = {}
369
744
  ...(error instanceof CliError && error.details ? { details: error.details } : {}),
370
745
  },
371
746
  };
372
- 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
+ }
373
762
  }
374
763
 
375
764
  export async function runMain(main, argv = process.argv.slice(2)) {
@@ -386,15 +775,26 @@ export async function runMain(main, argv = process.argv.slice(2)) {
386
775
 
387
776
  export async function openBrowser(url) {
388
777
  const target = String(url);
389
- const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
390
- const args = platform() === "win32" ? ["/c", "start", "", target] : [target];
391
- try {
392
- const child = spawn(command, args, { detached: true, stdio: "ignore" });
393
- child.unref();
394
- return true;
395
- } catch {
396
- return false;
397
- }
778
+ const command = platform() === "darwin" ? "open" : platform() === "win32" ? "rundll32.exe" : "xdg-open";
779
+ const args = platform() === "win32" ? ["url.dll,FileProtocolHandler", target] : [target];
780
+ return new Promise((resolvePromise) => {
781
+ let settled = false;
782
+ const finish = (opened) => {
783
+ if (settled) return;
784
+ settled = true;
785
+ resolvePromise(opened);
786
+ };
787
+ try {
788
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
789
+ child.once("error", () => finish(false));
790
+ child.once("spawn", () => {
791
+ child.unref();
792
+ finish(true);
793
+ });
794
+ } catch {
795
+ finish(false);
796
+ }
797
+ });
398
798
  }
399
799
 
400
800
  export async function sleep(milliseconds) {
@@ -408,14 +808,28 @@ export async function readTextFile(path) {
408
808
  export async function writeTextFile(path, content) {
409
809
  const target = resolve(String(path));
410
810
  await mkdir(dirname(target), { recursive: true });
411
- 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
+ }
412
819
  return target;
413
820
  }
414
821
 
415
822
  export async function writeBinaryFile(path, content) {
416
823
  const target = resolve(String(path));
417
824
  await mkdir(dirname(target), { recursive: true });
418
- 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
+ }
419
833
  return target;
420
834
  }
421
835
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@corvio/admin-cli",
3
- "version": "0.1.0-beta.3",
3
+ "version": "0.1.0-beta.5",
4
4
  "description": "Official read-only Corvio platform administrator CLI.",
5
5
  "type": "module",
6
6
  "bin": {