@ornncompute/cli 0.1.8 → 0.2.0
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 -141
- package/package.json +7 -2
- package/src/api-client.mjs +147 -201
- package/src/auth-store.mjs +9 -5
- package/src/catalog-dispatch.mjs +186 -0
- package/src/cli.mjs +2126 -2351
- 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 +130 -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,110 +136,84 @@ 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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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;
|
|
167
|
+
export async function raiseForFailedResponse({
|
|
168
|
+
env = process.env,
|
|
169
|
+
fallbackMessage,
|
|
170
|
+
formatMessage = (message) => message,
|
|
171
|
+
hadToken,
|
|
172
|
+
response,
|
|
173
|
+
text,
|
|
174
|
+
url,
|
|
175
|
+
} = {}) {
|
|
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,
|
|
185
|
+
});
|
|
170
186
|
}
|
|
171
187
|
|
|
172
|
-
export function
|
|
173
|
-
const
|
|
174
|
-
if (!
|
|
175
|
-
|
|
176
|
-
"Set ORNN_INTERNAL_REVIEW_SECRET to authenticate operator commands, or run `ornn login` as Ornn staff.",
|
|
177
|
-
);
|
|
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;
|
|
178
192
|
}
|
|
179
|
-
return
|
|
193
|
+
return requireValidBaseUrl(explicit, "ORNN_INSTALLER_URL", { allowPath: true });
|
|
180
194
|
}
|
|
181
195
|
|
|
182
196
|
function operatorProxyEndpoint(endpoint) {
|
|
183
|
-
return `/
|
|
197
|
+
return `/v1/cli/operator${normalizeComputePath(endpoint)}`;
|
|
184
198
|
}
|
|
185
199
|
|
|
186
|
-
|
|
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({
|
|
187
204
|
body,
|
|
188
205
|
endpoint,
|
|
189
206
|
env = process.env,
|
|
190
207
|
fetchImpl = fetch,
|
|
191
208
|
method = "GET",
|
|
192
209
|
} = {}) {
|
|
193
|
-
|
|
210
|
+
const session = await loadAuthSession({ env });
|
|
211
|
+
if (!session?.accessToken) {
|
|
212
|
+
throw new CliApiError(
|
|
213
|
+
"Operator commands need Ornn staff auth: run `ornn login` as an internal user (ORNN_AUTH_BASE_URL).",
|
|
214
|
+
{ status: 401 }
|
|
215
|
+
);
|
|
216
|
+
}
|
|
194
217
|
return cliRequest({
|
|
195
218
|
body,
|
|
196
219
|
endpoint: operatorProxyEndpoint(endpoint),
|
|
@@ -201,101 +224,9 @@ async function operatorRequestViaStaffSession({
|
|
|
201
224
|
});
|
|
202
225
|
}
|
|
203
226
|
|
|
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
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Commerce-backed operator actions must cross the Web trust boundary. Web
|
|
276
|
-
* validates Commerce state before it forwards to Compute, so direct
|
|
277
|
-
* review-secret access is intentionally unavailable for these requests.
|
|
278
|
-
*/
|
|
279
|
-
export async function webOperatorRequest({
|
|
280
|
-
body,
|
|
281
|
-
endpoint,
|
|
282
|
-
env = process.env,
|
|
283
|
-
fetchImpl = fetch,
|
|
284
|
-
method = "GET",
|
|
285
|
-
} = {}) {
|
|
286
|
-
const session = await loadAuthSession({ env });
|
|
287
|
-
if (!session?.accessToken) {
|
|
288
|
-
throw new CliApiError(
|
|
289
|
-
"Commerce-backed deployment requires Ornn staff auth through Web. Run `ornn login` first.",
|
|
290
|
-
{ status: 401 },
|
|
291
|
-
);
|
|
292
|
-
}
|
|
293
|
-
return operatorRequestViaStaffSession({ body, endpoint, env, fetchImpl, method });
|
|
294
|
-
}
|
|
295
|
-
|
|
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,11 +240,11 @@ 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(
|
|
316
|
-
`${sourceName} must be a valid origin (got empty value after removing trailing slashes)
|
|
247
|
+
`${sourceName} must be a valid origin (got empty value after removing trailing slashes).`
|
|
317
248
|
);
|
|
318
249
|
}
|
|
319
250
|
let parsed;
|
|
@@ -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;
|
|
@@ -337,9 +271,7 @@ function parseJson(text, url, response) {
|
|
|
337
271
|
} catch {
|
|
338
272
|
const contentType = response?.headers?.get?.("content-type") || "";
|
|
339
273
|
const isHtml = /html/i.test(contentType) || /^\s*</.test(text);
|
|
340
|
-
const hint = isHtml
|
|
341
|
-
? " The Ornn host may not have this CLI endpoint deployed yet."
|
|
342
|
-
: "";
|
|
274
|
+
const hint = isHtml ? " The Ornn host may not have this CLI endpoint deployed yet." : "";
|
|
343
275
|
throw new CliApiError(`Ornn returned a non-JSON response from ${url}.${hint}`, {
|
|
344
276
|
status: response?.status,
|
|
345
277
|
});
|
|
@@ -351,26 +283,40 @@ async function handleAuthExpiry({ env, response, hadToken }) {
|
|
|
351
283
|
return;
|
|
352
284
|
}
|
|
353
285
|
await clearAuthSession({ env });
|
|
354
|
-
throw new CliApiError(
|
|
355
|
-
|
|
356
|
-
|
|
286
|
+
throw new CliApiError(
|
|
287
|
+
"Your Ornn session has expired or was revoked. Run `ornn login` to sign in again.",
|
|
288
|
+
{
|
|
289
|
+
status: 401,
|
|
290
|
+
}
|
|
291
|
+
);
|
|
357
292
|
}
|
|
358
293
|
|
|
359
294
|
function detailMessage(detail) {
|
|
360
295
|
if (typeof detail === "string") {
|
|
361
296
|
return detail;
|
|
362
297
|
}
|
|
298
|
+
if (Array.isArray(detail)) {
|
|
299
|
+
return detail
|
|
300
|
+
.map((item) => detailMessage(item))
|
|
301
|
+
.filter(Boolean)
|
|
302
|
+
.join("; ");
|
|
303
|
+
}
|
|
363
304
|
if (!detail || typeof detail !== "object") {
|
|
364
305
|
return "";
|
|
365
306
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
307
|
+
const message = [detail.msg, detail.message, detail.summary, detail.code].find(
|
|
308
|
+
(value) => typeof value === "string" && value.trim()
|
|
309
|
+
);
|
|
310
|
+
const location = Array.isArray(detail.loc)
|
|
311
|
+
? detail.loc
|
|
312
|
+
.filter((value) => typeof value === "string" || typeof value === "number")
|
|
313
|
+
.map(String)
|
|
314
|
+
.join(".")
|
|
315
|
+
: typeof detail.loc === "string"
|
|
316
|
+
? detail.loc.trim()
|
|
317
|
+
: "";
|
|
318
|
+
if (message) {
|
|
319
|
+
return location ? `${location}: ${message.trim()}` : message.trim();
|
|
374
320
|
}
|
|
375
|
-
return
|
|
321
|
+
return detailMessage(detail.detail ?? detail.errors);
|
|
376
322
|
}
|
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
|
+
}
|