@ornncompute/cli 0.1.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 +44 -0
- package/bin/ornn.js +11 -0
- package/package.json +35 -0
- package/src/api-client.mjs +112 -0
- package/src/auth-store.mjs +56 -0
- package/src/cli.mjs +902 -0
- package/src/device-auth.mjs +188 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Ornn Fabric CLI
|
|
2
|
+
|
|
3
|
+
Command-line access for Ornn Fabric marketplace, reserve, portfolio, VM/Bare
|
|
4
|
+
Metal machine, SSH key, and approved VM image workflows.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
curl -fsSL https://fabric.ornn.com/cli/install | sh
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The installer requires Node.js 20 or newer and npm. It installs `@ornncompute/cli`
|
|
13
|
+
globally, exposes both `fabric` and `ornn` binaries, adds the npm global bin
|
|
14
|
+
directory to the user's shell profile when needed, and writes
|
|
15
|
+
`ORNN_AUTH_BASE_URL` for the Fabric host that served the installer.
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
fabric login
|
|
21
|
+
fabric whoami
|
|
22
|
+
fabric status
|
|
23
|
+
fabric marketplace listings
|
|
24
|
+
fabric reserve view --view week
|
|
25
|
+
fabric reserve create --deployment-id <id> --gpu-count 8 --start-date 2026-06-01 --end-date 2026-06-07 --price 12.50
|
|
26
|
+
fabric portfolio reservations
|
|
27
|
+
fabric portfolio bids
|
|
28
|
+
fabric machines list <reservation-id>
|
|
29
|
+
fabric machines switch-access <reservation-id> --mode vm --ssh-key-id <key-id> --image-id <image-id>
|
|
30
|
+
fabric machines push-keys <instance-id> --ssh-key-id <key-id>
|
|
31
|
+
fabric ssh-keys list
|
|
32
|
+
fabric images list
|
|
33
|
+
fabric images upload --name "Research Image" --os-family ubuntu-22.04 --registry-ref ghcr.io/org/image:tag --sha256 <hex> --cosign-signature <sig> --size-bytes <n>
|
|
34
|
+
fabric images revoke <image-id>
|
|
35
|
+
fabric logout
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Custom VM image uploads are tenant-scoped by the web proxy; the CLI does not
|
|
39
|
+
ask for an internal tenant ID. Use `--image-file <path>` for small local test
|
|
40
|
+
payloads, or `--registry-ref <ref>` with `--sha256` and `--size-bytes` for
|
|
41
|
+
registry-backed image records.
|
|
42
|
+
|
|
43
|
+
Run `fabric --help` for the full command list and `fabric api` for the
|
|
44
|
+
allowlisted compute API escape hatch.
|
package/bin/ornn.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ornncompute/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line interface for Ornn Fabric marketplace, reserve, portfolio, and machine workflows.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"fabric": "bin/ornn.js",
|
|
8
|
+
"ornn": "bin/ornn.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://fabric.ornn.com",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/Ornn-AI/fabric/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/Ornn-AI/fabric.git",
|
|
22
|
+
"directory": "apps/cli"
|
|
23
|
+
},
|
|
24
|
+
"license": "UNLICENSED",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"registry": "https://registry.npmjs.org/"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { loadAuthSession } from "./auth-store.mjs";
|
|
2
|
+
import { resolveAuthBaseUrl } from "./device-auth.mjs";
|
|
3
|
+
|
|
4
|
+
export class CliApiError extends Error {
|
|
5
|
+
constructor(message, { status, detail } = {}) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "CliApiError";
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.detail = detail;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
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(/\/+$/, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function loadRequiredSession({ env = process.env } = {}) {
|
|
24
|
+
const session = await loadAuthSession({ env });
|
|
25
|
+
if (!session?.accessToken) {
|
|
26
|
+
throw new CliApiError("Not logged in. Run `fabric login` first.", { status: 401 });
|
|
27
|
+
}
|
|
28
|
+
return session;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function cliRequest({
|
|
32
|
+
authRequired = true,
|
|
33
|
+
body,
|
|
34
|
+
endpoint,
|
|
35
|
+
env = process.env,
|
|
36
|
+
fetchImpl = fetch,
|
|
37
|
+
method = "GET",
|
|
38
|
+
raw = false,
|
|
39
|
+
session,
|
|
40
|
+
} = {}) {
|
|
41
|
+
const authSession = session ?? (authRequired ? await loadRequiredSession({ env }) : null);
|
|
42
|
+
const baseUrl = resolveApiBaseUrl({ env, session: authSession });
|
|
43
|
+
const url = new URL(endpoint, `${baseUrl}/`);
|
|
44
|
+
const headers = {
|
|
45
|
+
Accept: raw ? "*/*" : "application/json",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (authSession?.accessToken) {
|
|
49
|
+
headers.Authorization = `Bearer ${authSession.accessToken}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let requestBody;
|
|
53
|
+
if (body !== undefined) {
|
|
54
|
+
headers["Content-Type"] = "application/json";
|
|
55
|
+
requestBody = typeof body === "string" ? body : JSON.stringify(body);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let response;
|
|
59
|
+
try {
|
|
60
|
+
response = await fetchImpl(url, {
|
|
61
|
+
body: requestBody,
|
|
62
|
+
headers,
|
|
63
|
+
method,
|
|
64
|
+
});
|
|
65
|
+
} catch (error) {
|
|
66
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
67
|
+
throw new CliApiError(`Could not reach Fabric at ${baseUrl}: ${message}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (raw) {
|
|
71
|
+
const text = await response.text();
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new CliApiError(text || response.statusText, { status: response.status });
|
|
74
|
+
}
|
|
75
|
+
return text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const text = await response.text();
|
|
79
|
+
const data = text ? parseJson(text, url.toString()) : null;
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
const detail = data?.detail || data?.error || response.statusText;
|
|
82
|
+
throw new CliApiError(`Fabric request failed: ${detail}`, {
|
|
83
|
+
detail: data,
|
|
84
|
+
status: response.status,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return data;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function computeEndpoint(path) {
|
|
91
|
+
const normalized = normalizeComputePath(path);
|
|
92
|
+
return `/api/cli/compute${normalized}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function normalizeComputePath(path) {
|
|
96
|
+
if (!path || typeof path !== "string") {
|
|
97
|
+
throw new CliApiError("API path is required.");
|
|
98
|
+
}
|
|
99
|
+
const trimmed = path.trim();
|
|
100
|
+
if (!trimmed.startsWith("/")) {
|
|
101
|
+
return `/${trimmed}`;
|
|
102
|
+
}
|
|
103
|
+
return trimmed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseJson(text, url) {
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(text);
|
|
109
|
+
} catch {
|
|
110
|
+
throw new CliApiError(`Fabric returned invalid JSON from ${url}.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
const APP_DIR = "ornn";
|
|
6
|
+
const AUTH_FILE = "auth.json";
|
|
7
|
+
|
|
8
|
+
export function getAuthConfigDir(env = process.env) {
|
|
9
|
+
if (env.ORNN_CONFIG_HOME?.trim()) {
|
|
10
|
+
return resolve(env.ORNN_CONFIG_HOME.trim());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (env.XDG_CONFIG_HOME?.trim()) {
|
|
14
|
+
return join(env.XDG_CONFIG_HOME.trim(), APP_DIR);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return join(homedir(), ".config", APP_DIR);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getAuthConfigPath(env = process.env) {
|
|
21
|
+
return join(getAuthConfigDir(env), AUTH_FILE);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function loadAuthSession({ env = process.env } = {}) {
|
|
25
|
+
const path = getAuthConfigPath(env);
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(path, "utf8");
|
|
29
|
+
return JSON.parse(raw);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error?.code === "ENOENT") {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function saveAuthSession(session, { env = process.env, now = new Date() } = {}) {
|
|
39
|
+
const path = getAuthConfigPath(env);
|
|
40
|
+
const payload = {
|
|
41
|
+
...session,
|
|
42
|
+
savedAt: now.toISOString(),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
46
|
+
await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, {
|
|
47
|
+
encoding: "utf8",
|
|
48
|
+
mode: 0o600,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return payload;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function clearAuthSession({ env = process.env } = {}) {
|
|
55
|
+
await rm(getAuthConfigPath(env), { force: true });
|
|
56
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CliApiError,
|
|
6
|
+
cliRequest,
|
|
7
|
+
computeEndpoint,
|
|
8
|
+
} from "./api-client.mjs";
|
|
9
|
+
import { clearAuthSession, getAuthConfigPath, loadAuthSession, saveAuthSession } from "./auth-store.mjs";
|
|
10
|
+
import {
|
|
11
|
+
DeviceAuthError,
|
|
12
|
+
openBrowser,
|
|
13
|
+
resolveAuthBaseUrl,
|
|
14
|
+
startDeviceFlow,
|
|
15
|
+
waitForDeviceApproval,
|
|
16
|
+
} from "./device-auth.mjs";
|
|
17
|
+
|
|
18
|
+
const VERSION = "0.1.0";
|
|
19
|
+
|
|
20
|
+
const HELP = `Ornn Fabric CLI
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
fabric login [--auth-base <url>] [--no-browser] [--timeout <seconds>]
|
|
24
|
+
fabric whoami [--json]
|
|
25
|
+
fabric status [--json]
|
|
26
|
+
fabric marketplace listings [--json]
|
|
27
|
+
fabric reserve view [--gpu-type <type>] [--facility <name>] [--view week|month]
|
|
28
|
+
fabric reserve create --deployment-id <id> --gpu-count <n> --start-date <yyyy-mm-dd> --end-date <yyyy-mm-dd> --price <usd>
|
|
29
|
+
fabric portfolio reservations [--status <status>] [--json]
|
|
30
|
+
fabric portfolio bids [--json]
|
|
31
|
+
fabric portfolio bid update <bid-id> --gpu-count <n> --min-gpu-count <n> --start-date <yyyy-mm-dd> --end-date <yyyy-mm-dd> --price <usd>
|
|
32
|
+
fabric portfolio bid withdraw <bid-id>
|
|
33
|
+
fabric portfolio reservation access-mode <reservation-id> --mode vm|bare-metal [--image-id <id>]
|
|
34
|
+
fabric portfolio reservation list <reservation-id> --ask-price <usd>
|
|
35
|
+
fabric portfolio reservation ask-price <reservation-id> --ask-price <usd>
|
|
36
|
+
fabric portfolio reservation delist <reservation-id>
|
|
37
|
+
fabric portfolio reservation buy <reservation-id>
|
|
38
|
+
fabric machines list <reservation-id> [--json]
|
|
39
|
+
fabric machines get <instance-id> [--json]
|
|
40
|
+
fabric machines cost <reservation-id> [--json]
|
|
41
|
+
fabric machines launch <reservation-id> --ssh-key-id <id> [--username <name>] [--machine-count <n>]
|
|
42
|
+
fabric machines switch-access <reservation-id> --mode vm|bare-metal [--ssh-key-id <id>] [--username <name>] [--image-id <id>]
|
|
43
|
+
fabric machines action <instance-id> start|stop|extend|teardown [--extend-by-minutes <n>]
|
|
44
|
+
fabric machines push-keys <instance-id> --ssh-key-id <id>
|
|
45
|
+
fabric machines revoke <instance-id>
|
|
46
|
+
fabric ssh-keys list [--json]
|
|
47
|
+
fabric ssh-keys add --public-key <key> [--label <label>]
|
|
48
|
+
fabric ssh-keys delete <key-id>
|
|
49
|
+
fabric images list [--json]
|
|
50
|
+
fabric images upload --name <name> --os-family <os> --cosign-signature <sig> (--image-file <path>|--registry-ref <ref>) [--sha256 <hex>] [--size-bytes <n>]
|
|
51
|
+
fabric images revoke <image-id>
|
|
52
|
+
fabric api <get|post|patch|put|delete> <compute-path> [--data <json>] [--raw]
|
|
53
|
+
fabric logout
|
|
54
|
+
|
|
55
|
+
Environment:
|
|
56
|
+
ORNN_AUTH_BASE_URL Web/auth server origin. Defaults to http://localhost:3000.
|
|
57
|
+
ORNN_API_BASE_URL Optional web API origin. Defaults to ORNN_AUTH_BASE_URL.
|
|
58
|
+
ORNN_CONFIG_HOME Directory for CLI auth state. Defaults to ~/.config/ornn.
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
export async function run(argv = [], io = {}) {
|
|
62
|
+
const stdout = io.stdout ?? process.stdout;
|
|
63
|
+
const stderr = io.stderr ?? process.stderr;
|
|
64
|
+
const env = io.env ?? process.env;
|
|
65
|
+
const fetchImpl = io.fetch ?? fetch;
|
|
66
|
+
const [command, ...args] = argv;
|
|
67
|
+
|
|
68
|
+
if (!command || command === "--help" || command === "-h") {
|
|
69
|
+
stdout.write(HELP);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
74
|
+
stdout.write(`${VERSION}\n`);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
if (command === "login") {
|
|
80
|
+
return await login(args, { env, stderr, stdout });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (command === "whoami" || command === "account") {
|
|
84
|
+
return await whoami(args, { env, fetchImpl, stderr, stdout });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (command === "status") {
|
|
88
|
+
return await status(args, { env, fetchImpl, stdout });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (command === "api") {
|
|
92
|
+
return await api(args, { env, fetchImpl, stdout });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (command === "marketplace") {
|
|
96
|
+
return await marketplace(args, { env, fetchImpl, stdout });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (command === "reserve") {
|
|
100
|
+
return await reserve(args, { env, fetchImpl, stdout });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (command === "portfolio") {
|
|
104
|
+
return await portfolio(args, { env, fetchImpl, stdout });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (command === "machines") {
|
|
108
|
+
return await machines(args, { env, fetchImpl, stdout });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (command === "ssh-keys") {
|
|
112
|
+
return await sshKeys(args, { env, fetchImpl, stdout });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (command === "images") {
|
|
116
|
+
return await images(args, { env, fetchImpl, stdout });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (command === "logout") {
|
|
120
|
+
await clearAuthSession({ env });
|
|
121
|
+
stdout.write("Logged out of Ornn.\n");
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
stderr.write(`Unknown command: ${command}\n\n${HELP}`);
|
|
126
|
+
return 1;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
129
|
+
stderr.write(`${message}\n`);
|
|
130
|
+
return error instanceof DeviceAuthError || error instanceof CliApiError ? 2 : 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function login(args, { env, stderr, stdout }) {
|
|
135
|
+
const options = parseLoginOptions(args);
|
|
136
|
+
|
|
137
|
+
if (options.help) {
|
|
138
|
+
stdout.write(HELP);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const authBaseUrl = resolveAuthBaseUrl({ env, explicit: options.authBase });
|
|
143
|
+
stdout.write(`Starting Ornn browser login against ${authBaseUrl}\n`);
|
|
144
|
+
|
|
145
|
+
const started = await startDeviceFlow({ authBaseUrl });
|
|
146
|
+
stdout.write(`\nOpen this URL to continue:\n${started.verificationUri}\n\n`);
|
|
147
|
+
stdout.write(`Code: ${started.userCode}\n\n`);
|
|
148
|
+
|
|
149
|
+
if (!options.noBrowser) {
|
|
150
|
+
const opened = await openBrowser(started.verificationUri);
|
|
151
|
+
if (!opened) {
|
|
152
|
+
stderr.write("Could not open the browser automatically. Open the URL above manually.\n");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
stdout.write("Waiting for browser approval");
|
|
157
|
+
const session = await waitForDeviceApproval({
|
|
158
|
+
authBaseUrl,
|
|
159
|
+
deviceCode: started.deviceCode,
|
|
160
|
+
expiresIn: started.expiresIn,
|
|
161
|
+
interval: options.pollInterval ?? started.interval,
|
|
162
|
+
onPending: () => stdout.write("."),
|
|
163
|
+
timeoutSeconds: options.timeout,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await saveAuthSession(session, { env });
|
|
167
|
+
stdout.write(`\nLogged in as ${formatUser(session.user)}.\n`);
|
|
168
|
+
stdout.write(`Session saved to ${getAuthConfigPath(env)}\n`);
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function whoami(args, { env, fetchImpl, stderr, stdout }) {
|
|
173
|
+
const { options } = parseOptions(args, { boolean: ["json"] });
|
|
174
|
+
const session = await loadAuthSession({ env });
|
|
175
|
+
|
|
176
|
+
if (!session) {
|
|
177
|
+
stderr.write("Not logged in. Run `fabric login` first.\n");
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const serverSession = await cliRequest({
|
|
183
|
+
endpoint: "/api/cli/session",
|
|
184
|
+
env,
|
|
185
|
+
fetchImpl,
|
|
186
|
+
session,
|
|
187
|
+
});
|
|
188
|
+
if (options.json) {
|
|
189
|
+
writeJson(stdout, serverSession);
|
|
190
|
+
} else {
|
|
191
|
+
stdout.write(`${formatUser(serverSession.user)}\n`);
|
|
192
|
+
stdout.write(`Organization: ${serverSession.organization?.name || "none"}\n`);
|
|
193
|
+
stdout.write(`Tenant: ${serverSession.tenant?.company_name || serverSession.tenant?.id || "none"}\n`);
|
|
194
|
+
stdout.write(`Role: ${serverSession.role || "none"}\n`);
|
|
195
|
+
stdout.write(`Approved: ${serverSession.routeState.isApproved ? "yes" : "no"}\n`);
|
|
196
|
+
}
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (options.json) {
|
|
199
|
+
writeJson(stdout, session);
|
|
200
|
+
} else {
|
|
201
|
+
stdout.write(`${formatUser(session.user)}\n`);
|
|
202
|
+
stdout.write(`Auth base: ${session.authBaseUrl || "unknown"}\n`);
|
|
203
|
+
stdout.write("Server session: unavailable\n");
|
|
204
|
+
if (session.savedAt) {
|
|
205
|
+
stdout.write(`Saved at: ${session.savedAt}\n`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function status(args, { env, fetchImpl, stdout }) {
|
|
213
|
+
const { options } = parseOptions(args, { boolean: ["json"] });
|
|
214
|
+
const snapshot = await cliRequest({
|
|
215
|
+
authRequired: false,
|
|
216
|
+
endpoint: "/api/cli/status",
|
|
217
|
+
env,
|
|
218
|
+
fetchImpl,
|
|
219
|
+
});
|
|
220
|
+
if (options.json) {
|
|
221
|
+
writeJson(stdout, snapshot);
|
|
222
|
+
} else {
|
|
223
|
+
stdout.write(`${snapshot.overallStatusLabel}\n`);
|
|
224
|
+
stdout.write(`${snapshot.overallDetail}\n`);
|
|
225
|
+
stdout.write(`Generated: ${snapshot.generatedAtLabel}\n`);
|
|
226
|
+
for (const group of snapshot.groups || []) {
|
|
227
|
+
stdout.write(`${group.name}: ${group.statusLabel}\n`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function api(args, { env, fetchImpl, stdout }) {
|
|
234
|
+
const [method, path, ...rest] = args;
|
|
235
|
+
if (!method || !path) {
|
|
236
|
+
throw new Error("Usage: fabric api <get|post|patch|put|delete> <compute-path> [--data <json>] [--raw]");
|
|
237
|
+
}
|
|
238
|
+
const normalizedMethod = method.toUpperCase();
|
|
239
|
+
if (!["DELETE", "GET", "PATCH", "POST", "PUT"].includes(normalizedMethod)) {
|
|
240
|
+
throw new Error(`Unsupported API method: ${method}`);
|
|
241
|
+
}
|
|
242
|
+
const { options } = parseOptions(rest, { boolean: ["raw", "json"] });
|
|
243
|
+
const body = await readJsonOption(options.data);
|
|
244
|
+
const response = await cliRequest({
|
|
245
|
+
body,
|
|
246
|
+
endpoint: computeEndpoint(path),
|
|
247
|
+
env,
|
|
248
|
+
fetchImpl,
|
|
249
|
+
method: normalizedMethod,
|
|
250
|
+
raw: options.raw,
|
|
251
|
+
});
|
|
252
|
+
writeOutput(stdout, response, { raw: options.raw });
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function marketplace(args, context) {
|
|
257
|
+
const [subcommand, ...rest] = args;
|
|
258
|
+
if (subcommand !== "listings") {
|
|
259
|
+
throw new Error("Usage: fabric marketplace listings [--json]");
|
|
260
|
+
}
|
|
261
|
+
const { options } = parseOptions(rest, { boolean: ["json"] });
|
|
262
|
+
const listings = await cliRequest({
|
|
263
|
+
authRequired: false,
|
|
264
|
+
endpoint: computeEndpoint("/marketplace/resale-listings"),
|
|
265
|
+
env: context.env,
|
|
266
|
+
fetchImpl: context.fetchImpl,
|
|
267
|
+
});
|
|
268
|
+
writeOutput(context.stdout, listings, { json: options.json });
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function reserve(args, context) {
|
|
273
|
+
const [subcommand, ...rest] = args;
|
|
274
|
+
if (subcommand === "view") {
|
|
275
|
+
const { options } = parseOptions(rest, { boolean: ["json"] });
|
|
276
|
+
const query = buildQuery({
|
|
277
|
+
anchor_date: options.anchorDate,
|
|
278
|
+
facility: options.facility,
|
|
279
|
+
gpu_type: options.gpuType,
|
|
280
|
+
view: options.view,
|
|
281
|
+
});
|
|
282
|
+
const payload = await cliRequest({
|
|
283
|
+
authRequired: false,
|
|
284
|
+
endpoint: computeEndpoint(`/reserve/view${query}`),
|
|
285
|
+
env: context.env,
|
|
286
|
+
fetchImpl: context.fetchImpl,
|
|
287
|
+
});
|
|
288
|
+
writeOutput(context.stdout, payload, { json: options.json });
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (subcommand === "create") {
|
|
293
|
+
const { options } = parseOptions(rest);
|
|
294
|
+
const gpuCount = numberOption(options.gpuCount, "--gpu-count");
|
|
295
|
+
const minGpuCount = options.minGpuCount ? numberOption(options.minGpuCount, "--min-gpu-count") : gpuCount;
|
|
296
|
+
const payload = {
|
|
297
|
+
access_mode: options.accessMode || "vm",
|
|
298
|
+
await_down_payment: true,
|
|
299
|
+
deployment_id: requiredOption(options.deploymentId, "--deployment-id"),
|
|
300
|
+
end_date: requiredOption(options.endDate, "--end-date"),
|
|
301
|
+
gpu_count: gpuCount,
|
|
302
|
+
image_id: options.imageId || null,
|
|
303
|
+
min_gpu_count: minGpuCount,
|
|
304
|
+
price_per_gpu_hour: numberOption(options.price || options.bidPricePerGpuHour, "--price"),
|
|
305
|
+
start_date: requiredOption(options.startDate, "--start-date"),
|
|
306
|
+
};
|
|
307
|
+
const reservation = await cliRequest({
|
|
308
|
+
body: payload,
|
|
309
|
+
endpoint: computeEndpoint("/reservations"),
|
|
310
|
+
env: context.env,
|
|
311
|
+
fetchImpl: context.fetchImpl,
|
|
312
|
+
method: "POST",
|
|
313
|
+
});
|
|
314
|
+
writeOutput(context.stdout, reservation);
|
|
315
|
+
return 0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
throw new Error("Usage: fabric reserve view|create");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function portfolio(args, context) {
|
|
322
|
+
const [area, subcommand, ...rest] = args;
|
|
323
|
+
|
|
324
|
+
if (area === "reservations") {
|
|
325
|
+
const { options } = parseOptions([subcommand, ...rest].filter(Boolean), { boolean: ["json"] });
|
|
326
|
+
const query = buildQuery({ status: options.status });
|
|
327
|
+
const reservations = await cliRequest({
|
|
328
|
+
endpoint: computeEndpoint(`/tenants/me/reservations${query}`),
|
|
329
|
+
env: context.env,
|
|
330
|
+
fetchImpl: context.fetchImpl,
|
|
331
|
+
});
|
|
332
|
+
writeOutput(context.stdout, reservations, { json: options.json });
|
|
333
|
+
return 0;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (area === "bids") {
|
|
337
|
+
const { options } = parseOptions([subcommand, ...rest].filter(Boolean), { boolean: ["json"] });
|
|
338
|
+
const bids = await cliRequest({
|
|
339
|
+
endpoint: computeEndpoint("/tenants/me/bids"),
|
|
340
|
+
env: context.env,
|
|
341
|
+
fetchImpl: context.fetchImpl,
|
|
342
|
+
});
|
|
343
|
+
writeOutput(context.stdout, bids, { json: options.json });
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (area === "bid") {
|
|
348
|
+
return portfolioBid(subcommand, rest, context);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (area === "reservation") {
|
|
352
|
+
return portfolioReservation(subcommand, rest, context);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
throw new Error("Usage: fabric portfolio reservations|bids|bid|reservation");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function portfolioBid(action, args, context) {
|
|
359
|
+
const [bidId, ...rest] = args;
|
|
360
|
+
if (!action || !bidId) {
|
|
361
|
+
throw new Error("Usage: fabric portfolio bid update|withdraw <bid-id>");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (action === "withdraw" || action === "delete") {
|
|
365
|
+
const response = await cliRequest({
|
|
366
|
+
endpoint: computeEndpoint(`/tenants/me/bids/${bidId}`),
|
|
367
|
+
env: context.env,
|
|
368
|
+
fetchImpl: context.fetchImpl,
|
|
369
|
+
method: "DELETE",
|
|
370
|
+
});
|
|
371
|
+
writeOutput(context.stdout, response ?? { ok: true });
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (action === "update") {
|
|
376
|
+
const { options } = parseOptions(rest);
|
|
377
|
+
const payload = {
|
|
378
|
+
bid_price_per_gpu_hour: numberOption(options.price || options.bidPricePerGpuHour, "--price"),
|
|
379
|
+
end_date: requiredOption(options.endDate, "--end-date"),
|
|
380
|
+
gpu_count: numberOption(options.gpuCount, "--gpu-count"),
|
|
381
|
+
min_gpu_count: numberOption(options.minGpuCount, "--min-gpu-count"),
|
|
382
|
+
start_date: requiredOption(options.startDate, "--start-date"),
|
|
383
|
+
};
|
|
384
|
+
const bid = await cliRequest({
|
|
385
|
+
body: payload,
|
|
386
|
+
endpoint: computeEndpoint(`/tenants/me/bids/${bidId}`),
|
|
387
|
+
env: context.env,
|
|
388
|
+
fetchImpl: context.fetchImpl,
|
|
389
|
+
method: "PATCH",
|
|
390
|
+
});
|
|
391
|
+
writeOutput(context.stdout, bid);
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
throw new Error(`Unsupported portfolio bid action: ${action}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function portfolioReservation(action, args, context) {
|
|
399
|
+
const [reservationId, ...rest] = args;
|
|
400
|
+
if (!action || !reservationId) {
|
|
401
|
+
throw new Error("Usage: fabric portfolio reservation access-mode|list|ask-price|delist|buy <reservation-id>");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const { options } = parseOptions(rest);
|
|
405
|
+
const basePath = `/tenants/me/reservations/${reservationId}`;
|
|
406
|
+
|
|
407
|
+
if (action === "access-mode") {
|
|
408
|
+
const payload = {
|
|
409
|
+
access_mode: requiredOption(options.mode, "--mode"),
|
|
410
|
+
image_id: options.imageId || null,
|
|
411
|
+
};
|
|
412
|
+
const access = await cliRequest({
|
|
413
|
+
body: payload,
|
|
414
|
+
endpoint: computeEndpoint(`${basePath}/access-mode`),
|
|
415
|
+
env: context.env,
|
|
416
|
+
fetchImpl: context.fetchImpl,
|
|
417
|
+
method: "POST",
|
|
418
|
+
});
|
|
419
|
+
writeOutput(context.stdout, access);
|
|
420
|
+
return 0;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (action === "list") {
|
|
424
|
+
const reservation = await cliRequest({
|
|
425
|
+
body: { ask_price_per_gpu_hour: numberOption(options.askPrice, "--ask-price") },
|
|
426
|
+
endpoint: computeEndpoint(`${basePath}/list`),
|
|
427
|
+
env: context.env,
|
|
428
|
+
fetchImpl: context.fetchImpl,
|
|
429
|
+
method: "POST",
|
|
430
|
+
});
|
|
431
|
+
writeOutput(context.stdout, reservation);
|
|
432
|
+
return 0;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (action === "ask-price") {
|
|
436
|
+
const reservation = await cliRequest({
|
|
437
|
+
body: { ask_price_per_gpu_hour: numberOption(options.askPrice, "--ask-price") },
|
|
438
|
+
endpoint: computeEndpoint(`${basePath}/ask-price`),
|
|
439
|
+
env: context.env,
|
|
440
|
+
fetchImpl: context.fetchImpl,
|
|
441
|
+
method: "PATCH",
|
|
442
|
+
});
|
|
443
|
+
writeOutput(context.stdout, reservation);
|
|
444
|
+
return 0;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (action === "delist") {
|
|
448
|
+
const reservation = await cliRequest({
|
|
449
|
+
endpoint: computeEndpoint(`${basePath}/delist`),
|
|
450
|
+
env: context.env,
|
|
451
|
+
fetchImpl: context.fetchImpl,
|
|
452
|
+
method: "POST",
|
|
453
|
+
});
|
|
454
|
+
writeOutput(context.stdout, reservation);
|
|
455
|
+
return 0;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (action === "buy") {
|
|
459
|
+
const transaction = await cliRequest({
|
|
460
|
+
body: { buyer_tenant_id: "me" },
|
|
461
|
+
endpoint: computeEndpoint(`${basePath}/buy`),
|
|
462
|
+
env: context.env,
|
|
463
|
+
fetchImpl: context.fetchImpl,
|
|
464
|
+
method: "POST",
|
|
465
|
+
});
|
|
466
|
+
writeOutput(context.stdout, transaction);
|
|
467
|
+
return 0;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
throw new Error(`Unsupported portfolio reservation action: ${action}`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function machines(args, context) {
|
|
474
|
+
const [subcommand, id, action, ...rest] = args;
|
|
475
|
+
if (subcommand === "list" && id) {
|
|
476
|
+
const { options } = parseOptions([action, ...rest].filter(Boolean), { boolean: ["json"] });
|
|
477
|
+
const machinesPayload = await cliRequest({
|
|
478
|
+
endpoint: computeEndpoint(`/standalone-vms/reservations/${id}/machines`),
|
|
479
|
+
env: context.env,
|
|
480
|
+
fetchImpl: context.fetchImpl,
|
|
481
|
+
});
|
|
482
|
+
writeOutput(context.stdout, machinesPayload, { json: options.json });
|
|
483
|
+
return 0;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (subcommand === "get" && id) {
|
|
487
|
+
const { options } = parseOptions([action, ...rest].filter(Boolean), { boolean: ["json"] });
|
|
488
|
+
const machine = await cliRequest({
|
|
489
|
+
endpoint: computeEndpoint(`/standalone-vms/${id}`),
|
|
490
|
+
env: context.env,
|
|
491
|
+
fetchImpl: context.fetchImpl,
|
|
492
|
+
});
|
|
493
|
+
writeOutput(context.stdout, machine, { json: options.json });
|
|
494
|
+
return 0;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (subcommand === "cost" && id) {
|
|
498
|
+
const { options } = parseOptions([action, ...rest].filter(Boolean), { boolean: ["json"] });
|
|
499
|
+
const cost = await cliRequest({
|
|
500
|
+
endpoint: computeEndpoint(`/standalone-vms/reservations/${id}/cost`),
|
|
501
|
+
env: context.env,
|
|
502
|
+
fetchImpl: context.fetchImpl,
|
|
503
|
+
});
|
|
504
|
+
writeOutput(context.stdout, cost, { json: options.json });
|
|
505
|
+
return 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (subcommand === "launch" && id) {
|
|
509
|
+
const { options } = parseOptions([action, ...rest].filter(Boolean));
|
|
510
|
+
const payload = {
|
|
511
|
+
image_id: options.imageId || null,
|
|
512
|
+
machine_count: options.machineCount ? numberOption(options.machineCount, "--machine-count") : 1,
|
|
513
|
+
machine_type: options.machineType || null,
|
|
514
|
+
request_id: options.requestId || null,
|
|
515
|
+
ssh_key_ids: arrayOption(options.sshKeyId),
|
|
516
|
+
tenant_username: options.username || options.tenantUsername || null,
|
|
517
|
+
zone: options.zone || null,
|
|
518
|
+
};
|
|
519
|
+
const response = await cliRequest({
|
|
520
|
+
body: payload,
|
|
521
|
+
endpoint: computeEndpoint(`/standalone-vms/reservations/${id}/launch`),
|
|
522
|
+
env: context.env,
|
|
523
|
+
fetchImpl: context.fetchImpl,
|
|
524
|
+
method: "POST",
|
|
525
|
+
});
|
|
526
|
+
writeOutput(context.stdout, response);
|
|
527
|
+
return 0;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (subcommand === "switch-access" && id) {
|
|
531
|
+
const { options } = parseOptions([action, ...rest].filter(Boolean));
|
|
532
|
+
const payload = {
|
|
533
|
+
access_mode: requiredOption(options.mode, "--mode"),
|
|
534
|
+
image_id: options.imageId || null,
|
|
535
|
+
request_id: options.requestId || null,
|
|
536
|
+
ssh_key_ids: arrayOption(options.sshKeyId),
|
|
537
|
+
tenant_username: options.username || options.tenantUsername || null,
|
|
538
|
+
};
|
|
539
|
+
const response = await cliRequest({
|
|
540
|
+
body: payload,
|
|
541
|
+
endpoint: computeEndpoint(`/standalone-vms/reservations/${id}/switch-access`),
|
|
542
|
+
env: context.env,
|
|
543
|
+
fetchImpl: context.fetchImpl,
|
|
544
|
+
method: "POST",
|
|
545
|
+
});
|
|
546
|
+
writeOutput(context.stdout, response);
|
|
547
|
+
return 0;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (subcommand === "action" && id && action) {
|
|
551
|
+
if (!["extend", "start", "stop", "teardown"].includes(action)) {
|
|
552
|
+
throw new Error("Machine action must be start, stop, extend, or teardown.");
|
|
553
|
+
}
|
|
554
|
+
const { options } = parseOptions(rest);
|
|
555
|
+
const body = action === "extend"
|
|
556
|
+
? { extend_by_minutes: numberOption(options.extendByMinutes || 60, "--extend-by-minutes") }
|
|
557
|
+
: undefined;
|
|
558
|
+
const response = await cliRequest({
|
|
559
|
+
body,
|
|
560
|
+
endpoint: computeEndpoint(`/standalone-vms/${id}/${action}`),
|
|
561
|
+
env: context.env,
|
|
562
|
+
fetchImpl: context.fetchImpl,
|
|
563
|
+
method: "POST",
|
|
564
|
+
});
|
|
565
|
+
writeOutput(context.stdout, response);
|
|
566
|
+
return 0;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (subcommand === "push-keys" && id) {
|
|
570
|
+
const { options } = parseOptions([action, ...rest].filter(Boolean));
|
|
571
|
+
const payload = {
|
|
572
|
+
request_id: options.requestId || null,
|
|
573
|
+
ssh_key_ids: requiredArrayOption(options.sshKeyId, "--ssh-key-id"),
|
|
574
|
+
};
|
|
575
|
+
const response = await cliRequest({
|
|
576
|
+
body: payload,
|
|
577
|
+
endpoint: computeEndpoint(`/standalone-vms/${id}/push-keys`),
|
|
578
|
+
env: context.env,
|
|
579
|
+
fetchImpl: context.fetchImpl,
|
|
580
|
+
method: "POST",
|
|
581
|
+
});
|
|
582
|
+
writeOutput(context.stdout, response);
|
|
583
|
+
return 0;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (subcommand === "revoke" && id) {
|
|
587
|
+
const { options } = parseOptions([action, ...rest].filter(Boolean));
|
|
588
|
+
const response = await cliRequest({
|
|
589
|
+
body: { request_id: options.requestId || null },
|
|
590
|
+
endpoint: computeEndpoint(`/standalone-vms/${id}/revoke`),
|
|
591
|
+
env: context.env,
|
|
592
|
+
fetchImpl: context.fetchImpl,
|
|
593
|
+
method: "POST",
|
|
594
|
+
});
|
|
595
|
+
writeOutput(context.stdout, response);
|
|
596
|
+
return 0;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
throw new Error("Usage: fabric machines list|get|cost|launch|switch-access|action|push-keys|revoke");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function sshKeys(args, context) {
|
|
603
|
+
const [subcommand, id, ...rest] = args;
|
|
604
|
+
if (subcommand === "list") {
|
|
605
|
+
const keys = await cliRequest({
|
|
606
|
+
endpoint: computeEndpoint("/standalone-vms/tenants/me/ssh-keys"),
|
|
607
|
+
env: context.env,
|
|
608
|
+
fetchImpl: context.fetchImpl,
|
|
609
|
+
});
|
|
610
|
+
writeOutput(context.stdout, keys);
|
|
611
|
+
return 0;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (subcommand === "add") {
|
|
615
|
+
const { options } = parseOptions([id, ...rest].filter(Boolean));
|
|
616
|
+
const publicKey = options.publicKeyFile
|
|
617
|
+
? (await readFile(options.publicKeyFile, "utf8")).trim()
|
|
618
|
+
: requiredOption(options.publicKey, "--public-key");
|
|
619
|
+
const key = await cliRequest({
|
|
620
|
+
body: {
|
|
621
|
+
label: options.label || null,
|
|
622
|
+
public_key: publicKey,
|
|
623
|
+
},
|
|
624
|
+
endpoint: computeEndpoint("/standalone-vms/tenants/me/ssh-keys"),
|
|
625
|
+
env: context.env,
|
|
626
|
+
fetchImpl: context.fetchImpl,
|
|
627
|
+
method: "POST",
|
|
628
|
+
});
|
|
629
|
+
writeOutput(context.stdout, key);
|
|
630
|
+
return 0;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if ((subcommand === "delete" || subcommand === "remove") && id) {
|
|
634
|
+
const response = await cliRequest({
|
|
635
|
+
endpoint: computeEndpoint(`/standalone-vms/tenants/me/ssh-keys/${id}`),
|
|
636
|
+
env: context.env,
|
|
637
|
+
fetchImpl: context.fetchImpl,
|
|
638
|
+
method: "DELETE",
|
|
639
|
+
});
|
|
640
|
+
writeOutput(context.stdout, response);
|
|
641
|
+
return 0;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
throw new Error("Usage: fabric ssh-keys list|add|delete");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function images(args, context) {
|
|
648
|
+
const [subcommand, id, ...rest] = args;
|
|
649
|
+
if (subcommand === "list") {
|
|
650
|
+
const { options } = parseOptions([id, ...rest].filter(Boolean), { boolean: ["json"] });
|
|
651
|
+
const imagesPayload = await cliRequest({
|
|
652
|
+
endpoint: computeEndpoint("/account/images"),
|
|
653
|
+
env: context.env,
|
|
654
|
+
fetchImpl: context.fetchImpl,
|
|
655
|
+
});
|
|
656
|
+
writeOutput(context.stdout, imagesPayload, { json: options.json });
|
|
657
|
+
return 0;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (subcommand === "upload") {
|
|
661
|
+
const { options } = parseOptions([id, ...rest].filter(Boolean), {
|
|
662
|
+
boolean: ["json", "operator-approved-large-image"],
|
|
663
|
+
});
|
|
664
|
+
const payload = await buildImageUploadPayload(options);
|
|
665
|
+
const image = await cliRequest({
|
|
666
|
+
body: payload,
|
|
667
|
+
endpoint: computeEndpoint("/vm-images"),
|
|
668
|
+
env: context.env,
|
|
669
|
+
fetchImpl: context.fetchImpl,
|
|
670
|
+
method: "POST",
|
|
671
|
+
});
|
|
672
|
+
writeOutput(context.stdout, image, { json: options.json });
|
|
673
|
+
return 0;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if ((subcommand === "revoke" || subcommand === "delete") && id) {
|
|
677
|
+
const { options } = parseOptions(rest, { boolean: ["json"] });
|
|
678
|
+
const response = await cliRequest({
|
|
679
|
+
endpoint: computeEndpoint(`/vm-images/${id}/revoke`),
|
|
680
|
+
env: context.env,
|
|
681
|
+
fetchImpl: context.fetchImpl,
|
|
682
|
+
method: "POST",
|
|
683
|
+
});
|
|
684
|
+
writeOutput(context.stdout, response, { json: options.json });
|
|
685
|
+
return 0;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
throw new Error("Usage: fabric images list|upload|revoke");
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function buildImageUploadPayload(options) {
|
|
692
|
+
const imageFile = options.imageFile ? requiredOption(options.imageFile, "--image-file") : null;
|
|
693
|
+
const registryRef = options.registryRef ? requiredOption(options.registryRef, "--registry-ref") : null;
|
|
694
|
+
if (!imageFile && !registryRef) {
|
|
695
|
+
throw new Error("Provide --image-file or --registry-ref for image upload.");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
let imageBuffer = null;
|
|
699
|
+
if (imageFile) {
|
|
700
|
+
imageBuffer = await readFile(imageFile);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const sha256 = options.sha256
|
|
704
|
+
? requiredOption(options.sha256, "--sha256")
|
|
705
|
+
: imageBuffer
|
|
706
|
+
? createHash("sha256").update(imageBuffer).digest("hex")
|
|
707
|
+
: requiredOption(options.sha256, "--sha256");
|
|
708
|
+
const sizeBytes = options.sizeBytes
|
|
709
|
+
? numberOption(options.sizeBytes, "--size-bytes")
|
|
710
|
+
: imageBuffer
|
|
711
|
+
? imageBuffer.byteLength
|
|
712
|
+
: numberOption(options.sizeBytes, "--size-bytes");
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
cosign_signature: requiredOption(options.cosignSignature, "--cosign-signature"),
|
|
716
|
+
image_b64: imageBuffer ? imageBuffer.toString("base64") : undefined,
|
|
717
|
+
name: requiredOption(options.name, "--name"),
|
|
718
|
+
operator_approved_large_image: Boolean(options.operatorApprovedLargeImage),
|
|
719
|
+
os_family: requiredOption(options.osFamily, "--os-family"),
|
|
720
|
+
registry_ref: registryRef,
|
|
721
|
+
sha256,
|
|
722
|
+
size_bytes: sizeBytes,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function requiredArrayOption(value, name) {
|
|
727
|
+
const values = arrayOption(value);
|
|
728
|
+
if (!values.length) {
|
|
729
|
+
throw new Error(`${name} is required.`);
|
|
730
|
+
}
|
|
731
|
+
return values;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function parseLoginOptions(args) {
|
|
735
|
+
const options = {
|
|
736
|
+
authBase: null,
|
|
737
|
+
help: false,
|
|
738
|
+
noBrowser: false,
|
|
739
|
+
pollInterval: null,
|
|
740
|
+
timeout: null,
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
744
|
+
const arg = args[index];
|
|
745
|
+
if (arg === "--help" || arg === "-h") {
|
|
746
|
+
options.help = true;
|
|
747
|
+
} else if (arg === "--no-browser") {
|
|
748
|
+
options.noBrowser = true;
|
|
749
|
+
} else if (arg === "--auth-base") {
|
|
750
|
+
options.authBase = requireValue(args, index, arg);
|
|
751
|
+
index += 1;
|
|
752
|
+
} else if (arg === "--timeout") {
|
|
753
|
+
options.timeout = parsePositiveNumber(requireValue(args, index, arg), arg);
|
|
754
|
+
index += 1;
|
|
755
|
+
} else if (arg === "--poll-interval") {
|
|
756
|
+
options.pollInterval = parsePositiveNumber(requireValue(args, index, arg), arg);
|
|
757
|
+
index += 1;
|
|
758
|
+
} else {
|
|
759
|
+
throw new Error(`Unknown login option: ${arg}`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return options;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function requireValue(args, index, option) {
|
|
767
|
+
const value = args[index + 1];
|
|
768
|
+
if (!value || value.startsWith("-")) {
|
|
769
|
+
throw new Error(`Missing value for ${option}.`);
|
|
770
|
+
}
|
|
771
|
+
return value;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function parsePositiveNumber(value, option) {
|
|
775
|
+
const parsed = Number(value);
|
|
776
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
777
|
+
throw new Error(`${option} must be a positive number.`);
|
|
778
|
+
}
|
|
779
|
+
return parsed;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function formatUser(user = {}) {
|
|
783
|
+
const name = user.name ? `${user.name} ` : "";
|
|
784
|
+
const email = user.email || "unknown user";
|
|
785
|
+
return `${name}<${email}>`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function parseOptions(args, { boolean = [] } = {}) {
|
|
789
|
+
const booleanSet = new Set(boolean.map(toCamelCase));
|
|
790
|
+
const options = {};
|
|
791
|
+
const positionals = [];
|
|
792
|
+
|
|
793
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
794
|
+
const arg = args[index];
|
|
795
|
+
if (!arg?.startsWith("--")) {
|
|
796
|
+
positionals.push(arg);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const optionText = arg.slice(2);
|
|
801
|
+
const [rawKey, inlineValue] = splitOption(optionText);
|
|
802
|
+
const key = toCamelCase(rawKey);
|
|
803
|
+
let value;
|
|
804
|
+
if (inlineValue !== null) {
|
|
805
|
+
value = inlineValue;
|
|
806
|
+
} else if (booleanSet.has(key)) {
|
|
807
|
+
value = true;
|
|
808
|
+
} else {
|
|
809
|
+
value = requireValue(args, index, arg);
|
|
810
|
+
index += 1;
|
|
811
|
+
}
|
|
812
|
+
addOption(options, key, value);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return { options, positionals };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function splitOption(value) {
|
|
819
|
+
const equalsIndex = value.indexOf("=");
|
|
820
|
+
if (equalsIndex === -1) {
|
|
821
|
+
return [value, null];
|
|
822
|
+
}
|
|
823
|
+
return [value.slice(0, equalsIndex), value.slice(equalsIndex + 1)];
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function addOption(options, key, value) {
|
|
827
|
+
if (options[key] === undefined) {
|
|
828
|
+
options[key] = value;
|
|
829
|
+
} else if (Array.isArray(options[key])) {
|
|
830
|
+
options[key].push(value);
|
|
831
|
+
} else {
|
|
832
|
+
options[key] = [options[key], value];
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function toCamelCase(value) {
|
|
837
|
+
return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function requiredOption(value, name) {
|
|
841
|
+
if (value === undefined || value === null || value === "") {
|
|
842
|
+
throw new Error(`${name} is required.`);
|
|
843
|
+
}
|
|
844
|
+
if (Array.isArray(value)) {
|
|
845
|
+
return value[value.length - 1];
|
|
846
|
+
}
|
|
847
|
+
return value;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function numberOption(value, name) {
|
|
851
|
+
const parsed = Number(requiredOption(value, name));
|
|
852
|
+
if (!Number.isFinite(parsed)) {
|
|
853
|
+
throw new Error(`${name} must be a number.`);
|
|
854
|
+
}
|
|
855
|
+
return parsed;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function arrayOption(value) {
|
|
859
|
+
if (value === undefined || value === null || value === "") {
|
|
860
|
+
return [];
|
|
861
|
+
}
|
|
862
|
+
return Array.isArray(value) ? value : [value];
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async function readJsonOption(value) {
|
|
866
|
+
if (value === undefined) {
|
|
867
|
+
return undefined;
|
|
868
|
+
}
|
|
869
|
+
const raw = requiredOption(value, "--data");
|
|
870
|
+
const text = raw.startsWith("@") ? await readFile(raw.slice(1), "utf8") : raw;
|
|
871
|
+
return JSON.parse(text);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function buildQuery(values) {
|
|
875
|
+
const params = new URLSearchParams();
|
|
876
|
+
for (const [key, value] of Object.entries(values)) {
|
|
877
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
878
|
+
params.set(key, String(value));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
const text = params.toString();
|
|
882
|
+
return text ? `?${text}` : "";
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function writeOutput(stdout, payload, { json = true, raw = false } = {}) {
|
|
886
|
+
if (raw) {
|
|
887
|
+
stdout.write(String(payload));
|
|
888
|
+
if (!String(payload).endsWith("\n")) {
|
|
889
|
+
stdout.write("\n");
|
|
890
|
+
}
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
if (json || typeof payload === "object") {
|
|
894
|
+
writeJson(stdout, payload);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
stdout.write(`${payload}\n`);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function writeJson(stdout, payload) {
|
|
901
|
+
stdout.write(`${JSON.stringify(payload ?? { ok: true }, null, 2)}\n`);
|
|
902
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_AUTH_BASE_URL = "http://localhost:3000";
|
|
4
|
+
const DEFAULT_POLL_INTERVAL_SECONDS = 2;
|
|
5
|
+
const DEFAULT_TIMEOUT_SECONDS = 600;
|
|
6
|
+
|
|
7
|
+
export class DeviceAuthError extends Error {
|
|
8
|
+
constructor(message, { status, detail } = {}) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "DeviceAuthError";
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.detail = detail;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resolveAuthBaseUrl({ explicit, env = process.env } = {}) {
|
|
17
|
+
const value =
|
|
18
|
+
explicit?.trim() ||
|
|
19
|
+
env.ORNN_AUTH_BASE_URL?.trim() ||
|
|
20
|
+
env.BETTER_AUTH_URL?.trim() ||
|
|
21
|
+
DEFAULT_AUTH_BASE_URL;
|
|
22
|
+
|
|
23
|
+
return value.replace(/\/+$/, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function openBrowser(url, { platform = process.platform, spawnImpl = spawn } = {}) {
|
|
27
|
+
const command =
|
|
28
|
+
platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
29
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const child = spawnImpl(command, args, {
|
|
33
|
+
detached: true,
|
|
34
|
+
stdio: "ignore",
|
|
35
|
+
});
|
|
36
|
+
child.unref?.();
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function startDeviceFlow({ authBaseUrl, fetchImpl = fetch } = {}) {
|
|
44
|
+
const data = await postJson(fetchImpl, `${authBaseUrl}/api/cli/auth/device/start`, {
|
|
45
|
+
client: "ornn-cli",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const deviceCode = pickString(data, "deviceCode", "device_code");
|
|
49
|
+
const userCode = pickString(data, "userCode", "user_code");
|
|
50
|
+
const verificationUri =
|
|
51
|
+
pickString(data, "verificationUriComplete", "verification_uri_complete") ||
|
|
52
|
+
pickString(data, "verificationUri", "verification_uri");
|
|
53
|
+
|
|
54
|
+
if (!deviceCode || !userCode || !verificationUri) {
|
|
55
|
+
throw new DeviceAuthError("Auth server returned an invalid device login response.", {
|
|
56
|
+
detail: data,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
deviceCode,
|
|
62
|
+
expiresIn: pickNumber(data, "expiresIn", "expires_in") ?? DEFAULT_TIMEOUT_SECONDS,
|
|
63
|
+
interval: pickNumber(data, "interval") ?? DEFAULT_POLL_INTERVAL_SECONDS,
|
|
64
|
+
userCode,
|
|
65
|
+
verificationUri,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function pollDeviceFlow({ authBaseUrl, deviceCode, fetchImpl = fetch } = {}) {
|
|
70
|
+
return postJson(fetchImpl, `${authBaseUrl}/api/cli/auth/device/poll`, {
|
|
71
|
+
deviceCode,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function waitForDeviceApproval({
|
|
76
|
+
authBaseUrl,
|
|
77
|
+
deviceCode,
|
|
78
|
+
expiresIn,
|
|
79
|
+
fetchImpl = fetch,
|
|
80
|
+
interval,
|
|
81
|
+
onPending,
|
|
82
|
+
timeoutSeconds,
|
|
83
|
+
} = {}) {
|
|
84
|
+
const pollIntervalMs = Math.max(1, Number(interval) || DEFAULT_POLL_INTERVAL_SECONDS) * 1000;
|
|
85
|
+
const timeoutMs =
|
|
86
|
+
Math.max(1, Number(timeoutSeconds) || Number(expiresIn) || DEFAULT_TIMEOUT_SECONDS) * 1000;
|
|
87
|
+
const deadline = Date.now() + timeoutMs;
|
|
88
|
+
|
|
89
|
+
while (Date.now() < deadline) {
|
|
90
|
+
await sleep(pollIntervalMs);
|
|
91
|
+
const data = await pollDeviceFlow({ authBaseUrl, deviceCode, fetchImpl });
|
|
92
|
+
const status = pickString(data, "status") || "pending";
|
|
93
|
+
|
|
94
|
+
if (status === "pending") {
|
|
95
|
+
onPending?.();
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (status === "approved") {
|
|
100
|
+
const token = pickString(data, "accessToken", "access_token", "sessionToken", "session_token");
|
|
101
|
+
const user = data.user && typeof data.user === "object" ? data.user : null;
|
|
102
|
+
|
|
103
|
+
if (!token || !user?.email) {
|
|
104
|
+
throw new DeviceAuthError("Auth server approved login without a usable session.");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
accessToken: token,
|
|
109
|
+
authBaseUrl,
|
|
110
|
+
tokenType: pickString(data, "tokenType", "token_type") || "bearer",
|
|
111
|
+
user: {
|
|
112
|
+
email: String(user.email),
|
|
113
|
+
id: user.id ? String(user.id) : null,
|
|
114
|
+
name: user.name ? String(user.name) : null,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (status === "denied") {
|
|
120
|
+
throw new DeviceAuthError("Device login was denied in the browser.");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (status === "expired") {
|
|
124
|
+
throw new DeviceAuthError("Device login expired. Run `ornn login` again.");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
throw new DeviceAuthError(`Auth server returned unexpected status: ${status}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
throw new DeviceAuthError("Timed out waiting for browser login approval.");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function postJson(fetchImpl, url, body) {
|
|
134
|
+
const response = await fetchImpl(url, {
|
|
135
|
+
body: JSON.stringify(body),
|
|
136
|
+
headers: {
|
|
137
|
+
Accept: "application/json",
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
},
|
|
140
|
+
method: "POST",
|
|
141
|
+
});
|
|
142
|
+
const text = await response.text();
|
|
143
|
+
const data = text ? parseJson(text, url) : {};
|
|
144
|
+
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
const detail = data.detail || data.error || response.statusText;
|
|
147
|
+
throw new DeviceAuthError(`Auth request failed: ${detail}`, {
|
|
148
|
+
detail: data,
|
|
149
|
+
status: response.status,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return data;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseJson(text, url) {
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(text);
|
|
159
|
+
} catch {
|
|
160
|
+
throw new DeviceAuthError(`Auth server returned invalid JSON from ${url}.`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function pickString(value, ...keys) {
|
|
165
|
+
for (const key of keys) {
|
|
166
|
+
const found = value?.[key];
|
|
167
|
+
if (typeof found === "string" && found.trim()) {
|
|
168
|
+
return found.trim();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function pickNumber(value, ...keys) {
|
|
175
|
+
for (const key of keys) {
|
|
176
|
+
const found = value?.[key];
|
|
177
|
+
if (typeof found === "number" && Number.isFinite(found)) {
|
|
178
|
+
return found;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function sleep(ms) {
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
setTimeout(resolve, ms);
|
|
187
|
+
});
|
|
188
|
+
}
|