@ornncompute/cli 1.0.137 → 1.0.139

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
@@ -210,9 +210,14 @@ node ID. Changing filters requires a new first-page request. Legacy raw owner
210
210
  cursors require the original `--before` value and filters.
211
211
  Metrics and logs use node-scoped staff gateway routes. Exported private keys
212
212
  include the final newline required by OpenSSH, both in files and with `--print`.
213
- `ornn admin nodes enroll` takes optional `--filter COMPARTMENT_IDS`
214
- (omit to run every compartment step); the retired `--mode` flag is gone. The
215
- gateway, not CLI metadata, authoritatively enforces the admin role.
213
+ `ornn admin enrollment-tokens create` exports the configurable run
214
+ `install_command` as a secret. Its file receipt separates `enrollment_run_id` from
215
+ `token_metadata.id`, the credential ID used for revocation. Read its exact
216
+ `created_at` with `enrollment-tokens list` before machine revocation; the run
217
+ timestamp is not the credential generation. `ornn admin nodes enroll` creates and SSH-launches
218
+ a configurable agent recovery run for the existing node. The former `--filter`
219
+ and `--verbose` compartment options are removed; inspect the saved run for step
220
+ progress. The gateway authoritatively enforces the admin role.
216
221
  `ornn admin nodes sanitize`, `sanitize-status`, and `sanitize-decide` drive one
217
222
  node's held sanitize job, sharing the capability with MCP
218
223
  `admin_node_sanitize_*` and Slack `node_sanitize_*`. `sanitize` needs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ornncompute/cli",
3
- "version": "1.0.137",
3
+ "version": "1.0.139",
4
4
  "description": "Command-line interface for Ornn compute access workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,18 +1,11 @@
1
- import {
2
- enrollmentTokensPath,
3
- remoteRunPath,
4
- listEnrollmentLogs,
5
- } from "../vendor/api-client/orchestrator.js";
6
1
  import { createHash } from "node:crypto";
7
2
  import { lstat, readFile } from "node:fs/promises";
8
3
 
9
- import { CliApiError, resolveInstallerBaseUrl } from "./api-client.mjs";
10
- import { AtomicOutputExistsError, writeAtomicOutput } from "./atomic-output.mjs";
11
4
  import { ADMIN_MUTATION_SAFETY, AdminMutationError, runAdminMutation } from "./admin-mutations.mjs";
5
+ import { CliApiError } from "./api-client.mjs";
6
+ import { AtomicOutputExistsError, writeAtomicOutput } from "./atomic-output.mjs";
12
7
 
13
8
  const SECRET_OUTPUT = new Set(["tokens.create", "nodes.rotate-key"]);
14
- const ENROLLMENT_LOG_POLL_MS = 750;
15
- const ENROLLMENT_LOG_READ_TIMEOUT_MS = 5_000;
16
9
 
