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