@ornncompute/cli 0.2.24 → 1.0.34

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.
Files changed (90) hide show
  1. package/README.md +351 -426
  2. package/package.json +12 -6
  3. package/src/admin-capacity-reservations.mjs +305 -0
  4. package/src/admin-command-handlers.mjs +57 -0
  5. package/src/admin-enrollment.mjs +681 -0
  6. package/src/admin-mutations.mjs +187 -0
  7. package/src/admin-node-diagnostics.mjs +412 -0
  8. package/src/admin-node-lifecycle.mjs +905 -0
  9. package/src/admin-shell.mjs +456 -0
  10. package/src/api-client.mjs +81 -28
  11. package/src/atomic-output.mjs +112 -0
  12. package/src/auth-store.mjs +182 -11
  13. package/src/capability-catalog.mjs +7 -0
  14. package/src/catalog-shell.mjs +1363 -0
  15. package/src/cli.mjs +123 -10266
  16. package/src/connect-mode-handlers.mjs +170 -0
  17. package/src/device-auth.mjs +179 -62
  18. package/src/evidence-commands.mjs +1354 -0
  19. package/src/external-bucket-commands.mjs +1374 -0
  20. package/src/gpu-catalog.mjs +21 -0
  21. package/src/identity-file.mjs +28 -0
  22. package/src/interaction-runtime.mjs +310 -0
  23. package/src/kubernetes-commands.mjs +2235 -0
  24. package/src/launch-mode-handlers.mjs +119 -0
  25. package/src/machine-reference.mjs +285 -0
  26. package/src/mount-commands.mjs +1148 -0
  27. package/src/session-commands.mjs +565 -0
  28. package/src/slurm-commands.mjs +274 -0
  29. package/src/startup.mjs +34 -11
  30. package/src/storage-commands.mjs +1164 -0
  31. package/src/update.mjs +172 -24
  32. package/src/vm-commands.mjs +4308 -0
  33. package/src/vpn-secret-output.mjs +150 -0
  34. package/src/wait-runtime.mjs +231 -0
  35. package/vendor/capabilities/capabilities/admin-capacity-reservations.js +96 -0
  36. package/vendor/capabilities/capabilities/admin-enrollment.js +321 -0
  37. package/vendor/capabilities/capabilities/admin-node-diagnostics.js +88 -0
  38. package/vendor/capabilities/capabilities/admin-nodes-lifecycle.js +235 -0
  39. package/vendor/capabilities/capabilities/admin.js +89 -0
  40. package/vendor/capabilities/capabilities/cli.js +156 -0
  41. package/vendor/capabilities/capabilities/clusters.js +150 -1
  42. package/vendor/capabilities/capabilities/commerce-market.js +330 -0
  43. package/vendor/capabilities/capabilities/compute.js +522 -0
  44. package/vendor/capabilities/capabilities/evidence.js +145 -0
  45. package/vendor/capabilities/capabilities/external-buckets.js +191 -0
  46. package/vendor/capabilities/capabilities/fleet.js +77 -25
  47. package/vendor/capabilities/capabilities/identity.js +114 -3
  48. package/vendor/capabilities/capabilities/listings.js +141 -144
  49. package/vendor/capabilities/capabilities/market-shared.js +44 -0
  50. package/vendor/capabilities/capabilities/markets.js +449 -0
  51. package/vendor/capabilities/capabilities/mounts.js +176 -0
  52. package/vendor/capabilities/capabilities/networks.js +1264 -0
  53. package/vendor/capabilities/capabilities/observability.js +38 -8
  54. package/vendor/capabilities/capabilities/tokens.js +32 -17
  55. package/vendor/capabilities/capabilities/volumes.js +178 -0
  56. package/vendor/capabilities/capability.js +21 -14
  57. package/vendor/capabilities/cli-catalog.js +67 -0
  58. package/vendor/capabilities/cli-migrations.js +261 -0
  59. package/vendor/capabilities/cli.js +7 -0
  60. package/vendor/capabilities/software-catalog.js +36 -0
  61. package/vendor/capabilities/spec-catalog.js +28 -5
  62. package/vendor/capabilities/supplier-directory.js +20 -0
  63. package/src/catalog-dispatch.mjs +0 -605
  64. package/src/color.mjs +0 -71
  65. package/vendor/capabilities/capabilities/clusters.d.ts +0 -2
  66. package/vendor/capabilities/capabilities/fleet.d.ts +0 -8
  67. package/vendor/capabilities/capabilities/identity.d.ts +0 -3
  68. package/vendor/capabilities/capabilities/listings.d.ts +0 -51
  69. package/vendor/capabilities/capabilities/observability.d.ts +0 -5
  70. package/vendor/capabilities/capabilities/staff-access.d.ts +0 -29
  71. package/vendor/capabilities/capabilities/staff-access.js +0 -278
  72. package/vendor/capabilities/capabilities/staff-commerce.d.ts +0 -160
  73. package/vendor/capabilities/capabilities/staff-commerce.js +0 -689
  74. package/vendor/capabilities/capabilities/staff-fleet.d.ts +0 -140
  75. package/vendor/capabilities/capabilities/staff-fleet.js +0 -538
  76. package/vendor/capabilities/capabilities/staff-gtm.d.ts +0 -142
  77. package/vendor/capabilities/capabilities/staff-gtm.js +0 -521
  78. package/vendor/capabilities/capabilities/staff-shared.d.ts +0 -47
  79. package/vendor/capabilities/capabilities/staff-shared.js +0 -98
  80. package/vendor/capabilities/capabilities/tokens.d.ts +0 -10
  81. package/vendor/capabilities/capabilities/users.d.ts +0 -2
  82. package/vendor/capabilities/capability.d.ts +0 -93
  83. package/vendor/capabilities/catalog.d.ts +0 -3
  84. package/vendor/capabilities/catalog.js +0 -26
  85. package/vendor/capabilities/index.d.ts +0 -16
  86. package/vendor/capabilities/index.js +0 -16
  87. package/vendor/capabilities/names.d.ts +0 -6
  88. package/vendor/capabilities/role.d.ts +0 -6
  89. package/vendor/capabilities/role.js +0 -17
  90. package/vendor/capabilities/spec-catalog.d.ts +0 -78
