@corvio/cli 0.1.0-beta.4 → 0.1.0-beta.40
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 +85 -4
- package/dist/cli.js +3524 -132
- package/dist/core.js +554 -65
- 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,42 @@ export function parseArgv(argv) {
|
|
|
40
93
|
"no-input",
|
|
41
94
|
"yes",
|
|
42
95
|
"allow-actions",
|
|
96
|
+
"background",
|
|
97
|
+
"content",
|
|
98
|
+
"organize",
|
|
43
99
|
"private-link",
|
|
44
100
|
"force",
|
|
101
|
+
"once",
|
|
102
|
+
"include-revoked",
|
|
103
|
+
"recover-credential",
|
|
104
|
+
"no-listen",
|
|
105
|
+
"start-new-chain",
|
|
106
|
+
"wait-until-terminal",
|
|
45
107
|
"help",
|
|
46
108
|
"version",
|
|
47
109
|
]);
|
|
48
110
|
for (let index = 0; index < argv.length; index += 1) {
|
|
49
111
|
const value = argv[index];
|
|
112
|
+
if (value === "--") {
|
|
113
|
+
positionals.push(...argv.slice(index + 1));
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
50
116
|
if (!value.startsWith("--")) {
|
|
51
117
|
positionals.push(value);
|
|
52
118
|
continue;
|
|
53
119
|
}
|
|
54
120
|
const separator = value.indexOf("=");
|
|
55
121
|
const name = value.slice(2, separator === -1 ? undefined : separator);
|
|
122
|
+
if (!name) throw new CliError("Option name must not be empty.", { code: "invalid_option" });
|
|
123
|
+
if (Object.hasOwn(options, name)) {
|
|
124
|
+
throw new CliError(`Option --${name} was provided more than once.`, { code: "duplicate_option" });
|
|
125
|
+
}
|
|
56
126
|
if (booleanOptions.has(name)) {
|
|
57
|
-
|
|
127
|
+
const booleanValue = separator === -1 ? "true" : value.slice(separator + 1);
|
|
128
|
+
if (booleanValue !== "true" && booleanValue !== "false") {
|
|
129
|
+
throw new CliError(`Option --${name} must be true or false.`, { code: "invalid_boolean_option" });
|
|
130
|
+
}
|
|
131
|
+
options[name] = booleanValue === "true";
|
|
58
132
|
continue;
|
|
59
133
|
}
|
|
60
134
|
if (separator !== -1) {
|
|
@@ -71,6 +145,21 @@ export function parseArgv(argv) {
|
|
|
71
145
|
return { options, positionals };
|
|
72
146
|
}
|
|
73
147
|
|
|
148
|
+
export function assertKnownOptions(options, allowed) {
|
|
149
|
+
const allowedSet = new Set(allowed);
|
|
150
|
+
for (const name of Object.keys(options)) {
|
|
151
|
+
if (!allowedSet.has(name)) {
|
|
152
|
+
throw new CliError(`Unknown option --${name}.`, { code: "unknown_option" });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function assertPositionalCount(positionals, maximum) {
|
|
158
|
+
if (positionals.length > maximum) {
|
|
159
|
+
throw new CliError(`Unexpected argument: ${positionals[maximum]}`, { code: "unexpected_argument" });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
74
163
|
export function option(options, name, { required = false, fallback = undefined } = {}) {
|
|
75
164
|
const value = options[name] ?? fallback;
|
|
76
165
|
if (required && (value === undefined || String(value).trim() === "")) {
|
|
@@ -85,28 +174,43 @@ export function listOption(options, name, fallback = []) {
|
|
|
85
174
|
return String(value).split(",").map((item) => item.trim()).filter(Boolean);
|
|
86
175
|
}
|
|
87
176
|
|
|
88
|
-
export function numberOption(options, name, fallback) {
|
|
177
|
+
export function numberOption(options, name, fallback, { minimum = 1, maximum = Number.MAX_SAFE_INTEGER } = {}) {
|
|
89
178
|
const value = options[name];
|
|
90
179
|
if (value === undefined) return fallback;
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
180
|
+
const normalized = String(value).trim();
|
|
181
|
+
const parsed = /^\d+$/.test(normalized) ? Number(normalized) : Number.NaN;
|
|
182
|
+
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
183
|
+
throw new CliError(
|
|
184
|
+
`Option --${name} must be an integer between ${minimum} and ${maximum}.`,
|
|
185
|
+
{ code: "invalid_number_option" },
|
|
186
|
+
);
|
|
94
187
|
}
|
|
95
188
|
return parsed;
|
|
96
189
|
}
|
|
97
190
|
|
|
98
191
|
export function normalizeApiBase(value, fallback) {
|
|
99
192
|
const raw = String(value || fallback).trim().replace(/\/+$/, "");
|
|
100
|
-
|
|
193
|
+
let parsed;
|
|
194
|
+
try {
|
|
195
|
+
parsed = new URL(raw);
|
|
196
|
+
} catch {
|
|
197
|
+
throw new CliError("Corvio API base must be a valid URL.", { code: "invalid_api_base" });
|
|
198
|
+
}
|
|
199
|
+
const localhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
|
|
200
|
+
if (parsed.username || parsed.password || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && localhost))) {
|
|
101
201
|
throw new CliError("Corvio API base must use HTTPS (localhost is allowed for development).", {
|
|
102
202
|
code: "insecure_api_base",
|
|
103
203
|
});
|
|
104
204
|
}
|
|
105
|
-
|
|
205
|
+
if (parsed.search || parsed.hash) {
|
|
206
|
+
throw new CliError("Corvio API base must not include a query string or fragment.", { code: "invalid_api_base" });
|
|
207
|
+
}
|
|
208
|
+
const normalized = parsed.toString().replace(/\/+$/, "");
|
|
209
|
+
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
|
|
106
210
|
}
|
|
107
211
|
|
|
108
212
|
function parseSemver(value) {
|
|
109
|
-
const match = String(value || "").trim().match(/^(\d
|
|
213
|
+
const match = String(value || "").trim().match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/);
|
|
110
214
|
if (!match) return null;
|
|
111
215
|
return {
|
|
112
216
|
core: match.slice(1, 4).map((item) => Number.parseInt(item, 10)),
|
|
@@ -142,6 +246,7 @@ export async function resolveNpmUpdateStatus({
|
|
|
142
246
|
policy,
|
|
143
247
|
userAgent,
|
|
144
248
|
allowRegistryFailure = false,
|
|
249
|
+
checkRegistry = true,
|
|
145
250
|
fetchImpl = globalThis.fetch,
|
|
146
251
|
registryBase = process.env.CORVIO_NPM_REGISTRY_BASE || "https://registry.npmjs.org",
|
|
147
252
|
}) {
|
|
@@ -151,51 +256,76 @@ export async function resolveNpmUpdateStatus({
|
|
|
151
256
|
const encoded = encodeURIComponent(packageName).replace("%40", "@");
|
|
152
257
|
let latestVersion = null;
|
|
153
258
|
let registryError = null;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (!response.ok) throw new Error(`npm registry returned ${response.status}`);
|
|
161
|
-
const metadata = await response.json();
|
|
162
|
-
latestVersion = String(metadata?.["dist-tags"]?.[distTag] || "").trim() || null;
|
|
163
|
-
} catch (error) {
|
|
164
|
-
registryError = error instanceof Error ? error.message : String(error);
|
|
165
|
-
if (!allowRegistryFailure) {
|
|
166
|
-
throw new CliError(`Unable to check npm for updates: ${registryError}`, {
|
|
167
|
-
code: "update_check_failed",
|
|
168
|
-
exitCode: EXIT.REMOTE,
|
|
259
|
+
if (checkRegistry) {
|
|
260
|
+
try {
|
|
261
|
+
const registry = String(registryBase).replace(/\/+$/, "");
|
|
262
|
+
const response = await fetchImpl(`${registry}/${encoded}`, {
|
|
263
|
+
headers: { accept: "application/json", "user-agent": userAgent },
|
|
264
|
+
signal: AbortSignal.timeout(2500),
|
|
169
265
|
});
|
|
266
|
+
if (!response.ok) throw new Error(`npm registry returned ${response.status}`);
|
|
267
|
+
const metadata = await response.json();
|
|
268
|
+
latestVersion = String(metadata?.["dist-tags"]?.[distTag] || "").trim() || null;
|
|
269
|
+
} catch (error) {
|
|
270
|
+
registryError = error instanceof Error ? error.message : String(error);
|
|
271
|
+
if (!allowRegistryFailure) {
|
|
272
|
+
throw new CliError(`Unable to check npm for updates: ${registryError}`, {
|
|
273
|
+
code: "update_check_failed",
|
|
274
|
+
exitCode: EXIT.REMOTE,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
170
277
|
}
|
|
171
278
|
}
|
|
172
279
|
const minimum = String(policy.minimum_supported_version || "").trim() || null;
|
|
280
|
+
const installCommand = latestVersion
|
|
281
|
+
? `npm install --global ${packageName}@${latestVersion}`
|
|
282
|
+
: String(policy.install_command || `npm install --global ${packageName}@${distTag}`);
|
|
283
|
+
const executable = packageName === "@corvio/admin-cli" ? "corvio-admin" : "corvio";
|
|
173
284
|
return {
|
|
174
285
|
package: packageName,
|
|
175
286
|
channel: String(policy.channel || distTag),
|
|
176
287
|
dist_tag: distTag,
|
|
177
288
|
current_version: currentVersion,
|
|
178
289
|
latest_published_version: latestVersion,
|
|
290
|
+
registry_checked: checkRegistry,
|
|
179
291
|
reviewed_version: String(policy.reviewed_version || "").trim() || null,
|
|
180
292
|
minimum_supported_version: minimum,
|
|
181
293
|
update_available: Boolean(latestVersion && compareVersions(latestVersion, currentVersion) > 0),
|
|
182
294
|
supported: minimum ? compareVersions(currentVersion, minimum) >= 0 : true,
|
|
183
|
-
install_command:
|
|
295
|
+
install_command: installCommand,
|
|
296
|
+
verify_command: `${executable} --version`,
|
|
297
|
+
expected_version: latestVersion,
|
|
184
298
|
automatic_install: false,
|
|
185
299
|
...(registryError ? { registry_error: registryError } : {}),
|
|
186
300
|
};
|
|
187
301
|
}
|
|
188
302
|
|
|
189
303
|
export class ApiClient {
|
|
190
|
-
constructor({
|
|
304
|
+
constructor({
|
|
305
|
+
baseUrl,
|
|
306
|
+
credential = null,
|
|
307
|
+
workspaceId = null,
|
|
308
|
+
userAgent,
|
|
309
|
+
fetchImpl = globalThis.fetch,
|
|
310
|
+
timeoutMs = process.env.CORVIO_REQUEST_TIMEOUT_MS,
|
|
311
|
+
}) {
|
|
191
312
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
192
313
|
this.credential = credential;
|
|
193
314
|
this.workspaceId = String(workspaceId || "").trim() || null;
|
|
194
315
|
this.userAgent = userAgent;
|
|
195
316
|
this.fetchImpl = fetchImpl;
|
|
317
|
+
this.timeoutMs = normalizeRequestTimeout(timeoutMs);
|
|
196
318
|
}
|
|
197
319
|
|
|
198
|
-
async request(path, {
|
|
320
|
+
async request(path, {
|
|
321
|
+
method = "GET",
|
|
322
|
+
body,
|
|
323
|
+
query,
|
|
324
|
+
headers = {},
|
|
325
|
+
auth = true,
|
|
326
|
+
retrySafe = false,
|
|
327
|
+
timeoutMs = this.timeoutMs,
|
|
328
|
+
} = {}) {
|
|
199
329
|
const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
|
|
200
330
|
for (const [key, value] of Object.entries(query || {})) {
|
|
201
331
|
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
|
|
@@ -216,18 +346,56 @@ export class ApiClient {
|
|
|
216
346
|
requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
|
|
217
347
|
}
|
|
218
348
|
}
|
|
349
|
+
const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
|
|
219
350
|
let response;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
351
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
352
|
+
try {
|
|
353
|
+
response = await this.fetchImpl(url, {
|
|
354
|
+
method,
|
|
355
|
+
headers: requestHeaders,
|
|
356
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
357
|
+
signal: AbortSignal.timeout(normalizedTimeout),
|
|
358
|
+
});
|
|
359
|
+
const safety = networkFailureDetails({
|
|
360
|
+
cause: null,
|
|
361
|
+
url,
|
|
362
|
+
method,
|
|
363
|
+
requestHeaders,
|
|
364
|
+
body,
|
|
365
|
+
retrySafe,
|
|
366
|
+
});
|
|
367
|
+
if (attempt === 1 && safety.safe_to_retry && RETRYABLE_HTTP_STATUSES.has(response.status)) {
|
|
368
|
+
await response.body?.cancel().catch(() => {});
|
|
369
|
+
const retryAfterValue = response.headers.get("retry-after");
|
|
370
|
+
const retryAfterSeconds = retryAfterValue === null ? Number.NaN : Number(retryAfterValue);
|
|
371
|
+
const delayMs = Number.isFinite(retryAfterSeconds)
|
|
372
|
+
? Math.min(2_000, Math.max(0, retryAfterSeconds * 1000))
|
|
373
|
+
: 150;
|
|
374
|
+
await sleep(delayMs);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
break;
|
|
378
|
+
} catch (cause) {
|
|
379
|
+
const details = networkFailureDetails({ cause, url, method, requestHeaders, body, retrySafe });
|
|
380
|
+
if (attempt === 1 && details.safe_to_retry) {
|
|
381
|
+
await sleep(150);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
throw new CliError(
|
|
385
|
+
details.timed_out
|
|
386
|
+
? `Corvio did not respond within ${normalizedTimeout} ms.`
|
|
387
|
+
: `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
388
|
+
{
|
|
389
|
+
code: details.timed_out ? "request_timeout" : "network_error",
|
|
390
|
+
exitCode: EXIT.REMOTE,
|
|
391
|
+
details: {
|
|
392
|
+
...details,
|
|
393
|
+
attempts: attempt,
|
|
394
|
+
automatic_retry_attempted: attempt > 1,
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
}
|
|
231
399
|
}
|
|
232
400
|
const text = await response.text();
|
|
233
401
|
let payload = null;
|
|
@@ -244,13 +412,18 @@ export class ApiClient {
|
|
|
244
412
|
code: String(envelope.code || `http_${response.status}`),
|
|
245
413
|
status: response.status,
|
|
246
414
|
exitCode: exitCodeForStatus(response.status),
|
|
247
|
-
details:
|
|
415
|
+
details: {
|
|
416
|
+
...(envelope.details || {}),
|
|
417
|
+
...(envelope.request_id || response.headers.get("x-request-id")
|
|
418
|
+
? { request_id: envelope.request_id || response.headers.get("x-request-id") }
|
|
419
|
+
: {}),
|
|
420
|
+
},
|
|
248
421
|
});
|
|
249
422
|
}
|
|
250
423
|
return payload;
|
|
251
424
|
}
|
|
252
425
|
|
|
253
|
-
async requestBytes(path, { query, headers = {}, auth = true } = {}) {
|
|
426
|
+
async requestBytes(path, { query, headers = {}, auth = true, timeoutMs = this.timeoutMs } = {}) {
|
|
254
427
|
const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
|
|
255
428
|
for (const [key, value] of Object.entries(query || {})) {
|
|
256
429
|
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
|
|
@@ -270,14 +443,38 @@ export class ApiClient {
|
|
|
270
443
|
requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
|
|
271
444
|
}
|
|
272
445
|
}
|
|
446
|
+
const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
|
|
273
447
|
let response;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
448
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
449
|
+
try {
|
|
450
|
+
response = await this.fetchImpl(url, {
|
|
451
|
+
method: "GET",
|
|
452
|
+
headers: requestHeaders,
|
|
453
|
+
signal: AbortSignal.timeout(normalizedTimeout),
|
|
454
|
+
});
|
|
455
|
+
if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
|
|
456
|
+
await response.body?.cancel().catch(() => {});
|
|
457
|
+
await sleep(150);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
break;
|
|
461
|
+
} catch (cause) {
|
|
462
|
+
const details = networkFailureDetails({ cause, url, method: "GET", requestHeaders });
|
|
463
|
+
if (attempt === 1) {
|
|
464
|
+
await sleep(150);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
throw new CliError(
|
|
468
|
+
details.timed_out
|
|
469
|
+
? `Corvio did not respond within ${normalizedTimeout} ms.`
|
|
470
|
+
: `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
471
|
+
{
|
|
472
|
+
code: details.timed_out ? "request_timeout" : "network_error",
|
|
473
|
+
exitCode: EXIT.REMOTE,
|
|
474
|
+
details: { ...details, attempts: attempt, automatic_retry_attempted: true },
|
|
475
|
+
},
|
|
476
|
+
);
|
|
477
|
+
}
|
|
281
478
|
}
|
|
282
479
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
283
480
|
if (!response.ok) {
|
|
@@ -293,25 +490,57 @@ export class ApiClient {
|
|
|
293
490
|
code: String(envelope.code || `http_${response.status}`),
|
|
294
491
|
status: response.status,
|
|
295
492
|
exitCode: exitCodeForStatus(response.status),
|
|
296
|
-
details:
|
|
493
|
+
details: {
|
|
494
|
+
...(envelope.details || {}),
|
|
495
|
+
...(envelope.request_id || response.headers.get("x-request-id")
|
|
496
|
+
? { request_id: envelope.request_id || response.headers.get("x-request-id") }
|
|
497
|
+
: {}),
|
|
498
|
+
},
|
|
297
499
|
});
|
|
298
500
|
}
|
|
299
501
|
return bytes;
|
|
300
502
|
}
|
|
301
503
|
|
|
302
|
-
async putUrl(url, content, { headers = {} } = {}) {
|
|
504
|
+
async putUrl(url, content, { headers = {}, timeoutMs = this.timeoutMs } = {}) {
|
|
505
|
+
const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
|
|
303
506
|
let response;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
507
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
508
|
+
try {
|
|
509
|
+
response = await this.fetchImpl(String(url), {
|
|
510
|
+
method: "PUT",
|
|
511
|
+
headers,
|
|
512
|
+
body: content,
|
|
513
|
+
signal: AbortSignal.timeout(normalizedTimeout),
|
|
514
|
+
});
|
|
515
|
+
if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
|
|
516
|
+
await response.body?.cancel().catch(() => {});
|
|
517
|
+
await sleep(150);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
break;
|
|
521
|
+
} catch (cause) {
|
|
522
|
+
const details = networkFailureDetails({
|
|
523
|
+
cause,
|
|
524
|
+
url,
|
|
525
|
+
method: "PUT",
|
|
526
|
+
requestHeaders: headers,
|
|
527
|
+
retrySafe: true,
|
|
528
|
+
});
|
|
529
|
+
if (attempt === 1) {
|
|
530
|
+
await sleep(150);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
throw new CliError(
|
|
534
|
+
details.timed_out
|
|
535
|
+
? `The file upload did not respond within ${normalizedTimeout} ms.`
|
|
536
|
+
: `Unable to upload the file: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
537
|
+
{
|
|
538
|
+
code: details.timed_out ? "upload_timeout" : "upload_network_error",
|
|
539
|
+
exitCode: EXIT.REMOTE,
|
|
540
|
+
details: { ...details, attempts: attempt, automatic_retry_attempted: true },
|
|
541
|
+
},
|
|
542
|
+
);
|
|
543
|
+
}
|
|
315
544
|
}
|
|
316
545
|
if (!response.ok) {
|
|
317
546
|
const message = (await response.text()).slice(0, 500);
|
|
@@ -343,6 +572,14 @@ export function adminCredentialPath(env = process.env) {
|
|
|
343
572
|
return join(configRoot(env), "admin-credentials.json");
|
|
344
573
|
}
|
|
345
574
|
|
|
575
|
+
export function agentCredentialPath(env = process.env) {
|
|
576
|
+
return join(configRoot(env), "agent-credentials.json");
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function conversationRoutingPath(env = process.env) {
|
|
580
|
+
return join(configRoot(env), "conversation-routing.json");
|
|
581
|
+
}
|
|
582
|
+
|
|
346
583
|
export async function readWorkspaceCredential(env = process.env) {
|
|
347
584
|
const environmentKey = String(env.CORVIO_API_KEY || "").trim();
|
|
348
585
|
if (environmentKey) {
|
|
@@ -375,6 +612,153 @@ export async function readWorkspaceCredential(env = process.env) {
|
|
|
375
612
|
return { key, source: "device_link", profile: profileName, metadata };
|
|
376
613
|
}
|
|
377
614
|
|
|
615
|
+
export async function readAgentCredential(
|
|
616
|
+
env = process.env,
|
|
617
|
+
{
|
|
618
|
+
required = true,
|
|
619
|
+
bindingId = null,
|
|
620
|
+
agentId = null,
|
|
621
|
+
projectRoot = null,
|
|
622
|
+
fallbackToActive = true,
|
|
623
|
+
} = {},
|
|
624
|
+
) {
|
|
625
|
+
const key = String(env.CORVIO_AGENT_API_KEY || "").trim();
|
|
626
|
+
if (key) {
|
|
627
|
+
if (!key.startsWith("cvg_")) {
|
|
628
|
+
throw new CliError("CORVIO_AGENT_API_KEY must be an Agent cvg_ key.", {
|
|
629
|
+
code: "invalid_agent_key",
|
|
630
|
+
exitCode: EXIT.AUTH,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
return { key, source: "environment", profile: null, metadata: null };
|
|
634
|
+
}
|
|
635
|
+
const path = agentCredentialPath(env);
|
|
636
|
+
let parsed;
|
|
637
|
+
try {
|
|
638
|
+
parsed = JSON.parse(await readFile(path, "utf8"));
|
|
639
|
+
} catch (cause) {
|
|
640
|
+
if (cause?.code === "ENOENT" && !required) return null;
|
|
641
|
+
if (cause?.code === "ENOENT") {
|
|
642
|
+
throw new CliError(
|
|
643
|
+
"No Agent credential found. Run `corvio agents connect` or set CORVIO_AGENT_API_KEY.",
|
|
644
|
+
{ code: "agent_auth_required", exitCode: EXIT.AUTH },
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
throw new CliError("Stored Agent credentials are unreadable.", {
|
|
648
|
+
code: "agent_credential_store_invalid",
|
|
649
|
+
exitCode: EXIT.AUTH,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
let selected = parsed;
|
|
653
|
+
let selectedProfile = "default";
|
|
654
|
+
if (Number(parsed?.version) >= 2 && parsed?.credentials && typeof parsed.credentials === "object") {
|
|
655
|
+
const entries = Object.entries(parsed.credentials);
|
|
656
|
+
let match = null;
|
|
657
|
+
if (bindingId) {
|
|
658
|
+
match = entries.find(([key, value]) => key === bindingId || value?.binding_id === bindingId) || null;
|
|
659
|
+
}
|
|
660
|
+
if (!match && agentId) {
|
|
661
|
+
match = entries.find(([, value]) => String(value?.agent_id || "") === String(agentId)) || null;
|
|
662
|
+
}
|
|
663
|
+
if (!match && projectRoot) {
|
|
664
|
+
const normalizedRoot = resolve(String(projectRoot));
|
|
665
|
+
const candidates = entries
|
|
666
|
+
.filter(([, value]) => {
|
|
667
|
+
const storedRoot = String(value?.project_root || "").trim();
|
|
668
|
+
if (!storedRoot) return false;
|
|
669
|
+
const normalizedStored = resolve(storedRoot);
|
|
670
|
+
return normalizedRoot === normalizedStored || normalizedRoot.startsWith(`${normalizedStored}${process.platform === "win32" ? "\\" : "/"}`);
|
|
671
|
+
})
|
|
672
|
+
.sort((left, right) => String(right[1]?.project_root || "").length - String(left[1]?.project_root || "").length);
|
|
673
|
+
match = candidates[0] || null;
|
|
674
|
+
}
|
|
675
|
+
if (!match && fallbackToActive && parsed.active_binding_id) {
|
|
676
|
+
const value = parsed.credentials[parsed.active_binding_id];
|
|
677
|
+
if (value) match = [parsed.active_binding_id, value];
|
|
678
|
+
}
|
|
679
|
+
if (!match && fallbackToActive && entries.length === 1) match = entries[0];
|
|
680
|
+
if (!match) {
|
|
681
|
+
if (!required) return null;
|
|
682
|
+
throw new CliError(
|
|
683
|
+
"Multiple Agent project bindings are stored. Run from a connected project or pass --agent <id>.",
|
|
684
|
+
{ code: "agent_routing_ambiguous", exitCode: EXIT.AUTH },
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
[selectedProfile, selected] = match;
|
|
688
|
+
}
|
|
689
|
+
const storedKey = String(selected?.api_key || "").trim();
|
|
690
|
+
if (!storedKey.startsWith("cvg_")) {
|
|
691
|
+
throw new CliError(
|
|
692
|
+
"Stored Agent credentials are invalid; run `corvio agents connect --recover-credential`.",
|
|
693
|
+
{ code: "agent_credential_store_invalid", exitCode: EXIT.AUTH },
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
return { key: storedKey, source: "secure_store", profile: selectedProfile, metadata: selected };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
export async function storeAgentCredential(payload, env = process.env) {
|
|
700
|
+
const secret = String(payload?.secret || "").trim();
|
|
701
|
+
if (!secret.startsWith("cvg_")) {
|
|
702
|
+
throw new CliError("Agent connection did not return a valid cvg_ credential.", {
|
|
703
|
+
code: "agent_credential_invalid",
|
|
704
|
+
exitCode: EXIT.AUTH,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
const path = agentCredentialPath(env);
|
|
708
|
+
const root = dirname(path);
|
|
709
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
710
|
+
await chmod(root, 0o700).catch(() => {});
|
|
711
|
+
const credential = {
|
|
712
|
+
api_key: secret,
|
|
713
|
+
agent_id: String(payload.agent_id || ""),
|
|
714
|
+
binding_id: String(payload.binding_id || "").trim() || null,
|
|
715
|
+
project_root: String(payload.project_root || "").trim() || null,
|
|
716
|
+
project_label: String(payload.project_label || "").trim() || null,
|
|
717
|
+
project_fingerprint: String(payload.project_fingerprint || "").trim() || null,
|
|
718
|
+
display_name: String(payload.display_name || ""),
|
|
719
|
+
provider: String(payload.provider || "custom"),
|
|
720
|
+
permission_profile: String(payload.permission_profile || "read_only"),
|
|
721
|
+
trigger_policy: String(payload.trigger_policy || "owner_only"),
|
|
722
|
+
skills_scope: String(payload.skills_scope || "project"),
|
|
723
|
+
memory_scope: String(payload.memory_scope || "off"),
|
|
724
|
+
connector_scope: String(payload.connector_scope || "off"),
|
|
725
|
+
display_prefix: String(payload.display_prefix || "cvg"),
|
|
726
|
+
created_at: payload.created_at || new Date().toISOString(),
|
|
727
|
+
source: "agent_connect",
|
|
728
|
+
};
|
|
729
|
+
let current = null;
|
|
730
|
+
try {
|
|
731
|
+
current = JSON.parse(await readFile(path, "utf8"));
|
|
732
|
+
} catch (error) {
|
|
733
|
+
if (error?.code !== "ENOENT") {
|
|
734
|
+
throw new CliError("Stored Agent credentials are unreadable.", {
|
|
735
|
+
code: "agent_credential_store_invalid",
|
|
736
|
+
exitCode: EXIT.AUTH,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
const credentials = Number(current?.version) >= 2 && current?.credentials && typeof current.credentials === "object"
|
|
741
|
+
? { ...current.credentials }
|
|
742
|
+
: {};
|
|
743
|
+
if (String(current?.api_key || "").startsWith("cvg_") && current?.agent_id) {
|
|
744
|
+
const legacyKey = String(current.binding_id || `legacy:${current.agent_id}`);
|
|
745
|
+
credentials[legacyKey] = { ...current, binding_id: current.binding_id || null };
|
|
746
|
+
}
|
|
747
|
+
const profile = credential.binding_id || `agent:${credential.agent_id}`;
|
|
748
|
+
credentials[profile] = credential;
|
|
749
|
+
const document = {
|
|
750
|
+
version: 2,
|
|
751
|
+
active_binding_id: profile,
|
|
752
|
+
credentials,
|
|
753
|
+
};
|
|
754
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
755
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
756
|
+
await chmod(temporary, 0o600);
|
|
757
|
+
await rename(temporary, path);
|
|
758
|
+
await chmod(path, 0o600);
|
|
759
|
+
return { path, profile, metadata: credential };
|
|
760
|
+
}
|
|
761
|
+
|
|
378
762
|
export async function storeWorkspaceCredential(payload, env = process.env) {
|
|
379
763
|
const path = workspaceCredentialPath(env);
|
|
380
764
|
const root = dirname(path);
|
|
@@ -412,6 +796,7 @@ export async function readWorkspaceContext(env = process.env) {
|
|
|
412
796
|
workspaceId,
|
|
413
797
|
workspaceName: String(parsed.active_workspace_name || "").trim() || null,
|
|
414
798
|
source: "stored",
|
|
799
|
+
routingPreference: parsed.routing_preference || null,
|
|
415
800
|
};
|
|
416
801
|
} catch (cause) {
|
|
417
802
|
if (cause?.code === "ENOENT") return null;
|
|
@@ -422,7 +807,7 @@ export async function readWorkspaceContext(env = process.env) {
|
|
|
422
807
|
}
|
|
423
808
|
}
|
|
424
809
|
|
|
425
|
-
export async function storeWorkspaceContext({ workspaceId, workspaceName = null }, env = process.env) {
|
|
810
|
+
export async function storeWorkspaceContext({ workspaceId, workspaceName = null, routingPreference = null }, env = process.env) {
|
|
426
811
|
const normalizedId = String(workspaceId || "").trim();
|
|
427
812
|
if (!normalizedId) throw new CliError("Workspace ID is required.", { code: "workspace_selection_required" });
|
|
428
813
|
const path = workspaceContextPath(env);
|
|
@@ -433,6 +818,13 @@ export async function storeWorkspaceContext({ workspaceId, workspaceName = null
|
|
|
433
818
|
version: 1,
|
|
434
819
|
active_workspace_id: normalizedId,
|
|
435
820
|
active_workspace_name: String(workspaceName || "").trim() || null,
|
|
821
|
+
routing_preference: routingPreference || {
|
|
822
|
+
workspace_id: normalizedId,
|
|
823
|
+
provenance: "user_confirmed_cli",
|
|
824
|
+
confidence: 1,
|
|
825
|
+
scope: "global",
|
|
826
|
+
expires_at: null,
|
|
827
|
+
},
|
|
436
828
|
updated_at: new Date().toISOString(),
|
|
437
829
|
};
|
|
438
830
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
@@ -443,6 +835,75 @@ export async function storeWorkspaceContext({ workspaceId, workspaceName = null
|
|
|
443
835
|
return { path, workspaceId: normalizedId };
|
|
444
836
|
}
|
|
445
837
|
|
|
838
|
+
export async function readConversationRoutingReceipt(conversationId, env = process.env) {
|
|
839
|
+
const normalizedId = String(conversationId || "").trim();
|
|
840
|
+
if (!normalizedId) return null;
|
|
841
|
+
const path = conversationRoutingPath(env);
|
|
842
|
+
try {
|
|
843
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
844
|
+
const receipt = parsed?.receipts?.[normalizedId];
|
|
845
|
+
const workspaceId = String(receipt?.workspace_id || "").trim();
|
|
846
|
+
if (!workspaceId) return null;
|
|
847
|
+
return {
|
|
848
|
+
conversationId: normalizedId,
|
|
849
|
+
workspaceId,
|
|
850
|
+
agentId: String(receipt.agent_id || "").trim() || null,
|
|
851
|
+
apiBase: String(receipt.api_base || "").trim() || null,
|
|
852
|
+
recordedAt: String(receipt.recorded_at || "").trim() || null,
|
|
853
|
+
};
|
|
854
|
+
} catch (cause) {
|
|
855
|
+
if (cause?.code === "ENOENT") return null;
|
|
856
|
+
throw new CliError("Stored Corvio conversation routing receipts are unreadable.", {
|
|
857
|
+
code: "conversation_routing_store_invalid",
|
|
858
|
+
exitCode: EXIT.AUTH,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export async function storeConversationRoutingReceipt({ conversationId, workspaceId, agentId = null, apiBase = null }, env = process.env) {
|
|
864
|
+
const normalizedConversationId = String(conversationId || "").trim();
|
|
865
|
+
const normalizedWorkspaceId = String(workspaceId || "").trim();
|
|
866
|
+
if (!normalizedConversationId || !normalizedWorkspaceId) {
|
|
867
|
+
throw new CliError("Conversation and Workspace IDs are required for routing continuity.", {
|
|
868
|
+
code: "conversation_routing_receipt_invalid",
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
const path = conversationRoutingPath(env);
|
|
872
|
+
const root = dirname(path);
|
|
873
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
874
|
+
await chmod(root, 0o700).catch(() => {});
|
|
875
|
+
let existing = {};
|
|
876
|
+
try {
|
|
877
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
878
|
+
if (parsed?.receipts && typeof parsed.receipts === "object" && !Array.isArray(parsed.receipts)) {
|
|
879
|
+
existing = parsed.receipts;
|
|
880
|
+
}
|
|
881
|
+
} catch (cause) {
|
|
882
|
+
if (cause?.code !== "ENOENT") {
|
|
883
|
+
throw new CliError("Stored Corvio conversation routing receipts are unreadable.", {
|
|
884
|
+
code: "conversation_routing_store_invalid",
|
|
885
|
+
exitCode: EXIT.AUTH,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
const ordered = Object.entries({
|
|
890
|
+
...existing,
|
|
891
|
+
[normalizedConversationId]: {
|
|
892
|
+
workspace_id: normalizedWorkspaceId,
|
|
893
|
+
agent_id: String(agentId || "").trim() || null,
|
|
894
|
+
api_base: String(apiBase || "").trim() || null,
|
|
895
|
+
recorded_at: new Date().toISOString(),
|
|
896
|
+
},
|
|
897
|
+
}).sort((left, right) => String(right[1]?.recorded_at || "").localeCompare(String(left[1]?.recorded_at || "")));
|
|
898
|
+
const document = { version: 1, receipts: Object.fromEntries(ordered.slice(0, 500)) };
|
|
899
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
900
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
901
|
+
await chmod(temporary, 0o600);
|
|
902
|
+
await rename(temporary, path);
|
|
903
|
+
await chmod(path, 0o600);
|
|
904
|
+
return { path, conversationId: normalizedConversationId, workspaceId: normalizedWorkspaceId };
|
|
905
|
+
}
|
|
906
|
+
|
|
446
907
|
export async function clearWorkspaceContext(env = process.env) {
|
|
447
908
|
const path = workspaceContextPath(env);
|
|
448
909
|
await rm(path, { force: true });
|
|
@@ -534,7 +995,21 @@ export function writeError(error, { json = false, stderr = process.stderr } = {}
|
|
|
534
995
|
...(error instanceof CliError && error.details ? { details: error.details } : {}),
|
|
535
996
|
},
|
|
536
997
|
};
|
|
537
|
-
|
|
998
|
+
if (json) {
|
|
999
|
+
stderr.write(`${JSON.stringify(safe)}\n`);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
stderr.write(`Error: ${safe.error.message}\n`);
|
|
1003
|
+
if (safe.error.details?.request_id) stderr.write(`Request ID: ${safe.error.details.request_id}\n`);
|
|
1004
|
+
if (safe.error.details?.safe_to_retry && safe.error.details?.idempotency_key) {
|
|
1005
|
+
stderr.write(
|
|
1006
|
+
`Safe retry: rerun the command with --idempotency-key ${safe.error.details.idempotency_key}\n`,
|
|
1007
|
+
);
|
|
1008
|
+
} else if (safe.error.details?.retry_safety === "revision_guard") {
|
|
1009
|
+
stderr.write("Safe retry: rerun with the same --expected-revision; Corvio will reject an already-committed replacement.\n");
|
|
1010
|
+
} else if (safe.error.details?.retry_safety === "unknown_commit_state") {
|
|
1011
|
+
stderr.write("Retry state is unknown; read the remote object before repeating this mutation.\n");
|
|
1012
|
+
}
|
|
538
1013
|
}
|
|
539
1014
|
|
|
540
1015
|
export async function runMain(main, argv = process.argv.slice(2)) {
|
|
@@ -551,8 +1026,8 @@ export async function runMain(main, argv = process.argv.slice(2)) {
|
|
|
551
1026
|
|
|
552
1027
|
export async function openBrowser(url) {
|
|
553
1028
|
const target = String(url);
|
|
554
|
-
const command = platform() === "darwin" ? "open" : platform() === "win32" ? "
|
|
555
|
-
const args = platform() === "win32" ? ["
|
|
1029
|
+
const command = platform() === "darwin" ? "open" : platform() === "win32" ? "rundll32.exe" : "xdg-open";
|
|
1030
|
+
const args = platform() === "win32" ? ["url.dll,FileProtocolHandler", target] : [target];
|
|
556
1031
|
return new Promise((resolvePromise) => {
|
|
557
1032
|
let settled = false;
|
|
558
1033
|
const finish = (opened) => {
|
|
@@ -584,14 +1059,28 @@ export async function readTextFile(path) {
|
|
|
584
1059
|
export async function writeTextFile(path, content) {
|
|
585
1060
|
const target = resolve(String(path));
|
|
586
1061
|
await mkdir(dirname(target), { recursive: true });
|
|
587
|
-
|
|
1062
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
1063
|
+
try {
|
|
1064
|
+
await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
|
|
1065
|
+
await rename(temporary, target);
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
1068
|
+
throw error;
|
|
1069
|
+
}
|
|
588
1070
|
return target;
|
|
589
1071
|
}
|
|
590
1072
|
|
|
591
1073
|
export async function writeBinaryFile(path, content) {
|
|
592
1074
|
const target = resolve(String(path));
|
|
593
1075
|
await mkdir(dirname(target), { recursive: true });
|
|
594
|
-
|
|
1076
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
1077
|
+
try {
|
|
1078
|
+
await writeFile(temporary, content, { flag: "wx" });
|
|
1079
|
+
await rename(temporary, target);
|
|
1080
|
+
} catch (error) {
|
|
1081
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
1082
|
+
throw error;
|
|
1083
|
+
}
|
|
595
1084
|
return target;
|
|
596
1085
|
}
|
|
597
1086
|
|