17
10
  const USAGE = {
18
11
  "tokens.create":
@@ -20,7 +13,7 @@ const USAGE = {
20
13
  "enrollment.token.revoke":
21
14
  "Usage: ornn admin enrollment-tokens revoke <token-id> [--expected-created-at TIME] [--idempotency-key KEY]",
22
15
  "nodes.enroll":
23
- "Usage: ornn admin nodes enroll <node-id> --host HOST --supplier UUID --facility UUID [--filter COMPARTMENT_IDS] --identity-file PATH --token-ttl SECONDS --replace-existing-management [--verbose]",
16
+ "Usage: ornn admin nodes enroll <node-id> --host HOST --supplier UUID --facility UUID --identity-file PATH --token-ttl SECONDS --replace-existing-management",
24
17
  "nodes.export-key":
25
18
  "Usage: ornn admin nodes export-key <node-id> (--print | --output PATH) [--overwrite]",
26
19
  "nodes.rotate-key":
@@ -167,22 +160,12 @@ function parseNodeEnroll(argv) {
167
160
  const { options, positionals } = parseOptions(
168
161
  argv,
169
162
  {
170
- booleans: ["replace-existing-management", "verbose"],
163
+ booleans: ["replace-existing-management"],
171
164
  positionals: 1,
172
- values: [
173
- "facility",
174
- "filter",
175
- "host",
176
- "identity-file",
177
- "port",
178
- "supplier",
179
- "token-ttl",
180
- "username",
181
- ],
165
+ values: ["facility", "host", "identity-file", "port", "supplier", "token-ttl", "username"],
182
166
  },
183
167
  usage
184
168
  );
185
- const filter = options.filter?.trim();
186
169
  if (!options["replace-existing-management"]) {
187
170
  throw new AdminMutationError(`${usage}\n--replace-existing-management is required.`, {
188
171
  field: "replace-existing-management",
@@ -194,7 +177,6 @@ function parseNodeEnroll(argv) {
194
177
  host: required(options, "host", usage),
195
178
  organization_id: uuid(required(options, "supplier", usage), "supplier"),
196
179
  facility_id: uuid(required(options, "facility", usage), "facility"),
197
- ...(filter ? { filter } : {}),
198
180
  identity_file: required(options, "identity-file", usage),
199
181
  expires_in_seconds: boundedInteger(
200
182
  required(options, "token-ttl", usage),
@@ -205,7 +187,6 @@ function parseNodeEnroll(argv) {
205
187
  username: options.username?.trim() || "root",
206
188
  port: options.port ? boundedInteger(options.port, "port", 1, 65_535) : 22,
207
189
  replace_existing_management: true,
208
- verbose: options.verbose === true,
209
190
  },
210
191
  mutation: {
211
192
  safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
@@ -246,17 +227,18 @@ function text(value, fallback = "") {
246
227
 
247
228
  function safeTokenMetadata(payload) {
248
229
  return {
249
- id: text(payload?.id) || null,
230
+ id: text(payload?.credential_id) || null,
250
231
  organization_id: text(payload?.organization_id) || null,
251
- status: text(payload?.status, "active"),
252
- expires_at: text(payload?.expires_at) || null,
253
- created_at: text(payload?.created_at) || null,
232
+ status: "active",
233
+ expires_at: text(payload?.credential_expires_at) || null,
234
+ // The run's creation time is not the credential's generation identity.
235
+ created_at: null,
254
236
  };
255
237
  }
256
238
 
257
239
  function secretMaterial(capabilityId, payload) {
258
240
  if (capabilityId === "tokens.create") {
259
- return text(payload?.token) || text(payload?.plaintext);
241
+ return text(payload?.install_command);
260
242
  }
261
243
  const key = payload?.private_key;
262
244
  if (typeof key !== "string" || !key.length) return "";
@@ -302,7 +284,7 @@ async function exportSecret({ capabilityId, input, options, payload, stdout }) {
302
284
  }
303
285
  const metadata =
304
286
  capabilityId === "tokens.create"
305
- ? { token_metadata: safeTokenMetadata(payload) }
287
+ ? { enrollment_run_id: text(payload?.id) || null, token_metadata: safeTokenMetadata(payload) }
306
288
  : { node_key: keyMetadata(input, payload) };
307
289
  if (options.print) {
308
290
  stdout.write(material);
@@ -341,7 +323,7 @@ async function exportSecret({ capabilityId, input, options, payload, stdout }) {
341
323
  function reviewFor(capabilityId, input, prepared) {
342
324
  if (capabilityId === "tokens.create") {
343
325
  return {
344
- title: "Create enrollment token",
326
+ title: "Create enrollment command",
345
327
  fields: [
346
328
  { label: "Supplier", value: input.organization_id },
347
329
  { label: "Expires in", value: `${input.expires_in_seconds} seconds` },
@@ -374,7 +356,6 @@ function reviewFor(capabilityId, input, prepared) {
374
356
  { label: "SSH target", value: `${input.username}@${input.host}:${input.port}` },
375
357
  { label: "Supplier", value: input.organization_id },
376
358
  { label: "Facility", value: input.facility_id },
377
- ...(input.filter ? [{ label: "Compartment filter", value: input.filter }] : []),
378
359
  { label: "Token lifetime", value: `${input.expires_in_seconds} seconds` },
379
360
  ],
380
361
  consequence:
@@ -403,7 +384,6 @@ async function enrollmentIdentityInput(input) {
403
384
  }
404
385
  return {
405
386
  ...input,
406
- installer_base: resolveInstallerBaseUrl({ env: input.env }) ?? undefined,
407
387
  private_key: privateKey,
408
388
  };
409
389
  } catch (error) {
@@ -415,129 +395,6 @@ async function enrollmentIdentityInput(input) {
415
395
  }
416
396
  }
417
397
 
418
- function enrollmentRuns(payload) {
419
- if (!payload || typeof payload !== "object") return [];
420
- if (Array.isArray(payload.logs)) return payload.logs;
421
- return Array.isArray(payload.runs) ? payload.runs : [];
422
- }
423
-
424
- function redactEnrollmentText(value, secrets) {
425
- let text = typeof value === "string" ? value : String(value ?? "");
426
- for (const secret of secrets) {
427
- if (!secret) continue;
428
- text = text.replaceAll(secret, "[redacted]");
429
- text = text.replaceAll(encodeURIComponent(secret), "[redacted]");
430
- }
431
- return text;
432
- }
433
-
434
- function freshEnrollmentLogLines(payload, tokenId, secrets, seen) {
435
- const session = enrollmentRuns(payload).find((candidate) => candidate?.token_id === tokenId);
436
- if (!session || typeof session !== "object") return [];
437
- const compartments = Array.isArray(session.compartments) ? session.compartments : [];
438
- const total = Number.isInteger(session.progress_total)
439
- ? session.progress_total
440
- : compartments.length;
441
- const lines = [];
442
- for (const [index, compartment] of compartments.entries()) {
443
- if (!compartment || typeof compartment !== "object") continue;
444
- const id = String(compartment.id ?? index);
445
- const numericId = Number(id);
446
- const current = Number.isInteger(numericId) ? numericId + 1 : session.progress_index;
447
- const title =
448
- typeof compartment.title === "string" && compartment.title ? compartment.title : `Step ${id}`;
449
- const state = typeof compartment.state === "string" ? compartment.state : "";
450
- const stateKey = `${id}:state:${state}`;
451
- if (state && !seen.has(stateKey)) {
452
- seen.add(stateKey);
453
- lines.push(`[enroll ${current}/${total}] ${title}: ${state}`);
454
- }
455
- const output = Array.isArray(compartment.output) ? compartment.output : [];
456
- for (const [outputIndex, entry] of output.entries()) {
457
- const stream =
458
- entry && typeof entry === "object" && typeof entry.stream === "string"
459
- ? entry.stream
460
- : "stdout";
461
- const seq =
462
- entry && typeof entry === "object" && Number.isInteger(entry.seq) ? entry.seq : outputIndex;
463
- const key = `${id}:output:${seq}:${stream}`;
464
- if (seen.has(key)) continue;
465
- seen.add(key);
466
- const raw = entry && typeof entry === "object" ? entry.text : entry;
467
- const text = redactEnrollmentText(raw, secrets);
468
- if (text) lines.push(` ${stream === "stderr" ? "[stderr] " : ""}${text}`);
469
- }
470
- }
471
- return lines;
472
- }
473
-
474
- function enrollmentProgressTransport(fetch, runtime, privateKey) {
475
- let tokenId = "";
476
- const secrets = [privateKey];
477
- const seen = new Set();
478
- const request = (path, init = {}) =>
479
- fetch(
480
- path,
481
- init.signal === undefined && runtime.signal ? { ...init, signal: runtime.signal } : init
482
- );
483
-
484
- return async (path, init = {}) => {
485
- if (path === enrollmentTokensPath) {
486
- const minted = await request(path, init);
487
- if (minted && typeof minted === "object") {
488
- tokenId = typeof minted.id === "string" ? minted.id : "";
489
- const token =
490
- typeof minted.token === "string"
491
- ? minted.token
492
- : typeof minted.plaintext === "string"
493
- ? minted.plaintext
494
- : "";
495
- if (token) secrets.push(token);
496
- }
497
- return minted;
498
- }
499
- if (path !== remoteRunPath || !tokenId) return request(path, init);
500
-
501
- const remote = request(path, init).then(
502
- (value) => ({ ok: true, value }),
503
- (error) => ({ error, ok: false })
504
- );
505
- let stopped = false;
506
- let timer;
507
- let currentPoll = Promise.resolve();
508
- const poll = async () => {
509
- try {
510
- const payload = await listEnrollmentLogs(
511
- fetch,
512
- {},
513
- AbortSignal.timeout(ENROLLMENT_LOG_READ_TIMEOUT_MS)
514
- );
515
- const lines = freshEnrollmentLogLines(payload, tokenId, secrets, seen);
516
- for (const line of lines) runtime.stderr.write(`${line}\n`);
517
- } catch {
518
- // Progress is best-effort; the authoritative remote request still determines success.
519
- }
520
- };
521
- const schedule = () => {
522
- timer = setTimeout(() => {
523
- currentPoll = poll().finally(() => {
524
- if (!stopped) schedule();
525
- });
526
- }, ENROLLMENT_LOG_POLL_MS);
527
- };
528
-
529
- await poll();
530
- schedule();
531
- const result = await remote;
532
- stopped = true;
533
- clearTimeout(timer);
534
- await currentPoll;
535
- await poll();
536
- if (!result.ok) throw result.error;
537
- return result.value;
538
- };
539
- }
540
-
541
398
  function assertSecretOutputContext(input, runtime) {
542
399
  if (input.export?.print && runtime.formatProvided) {
543
400
  throw new AdminMutationError("--print cannot be combined with --format.", {
@@ -626,11 +483,7 @@ async function runEnrollmentMutation({ capability, execute, fetch, input, mutati
626
483
  capabilityId === "nodes.enroll"
627
484
  ? await enrollmentIdentityInput({ ...value, env: runtime.env })
628
485
  : value;
629
- const transport =
630
- capabilityId === "nodes.enroll" && executionInput.verbose
631
- ? enrollmentProgressTransport(fetch, runtime, executionInput.private_key)
632
- : undefined;
633
- return execute(executionInput, { confirmed: true, transport });
486
+ return execute(executionInput, { confirmed: true });
634
487
  },
635
488
  });
636
489
 
@@ -814,7 +814,7 @@ function assertCustomerResultSafe(value, ownOrganizationId) {
814
814
  }
815
815
 
816
816
  function parseCapabilityInput(capability, rawInput) {
817
- const parsed = capability.input.safeParse(rawInput);
817
+ const parsed = (capability.cli?.input ?? capability.input).safeParse(rawInput);
818
818
  if (parsed.success) return parsed.data;
819
819
  const issue = parsed.error.issues[0];
820
820
  if (issue?.path?.[0] === "$positionals") {
@@ -992,7 +992,7 @@ async function executeCapability(
992
992
  let ownOrganizationId;
993
993
  let data;
994
994
  try {
995
- data = await capability.execute(
995
+ data = await (capability.cli?.execute ?? capability.execute)(
996
996
  {
997
997
  actor: "",
998
998
  confirmed,
@@ -1,5 +1,6 @@
1
1
  import * as storageApi from "../vendor/api-client/orchestrator.js";
2
2
  import * as gatewayApi from "../vendor/api-client/gateway.js";
3
+ import { bucketShowCapability, bucketDisconnectCapability } from "../vendor/capabilities/cli.js";
3
4
  import { createHash } from "node:crypto";
4
5
  import { readFile } from "node:fs/promises";
5
6
 
@@ -479,7 +480,11 @@ async function listBuckets(input, runtime) {
479
480
 
480
481
  async function showBucket(input, runtime) {
481
482
  const [reference] = positions(input, 1, "ornn external-buckets show <external-bucket>");
482
- const bucket = resolveExternalBucket(await fetchDrives(runtime), reference);
483
+ const source = resolveExternalBucket(await fetchDrives(runtime), reference);
484
+ const bucket = await bucketShowCapability.execute(
485
+ { actor: "", role: "user", fetch: runtime.fetch },
486
+ { driveId: source.id }
487
+ );
483
488
  return runtime.interaction.result({ data: { external_bucket: safeExternalBucket(bucket) } });
484
489
  }
485
490
 
@@ -692,11 +697,20 @@ async function disconnectBucket(input, runtime) {
692
697
  );
693
698
  }
694
699
  try {
695
- await storageApi.deleteDrive(
696
- runtime.fetch,
697
- safe.id,
698
- {},
699
- { headers: idempotencyHeaders(input) }
700
+ await bucketDisconnectCapability.execute(
701
+ {
702
+ actor: "",
703
+ role: "user",
704
+ confirmed: true,
705
+ fetch: (path, init) =>
706
+ runtime.fetch(path, {
707
+ ...init,
708
+ ...(init?.method === "DELETE"
709
+ ? { headers: { ...init.headers, ...idempotencyHeaders(input) } }
710
+ : {}),
711
+ }),
712
+ },
713
+ { driveId: safe.id }
700
714
  );
701
715
  } catch (error) {
702
716
  throw safeExternalError(error, "The external bucket could not be disconnected safely.");
@@ -1,4 +1,5 @@
1
1
  import * as storageApi from "../vendor/api-client/orchestrator.js";
2
+ import { bucketListCapability, volumeFilesListCapability } from "../vendor/capabilities/cli.js";
2
3
  import { createHash } from "node:crypto";
3
4
  import { createReadStream } from "node:fs";
4
5
  import { stat } from "node:fs/promises";
@@ -292,11 +293,14 @@ export async function fetchDrivePage(runtime, { limit = 25, cursor, scope = "all
292
293
  usage("--limit must be an integer from 1 through 100.", "limit");
293
294
  }
294
295
  try {
295
- const payload = await storageApi.listDrives(runtime.fetch, {
296
- limit: Number(limit),
297
- scope,
298
- ...(cursor ? { cursor } : {}),
299
- });
296
+ const input = { limit: Number(limit), ...(cursor ? { cursor } : {}) };
297
+ const payload =
298
+ scope === "external"
299
+ ? await bucketListCapability.execute(
300
+ { actor: "", role: "user", fetch: runtime.fetch },
301
+ input
302
+ )
303
+ : await storageApi.listDrives(runtime.fetch, { ...input, scope });
300
304
  return { drives: volumeRows(payload), next_cursor: payload?.next_cursor ?? null };
301
305
  } catch (error) {
302
306
  throw safeStorageError(error, "Volumes could not be loaded safely.");
@@ -552,33 +556,21 @@ function shellQuote(value) {
552
556
  }
553
557
 
554
558
  async function fetchFileRows(runtime, drive, prefix) {
555
- const folder = prefix.includes("/") ? prefix.slice(0, prefix.lastIndexOf("/")) : "";
556
559
  const entries = [];
557
- let cursor = 0;
558
- while (cursor !== null) {
559
- let page;
560
- try {
561
- page = await storageApi.listDriveContents(runtime.fetch, String(drive.id), {
562
- recursive: true,
563
- ...(folder ? { path: folder } : {}),
564
- ...(cursor ? { cursor } : {}),
565
- });
566
- } catch (error) {
567
- throw safeStorageError(error, "Volume files could not be listed safely.");
568
- }
569
- entries.push(...(Array.isArray(page?.entries) ? page.entries : []));
570
- const next = page?.next_cursor;
571
- cursor = Number.isInteger(next) || (typeof next === "string" && next) ? next : null;
572
- }
573
- return entries
574
- .filter((entry) => entry?.kind === "file")
575
- .map(safeFile)
576
- .filter((entry) => entry.path && entry.path.startsWith(prefix))
577
- .sort(
578
- (left, right) =>
579
- (Date.parse(right.updated_at ?? "") || 0) - (Date.parse(left.updated_at ?? "") || 0) ||
580
- left.path.localeCompare(right.path)
560
+ try {
561
+ const files = await volumeFilesListCapability.execute(
562
+ { actor: "", role: "user", fetch: runtime.fetch },
563
+ { driveId: drive.id, prefix }
581
564
  );
565
+ for await (const entry of files) entries.push(safeFile(entry));
566
+ } catch (error) {
567
+ throw safeStorageError(error, "Volume files could not be listed safely.");
568
+ }
569
+ return entries.sort(
570
+ (left, right) =>
571
+ (Date.parse(right.updated_at ?? "") || 0) - (Date.parse(left.updated_at ?? "") || 0) ||
572
+ left.path.localeCompare(right.path)
573
+ );
582
574
  }
583
575
 
584
576
  async function listFiles(input, runtime) {