@ornncompute/cli 0.1.3 → 0.1.4

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
@@ -8,6 +8,7 @@ import {
8
8
  CliApiError,
9
9
  cliRequest,
10
10
  computeEndpoint,
11
+ operatorRequest,
11
12
  } from "./api-client.mjs";
12
13
  import { clearAuthSession, getAuthConfigPath, loadAuthSession, saveAuthSession } from "./auth-store.mjs";
13
14
  import {
@@ -17,6 +18,7 @@ import {
17
18
  startDeviceFlow,
18
19
  waitForDeviceApproval,
19
20
  } from "./device-auth.mjs";
21
+ import { maybeNotifyUpdate, updateCommand } from "./update.mjs";
20
22
 
21
23
  const { version: VERSION } = createRequire(import.meta.url)("../package.json");
22
24
  const DEFAULT_BUY_NOW_USD_PER_GPU_HOUR = 10;
@@ -58,6 +60,14 @@ Examples:
58
60
  ornn clusters create <reservation-id> --type kubernetes --wait
59
61
  ornn networks list
60
62
  ornn storage volumes list
63
+ ornn storage deploy <drive-id> --reservation <reservation-id>
64
+ ornn storage deploy status --reservation <reservation-id>
65
+ ornn storage filesystem deploy --reservation <reservation-id>
66
+ ornn storage filesystem delete --reservation <reservation-id>
67
+ ornn storage buckets connect gcs --bucket <bucket>
68
+ ornn storage buckets connect s3 --bucket <bucket> [--access-key-id <id> --secret-access-key-file <path>]
69
+ ornn storage buckets connect r2 --bucket <bucket> --account-id <id> [--access-key-id <id> --secret-access-key-file <path>]
70
+ ornn storage buckets verify <drive-id>
61
71
  ornn ssh <machine-id>
62
72
 
63
73
  Run \`ornn --help\` for all commands, or \`ornn help <command>\`.
@@ -68,6 +78,7 @@ const HELP = `Ornn CLI
68
78
  Usage:
69
79
  ornn help [command]
70
80
  ornn login [--auth-base <url>] [--no-browser] [--timeout <seconds>]
81
+ ornn update
71
82
  ornn whoami [--json]
72
83
  ornn status [--json]
73
84
  ornn listings list [--gpu-type <type>] [--facility <name>] [--operator <name>] [--json]
@@ -92,6 +103,10 @@ Usage:
92
103
  ornn nodes ssh-command <node-or-reservation-id> [--json]
93
104
  ornn nodes keys attach <node-id> --key <path|id|label> [--json]
94
105
  ornn nodes keys list <node-id> [--json]
106
+ ornn node health <node-id> [--json]
107
+ ornn node diagnose <node-id> [--json]
108
+ ornn node drain <node-id> [--reason <text>] [--json]
109
+ ornn node restore <node-id> [--reason <text>] [--json]
95
110
  ornn ssh <node-or-reservation-id> [--print] [--identity-file <path>] [--user <name>] [--json]
96
111
  ornn metrics nodes [--json]
97
112
  ornn metrics node <node-id> [--json]
@@ -124,6 +139,17 @@ Usage:
124
139
  ornn storage volumes refresh <drive-id> [--json]
125
140
  ornn storage volumes clear <drive-id> [--json]
126
141
  ornn storage volumes delete <drive-id> [--json]
142
+ ornn storage deploy <drive-id> --reservation <reservation-id> [--mount-path <path>] [--read-only|--read-write] [--json]
143
+ ornn storage deploy status --reservation <reservation-id> [--json]
144
+ ornn storage filesystem deploy --reservation <reservation-id> [--performance-tier <tier>] [--capacity-gib <gib>] [--json]
145
+ ornn storage filesystem status --reservation <reservation-id> [--json]
146
+ ornn storage filesystem delete --reservation <reservation-id> [--json]
147
+ ornn storage buckets list [--json]
148
+ ornn storage buckets show <drive-id> [--json]
149
+ ornn storage buckets connect gcs|s3|r2 --bucket <bucket>|--url <url> [--name <name>] [--prefix <prefix>] [--region <region>] [--account-id <id>|--endpoint-url <url>] [--access-key-id <id> --secret-access-key-file <path>] [--read-only|--read-write] [--verify] [--json]
150
+ ornn storage buckets update-credentials <drive-id> --access-key-id <id> --secret-access-key-file <path> [--json]
151
+ ornn storage buckets verify <drive-id> [--json]
152
+ ornn storage buckets disconnect <drive-id> [--json]
127
153
  ornn keys list [--json]
128
154
  ornn keys add [<public-key-file>] [--public-key <key>] [--label <label>] [--json]
129
155
  ornn keys delete <key-id> [--json]
@@ -151,10 +177,17 @@ Aliases:
151
177
  exchange = bid
152
178
  gpus = reservations
153
179
 
180
+ Operator commands:
181
+ The \`ornn node\` commands are for Ornn operators/reviewers. They wrap the
182
+ internal-reviewer-gated compute endpoints directly and require
183
+ ORNN_COMPUTE_BASE_URL and ORNN_INTERNAL_REVIEW_SECRET (see Environment).
184
+
154
185
  Environment:
155
- ORNN_AUTH_BASE_URL Web/auth server origin. Defaults to http://localhost:3000.
156
- ORNN_API_BASE_URL Optional web API origin. Defaults to ORNN_AUTH_BASE_URL.
157
- ORNN_CONFIG_HOME Directory for CLI auth state. Defaults to ~/.config/ornn.
186
+ ORNN_AUTH_BASE_URL Web/auth server origin. Defaults to http://localhost:3000.
187
+ ORNN_API_BASE_URL Optional web API origin. Defaults to ORNN_AUTH_BASE_URL.
188
+ ORNN_CONFIG_HOME Directory for CLI auth state. Defaults to ~/.config/ornn.
189
+ ORNN_COMPUTE_BASE_URL Internal compute service origin for \`ornn node\` operator commands.
190
+ ORNN_INTERNAL_REVIEW_SECRET Reviewer secret for \`ornn node\` operator commands.
158
191
 
159
192
  Output:
160
193
  Commands print human-readable output by default.
@@ -185,20 +218,37 @@ const HELP_TOPICS = new Set([
185
218
  "metrics",
186
219
  "network",
187
220
  "networks",
221
+ "node",
188
222
  "nodes",
189
223
  "reservations",
190
224
  "ssh",
191
225
  "ssh-keys",
192
226
  "status",
193
227
  "storage",
228
+ "update",
194
229
  "whoami",
195
230
  ]);
196
231
 
232
+ const NODE_OP_SUBCOMMANDS = new Set(["health", "diagnose", "drain", "restore"]);
197
233
  const LISTINGS_COMMANDS = new Set(["availability", "listing", "listings"]);
198
234
  const EXCHANGE_COMMANDS = new Set(["bid", "bids", "exchange"]);
199
235
  const GPU_COMMANDS = new Set(["gpu", "gpus", "reservations"]);
200
236
 
201
237
  export async function run(argv = [], io = {}) {
238
+ const exitCode = await dispatch(argv, io);
239
+ if (argv[0] !== "update") {
240
+ await maybeNotifyUpdate({
241
+ binPath: io.binPath ?? process.argv[1],
242
+ currentVersion: VERSION,
243
+ env: io.env ?? process.env,
244
+ fetchImpl: io.fetch ?? fetch,
245
+ stderr: io.stderr ?? process.stderr,
246
+ });
247
+ }
248
+ return exitCode;
249
+ }
250
+
251
+ async function dispatch(argv = [], io = {}) {
202
252
  const stdout = io.stdout ?? process.stdout;
203
253
  const stderr = io.stderr ?? process.stderr;
204
254
  const env = io.env ?? process.env;
@@ -244,6 +294,19 @@ export async function run(argv = [], io = {}) {
244
294
  return await login(args, { env, fetchImpl, openBrowserImpl, stderr, stdout });
245
295
  }
246
296
 
297
+ if (command === "update") {
298
+ parseCommandOptions(args, {}, "Usage: ornn update");
299
+ return await updateCommand({
300
+ binPath: io.binPath ?? process.argv[1],
301
+ currentVersion: VERSION,
302
+ env,
303
+ fetchImpl,
304
+ spawnProcess,
305
+ stderr,
306
+ stdout,
307
+ });
308
+ }
309
+
247
310
  if (command === "whoami" || command === "account") {
248
311
  return await whoami(args, { env, fetchImpl, stderr, stdout });
249
312
  }
@@ -272,6 +335,10 @@ export async function run(argv = [], io = {}) {
272
335
  return await reservations(args, { commandName: command, env, fetchImpl, openBrowserImpl, stdout });
273
336
  }
274
337
 
338
+ if (command === "node") {
339
+ return await nodeOps(args, { env, fetchImpl, stdout });
340
+ }
341
+
275
342
  if (command === "nodes") {
276
343
  return await nodes(args, { env, fetchImpl, openBrowserImpl, spawnProcess, stdout });
277
344
  }
@@ -354,6 +421,10 @@ function validateHelpInvocation(command, args) {
354
421
  return validateNoPositionals(args, "Usage: ornn logout");
355
422
  }
356
423
 
424
+ if (command === "update") {
425
+ return validateNoPositionals(args, "Usage: ornn update");
426
+ }
427
+
357
428
  if (command === "whoami" || command === "account") {
358
429
  return validateNoPositionals(args, "Usage: ornn whoami [--json]", { booleanOptions: ["--json"] });
359
430
  }
@@ -433,6 +504,18 @@ function validateHelpInvocation(command, args) {
433
504
  return `Usage: ornn ${usageCommand} list|show|checkout`;
434
505
  }
435
506
 
507
+ if (command === "node") {
508
+ const parsed = parseHelpArgs(args, { booleanOptions: ["--json"], valueOptions: ["--reason"] });
509
+ if (parsed.error) {
510
+ return parsed.error;
511
+ }
512
+ const [subcommand, , ...extra] = parsed.positionals;
513
+ if (!subcommand || !NODE_OP_SUBCOMMANDS.has(subcommand)) {
514
+ return "Usage: ornn node health|diagnose|drain|restore <node-id>";
515
+ }
516
+ return extra.length ? nodeOpUsage(subcommand) : null;
517
+ }
518
+
436
519
  if (command === "nodes") {
437
520
  const parsed = parseHelpArgs(args, {
438
521
  booleanOptions: ["--json", "--no-open", "--open", "--wait"],
@@ -587,8 +670,8 @@ function validateHelpInvocation(command, args) {
587
670
 
588
671
  if (command === "storage") {
589
672
  const parsed = parseHelpArgs(args, {
590
- booleanOptions: ["--json"],
591
- valueOptions: ["--name", "--source", "--source-drive-id"],
673
+ booleanOptions: ["--json", "--read-only", "--read-write", "--verify"],
674
+ valueOptions: ["--access-key-id", "--account-id", "--bucket", "--endpoint-url", "--mount-path", "--name", "--prefix", "--region", "--reservation", "--reservation-id", "--secret-access-key", "--secret-access-key-file", "--source", "--source-drive-id", "--url"],
592
675
  });
593
676
  if (parsed.error) {
594
677
  return parsed.error;
@@ -597,8 +680,38 @@ function validateHelpInvocation(command, args) {
597
680
  if (!resource) {
598
681
  return null;
599
682
  }
683
+ if (resource === "deploy") {
684
+ return !subcommand || id || extra.length
685
+ ? "Usage: ornn storage deploy <drive-id> --reservation <reservation-id> [--mount-path <path>] [--read-only|--read-write] [--json]"
686
+ : null;
687
+ }
688
+ if (resource === "buckets") {
689
+ if (!subcommand || subcommand === "list") {
690
+ return id || extra.length ? "Usage: ornn storage buckets list [--json]" : null;
691
+ }
692
+ if (subcommand === "show") {
693
+ return !id || extra.length ? "Usage: ornn storage buckets show <drive-id> [--json]" : null;
694
+ }
695
+ if (subcommand === "connect") {
696
+ return !["gcs", "s3", "r2"].includes(id) || extra.length
697
+ ? "Usage: ornn storage buckets connect gcs|s3|r2 --bucket <bucket>|--url <url> [--name <name>] [--prefix <prefix>] [--region <region>] [--account-id <id>|--endpoint-url <url>] [--access-key-id <id> --secret-access-key-file <path>] [--read-only|--read-write] [--verify] [--json]"
698
+ : null;
699
+ }
700
+ if (subcommand === "verify") {
701
+ return !id || extra.length ? "Usage: ornn storage buckets verify <drive-id> [--json]" : null;
702
+ }
703
+ if (subcommand === "update-credentials") {
704
+ return !id || extra.length
705
+ ? "Usage: ornn storage buckets update-credentials <drive-id> --access-key-id <id> --secret-access-key-file <path> [--json]"
706
+ : null;
707
+ }
708
+ if (subcommand === "disconnect") {
709
+ return !id || extra.length ? "Usage: ornn storage buckets disconnect <drive-id> [--json]" : null;
710
+ }
711
+ return "Usage: ornn storage buckets list|show|connect|verify|update-credentials|disconnect";
712
+ }
600
713
  if (!["drives", "volumes"].includes(resource)) {
601
- return "Usage: ornn storage volumes list|show|create|refresh|clear|delete";
714
+ return "Usage: ornn storage volumes list|show|create|refresh|clear|delete; ornn storage buckets connect gcs|s3|r2 --bucket <bucket>";
602
715
  }
603
716
  if (!subcommand || subcommand === "list") {
604
717
  return id || extra.length ? "Usage: ornn storage volumes list [--json]" : null;
@@ -1157,6 +1270,116 @@ async function reservations(args, context) {
1157
1270
  throw new Error(commandUsage);
1158
1271
  }
1159
1272
 
1273
+ // Operator-only surface (`ornn node`); distinct from tenant `ornn nodes`.
1274
+ async function nodeOps(args, context) {
1275
+ const usage = "Usage: ornn node health|diagnose|drain|restore <node-id>";
1276
+ const [subcommand, id, ...rest] = args;
1277
+ if (!subcommand || !NODE_OP_SUBCOMMANDS.has(subcommand)) {
1278
+ throw new Error(usage);
1279
+ }
1280
+ if (!id) {
1281
+ throw new Error(nodeOpUsage(subcommand));
1282
+ }
1283
+
1284
+ if (subcommand === "health") {
1285
+ const options = parseCommandOptions(rest, { boolean: ["json"] }, nodeOpUsage(subcommand));
1286
+ const node = await operatorRequest({
1287
+ endpoint: `/provisioning/nodes/${encodeURIComponent(id)}`,
1288
+ env: context.env,
1289
+ fetchImpl: context.fetchImpl,
1290
+ });
1291
+ if (options.json) {
1292
+ writeJson(context.stdout, redactNodeSecrets(node));
1293
+ } else {
1294
+ writeNodeHealth(context.stdout, node);
1295
+ }
1296
+ return 0;
1297
+ }
1298
+
1299
+ if (subcommand === "diagnose") {
1300
+ const options = parseCommandOptions(rest, { boolean: ["json"] }, nodeOpUsage(subcommand));
1301
+ // source.mode tags the validation run as CLI-originated for compute audit.
1302
+ const packet = await operatorRequest({
1303
+ body: { source: { mode: "cli" } },
1304
+ endpoint: `/provisioning/nodes/${encodeURIComponent(id)}/tests`,
1305
+ env: context.env,
1306
+ fetchImpl: context.fetchImpl,
1307
+ method: "POST",
1308
+ });
1309
+ if (options.json) {
1310
+ writeJson(context.stdout, packet);
1311
+ } else {
1312
+ context.stdout.write(`Node validation test run queued for node ${id}.\n`);
1313
+ writeOptionalStatusLine(context.stdout, "Packet", packet?.packet_id ?? packet?.id);
1314
+ writeOptionalStatusLine(context.stdout, "Request", packet?.request_id);
1315
+ }
1316
+ return 0;
1317
+ }
1318
+
1319
+ // drain / restore share POST body + reason handling.
1320
+ const options = parseCommandOptions(
1321
+ rest,
1322
+ { boolean: ["json"], value: ["reason"] },
1323
+ nodeOpUsage(subcommand),
1324
+ );
1325
+ const reason = optionalStringOption(options.reason);
1326
+ const node = await operatorRequest({
1327
+ body: reason ? { reason } : {},
1328
+ endpoint: `/provisioning/nodes/${encodeURIComponent(id)}/${subcommand}`,
1329
+ env: context.env,
1330
+ fetchImpl: context.fetchImpl,
1331
+ method: "POST",
1332
+ });
1333
+ if (options.json) {
1334
+ writeJson(context.stdout, redactNodeSecrets(node));
1335
+ } else {
1336
+ context.stdout.write(`Node ${subcommand} requested: ${node?.id ?? id}\n`);
1337
+ writeNodeHealth(context.stdout, node);
1338
+ }
1339
+ return 0;
1340
+ }
1341
+
1342
+ function nodeOpUsage(subcommand) {
1343
+ if (subcommand === "drain" || subcommand === "restore") {
1344
+ return `Usage: ornn node ${subcommand} <node-id> [--reason <text>] [--json]`;
1345
+ }
1346
+ return `Usage: ornn node ${subcommand} <node-id> [--json]`;
1347
+ }
1348
+
1349
+ function writeNodeHealth(stdout, node) {
1350
+ stdout.write(`${node?.id ?? "unknown"}\n`);
1351
+ stdout.write(`Status: ${node?.status || "unknown"}\n`);
1352
+
1353
+ const readiness = node?.health_readiness;
1354
+ const reasons = Array.isArray(readiness?.reasons) ? readiness.reasons : [];
1355
+ const readyLabel = readiness?.ready ? "ready" : readiness?.status || "blocked";
1356
+ stdout.write(`Launch readiness: ${readyLabel}\n`);
1357
+ // Compute encodes passive faults as `passive_fault:<name>` in readiness.reasons.
1358
+ const blockers = reasons.filter((reason) => !String(reason).startsWith("passive_fault:"));
1359
+ if (blockers.length) {
1360
+ stdout.write("Blockers:\n");
1361
+ for (const reason of blockers) {
1362
+ stdout.write(` - ${reason}\n`);
1363
+ }
1364
+ }
1365
+
1366
+ const passiveFaults = reasons
1367
+ .filter((reason) => String(reason).startsWith("passive_fault:"))
1368
+ .map((reason) => String(reason).slice("passive_fault:".length));
1369
+ if (passiveFaults.length) {
1370
+ stdout.write("Passive faults:\n");
1371
+ for (const fault of passiveFaults) {
1372
+ stdout.write(` - ${fault}\n`);
1373
+ }
1374
+ } else {
1375
+ stdout.write("Passive faults: none\n");
1376
+ }
1377
+
1378
+ stdout.write(`Validation: ${node?.validation_status || "unknown"}\n`);
1379
+ writeOptionalStatusLine(stdout, "Validation checked", node?.validation_checked_at);
1380
+ writeOptionalStatusLine(stdout, "Last heartbeat", node?.last_heartbeat_at);
1381
+ }
1382
+
1160
1383
  async function nodes(args, context) {
1161
1384
  const [subcommand = "list", id, nested, ...rest] = args;
1162
1385
 
@@ -1837,9 +2060,346 @@ async function networks(args, context) {
1837
2060
  }
1838
2061
 
1839
2062
  async function storage(args, context) {
1840
- const [resource = "volumes", subcommand = "list", id, ...rest] = args;
2063
+ const [resource = "volumes", rawSubcommand, id, ...rest] = args;
2064
+ const subcommand =
2065
+ rawSubcommand ?? (resource === "buckets" || ["drives", "volumes"].includes(resource) ? "list" : undefined);
2066
+ if (resource === "filesystem" || resource === "fileshare") {
2067
+ if (subcommand === "deploy") {
2068
+ const options = parseCommandOptions(
2069
+ [id, ...rest].filter(Boolean),
2070
+ {
2071
+ boolean: ["json"],
2072
+ value: ["capacity-gib", "performance-tier", "reservation", "reservation-id"],
2073
+ },
2074
+ "Usage: ornn storage filesystem deploy --reservation <reservation-id> [--performance-tier <tier>] [--capacity-gib <gib>] [--json]",
2075
+ );
2076
+ const reservationId = requiredOption(options.reservation || options.reservationId, "--reservation");
2077
+ const payload = await cliRequest({
2078
+ body: {
2079
+ ...(optionProvided(options.performanceTier) ? { performance_tier: requiredOption(options.performanceTier, "--performance-tier") } : {}),
2080
+ ...(optionProvided(options.capacityGib) ? { capacity_gib: positiveIntegerOption(options.capacityGib, "--capacity-gib") } : {}),
2081
+ },
2082
+ endpoint: computeEndpoint(`/standalone-vms/reservations/${encodeURIComponent(reservationId)}/filesystem-deployment`),
2083
+ env: context.env,
2084
+ fetchImpl: context.fetchImpl,
2085
+ method: "POST",
2086
+ });
2087
+ if (options.json) {
2088
+ writeJson(context.stdout, payload);
2089
+ } else {
2090
+ context.stdout.write("Filesystem deployment started.\n");
2091
+ writeReservationStorageAttachment(context.stdout, payload.attachment);
2092
+ }
2093
+ return 0;
2094
+ }
2095
+ if (subcommand === "status") {
2096
+ const options = parseCommandOptions(
2097
+ [id, ...rest].filter(Boolean),
2098
+ {
2099
+ boolean: ["json"],
2100
+ value: ["reservation", "reservation-id"],
2101
+ },
2102
+ "Usage: ornn storage filesystem status --reservation <reservation-id> [--json]",
2103
+ );
2104
+ const reservationId = requiredOption(options.reservation || options.reservationId, "--reservation");
2105
+ const payload = await cliRequest({
2106
+ endpoint: computeEndpoint(`/standalone-vms/reservations/${encodeURIComponent(reservationId)}/storage-attachment`),
2107
+ env: context.env,
2108
+ fetchImpl: context.fetchImpl,
2109
+ });
2110
+ if (options.json) {
2111
+ writeJson(context.stdout, payload);
2112
+ } else {
2113
+ context.stdout.write("Filesystem deployment status.\n");
2114
+ writeReservationStorageAttachment(context.stdout, payload.attachment);
2115
+ }
2116
+ return 0;
2117
+ }
2118
+ if (subcommand === "delete" || subcommand === "teardown") {
2119
+ const options = parseCommandOptions(
2120
+ [id, ...rest].filter(Boolean),
2121
+ {
2122
+ boolean: ["json"],
2123
+ value: ["reservation", "reservation-id"],
2124
+ },
2125
+ "Usage: ornn storage filesystem delete --reservation <reservation-id> [--json]",
2126
+ );
2127
+ const reservationId = requiredOption(options.reservation || options.reservationId, "--reservation");
2128
+ const payload = await cliRequest({
2129
+ endpoint: computeEndpoint(`/standalone-vms/reservations/${encodeURIComponent(reservationId)}/filesystem-deployment`),
2130
+ env: context.env,
2131
+ fetchImpl: context.fetchImpl,
2132
+ method: "DELETE",
2133
+ });
2134
+ if (options.json) {
2135
+ writeJson(context.stdout, payload);
2136
+ } else {
2137
+ context.stdout.write("Filesystem deletion requested.\n");
2138
+ writeReservationStorageAttachment(context.stdout, payload.attachment);
2139
+ }
2140
+ return 0;
2141
+ }
2142
+ throw new Error("Usage: ornn storage filesystem deploy|status|delete --reservation <reservation-id> [--json]");
2143
+ }
2144
+ if (resource === "deploy" && subcommand === "status") {
2145
+ const options = parseCommandOptions(
2146
+ [id, ...rest].filter(Boolean),
2147
+ {
2148
+ boolean: ["json"],
2149
+ value: ["reservation", "reservation-id"],
2150
+ },
2151
+ "Usage: ornn storage deploy status --reservation <reservation-id> [--json]",
2152
+ );
2153
+ const reservationId = requiredOption(options.reservation || options.reservationId, "--reservation");
2154
+ const payload = await cliRequest({
2155
+ endpoint: computeEndpoint(`/standalone-vms/reservations/${encodeURIComponent(reservationId)}/storage-attachment`),
2156
+ env: context.env,
2157
+ fetchImpl: context.fetchImpl,
2158
+ });
2159
+ if (options.json) {
2160
+ writeJson(context.stdout, payload);
2161
+ } else {
2162
+ context.stdout.write("Storage deployment status.\n");
2163
+ writeReservationStorageAttachment(context.stdout, payload.attachment);
2164
+ }
2165
+ return 0;
2166
+ }
2167
+ if (resource === "deploy" && subcommand) {
2168
+ const options = parseCommandOptions(
2169
+ [id, ...rest].filter(Boolean),
2170
+ {
2171
+ boolean: ["json", "read-only", "read-write"],
2172
+ value: ["mount-path", "reservation", "reservation-id"],
2173
+ },
2174
+ "Usage: ornn storage deploy <drive-id> --reservation <reservation-id> [--mount-path <path>] [--read-only|--read-write] [--json]",
2175
+ );
2176
+ if (options.readOnly && options.readWrite) {
2177
+ throw new Error("Choose only one of --read-only or --read-write.");
2178
+ }
2179
+ const reservationId = requiredOption(options.reservation || options.reservationId, "--reservation");
2180
+ const payload = await cliRequest({
2181
+ body: {
2182
+ drive_id: subcommand,
2183
+ ...(optionProvided(options.mountPath) ? { mount_path: requiredOption(options.mountPath, "--mount-path") } : {}),
2184
+ access_mode: options.readOnly ? "read-only" : "read-write",
2185
+ },
2186
+ endpoint: computeEndpoint(`/standalone-vms/reservations/${encodeURIComponent(reservationId)}/storage-deployment`),
2187
+ env: context.env,
2188
+ fetchImpl: context.fetchImpl,
2189
+ method: "POST",
2190
+ });
2191
+ if (options.json) {
2192
+ writeJson(context.stdout, payload);
2193
+ } else {
2194
+ context.stdout.write("Storage deployment started.\n");
2195
+ writeReservationStorageAttachment(context.stdout, payload.attachment);
2196
+ }
2197
+ return 0;
2198
+ }
2199
+ if (resource === "deploy") {
2200
+ throw new Error("Usage: ornn storage deploy <drive-id> --reservation <reservation-id> [--mount-path <path>] [--read-only|--read-write] [--json]");
2201
+ }
2202
+ if (resource === "buckets") {
2203
+ if (subcommand === "list") {
2204
+ const options = parseCommandOptions(
2205
+ [id, ...rest].filter(Boolean),
2206
+ { boolean: ["json"] },
2207
+ "Usage: ornn storage buckets list [--json]",
2208
+ );
2209
+ const payload = await fetchStorageDrives(context);
2210
+ const buckets = storageBucketsFromPayload(payload);
2211
+ if (options.json) {
2212
+ writeJson(context.stdout, { buckets });
2213
+ } else {
2214
+ writeStorageBucketList(context.stdout, buckets);
2215
+ }
2216
+ return 0;
2217
+ }
2218
+ if (subcommand === "show" && id) {
2219
+ const options = parseCommandOptions(
2220
+ rest,
2221
+ { boolean: ["json"] },
2222
+ "Usage: ornn storage buckets show <drive-id> [--json]",
2223
+ );
2224
+ const bucket = await findStorageBucket(id, context);
2225
+ if (options.json) {
2226
+ writeJson(context.stdout, bucket);
2227
+ } else {
2228
+ writeStorageDriveDetail(context.stdout, bucket);
2229
+ }
2230
+ return 0;
2231
+ }
2232
+ if (subcommand === "verify" && id) {
2233
+ const options = parseCommandOptions(
2234
+ rest,
2235
+ { boolean: ["json"] },
2236
+ "Usage: ornn storage buckets verify <drive-id> [--json]",
2237
+ );
2238
+ const drive = await cliRequest({
2239
+ endpoint: computeEndpoint(`/standalone-vms/storage-drives/${encodeURIComponent(id)}/verify-source`),
2240
+ env: context.env,
2241
+ fetchImpl: context.fetchImpl,
2242
+ method: "POST",
2243
+ });
2244
+ const verificationFailed = drive?.source?.connection_status === "verification_failed";
2245
+ if (options.json) {
2246
+ writeJson(context.stdout, drive);
2247
+ } else {
2248
+ context.stdout.write(
2249
+ verificationFailed ? "Bucket source verification failed.\n" : "Bucket source verified.\n",
2250
+ );
2251
+ writeStorageDriveDetail(context.stdout, drive);
2252
+ }
2253
+ return verificationFailed ? 1 : 0;
2254
+ }
2255
+ if (subcommand === "update-credentials" && id) {
2256
+ const options = parseCommandOptions(
2257
+ rest,
2258
+ {
2259
+ boolean: ["json"],
2260
+ value: ["access-key-id", "secret-access-key", "secret-access-key-file"],
2261
+ },
2262
+ "Usage: ornn storage buckets update-credentials <drive-id> --access-key-id <id> --secret-access-key-file <path> [--json]",
2263
+ );
2264
+ if (options.secretAccessKey && options.secretAccessKeyFile) {
2265
+ throw new Error("Use only one of --secret-access-key or --secret-access-key-file.");
2266
+ }
2267
+ const secretAccessKey = options.secretAccessKeyFile
2268
+ ? (await readFile(options.secretAccessKeyFile, "utf8")).trim()
2269
+ : optionalStringOption(options.secretAccessKey);
2270
+ if (!secretAccessKey) {
2271
+ throw new Error("--secret-access-key-file or --secret-access-key is required.");
2272
+ }
2273
+ const drive = await cliRequest({
2274
+ body: {
2275
+ external_access_key_id: requiredOption(options.accessKeyId, "--access-key-id"),
2276
+ external_secret_access_key: secretAccessKey,
2277
+ },
2278
+ endpoint: computeEndpoint(`/standalone-vms/storage-drives/${encodeURIComponent(id)}/source-credentials`),
2279
+ env: context.env,
2280
+ fetchImpl: context.fetchImpl,
2281
+ method: "PATCH",
2282
+ });
2283
+ if (options.json) {
2284
+ writeJson(context.stdout, drive);
2285
+ } else {
2286
+ context.stdout.write("Bucket credentials updated.\n");
2287
+ writeStorageDriveDetail(context.stdout, drive);
2288
+ }
2289
+ return 0;
2290
+ }
2291
+ if (subcommand === "disconnect" && id) {
2292
+ const options = parseCommandOptions(
2293
+ rest,
2294
+ { boolean: ["json"] },
2295
+ "Usage: ornn storage buckets disconnect <drive-id> [--json]",
2296
+ );
2297
+ await cliRequest({
2298
+ endpoint: computeEndpoint(`/standalone-vms/storage-drives/${encodeURIComponent(id)}`),
2299
+ env: context.env,
2300
+ fetchImpl: context.fetchImpl,
2301
+ method: "DELETE",
2302
+ });
2303
+ const result = { disconnected: true, drive_id: id };
2304
+ if (options.json) {
2305
+ writeJson(context.stdout, result);
2306
+ } else {
2307
+ context.stdout.write(`Bucket disconnected: ${id}\n`);
2308
+ }
2309
+ return 0;
2310
+ }
2311
+ if (subcommand !== "connect" || !["gcs", "s3", "r2"].includes(id)) {
2312
+ throw new Error(
2313
+ "Usage: ornn storage buckets list|show|connect|verify|update-credentials|disconnect",
2314
+ );
2315
+ }
2316
+ const options = parseCommandOptions(
2317
+ rest,
2318
+ {
2319
+ boolean: ["json", "read-only", "read-write", "verify"],
2320
+ value: ["access-key-id", "account-id", "bucket", "endpoint-url", "name", "prefix", "region", "secret-access-key", "secret-access-key-file", "url"],
2321
+ },
2322
+ "Usage: ornn storage buckets connect gcs|s3|r2 --bucket <bucket>|--url <url> [--name <name>] [--prefix <prefix>] [--region <region>] [--account-id <id>|--endpoint-url <url>] [--access-key-id <id> --secret-access-key-file <path>] [--read-only|--read-write] [--verify] [--json]",
2323
+ );
2324
+ if (options.readOnly && options.readWrite) {
2325
+ throw new Error("Choose only one of --read-only or --read-write.");
2326
+ }
2327
+ if (options.bucket && options.url) {
2328
+ throw new Error("Use only one of --bucket or --url.");
2329
+ }
2330
+ if (id === "gcs" && options.verify) {
2331
+ throw new Error("--verify is only supported for S3-compatible buckets.");
2332
+ }
2333
+ if (options.secretAccessKey && options.secretAccessKeyFile) {
2334
+ throw new Error("Use only one of --secret-access-key or --secret-access-key-file.");
2335
+ }
2336
+ if (id === "gcs" && (options.accessKeyId || options.secretAccessKey || options.secretAccessKeyFile)) {
2337
+ throw new Error("Access key credentials are only supported for S3-compatible buckets.");
2338
+ }
2339
+ const secretAccessKey = options.secretAccessKeyFile
2340
+ ? (await readFile(options.secretAccessKeyFile, "utf8")).trim()
2341
+ : optionalStringOption(options.secretAccessKey);
2342
+ if (Boolean(options.accessKeyId) !== Boolean(secretAccessKey)) {
2343
+ throw new Error("Use --access-key-id with --secret-access-key-file or --secret-access-key.");
2344
+ }
2345
+ const source = storageBucketSourceFromOptions(id, options);
2346
+ const prefix = optionalStringOption(options.prefix) ?? source.prefix;
2347
+ const endpointOptions = { ...options };
2348
+ if (!optionProvided(endpointOptions.accountId) && source.accountId && !optionProvided(endpointOptions.endpointUrl)) {
2349
+ endpointOptions.accountId = source.accountId;
2350
+ }
2351
+ const endpointUrl = storageBucketEndpointUrl(id, endpointOptions);
2352
+ const sourceProvider = id === "gcs" ? "external_gcs" : "s3";
2353
+ const bucketName = source.bucket;
2354
+ let drive = await cliRequest({
2355
+ body: {
2356
+ name: optionalStringOption(options.name) ?? bucketName,
2357
+ provider: "ornn_volume",
2358
+ source_provider: sourceProvider,
2359
+ external_bucket: bucketName,
2360
+ external_prefix: prefix,
2361
+ external_region: optionalStringOption(options.region) ?? source.region ?? (id === "r2" ? "auto" : null),
2362
+ external_read_only: options.readWrite ? false : true,
2363
+ ...(id === "r2" ? { external_s3_provider: "cloudflare_r2" } : {}),
2364
+ ...(id === "s3" ? { external_s3_provider: "aws_s3" } : {}),
2365
+ ...(endpointUrl ? { external_endpoint_url: endpointUrl } : {}),
2366
+ ...(options.accessKeyId ? { external_access_key_id: options.accessKeyId } : {}),
2367
+ ...(secretAccessKey ? { external_secret_access_key: secretAccessKey } : {}),
2368
+ },
2369
+ endpoint: computeEndpoint("/standalone-vms/storage-drives"),
2370
+ env: context.env,
2371
+ fetchImpl: context.fetchImpl,
2372
+ method: "POST",
2373
+ });
2374
+ if (options.verify) {
2375
+ drive = await verifyStorageBucketSource(drive.id, context);
2376
+ }
2377
+ const commands = storageBucketImportCommands({
2378
+ bucket: bucketName,
2379
+ endpointUrl,
2380
+ prefix,
2381
+ provider: id,
2382
+ region: optionalStringOption(options.region) ?? source.region ?? (id === "r2" ? "auto" : null),
2383
+ target: drive.import_target,
2384
+ });
2385
+ if (options.json) {
2386
+ writeJson(context.stdout, { ...drive, import_commands: commands });
2387
+ } else {
2388
+ context.stdout.write("Bucket connected.\n");
2389
+ if (options.verify) {
2390
+ context.stdout.write(
2391
+ drive?.source?.connection_status === "verification_failed"
2392
+ ? "Bucket source verification failed.\n"
2393
+ : "Bucket source verified.\n",
2394
+ );
2395
+ }
2396
+ writeStorageDriveDetail(context.stdout, drive);
2397
+ writeStorageImportCommands(context.stdout, commands);
2398
+ }
2399
+ return drive?.source?.connection_status === "verification_failed" ? 1 : 0;
2400
+ }
1841
2401
  if (!["drives", "volumes"].includes(resource)) {
1842
- throw new Error("Usage: ornn storage volumes list|show|create|refresh|clear|delete");
2402
+ throw new Error("Usage: ornn storage volumes list|show|create|refresh|clear|delete; ornn storage buckets connect gcs|s3|r2 --bucket <bucket>");
1843
2403
  }
1844
2404
 
1845
2405
  if (subcommand === "list") {
@@ -2725,6 +3285,15 @@ async function fetchStorageDrives(context) {
2725
3285
  });
2726
3286
  }
2727
3287
 
3288
+ async function verifyStorageBucketSource(driveId, context) {
3289
+ return await cliRequest({
3290
+ endpoint: computeEndpoint(`/standalone-vms/storage-drives/${encodeURIComponent(driveId)}/verify-source`),
3291
+ env: context.env,
3292
+ fetchImpl: context.fetchImpl,
3293
+ method: "POST",
3294
+ });
3295
+ }
3296
+
2728
3297
  async function findStorageDrive(driveId, context) {
2729
3298
  const payload = await fetchStorageDrives(context);
2730
3299
  const drive = storageDrivesFromPayload(payload).find((item) => String(item.id) === String(driveId));
@@ -2734,6 +3303,15 @@ async function findStorageDrive(driveId, context) {
2734
3303
  return drive;
2735
3304
  }
2736
3305
 
3306
+ async function findStorageBucket(driveId, context) {
3307
+ const payload = await fetchStorageDrives(context);
3308
+ const bucket = storageBucketsFromPayload(payload).find((item) => String(item.id) === String(driveId));
3309
+ if (!bucket) {
3310
+ throw new Error(`Storage bucket not found: ${driveId}`);
3311
+ }
3312
+ return bucket;
3313
+ }
3314
+
2737
3315
  function reservationSshKeysEndpoint(reservationId) {
2738
3316
  return computeEndpoint(`/standalone-vms/reservations/${reservationId}/ssh-keys`);
2739
3317
  }
@@ -3611,6 +4189,63 @@ function writeReservationNetwork(stdout, payload = {}) {
3611
4189
  writeOptionalStatusLine(stdout, "Status", payload.network.status);
3612
4190
  }
3613
4191
 
4192
+ function writeReservationStorageAttachment(stdout, attachment = {}) {
4193
+ if (!attachment) {
4194
+ stdout.write("No reservation storage attachment.\n");
4195
+ return;
4196
+ }
4197
+ stdout.write(`${attachment.id || "storage-attachment"}\n`);
4198
+ writeOptionalStatusLine(stdout, "Reservation", attachment.reservation_id);
4199
+ writeOptionalStatusLine(stdout, "Drive", attachment.drive_id);
4200
+ writeOptionalStatusLine(stdout, "State", attachment.state);
4201
+ writeOptionalStatusLine(stdout, "Target region", attachment.target_region);
4202
+ writeOptionalStatusLine(stdout, "Mount path", attachment.mount_path);
4203
+ writeOptionalStatusLine(stdout, "Mount mode", attachment.mount_mode);
4204
+ writeOptionalStatusLine(stdout, "Access", attachment.access_mode);
4205
+ if (attachment.placement) {
4206
+ const placement = attachment.placement;
4207
+ stdout.write("Placement:\n");
4208
+ writeOptionalStatusLine(stdout, "State", placement.state);
4209
+ writeOptionalStatusLine(
4210
+ stdout,
4211
+ "Target",
4212
+ placement.target_storage_uri ||
4213
+ placement.storage_uri ||
4214
+ storageObjectUri("gs", placement.bucket, placement.prefix),
4215
+ );
4216
+ writeOptionalStatusLine(stdout, "Transfer", placement.transfer_status);
4217
+ if (
4218
+ typeof placement.transfer_object_count === "number" ||
4219
+ typeof placement.transfer_bytes === "number"
4220
+ ) {
4221
+ const objectCount = placement.transfer_object_count;
4222
+ const bytes = placement.transfer_bytes;
4223
+ const objectLabel =
4224
+ typeof objectCount === "number"
4225
+ ? `${objectCount} ${objectCount === 1 ? "object" : "objects"}`
4226
+ : null;
4227
+ const bytesLabel = typeof bytes === "number" ? formatBytes(bytes) : null;
4228
+ writeOptionalStatusLine(
4229
+ stdout,
4230
+ "Copied",
4231
+ [objectLabel, bytesLabel].filter(Boolean).join(", "),
4232
+ );
4233
+ }
4234
+ writeOptionalStatusLine(stdout, "Source provider", placement.source_provider);
4235
+ writeOptionalStatusLine(stdout, "Source bucket", placement.source_bucket);
4236
+ writeOptionalStatusLine(stdout, "Source prefix", placement.source_prefix);
4237
+ }
4238
+ const nfsBackend = attachment.drive?.nfs_backend || attachment.drive?.accelerators?.nfs;
4239
+ if (nfsBackend) {
4240
+ stdout.write("Filesystem:\n");
4241
+ writeOptionalStatusLine(stdout, "State", nfsBackend.state);
4242
+ writeOptionalStatusLine(stdout, "Region", nfsBackend.region);
4243
+ writeOptionalStatusLine(stdout, "Tier", nfsBackend.performance_tier);
4244
+ writeOptionalStatusLine(stdout, "Capacity", nfsBackend.capacity_gib ? `${nfsBackend.capacity_gib} GiB` : null);
4245
+ writeOptionalStatusLine(stdout, "Mount ready", nfsBackend.mount_ready ? "yes" : "no");
4246
+ }
4247
+ }
4248
+
3614
4249
  function writeStorageDriveList(stdout, drives) {
3615
4250
  if (!drives.length) {
3616
4251
  stdout.write("No storage volumes found.\n");
@@ -3623,6 +4258,12 @@ function writeStorageDriveList(stdout, drives) {
3623
4258
  if (drive.source_drive_id) {
3624
4259
  stdout.write(` source=${drive.source_drive_id}`);
3625
4260
  }
4261
+ if (drive.source?.provider && drive.source.provider !== "ornn_object") {
4262
+ stdout.write(` source=${drive.source.provider}`);
4263
+ if (drive.source.bucket) {
4264
+ stdout.write(` bucket=${drive.source.bucket}`);
4265
+ }
4266
+ }
3626
4267
  if (drive.active_mount) {
3627
4268
  stdout.write(` mounted=${drive.active_mount.reservation_id || "yes"}`);
3628
4269
  }
@@ -3630,12 +4271,67 @@ function writeStorageDriveList(stdout, drives) {
3630
4271
  }
3631
4272
  }
3632
4273
 
4274
+ function writeStorageBucketList(stdout, buckets) {
4275
+ if (!buckets.length) {
4276
+ stdout.write("No storage buckets found.\n");
4277
+ return;
4278
+ }
4279
+ stdout.write("Storage buckets:\n");
4280
+ for (const drive of buckets) {
4281
+ const source = drive.source || {};
4282
+ stdout.write(`- ${drive.id} ${drive.name || "unnamed"} ${source.provider || "unknown"}`);
4283
+ if (source.s3_provider) {
4284
+ stdout.write(`/${source.s3_provider}`);
4285
+ }
4286
+ if (source.bucket) {
4287
+ stdout.write(` bucket=${source.bucket}`);
4288
+ }
4289
+ if (source.prefix) {
4290
+ stdout.write(` prefix=${source.prefix}`);
4291
+ }
4292
+ if (source.connection_status) {
4293
+ stdout.write(` status=${source.connection_status}`);
4294
+ }
4295
+ if (source.access_key_id_hint) {
4296
+ stdout.write(` key=${source.access_key_id_hint}`);
4297
+ }
4298
+ if (source.provider && source.provider !== "ornn_object") {
4299
+ stdout.write(` access=${source.read_only === false ? "read-write" : "read-only"}`);
4300
+ }
4301
+ stdout.write("\n");
4302
+ }
4303
+ }
4304
+
3633
4305
  function writeStorageDriveDetail(stdout, drive = {}) {
3634
4306
  stdout.write(`${drive.id || "drive"}\n`);
3635
4307
  stdout.write(`Name: ${drive.name || "unnamed"}\n`);
3636
4308
  stdout.write(`Status: ${drive.status || "unknown"}\n`);
3637
4309
  stdout.write(`Size: ${formatBytes(drive.size_bytes)}\n`);
3638
4310
  writeOptionalStatusLine(stdout, "Source", drive.source_drive_id);
4311
+ if (drive.source) {
4312
+ writeOptionalStatusLine(stdout, "Source provider", drive.source.provider);
4313
+ writeOptionalStatusLine(stdout, "Bucket", drive.source.bucket);
4314
+ writeOptionalStatusLine(stdout, "Prefix", drive.source.prefix);
4315
+ writeOptionalStatusLine(stdout, "Region", drive.source.region);
4316
+ writeOptionalStatusLine(stdout, "S3 provider", drive.source.s3_provider);
4317
+ writeOptionalStatusLine(stdout, "Endpoint", drive.source.endpoint_url);
4318
+ if (drive.source.credentials_configured) {
4319
+ stdout.write(`Credentials: configured${drive.source.access_key_id_hint ? ` (${drive.source.access_key_id_hint})` : ""}\n`);
4320
+ }
4321
+ writeOptionalStatusLine(stdout, "Connection", drive.source.connection_status);
4322
+ writeOptionalStatusLine(stdout, "Verified", drive.source.last_verified_at);
4323
+ if (drive.source.verification_error) {
4324
+ const error = drive.source.verification_error;
4325
+ stdout.write(`Verification error: ${error.code || "failed"}`);
4326
+ if (error.message) {
4327
+ stdout.write(` - ${error.message}`);
4328
+ }
4329
+ stdout.write("\n");
4330
+ }
4331
+ if (drive.source.provider && drive.source.provider !== "ornn_object") {
4332
+ stdout.write(`Access: ${drive.source.read_only === false ? "read-write" : "read-only"}\n`);
4333
+ }
4334
+ }
3639
4335
  writeOptionalStatusLine(stdout, "File tree", drive.file_tree_status);
3640
4336
  if (drive.file_tree_truncated) {
3641
4337
  stdout.write(" File tree: truncated\n");
@@ -3652,6 +4348,20 @@ function writeStorageDriveDetail(stdout, drive = {}) {
3652
4348
  writeOptionalStatusLine(stdout, "Updated", drive.updated_at);
3653
4349
  }
3654
4350
 
4351
+ function writeStorageImportCommands(stdout, commands = []) {
4352
+ if (!commands.length) {
4353
+ return;
4354
+ }
4355
+ stdout.write("\nRun these commands to import data into Ornn object storage:\n");
4356
+ for (const item of commands) {
4357
+ stdout.write(`\n# ${item.label}\n`);
4358
+ if (item.note) {
4359
+ stdout.write(`# ${item.note}\n`);
4360
+ }
4361
+ stdout.write(`${item.command}\n`);
4362
+ }
4363
+ }
4364
+
3655
4365
  function writeBillingSummary(stdout, summary = {}) {
3656
4366
  stdout.write("Billing summary:\n");
3657
4367
  stdout.write(`Open balance: ${formatCents(summary.open_balance_cents)}\n`);
@@ -3745,6 +4455,188 @@ function storageDrivesFromPayload(payload) {
3745
4455
  return Array.isArray(payload) ? payload : Array.isArray(payload?.drives) ? payload.drives : [];
3746
4456
  }
3747
4457
 
4458
+ function storageBucketsFromPayload(payload) {
4459
+ return storageDrivesFromPayload(payload).filter(isStorageBucketDrive);
4460
+ }
4461
+
4462
+ function isStorageBucketDrive(drive = {}) {
4463
+ const source = drive.source || {};
4464
+ return (
4465
+ source.kind === "object" ||
4466
+ ["ornn_object", "external_gcs", "s3"].includes(String(source.provider || ""))
4467
+ );
4468
+ }
4469
+
4470
+ function storageBucketLocationFromInput(value) {
4471
+ const withoutScheme = String(value || "")
4472
+ .trim()
4473
+ .replace(/^(gs|s3|r2):\/\//i, "")
4474
+ .replace(/^\/+|\/+$/g, "");
4475
+ const [bucket, ...prefixParts] = withoutScheme.split("/");
4476
+ return {
4477
+ bucket,
4478
+ prefix: prefixParts.join("/") || null,
4479
+ };
4480
+ }
4481
+
4482
+ function storageBucketSourceFromOptions(provider, options = {}) {
4483
+ const rawBucket = optionalStringOption(options.bucket);
4484
+ const rawUrl = optionalStringOption(options.url);
4485
+ const parsed = rawUrl ? storageBucketSourceFromConsoleUrl(provider, rawUrl) : {};
4486
+ const location = rawBucket ? storageBucketLocationFromInput(rawBucket) : parsed;
4487
+ if (!location.bucket) {
4488
+ throw new Error("--bucket or --url is required.");
4489
+ }
4490
+ return {
4491
+ accountId: parsed.accountId || null,
4492
+ bucket: location.bucket,
4493
+ prefix: location.prefix || null,
4494
+ region: parsed.region || null,
4495
+ };
4496
+ }
4497
+
4498
+ function storageBucketSourceFromConsoleUrl(provider, rawUrl) {
4499
+ let url;
4500
+ try {
4501
+ url = new URL(String(rawUrl || "").trim());
4502
+ } catch {
4503
+ throw new Error("--url must be a valid console URL.");
4504
+ }
4505
+ const hostname = url.hostname.toLowerCase();
4506
+ const pathParts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
4507
+ if (provider === "s3") {
4508
+ const bucketIndex = pathParts.findIndex((part, index) => part === "buckets" && pathParts[index - 1] === "s3");
4509
+ const bucket = bucketIndex >= 0 ? storageConsolePathValue(pathParts[bucketIndex + 1]) : null;
4510
+ const rawRegionFromHost = hostname.endsWith(".console.aws.amazon.com")
4511
+ ? hostname.slice(0, -".console.aws.amazon.com".length)
4512
+ : null;
4513
+ const regionFromHost = rawRegionFromHost && rawRegionFromHost !== "s3" ? rawRegionFromHost : null;
4514
+ const region = url.searchParams.get("region") || regionFromHost;
4515
+ const prefix = url.searchParams.get("prefix");
4516
+ if (!bucket) {
4517
+ throw new Error("AWS S3 console URL must include /s3/buckets/<bucket>.");
4518
+ }
4519
+ return { bucket, prefix: normalizeStoragePrefix(prefix), region };
4520
+ }
4521
+ if (provider === "r2") {
4522
+ const accountId = hostname === "dash.cloudflare.com" ? pathParts[0] : null;
4523
+ const bucketIndex = pathParts.findIndex((part) => part === "buckets");
4524
+ const bucket = bucketIndex >= 0 ? storageConsolePathValue(pathParts[bucketIndex + 1]) : null;
4525
+ if (!accountId || !bucket) {
4526
+ throw new Error("Cloudflare R2 console URL must include /<account-id>/r2/.../buckets/<bucket>.");
4527
+ }
4528
+ return { accountId, bucket, prefix: null, region: "auto" };
4529
+ }
4530
+ if (provider === "gcs") {
4531
+ const browserIndex = pathParts.findIndex((part) => part === "browser");
4532
+ const bucket = browserIndex >= 0 ? storageConsolePathValue(pathParts[browserIndex + 1]) : null;
4533
+ const prefix = url.searchParams.get("prefix");
4534
+ if (!bucket) {
4535
+ throw new Error("GCS console URL must include /storage/browser/<bucket>.");
4536
+ }
4537
+ return { bucket, prefix: normalizeStoragePrefix(prefix), region: null };
4538
+ }
4539
+ throw new Error("Unsupported bucket provider.");
4540
+ }
4541
+
4542
+ function storageConsolePathValue(value) {
4543
+ return String(value || "").split(";", 1)[0] || null;
4544
+ }
4545
+
4546
+ function normalizeStoragePrefix(value) {
4547
+ const normalized = String(value || "").trim().replace(/^\/+|\/+$/g, "");
4548
+ return normalized || null;
4549
+ }
4550
+
4551
+ function storageBucketEndpointUrl(provider, options = {}) {
4552
+ const endpointUrl = optionalStringOption(options.endpointUrl);
4553
+ const accountId = optionalStringOption(options.accountId);
4554
+ if (provider !== "r2") {
4555
+ if (endpointUrl || accountId) {
4556
+ throw new Error("--account-id and --endpoint-url are only supported for Cloudflare R2.");
4557
+ }
4558
+ return null;
4559
+ }
4560
+ if (endpointUrl && accountId) {
4561
+ throw new Error("Use only one of --account-id or --endpoint-url.");
4562
+ }
4563
+ if (endpointUrl) {
4564
+ return endpointUrl.replace(/\/+$/, "");
4565
+ }
4566
+ if (!accountId) {
4567
+ throw new Error("Cloudflare R2 requires --account-id or --endpoint-url.");
4568
+ }
4569
+ return `https://${accountId}.r2.cloudflarestorage.com`;
4570
+ }
4571
+
4572
+ function storageObjectUri(scheme, bucket, prefix) {
4573
+ const normalizedPrefix = String(prefix || "").trim().replace(/^\/+|\/+$/g, "");
4574
+ return normalizedPrefix ? `${scheme}://${bucket}/${normalizedPrefix}` : `${scheme}://${bucket}`;
4575
+ }
4576
+
4577
+ function storageImportTargetUri(target = {}) {
4578
+ if (target.storage_uri) {
4579
+ return String(target.storage_uri);
4580
+ }
4581
+ if (target.bucket_name && target.object_prefix) {
4582
+ return `gs://${target.bucket_name}/${String(target.object_prefix).replace(/^\/+/, "").replace(/\/?$/, "/")}`;
4583
+ }
4584
+ return null;
4585
+ }
4586
+
4587
+ function storageBucketImportCommands({ bucket, endpointUrl, prefix, provider, region, target }) {
4588
+ const targetUri = storageImportTargetUri(target);
4589
+ if (!targetUri) {
4590
+ return [];
4591
+ }
4592
+ const sourceScheme = provider === "gcs" ? "gs" : "s3";
4593
+ const sourceUri = storageObjectUri(sourceScheme, bucket, prefix);
4594
+ if (provider === "gcs") {
4595
+ return [
4596
+ {
4597
+ label: "Import from GCS",
4598
+ note: "Uses your active gcloud credentials.",
4599
+ command: `gcloud storage rsync -r ${shellQuote(sourceUri)} ${shellQuote(targetUri)}`,
4600
+ },
4601
+ ];
4602
+ }
4603
+ if (provider === "s3") {
4604
+ return [
4605
+ {
4606
+ label: "Check AWS S3 source",
4607
+ note: "Uses your AWS credentials from the AWS CLI environment or ~/.aws/credentials.",
4608
+ command: `aws s3 ls ${shellQuote(sourceUri)}`,
4609
+ },
4610
+ {
4611
+ label: "Import from AWS S3",
4612
+ note: "Uses gcloud storage interoperability with your AWS S3 credentials.",
4613
+ command: `gcloud storage rsync -r ${shellQuote(sourceUri)} ${shellQuote(targetUri)}`,
4614
+ },
4615
+ ];
4616
+ }
4617
+ return [
4618
+ {
4619
+ label: "Check Cloudflare R2 source",
4620
+ note: "Uses your R2 access key configured for the AWS CLI.",
4621
+ command: `AWS_REGION=${shellQuote(region || "auto")} aws s3 ls ${shellQuote(sourceUri)} --endpoint-url ${shellQuote(endpointUrl)}`,
4622
+ },
4623
+ {
4624
+ label: "Point gcloud at R2 for this import",
4625
+ note: "Run the unset command below after the import if you do not want this endpoint kept in your gcloud config.",
4626
+ command: `gcloud config set storage/s3_endpoint_url ${shellQuote(endpointUrl)}`,
4627
+ },
4628
+ {
4629
+ label: "Import from Cloudflare R2",
4630
+ note: "Uses your R2 S3-compatible credentials and copies into Ornn GCS.",
4631
+ command: `gcloud storage rsync -r ${shellQuote(sourceUri)} ${shellQuote(targetUri)}`,
4632
+ },
4633
+ {
4634
+ label: "Reset gcloud S3 endpoint",
4635
+ command: "gcloud config unset storage/s3_endpoint_url",
4636
+ },
4637
+ ];
4638
+ }
4639
+
3748
4640
  function metricSnapshotFromLivePayload(fallbackMachine, payload = {}) {
3749
4641
  const liveMachine = payload?.machine || null;
3750
4642
  const machine = liveMachine
@@ -4191,18 +5083,6 @@ function activeMachines(machines) {
4191
5083
  });
4192
5084
  }
4193
5085
 
4194
- function writeMutationWithOpen(stdout, resource, opened, options, label) {
4195
- if (options.json) {
4196
- writeJson(stdout, { resource, opened: opened.opened, url: opened.url });
4197
- return;
4198
- }
4199
- stdout.write(`${label} updated.\n`);
4200
- if (resource?.id) {
4201
- stdout.write(`ID: ${resource.id}\n`);
4202
- }
4203
- writeCheckoutOpenResult(stdout, opened, label);
4204
- }
4205
-
4206
5086
  function writeSshKeyList(stdout, keys) {
4207
5087
  const activeKeys = activeSshKeys(keys);
4208
5088
  if (!activeKeys.length) {
@@ -4646,6 +5526,17 @@ function writeJson(stdout, payload) {
4646
5526
  stdout.write(`${JSON.stringify(payload ?? { ok: true }, null, 2)}\n`);
4647
5527
  }
4648
5528
 
5529
+ // Strip fields that leak into ticket pastes / CI logs on --json dumps.
5530
+ function redactNodeSecrets(node) {
5531
+ if (!node || typeof node !== "object") {
5532
+ return node;
5533
+ }
5534
+ const redacted = { ...node };
5535
+ delete redacted.admin_private_key;
5536
+ delete redacted.system_variables;
5537
+ return redacted;
5538
+ }
5539
+
4649
5540
  function formatCliError(error) {
4650
5541
  if (error instanceof CliApiError) {
4651
5542
  const structuredDetail = extractStructuredErrorDetail(error.detail);