@corvio/cli 0.1.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +45 -0
  2. package/dist/cli.js +2681 -0
  3. package/dist/core.js +1081 -0
  4. package/package.json +40 -0
package/dist/core.js ADDED
@@ -0,0 +1,1081 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { homedir, platform } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+
7
+ export const EXIT = Object.freeze({
8
+ OK: 0,
9
+ USAGE: 2,
10
+ AUTH: 3,
11
+ UNAVAILABLE: 4,
12
+ CONFLICT: 5,
13
+ RATE_LIMIT: 6,
14
+ REMOTE: 7,
15
+ });
16
+
17
+ export class CliError extends Error {
18
+ constructor(message, { code = "cli_error", exitCode = EXIT.USAGE, status = null, details = null } = {}) {
19
+ super(message);
20
+ this.name = "CliError";
21
+ this.code = code;
22
+ this.exitCode = exitCode;
23
+ this.status = status;
24
+ this.details = details;
25
+ }
26
+ }
27
+
28
+ function exitCodeForStatus(status) {
29
+ if (status === 401 || status === 403) return EXIT.AUTH;
30
+ if (status === 409) return EXIT.CONFLICT;
31
+ if (status === 429) return EXIT.RATE_LIMIT;
32
+ return EXIT.REMOTE;
33
+ }
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
+
88
+ export function parseArgv(argv) {
89
+ const options = Object.create(null);
90
+ const positionals = [];
91
+ const booleanOptions = new Set([
92
+ "json",
93
+ "no-input",
94
+ "yes",
95
+ "allow-actions",
96
+ "private-link",
97
+ "force",
98
+ "once",
99
+ "include-revoked",
100
+ "recover-credential",
101
+ "no-listen",
102
+ "start-new-chain",
103
+ "help",
104
+ "version",
105
+ ]);
106
+ for (let index = 0; index < argv.length; index += 1) {
107
+ const value = argv[index];
108
+ if (value === "--") {
109
+ positionals.push(...argv.slice(index + 1));
110
+ break;
111
+ }
112
+ if (!value.startsWith("--")) {
113
+ positionals.push(value);
114
+ continue;
115
+ }
116
+ const separator = value.indexOf("=");
117
+ const name = value.slice(2, separator === -1 ? undefined : separator);
118
+ if (!name) throw new CliError("Option name must not be empty.", { code: "invalid_option" });
119
+ if (Object.hasOwn(options, name)) {
120
+ throw new CliError(`Option --${name} was provided more than once.`, { code: "duplicate_option" });
121
+ }
122
+ if (booleanOptions.has(name)) {
123
+ const booleanValue = separator === -1 ? "true" : value.slice(separator + 1);
124
+ if (booleanValue !== "true" && booleanValue !== "false") {
125
+ throw new CliError(`Option --${name} must be true or false.`, { code: "invalid_boolean_option" });
126
+ }
127
+ options[name] = booleanValue === "true";
128
+ continue;
129
+ }
130
+ if (separator !== -1) {
131
+ options[name] = value.slice(separator + 1);
132
+ continue;
133
+ }
134
+ const next = argv[index + 1];
135
+ if (next === undefined || next.startsWith("--")) {
136
+ throw new CliError(`Option --${name} requires a value.`, { code: "missing_option_value" });
137
+ }
138
+ options[name] = next;
139
+ index += 1;
140
+ }
141
+ return { options, positionals };
142
+ }
143
+
144
+ export function assertKnownOptions(options, allowed) {
145
+ const allowedSet = new Set(allowed);
146
+ for (const name of Object.keys(options)) {
147
+ if (!allowedSet.has(name)) {
148
+ throw new CliError(`Unknown option --${name}.`, { code: "unknown_option" });
149
+ }
150
+ }
151
+ }
152
+
153
+ export function assertPositionalCount(positionals, maximum) {
154
+ if (positionals.length > maximum) {
155
+ throw new CliError(`Unexpected argument: ${positionals[maximum]}`, { code: "unexpected_argument" });
156
+ }
157
+ }
158
+
159
+ export function option(options, name, { required = false, fallback = undefined } = {}) {
160
+ const value = options[name] ?? fallback;
161
+ if (required && (value === undefined || String(value).trim() === "")) {
162
+ throw new CliError(`Missing required option --${name}.`, { code: "missing_required_option" });
163
+ }
164
+ return value;
165
+ }
166
+
167
+ export function listOption(options, name, fallback = []) {
168
+ const value = options[name];
169
+ if (value === undefined) return fallback;
170
+ return String(value).split(",").map((item) => item.trim()).filter(Boolean);
171
+ }
172
+
173
+ export function numberOption(options, name, fallback, { minimum = 1, maximum = Number.MAX_SAFE_INTEGER } = {}) {
174
+ const value = options[name];
175
+ if (value === undefined) return fallback;
176
+ const normalized = String(value).trim();
177
+ const parsed = /^\d+$/.test(normalized) ? Number(normalized) : Number.NaN;
178
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
179
+ throw new CliError(
180
+ `Option --${name} must be an integer between ${minimum} and ${maximum}.`,
181
+ { code: "invalid_number_option" },
182
+ );
183
+ }
184
+ return parsed;
185
+ }
186
+
187
+ export function normalizeApiBase(value, fallback) {
188
+ const raw = String(value || fallback).trim().replace(/\/+$/, "");
189
+ let parsed;
190
+ try {
191
+ parsed = new URL(raw);
192
+ } catch {
193
+ throw new CliError("Corvio API base must be a valid URL.", { code: "invalid_api_base" });
194
+ }
195
+ const localhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
196
+ if (parsed.username || parsed.password || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && localhost))) {
197
+ throw new CliError("Corvio API base must use HTTPS (localhost is allowed for development).", {
198
+ code: "insecure_api_base",
199
+ });
200
+ }
201
+ if (parsed.search || parsed.hash) {
202
+ throw new CliError("Corvio API base must not include a query string or fragment.", { code: "invalid_api_base" });
203
+ }
204
+ const normalized = parsed.toString().replace(/\/+$/, "");
205
+ return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
206
+ }
207
+
208
+ function parseSemver(value) {
209
+ 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-]+)*)?$/);
210
+ if (!match) return null;
211
+ return {
212
+ core: match.slice(1, 4).map((item) => Number.parseInt(item, 10)),
213
+ pre: match[4] ? match[4].split(".") : [],
214
+ };
215
+ }
216
+
217
+ export function compareVersions(left, right) {
218
+ const a = parseSemver(left);
219
+ const b = parseSemver(right);
220
+ if (!a || !b) return 0;
221
+ for (let index = 0; index < 3; index += 1) {
222
+ if (a.core[index] !== b.core[index]) return a.core[index] > b.core[index] ? 1 : -1;
223
+ }
224
+ if (a.pre.length === 0 && b.pre.length > 0) return 1;
225
+ if (b.pre.length === 0 && a.pre.length > 0) return -1;
226
+ for (let index = 0; index < Math.max(a.pre.length, b.pre.length); index += 1) {
227
+ if (a.pre[index] === undefined) return -1;
228
+ if (b.pre[index] === undefined) return 1;
229
+ if (a.pre[index] === b.pre[index]) continue;
230
+ const aNumber = /^\d+$/.test(a.pre[index]) ? Number.parseInt(a.pre[index], 10) : null;
231
+ const bNumber = /^\d+$/.test(b.pre[index]) ? Number.parseInt(b.pre[index], 10) : null;
232
+ if (aNumber !== null && bNumber !== null) return aNumber > bNumber ? 1 : -1;
233
+ if (aNumber !== null) return -1;
234
+ if (bNumber !== null) return 1;
235
+ return a.pre[index] > b.pre[index] ? 1 : -1;
236
+ }
237
+ return 0;
238
+ }
239
+
240
+ export async function resolveNpmUpdateStatus({
241
+ currentVersion,
242
+ policy,
243
+ userAgent,
244
+ allowRegistryFailure = false,
245
+ fetchImpl = globalThis.fetch,
246
+ registryBase = process.env.CORVIO_NPM_REGISTRY_BASE || "https://registry.npmjs.org",
247
+ }) {
248
+ const packageName = String(policy?.package || "").trim();
249
+ const distTag = String(policy?.dist_tag || "beta").trim();
250
+ if (!packageName) throw new CliError("The API did not advertise an npm package.", { code: "update_policy_invalid" });
251
+ const encoded = encodeURIComponent(packageName).replace("%40", "@");
252
+ let latestVersion = null;
253
+ let registryError = null;
254
+ try {
255
+ const registry = String(registryBase).replace(/\/+$/, "");
256
+ const response = await fetchImpl(`${registry}/${encoded}`, {
257
+ headers: { accept: "application/json", "user-agent": userAgent },
258
+ signal: AbortSignal.timeout(2500),
259
+ });
260
+ if (!response.ok) throw new Error(`npm registry returned ${response.status}`);
261
+ const metadata = await response.json();
262
+ latestVersion = String(metadata?.["dist-tags"]?.[distTag] || "").trim() || null;
263
+ } catch (error) {
264
+ registryError = error instanceof Error ? error.message : String(error);
265
+ if (!allowRegistryFailure) {
266
+ throw new CliError(`Unable to check npm for updates: ${registryError}`, {
267
+ code: "update_check_failed",
268
+ exitCode: EXIT.REMOTE,
269
+ });
270
+ }
271
+ }
272
+ const minimum = String(policy.minimum_supported_version || "").trim() || null;
273
+ return {
274
+ package: packageName,
275
+ channel: String(policy.channel || distTag),
276
+ dist_tag: distTag,
277
+ current_version: currentVersion,
278
+ latest_published_version: latestVersion,
279
+ reviewed_version: String(policy.reviewed_version || "").trim() || null,
280
+ minimum_supported_version: minimum,
281
+ update_available: Boolean(latestVersion && compareVersions(latestVersion, currentVersion) > 0),
282
+ supported: minimum ? compareVersions(currentVersion, minimum) >= 0 : true,
283
+ install_command: String(policy.install_command || `npm install --global ${packageName}@${distTag}`),
284
+ automatic_install: false,
285
+ ...(registryError ? { registry_error: registryError } : {}),
286
+ };
287
+ }
288
+
289
+ export class ApiClient {
290
+ constructor({
291
+ baseUrl,
292
+ credential = null,
293
+ workspaceId = null,
294
+ userAgent,
295
+ fetchImpl = globalThis.fetch,
296
+ timeoutMs = process.env.CORVIO_REQUEST_TIMEOUT_MS,
297
+ }) {
298
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
299
+ this.credential = credential;
300
+ this.workspaceId = String(workspaceId || "").trim() || null;
301
+ this.userAgent = userAgent;
302
+ this.fetchImpl = fetchImpl;
303
+ this.timeoutMs = normalizeRequestTimeout(timeoutMs);
304
+ }
305
+
306
+ async request(path, {
307
+ method = "GET",
308
+ body,
309
+ query,
310
+ headers = {},
311
+ auth = true,
312
+ retrySafe = false,
313
+ timeoutMs = this.timeoutMs,
314
+ } = {}) {
315
+ const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
316
+ for (const [key, value] of Object.entries(query || {})) {
317
+ if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
318
+ }
319
+ const requestHeaders = {
320
+ accept: "application/json",
321
+ "user-agent": this.userAgent,
322
+ ...headers,
323
+ };
324
+ if (body !== undefined) requestHeaders["content-type"] = "application/json";
325
+ if (auth) {
326
+ if (!this.credential) {
327
+ throw new CliError("Corvio authentication is required.", { code: "auth_required", exitCode: EXIT.AUTH });
328
+ }
329
+ requestHeaders.authorization = `Bearer ${this.credential}`;
330
+ if (this.workspaceId) {
331
+ requestHeaders["X-Corvio-Workspace-Id"] = this.workspaceId;
332
+ requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
333
+ }
334
+ }
335
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
336
+ let response;
337
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
338
+ try {
339
+ response = await this.fetchImpl(url, {
340
+ method,
341
+ headers: requestHeaders,
342
+ body: body === undefined ? undefined : JSON.stringify(body),
343
+ signal: AbortSignal.timeout(normalizedTimeout),
344
+ });
345
+ const safety = networkFailureDetails({
346
+ cause: null,
347
+ url,
348
+ method,
349
+ requestHeaders,
350
+ body,
351
+ retrySafe,
352
+ });
353
+ if (attempt === 1 && safety.safe_to_retry && RETRYABLE_HTTP_STATUSES.has(response.status)) {
354
+ await response.body?.cancel().catch(() => {});
355
+ const retryAfterValue = response.headers.get("retry-after");
356
+ const retryAfterSeconds = retryAfterValue === null ? Number.NaN : Number(retryAfterValue);
357
+ const delayMs = Number.isFinite(retryAfterSeconds)
358
+ ? Math.min(2_000, Math.max(0, retryAfterSeconds * 1000))
359
+ : 150;
360
+ await sleep(delayMs);
361
+ continue;
362
+ }
363
+ break;
364
+ } catch (cause) {
365
+ const details = networkFailureDetails({ cause, url, method, requestHeaders, body, retrySafe });
366
+ if (attempt === 1 && details.safe_to_retry) {
367
+ await sleep(150);
368
+ continue;
369
+ }
370
+ throw new CliError(
371
+ details.timed_out
372
+ ? `Corvio did not respond within ${normalizedTimeout} ms.`
373
+ : `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
374
+ {
375
+ code: details.timed_out ? "request_timeout" : "network_error",
376
+ exitCode: EXIT.REMOTE,
377
+ details: {
378
+ ...details,
379
+ attempts: attempt,
380
+ automatic_retry_attempted: attempt > 1,
381
+ },
382
+ },
383
+ );
384
+ }
385
+ }
386
+ const text = await response.text();
387
+ let payload = null;
388
+ if (text) {
389
+ try {
390
+ payload = JSON.parse(text);
391
+ } catch {
392
+ payload = { message: text.slice(0, 500) };
393
+ }
394
+ }
395
+ if (!response.ok) {
396
+ const envelope = payload?.error || payload?.detail || payload || {};
397
+ throw new CliError(String(envelope.message || envelope.code || `Corvio request failed (${response.status}).`), {
398
+ code: String(envelope.code || `http_${response.status}`),
399
+ status: response.status,
400
+ exitCode: exitCodeForStatus(response.status),
401
+ details: {
402
+ ...(envelope.details || {}),
403
+ ...(envelope.request_id || response.headers.get("x-request-id")
404
+ ? { request_id: envelope.request_id || response.headers.get("x-request-id") }
405
+ : {}),
406
+ },
407
+ });
408
+ }
409
+ return payload;
410
+ }
411
+
412
+ async requestBytes(path, { query, headers = {}, auth = true, timeoutMs = this.timeoutMs } = {}) {
413
+ const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
414
+ for (const [key, value] of Object.entries(query || {})) {
415
+ if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
416
+ }
417
+ const requestHeaders = {
418
+ accept: "application/zip, application/octet-stream",
419
+ "user-agent": this.userAgent,
420
+ ...headers,
421
+ };
422
+ if (auth) {
423
+ if (!this.credential) {
424
+ throw new CliError("Corvio authentication is required.", { code: "auth_required", exitCode: EXIT.AUTH });
425
+ }
426
+ requestHeaders.authorization = `Bearer ${this.credential}`;
427
+ if (this.workspaceId) {
428
+ requestHeaders["X-Corvio-Workspace-Id"] = this.workspaceId;
429
+ requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
430
+ }
431
+ }
432
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
433
+ let response;
434
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
435
+ try {
436
+ response = await this.fetchImpl(url, {
437
+ method: "GET",
438
+ headers: requestHeaders,
439
+ signal: AbortSignal.timeout(normalizedTimeout),
440
+ });
441
+ if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
442
+ await response.body?.cancel().catch(() => {});
443
+ await sleep(150);
444
+ continue;
445
+ }
446
+ break;
447
+ } catch (cause) {
448
+ const details = networkFailureDetails({ cause, url, method: "GET", requestHeaders });
449
+ if (attempt === 1) {
450
+ await sleep(150);
451
+ continue;
452
+ }
453
+ throw new CliError(
454
+ details.timed_out
455
+ ? `Corvio did not respond within ${normalizedTimeout} ms.`
456
+ : `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
457
+ {
458
+ code: details.timed_out ? "request_timeout" : "network_error",
459
+ exitCode: EXIT.REMOTE,
460
+ details: { ...details, attempts: attempt, automatic_retry_attempted: true },
461
+ },
462
+ );
463
+ }
464
+ }
465
+ const bytes = new Uint8Array(await response.arrayBuffer());
466
+ if (!response.ok) {
467
+ const text = new TextDecoder().decode(bytes);
468
+ let payload = {};
469
+ try {
470
+ payload = JSON.parse(text);
471
+ } catch {
472
+ payload = { message: text.slice(0, 500) };
473
+ }
474
+ const envelope = payload?.error || payload?.detail || payload || {};
475
+ throw new CliError(String(envelope.message || envelope.code || `Corvio request failed (${response.status}).`), {
476
+ code: String(envelope.code || `http_${response.status}`),
477
+ status: response.status,
478
+ exitCode: exitCodeForStatus(response.status),
479
+ details: {
480
+ ...(envelope.details || {}),
481
+ ...(envelope.request_id || response.headers.get("x-request-id")
482
+ ? { request_id: envelope.request_id || response.headers.get("x-request-id") }
483
+ : {}),
484
+ },
485
+ });
486
+ }
487
+ return bytes;
488
+ }
489
+
490
+ async putUrl(url, content, { headers = {}, timeoutMs = this.timeoutMs } = {}) {
491
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
492
+ let response;
493
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
494
+ try {
495
+ response = await this.fetchImpl(String(url), {
496
+ method: "PUT",
497
+ headers,
498
+ body: content,
499
+ signal: AbortSignal.timeout(normalizedTimeout),
500
+ });
501
+ if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
502
+ await response.body?.cancel().catch(() => {});
503
+ await sleep(150);
504
+ continue;
505
+ }
506
+ break;
507
+ } catch (cause) {
508
+ const details = networkFailureDetails({
509
+ cause,
510
+ url,
511
+ method: "PUT",
512
+ requestHeaders: headers,
513
+ retrySafe: true,
514
+ });
515
+ if (attempt === 1) {
516
+ await sleep(150);
517
+ continue;
518
+ }
519
+ throw new CliError(
520
+ details.timed_out
521
+ ? `The file upload did not respond within ${normalizedTimeout} ms.`
522
+ : `Unable to upload the file: ${cause instanceof Error ? cause.message : String(cause)}`,
523
+ {
524
+ code: details.timed_out ? "upload_timeout" : "upload_network_error",
525
+ exitCode: EXIT.REMOTE,
526
+ details: { ...details, attempts: attempt, automatic_retry_attempted: true },
527
+ },
528
+ );
529
+ }
530
+ }
531
+ if (!response.ok) {
532
+ const message = (await response.text()).slice(0, 500);
533
+ throw new CliError(message || `File upload failed (${response.status}).`, {
534
+ code: "upload_failed",
535
+ status: response.status,
536
+ exitCode: EXIT.REMOTE,
537
+ });
538
+ }
539
+ return { ok: true, status: response.status };
540
+ }
541
+ }
542
+
543
+ function configRoot(env = process.env) {
544
+ if (env.CORVIO_CONFIG_DIR) return resolve(env.CORVIO_CONFIG_DIR);
545
+ if (env.XDG_CONFIG_HOME) return join(resolve(env.XDG_CONFIG_HOME), "corvio");
546
+ return join(homedir(), ".config", "corvio");
547
+ }
548
+
549
+ export function workspaceCredentialPath(env = process.env) {
550
+ return join(configRoot(env), "credentials.json");
551
+ }
552
+
553
+ export function workspaceContextPath(env = process.env) {
554
+ return join(configRoot(env), "workspace-context.json");
555
+ }
556
+
557
+ export function adminCredentialPath(env = process.env) {
558
+ return join(configRoot(env), "admin-credentials.json");
559
+ }
560
+
561
+ export function agentCredentialPath(env = process.env) {
562
+ return join(configRoot(env), "agent-credentials.json");
563
+ }
564
+
565
+ export function conversationRoutingPath(env = process.env) {
566
+ return join(configRoot(env), "conversation-routing.json");
567
+ }
568
+
569
+ export async function readWorkspaceCredential(env = process.env) {
570
+ const environmentKey = String(env.CORVIO_API_KEY || "").trim();
571
+ if (environmentKey) {
572
+ if (!environmentKey.startsWith("cvu_") && !environmentKey.startsWith("cvk_")) {
573
+ throw new CliError("CORVIO_API_KEY must be a user cvu_ key or legacy Workspace cvk_ key.", {
574
+ code: "invalid_workspace_key",
575
+ exitCode: EXIT.AUTH,
576
+ });
577
+ }
578
+ return { key: environmentKey, source: "environment", profile: null, metadata: null };
579
+ }
580
+ const path = workspaceCredentialPath(env);
581
+ let parsed;
582
+ try {
583
+ parsed = JSON.parse(await readFile(path, "utf8"));
584
+ } catch (cause) {
585
+ if (cause?.code === "ENOENT") return null;
586
+ throw new CliError("Stored Corvio credentials are unreadable.", { code: "credential_store_invalid", exitCode: EXIT.AUTH });
587
+ }
588
+ const profileName = String(parsed.active_profile || "default");
589
+ const profile = parsed.profiles?.[profileName];
590
+ const metadata = String(parsed.api_key || "").trim() ? parsed : profile;
591
+ const key = String(metadata?.api_key || "").trim();
592
+ if (!key.startsWith("cvu_") && !key.startsWith("cvk_")) {
593
+ throw new CliError("Stored Corvio credentials are invalid; run `corvio auth login` again.", {
594
+ code: "credential_store_invalid",
595
+ exitCode: EXIT.AUTH,
596
+ });
597
+ }
598
+ return { key, source: "device_link", profile: profileName, metadata };
599
+ }
600
+
601
+ export async function readAgentCredential(
602
+ env = process.env,
603
+ {
604
+ required = true,
605
+ bindingId = null,
606
+ agentId = null,
607
+ projectRoot = null,
608
+ fallbackToActive = true,
609
+ } = {},
610
+ ) {
611
+ const key = String(env.CORVIO_AGENT_API_KEY || "").trim();
612
+ if (key) {
613
+ if (!key.startsWith("cvg_")) {
614
+ throw new CliError("CORVIO_AGENT_API_KEY must be an Agent cvg_ key.", {
615
+ code: "invalid_agent_key",
616
+ exitCode: EXIT.AUTH,
617
+ });
618
+ }
619
+ return { key, source: "environment", profile: null, metadata: null };
620
+ }
621
+ const path = agentCredentialPath(env);
622
+ let parsed;
623
+ try {
624
+ parsed = JSON.parse(await readFile(path, "utf8"));
625
+ } catch (cause) {
626
+ if (cause?.code === "ENOENT" && !required) return null;
627
+ if (cause?.code === "ENOENT") {
628
+ throw new CliError(
629
+ "No Agent credential found. Run `corvio agents connect` or set CORVIO_AGENT_API_KEY.",
630
+ { code: "agent_auth_required", exitCode: EXIT.AUTH },
631
+ );
632
+ }
633
+ throw new CliError("Stored Agent credentials are unreadable.", {
634
+ code: "agent_credential_store_invalid",
635
+ exitCode: EXIT.AUTH,
636
+ });
637
+ }
638
+ let selected = parsed;
639
+ let selectedProfile = "default";
640
+ if (Number(parsed?.version) >= 2 && parsed?.credentials && typeof parsed.credentials === "object") {
641
+ const entries = Object.entries(parsed.credentials);
642
+ let match = null;
643
+ if (bindingId) {
644
+ match = entries.find(([key, value]) => key === bindingId || value?.binding_id === bindingId) || null;
645
+ }
646
+ if (!match && agentId) {
647
+ match = entries.find(([, value]) => String(value?.agent_id || "") === String(agentId)) || null;
648
+ }
649
+ if (!match && projectRoot) {
650
+ const normalizedRoot = resolve(String(projectRoot));
651
+ const candidates = entries
652
+ .filter(([, value]) => {
653
+ const storedRoot = String(value?.project_root || "").trim();
654
+ if (!storedRoot) return false;
655
+ const normalizedStored = resolve(storedRoot);
656
+ return normalizedRoot === normalizedStored || normalizedRoot.startsWith(`${normalizedStored}${process.platform === "win32" ? "\\" : "/"}`);
657
+ })
658
+ .sort((left, right) => String(right[1]?.project_root || "").length - String(left[1]?.project_root || "").length);
659
+ match = candidates[0] || null;
660
+ }
661
+ if (!match && fallbackToActive && parsed.active_binding_id) {
662
+ const value = parsed.credentials[parsed.active_binding_id];
663
+ if (value) match = [parsed.active_binding_id, value];
664
+ }
665
+ if (!match && fallbackToActive && entries.length === 1) match = entries[0];
666
+ if (!match) {
667
+ if (!required) return null;
668
+ throw new CliError(
669
+ "Multiple Agent project bindings are stored. Run from a connected project or pass --agent <id>.",
670
+ { code: "agent_routing_ambiguous", exitCode: EXIT.AUTH },
671
+ );
672
+ }
673
+ [selectedProfile, selected] = match;
674
+ }
675
+ const storedKey = String(selected?.api_key || "").trim();
676
+ if (!storedKey.startsWith("cvg_")) {
677
+ throw new CliError(
678
+ "Stored Agent credentials are invalid; run `corvio agents connect --recover-credential`.",
679
+ { code: "agent_credential_store_invalid", exitCode: EXIT.AUTH },
680
+ );
681
+ }
682
+ return { key: storedKey, source: "secure_store", profile: selectedProfile, metadata: selected };
683
+ }
684
+
685
+ export async function storeAgentCredential(payload, env = process.env) {
686
+ const secret = String(payload?.secret || "").trim();
687
+ if (!secret.startsWith("cvg_")) {
688
+ throw new CliError("Agent connection did not return a valid cvg_ credential.", {
689
+ code: "agent_credential_invalid",
690
+ exitCode: EXIT.AUTH,
691
+ });
692
+ }
693
+ const path = agentCredentialPath(env);
694
+ const root = dirname(path);
695
+ await mkdir(root, { recursive: true, mode: 0o700 });
696
+ await chmod(root, 0o700).catch(() => {});
697
+ const credential = {
698
+ api_key: secret,
699
+ agent_id: String(payload.agent_id || ""),
700
+ binding_id: String(payload.binding_id || "").trim() || null,
701
+ project_root: String(payload.project_root || "").trim() || null,
702
+ project_label: String(payload.project_label || "").trim() || null,
703
+ project_fingerprint: String(payload.project_fingerprint || "").trim() || null,
704
+ display_name: String(payload.display_name || ""),
705
+ provider: String(payload.provider || "custom"),
706
+ permission_profile: String(payload.permission_profile || "read_only"),
707
+ trigger_policy: String(payload.trigger_policy || "owner_only"),
708
+ skills_scope: String(payload.skills_scope || "project"),
709
+ memory_scope: String(payload.memory_scope || "off"),
710
+ connector_scope: String(payload.connector_scope || "off"),
711
+ display_prefix: String(payload.display_prefix || "cvg"),
712
+ created_at: payload.created_at || new Date().toISOString(),
713
+ source: "agent_connect",
714
+ };
715
+ let current = null;
716
+ try {
717
+ current = JSON.parse(await readFile(path, "utf8"));
718
+ } catch (error) {
719
+ if (error?.code !== "ENOENT") {
720
+ throw new CliError("Stored Agent credentials are unreadable.", {
721
+ code: "agent_credential_store_invalid",
722
+ exitCode: EXIT.AUTH,
723
+ });
724
+ }
725
+ }
726
+ const credentials = Number(current?.version) >= 2 && current?.credentials && typeof current.credentials === "object"
727
+ ? { ...current.credentials }
728
+ : {};
729
+ if (String(current?.api_key || "").startsWith("cvg_") && current?.agent_id) {
730
+ const legacyKey = String(current.binding_id || `legacy:${current.agent_id}`);
731
+ credentials[legacyKey] = { ...current, binding_id: current.binding_id || null };
732
+ }
733
+ const profile = credential.binding_id || `agent:${credential.agent_id}`;
734
+ credentials[profile] = credential;
735
+ const document = {
736
+ version: 2,
737
+ active_binding_id: profile,
738
+ credentials,
739
+ };
740
+ const temporary = `${path}.${randomUUID()}.tmp`;
741
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
742
+ await chmod(temporary, 0o600);
743
+ await rename(temporary, path);
744
+ await chmod(path, 0o600);
745
+ return { path, profile, metadata: credential };
746
+ }
747
+
748
+ export async function storeWorkspaceCredential(payload, env = process.env) {
749
+ const path = workspaceCredentialPath(env);
750
+ const root = dirname(path);
751
+ await mkdir(root, { recursive: true, mode: 0o700 });
752
+ await chmod(root, 0o700).catch(() => {});
753
+ const document = {
754
+ version: 2,
755
+ api_key: payload.secret,
756
+ key_id: payload.key_id,
757
+ audience: payload.audience || "user",
758
+ scopes: payload.scopes || [],
759
+ expires_at: payload.expires_at || null,
760
+ created_at: payload.created_at || new Date().toISOString(),
761
+ source: "device_link",
762
+ };
763
+ const temporary = `${path}.${randomUUID()}.tmp`;
764
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
765
+ await chmod(temporary, 0o600);
766
+ await rename(temporary, path);
767
+ await chmod(path, 0o600);
768
+ return { path, profile: "default" };
769
+ }
770
+
771
+ export async function readWorkspaceContext(env = process.env) {
772
+ const environmentWorkspaceId = String(env.CORVIO_WORKSPACE_ID || "").trim();
773
+ if (environmentWorkspaceId) {
774
+ return { workspaceId: environmentWorkspaceId, workspaceName: null, source: "environment" };
775
+ }
776
+ const path = workspaceContextPath(env);
777
+ try {
778
+ const parsed = JSON.parse(await readFile(path, "utf8"));
779
+ const workspaceId = String(parsed.active_workspace_id || "").trim();
780
+ if (!workspaceId) return null;
781
+ return {
782
+ workspaceId,
783
+ workspaceName: String(parsed.active_workspace_name || "").trim() || null,
784
+ source: "stored",
785
+ routingPreference: parsed.routing_preference || null,
786
+ };
787
+ } catch (cause) {
788
+ if (cause?.code === "ENOENT") return null;
789
+ throw new CliError("Stored Corvio Workspace selection is unreadable.", {
790
+ code: "workspace_context_store_invalid",
791
+ exitCode: EXIT.AUTH,
792
+ });
793
+ }
794
+ }
795
+
796
+ export async function storeWorkspaceContext({ workspaceId, workspaceName = null, routingPreference = null }, env = process.env) {
797
+ const normalizedId = String(workspaceId || "").trim();
798
+ if (!normalizedId) throw new CliError("Workspace ID is required.", { code: "workspace_selection_required" });
799
+ const path = workspaceContextPath(env);
800
+ const root = dirname(path);
801
+ await mkdir(root, { recursive: true, mode: 0o700 });
802
+ await chmod(root, 0o700).catch(() => {});
803
+ const document = {
804
+ version: 1,
805
+ active_workspace_id: normalizedId,
806
+ active_workspace_name: String(workspaceName || "").trim() || null,
807
+ routing_preference: routingPreference || {
808
+ workspace_id: normalizedId,
809
+ provenance: "user_confirmed_cli",
810
+ confidence: 1,
811
+ scope: "global",
812
+ expires_at: null,
813
+ },
814
+ updated_at: new Date().toISOString(),
815
+ };
816
+ const temporary = `${path}.${randomUUID()}.tmp`;
817
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
818
+ await chmod(temporary, 0o600);
819
+ await rename(temporary, path);
820
+ await chmod(path, 0o600);
821
+ return { path, workspaceId: normalizedId };
822
+ }
823
+
824
+ export async function readConversationRoutingReceipt(conversationId, env = process.env) {
825
+ const normalizedId = String(conversationId || "").trim();
826
+ if (!normalizedId) return null;
827
+ const path = conversationRoutingPath(env);
828
+ try {
829
+ const parsed = JSON.parse(await readFile(path, "utf8"));
830
+ const receipt = parsed?.receipts?.[normalizedId];
831
+ const workspaceId = String(receipt?.workspace_id || "").trim();
832
+ if (!workspaceId) return null;
833
+ return {
834
+ conversationId: normalizedId,
835
+ workspaceId,
836
+ agentId: String(receipt.agent_id || "").trim() || null,
837
+ apiBase: String(receipt.api_base || "").trim() || null,
838
+ recordedAt: String(receipt.recorded_at || "").trim() || null,
839
+ };
840
+ } catch (cause) {
841
+ if (cause?.code === "ENOENT") return null;
842
+ throw new CliError("Stored Corvio conversation routing receipts are unreadable.", {
843
+ code: "conversation_routing_store_invalid",
844
+ exitCode: EXIT.AUTH,
845
+ });
846
+ }
847
+ }
848
+
849
+ export async function storeConversationRoutingReceipt({ conversationId, workspaceId, agentId = null, apiBase = null }, env = process.env) {
850
+ const normalizedConversationId = String(conversationId || "").trim();
851
+ const normalizedWorkspaceId = String(workspaceId || "").trim();
852
+ if (!normalizedConversationId || !normalizedWorkspaceId) {
853
+ throw new CliError("Conversation and Workspace IDs are required for routing continuity.", {
854
+ code: "conversation_routing_receipt_invalid",
855
+ });
856
+ }
857
+ const path = conversationRoutingPath(env);
858
+ const root = dirname(path);
859
+ await mkdir(root, { recursive: true, mode: 0o700 });
860
+ await chmod(root, 0o700).catch(() => {});
861
+ let existing = {};
862
+ try {
863
+ const parsed = JSON.parse(await readFile(path, "utf8"));
864
+ if (parsed?.receipts && typeof parsed.receipts === "object" && !Array.isArray(parsed.receipts)) {
865
+ existing = parsed.receipts;
866
+ }
867
+ } catch (cause) {
868
+ if (cause?.code !== "ENOENT") {
869
+ throw new CliError("Stored Corvio conversation routing receipts are unreadable.", {
870
+ code: "conversation_routing_store_invalid",
871
+ exitCode: EXIT.AUTH,
872
+ });
873
+ }
874
+ }
875
+ const ordered = Object.entries({
876
+ ...existing,
877
+ [normalizedConversationId]: {
878
+ workspace_id: normalizedWorkspaceId,
879
+ agent_id: String(agentId || "").trim() || null,
880
+ api_base: String(apiBase || "").trim() || null,
881
+ recorded_at: new Date().toISOString(),
882
+ },
883
+ }).sort((left, right) => String(right[1]?.recorded_at || "").localeCompare(String(left[1]?.recorded_at || "")));
884
+ const document = { version: 1, receipts: Object.fromEntries(ordered.slice(0, 500)) };
885
+ const temporary = `${path}.${randomUUID()}.tmp`;
886
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
887
+ await chmod(temporary, 0o600);
888
+ await rename(temporary, path);
889
+ await chmod(path, 0o600);
890
+ return { path, conversationId: normalizedConversationId, workspaceId: normalizedWorkspaceId };
891
+ }
892
+
893
+ export async function clearWorkspaceContext(env = process.env) {
894
+ const path = workspaceContextPath(env);
895
+ await rm(path, { force: true });
896
+ return path;
897
+ }
898
+
899
+ export async function clearWorkspaceCredential(env = process.env) {
900
+ const path = workspaceCredentialPath(env);
901
+ await rm(path, { force: true });
902
+ return path;
903
+ }
904
+
905
+ export async function readAdminCredential(env = process.env) {
906
+ const key = String(env.CORVIO_ADMIN_API_KEY || "").trim();
907
+ if (key) {
908
+ if (!key.startsWith("cva_")) {
909
+ throw new CliError("CORVIO_ADMIN_API_KEY must be a platform administrator cva_ key.", {
910
+ code: "invalid_admin_key",
911
+ exitCode: EXIT.AUTH,
912
+ });
913
+ }
914
+ return { key, source: "environment", metadata: null };
915
+ }
916
+ const path = adminCredentialPath(env);
917
+ let parsed;
918
+ try {
919
+ parsed = JSON.parse(await readFile(path, "utf8"));
920
+ } catch (cause) {
921
+ if (cause?.code === "ENOENT") return null;
922
+ throw new CliError("Stored Corvio administrator credentials are unreadable.", {
923
+ code: "admin_credential_store_invalid",
924
+ exitCode: EXIT.AUTH,
925
+ });
926
+ }
927
+ const storedKey = String(parsed.api_key || "").trim();
928
+ if (!storedKey.startsWith("cva_")) {
929
+ throw new CliError("Stored Corvio administrator credentials are invalid; run `corvio-admin auth login` again.", {
930
+ code: "admin_credential_store_invalid",
931
+ exitCode: EXIT.AUTH,
932
+ });
933
+ }
934
+ return { key: storedKey, source: "device_link", metadata: parsed };
935
+ }
936
+
937
+ export async function storeAdminCredential(payload, env = process.env) {
938
+ const path = adminCredentialPath(env);
939
+ const root = dirname(path);
940
+ await mkdir(root, { recursive: true, mode: 0o700 });
941
+ await chmod(root, 0o700).catch(() => {});
942
+ const document = {
943
+ version: 1,
944
+ api_key: payload.secret,
945
+ key_id: payload.key_id,
946
+ audience: payload.audience,
947
+ api_base: payload.api_base,
948
+ scopes: payload.scopes || [],
949
+ expires_at: payload.expires_at,
950
+ created_at: payload.created_at || new Date().toISOString(),
951
+ source: "device_link",
952
+ };
953
+ const temporary = `${path}.${randomUUID()}.tmp`;
954
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
955
+ await chmod(temporary, 0o600);
956
+ await rename(temporary, path);
957
+ await chmod(path, 0o600);
958
+ return { path };
959
+ }
960
+
961
+ export async function clearAdminCredential(env = process.env) {
962
+ const path = adminCredentialPath(env);
963
+ await rm(path, { force: true });
964
+ return path;
965
+ }
966
+
967
+ export function writeResult(value, { json = false, stdout = process.stdout } = {}) {
968
+ if (json || typeof value !== "string") {
969
+ stdout.write(`${JSON.stringify(value, null, json ? 2 : 0)}\n`);
970
+ } else {
971
+ stdout.write(`${value}\n`);
972
+ }
973
+ }
974
+
975
+ export function writeError(error, { json = false, stderr = process.stderr } = {}) {
976
+ const safe = {
977
+ error: {
978
+ code: error instanceof CliError ? error.code : "unexpected_error",
979
+ message: error instanceof Error ? error.message : String(error),
980
+ ...(error instanceof CliError && error.status ? { status: error.status } : {}),
981
+ ...(error instanceof CliError && error.details ? { details: error.details } : {}),
982
+ },
983
+ };
984
+ if (json) {
985
+ stderr.write(`${JSON.stringify(safe)}\n`);
986
+ return;
987
+ }
988
+ stderr.write(`Error: ${safe.error.message}\n`);
989
+ if (safe.error.details?.request_id) stderr.write(`Request ID: ${safe.error.details.request_id}\n`);
990
+ if (safe.error.details?.safe_to_retry && safe.error.details?.idempotency_key) {
991
+ stderr.write(
992
+ `Safe retry: rerun the command with --idempotency-key ${safe.error.details.idempotency_key}\n`,
993
+ );
994
+ } else if (safe.error.details?.retry_safety === "revision_guard") {
995
+ stderr.write("Safe retry: rerun with the same --expected-revision; Corvio will reject an already-committed replacement.\n");
996
+ } else if (safe.error.details?.retry_safety === "unknown_commit_state") {
997
+ stderr.write("Retry state is unknown; read the remote object before repeating this mutation.\n");
998
+ }
999
+ }
1000
+
1001
+ export async function runMain(main, argv = process.argv.slice(2)) {
1002
+ let json = argv.includes("--json");
1003
+ try {
1004
+ const parsed = parseArgv(argv);
1005
+ json = Boolean(parsed.options.json);
1006
+ await main(parsed);
1007
+ } catch (error) {
1008
+ writeError(error, { json });
1009
+ process.exitCode = error instanceof CliError ? error.exitCode : 1;
1010
+ }
1011
+ }
1012
+
1013
+ export async function openBrowser(url) {
1014
+ const target = String(url);
1015
+ const command = platform() === "darwin" ? "open" : platform() === "win32" ? "rundll32.exe" : "xdg-open";
1016
+ const args = platform() === "win32" ? ["url.dll,FileProtocolHandler", target] : [target];
1017
+ return new Promise((resolvePromise) => {
1018
+ let settled = false;
1019
+ const finish = (opened) => {
1020
+ if (settled) return;
1021
+ settled = true;
1022
+ resolvePromise(opened);
1023
+ };
1024
+ try {
1025
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
1026
+ child.once("error", () => finish(false));
1027
+ child.once("spawn", () => {
1028
+ child.unref();
1029
+ finish(true);
1030
+ });
1031
+ } catch {
1032
+ finish(false);
1033
+ }
1034
+ });
1035
+ }
1036
+
1037
+ export async function sleep(milliseconds) {
1038
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
1039
+ }
1040
+
1041
+ export async function readTextFile(path) {
1042
+ return readFile(resolve(String(path)), "utf8");
1043
+ }
1044
+
1045
+ export async function writeTextFile(path, content) {
1046
+ const target = resolve(String(path));
1047
+ await mkdir(dirname(target), { recursive: true });
1048
+ const temporary = `${target}.${randomUUID()}.tmp`;
1049
+ try {
1050
+ await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
1051
+ await rename(temporary, target);
1052
+ } catch (error) {
1053
+ await rm(temporary, { force: true }).catch(() => {});
1054
+ throw error;
1055
+ }
1056
+ return target;
1057
+ }
1058
+
1059
+ export async function writeBinaryFile(path, content) {
1060
+ const target = resolve(String(path));
1061
+ await mkdir(dirname(target), { recursive: true });
1062
+ const temporary = `${target}.${randomUUID()}.tmp`;
1063
+ try {
1064
+ await writeFile(temporary, content, { flag: "wx" });
1065
+ await rename(temporary, target);
1066
+ } catch (error) {
1067
+ await rm(temporary, { force: true }).catch(() => {});
1068
+ throw error;
1069
+ }
1070
+ return target;
1071
+ }
1072
+
1073
+ export function requireConfirmation(options, action) {
1074
+ if (!options.yes) {
1075
+ throw new CliError(`${action} requires --yes.`, { code: "confirmation_required" });
1076
+ }
1077
+ }
1078
+
1079
+ export function idempotencyKey(options) {
1080
+ return String(options["idempotency-key"] || randomUUID());
1081
+ }