@ornncompute/cli 0.2.3 → 0.2.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 CHANGED
@@ -75,11 +75,11 @@ ornn node diagnose <node-id> [--json]
75
75
  ornn node list [--operator <id-or-slug>] [--facility <id>] [--json]
76
76
  ornn node reboot <node-id> [--json]
77
77
  ornn node hard-reset <node-id> [--json]
78
- ornn node off-grid <node-id> [--json]
79
- ornn node on-grid <node-id> [--json]
80
- ornn node terminate <node-id> [--reason <text>] [--force] [--json]
78
+ ornn node off-grid <node-id> [--reason <text>] [--yes] [--json]
79
+ ornn node on-grid <node-id> [--yes] [--json]
80
+ ornn node exec <node-id> --command <shell> --timeout-seconds <n> [--yes] [--json]
81
+ ornn node terminate <node-id> [--reason <text>] [--force] [--yes] [--json]
81
82
  ornn node admin-key <node-id> [--generate] [--json]
82
- ornn node deenroll <node-id> [--reason <text>] [--keep-record] [--json]
83
83
  ornn reservations commerce list --tenant <email-or-id> [--listing <listing-id>] [--json]
84
84
  ornn reservations commerce create --tenant <email> --listing <listing-id> [--node-count <n>] [--node <node-id>] [--start-at <timestamp>] [--end-at <timestamp>] [--price-per-gpu-hour <rate>] [--json]
85
85
  ornn reservations deploy <node-id> --target-tenant <id> --commerce-reservation <id> [--target-user <id>] [--network public|private] [--json]
@@ -244,23 +244,21 @@ path.
244
244
  - `ornn node hard-reset <node-id>` wipes tenant storage/users and reboots, then
245
245
  re-pushes the tenant's keys on reconnect. The tenant keeps ownership of the
246
246
  host.
247
- - `ornn node off-grid <node-id> [--reason <text>]` queues the agentless handoff
248
- that removes Ornn agent management from the still-powered-on host.
249
- - `ornn node on-grid <node-id>` re-enrolls an off-grid host (SSH in with the
250
- stored admin key and re-run the installer) to restore Ornn management.
251
- - `ornn node terminate <node-id> [--reason <text>] [--force]` best-effort cleans
252
- the host, removes the Ornn agent, and hard-deletes the node record from the
253
- platform. It does not return the host to a tenant.
247
+ - `ornn node off-grid <node-id> [--reason <text>] [--yes]` takes a node off-grid
248
+ (remove Ornn agent management, leave the host powered on). Asks y/N unless `--yes`.
249
+ - `ornn node on-grid <node-id> [--yes]` re-enrolls an off-grid host: orchestrator
250
+ SSHs with the stored admin key and re-runs the installer. Asks y/N unless `--yes`.
251
+ - `ornn node exec <node-id> --command <shell> --timeout-seconds <n> [--yes]` runs
252
+ one unrestricted command over orchestrator SSH with the stored admin key. Not
253
+ an interactive shell (`ornn ssh`). Asks y/N unless `--yes`.
254
+ - `ornn node terminate <node-id> [--reason <text>] [--force] [--yes]` best-effort
255
+ cleans the host, removes the Ornn agent, and hard-deletes the node record.
256
+ It does not return the host to a tenant. Asks y/N unless `--yes`. There is no
257
+ `deenroll` verb; use terminate.
254
258
  - `ornn node admin-key <node-id> [--generate]` prints the Ornn admin SSH keypair
255
259
  for the node (including the private key) so an operator can SSH into the host.
256
260
  `--generate` always mints a new pair, stores it, and installs the public key
257
261
  on the live host. Treat the output as a secret.
258
- - `ornn node deenroll <node-id>` gracefully terminates the enrollment (revokes
259
- the node claim, retires any listing, and queues the node-agent self-uninstall
260
- packet) while retaining the node row as a `deenrolled` audit record. The
261
- legacy `--keep-record` flag remains accepted but is now a no-op. Pass
262
- `--reason <text>` to annotate the audit log. The command is idempotent, and
263
- termination is forced by default so an unreachable node does not block drain.
264
262
 
265
263
  The operator resource commands share the same reviewer auth:
266
264
 
@@ -269,9 +267,10 @@ The operator resource commands share the same reviewer auth:
269
267
  filtered to one operator.
270
268
  - `ornn tokens list` lists enrollment tokens.
