@corvio/admin-cli 0.1.0-beta.4 → 0.1.0-beta.6
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 +306 -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.6";
|
|
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 = [];
|
|
@@ -42,19 +95,34 @@ export function parseArgv(argv) {
|
|
|
42
95
|
"allow-actions",
|
|
43
96
|
"private-link",
|
|
44
97
|
"force",
|
|
98
|
+
"once",
|
|
99
|
+
"include-revoked",
|
|
100
|
+
"start-new-chain",
|
|
45
101
|
"help",
|
|
46
102
|
"version",
|
|
47
103
|
]);
|
|
48
104
|
for (let index = 0; index < argv.length; index += 1) {
|
|
49
105
|
const value = argv[index];
|
|
106
|
+
if (value === "--") {
|
|
107
|
+
positionals.push(...argv.slice(index + 1));
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
50
110
|
if (!value.startsWith("--")) {
|
|
51
111
|
positionals.push(value);
|
|
52
112
|
continue;
|
|
53
113
|
}
|
|
54
114
|
const separator = value.indexOf("=");
|
|
55
115
|
const name = value.slice(2, separator === -1 ? undefined : separator);
|
|
116
|
+
if (!name) throw new CliError("Option name must not be empty.", { code: "invalid_option" });
|
|
117
|
+
if (Object.hasOwn(options, name)) {
|
|
118
|
+
throw new CliError(`Option --${name} was provided more than once.`, { code: "duplicate_option" });
|
|
119
|
+
}
|
|
56
120
|
if (booleanOptions.has(name)) {
|
|
57
|
-
|
|
121
|
+
const booleanValue = separator === -1 ? "true" : value.slice(separator + 1);
|
|
122
|
+
if (booleanValue !== "true" && booleanValue !== "false") {
|
|
123
|
+
throw new CliError(`Option --${name} must be true or false.`, { code: "invalid_boolean_option" });
|
|
124
|
+
}
|
|
125
|
+
options[name] = booleanValue === "true";
|
|
58
126
|
continue;
|
|
59
127
|
}
|
|
60
128
|
if (separator !== -1) {
|
|
@@ -71,6 +139,21 @@ export function parseArgv(argv) {
|
|
|
71
139
|
return { options, positionals };
|
|
72
140
|
}
|
|
73
141
|
|
|
142
|
+
export function assertKnownOptions(options, allowed) {
|
|
143
|
+
const allowedSet = new Set(allowed);
|
|
144
|
+
for (const name of Object.keys(options)) {
|
|
145
|
+
if (!allowedSet.has(name)) {
|
|
146
|
+
throw new CliError(`Unknown option --${name}.`, { code: "unknown_option" });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function assertPositionalCount(positionals, maximum) {
|
|
152
|
+
if (positionals.length > maximum) {
|
|
153
|
+
throw new CliError(`Unexpected argument: ${positionals[maximum]}`, { code: "unexpected_argument" });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
74
157
|
export function option(options, name, { required = false, fallback = undefined } = {}) {
|
|
75
158
|
const value = options[name] ?? fallback;
|
|
76
159
|
if (required && (value === undefined || String(value).trim() === "")) {
|
|
@@ -85,28 +168,43 @@ export function listOption(options, name, fallback = []) {
|
|
|
85
168
|
return String(value).split(",").map((item) => item.trim()).filter(Boolean);
|
|
86
169
|
}
|
|
87
170
|
|
|
88
|
-
export function numberOption(options, name, fallback) {
|
|
171
|
+
export function numberOption(options, name, fallback, { minimum = 1, maximum = Number.MAX_SAFE_INTEGER } = {}) {
|
|
89
172
|
const value = options[name];
|
|
90
173
|
if (value === undefined) return fallback;
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
174
|
+
const normalized = String(value).trim();
|
|
175
|
+
const parsed = /^\d+$/.test(normalized) ? Number(normalized) : Number.NaN;
|
|
176
|
+
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
177
|
+
throw new CliError(
|
|
178
|
+
`Option --${name} must be an integer between ${minimum} and ${maximum}.`,
|
|
179
|
+
{ code: "invalid_number_option" },
|
|
180
|
+
);
|
|
94
181
|
}
|
|
95
182
|
return parsed;
|
|
96
183
|
}
|
|
97
184
|
|
|
98
185
|
export function normalizeApiBase(value, fallback) {
|
|
99
186
|
const raw = String(value || fallback).trim().replace(/\/+$/, "");
|
|
100
|
-
|
|
187
|
+
let parsed;
|
|
188
|
+
try {
|
|
189
|
+
parsed = new URL(raw);
|
|
190
|
+
} catch {
|
|
191
|
+
throw new CliError("Corvio API base must be a valid URL.", { code: "invalid_api_base" });
|
|
192
|
+
}
|
|
193
|
+
const localhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
|
|
194
|
+
if (parsed.username || parsed.password || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && localhost))) {
|
|
101
195
|
throw new CliError("Corvio API base must use HTTPS (localhost is allowed for development).", {
|
|
102
196
|
code: "insecure_api_base",
|
|
103
197
|
});
|
|
104
198
|
}
|
|
105
|
-
|
|
199
|
+
if (parsed.search || parsed.hash) {
|
|
200
|
+
throw new CliError("Corvio API base must not include a query string or fragment.", { code: "invalid_api_base" });
|
|
201
|
+
}
|
|
202
|
+
const normalized = parsed.toString().replace(/\/+$/, "");
|
|
203
|
+
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
|
|
106
204
|
}
|
|
107
205
|
|
|
108
206
|
function parseSemver(value) {
|
|
109
|
-
const match = String(value || "").trim().match(/^(\d
|
|
207
|
+
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
208
|
if (!match) return null;
|
|
111
209
|
return {
|
|
112
210
|
core: match.slice(1, 4).map((item) => Number.parseInt(item, 10)),
|
|
@@ -187,15 +285,31 @@ export async function resolveNpmUpdateStatus({
|
|
|
187
285
|
}
|
|
188
286
|
|
|
189
287
|
export class ApiClient {
|
|
190
|
-
constructor({
|
|
288
|
+
constructor({
|
|
289
|
+
baseUrl,
|
|
290
|
+
credential = null,
|
|
291
|
+
workspaceId = null,
|
|
292
|
+
userAgent,
|
|
293
|
+
fetchImpl = globalThis.fetch,
|
|
294
|
+
timeoutMs = process.env.CORVIO_REQUEST_TIMEOUT_MS,
|
|
295
|
+
}) {
|
|
191
296
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
192
297
|
this.credential = credential;
|
|
193
298
|
this.workspaceId = String(workspaceId || "").trim() || null;
|
|
194
299
|
this.userAgent = userAgent;
|
|
195
300
|
this.fetchImpl = fetchImpl;
|
|
301
|
+
this.timeoutMs = normalizeRequestTimeout(timeoutMs);
|
|
196
302
|
}
|
|
197
303
|
|
|
198
|
-
async request(path, {
|
|
304
|
+
async request(path, {
|
|
305
|
+
method = "GET",
|
|
306
|
+
body,
|
|
307
|
+
query,
|
|
308
|
+
headers = {},
|
|
309
|
+
auth = true,
|
|
310
|
+
retrySafe = false,
|
|
311
|
+
timeoutMs = this.timeoutMs,
|
|
312
|
+
} = {}) {
|
|
199
313
|
const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
|
|
200
314
|
for (const [key, value] of Object.entries(query || {})) {
|
|
201
315
|
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
|
|
@@ -216,18 +330,56 @@ export class ApiClient {
|
|
|
216
330
|
requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
|
|
217
331
|
}
|
|
218
332
|
}
|
|
333
|
+
const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
|
|
219
334
|
let response;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
335
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
336
|
+
try {
|
|
337
|
+
response = await this.fetchImpl(url, {
|
|
338
|
+
method,
|
|
339
|
+
headers: requestHeaders,
|
|
340
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
341
|
+
signal: AbortSignal.timeout(normalizedTimeout),
|
|
342
|
+
});
|
|
343
|
+
const safety = networkFailureDetails({
|
|
344
|
+
cause: null,
|
|
345
|
+
url,
|
|
346
|
+
method,
|
|
347
|
+
requestHeaders,
|
|
348
|
+
body,
|
|
349
|
+
retrySafe,
|
|
350
|
+
});
|
|
351
|
+
if (attempt === 1 && safety.safe_to_retry && RETRYABLE_HTTP_STATUSES.has(response.status)) {
|
|
352
|
+
await response.body?.cancel().catch(() => {});
|
|
353
|
+
const retryAfterValue = response.headers.get("retry-after");
|
|
354
|
+
const retryAfterSeconds = retryAfterValue === null ? Number.NaN : Number(retryAfterValue);
|
|
355
|
+
const delayMs = Number.isFinite(retryAfterSeconds)
|
|
356
|
+
? Math.min(2_000, Math.max(0, retryAfterSeconds * 1000))
|
|
357
|
+
: 150;
|
|
358
|
+
await sleep(delayMs);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
break;
|
|
362
|
+
} catch (cause) {
|
|
363
|
+
const details = networkFailureDetails({ cause, url, method, requestHeaders, body, retrySafe });
|
|
364
|
+
if (attempt === 1 && details.safe_to_retry) {
|
|
365
|
+
await sleep(150);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
throw new CliError(
|
|
369
|
+
details.timed_out
|
|
370
|
+
? `Corvio did not respond within ${normalizedTimeout} ms.`
|
|
371
|
+
: `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
372
|
+
{
|
|
373
|
+
code: details.timed_out ? "request_timeout" : "network_error",
|
|
374
|
+
exitCode: EXIT.REMOTE,
|
|
375
|
+
details: {
|
|
376
|
+
...details,
|
|
377
|
+
attempts: attempt,
|
|
378
|
+
automatic_retry_attempted: attempt > 1,
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
}
|
|
231
383
|
}
|
|
232
384
|
const text = await response.text();
|
|
233
385
|
let payload = null;
|
|
@@ -244,13 +396,18 @@ export class ApiClient {
|
|
|
244
396
|
code: String(envelope.code || `http_${response.status}`),
|
|
245
397
|
status: response.status,
|
|
246
398
|
exitCode: exitCodeForStatus(response.status),
|
|
247
|
-
details:
|
|
399
|
+
details: {
|
|
400
|
+
...(envelope.details || {}),
|
|
401
|
+
...(envelope.request_id || response.headers.get("x-request-id")
|
|
402
|
+
? { request_id: envelope.request_id || response.headers.get("x-request-id") }
|
|
403
|
+
: {}),
|
|
404
|
+
},
|
|
248
405
|
});
|
|
249
406
|
}
|
|
250
407
|
return payload;
|
|
251
408
|
}
|
|
252
409
|
|
|
253
|
-
async requestBytes(path, { query, headers = {}, auth = true } = {}) {
|
|
410
|
+
async requestBytes(path, { query, headers = {}, auth = true, timeoutMs = this.timeoutMs } = {}) {
|
|
254
411
|
const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
|
|
255
412
|
for (const [key, value] of Object.entries(query || {})) {
|
|
256
413
|
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
|
|
@@ -270,14 +427,38 @@ export class ApiClient {
|
|
|
270
427
|
requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
|
|
271
428
|
}
|
|
272
429
|
}
|
|
430
|
+
const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
|
|
273
431
|
let response;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
432
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
433
|
+
try {
|
|
434
|
+
response = await this.fetchImpl(url, {
|
|
435
|
+
method: "GET",
|
|
436
|
+
headers: requestHeaders,
|
|
437
|
+
signal: AbortSignal.timeout(normalizedTimeout),
|
|
438
|
+
});
|
|
439
|
+
if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
|
|
440
|
+
await response.body?.cancel().catch(() => {});
|
|
441
|
+
await sleep(150);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
break;
|
|
445
|
+
} catch (cause) {
|
|
446
|
+
const details = networkFailureDetails({ cause, url, method: "GET", requestHeaders });
|
|
447
|
+
if (attempt === 1) {
|
|
448
|
+
await sleep(150);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
throw new CliError(
|
|
452
|
+
details.timed_out
|
|
453
|
+
? `Corvio did not respond within ${normalizedTimeout} ms.`
|
|
454
|
+
: `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
455
|
+
{
|
|
456
|
+
code: details.timed_out ? "request_timeout" : "network_error",
|
|
457
|
+
exitCode: EXIT.REMOTE,
|
|
458
|
+
details: { ...details, attempts: attempt, automatic_retry_attempted: true },
|
|
459
|
+
},
|
|
460
|
+
);
|
|
461
|
+
}
|
|
281
462
|
}
|
|
282
463
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
283
464
|
if (!response.ok) {
|
|
@@ -293,25 +474,57 @@ export class ApiClient {
|
|
|
293
474
|
code: String(envelope.code || `http_${response.status}`),
|
|
294
475
|
status: response.status,
|
|
295
476
|
exitCode: exitCodeForStatus(response.status),
|
|
296
|
-
details:
|
|
477
|
+
details: {
|
|
478
|
+
...(envelope.details || {}),
|
|
479
|
+
...(envelope.request_id || response.headers.get("x-request-id")
|
|
480
|
+
? { request_id: envelope.request_id || response.headers.get("x-request-id") }
|
|
481
|
+
: {}),
|
|
482
|
+
},
|
|
297
483
|
});
|
|
298
484
|
}
|
|
299
485
|
return bytes;
|
|
300
486
|
}
|
|
301
487
|
|
|
302
|
-
async putUrl(url, content, { headers = {} } = {}) {
|
|
488
|
+
async putUrl(url, content, { headers = {}, timeoutMs = this.timeoutMs } = {}) {
|
|
489
|
+
const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
|
|
303
490
|
let response;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
491
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
492
|
+
try {
|
|
493
|
+
response = await this.fetchImpl(String(url), {
|
|
494
|
+
method: "PUT",
|
|
495
|
+
headers,
|
|
496
|
+
body: content,
|
|
497
|
+
signal: AbortSignal.timeout(normalizedTimeout),
|
|
498
|
+
});
|
|
499
|
+
if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
|
|
500
|
+
await response.body?.cancel().catch(() => {});
|
|
501
|
+
await sleep(150);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
break;
|
|
505
|
+
} catch (cause) {
|
|
506
|
+
const details = networkFailureDetails({
|
|
507
|
+
cause,
|
|
508
|
+
url,
|
|
509
|
+
method: "PUT",
|
|
510
|
+
requestHeaders: headers,
|
|
511
|
+
retrySafe: true,
|
|
512
|
+
});
|
|
513
|
+
if (attempt === 1) {
|
|
514
|
+
await sleep(150);
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
throw new CliError(
|
|
518
|
+
details.timed_out
|
|
519
|
+
? `The file upload did not respond within ${normalizedTimeout} ms.`
|
|
520
|
+
: `Unable to upload the file: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
521
|
+
{
|
|
522
|
+
code: details.timed_out ? "upload_timeout" : "upload_network_error",
|
|
523
|
+
exitCode: EXIT.REMOTE,
|
|
524
|
+
details: { ...details, attempts: attempt, automatic_retry_attempted: true },
|
|
525
|
+
},
|
|
526
|
+
);
|
|
527
|
+
}
|
|
315
528
|
}
|
|
316
529
|
if (!response.ok) {
|
|
317
530
|
const message = (await response.text()).slice(0, 500);
|
|
@@ -375,6 +588,23 @@ export async function readWorkspaceCredential(env = process.env) {
|
|
|
375
588
|
return { key, source: "device_link", profile: profileName, metadata };
|
|
376
589
|
}
|
|
377
590
|
|
|
591
|
+
export function readAgentCredential(env = process.env) {
|
|
592
|
+
const key = String(env.CORVIO_AGENT_API_KEY || "").trim();
|
|
593
|
+
if (!key) {
|
|
594
|
+
throw new CliError(
|
|
595
|
+
"No Agent credential found. Set CORVIO_AGENT_API_KEY to the cvg_ secret returned when the Agent was created.",
|
|
596
|
+
{ code: "agent_auth_required", exitCode: EXIT.AUTH },
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
if (!key.startsWith("cvg_")) {
|
|
600
|
+
throw new CliError("CORVIO_AGENT_API_KEY must be an Agent cvg_ key.", {
|
|
601
|
+
code: "invalid_agent_key",
|
|
602
|
+
exitCode: EXIT.AUTH,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
return { key, source: "environment", profile: null, metadata: null };
|
|
606
|
+
}
|
|
607
|
+
|
|
378
608
|
export async function storeWorkspaceCredential(payload, env = process.env) {
|
|
379
609
|
const path = workspaceCredentialPath(env);
|
|
380
610
|
const root = dirname(path);
|
|
@@ -534,7 +764,21 @@ export function writeError(error, { json = false, stderr = process.stderr } = {}
|
|
|
534
764
|
...(error instanceof CliError && error.details ? { details: error.details } : {}),
|
|
535
765
|
},
|
|
536
766
|
};
|
|
537
|
-
|
|
767
|
+
if (json) {
|
|
768
|
+
stderr.write(`${JSON.stringify(safe)}\n`);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
stderr.write(`Error: ${safe.error.message}\n`);
|
|
772
|
+
if (safe.error.details?.request_id) stderr.write(`Request ID: ${safe.error.details.request_id}\n`);
|
|
773
|
+
if (safe.error.details?.safe_to_retry && safe.error.details?.idempotency_key) {
|
|
774
|
+
stderr.write(
|
|
775
|
+
`Safe retry: rerun the command with --idempotency-key ${safe.error.details.idempotency_key}\n`,
|
|
776
|
+
);
|
|
777
|
+
} else if (safe.error.details?.retry_safety === "revision_guard") {
|
|
778
|
+
stderr.write("Safe retry: rerun with the same --expected-revision; Corvio will reject an already-committed replacement.\n");
|
|
779
|
+
} else if (safe.error.details?.retry_safety === "unknown_commit_state") {
|
|
780
|
+
stderr.write("Retry state is unknown; read the remote object before repeating this mutation.\n");
|
|
781
|
+
}
|
|
538
782
|
}
|
|
539
783
|
|
|
540
784
|
export async function runMain(main, argv = process.argv.slice(2)) {
|
|
@@ -551,8 +795,8 @@ export async function runMain(main, argv = process.argv.slice(2)) {
|
|
|
551
795
|
|
|
552
796
|
export async function openBrowser(url) {
|
|
553
797
|
const target = String(url);
|
|
554
|
-
const command = platform() === "darwin" ? "open" : platform() === "win32" ? "
|
|
555
|
-
const args = platform() === "win32" ? ["
|
|
798
|
+
const command = platform() === "darwin" ? "open" : platform() === "win32" ? "rundll32.exe" : "xdg-open";
|
|
799
|
+
const args = platform() === "win32" ? ["url.dll,FileProtocolHandler", target] : [target];
|
|
556
800
|
return new Promise((resolvePromise) => {
|
|
557
801
|
let settled = false;
|
|
558
802
|
const finish = (opened) => {
|
|
@@ -584,14 +828,28 @@ export async function readTextFile(path) {
|
|
|
584
828
|
export async function writeTextFile(path, content) {
|
|
585
829
|
const target = resolve(String(path));
|
|
586
830
|
await mkdir(dirname(target), { recursive: true });
|
|
587
|
-
|
|
831
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
832
|
+
try {
|
|
833
|
+
await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
|
|
834
|
+
await rename(temporary, target);
|
|
835
|
+
} catch (error) {
|
|
836
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
837
|
+
throw error;
|
|
838
|
+
}
|
|
588
839
|
return target;
|
|
589
840
|
}
|
|
590
841
|
|
|
591
842
|
export async function writeBinaryFile(path, content) {
|
|
592
843
|
const target = resolve(String(path));
|
|
593
844
|
await mkdir(dirname(target), { recursive: true });
|
|
594
|
-
|
|
845
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
846
|
+
try {
|
|
847
|
+
await writeFile(temporary, content, { flag: "wx" });
|
|
848
|
+
await rename(temporary, target);
|
|
849
|
+
} catch (error) {
|
|
850
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
851
|
+
throw error;
|
|
852
|
+
}
|
|
595
853
|
return target;
|
|
596
854
|
}
|
|
597
855
|
|