@ornncompute/cli 0.1.0 → 0.1.2

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/src/cli.mjs CHANGED
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import { readFile } from "node:fs/promises";
3
2
 
4
3
  import {
@@ -15,57 +14,131 @@ import {
15
14
  waitForDeviceApproval,
16
15
  } from "./device-auth.mjs";
17
16
 
18
- const VERSION = "0.1.0";
17
+ const VERSION = "0.1.2";
18
+ const DEFAULT_BUY_NOW_USD_PER_GPU_HOUR = 10;
19
19
 
20
- const HELP = `Ornn Fabric CLI
20
+ function resolveBuyNowUsdPerGpuHour(record) {
21
+ const stored = record?.buy_now_price_per_gpu_hour;
22
+ if (stored != null && Number.isFinite(stored) && stored > 0) {
23
+ return stored;
24
+ }
25
+ const normalized = String(record?.gpu_type ?? "").toLowerCase();
26
+ if (normalized.includes("h200")) {
27
+ return 2.49;
28
+ }
29
+ if (normalized.includes("h100")) {
30
+ return 2.29;
31
+ }
32
+ if (normalized.includes("a100")) {
33
+ return 1.89;
34
+ }
35
+ if (normalized) {
36
+ return 1.75;
37
+ }
38
+ return DEFAULT_BUY_NOW_USD_PER_GPU_HOUR;
39
+ }
40
+
41
+ const CONCISE_HELP = `Ornn CLI
42
+
43
+ Find, buy, access, and resell bare-metal compute.
21
44
 
22
45
  Usage:
23
- fabric login [--auth-base <url>] [--no-browser] [--timeout <seconds>]
24
- fabric whoami [--json]
25
- fabric status [--json]
26
- fabric marketplace listings [--json]
27
- fabric reserve view [--gpu-type <type>] [--facility <name>] [--view week|month]
28
- fabric reserve create --deployment-id <id> --gpu-count <n> --start-date <yyyy-mm-dd> --end-date <yyyy-mm-dd> --price <usd>
29
- fabric portfolio reservations [--status <status>] [--json]
30
- fabric portfolio bids [--json]
31
- fabric portfolio bid update <bid-id> --gpu-count <n> --min-gpu-count <n> --start-date <yyyy-mm-dd> --end-date <yyyy-mm-dd> --price <usd>
32
- fabric portfolio bid withdraw <bid-id>
33
- fabric portfolio reservation access-mode <reservation-id> --mode vm|bare-metal [--image-id <id>]
34
- fabric portfolio reservation list <reservation-id> --ask-price <usd>
35
- fabric portfolio reservation ask-price <reservation-id> --ask-price <usd>
36
- fabric portfolio reservation delist <reservation-id>
37
- fabric portfolio reservation buy <reservation-id>
38
- fabric machines list <reservation-id> [--json]
39
- fabric machines get <instance-id> [--json]
40
- fabric machines cost <reservation-id> [--json]
41
- fabric machines launch <reservation-id> --ssh-key-id <id> [--username <name>] [--machine-count <n>]
42
- fabric machines switch-access <reservation-id> --mode vm|bare-metal [--ssh-key-id <id>] [--username <name>] [--image-id <id>]
43
- fabric machines action <instance-id> start|stop|extend|teardown [--extend-by-minutes <n>]
44
- fabric machines push-keys <instance-id> --ssh-key-id <id>
45
- fabric machines revoke <instance-id>
46
- fabric ssh-keys list [--json]
47
- fabric ssh-keys add --public-key <key> [--label <label>]
48
- fabric ssh-keys delete <key-id>
49
- fabric images list [--json]
50
- fabric images upload --name <name> --os-family <os> --cosign-signature <sig> (--image-file <path>|--registry-ref <ref>) [--sha256 <hex>] [--size-bytes <n>]
51
- fabric images revoke <image-id>
52
- fabric api <get|post|patch|put|delete> <compute-path> [--data <json>] [--raw]
53
- fabric logout
46
+ ornn <command> [options]
47
+
48
+ Examples:
49
+ ornn availability list
50
+ ornn buy <listing-id>
51
+ ornn access activate <reservation-id> --ssh-key-id <key-id>
52
+
53
+ Run \`ornn --help\` for all commands, or \`ornn help <command>\`.
54
+ `;
55
+
56
+ const HELP = `Ornn CLI
57
+
58
+ Usage:
59
+ ornn help [command]
60
+ ornn login [--auth-base <url>] [--no-browser] [--timeout <seconds>]
61
+ ornn whoami [--json]
62
+ ornn status [--json]
63
+ ornn availability list [--gpu-type <type>] [--facility <name>] [--operator <name>] [--json]
64
+ ornn availability show <listing-id> [--open] [--json]
65
+ ornn buy <listing-id> [--no-open] [--json]
66
+ ornn bid create <listing-id> --gpu-count <n> --min-gpu-count <n> --start-date <yyyy-mm-dd> --end-date <yyyy-mm-dd> --price <usd> [--no-open] [--json]
67
+ ornn bid list [--json]
68
+ ornn bid show <bid-id> [--open] [--json]
69
+ ornn bid update <bid-id> --gpu-count <n> --min-gpu-count <n> --start-date <yyyy-mm-dd> --end-date <yyyy-mm-dd> --price <usd>
70
+ ornn bid withdraw <bid-id>
71
+ ornn reservations list [--status <status>] [--json]
72
+ ornn reservations show <reservation-id> [--open] [--json]
73
+ ornn reservations checkout <reservation-id> [--no-open] [--json]
74
+ ornn access show <reservation-id> [--json]
75
+ ornn access activate <reservation-id> --ssh-key-id <id> [--username <name>] [--no-open] [--json]
76
+ ornn access push-keys <reservation-id> --ssh-key-id <id> [--json]
77
+ ornn access keys list <reservation-id> [--json]
78
+ ornn access keys add <reservation-id> --public-key <key> [--label <label>] [--json]
79
+ ornn access keys add <reservation-id> --public-key-file <path> [--label <label>] [--json]
80
+ ornn access keys push <reservation-id> --ssh-key-id <id> [--json]
81
+ ornn access keys status <reservation-id> [--json]
82
+ ornn resale browse [--json]
83
+ ornn resale list <reservation-id> --ask-price <usd> [--no-open] [--json]
84
+ ornn resale update <reservation-id> --ask-price <usd> [--no-open] [--json]
85
+ ornn resale delist <reservation-id> [--no-open] [--json]
86
+ ornn ssh-keys list [--json]
87
+ ornn ssh-keys add --public-key <key> [--label <label>] [--json]
88
+ ornn ssh-keys add --public-key-file <path> [--label <label>] [--json]
89
+ ornn ssh-keys delete <key-id> [--json]
90
+ ornn billing open [--no-open] [--json]
91
+ ornn api <get|post|patch|put|delete> <compute-path> [--data <json>] [--raw]
92
+ ornn logout
54
93
 
55
94
  Environment:
56
95
  ORNN_AUTH_BASE_URL Web/auth server origin. Defaults to http://localhost:3000.
57
96
  ORNN_API_BASE_URL Optional web API origin. Defaults to ORNN_AUTH_BASE_URL.
58
97
  ORNN_CONFIG_HOME Directory for CLI auth state. Defaults to ~/.config/ornn.
98
+
99
+ Output:
100
+ Commands print human-readable output by default.
101
+ Use --json for structured output suitable for scripts and jq.
102
+ Errors and login/browser progress are written to stderr.
59
103
  `;
60
104
 
105
+ const HELP_TOPICS = new Set([
106
+ "access",
107
+ "account",
108
+ "api",
109
+ "availability",
110
+ "bid",
111
+ "bids",
112
+ "billing",
113
+ "buy",
114
+ "help",
115
+ "login",
116
+ "logout",
117
+ "reservations",
118
+ "resale",
119
+ "ssh-keys",
120
+ "status",
121
+ "whoami",
122
+ ]);
123
+
61
124
  export async function run(argv = [], io = {}) {
62
125
  const stdout = io.stdout ?? process.stdout;
63
126
  const stderr = io.stderr ?? process.stderr;
64
127
  const env = io.env ?? process.env;
65
128
  const fetchImpl = io.fetch ?? fetch;
129
+ const openBrowserImpl = io.openBrowser ?? openBrowser;
66
130
  const [command, ...args] = argv;
67
131
 
68
- if (!command || command === "--help" || command === "-h") {
132
+ if (!command) {
133
+ stdout.write(CONCISE_HELP);
134
+ return 0;
135
+ }
136
+
137
+ if (command === "--help" || command === "-h") {
138
+ if (args.length) {
139
+ stderr.write(HELP);
140
+ return 1;
141
+ }
69
142
  stdout.write(HELP);
70
143
  return 0;
71
144
  }
@@ -76,8 +149,21 @@ export async function run(argv = [], io = {}) {
76
149
  }
77
150
 
78
151
  try {
152
+ if (args.includes("--help") || args.includes("-h")) {
153
+ const helpError = validateHelpInvocation(command, args.filter((arg) => arg !== "--help" && arg !== "-h"));
154
+ if (helpError) {
155
+ throw new Error(helpError);
156
+ }
157
+ stdout.write(HELP);
158
+ return 0;
159
+ }
160
+
161
+ if (command === "help") {
162
+ return await help(args, { stdout });
163
+ }
164
+
79
165
  if (command === "login") {
80
- return await login(args, { env, stderr, stdout });
166
+ return await login(args, { env, fetchImpl, openBrowserImpl, stderr, stdout });
81
167
  }
82
168
 
83
169
  if (command === "whoami" || command === "account") {
@@ -92,31 +178,40 @@ export async function run(argv = [], io = {}) {
92
178
  return await api(args, { env, fetchImpl, stdout });
93
179
  }
94
180
 
95
- if (command === "marketplace") {
96
- return await marketplace(args, { env, fetchImpl, stdout });
181
+ if (command === "availability") {
182
+ return await availability(args, { env, fetchImpl, openBrowserImpl, stdout });
97
183
  }
98
184
 
99
- if (command === "reserve") {
100
- return await reserve(args, { env, fetchImpl, stdout });
185
+ if (command === "buy") {
186
+ return await buy(args, { env, fetchImpl, openBrowserImpl, stdout });
101
187
  }
102
188
 
103
- if (command === "portfolio") {
104
- return await portfolio(args, { env, fetchImpl, stdout });
189
+ if (command === "bid" || command === "bids") {
190
+ return await bid(args, { env, fetchImpl, openBrowserImpl, stdout });
105
191
  }
106
192
 
107
- if (command === "machines") {
108
- return await machines(args, { env, fetchImpl, stdout });
193
+ if (command === "reservations") {
194
+ return await reservations(args, { env, fetchImpl, openBrowserImpl, stdout });
109
195
  }
110
196
 
111
- if (command === "ssh-keys") {
112
- return await sshKeys(args, { env, fetchImpl, stdout });
197
+ if (command === "access") {
198
+ return await access(args, { env, fetchImpl, openBrowserImpl, stdout });
199
+ }
200
+
201
+ if (command === "resale") {
202
+ return await resale(args, { env, fetchImpl, openBrowserImpl, stdout });
203
+ }
204
+
205
+ if (command === "billing") {
206
+ return await billing(args, { env, fetchImpl, openBrowserImpl, stdout });
113
207
  }
114
208
 
115
- if (command === "images") {
116
- return await images(args, { env, fetchImpl, stdout });
209
+ if (command === "ssh-keys") {
210
+ return await sshKeys(args, { env, fetchImpl, stdout });
117
211
  }
118
212
 
119
213
  if (command === "logout") {
214
+ parseCommandOptions(args, {}, "Usage: ornn logout");
120
215
  await clearAuthSession({ env });
121
216
  stdout.write("Logged out of Ornn.\n");
122
217
  return 0;
@@ -125,13 +220,223 @@ export async function run(argv = [], io = {}) {
125
220
  stderr.write(`Unknown command: ${command}\n\n${HELP}`);
126
221
  return 1;
127
222
  } catch (error) {
128
- const message = error instanceof Error ? error.message : String(error);
223
+ const message = formatCliError(error);
129
224
  stderr.write(`${message}\n`);
130
225
  return error instanceof DeviceAuthError || error instanceof CliApiError ? 2 : 1;
131
226
  }
132
227
  }
133
228
 
134
- async function login(args, { env, stderr, stdout }) {
229
+ function validateHelpInvocation(command, args) {
230
+ if (command === "help") {
231
+ const [topic, ...extra] = args;
232
+ if (extra.length) {
233
+ return "Usage: ornn help [command]";
234
+ }
235
+ return topic && !HELP_TOPICS.has(topic) ? `Unknown command: ${topic}` : null;
236
+ }
237
+
238
+ if (command === "login") {
239
+ const parsed = parseHelpArgs(args, { booleanOptions: ["--no-browser"], valueOptions: ["--auth-base", "--poll-interval", "--timeout"] });
240
+ return parsed.error || (parsed.positionals.length ? "Usage: ornn login [--auth-base <url>] [--no-browser] [--timeout <seconds>]" : null);
241
+ }
242
+
243
+ if (command === "logout") {
244
+ return validateNoPositionals(args, "Usage: ornn logout");
245
+ }
246
+
247
+ if (command === "whoami" || command === "account") {
248
+ return validateNoPositionals(args, "Usage: ornn whoami [--json]", { booleanOptions: ["--json"] });
249
+ }
250
+
251
+ if (command === "status") {
252
+ return validateNoPositionals(args, "Usage: ornn status [--json]", { booleanOptions: ["--json"] });
253
+ }
254
+
255
+ if (command === "api") {
256
+ const parsed = parseHelpArgs(args, { booleanOptions: ["--json", "--raw"], valueOptions: ["--data"] });
257
+ if (parsed.error) {
258
+ return parsed.error;
259
+ }
260
+ if (!parsed.positionals.length) {
261
+ return null;
262
+ }
263
+ const [method, path, ...extra] = parsed.positionals;
264
+ if (!path || extra.length) {
265
+ return "Usage: ornn api <get|post|patch|put|delete> <compute-path> [--data <json>] [--raw]";
266
+ }
267
+ return ["delete", "get", "patch", "post", "put"].includes(method.toLowerCase()) ? null : `Unsupported API method: ${method}`;
268
+ }
269
+
270
+ if (command === "availability") {
271
+ const parsed = parseHelpArgs(args, { booleanOptions: ["--json", "--open"], valueOptions: ["--facility", "--gpu-type", "--operator"] });
272
+ if (parsed.error) {
273
+ return parsed.error;
274
+ }
275
+ const [subcommand, id, ...extra] = parsed.positionals;
276
+ if (!subcommand || subcommand === "list") {
277
+ return id || extra.length ? "Usage: ornn availability list [--gpu-type <type>] [--facility <name>] [--operator <name>] [--json]" : null;
278
+ }
279
+ if (subcommand === "show") {
280
+ return !id || extra.length ? "Usage: ornn availability show <listing-id> [--open] [--json]" : null;
281
+ }
282
+ return "Usage: ornn availability list|show";
283
+ }
284
+
285
+ if (command === "buy") {
286
+ const parsed = parseHelpArgs(args, { booleanOptions: ["--json", "--no-open"] });
287
+ if (parsed.error) {
288
+ return parsed.error;
289
+ }
290
+ return parsed.positionals.length === 1 ? null : "Usage: ornn buy <listing-id> [--no-open] [--json]";
291
+ }
292
+
293
+ if (command === "bid" || command === "bids") {
294
+ const parsed = parseHelpArgs(args, { booleanOptions: ["--json", "--no-open", "--open"], valueOptions: ["--end-date", "--gpu-count", "--min-gpu-count", "--price", "--start-date"] });
295
+ if (parsed.error) {
296
+ return parsed.error;
297
+ }
298
+ const [subcommand, id, ...extra] = parsed.positionals;
299
+ if (!subcommand || subcommand === "list") {
300
+ return id || extra.length ? "Usage: ornn bid list|show|create|update|withdraw" : null;
301
+ }
302
+ if (["create", "show", "update", "withdraw", "delete"].includes(subcommand)) {
303
+ return !id || extra.length ? "Usage: ornn bid list|show|create|update|withdraw" : null;
304
+ }
305
+ return "Usage: ornn bid list|show|create|update|withdraw";
306
+ }
307
+
308
+ if (command === "reservations") {
309
+ const parsed = parseHelpArgs(args, { booleanOptions: ["--json", "--no-open", "--open"], valueOptions: ["--status"] });
310
+ if (parsed.error) {
311
+ return parsed.error;
312
+ }
313
+ const [subcommand, id, ...extra] = parsed.positionals;
314
+ if (!subcommand || subcommand === "list") {
315
+ return id || extra.length ? "Usage: ornn reservations list|show|checkout" : null;
316
+ }
317
+ if (["show", "checkout"].includes(subcommand)) {
318
+ return !id || extra.length ? "Usage: ornn reservations list|show|checkout" : null;
319
+ }
320
+ return "Usage: ornn reservations list|show|checkout";
321
+ }
322
+
323
+ if (command === "access") {
324
+ const parsed = parseHelpArgs(args, { booleanOptions: ["--json", "--no-open"], valueOptions: ["--label", "--machine-count", "--public-key", "--public-key-file", "--request-id", "--ssh-key-id", "--tenant-username", "--username"] });
325
+ if (parsed.error) {
326
+ return parsed.error;
327
+ }
328
+ const [subcommand, id, nestedId, ...extra] = parsed.positionals;
329
+ if (subcommand === "keys") {
330
+ if (["list", "add", "push", "status"].includes(id)) {
331
+ return !nestedId || extra.length ? "Usage: ornn access keys list|add|push|status <reservation-id>" : null;
332
+ }
333
+ return !id ? null : "Usage: ornn access keys list|add|push|status <reservation-id>";
334
+ }
335
+ if (["show", "activate", "push-keys"].includes(subcommand)) {
336
+ return !id || nestedId || extra.length ? "Usage: ornn access show|activate|push-keys <reservation-id>" : null;
337
+ }
338
+ return !subcommand ? null : "Usage: ornn access show|activate|push-keys|keys <reservation-id>";
339
+ }
340
+
341
+ if (command === "resale") {
342
+ const parsed = parseHelpArgs(args, { booleanOptions: ["--json", "--no-open"], valueOptions: ["--ask-price"] });
343
+ if (parsed.error) {
344
+ return parsed.error;
345
+ }
346
+ const [subcommand, id, ...extra] = parsed.positionals;
347
+ if (!subcommand || subcommand === "browse") {
348
+ return id || extra.length ? "Usage: ornn resale browse|list|update|delist" : null;
349
+ }
350
+ if (["list", "update", "delist"].includes(subcommand)) {
351
+ return !id || extra.length ? "Usage: ornn resale browse|list|update|delist" : null;
352
+ }
353
+ return "Usage: ornn resale browse|list|update|delist";
354
+ }
355
+
356
+ if (command === "billing") {
357
+ const parsed = parseHelpArgs(args, { booleanOptions: ["--json", "--no-open"] });
358
+ if (parsed.error) {
359
+ return parsed.error;
360
+ }
361
+ const [subcommand, ...extra] = parsed.positionals;
362
+ if (!subcommand || subcommand === "open") {
363
+ return extra.length ? "Usage: ornn billing open [--no-open] [--json]" : null;
364
+ }
365
+ return "Usage: ornn billing open [--no-open] [--json]";
366
+ }
367
+
368
+ if (command === "ssh-keys") {
369
+ const parsed = parseHelpArgs(args, { booleanOptions: ["--json"], valueOptions: ["--label", "--public-key", "--public-key-file"] });
370
+ if (parsed.error) {
371
+ return parsed.error;
372
+ }
373
+ const [subcommand, id, ...extra] = parsed.positionals;
374
+ if (!subcommand || subcommand === "list") {
375
+ return id || extra.length ? "Usage: ornn ssh-keys list|add|delete" : null;
376
+ }
377
+ if (subcommand === "add") {
378
+ return id || extra.length ? "Usage: ornn ssh-keys list|add|delete" : null;
379
+ }
380
+ if (["delete", "remove"].includes(subcommand)) {
381
+ return extra.length ? "Usage: ornn ssh-keys list|add|delete" : null;
382
+ }
383
+ return "Usage: ornn ssh-keys list|add|delete";
384
+ }
385
+
386
+ return `Unknown command: ${command}`;
387
+ }
388
+
389
+ async function help(args, { stdout }) {
390
+ const [topic, ...extra] = args;
391
+ if (extra.length) {
392
+ throw new Error("Usage: ornn help [command]");
393
+ }
394
+ if (topic && !HELP_TOPICS.has(topic)) {
395
+ throw new Error(`Unknown command: ${topic}`);
396
+ }
397
+ stdout.write(HELP);
398
+ return 0;
399
+ }
400
+
401
+ function validateNoPositionals(args, usage, optionSpec = {}) {
402
+ const parsed = parseHelpArgs(args, optionSpec);
403
+ return parsed.error || (parsed.positionals.length ? usage : null);
404
+ }
405
+
406
+ function parseHelpArgs(args, { booleanOptions = [], valueOptions = [] } = {}) {
407
+ const booleanSet = new Set(booleanOptions);
408
+ const valueSet = new Set(valueOptions);
409
+ const positionals = [];
410
+
411
+ for (let index = 0; index < args.length; index += 1) {
412
+ const arg = args[index];
413
+ if (!arg?.startsWith("--")) {
414
+ positionals.push(arg);
415
+ continue;
416
+ }
417
+
418
+ const [option, inlineValue] = splitOption(arg);
419
+ if (booleanSet.has(option)) {
420
+ continue;
421
+ }
422
+ if (valueSet.has(option)) {
423
+ if (inlineValue !== null) {
424
+ continue;
425
+ }
426
+ const value = args[index + 1];
427
+ if (!value || value.startsWith("-")) {
428
+ return { error: `Missing value for ${option}.`, positionals };
429
+ }
430
+ index += 1;
431
+ continue;
432
+ }
433
+ return { error: `Unsupported option: ${option}.`, positionals };
434
+ }
435
+
436
+ return { error: null, positionals };
437
+ }
438
+
439
+ async function login(args, { env, fetchImpl, openBrowserImpl, stderr, stdout }) {
135
440
  const options = parseLoginOptions(args);
136
441
 
137
442
  if (options.help) {
@@ -140,41 +445,42 @@ async function login(args, { env, stderr, stdout }) {
140
445
  }
141
446
 
142
447
  const authBaseUrl = resolveAuthBaseUrl({ env, explicit: options.authBase });
143
- stdout.write(`Starting Ornn browser login against ${authBaseUrl}\n`);
448
+ stderr.write(`Starting Ornn browser login against ${authBaseUrl}\n`);
144
449
 
145
- const started = await startDeviceFlow({ authBaseUrl });
146
- stdout.write(`\nOpen this URL to continue:\n${started.verificationUri}\n\n`);
147
- stdout.write(`Code: ${started.userCode}\n\n`);
450
+ const started = await startDeviceFlow({ authBaseUrl, fetchImpl });
451
+ stderr.write(`\nOpen this URL to continue:\n${started.verificationUri}\n\n`);
452
+ stderr.write(`Code: ${started.userCode}\n\n`);
148
453
 
149
454
  if (!options.noBrowser) {
150
- const opened = await openBrowser(started.verificationUri);
455
+ const opened = await openBrowserImpl(started.verificationUri);
151
456
  if (!opened) {
152
457
  stderr.write("Could not open the browser automatically. Open the URL above manually.\n");
153
458
  }
154
459
  }
155
460
 
156
- stdout.write("Waiting for browser approval");
461
+ stderr.write("Waiting for browser approval");
157
462
  const session = await waitForDeviceApproval({
158
463
  authBaseUrl,
159
464
  deviceCode: started.deviceCode,
160
465
  expiresIn: started.expiresIn,
466
+ fetchImpl,
161
467
  interval: options.pollInterval ?? started.interval,
162
- onPending: () => stdout.write("."),
468
+ onPending: () => stderr.write("."),
163
469
  timeoutSeconds: options.timeout,
164
470
  });
165
471
 
166
472
  await saveAuthSession(session, { env });
167
- stdout.write(`\nLogged in as ${formatUser(session.user)}.\n`);
168
- stdout.write(`Session saved to ${getAuthConfigPath(env)}\n`);
473
+ stderr.write(`\nLogged in as ${formatUser(session.user)}.\n`);
474
+ stderr.write(`Session saved to ${getAuthConfigPath(env)}\n`);
169
475
  return 0;
170
476
  }
171
477
 
172
478
  async function whoami(args, { env, fetchImpl, stderr, stdout }) {
173
- const { options } = parseOptions(args, { boolean: ["json"] });
479
+ const options = parseCommandOptions(args, { boolean: ["json"] }, "Usage: ornn whoami [--json]");
174
480
  const session = await loadAuthSession({ env });
175
481
 
176
482
  if (!session) {
177
- stderr.write("Not logged in. Run `fabric login` first.\n");
483
+ stderr.write("Not logged in. Run `ornn login` first.\n");
178
484
  return 1;
179
485
  }
180
486
 
@@ -210,7 +516,7 @@ async function whoami(args, { env, fetchImpl, stderr, stdout }) {
210
516
  }
211
517
 
212
518
  async function status(args, { env, fetchImpl, stdout }) {
213
- const { options } = parseOptions(args, { boolean: ["json"] });
519
+ const options = parseCommandOptions(args, { boolean: ["json"] }, "Usage: ornn status [--json]");
214
520
  const snapshot = await cliRequest({
215
521
  authRequired: false,
216
522
  endpoint: "/api/cli/status",
@@ -233,13 +539,17 @@ async function status(args, { env, fetchImpl, stdout }) {
233
539
  async function api(args, { env, fetchImpl, stdout }) {
234
540
  const [method, path, ...rest] = args;
235
541
  if (!method || !path) {
236
- throw new Error("Usage: fabric api <get|post|patch|put|delete> <compute-path> [--data <json>] [--raw]");
542
+ throw new Error("Usage: ornn api <get|post|patch|put|delete> <compute-path> [--data <json>] [--raw]");
237
543
  }
238
544
  const normalizedMethod = method.toUpperCase();
239
545
  if (!["DELETE", "GET", "PATCH", "POST", "PUT"].includes(normalizedMethod)) {
240
546
  throw new Error(`Unsupported API method: ${method}`);
241
547
  }
242
- const { options } = parseOptions(rest, { boolean: ["raw", "json"] });
548
+ const options = parseCommandOptions(
549
+ rest,
550
+ { boolean: ["raw", "json"], value: ["data"] },
551
+ "Usage: ornn api <get|post|patch|put|delete> <compute-path> [--data <json>] [--raw]",
552
+ );
243
553
  const body = await readJsonOption(options.data);
244
554
  const response = await cliRequest({
245
555
  body,
@@ -253,366 +563,601 @@ async function api(args, { env, fetchImpl, stdout }) {
253
563
  return 0;
254
564
  }
255
565
 
256
- async function marketplace(args, context) {
257
- const [subcommand, ...rest] = args;
258
- if (subcommand !== "listings") {
259
- throw new Error("Usage: fabric marketplace listings [--json]");
566
+ async function availability(args, context) {
567
+ const [subcommand = "list", id, ...rest] = args;
568
+ if (subcommand === "list") {
569
+ const options = parseCommandOptions(
570
+ [id, ...rest].filter(Boolean),
571
+ { boolean: ["json"], value: ["facility", "gpu-type", "operator"] },
572
+ "Usage: ornn availability list [--gpu-type <type>] [--facility <name>] [--operator <name>] [--json]",
573
+ );
574
+ const listings = await loadAvailabilityListings(context);
575
+ const filtered = filterAvailabilityListings(listings, options);
576
+ if (options.json) {
577
+ writeJson(context.stdout, filtered);
578
+ } else {
579
+ writeAvailabilityList(context.stdout, filtered);
580
+ }
581
+ return 0;
260
582
  }
261
- const { options } = parseOptions(rest, { boolean: ["json"] });
262
- const listings = await cliRequest({
263
- authRequired: false,
264
- endpoint: computeEndpoint("/marketplace/resale-listings"),
265
- env: context.env,
266
- fetchImpl: context.fetchImpl,
583
+
584
+ if (subcommand === "show" && id) {
585
+ const options = parseCommandOptions(
586
+ rest,
587
+ { boolean: ["json", "open"] },
588
+ "Usage: ornn availability show <listing-id> [--open] [--json]",
589
+ );
590
+ const listing = await resolveListing(id, context);
591
+ if (options.json) {
592
+ writeJson(context.stdout, listing);
593
+ } else {
594
+ writeAvailabilityDetail(context.stdout, listing);
595
+ }
596
+ if (options.open) {
597
+ await openFabricPage(context, marketplacePathForListing(listing), {
598
+ json: false,
599
+ label: "listing",
600
+ noOpen: false,
601
+ });
602
+ }
603
+ return 0;
604
+ }
605
+
606
+ throw new Error("Usage: ornn availability list|show");
607
+ }
608
+
609
+ async function buy(args, context) {
610
+ const [listingId, ...rest] = args;
611
+ if (!listingId) {
612
+ throw new Error("Usage: ornn buy <listing-id> [--no-open] [--json]");
613
+ }
614
+ const options = parseCommandOptions(
615
+ rest,
616
+ { boolean: ["json", "no-open"] },
617
+ "Usage: ornn buy <listing-id> [--no-open] [--json]",
618
+ );
619
+ const listing = await resolveListing(listingId, context);
620
+
621
+ const checkout = await openFabricPage(context, `/checkout?inventory=${encodeURIComponent(listing.id)}`, {
622
+ json: options.json,
623
+ label: "checkout",
624
+ noOpen: options.noOpen,
625
+ payload: { listing },
267
626
  });
268
- writeOutput(context.stdout, listings, { json: options.json });
627
+ if (!options.json) {
628
+ writeCheckoutOpenResult(context.stdout, checkout, listing.kind === "resale" ? "Resale checkout" : "Checkout");
629
+ }
269
630
  return 0;
270
631
  }
271
632
 
272
- async function reserve(args, context) {
273
- const [subcommand, ...rest] = args;
274
- if (subcommand === "view") {
275
- const { options } = parseOptions(rest, { boolean: ["json"] });
276
- const query = buildQuery({
277
- anchor_date: options.anchorDate,
278
- facility: options.facility,
279
- gpu_type: options.gpuType,
280
- view: options.view,
281
- });
282
- const payload = await cliRequest({
283
- authRequired: false,
284
- endpoint: computeEndpoint(`/reserve/view${query}`),
633
+ async function bid(args, context) {
634
+ const [subcommand = "list", id, ...rest] = args;
635
+
636
+ if (subcommand === "list") {
637
+ const options = parseCommandOptions(
638
+ [id, ...rest].filter(Boolean),
639
+ { boolean: ["json"] },
640
+ "Usage: ornn bid list [--json]",
641
+ );
642
+ const bids = await cliRequest({
643
+ endpoint: computeEndpoint("/tenants/me/bids"),
285
644
  env: context.env,
286
645
  fetchImpl: context.fetchImpl,
287
646
  });
288
- writeOutput(context.stdout, payload, { json: options.json });
647
+ if (options.json) {
648
+ writeJson(context.stdout, bids);
649
+ } else {
650
+ writeBidList(context.stdout, requireArrayPayload(bids, "bids"));
651
+ }
652
+ return 0;
653
+ }
654
+
655
+ if (subcommand === "show" && id) {
656
+ const options = parseCommandOptions(
657
+ rest,
658
+ { boolean: ["json", "open"] },
659
+ "Usage: ornn bid show <bid-id> [--open] [--json]",
660
+ );
661
+ const found = await findBid(id, context);
662
+ if (options.json) {
663
+ writeJson(context.stdout, found);
664
+ } else {
665
+ writeBidDetail(context.stdout, found);
666
+ }
667
+ if (options.open) {
668
+ await openFabricPage(context, `/portfolio/bids/${encodeURIComponent(found.id)}`, {
669
+ label: "bid",
670
+ noOpen: false,
671
+ });
672
+ }
289
673
  return 0;
290
674
  }
291
675
 
292
- if (subcommand === "create") {
293
- const { options } = parseOptions(rest);
294
- const gpuCount = numberOption(options.gpuCount, "--gpu-count");
295
- const minGpuCount = options.minGpuCount ? numberOption(options.minGpuCount, "--min-gpu-count") : gpuCount;
676
+ if (subcommand === "create" && id) {
677
+ const options = parseCommandOptions(
678
+ rest,
679
+ {
680
+ boolean: ["json", "no-open"],
681
+ value: ["bid-price-per-gpu-hour", "end-date", "gpu-count", "min-gpu-count", "price", "start-date"],
682
+ },
683
+ "Usage: ornn bid create <listing-id> --gpu-count <n> --min-gpu-count <n> --start-date <yyyy-mm-dd> --end-date <yyyy-mm-dd> --price <usd> [--no-open] [--json]",
684
+ );
685
+ if (id.startsWith("resale-")) {
686
+ throw new Error("Bids can only be placed on primary availability listings, not resale listings.");
687
+ }
688
+ const { endDate, startDate } = dateRangeOptions(options);
689
+ const gpuCount = positiveIntegerOption(options.gpuCount, "--gpu-count");
690
+ const minGpuCount = optionProvided(options.minGpuCount) ? positiveIntegerOption(options.minGpuCount, "--min-gpu-count") : gpuCount;
691
+ validateMinGpuCount(minGpuCount, gpuCount);
296
692
  const payload = {
297
- access_mode: options.accessMode || "vm",
298
- await_down_payment: true,
299
- deployment_id: requiredOption(options.deploymentId, "--deployment-id"),
300
- end_date: requiredOption(options.endDate, "--end-date"),
693
+ bid_price_per_gpu_hour: bidPriceOption(options),
694
+ deployment_id: id,
695
+ end_date: endDate,
301
696
  gpu_count: gpuCount,
302
- image_id: options.imageId || null,
303
697
  min_gpu_count: minGpuCount,
304
- price_per_gpu_hour: numberOption(options.price || options.bidPricePerGpuHour, "--price"),
305
- start_date: requiredOption(options.startDate, "--start-date"),
698
+ start_date: startDate,
306
699
  };
307
- const reservation = await cliRequest({
700
+ await fetchInventory(id, context);
701
+ const created = await cliRequest({
308
702
  body: payload,
309
- endpoint: computeEndpoint("/reservations"),
703
+ endpoint: computeEndpoint("/tenants/me/bids"),
310
704
  env: context.env,
311
705
  fetchImpl: context.fetchImpl,
312
706
  method: "POST",
313
707
  });
314
- writeOutput(context.stdout, reservation);
708
+ const opened = await openFabricPage(context, `/checkout?bid=${encodeURIComponent(created.id)}`, {
709
+ label: "bid checkout",
710
+ noOpen: options.noOpen,
711
+ });
712
+ if (options.json) {
713
+ writeJson(context.stdout, { bid: created, opened: opened.opened, url: opened.url });
714
+ } else {
715
+ context.stdout.write("Bid created\n");
716
+ context.stdout.write(`Bid: ${created.id}\n`);
717
+ writeCheckoutOpenResult(context.stdout, opened, "Bid checkout");
718
+ }
315
719
  return 0;
316
720
  }
317
721
 
318
- throw new Error("Usage: fabric reserve view|create");
319
- }
320
-
321
- async function portfolio(args, context) {
322
- const [area, subcommand, ...rest] = args;
323
-
324
- if (area === "reservations") {
325
- const { options } = parseOptions([subcommand, ...rest].filter(Boolean), { boolean: ["json"] });
326
- const query = buildQuery({ status: options.status });
327
- const reservations = await cliRequest({
328
- endpoint: computeEndpoint(`/tenants/me/reservations${query}`),
722
+ if (subcommand === "update" && id) {
723
+ const options = parseCommandOptions(
724
+ rest,
725
+ {
726
+ boolean: ["json"],
727
+ value: ["bid-price-per-gpu-hour", "end-date", "gpu-count", "min-gpu-count", "price", "start-date"],
728
+ },
729
+ "Usage: ornn bid update <bid-id> --gpu-count <n> --min-gpu-count <n> --start-date <yyyy-mm-dd> --end-date <yyyy-mm-dd> --price <usd>",
730
+ );
731
+ const { endDate, startDate } = dateRangeOptions(options);
732
+ const gpuCount = positiveIntegerOption(options.gpuCount, "--gpu-count");
733
+ const minGpuCount = positiveIntegerOption(options.minGpuCount, "--min-gpu-count");
734
+ validateMinGpuCount(minGpuCount, gpuCount);
735
+ const payload = {
736
+ bid_price_per_gpu_hour: bidPriceOption(options),
737
+ end_date: endDate,
738
+ gpu_count: gpuCount,
739
+ min_gpu_count: minGpuCount,
740
+ start_date: startDate,
741
+ };
742
+ const updated = await cliRequest({
743
+ body: payload,
744
+ endpoint: computeEndpoint(`/tenants/me/bids/${id}`),
329
745
  env: context.env,
330
746
  fetchImpl: context.fetchImpl,
747
+ method: "PATCH",
331
748
  });
332
- writeOutput(context.stdout, reservations, { json: options.json });
749
+ if (options.json) {
750
+ writeJson(context.stdout, updated);
751
+ } else {
752
+ context.stdout.write("Bid updated.\n");
753
+ writeBidDetail(context.stdout, updated);
754
+ }
333
755
  return 0;
334
756
  }
335
757
 
336
- if (area === "bids") {
337
- const { options } = parseOptions([subcommand, ...rest].filter(Boolean), { boolean: ["json"] });
338
- const bids = await cliRequest({
339
- endpoint: computeEndpoint("/tenants/me/bids"),
758
+ if ((subcommand === "withdraw" || subcommand === "delete") && id) {
759
+ const options = parseCommandOptions(
760
+ rest,
761
+ { boolean: ["json"] },
762
+ "Usage: ornn bid withdraw <bid-id> [--json]",
763
+ );
764
+ const response = await cliRequest({
765
+ endpoint: computeEndpoint(`/tenants/me/bids/${id}`),
340
766
  env: context.env,
341
767
  fetchImpl: context.fetchImpl,
768
+ method: "DELETE",
342
769
  });
343
- writeOutput(context.stdout, bids, { json: options.json });
770
+ if (options.json) {
771
+ writeJson(context.stdout, response ?? { ok: true });
772
+ } else {
773
+ context.stdout.write(`Bid withdrawn: ${id}\n`);
774
+ }
344
775
  return 0;
345
776
  }
346
777
 
347
- if (area === "bid") {
348
- return portfolioBid(subcommand, rest, context);
349
- }
350
-
351
- if (area === "reservation") {
352
- return portfolioReservation(subcommand, rest, context);
353
- }
354
-
355
- throw new Error("Usage: fabric portfolio reservations|bids|bid|reservation");
778
+ throw new Error("Usage: ornn bid list|show|create|update|withdraw");
356
779
  }
357
780
 
358
- async function portfolioBid(action, args, context) {
359
- const [bidId, ...rest] = args;
360
- if (!action || !bidId) {
361
- throw new Error("Usage: fabric portfolio bid update|withdraw <bid-id>");
362
- }
781
+ async function reservations(args, context) {
782
+ const [subcommand = "list", id, ...rest] = args;
363
783
 
364
- if (action === "withdraw" || action === "delete") {
365
- const response = await cliRequest({
366
- endpoint: computeEndpoint(`/tenants/me/bids/${bidId}`),
784
+ if (subcommand === "list") {
785
+ const options = parseCommandOptions(
786
+ [id, ...rest].filter(Boolean),
787
+ { boolean: ["json"], value: ["status"] },
788
+ "Usage: ornn reservations list [--status <status>] [--json]",
789
+ );
790
+ const query = buildQuery({ status: options.status });
791
+ const rows = await cliRequest({
792
+ endpoint: computeEndpoint(`/tenants/me/reservations${query}`),
367
793
  env: context.env,
368
794
  fetchImpl: context.fetchImpl,
369
- method: "DELETE",
370
795
  });
371
- writeOutput(context.stdout, response ?? { ok: true });
796
+ if (options.json) {
797
+ writeJson(context.stdout, rows);
798
+ } else {
799
+ writeReservationList(context.stdout, requireArrayPayload(rows, "reservations"));
800
+ }
372
801
  return 0;
373
802
  }
374
803
 
375
- if (action === "update") {
376
- const { options } = parseOptions(rest);
377
- const payload = {
378
- bid_price_per_gpu_hour: numberOption(options.price || options.bidPricePerGpuHour, "--price"),
379
- end_date: requiredOption(options.endDate, "--end-date"),
380
- gpu_count: numberOption(options.gpuCount, "--gpu-count"),
381
- min_gpu_count: numberOption(options.minGpuCount, "--min-gpu-count"),
382
- start_date: requiredOption(options.startDate, "--start-date"),
383
- };
384
- const bid = await cliRequest({
385
- body: payload,
386
- endpoint: computeEndpoint(`/tenants/me/bids/${bidId}`),
387
- env: context.env,
388
- fetchImpl: context.fetchImpl,
389
- method: "PATCH",
804
+ if (subcommand === "show" && id) {
805
+ const options = parseCommandOptions(
806
+ rest,
807
+ { boolean: ["json", "open"] },
808
+ "Usage: ornn reservations show <reservation-id> [--open] [--json]",
809
+ );
810
+ const found = await findReservation(id, context);
811
+ if (options.json) {
812
+ writeJson(context.stdout, found);
813
+ } else {
814
+ writeReservationDetail(context.stdout, found);
815
+ }
816
+ if (options.open) {
817
+ await openFabricPage(context, `/portfolio/${encodeURIComponent(found.id)}`, {
818
+ label: "reservation",
819
+ noOpen: false,
820
+ });
821
+ }
822
+ return 0;
823
+ }
824
+
825
+ if (subcommand === "checkout" && id) {
826
+ const options = parseCommandOptions(
827
+ rest,
828
+ { boolean: ["json", "no-open"] },
829
+ "Usage: ornn reservations checkout <reservation-id> [--no-open] [--json]",
830
+ );
831
+ const found = await findReservation(id, context);
832
+ if (found.status !== "pending_payment") {
833
+ throw new Error("This reservation is not awaiting checkout.");
834
+ }
835
+ const opened = await openFabricPage(context, `/checkout?reservation=${encodeURIComponent(id)}`, {
836
+ json: options.json,
837
+ label: "checkout",
838
+ noOpen: options.noOpen,
839
+ payload: { reservation: found },
390
840
  });
391
- writeOutput(context.stdout, bid);
841
+ if (!options.json) {
842
+ writeCheckoutOpenResult(context.stdout, opened, "Reservation checkout");
843
+ }
392
844
  return 0;
393
845
  }
394
846
 
395
- throw new Error(`Unsupported portfolio bid action: ${action}`);
847
+ throw new Error("Usage: ornn reservations list|show|checkout");
396
848
  }
397
849
 
398
- async function portfolioReservation(action, args, context) {
399
- const [reservationId, ...rest] = args;
400
- if (!action || !reservationId) {
401
- throw new Error("Usage: fabric portfolio reservation access-mode|list|ask-price|delist|buy <reservation-id>");
850
+ async function access(args, context) {
851
+ const [subcommand, ...subcommandArgs] = args;
852
+ if (subcommand === "keys") {
853
+ return await accessKeys(subcommandArgs, context);
402
854
  }
403
855
 
404
- const { options } = parseOptions(rest);
405
- const basePath = `/tenants/me/reservations/${reservationId}`;
406
-
407
- if (action === "access-mode") {
408
- const payload = {
409
- access_mode: requiredOption(options.mode, "--mode"),
410
- image_id: options.imageId || null,
411
- };
412
- const access = await cliRequest({
413
- body: payload,
414
- endpoint: computeEndpoint(`${basePath}/access-mode`),
415
- env: context.env,
416
- fetchImpl: context.fetchImpl,
417
- method: "POST",
418
- });
419
- writeOutput(context.stdout, access);
420
- return 0;
856
+ const [reservationId, ...rest] = subcommandArgs;
857
+ if (!subcommand || !reservationId) {
858
+ throw new Error("Usage: ornn access show|activate|push-keys|keys <reservation-id>");
421
859
  }
422
860
 
423
- if (action === "list") {
424
- const reservation = await cliRequest({
425
- body: { ask_price_per_gpu_hour: numberOption(options.askPrice, "--ask-price") },
426
- endpoint: computeEndpoint(`${basePath}/list`),
427
- env: context.env,
428
- fetchImpl: context.fetchImpl,
429
- method: "POST",
430
- });
431
- writeOutput(context.stdout, reservation);
861
+ if (subcommand === "show") {
862
+ const options = parseCommandOptions(
863
+ rest,
864
+ { boolean: ["json"] },
865
+ "Usage: ornn access show <reservation-id> [--json]",
866
+ );
867
+ const payload = await getReservationMachines(reservationId, context);
868
+ if (options.json) {
869
+ writeJson(context.stdout, payload);
870
+ } else {
871
+ writeAccessDetail(context.stdout, payload);
872
+ }
432
873
  return 0;
433
874
  }
434
875
 
435
- if (action === "ask-price") {
436
- const reservation = await cliRequest({
437
- body: { ask_price_per_gpu_hour: numberOption(options.askPrice, "--ask-price") },
438
- endpoint: computeEndpoint(`${basePath}/ask-price`),
876
+ if (subcommand === "activate") {
877
+ const options = parseCommandOptions(
878
+ rest,
879
+ {
880
+ boolean: ["json", "no-open"],
881
+ value: ["machine-count", "request-id", "ssh-key-id", "tenant-username", "username"],
882
+ },
883
+ "Usage: ornn access activate <reservation-id> --ssh-key-id <id> [--username <name>] [--no-open] [--json]",
884
+ );
885
+ const sshKeyIds = requiredArrayOption(options.sshKeyId, "--ssh-key-id");
886
+ const launchPayload = {
887
+ image_id: null,
888
+ machine_count: optionProvided(options.machineCount) ? positiveIntegerOption(options.machineCount, "--machine-count") : 1,
889
+ machine_type: null,
890
+ request_id: options.requestId || null,
891
+ ssh_key_ids: sshKeyIds,
892
+ tenant_username: options.username || options.tenantUsername || null,
893
+ zone: null,
894
+ };
895
+ const accessMode = await cliRequest({
896
+ body: { access_mode: "bare-metal", image_id: null },
897
+ endpoint: computeEndpoint(`/tenants/me/reservations/${reservationId}/access-mode`),
439
898
  env: context.env,
440
899
  fetchImpl: context.fetchImpl,
441
- method: "PATCH",
900
+ method: "POST",
442
901
  });
443
- writeOutput(context.stdout, reservation);
444
- return 0;
445
- }
446
-
447
- if (action === "delist") {
448
- const reservation = await cliRequest({
449
- endpoint: computeEndpoint(`${basePath}/delist`),
902
+ const launch = await cliRequest({
903
+ body: launchPayload,
904
+ endpoint: computeEndpoint(`/standalone-vms/reservations/${reservationId}/launch`),
450
905
  env: context.env,
451
906
  fetchImpl: context.fetchImpl,
452
907
  method: "POST",
453
908
  });
454
- writeOutput(context.stdout, reservation);
909
+ const opened = await openFabricPage(context, `/portfolio/${encodeURIComponent(reservationId)}`, {
910
+ label: "reservation",
911
+ noOpen: options.noOpen,
912
+ });
913
+ if (options.json) {
914
+ writeJson(context.stdout, { access: accessMode, launch, opened: opened.opened, url: opened.url });
915
+ } else {
916
+ context.stdout.write("Bare-metal access activation queued\n");
917
+ writeAccessLaunchSummary(context.stdout, launch);
918
+ writeCheckoutOpenResult(context.stdout, opened, "Reservation page");
919
+ }
455
920
  return 0;
456
921
  }
457
922
 
458
- if (action === "buy") {
459
- const transaction = await cliRequest({
460
- body: { buyer_tenant_id: "me" },
461
- endpoint: computeEndpoint(`${basePath}/buy`),
462
- env: context.env,
463
- fetchImpl: context.fetchImpl,
464
- method: "POST",
465
- });
466
- writeOutput(context.stdout, transaction);
923
+ if (subcommand === "push-keys") {
924
+ const options = parseCommandOptions(
925
+ rest,
926
+ { boolean: ["json"], value: ["request-id", "ssh-key-id"] },
927
+ "Usage: ornn access push-keys <reservation-id> --ssh-key-id <id> [--json]",
928
+ );
929
+ const sshKeyIds = requiredArrayOption(options.sshKeyId, "--ssh-key-id");
930
+ const pushed = await pushReservationKeys(reservationId, sshKeyIds, options.requestId, context);
931
+ if (options.json) {
932
+ writeJson(context.stdout, { machines: pushed });
933
+ } else {
934
+ context.stdout.write(`SSH key update queued for ${pushed.length} machine${pushed.length === 1 ? "" : "s"}.\n`);
935
+ }
467
936
  return 0;
468
937
  }
469
938
 
470
- throw new Error(`Unsupported portfolio reservation action: ${action}`);
939
+ throw new Error(`Unsupported access action: ${subcommand}`);
471
940
  }
472
941
 
473
- async function machines(args, context) {
474
- const [subcommand, id, action, ...rest] = args;
475
- if (subcommand === "list" && id) {
476
- const { options } = parseOptions([action, ...rest].filter(Boolean), { boolean: ["json"] });
477
- const machinesPayload = await cliRequest({
478
- endpoint: computeEndpoint(`/standalone-vms/reservations/${id}/machines`),
479
- env: context.env,
480
- fetchImpl: context.fetchImpl,
481
- });
482
- writeOutput(context.stdout, machinesPayload, { json: options.json });
483
- return 0;
942
+ async function accessKeys(args, context) {
943
+ const [action, reservationId, ...rest] = args;
944
+ if (!action || !reservationId) {
945
+ throw new Error("Usage: ornn access keys list|add|push|status <reservation-id>");
484
946
  }
485
947
 
486
- if (subcommand === "get" && id) {
487
- const { options } = parseOptions([action, ...rest].filter(Boolean), { boolean: ["json"] });
488
- const machine = await cliRequest({
489
- endpoint: computeEndpoint(`/standalone-vms/${id}`),
490
- env: context.env,
491
- fetchImpl: context.fetchImpl,
492
- });
493
- writeOutput(context.stdout, machine, { json: options.json });
948
+ if (action === "list") {
949
+ const options = parseCommandOptions(
950
+ rest,
951
+ { boolean: ["json"] },
952
+ "Usage: ornn access keys list <reservation-id> [--json]",
953
+ );
954
+ const payload = await fetchReservationSshKeys(reservationId, context);
955
+ if (options.json) {
956
+ writeJson(context.stdout, payload);
957
+ } else {
958
+ writeReservationSshKeyList(context.stdout, sshKeysFromPayload(payload), reservationId);
959
+ }
494
960
  return 0;
495
961
  }
496
962
 
497
- if (subcommand === "cost" && id) {
498
- const { options } = parseOptions([action, ...rest].filter(Boolean), { boolean: ["json"] });
499
- const cost = await cliRequest({
500
- endpoint: computeEndpoint(`/standalone-vms/reservations/${id}/cost`),
963
+ if (action === "add") {
964
+ const options = parseCommandOptions(
965
+ rest,
966
+ { boolean: ["json"], value: ["label", "public-key", "public-key-file"] },
967
+ "Usage: ornn access keys add <reservation-id> --public-key <key> [--label <label>] [--json]",
968
+ );
969
+ const publicKey = options.publicKeyFile
970
+ ? (await readFile(options.publicKeyFile, "utf8")).trim()
971
+ : requiredOption(options.publicKey, "--public-key");
972
+ const key = await cliRequest({
973
+ body: {
974
+ label: options.label || null,
975
+ public_key: publicKey,
976
+ },
977
+ endpoint: reservationSshKeysEndpoint(reservationId),
501
978
  env: context.env,
502
979
  fetchImpl: context.fetchImpl,
980
+ method: "POST",
503
981
  });
504
- writeOutput(context.stdout, cost, { json: options.json });
982
+ if (options.json) {
983
+ writeJson(context.stdout, key);
984
+ } else {
985
+ context.stdout.write("Reservation SSH key added.\n");
986
+ context.stdout.write(`Reservation: ${reservationId}\n`);
987
+ if (key?.id) {
988
+ context.stdout.write(`Key: ${key.id}\n`);
989
+ }
990
+ }
505
991
  return 0;
506
992
  }
507
993
 
508
- if (subcommand === "launch" && id) {
509
- const { options } = parseOptions([action, ...rest].filter(Boolean));
510
- const payload = {
511
- image_id: options.imageId || null,
512
- machine_count: options.machineCount ? numberOption(options.machineCount, "--machine-count") : 1,
513
- machine_type: options.machineType || null,
514
- request_id: options.requestId || null,
515
- ssh_key_ids: arrayOption(options.sshKeyId),
516
- tenant_username: options.username || options.tenantUsername || null,
517
- zone: options.zone || null,
518
- };
519
- const response = await cliRequest({
520
- body: payload,
521
- endpoint: computeEndpoint(`/standalone-vms/reservations/${id}/launch`),
522
- env: context.env,
523
- fetchImpl: context.fetchImpl,
524
- method: "POST",
525
- });
526
- writeOutput(context.stdout, response);
994
+ if (action === "push") {
995
+ const options = parseCommandOptions(
996
+ rest,
997
+ { boolean: ["json"], value: ["request-id", "ssh-key-id"] },
998
+ "Usage: ornn access keys push <reservation-id> --ssh-key-id <id> [--json]",
999
+ );
1000
+ const sshKeyIds = requiredArrayOption(options.sshKeyId, "--ssh-key-id");
1001
+ const pushed = await pushReservationKeys(reservationId, sshKeyIds, options.requestId, context);
1002
+ if (options.json) {
1003
+ writeJson(context.stdout, { machines: pushed });
1004
+ } else {
1005
+ context.stdout.write(`SSH key update queued for ${pushed.length} machine${pushed.length === 1 ? "" : "s"}.\n`);
1006
+ }
527
1007
  return 0;
528
1008
  }
529
1009
 
530
- if (subcommand === "switch-access" && id) {
531
- const { options } = parseOptions([action, ...rest].filter(Boolean));
532
- const payload = {
533
- access_mode: requiredOption(options.mode, "--mode"),
534
- image_id: options.imageId || null,
535
- request_id: options.requestId || null,
536
- ssh_key_ids: arrayOption(options.sshKeyId),
537
- tenant_username: options.username || options.tenantUsername || null,
538
- };
539
- const response = await cliRequest({
540
- body: payload,
541
- endpoint: computeEndpoint(`/standalone-vms/reservations/${id}/switch-access`),
542
- env: context.env,
543
- fetchImpl: context.fetchImpl,
544
- method: "POST",
545
- });
546
- writeOutput(context.stdout, response);
1010
+ if (action === "status") {
1011
+ const options = parseCommandOptions(
1012
+ rest,
1013
+ { boolean: ["json"] },
1014
+ "Usage: ornn access keys status <reservation-id> [--json]",
1015
+ );
1016
+ const status = await buildReservationKeyStatus(reservationId, context);
1017
+ if (options.json) {
1018
+ writeJson(context.stdout, status);
1019
+ } else {
1020
+ writeReservationKeyStatus(context.stdout, status);
1021
+ }
547
1022
  return 0;
548
1023
  }
549
1024
 
550
- if (subcommand === "action" && id && action) {
551
- if (!["extend", "start", "stop", "teardown"].includes(action)) {
552
- throw new Error("Machine action must be start, stop, extend, or teardown.");
1025
+ throw new Error("Usage: ornn access keys list|add|push|status <reservation-id>");
1026
+ }
1027
+
1028
+ async function resale(args, context) {
1029
+ const [subcommand = "browse", reservationId, ...rest] = args;
1030
+
1031
+ if (subcommand === "browse") {
1032
+ const options = parseCommandOptions(
1033
+ [reservationId, ...rest].filter(Boolean),
1034
+ { boolean: ["json"] },
1035
+ "Usage: ornn resale browse [--json]",
1036
+ );
1037
+ const listings = (await loadAvailabilityListings(context)).filter((listing) => listing.kind === "resale");
1038
+ if (options.json) {
1039
+ writeJson(context.stdout, listings);
1040
+ } else if (!listings.length) {
1041
+ context.stdout.write("No resale listings found.\n");
1042
+ } else {
1043
+ writeAvailabilityList(context.stdout, listings);
553
1044
  }
554
- const { options } = parseOptions(rest);
555
- const body = action === "extend"
556
- ? { extend_by_minutes: numberOption(options.extendByMinutes || 60, "--extend-by-minutes") }
557
- : undefined;
558
- const response = await cliRequest({
559
- body,
560
- endpoint: computeEndpoint(`/standalone-vms/${id}/${action}`),
1045
+ return 0;
1046
+ }
1047
+
1048
+ if (subcommand === "list" && reservationId) {
1049
+ const options = parseCommandOptions(
1050
+ rest,
1051
+ { boolean: ["json", "no-open"], value: ["ask-price"] },
1052
+ "Usage: ornn resale list <reservation-id> --ask-price <usd> [--no-open] [--json]",
1053
+ );
1054
+ const reservation = await cliRequest({
1055
+ body: { ask_price_per_gpu_hour: positiveNumberOption(options.askPrice, "--ask-price") },
1056
+ endpoint: computeEndpoint(`/tenants/me/reservations/${reservationId}/list`),
561
1057
  env: context.env,
562
1058
  fetchImpl: context.fetchImpl,
563
1059
  method: "POST",
564
1060
  });
565
- writeOutput(context.stdout, response);
1061
+ const opened = await openFabricPage(context, `/marketplace/resale-${encodeURIComponent(reservationId)}`, {
1062
+ label: "resale listing",
1063
+ noOpen: options.noOpen,
1064
+ });
1065
+ writeMutationWithOpen(context.stdout, reservation, opened, options, "Resale listing");
566
1066
  return 0;
567
1067
  }
568
1068
 
569
- if (subcommand === "push-keys" && id) {
570
- const { options } = parseOptions([action, ...rest].filter(Boolean));
571
- const payload = {
572
- request_id: options.requestId || null,
573
- ssh_key_ids: requiredArrayOption(options.sshKeyId, "--ssh-key-id"),
574
- };
575
- const response = await cliRequest({
576
- body: payload,
577
- endpoint: computeEndpoint(`/standalone-vms/${id}/push-keys`),
1069
+ if (subcommand === "update" && reservationId) {
1070
+ const options = parseCommandOptions(
1071
+ rest,
1072
+ { boolean: ["json", "no-open"], value: ["ask-price"] },
1073
+ "Usage: ornn resale update <reservation-id> --ask-price <usd> [--no-open] [--json]",
1074
+ );
1075
+ const reservation = await cliRequest({
1076
+ body: { ask_price_per_gpu_hour: positiveNumberOption(options.askPrice, "--ask-price") },
1077
+ endpoint: computeEndpoint(`/tenants/me/reservations/${reservationId}/ask-price`),
578
1078
  env: context.env,
579
1079
  fetchImpl: context.fetchImpl,
580
- method: "POST",
1080
+ method: "PATCH",
1081
+ });
1082
+ const opened = await openFabricPage(context, `/marketplace/resale-${encodeURIComponent(reservationId)}`, {
1083
+ label: "resale listing",
1084
+ noOpen: options.noOpen,
581
1085
  });
582
- writeOutput(context.stdout, response);
1086
+ writeMutationWithOpen(context.stdout, reservation, opened, options, "Resale listing");
583
1087
  return 0;
584
1088
  }
585
1089
 
586
- if (subcommand === "revoke" && id) {
587
- const { options } = parseOptions([action, ...rest].filter(Boolean));
588
- const response = await cliRequest({
589
- body: { request_id: options.requestId || null },
590
- endpoint: computeEndpoint(`/standalone-vms/${id}/revoke`),
1090
+ if (subcommand === "delist" && reservationId) {
1091
+ const options = parseCommandOptions(
1092
+ rest,
1093
+ { boolean: ["json", "no-open"] },
1094
+ "Usage: ornn resale delist <reservation-id> [--no-open] [--json]",
1095
+ );
1096
+ const reservation = await cliRequest({
1097
+ endpoint: computeEndpoint(`/tenants/me/reservations/${reservationId}/delist`),
591
1098
  env: context.env,
592
1099
  fetchImpl: context.fetchImpl,
593
1100
  method: "POST",
594
1101
  });
595
- writeOutput(context.stdout, response);
1102
+ const opened = await openFabricPage(context, `/portfolio/${encodeURIComponent(reservationId)}`, {
1103
+ label: "reservation",
1104
+ noOpen: options.noOpen,
1105
+ });
1106
+ writeMutationWithOpen(context.stdout, reservation, opened, options, "Reservation");
596
1107
  return 0;
597
1108
  }
598
1109
 
599
- throw new Error("Usage: fabric machines list|get|cost|launch|switch-access|action|push-keys|revoke");
1110
+ throw new Error("Usage: ornn resale browse|list|update|delist");
1111
+ }
1112
+
1113
+ async function billing(args, context) {
1114
+ const [subcommand = "open", ...rest] = args;
1115
+ if (subcommand === "open") {
1116
+ const options = parseCommandOptions(
1117
+ rest,
1118
+ { boolean: ["json", "no-open"] },
1119
+ "Usage: ornn billing open [--no-open] [--json]",
1120
+ );
1121
+ const opened = await openFabricPage(context, "/account?tab=billing", {
1122
+ json: options.json,
1123
+ label: "billing",
1124
+ noOpen: options.noOpen,
1125
+ });
1126
+ if (!options.json) {
1127
+ writeCheckoutOpenResult(context.stdout, opened, "Billing");
1128
+ }
1129
+ return 0;
1130
+ }
1131
+ throw new Error("Usage: ornn billing open [--no-open] [--json]");
600
1132
  }
601
1133
 
602
1134
  async function sshKeys(args, context) {
603
1135
  const [subcommand, id, ...rest] = args;
604
1136
  if (subcommand === "list") {
1137
+ const options = parseCommandOptions(
1138
+ [id, ...rest].filter(Boolean),
1139
+ { boolean: ["json"] },
1140
+ "Usage: ornn ssh-keys list [--json]",
1141
+ );
605
1142
  const keys = await cliRequest({
606
1143
  endpoint: computeEndpoint("/standalone-vms/tenants/me/ssh-keys"),
607
1144
  env: context.env,
608
1145
  fetchImpl: context.fetchImpl,
609
1146
  });
610
- writeOutput(context.stdout, keys);
1147
+ if (options.json) {
1148
+ writeJson(context.stdout, keys);
1149
+ } else {
1150
+ writeSshKeyList(context.stdout, sshKeysFromPayload(keys));
1151
+ }
611
1152
  return 0;
612
1153
  }
613
1154
 
614
1155
  if (subcommand === "add") {
615
- const { options } = parseOptions([id, ...rest].filter(Boolean));
1156
+ const options = parseCommandOptions(
1157
+ [id, ...rest].filter(Boolean),
1158
+ { boolean: ["json"], value: ["label", "public-key", "public-key-file"] },
1159
+ "Usage: ornn ssh-keys add --public-key <key> [--label <label>] [--json]",
1160
+ );
616
1161
  const publicKey = options.publicKeyFile
617
1162
  ? (await readFile(options.publicKeyFile, "utf8")).trim()
618
1163
  : requiredOption(options.publicKey, "--public-key");
@@ -626,106 +1171,734 @@ async function sshKeys(args, context) {
626
1171
  fetchImpl: context.fetchImpl,
627
1172
  method: "POST",
628
1173
  });
629
- writeOutput(context.stdout, key);
1174
+ if (options.json) {
1175
+ writeJson(context.stdout, key);
1176
+ } else {
1177
+ context.stdout.write("SSH key added.\n");
1178
+ if (key?.id) {
1179
+ context.stdout.write(`Key: ${key.id}\n`);
1180
+ }
1181
+ }
630
1182
  return 0;
631
1183
  }
632
1184
 
633
1185
  if ((subcommand === "delete" || subcommand === "remove") && id) {
1186
+ const options = parseCommandOptions(
1187
+ rest,
1188
+ { boolean: ["json"] },
1189
+ "Usage: ornn ssh-keys delete <key-id> [--json]",
1190
+ );
634
1191
  const response = await cliRequest({
635
1192
  endpoint: computeEndpoint(`/standalone-vms/tenants/me/ssh-keys/${id}`),
636
1193
  env: context.env,
637
1194
  fetchImpl: context.fetchImpl,
638
1195
  method: "DELETE",
639
1196
  });
640
- writeOutput(context.stdout, response);
1197
+ if (options.json) {
1198
+ writeJson(context.stdout, response);
1199
+ } else {
1200
+ context.stdout.write(`SSH key deleted: ${id}\n`);
1201
+ }
641
1202
  return 0;
642
1203
  }
643
1204
 
644
- throw new Error("Usage: fabric ssh-keys list|add|delete");
1205
+ throw new Error("Usage: ornn ssh-keys list|add|delete");
645
1206
  }
646
1207
 
647
- async function images(args, context) {
648
- const [subcommand, id, ...rest] = args;
649
- if (subcommand === "list") {
650
- const { options } = parseOptions([id, ...rest].filter(Boolean), { boolean: ["json"] });
651
- const imagesPayload = await cliRequest({
652
- endpoint: computeEndpoint("/account/images"),
653
- env: context.env,
654
- fetchImpl: context.fetchImpl,
655
- });
656
- writeOutput(context.stdout, imagesPayload, { json: options.json });
657
- return 0;
1208
+ async function loadAvailabilityListings(context) {
1209
+ const [inventoryRecords, resaleReservations] = await Promise.all([
1210
+ fetchInventoryList(context),
1211
+ fetchResaleListings(context),
1212
+ ]);
1213
+ const inventoryById = new Map(inventoryRecords.map((record) => [record.id, record]));
1214
+ const primaryListings = inventoryRecords
1215
+ .filter((record) => isTermEndDateActive(record.available_to))
1216
+ .filter((record) => inventoryAvailableGpuCount(record) > 0)
1217
+ .map(normalizeInventoryListing);
1218
+ const resaleListings = resaleReservations
1219
+ .filter((reservation) => reservation.is_marketplace_listed !== false)
1220
+ .filter((reservation) => reservation.ask_price_per_gpu_hour !== undefined && reservation.ask_price_per_gpu_hour !== null)
1221
+ .filter((reservation) => isTermEndDateActive(reservation.end_date))
1222
+ .map((reservation) => normalizeResaleListing(reservation, inventoryById.get(reservation.deployment_id) ?? null));
1223
+ return [...primaryListings, ...resaleListings];
1224
+ }
1225
+
1226
+ async function fetchInventoryList(context) {
1227
+ const payload = await cliRequest({
1228
+ authRequired: false,
1229
+ endpoint: computeEndpoint("/inventory"),
1230
+ env: context.env,
1231
+ fetchImpl: context.fetchImpl,
1232
+ });
1233
+ return requireArrayPayload(payload, "inventory");
1234
+ }
1235
+
1236
+ async function fetchInventory(id, context) {
1237
+ return await cliRequest({
1238
+ authRequired: false,
1239
+ endpoint: computeEndpoint(`/inventory/${encodeURIComponent(id)}`),
1240
+ env: context.env,
1241
+ fetchImpl: context.fetchImpl,
1242
+ });
1243
+ }
1244
+
1245
+ async function fetchResaleListings(context) {
1246
+ const payload = await cliRequest({
1247
+ authRequired: false,
1248
+ endpoint: computeEndpoint("/marketplace/resale-listings"),
1249
+ env: context.env,
1250
+ fetchImpl: context.fetchImpl,
1251
+ });
1252
+ return requireArrayPayload(payload, "resale listings");
1253
+ }
1254
+
1255
+ function requireArrayPayload(payload, label) {
1256
+ if (!Array.isArray(payload)) {
1257
+ throw new Error(`Ornn returned invalid ${label} data.`);
658
1258
  }
1259
+ return payload;
1260
+ }
659
1261
 
660
- if (subcommand === "upload") {
661
- const { options } = parseOptions([id, ...rest].filter(Boolean), {
662
- boolean: ["json", "operator-approved-large-image"],
663
- });
664
- const payload = await buildImageUploadPayload(options);
665
- const image = await cliRequest({
666
- body: payload,
667
- endpoint: computeEndpoint("/vm-images"),
668
- env: context.env,
669
- fetchImpl: context.fetchImpl,
670
- method: "POST",
671
- });
672
- writeOutput(context.stdout, image, { json: options.json });
673
- return 0;
1262
+ function sshKeysFromPayload(payload) {
1263
+ if (Array.isArray(payload)) {
1264
+ return payload;
674
1265
  }
1266
+ if (Array.isArray(payload?.ssh_keys)) {
1267
+ return payload.ssh_keys;
1268
+ }
1269
+ if (Array.isArray(payload?.keys)) {
1270
+ return payload.keys;
1271
+ }
1272
+ throw new Error("Ornn returned invalid SSH keys data.");
1273
+ }
1274
+
1275
+ function inventoryAvailableGpuCount(record) {
1276
+ if (record.available_gpu_count != null) {
1277
+ return Math.max(0, record.available_gpu_count);
1278
+ }
1279
+ const committed = Math.max(0, record.committed_gpu_count ?? 0);
1280
+ return Math.max(0, (record.gpu_count ?? 0) - committed);
1281
+ }
1282
+
1283
+ function normalizeInventoryListing(record) {
1284
+ const gpuCount = inventoryAvailableGpuCount(record);
1285
+ return {
1286
+ id: record.id,
1287
+ kind: "inventory",
1288
+ source: "primary",
1289
+ gpu_type: record.gpu_type ?? "GPU",
1290
+ gpu_count: gpuCount || null,
1291
+ operator: record.site_operator ?? record.operator ?? "Unknown operator",
1292
+ facility: record.site_nickname ?? record.facility ?? record.site ?? "Unknown facility",
1293
+ location: record.location ?? record.region ?? null,
1294
+ network: record.fabric_type ?? record.network_hardware ?? record.internet ?? null,
1295
+ start_date: record.available_from ?? null,
1296
+ end_date: record.available_to ?? null,
1297
+ price_per_gpu_hour: resolveBuyNowUsdPerGpuHour(record),
1298
+ checkout_url: `/checkout?inventory=${encodeURIComponent(record.id)}`,
1299
+ marketplace_url: `/marketplace/${encodeURIComponent(record.id)}`,
1300
+ inventory: record,
1301
+ };
1302
+ }
1303
+
1304
+ function normalizeResaleListing(reservation, inventory) {
1305
+ const id = `resale-${reservation.id}`;
1306
+ return {
1307
+ id,
1308
+ kind: "resale",
1309
+ source: "resale",
1310
+ reservation_id: reservation.id,
1311
+ seller_tenant_id: reservation.tenant_id ?? null,
1312
+ gpu_type: inventory?.gpu_type ?? reservation.gpu_type ?? "GPU",
1313
+ gpu_count: reservation.gpu_count ?? null,
1314
+ operator: inventory?.site_operator ?? "Resale",
1315
+ facility: inventory?.site_nickname ?? "Resale",
1316
+ location: inventory?.location ?? inventory?.region ?? null,
1317
+ network: inventory?.fabric_type ?? inventory?.network_hardware ?? inventory?.internet ?? null,
1318
+ start_date: reservation.start_date ?? null,
1319
+ end_date: reservation.end_date ?? null,
1320
+ price_per_gpu_hour: reservation.ask_price_per_gpu_hour ?? null,
1321
+ checkout_url: `/checkout?inventory=${encodeURIComponent(id)}`,
1322
+ marketplace_url: `/marketplace/${encodeURIComponent(id)}`,
1323
+ inventory,
1324
+ reservation,
1325
+ };
1326
+ }
1327
+
1328
+ function isTermEndDateActive(endDate, today = new Date()) {
1329
+ if (!endDate) {
1330
+ return true;
1331
+ }
1332
+ const parsed = parseDateOnly(endDate);
1333
+ if (!parsed) {
1334
+ return true;
1335
+ }
1336
+ const todayUtc = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
1337
+ return parsed.getTime() >= todayUtc;
1338
+ }
1339
+
1340
+ function parseDateOnly(value) {
1341
+ if (typeof value !== "string" || !value.trim()) {
1342
+ return null;
1343
+ }
1344
+ const match = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})/);
1345
+ if (!match) {
1346
+ return null;
1347
+ }
1348
+ const parsed = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
1349
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
1350
+ }
1351
+
1352
+ function filterAvailabilityListings(listings, options) {
1353
+ return listings.filter((listing) => {
1354
+ return (
1355
+ textMatches(listing.gpu_type, options.gpuType) &&
1356
+ textMatches(listing.facility, options.facility) &&
1357
+ textMatches(listing.operator, options.operator)
1358
+ );
1359
+ });
1360
+ }
1361
+
1362
+ function textMatches(value, filter) {
1363
+ if (!filter) {
1364
+ return true;
1365
+ }
1366
+ return String(value ?? "").toLowerCase().includes(String(filter).toLowerCase());
1367
+ }
1368
+
1369
+ async function resolveListing(listingId, context) {
1370
+ if (listingId.startsWith("resale-")) {
1371
+ const reservationId = listingId.replace(/^resale-/, "");
1372
+ const listings = await loadAvailabilityListings(context);
1373
+ const listing = listings.find(
1374
+ (candidate) => candidate.id === listingId || candidate.reservation_id === reservationId,
1375
+ );
1376
+ if (!listing) {
1377
+ throw new Error(`Listing not found: ${listingId}`);
1378
+ }
1379
+ return listing;
1380
+ }
1381
+
1382
+ const inventory = await fetchInventory(listingId, context);
1383
+ if (!isTermEndDateActive(inventory.available_to)) {
1384
+ throw new Error(`Listing is no longer available: ${listingId}`);
1385
+ }
1386
+ return normalizeInventoryListing(inventory);
1387
+ }
1388
+
1389
+ function marketplacePathForListing(listing) {
1390
+ return listing.marketplace_url || `/marketplace/${encodeURIComponent(listing.id)}`;
1391
+ }
1392
+
1393
+ async function openFabricPage(context, pathOrUrl, { json = false, label = "page", noOpen = false, payload = {} } = {}) {
1394
+ const url = await resolveFabricUrl(context, pathOrUrl);
1395
+ const opened = noOpen ? false : await context.openBrowserImpl(url);
1396
+ const result = { ...payload, label, opened, url };
1397
+ if (json) {
1398
+ writeJson(context.stdout, result);
1399
+ }
1400
+ return result;
1401
+ }
1402
+
1403
+ async function resolveFabricUrl(context, pathOrUrl) {
1404
+ if (/^https?:\/\//i.test(pathOrUrl)) {
1405
+ return pathOrUrl;
1406
+ }
1407
+ const baseUrl = await resolveFabricBaseUrl(context);
1408
+ return new URL(pathOrUrl, `${baseUrl}/`).toString();
1409
+ }
1410
+
1411
+ async function resolveFabricBaseUrl(context) {
1412
+ const env = context.env ?? {};
1413
+ const configured = env.ORNN_AUTH_BASE_URL?.trim() || env.BETTER_AUTH_URL?.trim();
1414
+ if (configured) {
1415
+ return configured.replace(/\/+$/, "");
1416
+ }
1417
+
1418
+ try {
1419
+ const session = await loadAuthSession({ env });
1420
+ if (session?.authBaseUrl?.trim()) {
1421
+ return session.authBaseUrl.trim().replace(/\/+$/, "");
1422
+ }
1423
+ } catch {
1424
+ // Browser handoff URLs should still work for unauthenticated flows.
1425
+ }
1426
+
1427
+ return resolveAuthBaseUrl({ env });
1428
+ }
1429
+
1430
+ function writeCheckoutOpenResult(stdout, result, label) {
1431
+ stdout.write(`${label} URL: ${result.url}\n`);
1432
+ if (result.opened) {
1433
+ stdout.write(`Opened ${label.toLowerCase()} in your browser.\n`);
1434
+ } else {
1435
+ stdout.write("Open the URL above in your browser.\n");
1436
+ }
1437
+ }
1438
+
1439
+ function writeAvailabilityList(stdout, listings) {
1440
+ if (!listings.length) {
1441
+ stdout.write("No available bare-metal compute found.\n");
1442
+ return;
1443
+ }
1444
+ stdout.write("Available bare-metal compute:\n");
1445
+ for (const listing of listings) {
1446
+ stdout.write(`- ${listing.id} [${listing.source}] ${formatGpuSummary(listing)}`);
1447
+ stdout.write(` at ${listing.operator} / ${listing.facility}`);
1448
+ stdout.write(`, ${formatDateRange(listing.start_date, listing.end_date)}`);
1449
+ if (listing.price_per_gpu_hour !== null && listing.price_per_gpu_hour !== undefined) {
1450
+ stdout.write(`, ${formatUsd(listing.price_per_gpu_hour)}/GPU-hr`);
1451
+ }
1452
+ stdout.write("\n");
1453
+ }
1454
+ }
1455
+
1456
+ function writeAvailabilityDetail(stdout, listing) {
1457
+ stdout.write(`${listing.id}\n`);
1458
+ stdout.write(`Type: ${listing.source}\n`);
1459
+ stdout.write(`Compute: ${formatGpuSummary(listing)}\n`);
1460
+ stdout.write(`Operator: ${listing.operator}\n`);
1461
+ stdout.write(`Facility: ${listing.facility}\n`);
1462
+ if (listing.location) {
1463
+ stdout.write(`Location: ${listing.location}\n`);
1464
+ }
1465
+ if (listing.network) {
1466
+ stdout.write(`Network: ${listing.network}\n`);
1467
+ }
1468
+ stdout.write(`Term: ${formatDateRange(listing.start_date, listing.end_date)}\n`);
1469
+ if (listing.price_per_gpu_hour !== null && listing.price_per_gpu_hour !== undefined) {
1470
+ stdout.write(`Price: ${formatUsd(listing.price_per_gpu_hour)}/GPU-hr\n`);
1471
+ }
1472
+ stdout.write(`Checkout: ${listing.checkout_url}\n`);
1473
+ stdout.write(`Marketplace: ${listing.marketplace_url}\n`);
1474
+ }
1475
+
1476
+ function formatGpuSummary(record) {
1477
+ const count = record.gpu_count ?? "?";
1478
+ const type = record.gpu_type ?? "GPU";
1479
+ return `${count}x ${type}`;
1480
+ }
1481
+
1482
+ function formatDateRange(startDate, endDate) {
1483
+ return `${startDate || "TBD"} to ${endDate || "TBD"}`;
1484
+ }
1485
+
1486
+ function formatUsd(value) {
1487
+ const numeric = Number(value);
1488
+ if (!Number.isFinite(numeric)) {
1489
+ return String(value);
1490
+ }
1491
+ return `$${numeric.toFixed(2)}`;
1492
+ }
1493
+
1494
+ async function findBid(id, context) {
1495
+ const bids = await cliRequest({
1496
+ endpoint: computeEndpoint("/tenants/me/bids"),
1497
+ env: context.env,
1498
+ fetchImpl: context.fetchImpl,
1499
+ });
1500
+ const found = requireArrayPayload(bids, "bids").find((candidate) => candidate.id === id);
1501
+ if (!found) {
1502
+ throw new Error(`Bid not found: ${id}`);
1503
+ }
1504
+ return found;
1505
+ }
675
1506
 
676
- if ((subcommand === "revoke" || subcommand === "delete") && id) {
677
- const { options } = parseOptions(rest, { boolean: ["json"] });
1507
+ function writeBidList(stdout, bids) {
1508
+ if (!bids.length) {
1509
+ stdout.write("No bids found.\n");
1510
+ return;
1511
+ }
1512
+ stdout.write("Bids:\n");
1513
+ for (const bid of bids) {
1514
+ stdout.write(`- ${bid.id} ${bid.status || "unknown"} ${bid.gpu_count ?? "?"} GPUs`);
1515
+ stdout.write(` ${bid.start_date || "TBD"} to ${bid.end_date || "TBD"}`);
1516
+ if (bid.bid_price_per_gpu_hour !== undefined && bid.bid_price_per_gpu_hour !== null) {
1517
+ stdout.write(` at ${formatUsd(bid.bid_price_per_gpu_hour)}/GPU-hr`);
1518
+ }
1519
+ stdout.write("\n");
1520
+ }
1521
+ }
1522
+
1523
+ function writeBidDetail(stdout, bid) {
1524
+ stdout.write(`${bid.id}\n`);
1525
+ stdout.write(`Status: ${bid.status || "unknown"}\n`);
1526
+ stdout.write(`Deployment: ${bid.deployment_id || "unknown"}\n`);
1527
+ stdout.write(`GPU count: ${bid.gpu_count ?? "unknown"}\n`);
1528
+ stdout.write(`Minimum GPU count: ${bid.min_gpu_count ?? bid.gpu_count ?? "unknown"}\n`);
1529
+ stdout.write(`Term: ${formatDateRange(bid.start_date, bid.end_date)}\n`);
1530
+ if (bid.bid_price_per_gpu_hour !== undefined && bid.bid_price_per_gpu_hour !== null) {
1531
+ stdout.write(`Price: ${formatUsd(bid.bid_price_per_gpu_hour)}/GPU-hr\n`);
1532
+ }
1533
+ stdout.write(`Checkout: /checkout?bid=${encodeURIComponent(bid.id)}\n`);
1534
+ }
1535
+
1536
+ async function findReservation(id, context) {
1537
+ const reservations = await cliRequest({
1538
+ endpoint: computeEndpoint("/tenants/me/reservations"),
1539
+ env: context.env,
1540
+ fetchImpl: context.fetchImpl,
1541
+ });
1542
+ const found = requireArrayPayload(reservations, "reservations").find((candidate) => candidate.id === id);
1543
+ if (!found) {
1544
+ throw new Error(`Reservation not found: ${id}`);
1545
+ }
1546
+ return found;
1547
+ }
1548
+
1549
+ function writeReservationList(stdout, reservations) {
1550
+ if (!reservations.length) {
1551
+ stdout.write("No reservations found.\n");
1552
+ return;
1553
+ }
1554
+ stdout.write("Reservations:\n");
1555
+ for (const reservation of reservations) {
1556
+ stdout.write(`- ${reservation.id} ${reservation.status || "unknown"} ${reservation.gpu_count ?? "?"} GPUs`);
1557
+ stdout.write(` ${reservation.start_date || "TBD"} to ${reservation.end_date || "TBD"}`);
1558
+ if (reservation.price_per_gpu_hour !== undefined && reservation.price_per_gpu_hour !== null) {
1559
+ stdout.write(` at ${formatUsd(reservation.price_per_gpu_hour)}/GPU-hr`);
1560
+ }
1561
+ stdout.write("\n");
1562
+ }
1563
+ }
1564
+
1565
+ function writeReservationDetail(stdout, reservation) {
1566
+ stdout.write(`${reservation.id}\n`);
1567
+ stdout.write(`Status: ${reservation.status || "unknown"}\n`);
1568
+ stdout.write(`Deployment: ${reservation.deployment_id || "unknown"}\n`);
1569
+ stdout.write(`GPU count: ${reservation.gpu_count ?? "unknown"}\n`);
1570
+ stdout.write(`Term: ${formatDateRange(reservation.start_date, reservation.end_date)}\n`);
1571
+ if (reservation.price_per_gpu_hour !== undefined && reservation.price_per_gpu_hour !== null) {
1572
+ stdout.write(`Price: ${formatUsd(reservation.price_per_gpu_hour)}/GPU-hr\n`);
1573
+ }
1574
+ if (reservation.is_marketplace_listed) {
1575
+ stdout.write(`Resale listing: /marketplace/resale-${encodeURIComponent(reservation.id)}\n`);
1576
+ }
1577
+ if (reservation.status === "pending_payment") {
1578
+ stdout.write(`Checkout: /checkout?reservation=${encodeURIComponent(reservation.id)}\n`);
1579
+ }
1580
+ }
1581
+
1582
+ async function getReservationMachines(reservationId, context) {
1583
+ const payload = await cliRequest({
1584
+ endpoint: computeEndpoint(`/standalone-vms/reservations/${reservationId}/machines`),
1585
+ env: context.env,
1586
+ fetchImpl: context.fetchImpl,
1587
+ });
1588
+ return Array.isArray(payload) ? { machines: payload } : payload;
1589
+ }
1590
+
1591
+ function reservationSshKeysEndpoint(reservationId) {
1592
+ return computeEndpoint(`/standalone-vms/reservations/${reservationId}/ssh-keys`);
1593
+ }
1594
+
1595
+ async function fetchReservationSshKeys(reservationId, context) {
1596
+ return await cliRequest({
1597
+ endpoint: reservationSshKeysEndpoint(reservationId),
1598
+ env: context.env,
1599
+ fetchImpl: context.fetchImpl,
1600
+ });
1601
+ }
1602
+
1603
+ async function pushReservationKeys(reservationId, sshKeyIds, requestId, context) {
1604
+ const machinesPayload = await getReservationMachines(reservationId, context);
1605
+ const machines = activeMachines(machinesPayload.machines || []);
1606
+ if (!machines.length) {
1607
+ throw new Error("No active bare-metal machines were found for this reservation.");
1608
+ }
1609
+
1610
+ const pushed = [];
1611
+ for (const machine of machines) {
1612
+ const id = machineId(machine);
678
1613
  const response = await cliRequest({
679
- endpoint: computeEndpoint(`/vm-images/${id}/revoke`),
1614
+ body: {
1615
+ request_id: requestId || `cli-push-keys:${id}:${Date.now()}`,
1616
+ ssh_key_ids: sshKeyIds,
1617
+ },
1618
+ endpoint: computeEndpoint(`/standalone-vms/${id}/push-keys`),
680
1619
  env: context.env,
681
1620
  fetchImpl: context.fetchImpl,
682
1621
  method: "POST",
683
1622
  });
684
- writeOutput(context.stdout, response, { json: options.json });
685
- return 0;
1623
+ pushed.push(response);
686
1624
  }
1625
+ return pushed;
1626
+ }
687
1627
 
688
- throw new Error("Usage: fabric images list|upload|revoke");
1628
+ async function buildReservationKeyStatus(reservationId, context) {
1629
+ const [keysPayload, machinesPayload] = await Promise.all([
1630
+ fetchReservationSshKeys(reservationId, context),
1631
+ getReservationMachines(reservationId, context),
1632
+ ]);
1633
+ const machines = activeMachines(machinesPayload.machines || []);
1634
+ const keys = activeSshKeys(sshKeysFromPayload(keysPayload)).map((key) => ({
1635
+ id: key.id || null,
1636
+ label: key.label || null,
1637
+ fingerprint: key.fingerprint || null,
1638
+ linux_username: key.linux_username || null,
1639
+ status: key.status || "active",
1640
+ machines: machines.map((machine) => reservationKeyMachineStatus(key, machine)),
1641
+ }));
1642
+ return {
1643
+ reservation_id: reservationId,
1644
+ machine_count: machines.length,
1645
+ keys,
1646
+ };
689
1647
  }
690
1648
 
691
- async function buildImageUploadPayload(options) {
692
- const imageFile = options.imageFile ? requiredOption(options.imageFile, "--image-file") : null;
693
- const registryRef = options.registryRef ? requiredOption(options.registryRef, "--registry-ref") : null;
694
- if (!imageFile && !registryRef) {
695
- throw new Error("Provide --image-file or --registry-ref for image upload.");
1649
+ function reservationKeyMachineStatus(key, machine) {
1650
+ const metadata = reservationKeyMetadataForMachine(key, machine);
1651
+ const fallbackInstalled =
1652
+ !metadata &&
1653
+ key.fingerprint &&
1654
+ Array.isArray(machine.authorized_key_fingerprints) &&
1655
+ machine.authorized_key_fingerprints.includes(key.fingerprint) &&
1656
+ machine.keys_pushed_at;
1657
+
1658
+ return {
1659
+ machine_id: machineId(machine),
1660
+ machine_state: machineState(machine),
1661
+ status: metadata?.status || (fallbackInstalled ? "installed" : "associated"),
1662
+ linux_username: metadata?.linux_username || key.linux_username || machine.linux_username || null,
1663
+ queued_at: metadata?.queued_at || null,
1664
+ pushed_at: metadata?.pushed_at || (fallbackInstalled ? machine.keys_pushed_at : null),
1665
+ failed_at: metadata?.failed_at || null,
1666
+ failure_reason: metadata?.failure_reason || null,
1667
+ request_id: metadata?.request_id || null,
1668
+ };
1669
+ }
1670
+
1671
+ function reservationKeyMetadataForMachine(key, machine) {
1672
+ if (!Array.isArray(machine.authorized_key_metadata)) {
1673
+ return null;
696
1674
  }
1675
+ return (
1676
+ machine.authorized_key_metadata.find((item) => {
1677
+ const itemKeyId = item.ssh_key_id === undefined || item.ssh_key_id === null ? "" : String(item.ssh_key_id);
1678
+ const keyId = key.id === undefined || key.id === null ? "" : String(key.id);
1679
+ return (keyId && itemKeyId === keyId) || (key.fingerprint && item.fingerprint === key.fingerprint);
1680
+ }) || null
1681
+ );
1682
+ }
697
1683
 
698
- let imageBuffer = null;
699
- if (imageFile) {
700
- imageBuffer = await readFile(imageFile);
1684
+ function writeAccessDetail(stdout, payload = {}) {
1685
+ const machines = Array.isArray(payload.machines) ? payload.machines : [];
1686
+ if (!machines.length) {
1687
+ stdout.write("No bare-metal access machines found for this reservation.\n");
1688
+ return;
1689
+ }
1690
+ stdout.write("Bare-metal access:\n");
1691
+ for (const machine of machines) {
1692
+ stdout.write(`- ${machineId(machine)} ${machineState(machine)}\n`);
1693
+ const username = machineUsername(machine);
1694
+ const host = machineHost(machine);
1695
+ const port = machinePort(machine);
1696
+ stdout.write(` Ready: ${host ? "yes" : "no"}\n`);
1697
+ if (username) {
1698
+ stdout.write(` Username: ${username}\n`);
1699
+ }
1700
+ if (host) {
1701
+ stdout.write(` Host: ${host}\n`);
1702
+ stdout.write(` Port: ${port}\n`);
1703
+ }
1704
+ const sshCommand = sshCommandForMachine(machine);
1705
+ if (sshCommand) {
1706
+ stdout.write(` SSH: ${sshCommand}\n`);
1707
+ }
1708
+ }
1709
+ }
1710
+
1711
+ function writeAccessLaunchSummary(stdout, launch = {}) {
1712
+ const machines = Array.isArray(launch.machines) ? launch.machines : [];
1713
+ if (!machines.length) {
1714
+ stdout.write("Provisioning response received.\n");
1715
+ return;
1716
+ }
1717
+ stdout.write(`Machines: ${machines.length}\n`);
1718
+ for (const machine of machines) {
1719
+ stdout.write(`- ${machineId(machine)} ${machineState(machine, "queued")}\n`);
1720
+ const sshCommand = sshCommandForMachine(machine);
1721
+ if (sshCommand) {
1722
+ stdout.write(` SSH: ${sshCommand}\n`);
1723
+ }
701
1724
  }
1725
+ }
1726
+
1727
+ function sshCommandForMachine(machine) {
1728
+ if (machine.ssh_command) {
1729
+ return machine.ssh_command;
1730
+ }
1731
+ if (machine.iap_ssh_command) {
1732
+ return machine.iap_ssh_command;
1733
+ }
1734
+
1735
+ if (machine.ssh_endpoint) {
1736
+ const port = machinePort(machine);
1737
+ return port === 22 ? `ssh ${machine.ssh_endpoint}` : `ssh -p ${port} ${machine.ssh_endpoint}`;
1738
+ }
1739
+
1740
+ const host = machineHost(machine);
1741
+ if (!host) {
1742
+ return "";
1743
+ }
1744
+ const username = machineUsername(machine) || "root";
1745
+ const port = machinePort(machine);
1746
+ return port === 22 ? `ssh ${username}@${host}` : `ssh -p ${port} ${username}@${host}`;
1747
+ }
1748
+
1749
+ function machineId(machine) {
1750
+ return machine.id || machine.machine_id || machine.instance_id || machine.instance_name || "machine";
1751
+ }
1752
+
1753
+ function machineState(machine, fallback = "unknown") {
1754
+ return machine.status || machine.state || machine.actual_state || machine.desired_state || fallback;
1755
+ }
1756
+
1757
+ function machineUsername(machine) {
1758
+ return machine.tenant_username || machine.username || machine.user || splitSshEndpoint(machine.ssh_endpoint).user;
1759
+ }
702
1760
 
703
- const sha256 = options.sha256
704
- ? requiredOption(options.sha256, "--sha256")
705
- : imageBuffer
706
- ? createHash("sha256").update(imageBuffer).digest("hex")
707
- : requiredOption(options.sha256, "--sha256");
708
- const sizeBytes = options.sizeBytes
709
- ? numberOption(options.sizeBytes, "--size-bytes")
710
- : imageBuffer
711
- ? imageBuffer.byteLength
712
- : numberOption(options.sizeBytes, "--size-bytes");
1761
+ function machineHost(machine) {
1762
+ return (
1763
+ splitSshEndpoint(machine.ssh_endpoint).host ||
1764
+ machine.ssh_host ||
1765
+ machine.public_ip ||
1766
+ machine.external_ip ||
1767
+ machine.ip_address ||
1768
+ machine.ip ||
1769
+ machine.hostname ||
1770
+ machine.access_host
1771
+ );
1772
+ }
713
1773
 
1774
+ function machinePort(machine) {
1775
+ const parsed = Number(machine.ssh_port || machine.port || 22);
1776
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 22;
1777
+ }
1778
+
1779
+ function splitSshEndpoint(endpoint) {
1780
+ if (typeof endpoint !== "string" || !endpoint.trim()) {
1781
+ return { host: "", user: "" };
1782
+ }
1783
+ const trimmed = endpoint.trim();
1784
+ const atIndex = trimmed.indexOf("@");
1785
+ if (atIndex <= 0 || atIndex === trimmed.length - 1) {
1786
+ return { host: trimmed, user: "" };
1787
+ }
714
1788
  return {
715
- cosign_signature: requiredOption(options.cosignSignature, "--cosign-signature"),
716
- image_b64: imageBuffer ? imageBuffer.toString("base64") : undefined,
717
- name: requiredOption(options.name, "--name"),
718
- operator_approved_large_image: Boolean(options.operatorApprovedLargeImage),
719
- os_family: requiredOption(options.osFamily, "--os-family"),
720
- registry_ref: registryRef,
721
- sha256,
722
- size_bytes: sizeBytes,
1789
+ host: trimmed.slice(atIndex + 1),
1790
+ user: trimmed.slice(0, atIndex),
723
1791
  };
724
1792
  }
725
1793
 
1794
+ function activeMachines(machines) {
1795
+ const terminalStates = new Set([
1796
+ "deleted",
1797
+ "deleting",
1798
+ "error",
1799
+ "failed",
1800
+ "revoked",
1801
+ "revoking",
1802
+ "teardown",
1803
+ "terminated",
1804
+ "terminating",
1805
+ "torn_down",
1806
+ ]);
1807
+ return machines.filter((machine) => {
1808
+ const status = String(machineState(machine, "")).toLowerCase();
1809
+ return !terminalStates.has(status);
1810
+ });
1811
+ }
1812
+
1813
+ function writeMutationWithOpen(stdout, resource, opened, options, label) {
1814
+ if (options.json) {
1815
+ writeJson(stdout, { resource, opened: opened.opened, url: opened.url });
1816
+ return;
1817
+ }
1818
+ stdout.write(`${label} updated.\n`);
1819
+ if (resource?.id) {
1820
+ stdout.write(`ID: ${resource.id}\n`);
1821
+ }
1822
+ writeCheckoutOpenResult(stdout, opened, label);
1823
+ }
1824
+
1825
+ function writeSshKeyList(stdout, keys) {
1826
+ const activeKeys = activeSshKeys(keys);
1827
+ if (!activeKeys.length) {
1828
+ stdout.write("No SSH keys found.\n");
1829
+ return;
1830
+ }
1831
+ stdout.write("SSH keys:\n");
1832
+ for (const key of activeKeys) {
1833
+ const label = key.label ? ` ${key.label}` : "";
1834
+ const fingerprint = key.fingerprint ? ` ${key.fingerprint}` : "";
1835
+ stdout.write(`- ${key.id || "key"}${label}${fingerprint}\n`);
1836
+ }
1837
+ }
1838
+
1839
+ function writeReservationSshKeyList(stdout, keys, reservationId) {
1840
+ const activeKeys = activeSshKeys(keys);
1841
+ if (!activeKeys.length) {
1842
+ stdout.write("No reservation SSH keys found.\n");
1843
+ return;
1844
+ }
1845
+ stdout.write(`Reservation SSH keys for ${reservationId}:\n`);
1846
+ for (const key of activeKeys) {
1847
+ const label = key.label ? ` ${key.label}` : "";
1848
+ const fingerprint = key.fingerprint ? ` ${key.fingerprint}` : "";
1849
+ const linuxUsername = key.linux_username ? ` ${key.linux_username}` : "";
1850
+ stdout.write(`- ${key.id || "key"}${label}${fingerprint}${linuxUsername}\n`);
1851
+ }
1852
+ }
1853
+
1854
+ function writeReservationKeyStatus(stdout, status) {
1855
+ const keys = Array.isArray(status.keys) ? status.keys : [];
1856
+ if (!keys.length) {
1857
+ stdout.write("No reservation SSH keys found.\n");
1858
+ stdout.write(
1859
+ `Add one with: ornn access keys add ${status.reservation_id} --public-key-file ~/.ssh/id_ed25519.pub\n`,
1860
+ );
1861
+ return;
1862
+ }
1863
+
1864
+ stdout.write(`Reservation SSH key status for ${status.reservation_id}:\n`);
1865
+ for (const key of keys) {
1866
+ const label = key.label ? ` ${key.label}` : "";
1867
+ const fingerprint = key.fingerprint ? ` ${key.fingerprint}` : "";
1868
+ stdout.write(`- ${key.id || "key"}${label}${fingerprint}\n`);
1869
+ if (key.linux_username) {
1870
+ stdout.write(` Linux user: ${key.linux_username}\n`);
1871
+ }
1872
+ const machines = Array.isArray(key.machines) ? key.machines : [];
1873
+ if (!machines.length) {
1874
+ stdout.write(" No active bare-metal machines yet.\n");
1875
+ continue;
1876
+ }
1877
+ for (const machine of machines) {
1878
+ stdout.write(` ${machine.machine_id} ${machine.machine_state}: ${machine.status}\n`);
1879
+ writeOptionalStatusLine(stdout, "Linux user", machine.linux_username);
1880
+ writeOptionalStatusLine(stdout, "Queued", machine.queued_at);
1881
+ writeOptionalStatusLine(stdout, "Pushed", machine.pushed_at);
1882
+ writeOptionalStatusLine(stdout, "Failed", machine.failed_at);
1883
+ writeOptionalStatusLine(stdout, "Reason", machine.failure_reason);
1884
+ writeOptionalStatusLine(stdout, "Request", machine.request_id);
1885
+ }
1886
+ }
1887
+ }
1888
+
1889
+ function writeOptionalStatusLine(stdout, label, value) {
1890
+ if (value !== undefined && value !== null && value !== "") {
1891
+ stdout.write(` ${label}: ${value}\n`);
1892
+ }
1893
+ }
1894
+
1895
+ function activeSshKeys(keys) {
1896
+ return keys.filter((key) => String(key.status || "active").toLowerCase() === "active" && !key.revoked_at);
1897
+ }
1898
+
726
1899
  function requiredArrayOption(value, name) {
727
- const values = arrayOption(value);
728
- if (!values.length) {
1900
+ const values = arrayOption(value).map((item) => String(item).trim());
1901
+ if (!values.length || values.some((item) => !item)) {
729
1902
  throw new Error(`${name} is required.`);
730
1903
  }
731
1904
  return values;
@@ -780,13 +1953,25 @@ function parsePositiveNumber(value, option) {
780
1953
  }
781
1954
 
782
1955
  function formatUser(user = {}) {
783
- const name = user.name ? `${user.name} ` : "";
784
1956
  const email = user.email || "unknown user";
1957
+ if (!user.name) {
1958
+ return email;
1959
+ }
1960
+ const name = `${user.name} `;
785
1961
  return `${name}<${email}>`;
786
1962
  }
787
1963
 
788
- function parseOptions(args, { boolean = [] } = {}) {
1964
+ function parseCommandOptions(args, optionSpec, usage) {
1965
+ const { options, positionals } = parseOptions(args, optionSpec);
1966
+ if (positionals.length) {
1967
+ throw new Error(usage);
1968
+ }
1969
+ return options;
1970
+ }
1971
+
1972
+ function parseOptions(args, { boolean = [], value } = {}) {
789
1973
  const booleanSet = new Set(boolean.map(toCamelCase));
1974
+ const valueSet = new Set((value ?? []).map(toCamelCase));
790
1975
  const options = {};
791
1976
  const positionals = [];
792
1977
 
@@ -802,10 +1987,19 @@ function parseOptions(args, { boolean = [] } = {}) {
802
1987
  const key = toCamelCase(rawKey);
803
1988
  let value;
804
1989
  if (inlineValue !== null) {
1990
+ if (booleanSet.has(key)) {
1991
+ throw new Error(`Unsupported value for --${rawKey}.`);
1992
+ }
1993
+ if (!valueSet.has(key)) {
1994
+ throw new Error(`Unsupported option: --${rawKey}.`);
1995
+ }
805
1996
  value = inlineValue;
806
1997
  } else if (booleanSet.has(key)) {
807
1998
  value = true;
808
1999
  } else {
2000
+ if (!valueSet.has(key)) {
2001
+ throw new Error(`Unsupported option: --${rawKey}.`);
2002
+ }
809
2003
  value = requireValue(args, index, arg);
810
2004
  index += 1;
811
2005
  }
@@ -855,6 +2049,76 @@ function numberOption(value, name) {
855
2049
  return parsed;
856
2050
  }
857
2051
 
2052
+ function optionProvided(value) {
2053
+ return value !== undefined && value !== null;
2054
+ }
2055
+
2056
+ function bidPriceOption(options) {
2057
+ return positiveNumberOption(optionProvided(options.price) ? options.price : options.bidPricePerGpuHour, "--price");
2058
+ }
2059
+
2060
+ function positiveNumberOption(value, name) {
2061
+ const parsed = numberOption(value, name);
2062
+ if (parsed <= 0) {
2063
+ throw new Error(`${name} must be greater than 0.`);
2064
+ }
2065
+ return parsed;
2066
+ }
2067
+
2068
+ function positiveIntegerOption(value, name) {
2069
+ const parsed = numberOption(value, name);
2070
+ if (!Number.isInteger(parsed) || parsed <= 0) {
2071
+ throw new Error(`${name} must be a positive integer.`);
2072
+ }
2073
+ return parsed;
2074
+ }
2075
+
2076
+ function dateRangeOptions(options) {
2077
+ const startDate = dateOption(options.startDate, "--start-date");
2078
+ const endDate = dateOption(options.endDate, "--end-date");
2079
+ if (compareDateOnly(startDate, endDate) > 0) {
2080
+ throw new Error("--start-date must be on or before --end-date.");
2081
+ }
2082
+ return { endDate, startDate };
2083
+ }
2084
+
2085
+ function dateOption(value, name) {
2086
+ const text = requiredOption(value, name);
2087
+ if (typeof text !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(text) || !parseStrictDateOnly(text)) {
2088
+ throw new Error(`${name} must be a date in yyyy-mm-dd format.`);
2089
+ }
2090
+ return text;
2091
+ }
2092
+
2093
+ function parseStrictDateOnly(value) {
2094
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
2095
+ if (!match) {
2096
+ return null;
2097
+ }
2098
+ const year = Number(match[1]);
2099
+ const month = Number(match[2]);
2100
+ const day = Number(match[3]);
2101
+ const parsed = new Date(Date.UTC(year, month - 1, day));
2102
+ if (
2103
+ parsed.getUTCFullYear() !== year ||
2104
+ parsed.getUTCMonth() !== month - 1 ||
2105
+ parsed.getUTCDate() !== day
2106
+ ) {
2107
+ return null;
2108
+ }
2109
+ return parsed;
2110
+ }
2111
+
2112
+ function compareDateOnly(left, right) {
2113
+ return parseStrictDateOnly(left).getTime() - parseStrictDateOnly(right).getTime();
2114
+ }
2115
+
2116
+ function validateMinGpuCount(minGpuCount, gpuCount) {
2117
+ if (minGpuCount > gpuCount) {
2118
+ throw new Error("--min-gpu-count must be less than or equal to --gpu-count.");
2119
+ }
2120
+ }
2121
+
858
2122
  function arrayOption(value) {
859
2123
  if (value === undefined || value === null || value === "") {
860
2124
  return [];
@@ -900,3 +2164,144 @@ function writeOutput(stdout, payload, { json = true, raw = false } = {}) {
900
2164
  function writeJson(stdout, payload) {
901
2165
  stdout.write(`${JSON.stringify(payload ?? { ok: true }, null, 2)}\n`);
902
2166
  }
2167
+
2168
+ function formatCliError(error) {
2169
+ if (error instanceof CliApiError) {
2170
+ const structuredDetail = extractStructuredErrorDetail(error.detail);
2171
+ if (structuredDetail) {
2172
+ return formatStructuredProvisioningError(structuredDetail);
2173
+ }
2174
+ }
2175
+ return error instanceof Error ? error.message : String(error);
2176
+ }
2177
+
2178
+ function extractStructuredErrorDetail(detail) {
2179
+ const candidate = detail?.detail ?? detail?.error ?? detail;
2180
+ if (isStructuredErrorCode(candidate)) {
2181
+ return { code: candidate };
2182
+ }
2183
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
2184
+ return null;
2185
+ }
2186
+ if (typeof candidate.code !== "string" && typeof candidate.summary !== "string") {
2187
+ return null;
2188
+ }
2189
+ return candidate;
2190
+ }
2191
+
2192
+ function isStructuredErrorCode(value) {
2193
+ if (typeof value !== "string" || !value.trim()) {
2194
+ return false;
2195
+ }
2196
+ const normalized = value.trim().toLowerCase();
2197
+ if (!/^[a-z][a-z0-9_-]*$/.test(normalized)) {
2198
+ return false;
2199
+ }
2200
+ return (
2201
+ normalized.includes("unavailable") ||
2202
+ normalized.includes("capacity") ||
2203
+ normalized.includes("invalid") ||
2204
+ normalized.includes("validation") ||
2205
+ normalized.includes("auth") ||
2206
+ normalized.includes("forbidden") ||
2207
+ normalized.includes("unauthorized") ||
2208
+ normalized.includes("provisioning") ||
2209
+ normalized.includes("failure")
2210
+ );
2211
+ }
2212
+
2213
+ function formatStructuredProvisioningError(detail) {
2214
+ const lines = [];
2215
+ const summary = textValue(detail.summary) || messageForErrorCode(detail.code) || "Ornn request failed.";
2216
+ lines.push(summary);
2217
+
2218
+ const code = textValue(detail.code);
2219
+ if (code) {
2220
+ lines.push(`Code: ${code}`);
2221
+ }
2222
+
2223
+ for (const reason of listValues(detail.reasons)) {
2224
+ lines.push(`Reason: ${humanizeErrorToken(reason)}`);
2225
+ }
2226
+
2227
+ const nextSteps = nextStepsForStructuredError(detail);
2228
+ if (nextSteps.length) {
2229
+ const label = nextSteps.length === 1 ? "Next step" : "Next steps";
2230
+ lines.push(`${label}: ${nextSteps.join(" ")}`);
2231
+ } else {
2232
+ lines.push(`Next step: ${defaultNextStepForErrorCode(code)}`);
2233
+ }
2234
+
2235
+ return lines.join("\n");
2236
+ }
2237
+
2238
+ function nextStepsForStructuredError(detail) {
2239
+ const code = textValue(detail.code);
2240
+ const nextSteps = listValues(detail.next_steps ?? detail.nextSteps);
2241
+ if (code.toLowerCase() !== "bare_metal_unavailable") {
2242
+ return nextSteps;
2243
+ }
2244
+ return nextSteps.filter((step) => !/\bvm\b|access mode/i.test(step));
2245
+ }
2246
+
2247
+ function messageForErrorCode(code) {
2248
+ if (!code) {
2249
+ return "";
2250
+ }
2251
+ const normalized = String(code).toLowerCase();
2252
+ if (normalized.includes("unavailable") || normalized.includes("capacity")) {
2253
+ return "Requested machine capacity is not available.";
2254
+ }
2255
+ if (normalized.includes("invalid") || normalized.includes("validation")) {
2256
+ return "The request is invalid.";
2257
+ }
2258
+ if (
2259
+ normalized.includes("auth") ||
2260
+ normalized.includes("forbidden") ||
2261
+ normalized.includes("unauthorized")
2262
+ ) {
2263
+ return "You are not authorized to perform this machine action.";
2264
+ }
2265
+ return "Machine provisioning failed.";
2266
+ }
2267
+
2268
+ function defaultNextStepForErrorCode(code) {
2269
+ if (!code) {
2270
+ return "Contact Ornn support with this error code.";
2271
+ }
2272
+ const normalized = String(code).toLowerCase();
2273
+ if (normalized === "bare_metal_unavailable") {
2274
+ return "Choose Bare Metal-capable capacity when available, or contact Ornn support with this error code.";
2275
+ }
2276
+ if (normalized.includes("unavailable") || normalized.includes("capacity")) {
2277
+ return "Try a different access mode or contact Ornn support with this error code.";
2278
+ }
2279
+ if (normalized.includes("invalid") || normalized.includes("validation")) {
2280
+ return "Check the command inputs and try again.";
2281
+ }
2282
+ if (
2283
+ normalized.includes("auth") ||
2284
+ normalized.includes("forbidden") ||
2285
+ normalized.includes("unauthorized")
2286
+ ) {
2287
+ return "Run `ornn login` with an approved tenant account and try again.";
2288
+ }
2289
+ return "Contact Ornn support with this error code.";
2290
+ }
2291
+
2292
+ function textValue(value) {
2293
+ return typeof value === "string" && value.trim() ? value.trim() : "";
2294
+ }
2295
+
2296
+ function listValues(value) {
2297
+ if (!Array.isArray(value)) {
2298
+ return [];
2299
+ }
2300
+ return value
2301
+ .filter((item) => typeof item === "string" && item.trim())
2302
+ .map((item) => item.trim());
2303
+ }
2304
+
2305
+ function humanizeErrorToken(value) {
2306
+ return value.replace(/[_-]+/g, " ");
2307
+ }