@ornncompute/cli 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -17,9 +17,10 @@ curl -fsSL https://compute.ornn.com/cli/install | sh
17
17
 
18
18
  The installer requires Node.js 20 or newer and npm. It installs `@ornncompute/cli`
19
19
  globally, exposes the `ornn` binary, adds the npm global bin directory to the
20
- user's shell profile when needed, and writes
21
- `ORNN_AUTH_BASE_URL` for the Ornn host that served the installer. The
22
- installer prints a short Ornn Compute welcome banner and login prompt; set
20
+ user's shell profile when needed, and records the Ornn host that served it as
21
+ `authBaseUrl` in `~/.config/ornn/config.json`, so `ornn login` reaches the right
22
+ host from the shell that ran the installer. The
23
+ installer prints a short Ornn Compute welcome banner and next steps; set
23
24
  `ORNN_INSTALL_BANNER=0` to skip it or `ORNN_INSTALL_ANIMATION=0` to keep it
24
25
  static.
25
26
 
@@ -28,30 +29,38 @@ static.
28
29
  ```bash
29
30
  ornn help [command]
30
31
  ornn login [--auth-base <url>] [--no-browser] [--timeout <seconds>]
32
+ ornn update
31
33
  ornn whoami [--json]
32
34
  ornn status [--json]
33
35
  ornn listings list [--gpu-type <type>] [--facility <name>] [--operator <name>] [--json]
34
36
  ornn listings show <listing-id> [--open] [--json]
35
37
  ornn buy <listing-id> [--no-open] [--json]
36
38
  ornn exchange 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]
37
- ornn exchange list [--json]
39
+ ornn exchange list [--limit <1-500>] [--cursor <last-id>] [--json]
38
40
  ornn exchange show <bid-id> [--open] [--json]
39
41
  ornn exchange update <bid-id> --gpu-count <n> --min-gpu-count <n> --start-date <yyyy-mm-dd> --end-date <yyyy-mm-dd> --price <usd>
40
42
  ornn exchange withdraw <bid-id>
41
- ornn gpus list [--status <status>] [--json]
43
+ ornn gpus list [--status <status>] [--limit <1-500>] [--cursor <last-id>] [--json]
42
44
  ornn gpus show <reservation-id> [--open] [--json]
43
45
  ornn gpus checkout <reservation-id> [--no-open] [--json]
44
46
  ornn nodes list [--json]
45
47
  ornn nodes show <node-id> [--json]
46
48
  ornn nodes launch <reservation-id> --key <path|id|label> [--mode bare-metal|vm] [--username <name>] [--network public|private] [--storage-load-drive-id <id>] [--storage-save-drive-id <id>] [--wait] [--json]
49
+ ornn nodes switch <reservation-id> --network public|private --key <path|id|label> [--mode bare-metal|vm] [--username <name>] [--wait] [--json]
47
50
  ornn nodes wait <node-or-reservation-id> [--timeout <seconds>] [--json]
48
- ornn nodes start <node-id> [--json]
49
- ornn nodes stop <node-id> [--json]
50
- ornn nodes teardown <node-id> [--json]
51
- ornn nodes revoke <node-id> [--json]
51
+ ornn nodes reboot <node-id> [--json]
52
+ ornn nodes hard-reset <node-id> [--json]
52
53
  ornn nodes ssh-command <node-or-reservation-id> [--json]
53
54
  ornn nodes keys attach <node-id> --key <path|id|label> [--json]
54
55
  ornn nodes keys list <node-id> [--json]
56
+ ornn node health <node-id> [--json]
57
+ ornn node diagnose <node-id> [--json]
58
+ ornn node deenroll <node-id> [--reason <text>] [--keep-record] [--json]
59
+ ornn fleet clean <ip>... --operator <id-or-slug> --ib-island <name> --identity-file <path> --dry-run [--ssh-user ubuntu|admin|ornn] [--json]
60
+ ornn fleet clean <failed-fleet-id> --identity-file <path> --dry-run [--json]
61
+ ornn fleet clean <fleet-id> --identity-file <path> --confirm-clean <plan-hash> [--json]
62
+ ornn fleet enroll <fleet-id> --identity-file <path> [--confirm-takeover <ip[,ip...]>] [--json]
63
+ ornn fleet deploy <fleet-id> --tenant <contact-email> --user <email-or-id> [--commerce-reservation <id>] [--network public|private] [--json]
55
64
  ornn ssh <node-or-reservation-id> [--print] [--identity-file <path>] [--user <name>] [--json]
56
65
  ornn metrics nodes [--json]
57
66
  ornn metrics node <node-id> [--json]
@@ -70,6 +79,16 @@ ornn clusters ssh <reservation-id> [--print] [--identity-file <path>] [--user <n
70
79
  ornn clusters add-node <reservation-id> --node <node-id> [--json]
71
80
  ornn clusters remove-node <reservation-id> --node <node-id> [--json]
72
81
  ornn clusters teardown <reservation-id> [--type kubernetes|slurm] [--json]
82
+ ornn slurm launch <reservation-id> [--network public|private] [--node <node-id>] [--node-count <n>] [--wait] [--wait-timeout <seconds>] [--json]
83
+ ornn slurm teardown <reservation-id> [--json]
84
+ ornn slurm status <reservation-id> [--json]
85
+ ornn slurm credentials <reservation-id> [--json]
86
+ ornn slurm ssh <reservation-id> [--print] [--identity-file <path>] [--user <name>] [--json]
87
+ ornn kubernetes launch <reservation-id> [--network public|private] [--node <node-id>] [--node-count <n>] [--wait] [--wait-timeout <seconds>] [--json]
88
+ ornn kubernetes teardown <reservation-id> [--json]
89
+ ornn kubernetes status <reservation-id> [--json]
90
+ ornn kubernetes credentials <reservation-id> [--json]
91
+ ornn kubernetes kubeconfig <reservation-id> [--output <path>] [--json]
73
92
  ornn networks list [--json]
74
93
  ornn networks show <network-id> [--json]
75
94
  ornn networks create --name <name> [--cidr <cidr>] [--description <text>] [--json]
@@ -84,11 +103,22 @@ ornn storage volumes create --name <name> [--source <drive-id>] [--json]
84
103
  ornn storage volumes refresh <drive-id> [--json]
85
104
  ornn storage volumes clear <drive-id> [--json]
86
105
  ornn storage volumes delete <drive-id> [--json]
106
+ ornn storage deploy <drive-id> --reservation <reservation-id> [--mount-path <path>] [--read-only|--read-write] [--json]
107
+ ornn storage deploy status --reservation <reservation-id> [--json]
108
+ ornn storage unmount --reservation <reservation-id> [--json]
109
+ ornn storage undeploy --reservation <reservation-id> [--json]
110
+ ornn storage buckets list [--json]
111
+ ornn storage buckets show <drive-id> [--json]
112
+ 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]
113
+ ornn storage buckets update-credentials <drive-id> --access-key-id <id> --secret-access-key-file <path> [--json]
114
+ ornn storage buckets verify <drive-id> [--json]
115
+ ornn storage buckets disconnect <drive-id> [--json]
87
116
  ornn keys list [--json]
88
117
  ornn keys add [<public-key-file>] [--public-key <key>] [--label <label>] [--json]
89
118
  ornn keys delete <key-id> [--json]
90
119
  ornn access show <reservation-id> [--json]
91
120
  ornn access activate <reservation-id> --key <path|id|label> [--mode bare-metal|vm] [--username <name>] [--network public|private] [--storage-load-drive-id <id>] [--storage-save-drive-id <id>] [--wait] [--no-open] [--json]
121
+ ornn access switch <reservation-id> --network public|private --key <path|id|label> [--mode bare-metal|vm] [--username <name>] [--wait] [--json]
92
122
  ornn access push-keys <reservation-id> --ssh-key-id <id> [--json]
93
123
  ornn access keys list <reservation-id> [--json]
94
124
  ornn access keys add <reservation-id> --public-key <key> [--label <label>] [--json]
@@ -107,9 +137,135 @@ ornn api <get|post|patch|put|delete> <compute-path> [--data <json>] [--raw]
107
137
  ornn logout
108
138
  ```
