@corvio/admin-cli 0.1.0-beta.4 → 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 +2 -0
- package/dist/cli.js +91 -9
- package/dist/core.js +286 -48
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,3 +10,5 @@ corvio-admin catalog --json
|
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
For Agents and CI, inject `CORVIO_ADMIN_API_KEY='cva_...'`; it takes precedence over the stored device-link credential. Every data query requires a purpose and returns an auditable receipt. This package contains no ordinary Workspace commands. See [the administrator CLI guide](https://corvio.ai/developers/admin-cli).
|
|
13
|
+
|
|
14
|
+
Unknown/duplicate options, malformed bounds, and invalid JSON fail locally. API calls use the same bounded transport/runtime safety policy as the Workspace CLI. `corvio-admin skill download` reads the protected manifest first and verifies the archive SHA-256 before writing the zip.
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
|
|
3
5
|
import {
|
|
4
6
|
ApiClient,
|
|
5
7
|
CliError,
|
|
6
8
|
EXIT,
|
|
9
|
+
assertKnownOptions,
|
|
10
|
+
assertPositionalCount,
|
|
7
11
|
clearAdminCredential,
|
|
8
12
|
listOption,
|
|
9
13
|
normalizeApiBase,
|
|
@@ -20,9 +24,29 @@ import {
|
|
|
20
24
|
writeResult,
|
|
21
25
|
} from "./core.js";
|
|
22
26
|
|
|
23
|
-
const VERSION = "0.1.0-beta.
|
|
27
|
+
const VERSION = "0.1.0-beta.5";
|
|
24
28
|
const DEFAULT_API_BASE = "https://api.corvio.ai/v1";
|
|
25
29
|
const USER_AGENT = `corvio-admin-cli/${VERSION}`;
|
|
30
|
+
const GLOBAL_OPTIONS = Object.freeze(["api-base", "json", "no-input", "help", "version"]);
|
|
31
|
+
const COMMAND_OPTIONS = Object.freeze({
|
|
32
|
+
"auth:login": ["force", "scopes"],
|
|
33
|
+
"auth:status": [],
|
|
34
|
+
"auth:logout": [],
|
|
35
|
+
"capabilities:": [],
|
|
36
|
+
"update:check": [],
|
|
37
|
+
"skill:download": ["output"],
|
|
38
|
+
"catalog:": [],
|
|
39
|
+
"query:": ["file", "purpose", "dataset", "fields", "group-by", "metrics", "filters", "order-by", "limit", "cursor"],
|
|
40
|
+
"data:query": ["file", "purpose", "dataset", "fields", "group-by", "metrics", "filters", "order-by", "limit", "cursor"],
|
|
41
|
+
"workspaces:list": ["purpose", "workspace-id", "status", "limit", "cursor"],
|
|
42
|
+
"users:list": ["purpose", "workspace-id", "status", "limit", "cursor"],
|
|
43
|
+
"worker-jobs:list": ["purpose", "workspace-id", "status", "limit", "cursor"],
|
|
44
|
+
"runs:list": ["purpose", "workspace-id", "status", "limit", "cursor"],
|
|
45
|
+
"runs:content": ["id", "purpose"],
|
|
46
|
+
"usage:events": ["purpose", "workspace-id", "status", "limit", "cursor"],
|
|
47
|
+
"usage:summary": ["purpose", "workspace-id", "status", "limit", "cursor"],
|
|
48
|
+
"receipts:get": ["id"],
|
|
49
|
+
});
|
|
26
50
|
|
|
27
51
|
const HELP = `Corvio Platform Administrator CLI ${VERSION}
|
|
28
52
|
|
|
@@ -47,6 +71,47 @@ Authentication:
|
|
|
47
71
|
This package contains no Workspace ask, search, docs, files, sync, or share commands.
|
|
48
72
|
`;
|
|
49
73
|
|
|
74
|
+
function resourcePath(value) {
|
|
75
|
+
return encodeURIComponent(String(value));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateInvocation(options, positionals) {
|
|
79
|
+
const [command, rawAction] = positionals;
|
|
80
|
+
if (!command || command === "help" || command === "version" || options.help || options.version) return;
|
|
81
|
+
const defaultActions = {
|
|
82
|
+
update: "check",
|
|
83
|
+
workspaces: "list",
|
|
84
|
+
users: "list",
|
|
85
|
+
"worker-jobs": "list",
|
|
86
|
+
};
|
|
87
|
+
const action = ["capabilities", "catalog", "query"].includes(command)
|
|
88
|
+
? ""
|
|
89
|
+
: rawAction || defaultActions[command] || "";
|
|
90
|
+
const schema = COMMAND_OPTIONS[`${command}:${action}`];
|
|
91
|
+
if (!schema) {
|
|
92
|
+
throw new CliError(`Unknown administrator command: ${positionals.join(" ")}`, {
|
|
93
|
+
code: "unknown_command",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
assertKnownOptions(options, [...GLOBAL_OPTIONS, ...schema]);
|
|
97
|
+
const maximum = new Set(["runs:content", "receipts:get"]).has(`${command}:${action}`)
|
|
98
|
+
? 3
|
|
99
|
+
: action
|
|
100
|
+
? 2
|
|
101
|
+
: 1;
|
|
102
|
+
assertPositionalCount(positionals, maximum);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseJsonValue(value, optionName) {
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(String(value));
|
|
108
|
+
} catch {
|
|
109
|
+
throw new CliError(`Option --${optionName} must contain valid JSON.`, {
|
|
110
|
+
code: "invalid_json_option",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
50
115
|
function baseUrl(options, credential = null) {
|
|
51
116
|
return normalizeApiBase(
|
|
52
117
|
options["api-base"]
|
|
@@ -76,7 +141,7 @@ function listQuery(options) {
|
|
|
76
141
|
purpose: option(options, "purpose", { required: true }),
|
|
77
142
|
workspace_id: options["workspace-id"],
|
|
78
143
|
status: options.status,
|
|
79
|
-
limit: numberOption(options, "limit", 100),
|
|
144
|
+
limit: numberOption(options, "limit", 100, { maximum: 200 }),
|
|
80
145
|
cursor: options.cursor,
|
|
81
146
|
};
|
|
82
147
|
}
|
|
@@ -216,7 +281,15 @@ async function catalog(options) {
|
|
|
216
281
|
async function query(options) {
|
|
217
282
|
let body;
|
|
218
283
|
if (options.file) {
|
|
219
|
-
|
|
284
|
+
try {
|
|
285
|
+
body = JSON.parse(await readTextFile(options.file));
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (error?.code === "ENOENT") throw error;
|
|
288
|
+
throw new CliError("--file must contain a valid JSON request object.", { code: "invalid_json_file" });
|
|
289
|
+
}
|
|
290
|
+
if (!body || Array.isArray(body) || typeof body !== "object") {
|
|
291
|
+
throw new CliError("--file must contain a JSON object.", { code: "invalid_json_file" });
|
|
292
|
+
}
|
|
220
293
|
if (options.purpose) body.purpose = options.purpose;
|
|
221
294
|
} else {
|
|
222
295
|
body = {
|
|
@@ -224,10 +297,10 @@ async function query(options) {
|
|
|
224
297
|
purpose: option(options, "purpose", { required: true }),
|
|
225
298
|
fields: listOption(options, "fields"),
|
|
226
299
|
group_by: listOption(options, "group-by"),
|
|
227
|
-
metrics: options.metrics ?
|
|
228
|
-
filters: options.filters ?
|
|
229
|
-
order_by: options["order-by"] ?
|
|
230
|
-
limit: numberOption(options, "limit", 100),
|
|
300
|
+
metrics: options.metrics ? parseJsonValue(options.metrics, "metrics") : [],
|
|
301
|
+
filters: options.filters ? parseJsonValue(options.filters, "filters") : [],
|
|
302
|
+
order_by: options["order-by"] ? parseJsonValue(options["order-by"], "order-by") : [],
|
|
303
|
+
limit: numberOption(options, "limit", 100, { maximum: 200 }),
|
|
231
304
|
cursor: options.cursor || null,
|
|
232
305
|
};
|
|
233
306
|
}
|
|
@@ -243,7 +316,15 @@ async function listResource(options, resource) {
|
|
|
243
316
|
async function downloadSkill(options) {
|
|
244
317
|
const { api } = await client(options);
|
|
245
318
|
const output = String(options.output || "corvio-admin-data.zip");
|
|
319
|
+
const manifest = await api.request("/admin/skill/manifest.json");
|
|
246
320
|
const bytes = await api.requestBytes("/admin/skill/corvio-admin-data.zip");
|
|
321
|
+
const actualHash = createHash("sha256").update(bytes).digest("hex");
|
|
322
|
+
if (!manifest.archive_sha256 || actualHash !== manifest.archive_sha256) {
|
|
323
|
+
throw new CliError("Administrator Skill archive did not match its manifest SHA-256.", {
|
|
324
|
+
code: "admin_skill_integrity_mismatch",
|
|
325
|
+
exitCode: EXIT.REMOTE,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
247
328
|
const path = await writeBinaryFile(output, bytes);
|
|
248
329
|
writeResult({
|
|
249
330
|
downloaded: true,
|
|
@@ -256,6 +337,7 @@ async function main({ options, positionals }) {
|
|
|
256
337
|
const [command, action, id] = positionals;
|
|
257
338
|
if (options.version || command === "version") return writeResult(VERSION, { json: options.json });
|
|
258
339
|
if (options.help || !command || command === "help") return writeResult(HELP.trimEnd(), { json: false });
|
|
340
|
+
validateInvocation(options, positionals);
|
|
259
341
|
if (command === "auth") {
|
|
260
342
|
if (action === "login") return authLogin(options);
|
|
261
343
|
if (action === "status") return authStatus(options);
|
|
@@ -276,7 +358,7 @@ async function main({ options, positionals }) {
|
|
|
276
358
|
const runId = id || option(options, "id", { required: true });
|
|
277
359
|
const purpose = option(options, "purpose", { required: true });
|
|
278
360
|
const { api } = await client(options);
|
|
279
|
-
return writeResult(await api.request(`/admin/runs/${runId}/content`, { query: { purpose } }), { json: options.json });
|
|
361
|
+
return writeResult(await api.request(`/admin/runs/${resourcePath(runId)}/content`, { query: { purpose } }), { json: options.json });
|
|
280
362
|
}
|
|
281
363
|
if (command === "usage" && action === "events") return listResource(options, "usage/events");
|
|
282
364
|
if (command === "usage" && action === "summary") {
|
|
@@ -286,7 +368,7 @@ async function main({ options, positionals }) {
|
|
|
286
368
|
if (command === "receipts" && action === "get") {
|
|
287
369
|
const receiptId = id || option(options, "id", { required: true });
|
|
288
370
|
const { api } = await client(options);
|
|
289
|
-
return writeResult(await api.request(`/admin/data/query-receipts/${receiptId}`), { json: options.json });
|
|
371
|
+
return writeResult(await api.request(`/admin/data/query-receipts/${resourcePath(receiptId)}`), { json: options.json });
|
|
290
372
|
}
|
|
291
373
|
throw new CliError(`Unknown administrator command: ${positionals.join(" ")}`);
|
|
292
374
|
}
|
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,28 +165,43 @@ 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`;
|
|
106
201
|
}
|
|
107
202
|
|
|
108
203
|
function parseSemver(value) {
|
|
109
|
-
const match = String(value || "").trim().match(/^(\d
|
|
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-]+)*)?$/);
|
|
110
205
|
if (!match) return null;
|
|
111
206
|
return {
|
|
112
207
|
core: match.slice(1, 4).map((item) => Number.parseInt(item, 10)),
|
|
@@ -187,15 +282,31 @@ export async function resolveNpmUpdateStatus({
|
|
|
187
282
|
}
|
|
188
283
|
|
|
189
284
|
export class ApiClient {
|
|
190
|
-
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
|
+
}) {
|
|
191
293
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
192
294
|
this.credential = credential;
|
|
193
295
|
this.workspaceId = String(workspaceId || "").trim() || null;
|
|
194
296
|
this.userAgent = userAgent;
|
|
195
297
|
this.fetchImpl = fetchImpl;
|
|
298
|
+
this.timeoutMs = normalizeRequestTimeout(timeoutMs);
|
|
196
299
|
}
|
|
197
300
|
|
|
198
|
-
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
|
+
} = {}) {
|
|
199
310
|
const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
|
|
200
311
|
for (const [key, value] of Object.entries(query || {})) {
|
|
201
312
|
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
|
|
@@ -216,18 +327,56 @@ export class ApiClient {
|
|
|
216
327
|
requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
|
|
217
328
|
}
|
|
218
329
|
}
|
|
330
|
+
const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
|
|
219
331
|
let response;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
+
}
|
|
231
380
|
}
|
|
232
381
|
const text = await response.text();
|
|
233
382
|
let payload = null;
|
|
@@ -244,13 +393,18 @@ export class ApiClient {
|
|
|
244
393
|
code: String(envelope.code || `http_${response.status}`),
|
|
245
394
|
status: response.status,
|
|
246
395
|
exitCode: exitCodeForStatus(response.status),
|
|
247
|
-
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
|
+
},
|
|
248
402
|
});
|
|
249
403
|
}
|
|
250
404
|
return payload;
|
|
251
405
|
}
|
|
252
406
|
|
|
253
|
-
async requestBytes(path, { query, headers = {}, auth = true } = {}) {
|
|
407
|
+
async requestBytes(path, { query, headers = {}, auth = true, timeoutMs = this.timeoutMs } = {}) {
|
|
254
408
|
const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
|
|
255
409
|
for (const [key, value] of Object.entries(query || {})) {
|
|
256
410
|
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
|
|
@@ -270,14 +424,38 @@ export class ApiClient {
|
|
|
270
424
|
requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
|
|
271
425
|
}
|
|
272
426
|
}
|
|
427
|
+
const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
|
|
273
428
|
let response;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
+
}
|
|
281
459
|
}
|
|
282
460
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
283
461
|
if (!response.ok) {
|
|
@@ -293,25 +471,57 @@ export class ApiClient {
|
|
|
293
471
|
code: String(envelope.code || `http_${response.status}`),
|
|
294
472
|
status: response.status,
|
|
295
473
|
exitCode: exitCodeForStatus(response.status),
|
|
296
|
-
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
|
+
},
|
|
297
480
|
});
|
|
298
481
|
}
|
|
299
482
|
return bytes;
|
|
300
483
|
}
|
|
301
484
|
|
|
302
|
-
async putUrl(url, content, { headers = {} } = {}) {
|
|
485
|
+
async putUrl(url, content, { headers = {}, timeoutMs = this.timeoutMs } = {}) {
|
|
486
|
+
const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
|
|
303
487
|
let response;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
+
}
|
|
315
525
|
}
|
|
316
526
|
if (!response.ok) {
|
|
317
527
|
const message = (await response.text()).slice(0, 500);
|
|
@@ -534,7 +744,21 @@ export function writeError(error, { json = false, stderr = process.stderr } = {}
|
|
|
534
744
|
...(error instanceof CliError && error.details ? { details: error.details } : {}),
|
|
535
745
|
},
|
|
536
746
|
};
|
|
537
|
-
|
|
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
|
+
}
|
|
538
762
|
}
|
|
539
763
|
|
|
540
764
|
export async function runMain(main, argv = process.argv.slice(2)) {
|
|
@@ -551,8 +775,8 @@ export async function runMain(main, argv = process.argv.slice(2)) {
|
|
|
551
775
|
|
|
552
776
|
export async function openBrowser(url) {
|
|
553
777
|
const target = String(url);
|
|
554
|
-
const command = platform() === "darwin" ? "open" : platform() === "win32" ? "
|
|
555
|
-
const args = platform() === "win32" ? ["
|
|
778
|
+
const command = platform() === "darwin" ? "open" : platform() === "win32" ? "rundll32.exe" : "xdg-open";
|
|
779
|
+
const args = platform() === "win32" ? ["url.dll,FileProtocolHandler", target] : [target];
|
|
556
780
|
return new Promise((resolvePromise) => {
|
|
557
781
|
let settled = false;
|
|
558
782
|
const finish = (opened) => {
|
|
@@ -584,14 +808,28 @@ export async function readTextFile(path) {
|
|
|
584
808
|
export async function writeTextFile(path, content) {
|
|
585
809
|
const target = resolve(String(path));
|
|
586
810
|
await mkdir(dirname(target), { recursive: true });
|
|
587
|
-
|
|
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
|
+
}
|
|
588
819
|
return target;
|
|
589
820
|
}
|
|
590
821
|
|
|
591
822
|
export async function writeBinaryFile(path, content) {
|
|
592
823
|
const target = resolve(String(path));
|
|
593
824
|
await mkdir(dirname(target), { recursive: true });
|
|
594
|
-
|
|
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
|
+
}
|
|
595
833
|
return target;
|
|
596
834
|
}
|
|
597
835
|
|