@ornncompute/cli 0.2.23 → 1.0.25

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 (85) hide show
  1. package/README.md +304 -420
  2. package/package.json +12 -6
  3. package/src/admin-capacity-reservations.mjs +280 -0
  4. package/src/admin-command-handlers.mjs +57 -0
  5. package/src/admin-enrollment.mjs +680 -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 +908 -0
  9. package/src/admin-shell.mjs +322 -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 +1350 -0
  15. package/src/cli.mjs +123 -10266
  16. package/src/connect-mode-handlers.mjs +170 -0
  17. package/src/device-auth.mjs +178 -61
  18. package/src/evidence-commands.mjs +1340 -0
  19. package/src/external-bucket-commands.mjs +1374 -0
  20. package/src/interaction-runtime.mjs +310 -0
  21. package/src/kubernetes-commands.mjs +2229 -0
  22. package/src/launch-mode-handlers.mjs +119 -0
  23. package/src/machine-reference.mjs +277 -0
  24. package/src/mount-commands.mjs +1146 -0
  25. package/src/session-commands.mjs +565 -0
  26. package/src/slurm-commands.mjs +263 -0
  27. package/src/startup.mjs +34 -11
  28. package/src/storage-commands.mjs +1144 -0
  29. package/src/update.mjs +172 -24
  30. package/src/vm-commands.mjs +4273 -0
  31. package/src/vpn-secret-output.mjs +150 -0
  32. package/src/wait-runtime.mjs +231 -0
  33. package/vendor/capabilities/capabilities/admin-capacity-reservations.js +126 -0
  34. package/vendor/capabilities/capabilities/admin-enrollment.js +321 -0
  35. package/vendor/capabilities/capabilities/admin-node-diagnostics.js +88 -0
  36. package/vendor/capabilities/capabilities/admin-nodes-lifecycle.js +235 -0
  37. package/vendor/capabilities/capabilities/admin.js +89 -0
  38. package/vendor/capabilities/capabilities/capacity-offers.js +368 -0
  39. package/vendor/capabilities/capabilities/cli.js +156 -0
  40. package/vendor/capabilities/capabilities/clusters.js +150 -1
  41. package/vendor/capabilities/capabilities/compute.js +522 -0
  42. package/vendor/capabilities/capabilities/evidence.js +145 -0
  43. package/vendor/capabilities/capabilities/external-buckets.js +191 -0
  44. package/vendor/capabilities/capabilities/fleet.js +21 -5
  45. package/vendor/capabilities/capabilities/identity.js +114 -3
  46. package/vendor/capabilities/capabilities/listings.js +14 -8
  47. package/vendor/capabilities/capabilities/mounts.js +176 -0
  48. package/vendor/capabilities/capabilities/networks.js +1257 -0
  49. package/vendor/capabilities/capabilities/observability.js +38 -8
  50. package/vendor/capabilities/capabilities/tokens.js +20 -8
  51. package/vendor/capabilities/capabilities/volumes.js +178 -0
  52. package/vendor/capabilities/capability.js +19 -13
  53. package/vendor/capabilities/cli-catalog.js +65 -0
  54. package/vendor/capabilities/cli-migrations.js +288 -0
  55. package/vendor/capabilities/cli.js +7 -0
  56. package/vendor/capabilities/software-catalog.js +36 -0
  57. package/vendor/capabilities/spec-catalog.js +2 -2
  58. package/src/catalog-dispatch.mjs +0 -605
  59. package/src/color.mjs +0 -71
  60. package/vendor/capabilities/capabilities/clusters.d.ts +0 -2
  61. package/vendor/capabilities/capabilities/fleet.d.ts +0 -8
  62. package/vendor/capabilities/capabilities/identity.d.ts +0 -3
  63. package/vendor/capabilities/capabilities/listings.d.ts +0 -51
  64. package/vendor/capabilities/capabilities/observability.d.ts +0 -5
  65. package/vendor/capabilities/capabilities/staff-access.d.ts +0 -29
  66. package/vendor/capabilities/capabilities/staff-access.js +0 -278
  67. package/vendor/capabilities/capabilities/staff-commerce.d.ts +0 -160
  68. package/vendor/capabilities/capabilities/staff-commerce.js +0 -689
  69. package/vendor/capabilities/capabilities/staff-fleet.d.ts +0 -140
  70. package/vendor/capabilities/capabilities/staff-fleet.js +0 -538
  71. package/vendor/capabilities/capabilities/staff-gtm.d.ts +0 -142
  72. package/vendor/capabilities/capabilities/staff-gtm.js +0 -521
  73. package/vendor/capabilities/capabilities/staff-shared.d.ts +0 -47
  74. package/vendor/capabilities/capabilities/staff-shared.js +0 -98
  75. package/vendor/capabilities/capabilities/tokens.d.ts +0 -10
  76. package/vendor/capabilities/capabilities/users.d.ts +0 -2
  77. package/vendor/capabilities/capability.d.ts +0 -93
  78. package/vendor/capabilities/catalog.d.ts +0 -3
  79. package/vendor/capabilities/catalog.js +0 -26
  80. package/vendor/capabilities/index.d.ts +0 -16
  81. package/vendor/capabilities/index.js +0 -16
  82. package/vendor/capabilities/names.d.ts +0 -6
  83. package/vendor/capabilities/role.d.ts +0 -6
  84. package/vendor/capabilities/role.js +0 -17
  85. package/vendor/capabilities/spec-catalog.d.ts +0 -78