109
139
 
140
+ `ornn access keys add` is safe before or after launch. When the reservation has
141
+ active VM or Bare Metal machines, adding the key automatically queues the
142
+ current user's complete reservation key set on those machines. Use
143
+ `ornn access keys push` to explicitly re-sync keys that are already attached.
144
+
145
+ `ornn storage deploy status` prints reconciled object and byte progress once the
146
+ managed transfer has reported progress. AWS S3 and external GCS sources use the
147
+ managed transfer path. Cloudflare R2 support is coming soon.
148
+
110
149
  UI aliases are first-class in the CLI: `listings` maps to the older
111
150
  `availability` command, `exchange` maps to `bid`, and `gpus` maps to
112
151
  `reservations`. The older command names remain available for scripts.
152
+ Bid and reservation list commands return a bounded page (500 rows by default);
153
+ use `--limit` and `--cursor` to traverse older history. Pass the final row ID
154
+ from one page as the next page's cursor. `show` and `checkout`
155
+ use exact tenant-owned item lookups, so they are not limited to the current page.
156
+
157
+ ## Operator commands
158
+
159
+ The `ornn node` commands are for Ornn operators and reviewers, not tenants. They
160
+ wrap internal-reviewer-gated compute endpoints. Prefer `ornn login` as an
161
+ internal-allowed staff user (`ORNN_AUTH_BASE_URL`); the CLI calls
162
+ `/api/cli/operator/*` and the web server attaches the review secret — it never
163
+ downloads to the laptop. Optionally set `ORNN_INTERNAL_REVIEW_SECRET` plus
164
+ `ORNN_COMPUTE_BASE_URL` for direct compute access (CI / break-glass):
165
+
166
+ - `ornn node health <node-id>` shows launch readiness (ready/blocked plus
167
+ reasons), passive fault reasons, and the last validation status.
168
+ - `ornn node diagnose <node-id>` queues a manual node validation test.
169
+ - `ornn node list [--operator <id-or-slug>] [--facility <id>]` lists GPU nodes,
170
+ optionally filtered by operator and/or facility. A UUID operator is matched by
171
+ id; anything else is treated as the operator slug.
172
+ - `ornn node reboot <node-id>` queues an OS reboot of the host backing the node
173
+ (storage and SSH keys preserved).
174
+ - `ornn node hard-reset <node-id>` wipes tenant storage/users and reboots, then
175
+ re-pushes the tenant's keys on reconnect. The tenant keeps ownership of the
176
+ host.
177
+ - `ornn node off-grid <node-id> [--reason <text>]` queues the agentless handoff
178
+ that removes Ornn agent management from the still-powered-on host.
179
+ - `ornn node on-grid <node-id>` re-enrolls an off-grid host (SSH in with the
180
+ stored admin key and re-run the installer) to restore Ornn management.
181
+ - `ornn node terminate <node-id> [--reason <text>] [--force]` best-effort cleans
182
+ the host, removes the Ornn agent, and hard-deletes the node record from the
183
+ platform. It does not return the host to a tenant.
184
+ - `ornn node admin-key <node-id>` prints the Ornn admin SSH keypair for the node
185
+ (including the private key) so an operator can SSH into the host. Treat the
186
+ output as a secret.
187
+ - `ornn node deenroll <node-id>` removes a node from the environment: it first
188
+ gracefully terminates the enrollment (revokes the node claim, retires any
189
+ listing, and queues the node-agent self-uninstall packet), then dereferences
190
+ (hard-deletes) the `gpu_nodes` record so nothing lingers. Pass `--keep-record`
191
+ to stop after termination and leave the row in a `deenrolled` state, or
192
+ `--reason <text>` to annotate the audit log. The command is idempotent: an
193
+ already-removed node reports success. Terminate is forced by default so a
194
+ dead/unreachable node does not block on workload drain.
195
+
196
+ The operator resource commands share the same reviewer auth:
197
+
198
+ - `ornn operators list` lists enrolled operators.
199
+ - `ornn facilities list [--operator <id-or-slug>]` lists facilities, optionally
200
+ filtered to one operator.
201
+ - `ornn tokens list` lists enrollment tokens.
202
+ - `ornn tokens create --operator <id> [--facility <id>]
203
+ [--expires-in <seconds>] [--mode bare-metal|vm] [--ip <addr>]` mints an
204
+ enrollment token and prints a ready-to-run `curl … | sudo bash` install
205
+ command.
206
+ - `ornn tokens revoke <token-id>` revokes an enrollment token.
207
+
208
+ Fleet handoff is an ordered, Compute-backed workflow:
209
+
210
+ - Before first contact, independently verify each host key and add it to the
211
+ operator's SSH `known_hosts`; fleet SSH uses strict host-key checking and will
212
+ not trust a key first presented during cleanup.
213
+ - `fleet clean ... --dry-run` tries `ubuntu`, `admin`, then `ornn`, records each
214
+ host's hardware identity and exact deletion plan, and prints one fleet plan
215
+ hash. Re-running with `--confirm-clean <plan-hash>` removes the approved tenant
216
+ users, keys, containers/images/volumes/networks/build cache, workload paths,
217
+ mounts, services, and logs; locks preserved-account passwords, enforces
218
+ key-only SSH, trims supported filesystems, pins the preserved management
219
+ account to the supplied key, and stores per-node proof in Compute. A failed
220
+ cleanup can be dry-run again with the same fleet ID; only failed members are
221
+ replanned. Residual GPU
222
+ consumers or tenant/platform listeners fail verification instead of being
223
+ silently accepted.
224
+ - `fleet enroll <fleet-id>` refuses nodes without successful cleanup proof,
225
+ issues and revokes one cleanup-bound token per host, installs the agent,
226
+ assigns the IB island, verifies the same management account still has
227
+ key-based SSH and passwordless sudo, and records the resulting GPU node IDs
228
+ in Compute. Generic enrollment tokens retain the legacy enrollment sanitizer.
229
+ - `fleet deploy <fleet-id> --tenant <contact-email> --user <email-or-id>
230
+ [--commerce-reservation <id>]` validates the exact tenant/user and, when
231
+ supplied, the Commerce reservation. It succeeds only after every selected
232
+ user's SSH key is installed on every running node and passwordless sudo is
233
+ reported ready.
234
+
235
+ Compute Postgres is authoritative; the mode-0600 files under
236
+ `~/.config/ornn/fleets/` are only a secret-free cache. Cleanup proves “no
237
+ observable residue under the verification scope,” not certified physical-media
238
+ erasure. Root is never the preserved management account. `--confirm-takeover`
239
+ names only fleet IPs whose existing node claims may be replaced. The IB-island
240
+ is a locality label, not cluster creation. Test scheduling remains a separate
241
+ control-plane policy.
242
+
243
+ Reservation lifecycle verbs also run under the operator surface:
244
+
245
+ - `ornn reservations withdraw <reservation-id>` withdraws a reservation's
246
+ acceptance, returning the node(s) to tenant inventory.
247
+ - `ornn reservations transfer <reservation-id> --target-tenant <id>
248
+ [--target-user <id>] [--node <id>] [--strategy reject|park] [--confirm]`
249
+ transfers a reservation to another tenant.
250
+ - `ornn reservations deploy <node-id> --target-tenant <id> [--target-user <id>]
251
+ [--network public|private]` deploys a node into a tenant's inventory.
252
+
253
+ The tenant `ornn reservations list|show|checkout` verbs still use the tenant CLI
254
+ login and `/api/cli` proxy; `withdraw|transfer|deploy` use the operator path
255
+ above (staff session proxy or optional review secret).
256
+
257
+ Staff happy path:
258
+
259
+ - `ORNN_AUTH_BASE_URL` — web/auth origin
260
+ - `ornn login` as a user allowed by `INTERNAL_ALLOWED_*` on that environment
261
+
262
+ Optional direct compute override:
263
+
264
+ - `ORNN_COMPUTE_BASE_URL` — compute origin (for example `http://localhost:8000`)
265
+ - `ORNN_INTERNAL_REVIEW_SECRET` — reviewer secret (`X-Internal-Review-Secret`)
266
+
267
+ Without a staff login session or the review-secret pair, operator commands fail
268
+ with a clear error before making a network request.
113
269
 