@@ -0,0 +1,456 @@
1
+ import { adminCommandHandler } from "./admin-command-handlers.mjs";
2
+ import { shellSingleQuote } from "./identity-file.mjs";
3
+
4
+ const DEFAULT_PAGE_LIMIT = 50;
5
+ const MAX_PAGE_LIMIT = 500;
6
+ const LOG_CURSOR_PREFIX = "ornn-admin-logs-v1.";
7
+
8
+ export class AdminUsageError extends Error {
9
+ constructor(message, { code = "usage_error", field } = {}) {
10
+ super(message);
11
+ this.name = "AdminUsageError";
12
+ this.code = code;
13
+ this.field = field;
14
+ }
15
+ }
16
+
17
+ function vnextCommand(capability) {
18
+ return capability.cli?.vnext?.command ?? capability.cli?.command ?? [];
19
+ }
20
+
21
+ function adminCommands(catalog) {
22
+ return catalog
23
+ .filter((capability) => capability.cli?.vnext?.audience === "admin")
24
+ .sort((left, right) =>
25
+ vnextCommand(left).join(" ").localeCompare(vnextCommand(right).join(" "))
26
+ );
27
+ }
28
+
29
+ function commandMatch(catalog, argv) {
30
+ return adminCommands(catalog)
31
+ .filter((capability) => vnextCommand(capability).every((part, index) => argv[index] === part))
32
+ .sort((left, right) => vnextCommand(right).length - vnextCommand(left).length)[0];
33
+ }
34
+
35
+ export function adminCommandData(capability) {
36
+ const help = capability.cli.vnext;
37
+ return {
38
+ command: vnextCommand(capability).join(" "),
39
+ outcome: help.outcome,
40
+ human: help.human,
41
+ automation: help.automation,
42
+ accepted_references: help.acceptedReferences ?? [],
43
+ review: help.review ?? null,
44
+ resumable_states: help.resumableStates ?? [],
45
+ related: help.related ?? [],
46
+ };
47
+ }
48
+
49
+ export function adminRootData(catalog, roots, selectedRoot) {
50
+ const commands = adminCommands(catalog);
51
+ return {
52
+ roots: roots
53
+ .filter((root) => selectedRoot === undefined || root.name === selectedRoot)
54
+ .map((root) => ({
55
+ name: root.name,
56
+ commands: commands
57
+ .filter((capability) => vnextCommand(capability)[1] === root.name)
58
+ .map(adminCommandData),
59
+ })),
60
+ };
61
+ }
62
+
63
+ export function resolveAdminRequest(catalog, roots, argv) {
64
+ if (
65
+ argv.length === 0 ||
66
+ (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help"))
67
+ ) {
68
+ return { kind: "root", command: "admin" };
69
+ }
70
+
71
+ if (argv[0] === "help") {
72
+ const targetArgv = ["admin", ...argv.slice(1)];
73
+ const target = commandMatch(catalog, targetArgv);
74
+ if (target && targetArgv.length === vnextCommand(target).length) {
75
+ return {
76
+ kind: "help",
77
+ command: `help ${vnextCommand(target).join(" ")}`,
78
+ capability: target,
79
+ };
80
+ }
81
+ if (argv.length === 2) {
82
+ const root = roots.find((candidate) => candidate.name === argv[1]);
83
+ if (root) return { kind: "root", command: `admin ${root.name}`, root: root.name };
84
+ }
85
+ throw new AdminUsageError(
86
+ "The requested admin command is unavailable for the current authorization.",
87
+ {
88
+ code: "admin_command_unavailable",
89
+ }
90
+ );
91
+ }
92
+
93
+ if (argv.length === 1 || (argv.length === 2 && ["--help", "-h"].includes(argv[1]))) {
94
+ const root = roots.find((candidate) => candidate.name === argv[0]);
95
+ if (root) return { kind: "root", command: `admin ${root.name}`, root: root.name };
96
+ }
97
+
98
+ const capability = commandMatch(catalog, ["admin", ...argv]);
99
+ if (!capability) {
100
+ throw new AdminUsageError(
101
+ "The requested admin command is unavailable for the current authorization.",
102
+ {
103
+ code: "admin_command_unavailable",
104
+ }
105
+ );
106
+ }
107
+ const command = vnextCommand(capability).join(" ");
108
+ const rest = argv.slice(vnextCommand(capability).length - 1);
109
+ if (rest.includes("--help") || rest.includes("-h")) {
110
+ if (rest.length !== 1) throw new AdminUsageError(`Usage: ${capability.cli.vnext.human}`);
111
+ return { kind: "help", command: `help ${command}`, capability };
112
+ }
113
+ const handler = adminCommandHandler(capability);
114
+ return {
115
+ kind: "command",
116
+ command,
117
+ capability,
118
+ handler,
119
+ ...(handler ? handler.parse(rest, capability) : parseAdminInput(capability, rest)),
120
+ };
121
+ }
122
+
123
+ function parseOptions(argv, allowed, usage) {
124
+ const options = {};
125
+ const positionals = [];
126
+ for (let index = 0; index < argv.length; index += 1) {
127
+ const argument = argv[index];
128
+ if (!argument.startsWith("--")) {
129
+ positionals.push(argument);
130
+ continue;
131
+ }
132
+ const separator = argument.indexOf("=");
133
+ const name = argument.slice(2, separator === -1 ? undefined : separator);
134
+ if (!allowed.includes(name)) throw new AdminUsageError(`${usage}\nUnknown option: --${name}`);
135
+ const value = separator === -1 ? argv[index + 1] : argument.slice(separator + 1);
136
+ if (!value || (separator === -1 && value.startsWith("--"))) {
137
+ throw new AdminUsageError(`${usage}\n--${name} requires a value.`, { field: name });
138
+ }
139
+ if (options[name] !== undefined) {
140
+ throw new AdminUsageError(`${usage}\n--${name} may be supplied only once.`, { field: name });
141
+ }
142
+ options[name] = value;
143
+ if (separator === -1) index += 1;
144
+ }
145
+ return { options, positionals };
146
+ }
147
+
148
+ function positiveInteger(value, name, maximum) {
149
+ if (value === undefined) return undefined;
150
+ const parsed = Number(value);
151
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > maximum) {
152
+ throw new AdminUsageError(`--${name} must be an integer from 1 to ${maximum}.`, {
153
+ field: name,
154
+ });
155
+ }
156
+ return parsed;
157
+ }
158
+
159
+ function parseAdminInput(capability, argv) {
160
+ const command = vnextCommand(capability).join(" ");
161
+ const help = capability.cli.vnext;
162
+ const usage = `Usage: ${help.human}`;
163
+ const allowed = [...help.human.matchAll(/--([a-z0-9-]+)/g)].map((match) => match[1]);
164
+ const { options, positionals } = parseOptions(argv, allowed, usage);
165
+ const nodeCommand = (help.acceptedReferences ?? []).includes("canonical node ID");
166
+ if ((nodeCommand && positionals.length !== 1) || (!nodeCommand && positionals.length !== 0)) {
167
+ throw new AdminUsageError(usage);
168
+ }
169
+
170
+ const limit = positiveInteger(options.limit, "limit", MAX_PAGE_LIMIT) ?? DEFAULT_PAGE_LIMIT;
171
+ const input = {};
172
+ if (nodeCommand) input.node_id = positionals[0];
173
+ if (options.supplier) input.organization_id = options.supplier;
174
+ if (options.facility) input.facility_id = options.facility;
175
+ if (options.status) input.status = options.status;
176
+ if (options.email) input.email = options.email;
177
+ if (options.organization) input.organization = options.organization;
178
+ if (options.name) input.name = options.name;
179
+ if (options.span) input.span = options.span;
180
+ if (options.start) input.start = options.start;
181
+ if (options.end) input.end = options.end;
182
+ if (options.before) input.before = options.before;
183
+ if (options.stream) input.stream = options.stream;
184
+ if (options.cursor && command === "admin nodes logs") input.cursor = options.cursor;
185
+ if (command === "admin users search") input.max = 1000;
186
+ const maxPoints = positiveInteger(options["max-points"], "max-points", 500);
187
+ if (maxPoints !== undefined) input.max_points = maxPoints;
188
+ const count = positiveInteger(options.count, "count", 500);
189
+ if (count !== undefined) input.count = count;
190
+
191
+ if (command === "admin nodes logs") return parseLogPage(input);
192
+
193
+ const ownerPagination = ["facilities.list", "nodes.list"].includes(capability.id);
194
+ if (ownerPagination) {
195
+ input.limit = limit;
196
+ if (options.cursor) input.cursor = options.cursor;
197
+ }
198
+
199
+ return {
200
+ input,
201
+ pagination: {
202
+ cursor: options.cursor,
203
+ limit,
204
+ ownerPagination,
205
+ },
206
+ };
207
+ }
208
+
209
+ function invalidLogCursor(message) {
210
+ throw new AdminUsageError(message, { code: "invalid_cursor", field: "cursor" });
211
+ }
212
+
213
+ function parseLogPage(input) {
214
+ let offset = 0;
215
+ if (input.cursor?.startsWith(LOG_CURSOR_PREFIX)) {
216
+ let saved;
217
+ try {
218
+ saved = JSON.parse(
219
+ Buffer.from(input.cursor.slice(LOG_CURSOR_PREFIX.length), "base64url").toString("utf8")
220
+ );
221
+ } catch {
222
+ invalidLogCursor("The log cursor is invalid; restart from the first page.");
223
+ }
224
+ if (
225
+ !saved ||
226
+ typeof saved !== "object" ||
227
+ saved.node_id !== input.node_id ||
228
+ !["kernel", "serial"].includes(saved.stream) ||
229
+ typeof saved.before !== "string" ||
230
+ !Number.isFinite(Date.parse(saved.before)) ||
231
+ !Number.isInteger(saved.count) ||
232
+ saved.count < 1 ||
233
+ saved.count > 500 ||
234
+ !Number.isSafeInteger(saved.offset) ||
235
+ saved.offset < 0 ||
236
+ !(
237
+ saved.ownerCursor === null ||
238
+ (typeof saved.ownerCursor === "string" && saved.ownerCursor.length > 0)
239
+ )
240
+ )
241
+ invalidLogCursor("The log cursor is invalid or belongs to a different node.");
242
+ for (const field of ["stream", "before", "count"]) {
243
+ if (input[field] !== undefined && input[field] !== saved[field]) {
244
+ invalidLogCursor(
245
+ `The log cursor has a different ${field}; restart from the first page to change filters.`
246
+ );
247
+ }
248
+ input[field] = saved[field];
249
+ }
250
+ input.cursor = saved.ownerCursor ?? undefined;
251
+ offset = saved.offset;
252
+ } else if (input.cursor && !input.before) {
253
+ invalidLogCursor(
254
+ "A legacy log cursor requires the original --before value; otherwise restart from the first page."
255
+ );
256
+ }
257
+ input.before ??= new Date().toISOString();
258
+ input.stream ??= "kernel";
259
+ input.count ??= 200;
260
+ if (!Number.isFinite(Date.parse(input.before))) {
261
+ throw new AdminUsageError("--before must be a valid timestamp.", { field: "before" });
262
+ }
263
+ return {
264
+ input,
265
+ pagination: {
266
+ limit: input.count,
267
+ logQuery: {
268
+ node_id: input.node_id,
269
+ before: input.before,
270
+ stream: input.stream,
271
+ count: input.count,
272
+ },
273
+ ownerCursor: input.cursor ?? null,
274
+ offset,
275
+ },
276
+ };
277
+ }
278
+
279
+ function collectionKey(capability) {
280
+ const [, root, leaf] = vnextCommand(capability);
281
+ if (root === "nodes" && leaf === "logs") return "logs";
282
+ if (!["list", "search"].includes(leaf)) return null;
283
+ if (root === "enrollment-tokens") return "enrollment_tokens";
284
+ return ["facilities", "nodes", "reservations", "suppliers", "users"].includes(root) ? root : null;
285
+ }
286
+
287
+ function isSensitiveAdminField(key) {
288
+ const normalized = key.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
289
+ return (
290
+ normalized === "authorization" ||
291
+ normalized === "session" ||
292
+ normalized === "token" ||
293
+ normalized.endsWith("_session") ||
294
+ normalized.endsWith("_token") ||
295
+ normalized.includes("access_token") ||
296
+ normalized.includes("api_key") ||
297
+ normalized.includes("credential") ||
298
+ normalized.includes("password") ||
299
+ normalized.includes("plaintext") ||
300
+ normalized.includes("private_key") ||
301
+ normalized.includes("secret")
302
+ );
303
+ }
304
+
305
+ function sanitizeAdminValue(value) {
306
+ if (Array.isArray(value)) return value.map(sanitizeAdminValue);
307
+ if (!value || typeof value !== "object") return value;
308
+ const safe = {};
309
+ for (const [key, child] of Object.entries(value)) {
310
+ if (isSensitiveAdminField(key)) continue;
311
+ safe[key] = sanitizeAdminValue(child);
312
+ }
313
+ return safe;
314
+ }
315
+
316
+ function collectionRows(payload, key) {
317
+ if (Array.isArray(payload)) return payload;
318
+ if (!payload || typeof payload !== "object") return [];
319
+ for (const candidate of [
320
+ key,
321
+ ...(key === "logs" ? ["lines"] : []),
322
+ "items",
323
+ "results",
324
+ "data",
325
+ "entries",
326
+ "events",
327
+ "records",
328
+ ]) {
329
+ if (Array.isArray(payload[candidate])) return payload[candidate];
330
+ }
331
+ return [];
332
+ }
333
+
334
+ function encodeCursor(command, offset) {
335
+ return Buffer.from(JSON.stringify({ command, offset }), "utf8").toString("base64url");
336
+ }
337
+
338
+ function decodeCursor(command, cursor) {
339
+ if (!cursor) return 0;
340
+ try {
341
+ const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
342
+ if (decoded.command === command && Number.isInteger(decoded.offset) && decoded.offset >= 0) {
343
+ return decoded.offset;
344
+ }
345
+ if (decoded.command !== undefined || decoded.offset !== undefined) {
346
+ throw new AdminUsageError("The pagination cursor belongs to a different admin command.", {
347
+ code: "invalid_cursor",
348
+ field: "cursor",
349
+ });
350
+ }
351
+ } catch (error) {
352
+ if (error instanceof AdminUsageError) throw error;
353
+ }
354
+ return 0;
355
+ }
356
+
357
+ export function finalizeAdminResult(capability, payload, pagination) {
358
+ if (!pagination) return { data: sanitizeAdminValue(payload), page: null };
359
+ const command = vnextCommand(capability).join(" ");
360
+ const key = collectionKey(capability);
361
+ if (!key) return { data: sanitizeAdminValue(payload), page: null };
362
+ const rows = collectionRows(payload, key);
363
+ const offset = pagination.ownerPagination
364
+ ? 0
365
+ : pagination.logQuery
366
+ ? pagination.offset
367
+ : decodeCursor(command, pagination.cursor);
368
+ const selected = (
369
+ pagination.ownerPagination ? rows : rows.slice(offset, offset + pagination.limit)
370
+ ).map(sanitizeAdminValue);
371
+ const ownerNextCursor =
372
+ payload && !Array.isArray(payload)
373
+ ? (payload.next_cursor ?? payload.nextCursor ?? payload.page?.next_cursor ?? null)
374
+ : null;
375
+ let nextCursor =
376
+ !pagination.ownerPagination && offset + selected.length < rows.length
377
+ ? encodeCursor(command, offset + selected.length)
378
+ : (ownerNextCursor ?? null);
379
+ if (pagination.ownerPagination && nextCursor !== null && nextCursor === pagination.cursor) {
380
+ throw new AdminUsageError(
381
+ "The service repeated its pagination cursor; restart from the first page.",
382
+ {
383
+ code: "pagination_not_advancing",
384
+ field: "cursor",
385
+ }
386
+ );
387
+ }
388
+ if (pagination.logQuery && nextCursor !== null) {
389
+ const localPage = offset + selected.length < rows.length;
390
+ nextCursor =
391
+ LOG_CURSOR_PREFIX +
392
+ Buffer.from(
393
+ JSON.stringify({
394
+ ...pagination.logQuery,
395
+ ownerCursor: localPage ? pagination.ownerCursor : ownerNextCursor,
396
+ offset: localPage ? offset + selected.length : 0,
397
+ }),
398
+ "utf8"
399
+ ).toString("base64url");
400
+ }
401
+ return {
402
+ data: { [key]: selected },
403
+ page: {
404
+ limit: pagination.limit,
405
+ returned: selected.length,
406
+ next_cursor: nextCursor,
407
+ },
408
+ };
409
+ }
410
+
411
+ export function writeAdminRootHuman(stdout, data) {
412
+ stdout.write("Ornn admin\n\nAuthorized resources:\n");
413
+ for (const root of data.roots) {
414
+ stdout.write(` ${root.name}\n`);
415
+ for (const command of root.commands)
416
+ stdout.write(` ${command.command} ${command.outcome}\n`);
417
+ }
418
+ }
419
+
420
+ export function writeAdminHelpHuman(stdout, capability) {
421
+ const data = adminCommandData(capability);
422
+ stdout.write(`${data.command}\n\nOutcome: ${data.outcome}\n\n`);
423
+ stdout.write(`Usage:\n ${data.human}\n\nAutomation:\n ${data.automation}\n`);
424
+ }
425
+
426
+ export function writeAdminResultHuman(stdout, command, result, input = {}) {
427
+ const key = result.page ? Object.keys(result.data)[0] : null;
428
+ if (!key) {
429
+ stdout.write(`${JSON.stringify(result.data, null, 2)}\n`);
430
+ return;
431
+ }
432
+ const rows = result.data[key];
433
+ if (rows.length === 0) {
434
+ stdout.write(`No ${key.replaceAll("_", " ")} found.\n`);
435
+ } else {
436
+ for (const row of rows) stdout.write(`${JSON.stringify(row)}\n`);
437
+ }
438
+ if (result.page.next_cursor) {
439
+ const target = command === "admin nodes logs" ? ` ${shellSingleQuote(input.node_id)}` : "";
440
+ const filters = [
441
+ ["supplier", input.organization_id],
442
+ ["facility", input.facility_id],
443
+ ["status", input.status],
444
+ ["email", input.email],
445
+ ["organization", input.organization],
446
+ ["name", input.name],
447
+ ["limit", input.limit],
448
+ ]
449
+ .filter(([, value]) => value !== undefined)
450
+ .map(([name, value]) => ` --${name} ${shellSingleQuote(String(value))}`)
451
+ .join("");
452
+ stdout.write(
453
+ `Next: ornn ${command}${target} --cursor ${shellSingleQuote(result.page.next_cursor)}${filters}\n`
454
+ );
455
+ }
456
+ }
@@ -1,6 +1,15 @@
1
- import { clearAuthSession, configuredApiBaseUrl, loadAuthSession } from "./auth-store.mjs";
1
+ import { createRequire } from "node:module";
2
+
3
+ import {
4
+ clearAuthCredentials,
5
+ configuredApiBaseUrl,
6
+ configuredInstallerBaseUrl,
7
+ loadAuthSession,
8
+ } from "./auth-store.mjs";
2
9
  import { resolveAuthBaseUrl } from "./device-auth.mjs";