@@ -0,0 +1,322 @@
1
+ import { adminCommandHandler } from "./admin-command-handlers.mjs";
2
+
3
+ const DEFAULT_PAGE_LIMIT = 50;
4
+ const MAX_PAGE_LIMIT = 500;
5
+
6
+ export class AdminUsageError extends Error {
7
+ constructor(message, { code = "usage_error", field } = {}) {
8
+ super(message);
9
+ this.name = "AdminUsageError";
10
+ this.code = code;
11
+ this.field = field;
12
+ }
13
+ }
14
+
15
+ function vnextCommand(capability) {
16
+ return capability.cli?.vnext?.command ?? capability.cli?.command ?? [];
17
+ }
18
+
19
+ function adminCommands(catalog) {
20
+ return catalog
21
+ .filter((capability) => capability.cli?.vnext?.audience === "admin")
22
+ .sort((left, right) =>
23
+ vnextCommand(left).join(" ").localeCompare(vnextCommand(right).join(" "))
24
+ );
25
+ }
26
+
27
+ function commandMatch(catalog, argv) {
28
+ return adminCommands(catalog)
29
+ .filter((capability) => vnextCommand(capability).every((part, index) => argv[index] === part))
30
+ .sort((left, right) => vnextCommand(right).length - vnextCommand(left).length)[0];
31
+ }
32
+
33
+ export function adminCommandData(capability) {
34
+ const help = capability.cli.vnext;
35
+ return {
36
+ command: vnextCommand(capability).join(" "),
37
+ outcome: help.outcome,
38
+ human: help.human,
39
+ automation: help.automation,
40
+ accepted_references: help.acceptedReferences ?? [],
41
+ review: help.review ?? null,
42
+ resumable_states: help.resumableStates ?? [],
43
+ related: help.related ?? [],
44
+ };
45
+ }
46
+
47
+ export function adminRootData(catalog, roots, selectedRoot) {
48
+ const commands = adminCommands(catalog);
49
+ return {
50
+ roots: roots
51
+ .filter((root) => selectedRoot === undefined || root.name === selectedRoot)
52
+ .map((root) => ({
53
+ name: root.name,
54
+ commands: commands
55
+ .filter((capability) => vnextCommand(capability)[1] === root.name)
56
+ .map(adminCommandData),
57
+ })),
58
+ };
59
+ }
60
+
61
+ export function resolveAdminRequest(catalog, roots, argv) {
62
+ if (
63
+ argv.length === 0 ||
64
+ (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help"))
65
+ ) {
66
+ return { kind: "root", command: "admin" };
67
+ }
68
+
69
+ if (argv[0] === "help") {
70
+ const targetArgv = ["admin", ...argv.slice(1)];
71
+ const target = commandMatch(catalog, targetArgv);
72
+ if (target && targetArgv.length === vnextCommand(target).length) {
73
+ return {
74
+ kind: "help",
75
+ command: `help ${vnextCommand(target).join(" ")}`,
76
+ capability: target,
77
+ };
78
+ }
79
+ if (argv.length === 2) {
80
+ const root = roots.find((candidate) => candidate.name === argv[1]);
81
+ if (root) return { kind: "root", command: `admin ${root.name}`, root: root.name };
82
+ }
83
+ throw new AdminUsageError(
84
+ "The requested admin command is unavailable for the current authorization.",
85
+ {
86
+ code: "admin_command_unavailable",
87
+ }
88
+ );
89
+ }
90
+
91
+ if (argv.length === 1) {
92
+ const root = roots.find((candidate) => candidate.name === argv[0]);
93
+ if (root) return { kind: "root", command: `admin ${root.name}`, root: root.name };
94
+ }
95
+
96
+ const capability = commandMatch(catalog, ["admin", ...argv]);
97
+ if (!capability) {
98
+ throw new AdminUsageError(
99
+ "The requested admin command is unavailable for the current authorization.",
100
+ {
101
+ code: "admin_command_unavailable",
102
+ }
103
+ );
104
+ }
105
+ const command = vnextCommand(capability).join(" ");
106
+ const rest = argv.slice(vnextCommand(capability).length - 1);
107
+ if (rest.includes("--help") || rest.includes("-h")) {
108
+ if (rest.length !== 1) throw new AdminUsageError(`Usage: ${capability.cli.vnext.human}`);
109
+ return { kind: "help", command: `help ${command}`, capability };
110
+ }
111
+ const handler = adminCommandHandler(capability);
112
+ return {
113
+ kind: "command",
114
+ command,
115
+ capability,
116
+ handler,
117
+ ...(handler ? handler.parse(rest, capability) : parseAdminInput(capability, rest)),
118
+ };
119
+ }
120
+
121
+ function parseOptions(argv, allowed, usage) {
122
+ const options = {};
123
+ const positionals = [];
124
+ for (let index = 0; index < argv.length; index += 1) {
125
+ const argument = argv[index];
126
+ if (!argument.startsWith("--")) {
127
+ positionals.push(argument);
128
+ continue;
129
+ }
130
+ const separator = argument.indexOf("=");
131
+ const name = argument.slice(2, separator === -1 ? undefined : separator);
132
+ if (!allowed.includes(name)) throw new AdminUsageError(`${usage}\nUnknown option: --${name}`);
133
+ const value = separator === -1 ? argv[index + 1] : argument.slice(separator + 1);
134
+ if (!value || (separator === -1 && value.startsWith("--"))) {
135
+ throw new AdminUsageError(`${usage}\n--${name} requires a value.`, { field: name });
136
+ }
137
+ if (options[name] !== undefined) {
138
+ throw new AdminUsageError(`${usage}\n--${name} may be supplied only once.`, { field: name });
139
+ }
140
+ options[name] = value;
141
+ if (separator === -1) index += 1;
142
+ }
143
+ return { options, positionals };
144
+ }
145
+
146
+ function positiveInteger(value, name, maximum) {
147
+ if (value === undefined) return undefined;
148
+ const parsed = Number(value);
149
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > maximum) {
150
+ throw new AdminUsageError(`--${name} must be an integer from 1 to ${maximum}.`, {
151
+ field: name,
152
+ });
153
+ }
154
+ return parsed;
155
+ }
156
+
157
+ function parseAdminInput(capability, argv) {
158
+ const command = vnextCommand(capability).join(" ");
159
+ const help = capability.cli.vnext;
160
+ const usage = `Usage: ${help.human}`;
161
+ const allowed = [...help.human.matchAll(/--([a-z0-9-]+)/g)].map((match) => match[1]);
162
+ const { options, positionals } = parseOptions(argv, allowed, usage);
163
+ const nodeCommand = (help.acceptedReferences ?? []).includes("canonical node ID");
164
+ if ((nodeCommand && positionals.length !== 1) || (!nodeCommand && positionals.length !== 0)) {
165
+ throw new AdminUsageError(usage);
166
+ }
167
+
168
+ const limit = positiveInteger(options.limit, "limit", MAX_PAGE_LIMIT) ?? DEFAULT_PAGE_LIMIT;
169
+ const input = {};
170
+ if (nodeCommand) input.node_id = positionals[0];
171
+ if (options.operator) input.operator = options.operator;
172
+ if (options.facility) input.facility_id = options.facility;
173
+ if (options.status) input.status = options.status;
174
+ if (options.email) input.email = options.email;
175
+ if (options.organization) input.organization = options.organization;
176
+ if (options.name) input.name = options.name;
177
+ if (options.span) input.span = options.span;
178
+ if (options.start) input.start = options.start;
179
+ if (options.end) input.end = options.end;
180
+ if (options.before) input.before = options.before;
181
+ if (options.stream) input.stream = options.stream;
182
+ if (options.cursor && command === "admin nodes logs") input.cursor = options.cursor;
183
+ if (command === "admin users search") input.max = 1000;
184
+ const maxPoints = positiveInteger(options["max-points"], "max-points", 500);
185
+ if (maxPoints !== undefined) input.max_points = maxPoints;
186
+ const count = positiveInteger(options.count, "count", 500);
187
+ if (count !== undefined) input.count = count;
188
+
189
+ return {
190
+ input,
191
+ pagination: {
192
+ cursor: options.cursor,
193
+ limit: command === "admin nodes logs" ? (count ?? 200) : limit,
194
+ },
195
+ };
196
+ }
197
+
198
+ function collectionKey(capability) {
199
+ const [, root, leaf] = vnextCommand(capability);
200
+ if (root === "enrollment-tokens") return "enrollment_tokens";
201
+ if (root === "nodes" && leaf === "logs") return "logs";
202
+ return ["facilities", "nodes", "operators", "reservations", "users"].includes(root) ? root : null;
203
+ }
204
+
205
+ function isSensitiveAdminField(key) {
206
+ const normalized = key.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
207
+ return (
208
+ normalized === "authorization" ||
209
+ normalized === "session" ||
210
+ normalized === "token" ||
211
+ normalized.endsWith("_session") ||
212
+ normalized.endsWith("_token") ||
213
+ normalized.includes("access_token") ||
214
+ normalized.includes("api_key") ||
215
+ normalized.includes("credential") ||
216
+ normalized.includes("password") ||
217
+ normalized.includes("plaintext") ||
218
+ normalized.includes("private_key") ||
219
+ normalized.includes("secret")
220
+ );
221
+ }
222
+
223
+ function sanitizeAdminValue(value) {
224
+ if (Array.isArray(value)) return value.map(sanitizeAdminValue);
225
+ if (!value || typeof value !== "object") return value;
226
+ const safe = {};
227
+ for (const [key, child] of Object.entries(value)) {
228
+ if (isSensitiveAdminField(key)) continue;
229
+ safe[key] = sanitizeAdminValue(child);
230
+ }
231
+ return safe;
232
+ }
233
+
234
+ function collectionRows(payload, key) {
235
+ if (Array.isArray(payload)) return payload;
236
+ if (!payload || typeof payload !== "object") return [];
237
+ for (const candidate of [key, "items", "results", "data", "entries", "events", "records"]) {
238
+ if (Array.isArray(payload[candidate])) return payload[candidate];
239
+ }
240
+ return [];
241
+ }
242
+
243
+ function encodeCursor(command, offset) {
244
+ return Buffer.from(JSON.stringify({ command, offset }), "utf8").toString("base64url");
245
+ }
246
+
247
+ function decodeCursor(command, cursor) {
248
+ if (!cursor) return 0;
249
+ try {
250
+ const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
251
+ if (decoded.command === command && Number.isInteger(decoded.offset) && decoded.offset >= 0) {
252
+ return decoded.offset;
253
+ }
254
+ if (decoded.command !== undefined || decoded.offset !== undefined) {
255
+ throw new AdminUsageError("The pagination cursor belongs to a different admin command.", {
256
+ code: "invalid_cursor",
257
+ field: "cursor",
258
+ });
259
+ }
260
+ } catch (error) {
261
+ if (error instanceof AdminUsageError) throw error;
262
+ }
263
+ return 0;
264
+ }
265
+
266
+ export function finalizeAdminResult(capability, payload, pagination) {
267
+ if (!pagination) return { data: sanitizeAdminValue(payload), page: null };
268
+ const command = vnextCommand(capability).join(" ");
269
+ const key = collectionKey(capability);
270
+ if (!key) return { data: sanitizeAdminValue(payload), page: null };
271
+ const rows = collectionRows(payload, key);
272
+ const offset = decodeCursor(command, pagination.cursor);
273
+ const selected = rows.slice(offset, offset + pagination.limit).map(sanitizeAdminValue);
274
+ const ownerNextCursor =
275
+ payload && !Array.isArray(payload)
276
+ ? (payload.next_cursor ?? payload.nextCursor ?? payload.page?.next_cursor ?? null)
277
+ : null;
278
+ const nextCursor =
279
+ offset + selected.length < rows.length
280
+ ? encodeCursor(command, offset + selected.length)
281
+ : (ownerNextCursor ?? null);
282
+ return {
283
+ data: { [key]: selected },
284
+ page: {
285
+ limit: pagination.limit,
286
+ returned: selected.length,
287
+ next_cursor: nextCursor,
288
+ },
289
+ };
290
+ }
291
+
292
+ export function writeAdminRootHuman(stdout, data) {
293
+ stdout.write("Ornn admin\n\nAuthorized resources:\n");
294
+ for (const root of data.roots) {
295
+ stdout.write(` ${root.name}\n`);
296
+ for (const command of root.commands)
297
+ stdout.write(` ${command.command} ${command.outcome}\n`);
298
+ }
299
+ }
300
+
301
+ export function writeAdminHelpHuman(stdout, capability) {
302
+ const data = adminCommandData(capability);
303
+ stdout.write(`${data.command}\n\nOutcome: ${data.outcome}\n\n`);
304
+ stdout.write(`Usage:\n ${data.human}\n\nAutomation:\n ${data.automation}\n`);
305
+ }
306
+
307
+ export function writeAdminResultHuman(stdout, command, result) {
308
+ const key = result.page ? Object.keys(result.data)[0] : null;
309
+ if (!key) {
310
+ stdout.write(`${JSON.stringify(result.data, null, 2)}\n`);
311
+ return;
312
+ }
313
+ const rows = result.data[key];
314
+ if (rows.length === 0) {
315
+ stdout.write(`No ${key.replaceAll("_", " ")} found.\n`);
316
+ } else {
317
+ for (const row of rows) stdout.write(`${JSON.stringify(row)}\n`);
318
+ }
319
+ if (result.page.next_cursor) {
320
+ stdout.write(`Next: ornn ${command} --cursor ${result.page.next_cursor}\n`);
321
+ }
322
+ }
@@ -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
  {
@@ -0,0 +1,112 @@
1
+ import { constants } from "node:fs";
2
+ import { chmod, link, lstat, open, rename, rm } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { Readable } from "node:stream";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ export class AtomicOutputExistsError extends Error {
8
+ constructor(path) {
9
+ super(`Refusing to replace existing file: ${path}`);
10
+ this.name = "AtomicOutputExistsError";
11
+ this.code = "destination_exists";
12
+ this.path = path;
13
+ }
14
+ }
15
+
16
+ async function destinationExists(path) {
17
+ try {
18
+ await lstat(path);
19
+ return true;
20
+ } catch (error) {
21
+ if (error?.code === "ENOENT") return false;
22
+ throw error;
23
+ }
24
+ }
25
+
26
+ export async function assertAtomicOutputAvailable(destination, { overwrite = false } = {}) {
27
+ if (typeof destination !== "string" || !destination.trim()) {
28
+ throw new TypeError("An output path is required.");
29
+ }
30
+ if (!overwrite && (await destinationExists(destination))) {
31
+ throw new AtomicOutputExistsError(destination);
32
+ }
33
+ }
34
+
35
+ function nodeReadable(content) {
36
+ if (content && typeof content.getReader === "function") return Readable.fromWeb(content);
37
+ if (content && typeof content.pipe === "function") return content;
38
+ return null;
39
+ }
40
+ async function writeReadable(handle, readable) {
41
+ for await (const chunk of readable) {
42
+ const bytes =
43
+ typeof chunk === "string"
44
+ ? Buffer.from(chunk)
45
+ : chunk instanceof Uint8Array || chunk instanceof DataView
46
+ ? Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
47
+ : null;
48
+ if (!bytes) throw new TypeError("Readable atomic output chunks must be bytes or text.");
49
+ let offset = 0;
50
+ while (offset < bytes.byteLength) {
51
+ const result = await handle.write(bytes, offset, bytes.byteLength - offset);
52
+ if (result.bytesWritten === 0) throw new Error("Atomic output write made no progress.");
53
+ offset += result.bytesWritten;
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Writes bytes through a unique same-directory temporary file and publishes
60
+ * them atomically. Without overwrite, hard-link publication provides an atomic
61
+ * no-replace operation; replacement uses an atomic rename.
62
+ */
63
+ export async function writeAtomicOutput(
64
+ destination,
65
+ content,
66
+ { mode = 0o644, overwrite = false } = {}
67
+ ) {
68
+ await assertAtomicOutputAvailable(destination, { overwrite });
69
+ if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
70
+ throw new TypeError("Output mode must be an integer between 0000 and 0777.");
71
+ }
72
+
73
+ const directory = dirname(destination);
74
+ const temporary = join(directory, `.${process.pid}.${randomUUID()}.ornn-partial`);
75
+ let handle;
76
+ try {
77
+ handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, mode);
78
+ const readable = nodeReadable(content);
79
+ if (readable) {
80
+ await writeReadable(handle, readable);
81
+ } else if (
82
+ typeof content === "string" ||
83
+ content instanceof Uint8Array ||
84
+ content instanceof DataView
85
+ ) {
86
+ await handle.writeFile(content);
87
+ } else {
88
+ throw new TypeError("Atomic output content must be bytes, text, or a readable stream.");
89
+ }
90
+ await handle.sync();
91
+ await handle.close();
92
+ handle = undefined;
93
+ await chmod(temporary, mode);
94
+
95
+ if (overwrite) {
96
+ await rename(temporary, destination);
97
+ } else {
98
+ try {
99
+ await link(temporary, destination);
100
+ } catch (error) {
101
+ if (error?.code === "EEXIST") throw new AtomicOutputExistsError(destination);
102
+ throw error;
103
+ }
104
+ await rm(temporary);
105
+ }
106
+ return { mode, path: destination };
107
+ } catch (error) {
108
+ await handle?.close().catch(() => {});
109
+ await rm(temporary, { force: true }).catch(() => {});
110
+ throw error;
111
+ }
112
+ }