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