3
10
 
11
+ const { version: CLI_VERSION } = createRequire(import.meta.url)("../package.json");
12
+
4
13
  export class CliApiError extends Error {
5
14
  constructor(message, { status, detail, genericFallback = false } = {}) {
6
15
  super(message);
@@ -52,10 +61,7 @@ function gatewayOriginForAuthOrigin(value, sourceName) {
52
61
  if (url.hostname === "compute.ornn.com") {
53
62
  return "https://api.ornn.com";
54
63
  }
55
- if (
56
- url.hostname === "staging.compute.ornn.com" ||
57
- url.hostname === "staging.fabric.ornn.com"
58
- ) {
64
+ if (url.hostname === "staging.compute.ornn.com" || url.hostname === "staging.fabric.ornn.com") {
59
65
  return "https://staging.api.ornn.com";
60
66
  }
61
67
  const dynamicMatch = url.hostname.match(/^([a-z0-9-]+)\.fabric\.ornn\.com$/i);
@@ -72,16 +78,32 @@ export async function loadRequiredSession({ env = process.env } = {}) {
72
78
  }
73
79
  return session;
74
80
  }
81
+ export function userCredentialHeaders(session) {
82
+ if (!session?.accessToken) return {};
83
+ const headers = {
84
+ Authorization: `Bearer ${session.accessToken}`,
85
+ // This marker and the organization selector grant nothing by themselves.
86
+ // The gateway re-resolves the bearer and replaces them with trusted scope.
87
+ "X-Ornn-Credential-Type": "user",
88
+ "X-Ornn-CLI-Version": CLI_VERSION,
89
+ };
90
+ if (typeof session.selectedOrganizationId === "string" && session.selectedOrganizationId.trim()) {
91
+ headers["X-Organization-Id"] = session.selectedOrganizationId.trim();
92
+ }
93
+ return headers;
94
+ }
75
95
 
76
96
  export async function cliRequest({
77
97
  authRequired = true,
78
98
  body,
79
99
  endpoint,
100
+ headers: extraHeaders = {},
80
101
  env = process.env,
81
102
  fetchImpl = fetch,
82
103
  method = "GET",
83
104
  raw = false,
84
105
  session,
106
+ signal,
85
107
  timeoutMs,
86
108
  } = {}) {
87
109
  const authSession = session ?? (authRequired ? await loadRequiredSession({ env }) : null);
@@ -89,15 +111,24 @@ export async function cliRequest({
89
111
  const url = new URL(endpoint, `${baseUrl}/`);
90
112
  const headers = {
91
113
  Accept: raw ? "*/*" : "application/json",
114
+ ...extraHeaders,
92
115
  };
93
-
94
- if (authSession?.accessToken) {
95
- headers.Authorization = `Bearer ${authSession.accessToken}`;
96
- // Explicitly select the gateway's user-credential resolver. This marker
97
- // grants nothing by itself; the gateway still has to resolve the bearer
98
- // through authorization and derive the caller's organization server-side.
99
- headers["X-Ornn-Credential-Type"] = "user";
116
+ for (const name of Object.keys(headers)) {
117
+ if (
118
+ [
119
+ "authorization",
120
+ "x-auth-tenant-id",
121
+ "x-auth-user-id",
122
+ "x-organization-id",
123
+ "x-ornn-credential-type",
124
+ "x-ornn-cli-version",
125
+ "x-tenant-id",
126
+ ].includes(name.toLowerCase())
127
+ ) {
128
+ delete headers[name];
129
+ }
100
130
  }
131
+ Object.assign(headers, userCredentialHeaders(authSession));
101
132
 
102
133
  let requestBody;
103
134
  if (body !== undefined) {
@@ -107,33 +138,43 @@ export async function cliRequest({
107
138
 
108
139
  let response;
109
140
  try {
141
+ const requestSignal = signal ?? (timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined);
110
142
  response = await fetchImpl(url, {
111
143
  body: requestBody,
112
144
  headers,
113
145
  method,
114
- ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
146
+ ...(requestSignal ? { signal: requestSignal } : {}),
115
147
  });
116
148
  } catch (error) {
117
149
  // Detect the timeout/abort case before the generic network-failure wrapping
118
150
  // below, so a wedged compute call (e.g. status blocked behind an
119
151
  // unresponsive FUSE mount) reports a customer-facing timeout instead of an
120
152
  // opaque "Could not reach Ornn" message.
153
+ if (signal?.aborted && (error?.name === "TimeoutError" || error?.name === "AbortError")) {
154
+ throw error;
155
+ }
121
156
  if (timeoutMs && (error?.name === "TimeoutError" || error?.name === "AbortError")) {
122
157
  throw new CliApiError(
123
- `Ornn did not respond within ${Math.round(timeoutMs / 1000)} seconds. The service or a node mount may be unresponsive; try again shortly.`,
158
+ `Ornn did not respond within ${Math.round(timeoutMs / 1000)} seconds. The service or a node mount may be unresponsive; try again shortly.`
124
159
  );
125
160
  }
126
161
  const message = error instanceof Error ? error.message : String(error);
127
162
  throw new CliApiError(
128
- `Could not reach Ornn at ${baseUrl} (${message}). Check your network connection and that ${baseUrl} is the correct Ornn API endpoint, then try again.`,
163
+ `Could not reach Ornn at ${baseUrl} (${message}). Check your network connection and that ${baseUrl} is the correct Ornn API endpoint, then try again.`
129
164
  );
130
165
  }
131
166
 
132
167
  if (raw) {
133
168
  const text = await response.text();
134
169
  if (!response.ok) {
135
- await handleAuthExpiry({ env, response, hadToken: Boolean(authSession?.accessToken) });
136
- throw new CliApiError(text || response.statusText, {
170
+ await handleAuthExpiry({ env, response, accessToken: authSession?.accessToken });
171
+ const message =
172
+ text ||
173
+ response.statusText ||
174
+ (response.status === 403
175
+ ? "Your account is not authorized for this command."
176
+ : `Ornn request failed with HTTP ${response.status}.`);
177
+ throw new CliApiError(message, {
137
178
  status: response.status,
138
179
  genericFallback: true,
139
180
  });
@@ -146,8 +187,8 @@ export async function cliRequest({
146
187
  // Always throws: handles the 401/expired-session case (clearing the
147
188
  // stored token) and the generic detail-surfacing case.
148
189
  await raiseForFailedResponse({
190
+ accessToken: authSession?.accessToken,
149
191
  env,
150
- hadToken: Boolean(authSession?.accessToken),
151
192
  response,
152
193
  text,
153
194
  url: url.toString(),
@@ -172,17 +213,17 @@ export async function cliRequest({
172
213
  * status text) when the body has no usable detail.
173
214
  */
174
215
  export async function raiseForFailedResponse({
216
+ accessToken,
175
217
  env = process.env,
176
218
  fallbackMessage,
177
219
  formatMessage = (message) => message,
178
- hadToken,
179
220
  response,
180
221
  text,
181
222
  url,
182
223
  } = {}) {
183
224
  // Clear a dead token before parsing, so a 401 with a non-JSON body
184
225
  // (e.g. a gateway/WAF error page) still triggers the re-login flow.
185
- await handleAuthExpiry({ env, response, hadToken });
226
+ await handleAuthExpiry({ accessToken, env, response });
186
227
  const data = text ? parseJson(text, url, response) : null;
187
228
  const detail = data?.detail ?? data?.error ?? response.statusText;
188
229
  const detailText = detailMessage(detail);
@@ -203,26 +244,32 @@ export async function raiseForFailedResponse({
203
244
  }
204
245
 
205
246
  export function resolveInstallerBaseUrl({ env = process.env } = {}) {
206
- const explicit = env.ORNN_INSTALLER_URL?.trim() || env.NEXT_PUBLIC_INSTALLER_URL?.trim();
207
- if (!explicit) {
247
+ const [value, source] = env.ORNN_INSTALLER_URL?.trim()
248
+ ? [env.ORNN_INSTALLER_URL, "ORNN_INSTALLER_URL"]
249
+ : env.NEXT_PUBLIC_INSTALLER_URL?.trim()
250
+ ? [env.NEXT_PUBLIC_INSTALLER_URL, "NEXT_PUBLIC_INSTALLER_URL"]
251
+ : [configuredInstallerBaseUrl(env), "configured installerBaseUrl"];
252
+ if (!value?.trim()) {
208
253
  return null;
209
254
  }
210
- return requireValidBaseUrl(explicit, "ORNN_INSTALLER_URL", { allowPath: true });
255
+ return requireValidBaseUrl(value.trim(), source, { allowPath: true });
211
256
  }
212
257
 
213
258
  function operatorProxyEndpoint(endpoint) {
214
- return normalizeComputePath(endpoint);
259
+ return `/v1/staff${normalizeComputePath(endpoint)}`;
215
260
  }
216
261
 
217
262
  // Staff path: CLI bearer → gateway /v1/orchestrator/ → the owning service.
218
263
  // The gateway resolves the bearer and applies staff policy; the CLI never ships
219
- // a reviewer or service secret.
264
+ // a review secret or service secret.
220
265
  export async function operatorRequest({
221
266
  body,
222
267
  endpoint,
268
+ headers,
223
269
  env = process.env,
224
270
  fetchImpl = fetch,
225
271
  method = "GET",
272
+ signal,
226
273
  } = {}) {
227
274
  const session = await loadAuthSession({ env });
228
275
  if (!session?.accessToken) {
@@ -235,12 +282,18 @@ export async function operatorRequest({
235
282
  body,
236
283
  endpoint: operatorProxyEndpoint(endpoint),
237
284
  env,
285
+ headers,
238
286
  fetchImpl,
239
287
  method,
288
+ signal,
240
289
  authRequired: true,
241
290
  });
242
291
  }
243
292
 
293
+ export function computeEndpoint(path) {
294
+ return `/v1/workflows/compute${normalizeComputePath(path)}`;
295
+ }
296
+
244
297
  export function normalizeComputePath(path) {
245
298
  if (!path || typeof path !== "string") {
246
299
  throw new CliApiError("API path is required.");
@@ -291,11 +344,11 @@ function parseJson(text, url, response) {
291
344
  }
292
345
  }
293
346
 
294
- async function handleAuthExpiry({ env, response, hadToken }) {
295
- if (response.status !== 401 || !hadToken) {
347
+ async function handleAuthExpiry({ accessToken, env, response }) {
348
+ if (response.status !== 401 || !accessToken) {
296
349
  return;
297
350
  }
298
- await clearAuthSession({ env });
351
+ await clearAuthCredentials({ env, expectedAccessToken: accessToken });
299
352
  throw new CliApiError(
300
353
  "Your Ornn session has expired or was revoked. Run `ornn login` to sign in again.",
301
354
  {