@ornncompute/cli 0.1.9 → 0.2.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/README.md +136 -160
- package/package.json +7 -2
- package/src/api-client.mjs +119 -185
- package/src/auth-store.mjs +9 -5
- package/src/catalog-dispatch.mjs +186 -0
- package/src/cli.mjs +1476 -2598
- package/src/device-auth.mjs +10 -8
- package/vendor/capabilities/capabilities/clusters.d.ts +2 -0
- package/vendor/capabilities/capabilities/clusters.js +99 -0
- package/vendor/capabilities/capabilities/fleet.d.ts +5 -0
- package/vendor/capabilities/capabilities/fleet.js +136 -0
- package/vendor/capabilities/capabilities/identity.d.ts +3 -0
- package/vendor/capabilities/capabilities/identity.js +24 -0
- package/vendor/capabilities/capabilities/listings.d.ts +45 -0
- package/vendor/capabilities/capabilities/listings.js +161 -0
- package/vendor/capabilities/capabilities/users.d.ts +2 -0
- package/vendor/capabilities/capabilities/users.js +49 -0
- package/vendor/capabilities/capability.d.ts +61 -0
- package/vendor/capabilities/capability.js +59 -0
- package/vendor/capabilities/catalog.d.ts +3 -0
- package/vendor/capabilities/catalog.js +14 -0
- package/vendor/capabilities/index.d.ts +10 -0
- package/vendor/capabilities/index.js +10 -0
- package/vendor/capabilities/names.d.ts +6 -0
- package/vendor/capabilities/names.js +6 -0
- package/vendor/capabilities/role.d.ts +6 -0
- package/vendor/capabilities/role.js +17 -0
- package/vendor/capabilities/spec-catalog.d.ts +78 -0
- package/vendor/capabilities/spec-catalog.js +128 -0
package/src/api-client.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { clearAuthSession,
|
|
1
|
+
import { clearAuthSession, configuredApiBaseUrl, loadAuthSession } from "./auth-store.mjs";
|
|
2
2
|
import { resolveAuthBaseUrl } from "./device-auth.mjs";
|
|
3
3
|
|
|
4
4
|
export class CliApiError extends Error {
|
|
@@ -11,22 +11,56 @@ export class CliApiError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function resolveApiBaseUrl({ env = process.env, explicit, session } = {}) {
|
|
14
|
-
// Prefer the first configured source; do not fall through when it strips empty.
|
|
15
|
-
const
|
|
14
|
+
// Prefer the first configured API source; do not fall through when it strips empty.
|
|
15
|
+
const apiCandidates = [
|
|
16
16
|
[explicit, "API base URL"],
|
|
17
17
|
[env.ORNN_API_BASE_URL, "ORNN_API_BASE_URL"],
|
|
18
|
+
[session?.apiBaseUrl, "session apiBaseUrl"],
|
|
19
|
+
[configuredApiBaseUrl(env), "configured apiBaseUrl"],
|
|
20
|
+
];
|
|
21
|
+
for (const [value, sourceName] of apiCandidates) {
|
|
22
|
+
const trimmed = value?.trim();
|
|
23
|
+
if (!trimmed) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
return requireValidBaseUrl(trimmed, sourceName);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Older CLI installs persisted only the web/auth origin. Migrate the known
|
|
30
|
+
// deployed Ornn hosts in memory so an update immediately reaches the gateway
|
|
31
|
+
// without requiring a reinstall; custom and localhost origins remain intact.
|
|
32
|
+
const authCandidates = [
|
|
18
33
|
[env.ORNN_AUTH_BASE_URL, "ORNN_AUTH_BASE_URL"],
|
|
19
34
|
[session?.authBaseUrl, "session authBaseUrl"],
|
|
20
35
|
];
|
|
21
|
-
for (const [value, sourceName] of
|
|
36
|
+
for (const [value, sourceName] of authCandidates) {
|
|
22
37
|
const trimmed = value?.trim();
|
|
23
38
|
if (!trimmed) {
|
|
24
39
|
continue;
|
|
25
40
|
}
|
|
26
|
-
return
|
|
41
|
+
return gatewayOriginForAuthOrigin(trimmed, sourceName);
|
|
27
42
|
}
|
|
28
43
|
|
|
29
|
-
return
|
|
44
|
+
return gatewayOriginForAuthOrigin(resolveAuthBaseUrl({ env }), "API base URL");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function gatewayOriginForAuthOrigin(value, sourceName) {
|
|
48
|
+
const authOrigin = requireValidBaseUrl(value, sourceName);
|
|
49
|
+
const url = new URL(authOrigin);
|
|
50
|
+
if (url.hostname === "compute.ornn.com") {
|
|
51
|
+
return "https://api.ornn.com";
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
url.hostname === "staging.compute.ornn.com" ||
|
|
55
|
+
url.hostname === "staging.fabric.ornn.com"
|
|
56
|
+
) {
|
|
57
|
+
return "https://staging.api.ornn.com";
|
|
58
|
+
}
|
|
59
|
+
const dynamicMatch = url.hostname.match(/^([a-z0-9-]+)\.fabric\.ornn\.com$/i);
|
|
60
|
+
if (dynamicMatch) {
|
|
61
|
+
return `https://${dynamicMatch[1]}.api.ornn.com`;
|
|
62
|
+
}
|
|
63
|
+
return authOrigin;
|
|
30
64
|
}
|
|
31
65
|
|
|
32
66
|
export async function loadRequiredSession({ env = process.env } = {}) {
|
|
@@ -46,6 +80,7 @@ export async function cliRequest({
|
|
|
46
80
|
method = "GET",
|
|
47
81
|
raw = false,
|
|
48
82
|
session,
|
|
83
|
+
timeoutMs,
|
|
49
84
|
} = {}) {
|
|
50
85
|
const authSession = session ?? (authRequired ? await loadRequiredSession({ env }) : null);
|
|
51
86
|
const baseUrl = resolveApiBaseUrl({ env, session: authSession });
|
|
@@ -56,6 +91,10 @@ export async function cliRequest({
|
|
|
56
91
|
|
|
57
92
|
if (authSession?.accessToken) {
|
|
58
93
|
headers.Authorization = `Bearer ${authSession.accessToken}`;
|
|
94
|
+
// Explicitly select the gateway's user-credential resolver. This marker
|
|
95
|
+
// grants nothing by itself; the gateway still has to resolve the bearer
|
|
96
|
+
// through authorization and derive the caller's organization server-side.
|
|
97
|
+
headers["X-Ornn-Credential-Type"] = "user";
|
|
59
98
|
}
|
|
60
99
|
|
|
61
100
|
let requestBody;
|
|
@@ -70,8 +109,18 @@ export async function cliRequest({
|
|
|
70
109
|
body: requestBody,
|
|
71
110
|
headers,
|
|
72
111
|
method,
|
|
112
|
+
...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
|
|
73
113
|
});
|
|
74
114
|
} catch (error) {
|
|
115
|
+
// Detect the timeout/abort case before the generic network-failure wrapping
|
|
116
|
+
// below, so a wedged compute call (e.g. status blocked behind an
|
|
117
|
+
// unresponsive FUSE mount) reports a customer-facing timeout instead of an
|
|
118
|
+
// opaque "Could not reach Ornn" message.
|
|
119
|
+
if (timeoutMs && (error?.name === "TimeoutError" || error?.name === "AbortError")) {
|
|
120
|
+
throw new CliApiError(
|
|
121
|
+
`Ornn did not respond within ${Math.round(timeoutMs / 1000)} seconds. The service or a node mount may be unresponsive; try again shortly.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
75
124
|
const message = error instanceof Error ? error.message : String(error);
|
|
76
125
|
throw new CliApiError(`Could not reach Ornn at ${baseUrl}: ${message}`);
|
|
77
126
|
}
|
|
@@ -87,196 +136,71 @@ export async function cliRequest({
|
|
|
87
136
|
|
|
88
137
|
const text = await response.text();
|
|
89
138
|
if (!response.ok) {
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
await
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
detail: data,
|
|
100
|
-
status: response.status,
|
|
139
|
+
// Always throws: handles the 401/expired-session case (clearing the
|
|
140
|
+
// stored token) and the generic detail-surfacing case.
|
|
141
|
+
await raiseForFailedResponse({
|
|
142
|
+
env,
|
|
143
|
+
hadToken: Boolean(authSession?.accessToken),
|
|
144
|
+
response,
|
|
145
|
+
text,
|
|
146
|
+
url: url.toString(),
|
|
147
|
+
formatMessage: (message) => `Ornn request failed: ${message}`,
|
|
101
148
|
});
|
|
102
149
|
}
|
|
150
|
+
const data = text ? parseJson(text, url.toString(), response) : null;
|
|
103
151
|
return data;
|
|
104
152
|
}
|
|
105
153
|
|
|
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
154
|
/**
|
|
117
|
-
*
|
|
155
|
+
* Shared failure path for both the JSON `cliRequest` helper and callers that
|
|
156
|
+
* stream a raw `fetchImpl` response directly (e.g. file downloads, which
|
|
157
|
+
* can't go through `cliRequest`'s JSON body handling). Always throws:
|
|
118
158
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* -
|
|
122
|
-
*
|
|
123
|
-
*
|
|
159
|
+
* - On 401 with a previously-valid token, clears the stored session and
|
|
160
|
+
* throws the standard "session expired" `CliApiError` so every call site
|
|
161
|
+
* gets the same re-login guidance instead of reimplementing it.
|
|
162
|
+
* - Otherwise, parses the body as JSON (if present) and surfaces its
|
|
163
|
+
* `detail`/`error` field through the same `detailMessage` conventions
|
|
164
|
+
* `cliRequest` uses, falling back to `fallbackMessage` (or the response's
|
|
165
|
+
* status text) when the body has no usable detail.
|
|
124
166
|
*/
|
|
125
|
-
export function
|
|
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,
|
|
167
|
+
export async function raiseForFailedResponse({
|
|
189
168
|
env = process.env,
|
|
190
|
-
|
|
191
|
-
|
|
169
|
+
fallbackMessage,
|
|
170
|
+
formatMessage = (message) => message,
|
|
171
|
+
hadToken,
|
|
172
|
+
response,
|
|
173
|
+
text,
|
|
174
|
+
url,
|
|
192
175
|
} = {}) {
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
176
|
+
// Clear a dead token before parsing, so a 401 with a non-JSON body
|
|
177
|
+
// (e.g. a gateway/WAF error page) still triggers the re-login flow.
|
|
178
|
+
await handleAuthExpiry({ env, response, hadToken });
|
|
179
|
+
const data = text ? parseJson(text, url, response) : null;
|
|
180
|
+
const detail = data?.detail ?? data?.error ?? response.statusText;
|
|
181
|
+
const message = detailMessage(detail) || fallbackMessage || response.statusText;
|
|
182
|
+
throw new CliApiError(formatMessage(message), {
|
|
183
|
+
detail: data,
|
|
184
|
+
status: response.status,
|
|
201
185
|
});
|
|
202
186
|
}
|
|
203
187
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
});
|
|
188
|
+
export function resolveInstallerBaseUrl({ env = process.env } = {}) {
|
|
189
|
+
const explicit = env.ORNN_INSTALLER_URL?.trim() || env.NEXT_PUBLIC_INSTALLER_URL?.trim();
|
|
190
|
+
if (!explicit) {
|
|
191
|
+
return null;
|
|
244
192
|
}
|
|
245
|
-
return
|
|
193
|
+
return requireValidBaseUrl(explicit, "ORNN_INSTALLER_URL", { allowPath: true });
|
|
246
194
|
}
|
|
247
195
|
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
);
|
|
196
|
+
function operatorProxyEndpoint(endpoint) {
|
|
197
|
+
return `/v1/cli/operator${normalizeComputePath(endpoint)}`;
|
|
272
198
|
}
|
|
273
199
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
*/
|
|
279
|
-
export async function webOperatorRequest({
|
|
200
|
+
// Staff path: CLI bearer → gateway /v1/cli/operator → the owning service.
|
|
201
|
+
// The gateway resolves the bearer and applies staff policy; the CLI never ships
|
|
202
|
+
// a reviewer or service secret.
|
|
203
|
+
export async function operatorRequest({
|
|
280
204
|
body,
|
|
281
205
|
endpoint,
|
|
282
206
|
env = process.env,
|
|
@@ -286,16 +210,23 @@ export async function webOperatorRequest({
|
|
|
286
210
|
const session = await loadAuthSession({ env });
|
|
287
211
|
if (!session?.accessToken) {
|
|
288
212
|
throw new CliApiError(
|
|
289
|
-
"
|
|
213
|
+
"Operator commands need Ornn staff auth: run `ornn login` as an internal user (ORNN_AUTH_BASE_URL).",
|
|
290
214
|
{ status: 401 }
|
|
291
215
|
);
|
|
292
216
|
}
|
|
293
|
-
return
|
|
217
|
+
return cliRequest({
|
|
218
|
+
body,
|
|
219
|
+
endpoint: operatorProxyEndpoint(endpoint),
|
|
220
|
+
env,
|
|
221
|
+
fetchImpl,
|
|
222
|
+
method,
|
|
223
|
+
authRequired: true,
|
|
224
|
+
});
|
|
294
225
|
}
|
|
295
226
|
|
|
296
227
|
export function computeEndpoint(path) {
|
|
297
228
|
const normalized = normalizeComputePath(path);
|
|
298
|
-
return `/
|
|
229
|
+
return `/v1/cli/compute${normalized}`;
|
|
299
230
|
}
|
|
300
231
|
|
|
301
232
|
export function normalizeComputePath(path) {
|
|
@@ -309,7 +240,7 @@ export function normalizeComputePath(path) {
|
|
|
309
240
|
return trimmed;
|
|
310
241
|
}
|
|
311
242
|
|
|
312
|
-
function requireValidBaseUrl(raw, sourceName) {
|
|
243
|
+
function requireValidBaseUrl(raw, sourceName, { allowPath = false } = {}) {
|
|
313
244
|
const stripped = raw.replace(/\/+$/, "");
|
|
314
245
|
if (!stripped) {
|
|
315
246
|
throw new CliApiError(
|
|
@@ -322,10 +253,13 @@ function requireValidBaseUrl(raw, sourceName) {
|
|
|
322
253
|
} catch {
|
|
323
254
|
throw new CliApiError(`${sourceName} must be a valid origin (got ${JSON.stringify(raw)}).`);
|
|
324
255
|
}
|
|
325
|
-
//
|
|
326
|
-
|
|
256
|
+
// API endpoints are composed from absolute paths and therefore require a
|
|
257
|
+
// bare origin. The installer is the one exception: its gateway-owned base
|
|
258
|
+
// intentionally includes `/v1/orchestrator` and is joined explicitly.
|
|
259
|
+
if ((!allowPath && parsed.pathname !== "/") || parsed.search || parsed.hash) {
|
|
260
|
+
const requirement = allowPath ? "base URL with no query or fragment" : "origin with no path";
|
|
327
261
|
throw new CliApiError(
|
|
328
|
-
`${sourceName} must be a valid
|
|
262
|
+
`${sourceName} must be a valid ${requirement} (got ${JSON.stringify(raw)}).`
|
|
329
263
|
);
|
|
330
264
|
}
|
|
331
265
|
return stripped;
|
package/src/auth-store.mjs
CHANGED
|
@@ -6,7 +6,6 @@ import { homedir } from "node:os";
|
|
|
6
6
|
const APP_DIR = "ornn";
|
|
7
7
|
const AUTH_FILE = "auth.json";
|
|
8
8
|
const CONFIG_FILE = "config.json";
|
|
9
|
-
const FLEETS_DIR = "fleets";
|
|
10
9
|
|
|
11
10
|
export function getAuthConfigDir(env = process.env) {
|
|
12
11
|
if (env.ORNN_CONFIG_HOME?.trim()) {
|
|
@@ -28,10 +27,6 @@ export function getCliConfigPath(env = process.env) {
|
|
|
28
27
|
return join(getAuthConfigDir(env), CONFIG_FILE);
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
export function getFleetConfigDir(env = process.env) {
|
|
32
|
-
return join(getAuthConfigDir(env), FLEETS_DIR);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
30
|
// Read synchronously so callers resolving a base URL stay synchronous.
|
|
36
31
|
export function configuredAuthBaseUrl(env = process.env) {
|
|
37
32
|
try {
|
|
@@ -42,6 +37,15 @@ export function configuredAuthBaseUrl(env = process.env) {
|
|
|
42
37
|
}
|
|
43
38
|
}
|
|
44
39
|
|
|
40
|
+
export function configuredApiBaseUrl(env = process.env) {
|
|
41
|
+
try {
|
|
42
|
+
const value = JSON.parse(readFileSync(getCliConfigPath(env), "utf8"))?.apiBaseUrl;
|
|
43
|
+
return typeof value === "string" ? value.trim() : "";
|
|
44
|
+
} catch {
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
45
49
|
export async function loadAuthSession({ env = process.env } = {}) {
|
|
46
50
|
const path = getAuthConfigPath(env);
|
|
47
51
|
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as defaultStdin, stdout as defaultStdout } from "node:process";
|
|
3
|
+
|
|
4
|
+
import { cliRequest, operatorRequest } from "./api-client.mjs";
|
|
5
|
+
|
|
6
|
+
async function loadCapabilities() {
|
|
7
|
+
try {
|
|
8
|
+
return await import("@ornn/capabilities");
|
|
9
|
+
} catch {
|
|
10
|
+
return await import("../vendor/capabilities/index.js");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseYes(value) {
|
|
15
|
+
return /^(y|yes)$/i.test(String(value ?? "").trim());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function confirmCatalogMutation({ stdin, stdout, yes, label }) {
|
|
19
|
+
if (yes) return true;
|
|
20
|
+
const input = stdin ?? defaultStdin;
|
|
21
|
+
const output = stdout ?? defaultStdout;
|
|
22
|
+
if (!input.isTTY || !output.isTTY) {
|
|
23
|
+
throw new Error("Refusing to create without --yes on a non-interactive terminal.");
|
|
24
|
+
}
|
|
25
|
+
const rl = createInterface({ input, output });
|
|
26
|
+
try {
|
|
27
|
+
const answer = await rl.question(`${label} [y/N] `);
|
|
28
|
+
return parseYes(answer);
|
|
29
|
+
} finally {
|
|
30
|
+
rl.close();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function catalogFetch({ env, fetchImpl }) {
|
|
35
|
+
return async (path, init) => {
|
|
36
|
+
const method = init?.method ?? "GET";
|
|
37
|
+
const staffPath =
|
|
38
|
+
path.startsWith("/internal") ||
|
|
39
|
+
path.startsWith("/provisioning") ||
|
|
40
|
+
path === "/inventory" ||
|
|
41
|
+
path.startsWith("/inventory/");
|
|
42
|
+
const request = staffPath ? operatorRequest : cliRequest;
|
|
43
|
+
return request({
|
|
44
|
+
endpoint: path,
|
|
45
|
+
env,
|
|
46
|
+
fetchImpl,
|
|
47
|
+
method,
|
|
48
|
+
...(init?.body !== undefined ? { body: init.body } : {}),
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runCatalogCapability(capability, input, context) {
|
|
54
|
+
const { parseRole } = await loadCapabilities();
|
|
55
|
+
const role = parseRole(context.role) ?? (context.staff ? "reviewer" : "user");
|
|
56
|
+
return capability.execute(
|
|
57
|
+
{
|
|
58
|
+
actor: context.actor ?? "",
|
|
59
|
+
role,
|
|
60
|
+
confirmed: context.confirmed === true,
|
|
61
|
+
fetch: catalogFetch(context),
|
|
62
|
+
},
|
|
63
|
+
input,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function listingsCreateCommand(args, context) {
|
|
68
|
+
const { createListingCapability } = await loadCapabilities();
|
|
69
|
+
const options = context.parseCommandOptions(
|
|
70
|
+
args,
|
|
71
|
+
{
|
|
72
|
+
boolean: ["json", "yes"],
|
|
73
|
+
value: [
|
|
74
|
+
"gpu-type",
|
|
75
|
+
"cpu",
|
|
76
|
+
"node-model",
|
|
77
|
+
"fabric-type",
|
|
78
|
+
"node-count",
|
|
79
|
+
"gpus-per-node",
|
|
80
|
+
"site-nickname",
|
|
81
|
+
"site-operator",
|
|
82
|
+
"ram",
|
|
83
|
+
"storage",
|
|
84
|
+
"available-start-at",
|
|
85
|
+
"available-end-at",
|
|
86
|
+
"buy-now-price-per-gpu-hour",
|
|
87
|
+
"cost-per-gpu-hour",
|
|
88
|
+
"deposit-percent",
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
"Usage: ornn listings create [--gpu-type H100] [--node-count 8] [--yes] [--json]",
|
|
92
|
+
);
|
|
93
|
+
const input = {
|
|
94
|
+
gpu_type: options.gpuType,
|
|
95
|
+
cpu: options.cpu,
|
|
96
|
+
node_model: options.nodeModel,
|
|
97
|
+
fabric_type: options.fabricType,
|
|
98
|
+
node_count: options.nodeCount != null ? Number(options.nodeCount) : undefined,
|
|
99
|
+
gpus_per_node: options.gpusPerNode != null ? Number(options.gpusPerNode) : undefined,
|
|
100
|
+
site_nickname: options.siteNickname,
|
|
101
|
+
site_operator: options.siteOperator,
|
|
102
|
+
ram: options.ram,
|
|
103
|
+
storage: options.storage,
|
|
104
|
+
available_start_at: options.availableStartAt,
|
|
105
|
+
available_end_at: options.availableEndAt,
|
|
106
|
+
buy_now_price_per_gpu_hour:
|
|
107
|
+
options.buyNowPricePerGpuHour != null ? Number(options.buyNowPricePerGpuHour) : undefined,
|
|
108
|
+
cost_per_gpu_hour: options.costPerGpuHour != null ? Number(options.costPerGpuHour) : undefined,
|
|
109
|
+
deposit_percent: options.depositPercent != null ? Number(options.depositPercent) : undefined,
|
|
110
|
+
};
|
|
111
|
+
const preview = await runCatalogCapability(createListingCapability, input, {
|
|
112
|
+
...context,
|
|
113
|
+
confirmed: false,
|
|
114
|
+
staff: true,
|
|
115
|
+
role: "reviewer",
|
|
116
|
+
});
|
|
117
|
+
if (preview?.ok === false) {
|
|
118
|
+
throw new Error(preview.error ?? "listing create failed");
|
|
119
|
+
}
|
|
120
|
+
const confirmed = await confirmCatalogMutation({
|
|
121
|
+
stdin: context.stdin,
|
|
122
|
+
stdout: context.stdout,
|
|
123
|
+
yes: options.yes,
|
|
124
|
+
label: "Create this listing?",
|
|
125
|
+
});
|
|
126
|
+
if (!confirmed) {
|
|
127
|
+
context.stdout.write("Aborted.\n");
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
const result = await runCatalogCapability(createListingCapability, input, {
|
|
131
|
+
...context,
|
|
132
|
+
confirmed: true,
|
|
133
|
+
staff: true,
|
|
134
|
+
role: "reviewer",
|
|
135
|
+
});
|
|
136
|
+
if (options.json) {
|
|
137
|
+
context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
138
|
+
} else {
|
|
139
|
+
context.stdout.write(`${JSON.stringify(result)}\n`);
|
|
140
|
+
}
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function operatorListCommand(args, context) {
|
|
145
|
+
const { listOperatorsCapability } = await loadCapabilities();
|
|
146
|
+
context.parseCommandOptions(args, { boolean: ["json"] }, "Usage: ornn operator list [--json]");
|
|
147
|
+
const result = await runCatalogCapability(listOperatorsCapability, {}, {
|
|
148
|
+
...context,
|
|
149
|
+
staff: true,
|
|
150
|
+
role: "reviewer",
|
|
151
|
+
});
|
|
152
|
+
context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function usersQueryCommand(args, context) {
|
|
157
|
+
const { usersQueryCapability } = await loadCapabilities();
|
|
158
|
+
const options = context.parseCommandOptions(
|
|
159
|
+
args,
|
|
160
|
+
{ boolean: ["json"], value: ["name", "email", "organization", "max"] },
|
|
161
|
+
"Usage: ornn users query [--name Ada] [--email a@b.com] [--organization Acme] [--max 20]",
|
|
162
|
+
);
|
|
163
|
+
const result = await runCatalogCapability(
|
|
164
|
+
usersQueryCapability,
|
|
165
|
+
{
|
|
166
|
+
name: options.name,
|
|
167
|
+
email: options.email,
|
|
168
|
+
organization: options.organization,
|
|
169
|
+
max: options.max != null ? Number(options.max) : undefined,
|
|
170
|
+
},
|
|
171
|
+
{ ...context, staff: true, role: "admin" },
|
|
172
|
+
);
|
|
173
|
+
context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function nodesConsoleCommand(nodeId, context) {
|
|
178
|
+
const { nodesConsoleCapability } = await loadCapabilities();
|
|
179
|
+
const result = await runCatalogCapability(
|
|
180
|
+
nodesConsoleCapability,
|
|
181
|
+
{ node_id: nodeId },
|
|
182
|
+
{ ...context, role: context.staff ? "reviewer" : "user" },
|
|
183
|
+
);
|
|
184
|
+
context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
185
|
+
return 0;
|
|
186
|
+
}
|