@corvio/admin-cli 0.1.0-beta.2 → 0.1.0-beta.21

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 CHANGED
@@ -1,12 +1,14 @@
1
1
  # Corvio Platform Administrator CLI
2
2
 
3
- Official read-only CLI for bounded Corvio platform investigations. It is separate from the Workspace CLI and accepts only a short-lived `cva_` credential.
3
+ Official read-only CLI for bounded Corvio platform investigations. It is separate from the Workspace CLI and accepts only a short-lived `cva_` credential, supplied by environment or administrator device link.
4
4
 
5
5
  ```bash
6
- npm install --global @corvio/admin-cli
7
- export CORVIO_ADMIN_API_KEY='cva_...'
8
- corvio-admin auth status
6
+ npm install --global @corvio/admin-cli@beta
7
+ corvio-admin auth login
8
+ corvio-admin auth status --json
9
9
  corvio-admin catalog --json
10
10
  ```
11
11
 
12
- 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).
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,28 +1,59 @@
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,
11
+ clearAdminCredential,
7
12
  listOption,
8
13
  normalizeApiBase,
9
14
  numberOption,
15
+ openBrowser,
10
16
  option,
11
17
  readAdminCredential,
12
18
  readTextFile,
19
+ resolveNpmUpdateStatus,
13
20
  runMain,
21
+ sleep,
22
+ storeAdminCredential,
23
+ writeBinaryFile,
14
24
  writeResult,
15
25
  } from "./core.js";
16
26
 
17
- const VERSION = "0.1.0-beta.2";
27
+ const VERSION = "0.1.0-beta.21";
18
28
  const DEFAULT_API_BASE = "https://api.corvio.ai/v1";
19
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
+ });
20
50
 
21
51
  const HELP = `Corvio Platform Administrator CLI ${VERSION}
22
52
 
23
53
  Usage:
24
- corvio-admin auth status
54
+ corvio-admin auth login|status|logout
25
55
  corvio-admin capabilities
56
+ corvio-admin skill download [--output <path>]
26
57
  corvio-admin catalog
27
58
  corvio-admin query|data query --file <request.json>
28
59
  corvio-admin workspaces list --purpose <reason>
@@ -31,26 +62,78 @@ Usage:
31
62
  corvio-admin worker-jobs list --purpose <reason>
32
63
  corvio-admin usage events|summary --purpose <reason>
33
64
  corvio-admin receipts get <id>
65
+ corvio-admin update check
34
66
 
35
67
  Authentication:
36
- Set CORVIO_ADMIN_API_KEY to a short-lived cva_ key.
68
+ CORVIO_ADMIN_API_KEY=cva_... takes precedence over the stored device-link login.
69
+ Run 'corvio-admin auth login' when no environment key is configured.
37
70
 
38
71
  This package contains no Workspace ask, search, docs, files, sync, or share commands.
39
72
  `;
40
73
 