114
270
  Commands print concise human-readable output by default. Use `--json` on
115
271
  read/show/list commands and transaction handoffs when you need structured
@@ -123,3 +279,20 @@ the terminal keeps polling until the login is approved or expires.
123
279
 
124
280
  Run `ornn --help` for the full command list and `ornn api` for the
125
281
  allowlisted compute API escape hatch.
282
+
283
+ WireGuard client configs use the current tenant-scoped `/nodes` routes:
284
+
285
+ ```bash
286
+ ornn api get /nodes/reservations/<reservation-id>/vpn/peers --raw
287
+ ornn api post /nodes/reservations/<reservation-id>/vpn/peers \
288
+ --data '{"public_key":"<wireguard-public-key>","label":"Laptop"}' --raw
289
+ ornn api delete /nodes/reservations/<reservation-id>/vpn/peers/<peer-id> --raw
290
+ ```
291
+
292
+ The legacy `/standalone-vms/*` prefix is not exposed through the CLI proxy.
293
+ Deleting a peer is the supported way to replace its key; switching a node
294
+ between Public and Private VPN preserves the peer.
295
+
296
+ ## Updating
297
+
298
+ The CLI checks the npm registry for a newer version at most once per day and prints a one-line notice on stderr when one exists. Run `ornn update` to install it (the command detects npm, pnpm, yarn, or bun installs). The check is skipped in CI and non-interactive shells.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ornncompute/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Command-line interface for Ornn compute access workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- import { clearAuthSession, loadAuthSession } from "./auth-store.mjs";
1
+ import { clearAuthSession, configuredAuthBaseUrl, loadAuthSession } from "./auth-store.mjs";
2
2
  import { resolveAuthBaseUrl } from "./device-auth.mjs";
