@ornncompute/cli 0.1.0 → 0.1.1

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