41
- function baseUrl(options) {
42
- return normalizeApiBase(options["api-base"] || process.env.CORVIO_API_BASE, DEFAULT_API_BASE);
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
+
115
+ function baseUrl(options, credential = null) {
116
+ return normalizeApiBase(
117
+ options["api-base"]
118
+ || process.env.CORVIO_ADMIN_API_BASE
119
+ || process.env.CORVIO_API_BASE
120
+ || credential?.metadata?.api_base,
121
+ DEFAULT_API_BASE,
122
+ );
43
123
  }
44
124
 
45
- function client(options) {
46
- const credential = readAdminCredential();
125
+ async function client(options) {
126
+ const credential = await readAdminCredential();
47
127
  if (!credential) {
48
- throw new CliError("Set CORVIO_ADMIN_API_KEY to a short-lived cva_ key.", {
128
+ throw new CliError("Set CORVIO_ADMIN_API_KEY or run `corvio-admin auth login`.", {
49
129
  code: "admin_auth_required",
50
130
  exitCode: EXIT.AUTH,
51
131
  });
52
132
  }
53
- return new ApiClient({ baseUrl: baseUrl(options), credential: credential.key, userAgent: USER_AGENT });
133
+ return {
134
+ api: new ApiClient({ baseUrl: baseUrl(options, credential), credential: credential.key, userAgent: USER_AGENT }),
135
+ credential,
136
+ };
54
137
  }
55
138
 
56
139
  function listQuery(options) {
@@ -58,13 +141,90 @@ function listQuery(options) {
58
141
  purpose: option(options, "purpose", { required: true }),
59
142
  workspace_id: options["workspace-id"],
60
143
  status: options.status,
61
- limit: numberOption(options, "limit", 100),
144
+ limit: numberOption(options, "limit", 100, { maximum: 200 }),
62
145
  cursor: options.cursor,
63
146
  };
64
147
  }
65
148
 
149
+ async function authLogin(options) {
150
+ const current = await readAdminCredential();
151
+ if (current && !options.force) {
152
+ const api = new ApiClient({ baseUrl: baseUrl(options, current), credential: current.key, userAgent: USER_AGENT });
153
+ const identity = await api.request("/me");
154
+ writeResult({ authenticated: true, source: current.source, identity }, { json: options.json });
155
+ return;
156
+ }
157
+ const requestedScopes = listOption(options, "scopes", [
158
+ "admin_data:read",
159
+ "admin_support:read",
160
+ "admin_costs:read",
161
+ ]);
162
+ if (requestedScopes.includes("admin_content:read") && !options.force) {
163
+ throw new CliError(
164
+ "Requesting admin_content:read requires --force and should be limited to a specific Run investigation.",
165
+ { code: "admin_content_scope_confirmation_required" },
166
+ );
167
+ }
168
+ const api = new ApiClient({ baseUrl: baseUrl(options), userAgent: USER_AGENT });
169
+ const authorization = await api.request("/admin/auth/device-authorizations", {
170
+ method: "POST",
171
+ auth: false,
172
+ body: {
173
+ client_name: "Corvio Platform Administrator CLI",
174
+ cli_version: VERSION,
175
+ scopes: requestedScopes,
176
+ },
177
+ });
178
+ process.stderr.write(`Open ${authorization.verification_uri_complete}\nCode: ${authorization.user_code}\n`);
179
+ if (!options["no-input"]) await openBrowser(authorization.verification_uri_complete);
180
+
181
+ const expiresAt = Date.now() + Number(authorization.expires_in || 600) * 1000;
182
+ let intervalSeconds = Math.max(5, Number(authorization.interval || 5));
183
+ let token;
184
+ while (Date.now() < expiresAt) {
185
+ await sleep(intervalSeconds * 1000);
186
+ try {
187
+ token = await api.request("/admin/auth/device-token", {
188
+ method: "POST",
189
+ auth: false,
190
+ body: { device_code: authorization.device_code },
191
+ });
192
+ break;
193
+ } catch (error) {
194
+ if (!(error instanceof CliError)) throw error;
195
+ if (error.code === "authorization_pending") continue;
196
+ if (error.code === "slow_down") {
197
+ intervalSeconds = Math.min(30, intervalSeconds + 5);
198
+ continue;
199
+ }
200
+ throw error;
201
+ }
202
+ }
203
+ if (!token?.secret || token.audience !== "platform_admin") {
204
+ throw new CliError("Administrator device authorization expired; run `corvio-admin auth login` again.", {
205
+ code: "expired_token",
206
+ exitCode: EXIT.AUTH,
207
+ });
208
+ }
209
+ await storeAdminCredential({ ...token, api_base: baseUrl(options) });
210
+ const authenticatedApi = new ApiClient({ baseUrl: baseUrl(options), credential: token.secret, userAgent: USER_AGENT });
211
+ const identity = await authenticatedApi.request("/me");
212
+ writeResult({
213
+ authenticated: true,
214
+ source: "device_link",
215
+ audience: identity.api_key?.audience,
216
+ scopes: identity.api_key?.scopes || [],
217
+ expires_at: token.expires_at,
218
+ }, { json: options.json });
219
+ }
220
+
66
221
  async function authStatus(options) {
67
- const api = client(options);
222
+ const credential = await readAdminCredential();
223
+ if (!credential) {
224
+ writeResult({ authenticated: false, sources: ["CORVIO_ADMIN_API_KEY", "device_link"] }, { json: options.json });
225
+ return;
226
+ }
227
+ const api = new ApiClient({ baseUrl: baseUrl(options, credential), credential: credential.key, userAgent: USER_AGENT });
68
228
  const identity = await api.request("/me");
69
229
  if (identity.api_key?.audience !== "platform_admin") {
70
230
  throw new CliError("The configured key is not a platform administrator credential.", {
@@ -72,23 +232,64 @@ async function authStatus(options) {
72
232
  exitCode: EXIT.AUTH,
73
233
  });
74
234
  }
75
- writeResult({ authenticated: true, source: "environment", identity }, { json: options.json });
235
+ writeResult({ authenticated: true, source: credential.source, identity }, { json: options.json });
236
+ }
237
+
238
+ async function authLogout(options) {
239
+ const credential = await readAdminCredential();
240
+ if (!credential) {
241
+ writeResult({ authenticated: false, changed: false }, { json: options.json });
242
+ return;
243
+ }
244
+ if (credential.source === "environment") {
245
+ throw new CliError("CORVIO_ADMIN_API_KEY is managed by the parent environment and cannot be removed by the CLI.", {
246
+ code: "environment_credential_managed_externally",
247
+ exitCode: EXIT.AUTH,
248
+ });
249
+ }
250
+ const api = new ApiClient({ baseUrl: baseUrl(options, credential), credential: credential.key, userAgent: USER_AGENT });
251
+ await api.request("/admin/auth/credentials/current:revoke", { method: "POST" });
252
+ await clearAdminCredential();
253
+ writeResult({ authenticated: false, revoked: true }, { json: options.json });
76
254
  }
77
255
 
78
256
  async function capabilities(options) {
79
- const api = client(options);
257
+ const { api, credential } = await client(options);
80
258
  const [index, identity] = await Promise.all([api.request("/admin"), api.request("/me")]);
81
- writeResult({ cli_version: VERSION, api: index, principal: identity }, { json: options.json });
259
+ writeResult({ cli_version: VERSION, auth_source: credential.source, api: index, principal: identity }, { json: options.json });
260
+ }
261
+
262
+ async function updateCheck(options) {
263
+ const { api } = await client(options);
264
+ const index = await api.request("/admin");
265
+ const result = await resolveNpmUpdateStatus({
266
+ currentVersion: VERSION,
267
+ policy: index?.distribution?.admin_cli || {
268
+ package: "@corvio/admin-cli",
269
+ dist_tag: "beta",
270
+ },
271
+ userAgent: USER_AGENT,
272
+ });
273
+ writeResult(result, { json: options.json });
82
274
  }
83
275
 
84
276
  async function catalog(options) {
85
- writeResult(await client(options).request("/admin/data/catalog"), { json: options.json });
277
+ const { api } = await client(options);
278
+ writeResult(await api.request("/admin/data/catalog"), { json: options.json });
86
279
  }
87
280
 
88
281
  async function query(options) {
89
282
  let body;
90
283
  if (options.file) {
91
- body = JSON.parse(await readTextFile(options.file));
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
+ }
92
293
  if (options.purpose) body.purpose = options.purpose;
93
294
  } else {
94
295
  body = {
@@ -96,26 +297,56 @@ async function query(options) {
96
297
  purpose: option(options, "purpose", { required: true }),
97
298
  fields: listOption(options, "fields"),
98
299
  group_by: listOption(options, "group-by"),
99
- metrics: options.metrics ? JSON.parse(options.metrics) : [],
100
- filters: options.filters ? JSON.parse(options.filters) : [],
101
- order_by: options["order-by"] ? JSON.parse(options["order-by"]) : [],
102
- 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 }),
103
304
  cursor: options.cursor || null,
104
305
  };
105
306
  }
106
- writeResult(await client(options).request("/admin/data/query", { method: "POST", body }), { json: options.json });
307
+ const { api } = await client(options);
308
+ writeResult(await api.request("/admin/data/query", { method: "POST", body }), { json: options.json });
107
309
  }
108
310
 
109
311
  async function listResource(options, resource) {
110
- writeResult(await client(options).request(`/admin/${resource}`, { query: listQuery(options) }), { json: options.json });
312
+ const { api } = await client(options);
313
+ writeResult(await api.request(`/admin/${resource}`, { query: listQuery(options) }), { json: options.json });
314
+ }
315
+
316
+ async function downloadSkill(options) {
317
+ const { api } = await client(options);
318
+ const output = String(options.output || "corvio-admin-data.zip");
319
+ const manifest = await api.request("/admin/skill/manifest.json");
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
+ }
328
+ const path = await writeBinaryFile(output, bytes);
329
+ writeResult({
330
+ downloaded: true,
331
+ output: path,
332
+ install: `npx skills add ${path}`,
333
+ }, { json: options.json });
111
334
  }
112
335
 
113
336
  async function main({ options, positionals }) {
114
337
  const [command, action, id] = positionals;
115
338
  if (options.version || command === "version") return writeResult(VERSION, { json: options.json });
116
339
  if (options.help || !command || command === "help") return writeResult(HELP.trimEnd(), { json: false });
117
- if (command === "auth" && action === "status") return authStatus(options);
340
+ validateInvocation(options, positionals);
341
+ if (command === "auth") {
342
+ if (action === "login") return authLogin(options);
343
+ if (action === "status") return authStatus(options);
344
+ if (action === "logout") return authLogout(options);
345
+ throw new CliError("auth action must be login, status, or logout.");
346
+ }
118
347
  if (command === "capabilities") return capabilities(options);
348
+ if (command === "update" && (action || "check") === "check") return updateCheck(options);
349
+ if (command === "skill" && action === "download") return downloadSkill(options);
119
350
  if (command === "catalog") return catalog(options);
120
351
  if (command === "query") return query(options);
121
352
  if (command === "data" && action === "query") return query(options);
@@ -126,15 +357,18 @@ async function main({ options, positionals }) {
126
357
  if (command === "runs" && action === "content") {
127
358
  const runId = id || option(options, "id", { required: true });
128
359
  const purpose = option(options, "purpose", { required: true });
129
- return writeResult(await client(options).request(`/admin/runs/${runId}/content`, { query: { purpose } }), { json: options.json });
360
+ const { api } = await client(options);
361
+ return writeResult(await api.request(`/admin/runs/${resourcePath(runId)}/content`, { query: { purpose } }), { json: options.json });
130
362
  }
131
363
  if (command === "usage" && action === "events") return listResource(options, "usage/events");
132
364
  if (command === "usage" && action === "summary") {
133
- return writeResult(await client(options).request("/admin/usage/summary", { query: listQuery(options) }), { json: options.json });
365
+ const { api } = await client(options);
366
+ return writeResult(await api.request("/admin/usage/summary", { query: listQuery(options) }), { json: options.json });
134
367
  }
135
368
  if (command === "receipts" && action === "get") {
136
369
  const receiptId = id || option(options, "id", { required: true });
137
- return writeResult(await client(options).request(`/admin/data/query-receipts/${receiptId}`), { json: options.json });
370
+ const { api } = await client(options);
371
+ return writeResult(await api.request(`/admin/data/query-receipts/${resourcePath(receiptId)}`), { json: options.json });
138
372
  }
139
373
  throw new CliError(`Unknown administrator command: ${positionals.join(" ")}`);
140
374
  }