3
3
 
4
4
  export class CliApiError extends Error {
@@ -11,13 +11,22 @@ export class CliApiError extends Error {
11
11
  }
12
12
 
13
13
  export function resolveApiBaseUrl({ env = process.env, explicit, session } = {}) {
14
- return (
15
- explicit?.trim() ||
16
- env.ORNN_API_BASE_URL?.trim() ||
17
- env.ORNN_AUTH_BASE_URL?.trim() ||
18
- session?.authBaseUrl?.trim() ||
19
- resolveAuthBaseUrl({ env })
20
- ).replace(/\/+$/, "");
14
+ // Prefer the first configured source; do not fall through when it strips empty.
15
+ const candidates = [
16
+ [explicit, "API base URL"],
17
+ [env.ORNN_API_BASE_URL, "ORNN_API_BASE_URL"],
18
+ [env.ORNN_AUTH_BASE_URL, "ORNN_AUTH_BASE_URL"],
19
+ [session?.authBaseUrl, "session authBaseUrl"],
20
+ ];
21
+ for (const [value, sourceName] of candidates) {
22
+ const trimmed = value?.trim();
23
+ if (!trimmed) {
24
+ continue;
25
+ }
26
+ return requireValidBaseUrl(trimmed, sourceName);
27
+ }
28
+
29
+ return requireValidBaseUrl(resolveAuthBaseUrl({ env }), "API base URL");
21
30
  }
22
31
 
23
32
  export async function loadRequiredSession({ env = process.env } = {}) {
@@ -94,6 +103,174 @@ export async function cliRequest({
94
103
  return data;
95
104
  }
96
105
 
106
+ export function resolveComputeBaseUrl({ env = process.env } = {}) {
107
+ const base = env.ORNN_COMPUTE_BASE_URL?.trim();
108
+ if (!base) {
109
+ throw new CliApiError(
110
+ "Set ORNN_COMPUTE_BASE_URL to the internal compute service origin to run operator commands.",
111
+ );
112
+ }
113
+ return requireValidBaseUrl(base, "ORNN_COMPUTE_BASE_URL");
114
+ }
115
+
116
+ /**
117
+ * Origin used in `curl …/enrollment/install` one-liners.
118
+ *
119
+ * Prefer an explicit compute/installer URL. When staff use the web operator
120
+ * proxy (no local review secret), derive compute from the auth/web origin:
121
+ * - `https://<slug>.fabric.ornn.com` → `https://<slug>-api.fabric.ornn.com`
122
+ * - `https://compute.ornn.com` → `https://api-compute.ornn.com`
123
+ * - `http://localhost:3000` → `http://localhost:8000`
124
+ */
125
+ export function resolveInstallerBaseUrl({ env = process.env, session } = {}) {
126
+ const explicit =
127
+ env.ORNN_INSTALLER_URL?.trim() ||
128
+ env.ORNN_COMPUTE_BASE_URL?.trim() ||
129
+ env.NEXT_PUBLIC_INSTALLER_URL?.trim();
130
+ if (explicit) {
131
+ return requireValidBaseUrl(explicit, "installer base URL");
132
+ }
133
+
134
+ // The session records where the caller actually logged in, so it outranks the
135
+ // installed default.
136
+ const authCandidates = [
137
+ env.ORNN_AUTH_BASE_URL,
138
+ env.ORNN_API_BASE_URL,
139
+ session?.authBaseUrl,
140
+ configuredAuthBaseUrl(env),
141
+ ];
142
+ for (const candidate of authCandidates) {
143
+ const trimmed = candidate?.trim();
144
+ if (!trimmed) {
145
+ continue;
146
+ }
147
+ const authOrigin = requireValidBaseUrl(trimmed, "ORNN_AUTH_BASE_URL");
148
+ return deriveInstallerOriginFromAuth(authOrigin);
149
+ }
150
+ return null;
151
+ }
152
+
153
+ function deriveInstallerOriginFromAuth(authOrigin) {
154
+ const url = new URL(authOrigin);
155
+ if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
156
+ url.port = "8000";
157
+ url.protocol = "http:";
158
+ return url.origin;
159
+ }
160
+ const fabricMatch = url.hostname.match(/^([a-z0-9-]+)\.fabric\.ornn\.com$/i);
161
+ if (fabricMatch && !fabricMatch[1].endsWith("-api")) {
162
+ url.hostname = `${fabricMatch[1]}-api.fabric.ornn.com`;
163
+ return url.origin;
164
+ }
165
+ if (url.hostname === "compute.ornn.com") {
166
+ url.hostname = "api-compute.ornn.com";
167
+ return url.origin;
168
+ }
169
+ return url.origin;
170
+ }
171
+
172
+ export function resolveInternalReviewSecret({ env = process.env } = {}) {
173
+ const secret = env.ORNN_INTERNAL_REVIEW_SECRET?.trim();
174
+ if (!secret) {
175
+ throw new CliApiError(
176
+ "Set ORNN_INTERNAL_REVIEW_SECRET to authenticate operator commands, or run `ornn login` as Ornn staff.",
177
+ );
178
+ }
179
+ return secret;
180
+ }
181
+
182
+ function operatorProxyEndpoint(endpoint) {
183
+ return `/api/cli/operator${normalizeComputePath(endpoint)}`;
184
+ }
185
+
186
+ async function operatorRequestViaStaffSession({
187
+ body,
188
+ endpoint,
189
+ env = process.env,
190
+ fetchImpl = fetch,
191
+ method = "GET",
192
+ } = {}) {
193
+ // Staff path: CLI bearer → web /api/cli/operator → compute with server-side review secret.
194
+ return cliRequest({
195
+ body,
196
+ endpoint: operatorProxyEndpoint(endpoint),
197
+ env,
198
+ fetchImpl,
199
+ method,
200
+ authRequired: true,
201
+ });
202
+ }
203
+
204
+ async function operatorRequestViaReviewSecret({
205
+ body,
206
+ endpoint,
207
+ env = process.env,
208
+ fetchImpl = fetch,
209
+ method = "GET",
210
+ } = {}) {
211
+ // Break-glass / CI: hit compute directly with the review secret.
212
+ const baseUrl = resolveComputeBaseUrl({ env });
213
+ const secret = resolveInternalReviewSecret({ env });
214
+ const url = new URL(endpoint, `${baseUrl}/`);
215
+ const headers = {
216
+ Accept: "application/json",
217
+ "X-Internal-Actor": env.ORNN_INTERNAL_ACTOR?.trim() || "break-glass-cli",
218
+ "X-Internal-Review-Secret": secret,
219
+ };
220
+
221
+ let requestBody;
222
+ if (body !== undefined) {
223
+ headers["Content-Type"] = "application/json";
224
+ requestBody = typeof body === "string" ? body : JSON.stringify(body);
225
+ }
226
+
227
+ let response;
228
+ try {
229
+ response = await fetchImpl(url, { body: requestBody, headers, method });
230
+ } catch (error) {
231
+ const message = error instanceof Error ? error.message : String(error);
232
+ throw new CliApiError(`Could not reach Ornn compute at ${baseUrl}: ${message}`);
233
+ }
234
+
235
+ const text = await response.text();
236
+ const data = text ? parseJson(text, url.toString(), response) : null;
237
+ if (!response.ok) {
238
+ const detail = data?.detail ?? data?.error ?? response.statusText;
239
+ const message = detailMessage(detail) || response.statusText;
240
+ throw new CliApiError(`Ornn request failed: ${message}`, {
241
+ detail: data,
242
+ status: response.status,
243
+ });
244
+ }
245
+ return data;
246
+ }
247
+
248
+ export async function operatorRequest({
249
+ body,
250
+ endpoint,
251
+ env = process.env,
252
+ fetchImpl = fetch,
253
+ method = "GET",
254
+ } = {}) {
255
+ // Prefer an explicit review secret (CI / break-glass). Otherwise use a staff
256
+ // `ornn login` session through the web operator proxy — the secret never
257
+ // leaves the server.
258
+ if (env.ORNN_INTERNAL_REVIEW_SECRET?.trim()) {
259
+ return operatorRequestViaReviewSecret({ body, endpoint, env, fetchImpl, method });
260
+ }
261
+
262
+ const session = await loadAuthSession({ env });
263
+ if (session?.accessToken) {
264
+ return operatorRequestViaStaffSession({ body, endpoint, env, fetchImpl, method });
265
+ }
266
+
267
+ throw new CliApiError(
268
+ "Operator commands need Ornn staff auth: run `ornn login` as an internal user " +
269
+ "(ORNN_AUTH_BASE_URL), or set ORNN_INTERNAL_REVIEW_SECRET (+ ORNN_COMPUTE_BASE_URL) for direct compute access.",
270
+ { status: 401 },
271
+ );
272
+ }
273
+
97
274
  export function computeEndpoint(path) {
98
275
  const normalized = normalizeComputePath(path);
99
276
  return `/api/cli/compute${normalized}`;
@@ -110,6 +287,28 @@ export function normalizeComputePath(path) {
110
287
  return trimmed;
111
288
  }
112
289
 
290
+ function requireValidBaseUrl(raw, sourceName) {
291
+ const stripped = raw.replace(/\/+$/, "");
292
+ if (!stripped) {
293
+ throw new CliApiError(
294
+ `${sourceName} must be a valid origin (got empty value after removing trailing slashes).`,
295
+ );
296
+ }
297
+ let parsed;
298
+ try {
299
+ parsed = new URL(stripped);
300
+ } catch {
301
+ throw new CliApiError(`${sourceName} must be a valid origin (got ${JSON.stringify(raw)}).`);
302
+ }
303
+ // Path/search/hash are dropped when composing absolute endpoints against this base.
304
+ if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
305
+ throw new CliApiError(
306
+ `${sourceName} must be a valid origin with no path (got ${JSON.stringify(raw)}).`,
307
+ );
308
+ }
309
+ return stripped;
310
+ }
311
+
113
312
  function parseJson(text, url, response) {
114
313
  try {
115
314
  return JSON.parse(text);
@@ -1,9 +1,12 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
3
  import { dirname, join, resolve } from "node:path";
3
4
  import { homedir } from "node:os";
4
5
 
5
6
  const APP_DIR = "ornn";
6
7
  const AUTH_FILE = "auth.json";
8
+ const CONFIG_FILE = "config.json";
9
+ const FLEETS_DIR = "fleets";
7
10
 
8
11
  export function getAuthConfigDir(env = process.env) {
9
12
  if (env.ORNN_CONFIG_HOME?.trim()) {
@@ -21,6 +24,24 @@ export function getAuthConfigPath(env = process.env) {
21
24
  return join(getAuthConfigDir(env), AUTH_FILE);
22
25
  }
23
26
 
27
+ export function getCliConfigPath(env = process.env) {
28
+ return join(getAuthConfigDir(env), CONFIG_FILE);
29
+ }
30
+
31
+ export function getFleetConfigDir(env = process.env) {
32
+ return join(getAuthConfigDir(env), FLEETS_DIR);
33
+ }
34
+
35
+ // Read synchronously so callers resolving a base URL stay synchronous.
36
+ export function configuredAuthBaseUrl(env = process.env) {
37
+ try {
38
+ const value = JSON.parse(readFileSync(getCliConfigPath(env), "utf8"))?.authBaseUrl;
39
+ return typeof value === "string" ? value.trim() : "";
40
+ } catch {
41
+ return "";
42
+ }
43
+ }
44
+
24
45
  export async function loadAuthSession({ env = process.env } = {}) {
25
46
  const path = getAuthConfigPath(env);
26
47