271
269
  - `ornn tokens create --operator <id> [--facility <id>]
272
- [--expires-in <seconds>] [--mode bare-metal|vm] [--ip <addr>]` mints an
273
- enrollment token and prints a ready-to-run `curl … | sudo bash` install
274
- command.
270
+ [--expires-in <seconds>] [--mode bare-metal|vm] [--ip <addr>] [--host <ip>]
271
+ [--identity-file <path>] [--yes]` mints an enrollment token and prints a
272
+ `curl … | sudo bash` install command. With `--host` and `--identity-file`,
273
+ orchestrator SSHs and runs that curl (same as Bulk Enroll). Asks y/N unless `--yes`.
275
274
  - `ornn tokens revoke <token-id>` revokes an enrollment token.
276
275
 
277
276
  Staff Commerce reservations are listing-backed, not fleet-backed:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ornncompute/cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Command-line interface for Ornn compute access workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,13 @@ import { readFileSync } from "node:fs";
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { stdin as defaultStdin, stdout as defaultStdout } from "node:process";
4
4
 
5
- import { cliRequest, loadRequiredSession, operatorRequest, resolveApiBaseUrl } from "./api-client.mjs";
5
+ import {
6
+ cliRequest,
7
+ loadRequiredSession,
8
+ operatorRequest,
9
+ resolveApiBaseUrl,
10
+ resolveInstallerBaseUrl,
11
+ } from "./api-client.mjs";
6
12
 
7
13
  async function collectSse(url, headers, durationMs, maxEvents, fetchImpl) {
8
14
  const controller = new AbortController();
@@ -260,29 +266,6 @@ export async function usersQueryCommand(args, context) {
260
266
  return 0;
261
267
  }
262
268
 
263
- export async function nodesConsoleCommand(args, context) {
264
- const { nodesConsoleCapability } = await loadCapabilities();
265
- const [nodeId, ...rest] = args;
266
- if (!nodeId) {
267
- throw new Error("Usage: ornn nodes console <node-id> [--identity-file <path>] [--json]");
268
- }
269
- const options = context.parseCommandOptions(
270
- rest,
271
- { boolean: ["json"], value: ["identity-file"] },
272
- "Usage: ornn nodes console <node-id> [--identity-file <path>] [--json]",
273
- );
274
- const result = await runCatalogCapability(
275
- nodesConsoleCapability,
276
- { node_id: nodeId, identity_file: options.identityFile },
277
- {
278
- ...context,
279
- role: options.identityFile ? "user" : context.staff ? "reviewer" : "user",
280
- },
281
- );
282
- context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
283
- return 0;
284
- }
285
-
286
269
  export async function telemetryLatestCommand(args, context) {
287
270
  const { telemetryLatestCapability } = await loadCapabilities();
288
271
  const [nodeId, ...rest] = args;
@@ -372,3 +355,151 @@ export async function logsTailCommand(args, context) {
372
355
  context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
373
356
  return 0;
374
357
  }
358
+
359
+ export async function tokensListCommand(args, context) {
360
+ const { tokensListCapability } = await loadCapabilities();
361
+ context.parseCommandOptions(args, { boolean: ["json"] }, "Usage: ornn tokens list [--json]");
362
+ const result = await runCatalogCapability(tokensListCapability, {}, {
363
+ ...context,
364
+ staff: true,
365
+ role: "reviewer",
366
+ });
367
+ const rows = Array.isArray(result) ? result : (result?.tokens ?? []);
368
+ if (args.includes("--json")) {
369
+ context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
370
+ } else if (!rows.length) {
371
+ context.stdout.write("No enrollment tokens found.\n");
372
+ } else {
373
+ for (const token of rows) {
374
+ context.stdout.write(
375
+ `${token?.id ?? "unknown"} ${token?.status ?? ""} operator=${token?.operator_id ?? ""} expires=${token?.expires_at ?? ""}\n`,
376
+ );
377
+ }
378
+ }
379
+ return 0;
380
+ }
381
+
382
+ export async function tokensCreateCommand(input, context) {
383
+ const { tokensCreateCapability } = await loadCapabilities();
384
+ const preview = await runCatalogCapability(tokensCreateCapability, input, {
385
+ ...context,
386
+ confirmed: false,
387
+ staff: true,
388
+ role: "admin",
389
+ });
390
+ if (preview?.ok === false) {
391
+ throw new Error(preview.error ?? "token create failed");
392
+ }
393
+ const confirmed = await confirmCatalogMutation({
394
+ stdin: context.stdin,
395
+ stdout: context.stdout,
396
+ yes: input.yes === true,
397
+ label: input.host || input.hosts?.length ? "Mint token and SSH enroll these hosts?" : "Mint this enrollment token?",
398
+ });
399
+ if (!confirmed) {
400
+ context.stdout.write("Aborted.\n");
401
+ return 1;
402
+ }
403
+ const result = await runCatalogCapability(tokensCreateCapability, input, {
404
+ ...context,
405
+ confirmed: true,
406
+ staff: true,
407
+ role: "admin",
408
+ });
409
+ context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
410
+ return 0;
411
+ }
412
+
413
+ async function runNodeLifecycle(capabilityName, input, context, label) {
414
+ const caps = await loadCapabilities();
415
+ const capability = caps[capabilityName];
416
+ const preview = await runCatalogCapability(capability, input, {
417
+ ...context,
418
+ confirmed: false,
419
+ staff: true,
420
+ role: "admin",
421
+ });
422
+ if (preview?.ok === false) {
423
+ throw new Error(preview.error ?? `${capabilityName} failed`);
424
+ }
425
+ const confirmed = await confirmCatalogMutation({
426
+ stdin: context.stdin,
427
+ stdout: context.stdout,
428
+ yes: input.yes === true,
429
+ label,
430
+ });
431
+ if (!confirmed) {
432
+ context.stdout.write("Aborted.\n");
433
+ return 1;
434
+ }
435
+ const result = await runCatalogCapability(capability, input, {
436
+ ...context,
437
+ confirmed: true,
438
+ staff: true,
439
+ role: "admin",
440
+ });
441
+ context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
442
+ return 0;
443
+ }
444
+
445
+ export async function nodeOffGridCommand(args, context) {
446
+ const [nodeId, ...rest] = args;
447
+ if (!nodeId) throw new Error("Usage: ornn node off-grid <node-id> [--reason <text>] [--yes] [--json]");
448
+ const options = context.parseCommandOptions(
449
+ rest,
450
+ { boolean: ["json", "yes"], value: ["reason"] },
451
+ "Usage: ornn node off-grid <node-id> [--reason <text>] [--yes] [--json]",
452
+ );
453
+ return runNodeLifecycle(
454
+ "nodeOffGridCapability",
455
+ { node_id: nodeId, reason: options.reason, yes: options.yes === true },
456
+ context,
457
+ `Take node ${nodeId} off-grid?`,
458
+ );
459
+ }
460
+
461
+ export async function nodeOnGridCommand(args, context) {
462
+ const [nodeId, ...rest] = args;
463
+ if (!nodeId) throw new Error("Usage: ornn node on-grid <node-id> [--yes] [--json]");
464
+ const options = context.parseCommandOptions(rest, { boolean: ["json", "yes"] }, "Usage: ornn node on-grid <node-id> [--yes] [--json]");
465
+ return runNodeLifecycle("nodeOnGridCapability", { node_id: nodeId, yes: options.yes === true }, context, `Re-enroll node ${nodeId} on-grid?`);
466
+ }
467
+
468
+ export async function nodeExecCommand(args, context) {
469
+ const [nodeId, ...rest] = args;
470
+ if (!nodeId) {
471
+ throw new Error("Usage: ornn node exec <node-id> --command <shell> --timeout-seconds <n> [--yes] [--json]");
472
+ }
473
+ const options = context.parseCommandOptions(
474
+ rest,
475
+ { boolean: ["json", "yes"], value: ["command", "timeout-seconds"] },
476
+ "Usage: ornn node exec <node-id> --command <shell> --timeout-seconds <n> [--yes] [--json]",
477
+ );
478
+ const command = String(options.command ?? "").trim();
479
+ const timeoutSeconds = Number(options.timeoutSeconds);
480
+ if (!command || !Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
481
+ throw new Error("Usage: ornn node exec <node-id> --command <shell> --timeout-seconds <n> [--yes] [--json]");
482
+ }
483
+ return runNodeLifecycle(
484
+ "nodeExecCapability",
485
+ { node_id: nodeId, command, timeout_seconds: timeoutSeconds, yes: options.yes === true },
486
+ context,
487
+ `Run ${JSON.stringify(command)} on node ${nodeId} (${timeoutSeconds}s)?`,
488
+ );
489
+ }
490
+
491
+ export async function nodeTerminateCommand(args, context) {
492
+ const [nodeId, ...rest] = args;
493
+ if (!nodeId) throw new Error("Usage: ornn node terminate <node-id> [--reason <text>] [--force] [--yes] [--json]");
494
+ const options = context.parseCommandOptions(
495
+ rest,
496
+ { boolean: ["json", "yes", "force"], value: ["reason"] },
497
+ "Usage: ornn node terminate <node-id> [--reason <text>] [--force] [--yes] [--json]",
498
+ );
499
+ return runNodeLifecycle(
500
+ "nodeTerminateCapability",
501
+ { node_id: nodeId, reason: options.reason, force: options.force === true, yes: options.yes === true },
502
+ context,
503
+ `Terminate node ${nodeId}?`,
504
+ );
505
+ }
package/src/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { createReadStream, createWriteStream, existsSync } from "node:fs";
2
+ import { createReadStream, createWriteStream, existsSync, readFileSync } from "node:fs";
3
3
  import {
4
4
  readFile,
5
5
  rename,
@@ -40,10 +40,15 @@ import {
40
40
  listingsCreateCommand,
41
41
  logsLatestCommand,
42
42
  logsTailCommand,
43
- nodesConsoleCommand,
43
+ nodeExecCommand,
44
+ nodeOffGridCommand,
45
+ nodeOnGridCommand,
46
+ nodeTerminateCommand,
44
47
  operatorListCommand,
45
48
  telemetryLatestCommand,
46
49
  telemetryTailCommand,
50
+ tokensCreateCommand,
51
+ tokensListCommand,
47
52
  usersQueryCommand,
48
53
  } from "./catalog-dispatch.mjs";
49
54
  import { maybeNotifyUpdate, updateCommand } from "./update.mjs";
@@ -63,6 +68,9 @@ function resolveBuyNowUsdPerGpuHour(record) {
63
68
  if (normalized.includes("h100")) {
64
69
  return 2.29;
65
70
  }
71
+ if (normalized.includes("mi355") || normalized.includes("mi325") || normalized.includes("mi300")) {
72
+ return 2.5;
73
+ }
66
74
  if (normalized.includes("a100")) {
67
75
  return 1.89;
68
76
  }
@@ -132,7 +140,6 @@ Usage:
132
140
  ornn gpus checkout <gpu-id> [--no-open] [--json]
133
141
  ornn nodes list [--json]
134
142
  ornn nodes show <node-id> [--json]
135
- ornn nodes console <node-id> [--identity-file <path>] [--json]
136
143
  ornn telemetry latest <node-id> [--span 15m] [--start <iso>] [--end <iso>] [--max-points <n>] [--json]
137
144
  ornn telemetry tail <node-id> [--duration <seconds>] [--json]
138
145
  ornn logs latest <node-id> [--stream kernel] [--count <n>] [--before <iso>] [--cursor <token>] [--json]
@@ -147,18 +154,18 @@ Usage:
147
154
  ornn nodes keys list <node-id> [--json]
148
155
  ornn node health <node-id> [--json]
149
156
  ornn node diagnose <node-id> [--json]
150
- ornn node deenroll <node-id> [--reason <text>] [--keep-record] [--json]
151
157
  ornn node list [--operator <id-or-slug>] [--facility <id>] [--email <email>] [--json]
152
158
  ornn node reboot <node-id> [--json]
153
159
  ornn node hard-reset <node-id> [--json]
154
- ornn node off-grid <node-id> [--reason <text>] [--json]
155
- ornn node on-grid <node-id> [--json]
156
- ornn node terminate <node-id> [--reason <text>] [--force] [--json]
160
+ ornn node off-grid <node-id> [--reason <text>] [--yes] [--json]
161
+ ornn node on-grid <node-id> [--yes] [--json]
162
+ ornn node exec <node-id> --command <shell> --timeout-seconds <n> [--yes] [--json]
163
+ ornn node terminate <node-id> [--reason <text>] [--force] [--yes] [--json]
157
164
  ornn node admin-key <node-id> [--generate] [--json]
158
165
  ornn operators list [--json]
159
166
  ornn facilities list [--operator <id-or-slug>] [--json]
160
167
  ornn tokens list [--json]
161
- ornn tokens create --operator <id> [--facility <id>] [--expires-in <seconds>] [--mode bare-metal|vm] [--ip <addr>] [--force] [--json]
168
+ ornn tokens create --operator <id> [--facility <id>] [--expires-in <seconds>] [--mode bare-metal|vm] [--ip <addr>] [--host <ip>] [--identity-file <path>] [--yes] [--json]
162
169
  ornn tokens revoke <token-id> [--json]
163
170
  ornn reservations transfer <reservation-id> --target-tenant <id> [--target-user <id>] [--node <id>] [--strategy reject|park] [--confirm] [--json]
164
171
  ornn reservations commerce list --tenant <email-or-id> [--listing <listing-id>] [--json]
@@ -333,12 +340,12 @@ const HELP_TOPICS = new Set([
333
340
  const NODE_OP_SUBCOMMANDS = new Set([
334
341
  "health",
335
342
  "diagnose",
336
- "deenroll",
337
343
  "list",
338
344
  "reboot",
339
345
  "hard-reset",
340
346
  "off-grid",
341
347
  "on-grid",
348
+ "exec",
342
349
  "terminate",
343
350
  "admin-key",
344
351
  ]);
@@ -534,11 +541,22 @@ async function dispatch(argv = [], io = {}) {
534
541
  }
535
542
 
536
543
  if (command === "node") {
537
- return await nodeOps(args, { env, fetchImpl, stdout });
544
+ return await nodeOps(args, {
545
+ env,
546
+ fetchImpl,
547
+ parseCommandOptions,
548
+ stdin: io.stdin ?? process.stdin,
549
+ stdout,
550
+ });
538
551
  }
539
552
 
540
553
  if (OPERATOR_COMMANDS.has(command)) {
541
- return await operatorResource(command, args, { env, fetchImpl, stdout });
554
+ return await operatorResource(command, args, {
555
+ env,
556
+ fetchImpl,
557
+ stdout,
558
+ stdin: io.stdin ?? process.stdin,
559
+ });
542
560
  }
543
561
 
544
562
  if (command === "nodes") {
@@ -849,15 +867,24 @@ function validateHelpInvocation(command, args) {
849
867
 
850
868
  if (command === "node") {
851
869
  const parsed = parseHelpArgs(args, {
852
- booleanOptions: ["--json", "--keep-record", "--force"],
853
- valueOptions: ["--reason", "--operator", "--facility", "--email", "--ssh-username", "--ssh-port"],
870
+ booleanOptions: ["--json", "--keep-record", "--force", "--yes"],
871
+ valueOptions: [
872
+ "--reason",
873
+ "--operator",
874
+ "--facility",
875
+ "--email",
876
+ "--ssh-username",
877
+ "--ssh-port",
878
+ "--command",
879
+ "--timeout-seconds",
880
+ ],
854
881
  });
855
882
  if (parsed.error) {
856
883
  return parsed.error;
857
884
  }
858
885
  const [subcommand, , ...extra] = parsed.positionals;
859
886
  if (!subcommand || !NODE_OP_SUBCOMMANDS.has(subcommand)) {
860
- return "Usage: ornn node list|health|diagnose|reboot|hard-reset|off-grid|on-grid|terminate|admin-key|deenroll <node-id>";
887
+ return "Usage: ornn node list|health|diagnose|reboot|hard-reset|off-grid|on-grid|exec|terminate|admin-key <node-id>";
861
888
  }
862
889
  return extra.length ? nodeOpUsage(subcommand) : null;
863
890
  }
@@ -902,7 +929,7 @@ function validateHelpInvocation(command, args) {
902
929
  return !id ? null : "Usage: ornn nodes keys list|attach|push|status <node-id>";
903
930
  }
904
931
  if (subcommand === "console") {
905
- return !id || nested || extra.length ? "Usage: ornn nodes console <node-id>" : null;
932
+ return "ornn nodes console is removed. Use `ornn ssh <node-id>` for an interactive session.";
906
933
  }
907
934
  if (["show", "wait", "reboot", "hard-reset", "ssh-command"].includes(subcommand)) {
908
935
  return nested || extra.length
@@ -914,7 +941,7 @@ function validateHelpInvocation(command, args) {
914
941
  ? `Usage: ornn nodes ${subcommand} <reservation-id> --key <path|id|label>`
915
942
  : null;
916
943
  }
917
- return "Usage: ornn nodes list|show|console|launch|switch|wait|reboot|hard-reset|ssh-command|keys";
944
+ return "Usage: ornn nodes list|show|launch|switch|wait|reboot|hard-reset|ssh-command|keys";
918
945
  }
919
946
 
920
947
  if (command === "ssh") {
@@ -1332,8 +1359,8 @@ function validateHelpInvocation(command, args) {
1332
1359
  // Operator resource commands accept a small, permissive option set; the
1333
1360
  // handlers do the strict validation. Just surface parse errors here.
1334
1361
  const parsed = parseHelpArgs(args, {
1335
- booleanOptions: ["--json", "--confirm", "--force"],
1336
- valueOptions: ["--operator", "--facility", "--expires-in", "--mode", "--ip"],
1362
+ booleanOptions: ["--json", "--confirm", "--force", "--yes"],
1363
+ valueOptions: ["--operator", "--facility", "--expires-in", "--mode", "--ip", "--host", "--identity-file"],
1337
1364
  });
1338
1365
  return parsed.error || null;
1339
1366
  }
@@ -2079,7 +2106,7 @@ async function reservations(args, context) {
2079
2106
  // Operator-only surface (`ornn node`); distinct from tenant `ornn nodes`.
2080
2107
  async function nodeOps(args, context) {
2081
2108
  const usage =
2082
- "Usage: ornn node list|health|diagnose|reboot|hard-reset|off-grid|on-grid|terminate|admin-key|deenroll <node-id>";
2109
+ "Usage: ornn node list|health|diagnose|reboot|hard-reset|off-grid|on-grid|exec|terminate|admin-key <node-id>";
2083
2110
  const [subcommand, id, ...rest] = args;
2084
2111
  if (!subcommand || !NODE_OP_SUBCOMMANDS.has(subcommand)) {
2085
2112
  throw new Error(usage);
@@ -2096,24 +2123,24 @@ async function nodeOps(args, context) {
2096
2123
  throw new Error(nodeOpUsage(subcommand));
2097
2124
  }
2098
2125
 
2099
- if (subcommand === "deenroll") {
2100
- return await nodeDeenroll(id, rest, context);
2101
- }
2102
-
2103
2126
  if (subcommand === "reboot" || subcommand === "hard-reset") {
2104
2127
  return await nodeRebootOp(subcommand, id, rest, context);
2105
2128
  }
2106
2129
 
2107
2130
  if (subcommand === "off-grid") {
2108
- return await nodeOffGridOp(id, rest, context);
2131
+ return await nodeOffGridCommand([id, ...rest], context);
2109
2132
  }
2110
2133
 
2111
2134
  if (subcommand === "on-grid") {
2112
- return await nodeOnGridOp(id, rest, context);
2135
+ return await nodeOnGridCommand([id, ...rest], context);
2136
+ }
2137
+
2138
+ if (subcommand === "exec") {
2139
+ return await nodeExecCommand([id, ...rest], context);
2113
2140
  }
2114
2141
 
2115
2142
  if (subcommand === "terminate") {
2116
- return await nodeTerminateOp(id, rest, context);
2143
+ return await nodeTerminateCommand([id, ...rest], context);
2117
2144
  }
2118
2145
 
2119
2146
  if (subcommand === "admin-key") {
@@ -2158,20 +2185,20 @@ async function nodeOps(args, context) {
2158
2185
  }
2159
2186
 
2160
2187
  function nodeOpUsage(subcommand) {
2161
- if (subcommand === "deenroll") {
2162
- return "Usage: ornn node deenroll <node-id> [--reason <text>] [--keep-record] [--json]";
2163
- }
2164
2188
  if (subcommand === "list") {
2165
2189
  return "Usage: ornn node list [--operator <id-or-slug>] [--facility <id>] [--email <email>] [--json]";
2166
2190
  }
2167
2191
  if (subcommand === "off-grid") {
2168
- return "Usage: ornn node off-grid <node-id> [--reason <text>] [--json]";
2192
+ return "Usage: ornn node off-grid <node-id> [--reason <text>] [--yes] [--json]";
2169
2193
  }
2170
2194
  if (subcommand === "on-grid") {
2171
- return "Usage: ornn node on-grid <node-id> [--json]";
2195
+ return "Usage: ornn node on-grid <node-id> [--yes] [--json]";
2196
+ }
2197
+ if (subcommand === "exec") {
2198
+ return "Usage: ornn node exec <node-id> --command <shell> --timeout-seconds <n> [--yes] [--json]";
2172
2199
  }
2173
2200
  if (subcommand === "terminate") {
2174
- return "Usage: ornn node terminate <node-id> [--reason <text>] [--force] [--json]";
2201
+ return "Usage: ornn node terminate <node-id> [--reason <text>] [--force] [--yes] [--json]";
2175
2202
  }
2176
2203
  if (subcommand === "admin-key") {
2177
2204
  return "Usage: ornn node admin-key <node-id> [--generate] [--json]";
@@ -2254,98 +2281,6 @@ async function nodeRebootOp(subcommand, id, rest, context) {
2254
2281
  return 0;
2255
2282
  }
2256
2283
 
2257
- // Take a node off-grid: queue the agentless handoff that removes Ornn agent
2258
- // management from the still-powered-on host.
2259
- async function nodeOffGridOp(id, rest, context) {
2260
- const options = parseCommandOptions(
2261
- rest,
2262
- { boolean: ["json"], value: ["reason"] },
2263
- nodeOpUsage("off-grid")
2264
- );
2265
- const reason = optionalStringOption(options.reason);
2266
- const result = await operatorRequest({
2267
- body: {
2268
- request_id: `cli-off-grid:${id}:${Date.now()}`,
2269
- ...(reason ? { reason } : {}),
2270
- },
2271
- endpoint: `/enrollment/nodes/${encodeURIComponent(id)}/agentless-handoff`,
2272
- env: context.env,
2273
- fetchImpl: context.fetchImpl,
2274
- method: "POST",
2275
- });
2276
- if (options.json) {
2277
- writeJson(context.stdout, result);
2278
- } else {
2279
- context.stdout.write(`Off-grid handoff queued for node ${id}.\n`);
2280
- writeOptionalStatusLine(context.stdout, "Status", result?.status);
2281
- writeOptionalStatusLine(context.stdout, "Request", result?.packet_request_id);
2282
- }
2283
- return 0;
2284
- }
2285
-
2286
- // Bring an off-grid node back on-grid: SSH in with the stored admin key and
2287
- // re-run the installer to restore Ornn agent management.
2288
- async function nodeOnGridOp(id, rest, context) {
2289
- const options = parseCommandOptions(
2290
- rest,
2291
- { boolean: ["json"], value: ["ssh-username", "ssh-port"] },
2292
- nodeOpUsage("on-grid")
2293
- );
2294
- const body = {};
2295
- const sshUsername = optionalStringOption(options.sshUsername);
2296
- const sshPort = optionalStringOption(options.sshPort);
2297
- if (sshUsername) {
2298
- body.ssh_username = sshUsername;
2299
- }
2300
- if (sshPort) {
2301
- body.ssh_port = Number.parseInt(sshPort, 10);
2302
- }
2303
- const result = await operatorRequest({
2304
- body,
2305
- endpoint: `/enrollment/nodes/${encodeURIComponent(id)}/agentless-handoff/on-grid`,
2306
- env: context.env,
2307
- fetchImpl: context.fetchImpl,
2308
- method: "POST",
2309
- });
2310
- if (options.json) {
2311
- writeJson(context.stdout, result);
2312
- } else {
2313
- context.stdout.write(`On-grid restore queued for node ${id}.\n`);
2314
- writeOptionalStatusLine(context.stdout, "Status", result?.status);
2315
- }
2316
- return 0;
2317
- }
2318
-
2319
- // Terminate a node: best-effort clean the host, remove the Ornn agent, and
2320
- // hard-delete the node record from the platform. Does NOT return it to a tenant.
2321
- async function nodeTerminateOp(id, rest, context) {
2322
- const options = parseCommandOptions(
2323
- rest,
2324
- { boolean: ["json", "force"], value: ["reason"] },
2325
- nodeOpUsage("terminate")
2326
- );
2327
- const reason = optionalStringOption(options.reason) || "cli-terminate";
2328
- const result = await operatorRequest({
2329
- body: {
2330
- request_id: `cli-terminate:${id}:${Date.now()}`,
2331
- reason,
2332
- force: options.force === true,
2333
- },
2334
- endpoint: `/enrollment/nodes/${encodeURIComponent(id)}/terminate`,
2335
- env: context.env,
2336
- fetchImpl: context.fetchImpl,
2337
- method: "POST",
2338
- });
2339
- if (options.json) {
2340
- writeJson(context.stdout, result);
2341
- } else {
2342
- context.stdout.write(`Termination queued for node ${id}.\n`);
2343
- writeOptionalStatusLine(context.stdout, "Status", result?.status);
2344
- writeOptionalStatusLine(context.stdout, "Request", result?.packet_request_id);
2345
- }
2346
- return 0;
2347
- }
2348
-
2349
2284
  // Fetch the Ornn admin SSH keypair for a node, including the private key, so an
2350
2285
  // operator can SSH into the host directly. Prints the private key; handle it as
2351
2286
  // a secret.
@@ -2533,39 +2468,21 @@ async function tokensCommand(args, context) {
2533
2468
  const usage = "Usage: ornn tokens list|create|revoke ...";
2534
2469
  const [subcommand, ...rest] = args;
2535
2470
  if (subcommand === "list" || subcommand === undefined) {
2536
- const options = parseCommandOptions(
2537
- rest,
2538
- { boolean: ["json"] },
2539
- "Usage: ornn tokens list [--json]"
2540
- );
2541
- const tokens = await operatorRequest({
2542
- endpoint: "/enrollment/tokens",
2543
- env: context.env,
2544
- fetchImpl: context.fetchImpl,
2471
+ return await tokensListCommand(rest, {
2472
+ ...context,
2473
+ parseCommandOptions,
2474
+ stdin: context.stdin,
2545
2475
  });
2546
- const rows = Array.isArray(tokens) ? tokens : [];
2547
- if (options.json) {
2548
- writeJson(context.stdout, rows);
2549
- } else if (!rows.length) {
2550
- context.stdout.write("No enrollment tokens found.\n");
2551
- } else {
2552
- for (const token of rows) {
2553
- context.stdout.write(
2554
- `${token?.id ?? "unknown"} ${token?.status ?? ""} operator=${token?.operator_id ?? ""} expires=${token?.expires_at ?? ""}\n`
2555
- );
2556
- }
2557
- }
2558
- return 0;
2559
2476
  }
2560
2477
 
2561
2478
  if (subcommand === "create") {
2562
2479
  const createUsage =
2563
- "Usage: ornn tokens create --operator <id-or-slug> [--facility <id>] [--expires-in <seconds>] [--mode bare-metal|vm] [--ip <addr>] [--force] [--json]";
2480
+ "Usage: ornn tokens create --operator <id-or-slug> [--facility <id>] [--expires-in <seconds>] [--mode bare-metal|vm] [--ip <addr>] [--host <ip>] [--identity-file <path>] [--yes] [--json]";
2564
2481
  const options = parseCommandOptions(
2565
2482
  rest,
2566
2483
  {
2567
- boolean: ["json", "force"],
2568
- value: ["operator", "facility", "expires-in", "mode", "ip"],
2484
+ boolean: ["json", "force", "yes"],
2485
+ value: ["operator", "facility", "expires-in", "mode", "ip", "host", "identity-file"],
2569
2486
  },
2570
2487
  createUsage
2571
2488
  );
@@ -2574,44 +2491,33 @@ async function tokensCommand(args, context) {
2574
2491
  throw new Error(createUsage);
2575
2492
  }
2576
2493
  const operatorId = await resolveOperatorId(operatorFilter, context);
2577
- const body = { operator_id: operatorId };
2578
- const facility = optionalStringOption(options.facility);
2579
- const expiresIn = optionalStringOption(options.expiresIn);
2580
- if (facility) {
2581
- body.facility_id = facility;
2582
- }
2583
- if (expiresIn) {
2584
- body.expires_in_seconds = Number.parseInt(expiresIn, 10);
2585
- }
2586
- const token = await operatorRequest({
2587
- body,
2588
- endpoint: "/enrollment/tokens",
2589
- env: context.env,
2590
- fetchImpl: context.fetchImpl,
2591
- method: "POST",
2592
- });
2593
- const installCommand = buildEnrollmentInstallCommand({
2594
- env: context.env,
2595
- token: token?.token,
2596
- mode: optionalStringOption(options.mode),
2597
- ip: optionalStringOption(options.ip),
2598
- force: options.force === true,
2599
- });
2600
- if (options.json) {
2601
- writeJson(context.stdout, { ...token, install_command: installCommand });
2602
- } else {
2603
- context.stdout.write(`Enrollment token created: ${token?.id ?? "unknown"}\n`);
2604
- writeOptionalStatusLine(context.stdout, "Token", token?.token);
2605
- writeOptionalStatusLine(context.stdout, "Expires", token?.expires_at);
2606
- if (installCommand) {
2607
- context.stdout.write(`Install command:\n${installCommand}\n`);
2608
- } else {
2609
- context.stdout.write(
2610
- "Install command unavailable: set ORNN_INSTALLER_URL to the origin serving /enrollment/install.\n"
2611
- );
2612
- }
2494
+ const identityFile = optionalStringOption(options.identityFile);
2495
+ let privateKey;
2496
+ if (identityFile) {
2497
+ privateKey = readFileSync(identityFile, "utf8");
2613
2498
  }
2614
- return 0;
2499
+ return await tokensCreateCommand(
2500
+ {
2501
+ operator_id: operatorId,
2502
+ ...(optionalStringOption(options.facility)
2503
+ ? { facility_id: optionalStringOption(options.facility) }
2504
+ : {}),
2505
+ ...(optionalStringOption(options.expiresIn)
2506
+ ? { expires_in_seconds: Number.parseInt(optionalStringOption(options.expiresIn), 10) }
2507
+ : {}),
2508
+ ...(optionalStringOption(options.mode) ? { node_mode: optionalStringOption(options.mode) } : {}),
2509
+ ...(optionalStringOption(options.ip) ? { ip: optionalStringOption(options.ip) } : {}),
2510
+ ...(optionalStringOption(options.host) ? { host: optionalStringOption(options.host) } : {}),
2511
+ ...(privateKey ? { private_key: privateKey } : {}),
2512
+ installer_base: resolveInstallerBaseUrl({ env: context.env }) ?? undefined,
2513
+ yes: options.yes === true || options.force === true,
2514
+ },
2515
+ {
2516
+ ...context,
2517
+ parseCommandOptions,
2518
+ stdin: context.stdin ?? process.stdin,
2519
+ },
2520
+ );
2615
2521
  }
2616
2522
 
2617
2523
  if (subcommand === "revoke") {
@@ -2638,78 +2544,6 @@ async function tokensCommand(args, context) {
2638
2544
  throw new Error(usage);
2639
2545
  }
2640
2546
 
2641
- // Mirrors apps/web/lib/operator-enrollment.ts and the MCP token tool.
2642
- function buildEnrollmentInstallCommand({ env = process.env, token, mode, ip, force = false } = {}) {
2643
- if (!token) {
2644
- return null;
2645
- }
2646
- const base = resolveInstallerBaseUrl({ env });
2647
- if (!base) {
2648
- return null;
2649
- }
2650
- const url = new URL(`${base.replace(/\/+$/, "")}/enrollment/install`);
2651
- url.searchParams.set("token", token);
2652
- if (mode) {
2653
- url.searchParams.set("node_mode", mode);
2654
- }
2655
- if (ip) {
2656
- url.searchParams.set("ip", ip);
2657
- }
2658
- if (force) {
2659
- url.searchParams.set("force", "true");
2660
- }
2661
- return `curl -fsSL '${url.toString().replace(/'/g, "'\\''")}' | sudo bash`;
2662
- }
2663
-
2664
- // De-enroll a node by gracefully terminating its enrollment. The audit row is
2665
- // intentionally retained; --keep-record remains accepted for script compatibility.
2666
- async function nodeDeenroll(id, rest, context) {
2667
- const options = parseCommandOptions(
2668
- rest,
2669
- { boolean: ["json", "keep-record", "force"], value: ["reason"] },
2670
- nodeOpUsage("deenroll")
2671
- );
2672
- const reason = optionalStringOption(options.reason) || "cli-deenroll";
2673
- // Dead/unreachable test nodes can't drain workloads, so default to force.
2674
- const force = options.force !== false;
2675
- const result = { node_id: id, terminated: false, record_retained: true };
2676
-
2677
- try {
2678
- const terminate = await operatorRequest({
2679
- body: {
2680
- request_id: `cli-deenroll:${id}:${Date.now()}`,
2681
- reason,
2682
- force,
2683
- },
2684
- endpoint: `/enrollment/nodes/${encodeURIComponent(id)}/terminate`,
2685
- env: context.env,
2686
- fetchImpl: context.fetchImpl,
2687
- method: "POST",
2688
- });
2689
- result.terminated = true;
2690
- result.terminate = terminate;
2691
- } catch (error) {
2692
- // Already-terminated / unknown node is a success for an idempotent teardown.
2693
- if (!(error instanceof CliApiError) || error.status !== 404) {
2694
- throw error;
2695
- }
2696
- result.already_gone = true;
2697
- }
2698
-
2699
- if (options.json) {
2700
- writeJson(context.stdout, result);
2701
- } else {
2702
- if (result.already_gone && !result.terminated) {
2703
- context.stdout.write(`Node ${id} already de-enrolled (nothing to remove).\n`);
2704
- } else {
2705
- context.stdout.write(`Node ${id} de-enrolled.\n`);
2706
- writeOptionalStatusLine(context.stdout, "Terminated", result.terminated ? "yes" : "skipped");
2707
- writeOptionalStatusLine(context.stdout, "Record retained", "yes");
2708
- }
2709
- }
2710
- return 0;
2711
- }
2712
-
2713
2547
  function writeNodeHealth(stdout, node) {
2714
2548
  stdout.write(`${node?.id ?? "unknown"}\n`);
2715
2549
  stdout.write(`Status: ${node?.status || "unknown"}\n`);
@@ -2762,8 +2596,8 @@ async function nodes(args, context) {
2762
2596
  return 0;
2763
2597
  }
2764
2598
 
2765
- if (subcommand === "console" && id) {
2766
- return await nodesConsoleCommand([id, nested, ...rest].filter(Boolean), context);
2599
+ if (subcommand === "console") {
2600
+ throw new Error("ornn nodes console is removed. Use `ornn ssh <node-id>` for an interactive session.");
2767
2601
  }
2768
2602
 
2769
2603
  if (subcommand === "show" && id) {
@@ -1,5 +1,8 @@
1
1
  export declare const listOperatorsCapability: import("../capability.ts").Capability<unknown, unknown>;
2
2
  export declare const listFacilitiesCapability: import("../capability.ts").Capability<unknown, unknown>;
3
3
  export declare const listNodesCapability: import("../capability.ts").Capability<unknown, unknown>;
4
- export declare const nodesConsoleCapability: import("../capability.ts").Capability<unknown, unknown>;
4
+ export declare const nodeOffGridCapability: import("../capability.ts").Capability<unknown, unknown>;
5
+ export declare const nodeOnGridCapability: import("../capability.ts").Capability<unknown, unknown>;
6
+ export declare const nodeExecCapability: import("../capability.ts").Capability<unknown, unknown>;
7
+ export declare const nodeTerminateCapability: import("../capability.ts").Capability<unknown, unknown>;
5
8
  export declare const fleetCapabilities: import("../capability.ts").Capability<unknown, unknown>[];
@@ -22,12 +22,23 @@ const nodesInput = z.object({
22
22
  status: z.string().optional(),
23
23
  email: z.string().optional(),
24
24
  });
25
- const consoleInput = z.object({
25
+ const nodeLifecycleInput = z.object({
26
26
  node_id: z.string().min(1),
27
27
  nodeId: z.string().optional(),
28
- identity_file: z.string().optional(),
29
- commands: z.array(z.string()).optional(),
28
+ reason: z.string().optional(),
29
+ force: z.boolean().optional(),
30
30
  });
31
+ const nodeExecInput = z.object({
32
+ node_id: z.string().min(1),
33
+ nodeId: z.string().optional(),
34
+ command: z.string().min(1),
35
+ timeout_seconds: z.number().int().positive(),
36
+ timeoutSeconds: z.number().int().positive().optional(),
37
+ yes: z.boolean().optional(),
38
+ });
39
+ function nodeIdOf(input) {
40
+ return (input.node_id || input.nodeId || "").trim();
41
+ }
31
42
  export const listOperatorsCapability = defineCapability({
32
43
  id: "operator.list",
33
44
  domain: "fleet",
@@ -104,44 +115,131 @@ export const listNodesCapability = defineCapability({
104
115
  }), { method: "GET" });
105
116
  },
106
117
  });
107
- export const nodesConsoleCapability = defineCapability({
108
- id: "nodes.console",
118
+ export const nodeOffGridCapability = defineCapability({
119
+ id: "node.off-grid",
109
120
  domain: "fleet",
110
- roles: ["user", "reviewer", "admin"],
111
- description: "Run an allowlisted host snapshot (ps -ef, nvidia-smi) on one node. Pass the orchestrator node UUID, never a hostname. Users must target a reserved node and pass identity_file (local PEM, same as ornn ssh --identity-file). Reviewers and admins may target any enrolled node; the orchestrator loads the stored admin SSH key. Slack staff never pass a key.",
112
- mutation: "read",
113
- http: { method: "POST", path: "/internal/nodes/{id}/console" },
121
+ roles: ["admin"],
122
+ description: "Admin-only. Take a node off-grid (remove Ornn agent management, leave the host powered on). CLI asks y/N. Slack waits for a human confirm. MCP requires confirm: true.",
123
+ mutation: "preview-confirm",
124
+ http: { method: "POST", path: "/internal/nodes/{id}/off-grid" },
114
125
  slack: {
115
126
  parameters: {
116
127
  type: "object",
117
128
  properties: {
118
- node_id: { type: "string", description: "Node UUID from nodes.list, not a hostname." },
119
- commands: {
120
- type: "array",
121
- items: { type: "string", enum: ["ps", "ps -ef", "nvidia-smi", "nvidia-smi -L"] },
122
- description: "Optional allowlisted commands. Default is ps and nvidia-smi.",
129
+ node_id: { type: "string", description: "Node UUID from nodes.list." },
130
+ reason: { type: "string" },
131
+ },
132
+ required: ["node_id"],
133
+ },
134
+ },
135
+ input: nodeLifecycleInput,
136
+ execute: async (ctx, input) => {
137
+ const parsed = nodeLifecycleInput.parse(input);
138
+ const id = nodeIdOf(parsed);
139
+ const body = { ...(parsed.reason ? { reason: parsed.reason } : {}) };
140
+ if (!ctx.confirmed) {
141
+ return { ok: true, action: "node.off-grid", payload: { node_id: id, ...body } };
142
+ }
143
+ return ctx.fetch(`/internal/nodes/${encodeURIComponent(id)}/off-grid`, {
144
+ method: "POST",
145
+ body,
146
+ });
147
+ },
148
+ });
149
+ export const nodeOnGridCapability = defineCapability({
150
+ id: "node.on-grid",
151
+ domain: "fleet",
152
+ roles: ["admin"],
153
+ description: "Admin-only. Re-enroll an off-grid node: orchestrator SSHs with the stored admin key and runs the installer with restore_node_id. No caller key. CLI asks y/N. Slack waits for a human confirm. MCP requires confirm: true.",
154
+ mutation: "preview-confirm",
155
+ http: { method: "POST", path: "/internal/nodes/{id}/on-grid" },
156
+ slack: {
157
+ parameters: {
158
+ type: "object",
159
+ properties: {
160
+ node_id: { type: "string", description: "Off-grid node UUID from nodes.list." },
161
+ },
162
+ required: ["node_id"],
163
+ },
164
+ },
165
+ input: nodeLifecycleInput,
166
+ execute: async (ctx, input) => {
167
+ const parsed = nodeLifecycleInput.parse(input);
168
+ const id = nodeIdOf(parsed);
169
+ if (!ctx.confirmed) {
170
+ return { ok: true, action: "node.on-grid", payload: { node_id: id } };
171
+ }
172
+ return ctx.fetch(`/internal/nodes/${encodeURIComponent(id)}/on-grid`, {
173
+ method: "POST",
174
+ body: {},
175
+ });
176
+ },
177
+ });
178
+ export const nodeExecCapability = defineCapability({
179
+ id: "node.exec",
180
+ domain: "fleet",
181
+ roles: ["admin"],
182
+ description: "Admin-only. Run one unrestricted command on an enrolled node over orchestrator SSH with the stored admin key. Always pass timeout_seconds. Not an interactive shell. On NVIDIA hosts use nvidia-smi; on AMD Instinct use amd-smi. CLI asks y/N. Slack waits for a human confirm. MCP requires confirm: true.",
183
+ mutation: "preview-confirm",
184
+ http: { method: "POST", path: "/internal/remote" },
185
+ slack: {
186
+ parameters: {
187
+ type: "object",
188
+ properties: {
189
+ node_id: { type: "string", description: "Enrolled node UUID from nodes.list." },
190
+ command: { type: "string", description: "Exact shell command to run. No allowlist." },
191
+ timeout_seconds: {
192
+ type: "integer",
193
+ description: "Seconds to wait before killing the SSH command. Required. 1-900.",
123
194
  },
124
195
  },
196
+ required: ["node_id", "command", "timeout_seconds"],
197
+ },
198
+ },
199
+ input: nodeExecInput,
200
+ execute: async (ctx, input) => {
201
+ const parsed = nodeExecInput.parse(input);
202
+ const id = nodeIdOf(parsed);
203
+ const timeoutSeconds = parsed.timeout_seconds ?? parsed.timeoutSeconds;
204
+ const payload = { node_id: id, command: parsed.command, timeout_seconds: timeoutSeconds };
205
+ if (!ctx.confirmed) {
206
+ return { ok: true, action: "node.exec", payload };
207
+ }
208
+ return ctx.fetch("/internal/remote", { method: "POST", body: payload });
209
+ },
210
+ });
211
+ export const nodeTerminateCapability = defineCapability({
212
+ id: "node.terminate",
213
+ domain: "fleet",
214
+ roles: ["admin"],
215
+ description: "Admin-only. Terminate a node record (best-effort clean + delete). Off-grid nodes need force: true. There is no deenroll verb. CLI asks y/N. Slack waits for a human confirm. MCP requires confirm: true.",
216
+ mutation: "preview-confirm",
217
+ http: { method: "POST", path: "/internal/nodes/{id}/terminate" },
218
+ slack: {
219
+ parameters: {
220
+ type: "object",
221
+ properties: {
222
+ node_id: { type: "string", description: "Node UUID from nodes.list." },
223
+ reason: { type: "string" },
224
+ force: { type: "boolean", description: "Required to delete an off-grid node." },
225
+ },
125
226
  required: ["node_id"],
126
227
  },
127
228
  },
128
- input: consoleInput,
229
+ input: nodeLifecycleInput,
129
230
  execute: async (ctx, input) => {
130
- const parsed = consoleInput.parse(input);
131
- const id = (parsed.node_id || parsed.nodeId || "").trim();
132
- if (ctx.role === "user") {
133
- const identity = parsed.identity_file?.trim();
134
- if (!identity) {
135
- return { error: "identity_file_required", detail: "Users must pass identity_file." };
136
- }
137
- return ctx.fetch(`/nodes/${encodeURIComponent(id)}/console`, {
138
- method: "POST",
139
- body: { identity_file: identity, commands: parsed.commands },
140
- });
231
+ const parsed = nodeLifecycleInput.parse(input);
232
+ const id = nodeIdOf(parsed);
233
+ const body = {
234
+ ...(parsed.reason ? { reason: parsed.reason } : {}),
235
+ ...(parsed.force !== undefined ? { force: parsed.force } : {}),
236
+ };
237
+ if (!ctx.confirmed) {
238
+ return { ok: true, action: "node.terminate", payload: { node_id: id, ...body } };
141
239
  }
142
- return ctx.fetch(`/internal/nodes/${encodeURIComponent(id)}/console`, {
240
+ return ctx.fetch(`/internal/nodes/${encodeURIComponent(id)}/terminate`, {
143
241
  method: "POST",
144
- body: { commands: parsed.commands },
242
+ body,
145
243
  });
146
244
  },
147
245
  });
@@ -149,5 +247,8 @@ export const fleetCapabilities = [
149
247
  listOperatorsCapability,
150
248
  listFacilitiesCapability,
151
249
  listNodesCapability,
152
- nodesConsoleCapability,
250
+ nodeOffGridCapability,
251
+ nodeOnGridCapability,
252
+ nodeExecCapability,
253
+ nodeTerminateCapability,
153
254
  ];
@@ -0,0 +1,11 @@
1
+ export declare function buildEnrollmentInstallCommand(options: {
2
+ token: string;
3
+ base?: string;
4
+ nodeMode?: string;
5
+ ip?: string;
6
+ restoreNodeId?: string;
7
+ force?: boolean;
8
+ }): string;
9
+ export declare const tokensListCapability: import("../capability.ts").Capability<unknown, unknown>;
10
+ export declare const tokensCreateCapability: import("../capability.ts").Capability<unknown, unknown>;
11
+ export declare const tokenCapabilities: import("../capability.ts").Capability<unknown, unknown>[];
@@ -0,0 +1,155 @@
1
+ import { z } from "zod";
2
+ import { defineCapability } from "../capability.js";
3
+ const optionalText = z.string().nullish();
4
+ const tokenCreateInput = z.object({
5
+ operator_id: z.string().min(1),
6
+ operatorId: optionalText,
7
+ facility_id: optionalText,
8
+ facilityId: optionalText,
9
+ expires_in_seconds: z.number().int().positive().nullish(),
10
+ expiresInSeconds: z.number().int().positive().nullish(),
11
+ node_mode: z.enum(["bare-metal", "virtual-machine", "vm"]).nullish(),
12
+ nodeMode: z.enum(["bare-metal", "virtual-machine", "vm"]).nullish(),
13
+ ip: optionalText,
14
+ installer_base: optionalText,
15
+ host: optionalText,
16
+ hosts: z.array(z.string()).nullish(),
17
+ username: optionalText,
18
+ port: z.number().int().min(1).max(65535).nullish(),
19
+ private_key: optionalText,
20
+ privateKey: optionalText,
21
+ yes: z.boolean().optional(),
22
+ });
23
+ const emptyInput = z.object({});
24
+ export function buildEnrollmentInstallCommand(options) {
25
+ const base = (options.base ?? "https://api.ornn.com/v1/orchestrator").replace(/\/+$/, "");
26
+ const params = [`token=${encodeURIComponent(options.token)}`];
27
+ if (options.nodeMode) {
28
+ const mode = options.nodeMode === "vm" ? "virtual-machine" : options.nodeMode;
29
+ params.push(`node_mode=${encodeURIComponent(mode)}`);
30
+ }
31
+ if (options.ip)
32
+ params.push(`ip=${encodeURIComponent(options.ip)}`);
33
+ if (options.restoreNodeId)
34
+ params.push(`restore_node_id=${encodeURIComponent(options.restoreNodeId)}`);
35
+ if (options.force)
36
+ params.push("force=true");
37
+ const href = `${base}/enrollment/install?${params.join("&")}`;
38
+ return `curl -fsSL '${href.replace(/'/g, "'\\''")}' | sudo bash`;
39
+ }
40
+ function tokenString(row, keys) {
41
+ for (const key of keys) {
42
+ const value = row[key];
43
+ if (typeof value === "string" && value.trim())
44
+ return value.trim();
45
+ }
46
+ return undefined;
47
+ }
48
+ export const tokensListCapability = defineCapability({
49
+ id: "tokens.list",
50
+ domain: "fleet",
51
+ roles: ["reviewer", "admin"],
52
+ description: "List issued enrollment tokens (id, operator, status, expiry). Does not return plaintext secrets.",
53
+ mutation: "read",
54
+ http: { method: "GET", path: "/internal/enrollment-tokens" },
55
+ slack: { parameters: { type: "object", properties: {} } },
56
+ input: emptyInput,
57
+ execute: async (ctx) => ctx.fetch("/internal/enrollment-tokens", { method: "GET" }),
58
+ });
59
+ export const tokensCreateCapability = defineCapability({
60
+ id: "tokens.create",
61
+ domain: "fleet",
62
+ roles: ["admin"],
63
+ description: "Admin-only. Mint an enrollment token. Without a host this returns the curl installer. With host + private_key, orchestrator SSHs and runs that curl (same as Bulk Enroll). Never invent a command. CLI asks y/N. Slack waits for a human confirm. MCP requires confirm: true.",
64
+ mutation: "preview-confirm",
65
+ http: { method: "POST", path: "/internal/enrollment-tokens" },
66
+ slack: {
67
+ parameters: {
68
+ type: "object",
69
+ properties: {
70
+ operator_id: { type: "string", description: "Commerce operator UUID from operator.list." },
71
+ facility_id: { type: "string", description: "Optional facility UUID." },
72
+ expires_in_seconds: { type: "integer", description: "Token TTL in seconds." },
73
+ node_mode: { type: "string", enum: ["bare-metal", "virtual-machine"] },
74
+ ip: { type: "string", description: "Optional IP stamped into the installer URL." },
75
+ host: { type: "string", description: "SSH host/IP to enroll now." },
76
+ hosts: {
77
+ type: "array",
78
+ items: { type: "string" },
79
+ description: "Optional extra SSH hosts to enroll with the same key.",
80
+ },
81
+ username: { type: "string", description: "SSH user. Default admin." },
82
+ port: { type: "integer", description: "SSH port. Default 22." },
83
+ private_key: { type: "string", description: "PEM private key. Required to enroll over SSH." },
84
+ },
85
+ required: ["operator_id"],
86
+ },
87
+ },
88
+ input: tokenCreateInput,
89
+ execute: async (ctx, input) => {
90
+ const parsed = tokenCreateInput.parse(input);
91
+ const operatorId = (parsed.operator_id || parsed.operatorId || "").trim();
92
+ const hosts = [
93
+ ...(parsed.host?.trim() ? [parsed.host.trim()] : []),
94
+ ...(parsed.hosts ?? []).map((host) => host.trim()).filter(Boolean),
95
+ ];
96
+ const privateKey = parsed.private_key?.trim() || parsed.privateKey?.trim() || "";
97
+ const nodeMode = parsed.node_mode ?? parsed.nodeMode;
98
+ const payload = {
99
+ operator_id: operatorId,
100
+ facility_id: parsed.facility_id ?? parsed.facilityId,
101
+ expires_in_seconds: parsed.expires_in_seconds ?? parsed.expiresInSeconds,
102
+ node_mode: nodeMode,
103
+ ip: parsed.ip,
104
+ hosts,
105
+ username: parsed.username?.trim() || "admin",
106
+ port: parsed.port ?? 22,
107
+ remote: hosts.length > 0 && privateKey.length > 0,
108
+ };
109
+ if (!ctx.confirmed) {
110
+ return { ok: true, action: "tokens.create", payload: { ...payload, private_key: privateKey ? "[redacted]" : "" } };
111
+ }
112
+ const minted = (await ctx.fetch("/internal/enrollment-tokens", {
113
+ method: "POST",
114
+ body: {
115
+ operator_id: operatorId,
116
+ ...(payload.facility_id ? { facility_id: payload.facility_id } : {}),
117
+ ...(payload.expires_in_seconds ? { expires_in_seconds: payload.expires_in_seconds } : {}),
118
+ },
119
+ }));
120
+ const token = tokenString(minted, ["token", "plaintext"]) ?? "";
121
+ const installCommand = token
122
+ ? buildEnrollmentInstallCommand({
123
+ token,
124
+ base: parsed.installer_base ?? undefined,
125
+ nodeMode: nodeMode ?? undefined,
126
+ ip: parsed.ip ?? undefined,
127
+ force: payload.remote,
128
+ })
129
+ : null;
130
+ if (!payload.remote || !installCommand) {
131
+ return { ...minted, install_command: installCommand };
132
+ }
133
+ const results = [];
134
+ for (const host of hosts) {
135
+ results.push(await ctx.fetch("/internal/remote", {
136
+ method: "POST",
137
+ body: {
138
+ host,
139
+ username: payload.username,
140
+ port: payload.port,
141
+ private_key: privateKey,
142
+ command: installCommand,
143
+ timeout_seconds: 600,
144
+ },
145
+ }));
146
+ }
147
+ return {
148
+ id: minted.id,
149
+ operator_id: minted.operator_id ?? operatorId,
150
+ expires_at: minted.expires_at,
151
+ hosts: results,
152
+ };
153
+ },
154
+ });
155
+ export const tokenCapabilities = [tokensListCapability, tokensCreateCapability];
@@ -1,5 +1,6 @@
1
1
  import { kubernetesCapabilities, slurmCapabilities } from "./capabilities/clusters.js";
2
2
  import { fleetCapabilities } from "./capabilities/fleet.js";
3
+ import { tokenCapabilities } from "./capabilities/tokens.js";
3
4
  import { identityCapabilities } from "./capabilities/identity.js";
4
5
  import { listingCapabilities } from "./capabilities/listings.js";
5
6
  import { observabilityCapabilities } from "./capabilities/observability.js";
@@ -9,6 +10,7 @@ export const catalog = [
9
10
  ...identityCapabilities,
10
11
  ...listingCapabilities,
11
12
  ...fleetCapabilities,
13
+ ...tokenCapabilities,
12
14
  ...userCapabilities,
13
15
  ...observabilityCapabilities,
14
16
  ...slurmCapabilities,
@@ -1,7 +1,8 @@
1
1
  export { byCliCommand, byMcpName, bySlackName, defineCapability, forRole, slackStaffVisible, visibleToClient, type Capability, type CapabilityBindings, type CapabilityContext, type CapabilitySpec, type JsonSchema, type CapabilityTransport, type Domain, type HttpSpec, type Mutation, } from "./capability.ts";
2
2
  export { namesFromId } from "./names.ts";
3
3
  export { catalog } from "./catalog.ts";
4
- export { fleetCapabilities, listFacilitiesCapability, listNodesCapability, listOperatorsCapability, nodesConsoleCapability, } from "./capabilities/fleet.ts";
4
+ export { fleetCapabilities, listFacilitiesCapability, listNodesCapability, listOperatorsCapability, nodeExecCapability, nodeOffGridCapability, nodeOnGridCapability, nodeTerminateCapability, } from "./capabilities/fleet.ts";
5
+ export { buildEnrollmentInstallCommand, tokenCapabilities, tokensCreateCapability, tokensListCapability, } from "./capabilities/tokens.ts";
5
6
  export { identityCapabilities, statusCapability, whoamiCapability } from "./capabilities/identity.ts";
6
7
  export { createListingCapability, listingCreateInput, listCatalogTermsCapability, listingCapabilities, listListingsCapability, proposeListingCapability, } from "./capabilities/listings.ts";
7
8
  export { userCapabilities, usersQueryCapability } from "./capabilities/users.ts";
@@ -1,7 +1,8 @@
1
1
  export { byCliCommand, byMcpName, bySlackName, defineCapability, forRole, slackStaffVisible, visibleToClient, } from "./capability.js";
2
2
  export { namesFromId } from "./names.js";
3
3
  export { catalog } from "./catalog.js";
4
- export { fleetCapabilities, listFacilitiesCapability, listNodesCapability, listOperatorsCapability, nodesConsoleCapability, } from "./capabilities/fleet.js";
4
+ export { fleetCapabilities, listFacilitiesCapability, listNodesCapability, listOperatorsCapability, nodeExecCapability, nodeOffGridCapability, nodeOnGridCapability, nodeTerminateCapability, } from "./capabilities/fleet.js";
5
+ export { buildEnrollmentInstallCommand, tokenCapabilities, tokensCreateCapability, tokensListCapability, } from "./capabilities/tokens.js";
5
6
  export { identityCapabilities, statusCapability, whoamiCapability } from "./capabilities/identity.js";
6
7
  export { createListingCapability, listingCreateInput, listCatalogTermsCapability, listingCapabilities, listListingsCapability, proposeListingCapability, } from "./capabilities/listings.js";
7
8
  export { userCapabilities, usersQueryCapability } from "./capabilities/users.js";
@@ -1,6 +1,6 @@
1
1
  /** Shared wire names derived from `Capability.id`. */
2
2
  export function namesFromId(id) {
3
- const wire = id.replaceAll(".", "_");
3
+ const wire = id.replaceAll(".", "_").replaceAll("-", "_");
4
4
  const cli = id === "identity.whoami" ? ["whoami"] : id === "identity.status" ? ["status"] : id.split(".");
5
5
  return { mcp: wire, slack: wire, cli };
6
6
  }