@corvio/cli 0.1.0-beta.3 → 0.1.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/cli.js +982 -35
- package/dist/core.js +475 -61
- 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 = [];
|
|
@@ -47,14 +100,26 @@ export function parseArgv(argv) {
|
|
|
47
100
|
]);
|
|
48
101
|
for (let index = 0; index < argv.length; index += 1) {
|
|
49
102
|
const value = argv[index];
|
|
103
|
+
if (value === "--") {
|
|
104
|
+
positionals.push(...argv.slice(index + 1));
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
50
107
|
if (!value.startsWith("--")) {
|
|
51
108
|
positionals.push(value);
|
|
52
109
|
continue;
|
|
53
110
|
}
|
|
54
111
|
const separator = value.indexOf("=");
|
|
55
112
|
const name = value.slice(2, separator === -1 ? undefined : separator);
|
|
113
|
+
if (!name) throw new CliError("Option name must not be empty.", { code: "invalid_option" });
|
|
114
|
+
if (Object.hasOwn(options, name)) {
|
|
115
|
+
throw new CliError(`Option --${name} was provided more than once.`, { code: "duplicate_option" });
|
|
116
|
+
}
|
|
56
117
|
if (booleanOptions.has(name)) {
|
|
57
|
-
|
|
118
|
+
const booleanValue = separator === -1 ? "true" : value.slice(separator + 1);
|
|
119
|
+
if (booleanValue !== "true" && booleanValue !== "false") {
|
|
120
|
+
throw new CliError(`Option --${name} must be true or false.`, { code: "invalid_boolean_option" });
|
|
121
|
+
}
|
|
122
|
+
options[name] = booleanValue === "true";
|
|
58
123
|
continue;
|
|
59
124
|
}
|
|
60
125
|
if (separator !== -1) {
|
|
@@ -71,6 +136,21 @@ export function parseArgv(argv) {
|
|
|
71
136
|
return { options, positionals };
|
|
72
137
|
}
|
|
73
138
|
|
|
139
|
+
export function assertKnownOptions(options, allowed) {
|
|
140
|
+
const allowedSet = new Set(allowed);
|
|
141
|
+
for (const name of Object.keys(options)) {
|
|
142
|
+
if (!allowedSet.has(name)) {
|
|
143
|
+
throw new CliError(`Unknown option --${name}.`, { code: "unknown_option" });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function assertPositionalCount(positionals, maximum) {
|
|
149
|
+
if (positionals.length > maximum) {
|
|
150
|
+
throw new CliError(`Unexpected argument: ${positionals[maximum]}`, { code: "unexpected_argument" });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
74
154
|
export function option(options, name, { required = false, fallback = undefined } = {}) {
|
|
75
155
|
const value = options[name] ?? fallback;
|
|
76
156
|
if (required && (value === undefined || String(value).trim() === "")) {
|
|
@@ -85,35 +165,148 @@ export function listOption(options, name, fallback = []) {
|
|
|
85
165
|
return String(value).split(",").map((item) => item.trim()).filter(Boolean);
|
|
86
166
|
}
|
|
87
167
|
|
|
88
|
-
export function numberOption(options, name, fallback) {
|
|
168
|
+
export function numberOption(options, name, fallback, { minimum = 1, maximum = Number.MAX_SAFE_INTEGER } = {}) {
|
|
89
169
|
const value = options[name];
|
|
90
170
|
if (value === undefined) return fallback;
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
171
|
+
const normalized = String(value).trim();
|
|
172
|
+
const parsed = /^\d+$/.test(normalized) ? Number(normalized) : Number.NaN;
|
|
173
|
+
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
174
|
+
throw new CliError(
|
|
175
|
+
`Option --${name} must be an integer between ${minimum} and ${maximum}.`,
|
|
176
|
+
{ code: "invalid_number_option" },
|
|
177
|
+
);
|
|
94
178
|
}
|
|
95
179
|
return parsed;
|
|
96
180
|
}
|
|
97
181
|
|
|
98
182
|
export function normalizeApiBase(value, fallback) {
|
|
99
183
|
const raw = String(value || fallback).trim().replace(/\/+$/, "");
|
|
100
|
-
|
|
184
|
+
let parsed;
|
|
185
|
+
try {
|
|
186
|
+
parsed = new URL(raw);
|
|
187
|
+
} catch {
|
|
188
|
+
throw new CliError("Corvio API base must be a valid URL.", { code: "invalid_api_base" });
|
|
189
|
+
}
|
|
190
|
+
const localhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
|
|
191
|
+
if (parsed.username || parsed.password || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && localhost))) {
|
|
101
192
|
throw new CliError("Corvio API base must use HTTPS (localhost is allowed for development).", {
|
|
102
193
|
code: "insecure_api_base",
|
|
103
194
|
});
|
|
104
195
|
}
|
|
105
|
-
|
|
196
|
+
if (parsed.search || parsed.hash) {
|
|
197
|
+
throw new CliError("Corvio API base must not include a query string or fragment.", { code: "invalid_api_base" });
|
|
198
|
+
}
|
|
199
|
+
const normalized = parsed.toString().replace(/\/+$/, "");
|
|
200
|
+
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseSemver(value) {
|
|
204
|
+
const match = String(value || "").trim().match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/);
|
|
205
|
+
if (!match) return null;
|
|
206
|
+
return {
|
|
207
|
+
core: match.slice(1, 4).map((item) => Number.parseInt(item, 10)),
|
|
208
|
+
pre: match[4] ? match[4].split(".") : [],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function compareVersions(left, right) {
|
|
213
|
+
const a = parseSemver(left);
|
|
214
|
+
const b = parseSemver(right);
|
|
215
|
+
if (!a || !b) return 0;
|
|
216
|
+
for (let index = 0; index < 3; index += 1) {
|
|
217
|
+
if (a.core[index] !== b.core[index]) return a.core[index] > b.core[index] ? 1 : -1;
|
|
218
|
+
}
|
|
219
|
+
if (a.pre.length === 0 && b.pre.length > 0) return 1;
|
|
220
|
+
if (b.pre.length === 0 && a.pre.length > 0) return -1;
|
|
221
|
+
for (let index = 0; index < Math.max(a.pre.length, b.pre.length); index += 1) {
|
|
222
|
+
if (a.pre[index] === undefined) return -1;
|
|
223
|
+
if (b.pre[index] === undefined) return 1;
|
|
224
|
+
if (a.pre[index] === b.pre[index]) continue;
|
|
225
|
+
const aNumber = /^\d+$/.test(a.pre[index]) ? Number.parseInt(a.pre[index], 10) : null;
|
|
226
|
+
const bNumber = /^\d+$/.test(b.pre[index]) ? Number.parseInt(b.pre[index], 10) : null;
|
|
227
|
+
if (aNumber !== null && bNumber !== null) return aNumber > bNumber ? 1 : -1;
|
|
228
|
+
if (aNumber !== null) return -1;
|
|
229
|
+
if (bNumber !== null) return 1;
|
|
230
|
+
return a.pre[index] > b.pre[index] ? 1 : -1;
|
|
231
|
+
}
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function resolveNpmUpdateStatus({
|
|
236
|
+
currentVersion,
|
|
237
|
+
policy,
|
|
238
|
+
userAgent,
|
|
239
|
+
allowRegistryFailure = false,
|
|
240
|
+
fetchImpl = globalThis.fetch,
|
|
241
|
+
registryBase = process.env.CORVIO_NPM_REGISTRY_BASE || "https://registry.npmjs.org",
|
|
242
|
+
}) {
|
|
243
|
+
const packageName = String(policy?.package || "").trim();
|
|
244
|
+
const distTag = String(policy?.dist_tag || "beta").trim();
|
|
245
|
+
if (!packageName) throw new CliError("The API did not advertise an npm package.", { code: "update_policy_invalid" });
|
|
246
|
+
const encoded = encodeURIComponent(packageName).replace("%40", "@");
|
|
247
|
+
let latestVersion = null;
|
|
248
|
+
let registryError = null;
|
|
249
|
+
try {
|
|
250
|
+
const registry = String(registryBase).replace(/\/+$/, "");
|
|
251
|
+
const response = await fetchImpl(`${registry}/${encoded}`, {
|
|
252
|
+
headers: { accept: "application/json", "user-agent": userAgent },
|
|
253
|
+
signal: AbortSignal.timeout(2500),
|
|
254
|
+
});
|
|
255
|
+
if (!response.ok) throw new Error(`npm registry returned ${response.status}`);
|
|
256
|
+
const metadata = await response.json();
|
|
257
|
+
latestVersion = String(metadata?.["dist-tags"]?.[distTag] || "").trim() || null;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
registryError = error instanceof Error ? error.message : String(error);
|
|
260
|
+
if (!allowRegistryFailure) {
|
|
261
|
+
throw new CliError(`Unable to check npm for updates: ${registryError}`, {
|
|
262
|
+
code: "update_check_failed",
|
|
263
|
+
exitCode: EXIT.REMOTE,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const minimum = String(policy.minimum_supported_version || "").trim() || null;
|
|
268
|
+
return {
|
|
269
|
+
package: packageName,
|
|
270
|
+
channel: String(policy.channel || distTag),
|
|
271
|
+
dist_tag: distTag,
|
|
272
|
+
current_version: currentVersion,
|
|
273
|
+
latest_published_version: latestVersion,
|
|
274
|
+
reviewed_version: String(policy.reviewed_version || "").trim() || null,
|
|
275
|
+
minimum_supported_version: minimum,
|
|
276
|
+
update_available: Boolean(latestVersion && compareVersions(latestVersion, currentVersion) > 0),
|
|
277
|
+
supported: minimum ? compareVersions(currentVersion, minimum) >= 0 : true,
|
|
278
|
+
install_command: String(policy.install_command || `npm install --global ${packageName}@${distTag}`),
|
|
279
|
+
automatic_install: false,
|
|
280
|
+
...(registryError ? { registry_error: registryError } : {}),
|
|
281
|
+
};
|
|
106
282
|
}
|
|
107
283
|
|
|
108
284
|
export class ApiClient {
|
|
109
|
-
constructor({
|
|
285
|
+
constructor({
|
|
286
|
+
baseUrl,
|
|
287
|
+
credential = null,
|
|
288
|
+
workspaceId = null,
|
|
289
|
+
userAgent,
|
|
290
|
+
fetchImpl = globalThis.fetch,
|
|
291
|
+
timeoutMs = process.env.CORVIO_REQUEST_TIMEOUT_MS,
|
|
292
|
+
}) {
|
|
110
293
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
111
294
|
this.credential = credential;
|
|
295
|
+
this.workspaceId = String(workspaceId || "").trim() || null;
|
|
112
296
|
this.userAgent = userAgent;
|
|
113
297
|
this.fetchImpl = fetchImpl;
|
|
298
|
+
this.timeoutMs = normalizeRequestTimeout(timeoutMs);
|
|
114
299
|
}
|
|
115
300
|
|
|
116
|
-
async request(path, {
|
|
301
|
+
async request(path, {
|
|
302
|
+
method = "GET",
|
|
303
|
+
body,
|
|
304
|
+
query,
|
|
305
|
+
headers = {},
|
|
306
|
+
auth = true,
|
|
307
|
+
retrySafe = false,
|
|
308
|
+
timeoutMs = this.timeoutMs,
|
|
309
|
+
} = {}) {
|
|
117
310
|
const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
|
|
118
311
|
for (const [key, value] of Object.entries(query || {})) {
|
|
119
312
|
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
|
|
@@ -129,19 +322,61 @@ export class ApiClient {
|
|
|
129
322
|
throw new CliError("Corvio authentication is required.", { code: "auth_required", exitCode: EXIT.AUTH });
|
|
130
323
|
}
|
|
131
324
|
requestHeaders.authorization = `Bearer ${this.credential}`;
|
|
325
|
+
if (this.workspaceId) {
|
|
326
|
+
requestHeaders["X-Corvio-Workspace-Id"] = this.workspaceId;
|
|
327
|
+
requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
|
|
328
|
+
}
|
|
132
329
|
}
|
|
330
|
+
const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
|
|
133
331
|
let response;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
332
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
333
|
+
try {
|
|
334
|
+
response = await this.fetchImpl(url, {
|
|
335
|
+
method,
|
|
336
|
+
headers: requestHeaders,
|
|
337
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
338
|
+
signal: AbortSignal.timeout(normalizedTimeout),
|
|
339
|
+
});
|
|
340
|
+
const safety = networkFailureDetails({
|
|
341
|
+
cause: null,
|
|
342
|
+
url,
|
|
343
|
+
method,
|
|
344
|
+
requestHeaders,
|
|
345
|
+
body,
|
|
346
|
+
retrySafe,
|
|
347
|
+
});
|
|
348
|
+
if (attempt === 1 && safety.safe_to_retry && RETRYABLE_HTTP_STATUSES.has(response.status)) {
|
|
349
|
+
await response.body?.cancel().catch(() => {});
|
|
350
|
+
const retryAfterValue = response.headers.get("retry-after");
|
|
351
|
+
const retryAfterSeconds = retryAfterValue === null ? Number.NaN : Number(retryAfterValue);
|
|
352
|
+
const delayMs = Number.isFinite(retryAfterSeconds)
|
|
353
|
+
? Math.min(2_000, Math.max(0, retryAfterSeconds * 1000))
|
|
354
|
+
: 150;
|
|
355
|
+
await sleep(delayMs);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
} catch (cause) {
|
|
360
|
+
const details = networkFailureDetails({ cause, url, method, requestHeaders, body, retrySafe });
|
|
361
|
+
if (attempt === 1 && details.safe_to_retry) {
|
|
362
|
+
await sleep(150);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
throw new CliError(
|
|
366
|
+
details.timed_out
|
|
367
|
+
? `Corvio did not respond within ${normalizedTimeout} ms.`
|
|
368
|
+
: `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
369
|
+
{
|
|
370
|
+
code: details.timed_out ? "request_timeout" : "network_error",
|
|
371
|
+
exitCode: EXIT.REMOTE,
|
|
372
|
+
details: {
|
|
373
|
+
...details,
|
|
374
|
+
attempts: attempt,
|
|
375
|
+
automatic_retry_attempted: attempt > 1,
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
}
|
|
145
380
|
}
|
|
146
381
|
const text = await response.text();
|
|
147
382
|
let payload = null;
|
|
@@ -158,13 +393,18 @@ export class ApiClient {
|
|
|
158
393
|
code: String(envelope.code || `http_${response.status}`),
|
|
159
394
|
status: response.status,
|
|
160
395
|
exitCode: exitCodeForStatus(response.status),
|
|
161
|
-
details:
|
|
396
|
+
details: {
|
|
397
|
+
...(envelope.details || {}),
|
|
398
|
+
...(envelope.request_id || response.headers.get("x-request-id")
|
|
399
|
+
? { request_id: envelope.request_id || response.headers.get("x-request-id") }
|
|
400
|
+
: {}),
|
|
401
|
+
},
|
|
162
402
|
});
|
|
163
403
|
}
|
|
164
404
|
return payload;
|
|
165
405
|
}
|
|
166
406
|
|
|
167
|
-
async requestBytes(path, { query, headers = {}, auth = true } = {}) {
|
|
407
|
+
async requestBytes(path, { query, headers = {}, auth = true, timeoutMs = this.timeoutMs } = {}) {
|
|
168
408
|
const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
|
|
169
409
|
for (const [key, value] of Object.entries(query || {})) {
|
|
170
410
|
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
|
|
@@ -179,15 +419,43 @@ export class ApiClient {
|
|
|
179
419
|
throw new CliError("Corvio authentication is required.", { code: "auth_required", exitCode: EXIT.AUTH });
|
|
180
420
|
}
|
|
181
421
|
requestHeaders.authorization = `Bearer ${this.credential}`;
|
|
422
|
+
if (this.workspaceId) {
|
|
423
|
+
requestHeaders["X-Corvio-Workspace-Id"] = this.workspaceId;
|
|
424
|
+
requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
|
|
425
|
+
}
|
|
182
426
|
}
|
|
427
|
+
const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
|
|
183
428
|
let response;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
429
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
430
|
+
try {
|
|
431
|
+
response = await this.fetchImpl(url, {
|
|
432
|
+
method: "GET",
|
|
433
|
+
headers: requestHeaders,
|
|
434
|
+
signal: AbortSignal.timeout(normalizedTimeout),
|
|
435
|
+
});
|
|
436
|
+
if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
|
|
437
|
+
await response.body?.cancel().catch(() => {});
|
|
438
|
+
await sleep(150);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
} catch (cause) {
|
|
443
|
+
const details = networkFailureDetails({ cause, url, method: "GET", requestHeaders });
|
|
444
|
+
if (attempt === 1) {
|
|
445
|
+
await sleep(150);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
throw new CliError(
|
|
449
|
+
details.timed_out
|
|
450
|
+
? `Corvio did not respond within ${normalizedTimeout} ms.`
|
|
451
|
+
: `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
452
|
+
{
|
|
453
|
+
code: details.timed_out ? "request_timeout" : "network_error",
|
|
454
|
+
exitCode: EXIT.REMOTE,
|
|
455
|
+
details: { ...details, attempts: attempt, automatic_retry_attempted: true },
|
|
456
|
+
},
|
|
457
|
+
);
|
|
458
|
+
}
|
|
191
459
|
}
|
|
192
460
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
193
461
|
if (!response.ok) {
|
|
@@ -203,11 +471,68 @@ export class ApiClient {
|
|
|
203
471
|
code: String(envelope.code || `http_${response.status}`),
|
|
204
472
|
status: response.status,
|
|
205
473
|
exitCode: exitCodeForStatus(response.status),
|
|
206
|
-
details:
|
|
474
|
+
details: {
|
|
475
|
+
...(envelope.details || {}),
|
|
476
|
+
...(envelope.request_id || response.headers.get("x-request-id")
|
|
477
|
+
? { request_id: envelope.request_id || response.headers.get("x-request-id") }
|
|
478
|
+
: {}),
|
|
479
|
+
},
|
|
207
480
|
});
|
|
208
481
|
}
|
|
209
482
|
return bytes;
|
|
210
483
|
}
|
|
484
|
+
|
|
485
|
+
async putUrl(url, content, { headers = {}, timeoutMs = this.timeoutMs } = {}) {
|
|
486
|
+
const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
|
|
487
|
+
let response;
|
|
488
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
489
|
+
try {
|
|
490
|
+
response = await this.fetchImpl(String(url), {
|
|
491
|
+
method: "PUT",
|
|
492
|
+
headers,
|
|
493
|
+
body: content,
|
|
494
|
+
signal: AbortSignal.timeout(normalizedTimeout),
|
|
495
|
+
});
|
|
496
|
+
if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
|
|
497
|
+
await response.body?.cancel().catch(() => {});
|
|
498
|
+
await sleep(150);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
break;
|
|
502
|
+
} catch (cause) {
|
|
503
|
+
const details = networkFailureDetails({
|
|
504
|
+
cause,
|
|
505
|
+
url,
|
|
506
|
+
method: "PUT",
|
|
507
|
+
requestHeaders: headers,
|
|
508
|
+
retrySafe: true,
|
|
509
|
+
});
|
|
510
|
+
if (attempt === 1) {
|
|
511
|
+
await sleep(150);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
throw new CliError(
|
|
515
|
+
details.timed_out
|
|
516
|
+
? `The file upload did not respond within ${normalizedTimeout} ms.`
|
|
517
|
+
: `Unable to upload the file: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
518
|
+
{
|
|
519
|
+
code: details.timed_out ? "upload_timeout" : "upload_network_error",
|
|
520
|
+
exitCode: EXIT.REMOTE,
|
|
521
|
+
details: { ...details, attempts: attempt, automatic_retry_attempted: true },
|
|
522
|
+
},
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (!response.ok) {
|
|
527
|
+
const message = (await response.text()).slice(0, 500);
|
|
528
|
+
throw new CliError(message || `File upload failed (${response.status}).`, {
|
|
529
|
+
code: "upload_failed",
|
|
530
|
+
status: response.status,
|
|
531
|
+
exitCode: EXIT.REMOTE,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
return { ok: true, status: response.status };
|
|
535
|
+
}
|
|
211
536
|
}
|
|
212
537
|
|
|
213
538
|
function configRoot(env = process.env) {
|
|
@@ -220,6 +545,10 @@ export function workspaceCredentialPath(env = process.env) {
|
|
|
220
545
|
return join(configRoot(env), "credentials.json");
|
|
221
546
|
}
|
|
222
547
|
|
|
548
|
+
export function workspaceContextPath(env = process.env) {
|
|
549
|
+
return join(configRoot(env), "workspace-context.json");
|
|
550
|
+
}
|
|
551
|
+
|
|
223
552
|
export function adminCredentialPath(env = process.env) {
|
|
224
553
|
return join(configRoot(env), "admin-credentials.json");
|
|
225
554
|
}
|
|
@@ -227,8 +556,8 @@ export function adminCredentialPath(env = process.env) {
|
|
|
227
556
|
export async function readWorkspaceCredential(env = process.env) {
|
|
228
557
|
const environmentKey = String(env.CORVIO_API_KEY || "").trim();
|
|
229
558
|
if (environmentKey) {
|
|
230
|
-
if (!environmentKey.startsWith("cvk_")) {
|
|
231
|
-
throw new CliError("CORVIO_API_KEY must be a Workspace cvk_ key.", {
|
|
559
|
+
if (!environmentKey.startsWith("cvu_") && !environmentKey.startsWith("cvk_")) {
|
|
560
|
+
throw new CliError("CORVIO_API_KEY must be a user cvu_ key or legacy Workspace cvk_ key.", {
|
|
232
561
|
code: "invalid_workspace_key",
|
|
233
562
|
exitCode: EXIT.AUTH,
|
|
234
563
|
});
|
|
@@ -245,14 +574,15 @@ export async function readWorkspaceCredential(env = process.env) {
|
|
|
245
574
|
}
|
|
246
575
|
const profileName = String(parsed.active_profile || "default");
|
|
247
576
|
const profile = parsed.profiles?.[profileName];
|
|
248
|
-
const
|
|
249
|
-
|
|
577
|
+
const metadata = String(parsed.api_key || "").trim() ? parsed : profile;
|
|
578
|
+
const key = String(metadata?.api_key || "").trim();
|
|
579
|
+
if (!key.startsWith("cvu_") && !key.startsWith("cvk_")) {
|
|
250
580
|
throw new CliError("Stored Corvio credentials are invalid; run `corvio auth login` again.", {
|
|
251
581
|
code: "credential_store_invalid",
|
|
252
582
|
exitCode: EXIT.AUTH,
|
|
253
583
|
});
|
|
254
584
|
}
|
|
255
|
-
return { key, source: "device_link", profile: profileName, metadata
|
|
585
|
+
return { key, source: "device_link", profile: profileName, metadata };
|
|
256
586
|
}
|
|
257
587
|
|
|
258
588
|
export async function storeWorkspaceCredential(payload, env = process.env) {
|
|
@@ -260,28 +590,73 @@ export async function storeWorkspaceCredential(payload, env = process.env) {
|
|
|
260
590
|
const root = dirname(path);
|
|
261
591
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
262
592
|
await chmod(root, 0o700).catch(() => {});
|
|
263
|
-
const
|
|
593
|
+
const document = {
|
|
594
|
+
version: 2,
|
|
595
|
+
api_key: payload.secret,
|
|
596
|
+
key_id: payload.key_id,
|
|
597
|
+
audience: payload.audience || "user",
|
|
598
|
+
scopes: payload.scopes || [],
|
|
599
|
+
expires_at: payload.expires_at || null,
|
|
600
|
+
created_at: payload.created_at || new Date().toISOString(),
|
|
601
|
+
source: "device_link",
|
|
602
|
+
};
|
|
603
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
604
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
605
|
+
await chmod(temporary, 0o600);
|
|
606
|
+
await rename(temporary, path);
|
|
607
|
+
await chmod(path, 0o600);
|
|
608
|
+
return { path, profile: "default" };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export async function readWorkspaceContext(env = process.env) {
|
|
612
|
+
const environmentWorkspaceId = String(env.CORVIO_WORKSPACE_ID || "").trim();
|
|
613
|
+
if (environmentWorkspaceId) {
|
|
614
|
+
return { workspaceId: environmentWorkspaceId, workspaceName: null, source: "environment" };
|
|
615
|
+
}
|
|
616
|
+
const path = workspaceContextPath(env);
|
|
617
|
+
try {
|
|
618
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
619
|
+
const workspaceId = String(parsed.active_workspace_id || "").trim();
|
|
620
|
+
if (!workspaceId) return null;
|
|
621
|
+
return {
|
|
622
|
+
workspaceId,
|
|
623
|
+
workspaceName: String(parsed.active_workspace_name || "").trim() || null,
|
|
624
|
+
source: "stored",
|
|
625
|
+
};
|
|
626
|
+
} catch (cause) {
|
|
627
|
+
if (cause?.code === "ENOENT") return null;
|
|
628
|
+
throw new CliError("Stored Corvio Workspace selection is unreadable.", {
|
|
629
|
+
code: "workspace_context_store_invalid",
|
|
630
|
+
exitCode: EXIT.AUTH,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export async function storeWorkspaceContext({ workspaceId, workspaceName = null }, env = process.env) {
|
|
636
|
+
const normalizedId = String(workspaceId || "").trim();
|
|
637
|
+
if (!normalizedId) throw new CliError("Workspace ID is required.", { code: "workspace_selection_required" });
|
|
638
|
+
const path = workspaceContextPath(env);
|
|
639
|
+
const root = dirname(path);
|
|
640
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
641
|
+
await chmod(root, 0o700).catch(() => {});
|
|
264
642
|
const document = {
|
|
265
643
|
version: 1,
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
api_key: payload.secret,
|
|
270
|
-
key_id: payload.key_id,
|
|
271
|
-
workspace_id: payload.workspace_id,
|
|
272
|
-
workspace_name: payload.workspace_name,
|
|
273
|
-
scopes: payload.scopes || [],
|
|
274
|
-
created_at: payload.created_at || new Date().toISOString(),
|
|
275
|
-
source: "device_link",
|
|
276
|
-
},
|
|
277
|
-
},
|
|
644
|
+
active_workspace_id: normalizedId,
|
|
645
|
+
active_workspace_name: String(workspaceName || "").trim() || null,
|
|
646
|
+
updated_at: new Date().toISOString(),
|
|
278
647
|
};
|
|
279
648
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
280
649
|
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
281
650
|
await chmod(temporary, 0o600);
|
|
282
651
|
await rename(temporary, path);
|
|
283
652
|
await chmod(path, 0o600);
|
|
284
|
-
return { path,
|
|
653
|
+
return { path, workspaceId: normalizedId };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export async function clearWorkspaceContext(env = process.env) {
|
|
657
|
+
const path = workspaceContextPath(env);
|
|
658
|
+
await rm(path, { force: true });
|
|
659
|
+
return path;
|
|
285
660
|
}
|
|
286
661
|
|
|
287
662
|
export async function clearWorkspaceCredential(env = process.env) {
|
|
@@ -369,7 +744,21 @@ export function writeError(error, { json = false, stderr = process.stderr } = {}
|
|
|
369
744
|
...(error instanceof CliError && error.details ? { details: error.details } : {}),
|
|
370
745
|
},
|
|
371
746
|
};
|
|
372
|
-
|
|
747
|
+
if (json) {
|
|
748
|
+
stderr.write(`${JSON.stringify(safe)}\n`);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
stderr.write(`Error: ${safe.error.message}\n`);
|
|
752
|
+
if (safe.error.details?.request_id) stderr.write(`Request ID: ${safe.error.details.request_id}\n`);
|
|
753
|
+
if (safe.error.details?.safe_to_retry && safe.error.details?.idempotency_key) {
|
|
754
|
+
stderr.write(
|
|
755
|
+
`Safe retry: rerun the command with --idempotency-key ${safe.error.details.idempotency_key}\n`,
|
|
756
|
+
);
|
|
757
|
+
} else if (safe.error.details?.retry_safety === "revision_guard") {
|
|
758
|
+
stderr.write("Safe retry: rerun with the same --expected-revision; Corvio will reject an already-committed replacement.\n");
|
|
759
|
+
} else if (safe.error.details?.retry_safety === "unknown_commit_state") {
|
|
760
|
+
stderr.write("Retry state is unknown; read the remote object before repeating this mutation.\n");
|
|
761
|
+
}
|
|
373
762
|
}
|
|
374
763
|
|
|
375
764
|
export async function runMain(main, argv = process.argv.slice(2)) {
|
|
@@ -386,15 +775,26 @@ export async function runMain(main, argv = process.argv.slice(2)) {
|
|
|
386
775
|
|
|
387
776
|
export async function openBrowser(url) {
|
|
388
777
|
const target = String(url);
|
|
389
|
-
const command = platform() === "darwin" ? "open" : platform() === "win32" ? "
|
|
390
|
-
const args = platform() === "win32" ? ["
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
778
|
+
const command = platform() === "darwin" ? "open" : platform() === "win32" ? "rundll32.exe" : "xdg-open";
|
|
779
|
+
const args = platform() === "win32" ? ["url.dll,FileProtocolHandler", target] : [target];
|
|
780
|
+
return new Promise((resolvePromise) => {
|
|
781
|
+
let settled = false;
|
|
782
|
+
const finish = (opened) => {
|
|
783
|
+
if (settled) return;
|
|
784
|
+
settled = true;
|
|
785
|
+
resolvePromise(opened);
|
|
786
|
+
};
|
|
787
|
+
try {
|
|
788
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
789
|
+
child.once("error", () => finish(false));
|
|
790
|
+
child.once("spawn", () => {
|
|
791
|
+
child.unref();
|
|
792
|
+
finish(true);
|
|
793
|
+
});
|
|
794
|
+
} catch {
|
|
795
|
+
finish(false);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
398
798
|
}
|
|
399
799
|
|
|
400
800
|
export async function sleep(milliseconds) {
|
|
@@ -408,14 +808,28 @@ export async function readTextFile(path) {
|
|
|
408
808
|
export async function writeTextFile(path, content) {
|
|
409
809
|
const target = resolve(String(path));
|
|
410
810
|
await mkdir(dirname(target), { recursive: true });
|
|
411
|
-
|
|
811
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
812
|
+
try {
|
|
813
|
+
await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
|
|
814
|
+
await rename(temporary, target);
|
|
815
|
+
} catch (error) {
|
|
816
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
817
|
+
throw error;
|
|
818
|
+
}
|
|
412
819
|
return target;
|
|
413
820
|
}
|
|
414
821
|
|
|
415
822
|
export async function writeBinaryFile(path, content) {
|
|
416
823
|
const target = resolve(String(path));
|
|
417
824
|
await mkdir(dirname(target), { recursive: true });
|
|
418
|
-
|
|
825
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
826
|
+
try {
|
|
827
|
+
await writeFile(temporary, content, { flag: "wx" });
|
|
828
|
+
await rename(temporary, target);
|
|
829
|
+
} catch (error) {
|
|
830
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
831
|
+
throw error;
|
|
832
|
+
}
|
|
419
833
|
return target;
|
|
420
834
|
}
|
|
421
835
|
|