@b3labs/b3iq-cli 0.0.0-stage → 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/AGENTS.md +319 -0
- package/ERROR_PATHS.md +63 -0
- package/LICENSE +202 -0
- package/README.md +207 -2
- package/dist/commands/claude-desktop.js +323 -0
- package/dist/commands/codex-desktop.js +118 -0
- package/dist/commands/commands.js +39 -0
- package/dist/commands/logs.js +41 -0
- package/dist/commands/machines.js +104 -0
- package/dist/commands/media-mcp.js +19 -0
- package/dist/commands/misc.js +257 -0
- package/dist/commands/models.js +141 -0
- package/dist/commands/native-agents.js +291 -0
- package/dist/commands/opencode.js +161 -0
- package/dist/commands/plugins.js +67 -0
- package/dist/commands/runtimes.js +76 -0
- package/dist/commands/ssh.js +156 -0
- package/dist/index.js +194 -0
- package/dist/lib/api.js +149 -0
- package/dist/lib/args.js +35 -0
- package/dist/lib/commands-wait.js +41 -0
- package/dist/lib/config.js +85 -0
- package/dist/lib/desktop-server-lifecycle.js +19 -0
- package/dist/lib/diagnosis.js +678 -0
- package/dist/lib/inference.js +272 -0
- package/dist/lib/machines.js +120 -0
- package/dist/lib/management-probe.js +36 -0
- package/dist/lib/media-download.js +50 -0
- package/dist/lib/output.js +151 -0
- package/dist/lib/ssh-session.js +280 -0
- package/dist/lib/ssh-target.js +28 -0
- package/dist/lib/stdin.js +16 -0
- package/dist/lib/version.js +1 -0
- package/package.json +58 -4
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { resolveMachineOrRental, pickMachineOrRental, machineLabel } from "../lib/machines.js";
|
|
4
|
+
import { explainCloudflaredMissing, explainSshLane, explainNoDirectAddress, explainDirectSshNotEnabled } from "../lib/diagnosis.js";
|
|
5
|
+
import { listedOrgLabel, resolveSettings } from "../lib/config.js";
|
|
6
|
+
import { CliError, log, warn, isJSON, emit } from "../lib/output.js";
|
|
7
|
+
import { SSH_USER, bastionRoute, cloudflaredRoute, directRoute, firstReachable, hasRemoteCommand, probeSsh, runSession, runSsh, wantsOwnSshLogging, } from "../lib/ssh-session.js";
|
|
8
|
+
import { assertSshHost, assertSshPort, assertSshUser } from "../lib/ssh-target.js";
|
|
9
|
+
export const TESTING_DIRECT_ADDR_ENV = "B3IQ_SSH_DIRECT_ADDR";
|
|
10
|
+
export const TESTING_FORCE_FLAG_ENV = "B3IQ_SSH_DIRECT_FORCE";
|
|
11
|
+
export function parseDirectAddrOverride(raw) {
|
|
12
|
+
const addresses = [];
|
|
13
|
+
let port = null;
|
|
14
|
+
for (const item of raw.split(",").map((part) => part.trim()).filter(Boolean)) {
|
|
15
|
+
const bracketed = /^\[([0-9A-Fa-f:.]+)\](?::(\d+))?$/.exec(item);
|
|
16
|
+
const hostPort = /^([^:\s]+):(\d+)$/.exec(item);
|
|
17
|
+
const [address, portText] = bracketed ? [bracketed[1], bracketed[2]] : hostPort ? [hostPort[1], hostPort[2]] : [item, undefined];
|
|
18
|
+
if (!address || /\s/.test(address))
|
|
19
|
+
throw new CliError(`Not an address: "${item}". Use ip, ip:port or [ipv6]:port.`, "usage", 1);
|
|
20
|
+
if (portText !== undefined) {
|
|
21
|
+
const value = Number(portText);
|
|
22
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535)
|
|
23
|
+
throw new CliError(`Not a port: "${portText}".`, "usage", 1);
|
|
24
|
+
port ??= value;
|
|
25
|
+
}
|
|
26
|
+
addresses.push(address);
|
|
27
|
+
}
|
|
28
|
+
if (addresses.length === 0)
|
|
29
|
+
throw new CliError("--direct-addr / B3IQ_SSH_DIRECT_ADDR names no address.", "usage", 1);
|
|
30
|
+
return { addresses, port: port ?? 22 };
|
|
31
|
+
}
|
|
32
|
+
function guestTarget(label, rental) {
|
|
33
|
+
const guest = rental.guest;
|
|
34
|
+
if (!guest || guest.state !== "ready" || !guest.ssh_hostname || !guest.ssh_user) {
|
|
35
|
+
const state = guest ? ` (its virtual machine is ${guest.state})` : "";
|
|
36
|
+
throw new CliError(`Your rental of ${label} is not ready yet${state}. Try again once it is: \`b3iq machines guests ${rental.node_id}\` shows its state.`, "usage", 1);
|
|
37
|
+
}
|
|
38
|
+
return { host: assertSshHost(guest.ssh_hostname, "guest hostname"), user: assertSshUser(guest.ssh_user, "guest user"), bastion: null, direct: null, guestId: guest.guest_id };
|
|
39
|
+
}
|
|
40
|
+
async function chooseTarget(selector, interactive) {
|
|
41
|
+
const nothingNamed = !selector?.trim() && !resolveSettings().defaultMachine?.trim();
|
|
42
|
+
if (nothingNamed && interactive)
|
|
43
|
+
return pickMachineOrRental();
|
|
44
|
+
return resolveMachineOrRental(selector);
|
|
45
|
+
}
|
|
46
|
+
async function sshTarget(chosen) {
|
|
47
|
+
const node = chosen.node;
|
|
48
|
+
const label = machineLabel(node);
|
|
49
|
+
if (chosen.kind === "rental")
|
|
50
|
+
return { label, nodeId: node.node_id, orgId: chosen.orgId, ...guestTarget(label, chosen.rental) };
|
|
51
|
+
const detail = await api.node(node.node_id);
|
|
52
|
+
const ssh_lane = detail.ssh_lane;
|
|
53
|
+
if (!ssh_lane?.enabled || !ssh_lane.hostname) {
|
|
54
|
+
throw explainSshLane(node.node_id, Boolean(ssh_lane?.enabled), Boolean(ssh_lane?.hostname));
|
|
55
|
+
}
|
|
56
|
+
const bastion = detail.ssh_bastion
|
|
57
|
+
? { host: assertSshHost(detail.ssh_bastion.host, "bastion hostname"), user: assertSshUser(detail.ssh_bastion.user, "bastion user") }
|
|
58
|
+
: null;
|
|
59
|
+
const reported = detail.ssh_direct;
|
|
60
|
+
const direct = reported && reported.addresses.length > 0
|
|
61
|
+
? { addresses: reported.addresses.map((address) => assertSshHost(address, "direct address")), port: assertSshPort(reported.port, "direct port") }
|
|
62
|
+
: null;
|
|
63
|
+
return { label, nodeId: node.node_id, orgId: chosen.orgId, guestId: null, host: assertSshHost(ssh_lane.hostname), user: bastion ? bastion.user : SSH_USER, bastion, direct };
|
|
64
|
+
}
|
|
65
|
+
const tunnelRoute = (to, passthrough, keepalive) => to.bastion ? bastionRoute(to.bastion, to.host, passthrough, keepalive) : cloudflaredRoute(to.host, passthrough, to.user, keepalive);
|
|
66
|
+
export const DIRECT_SSH_FEATURE = "cli_direct_ssh";
|
|
67
|
+
async function directSshEnabled() {
|
|
68
|
+
try {
|
|
69
|
+
const { features } = await api.features(AbortSignal.timeout(4_000));
|
|
70
|
+
return Array.isArray(features) && features.includes(DIRECT_SSH_FEATURE);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const cloudflaredInstalled = () => spawnSync("cloudflared", ["--version"], { stdio: "ignore" }).status === 0;
|
|
77
|
+
export async function sshMachine(selector, passthrough, options = { transport: "auto", reconnect: true }) {
|
|
78
|
+
const cannotTakeOverTheTerminal = isJSON() || !process.stdout.isTTY;
|
|
79
|
+
const forced = process.env[TESTING_FORCE_FLAG_ENV] === "1";
|
|
80
|
+
const flagRead = forced ? Promise.resolve(true) : directSshEnabled();
|
|
81
|
+
const to = await sshTarget(await chooseTarget(selector, !cannotTakeOverTheTerminal && Boolean(process.stdin.isTTY)));
|
|
82
|
+
const overrideAddr = options.directAddr ?? (process.env[TESTING_DIRECT_ADDR_ENV]?.trim() || undefined);
|
|
83
|
+
if (overrideAddr && to.guestId)
|
|
84
|
+
warn("TESTING OVERRIDE ignored: a rented guest is only reachable over its host's Cloudflare Tunnel.");
|
|
85
|
+
if (overrideAddr && !to.guestId) {
|
|
86
|
+
to.direct = parseDirectAddrOverride(overrideAddr);
|
|
87
|
+
warn(`TESTING OVERRIDE: trying ${to.direct.addresses.join(", ")} port ${to.direct.port} instead of the machine's reported ssh_direct.`);
|
|
88
|
+
}
|
|
89
|
+
if (forced)
|
|
90
|
+
warn(`TESTING OVERRIDE: ${TESTING_FORCE_FLAG_ENV}=1 treats ${DIRECT_SSH_FEATURE} as on without asking the API.`);
|
|
91
|
+
const flagged = await flagRead;
|
|
92
|
+
if (overrideAddr && !to.guestId && !flagged)
|
|
93
|
+
warn(`TESTING OVERRIDE ignored: ${DIRECT_SSH_FEATURE} is off for this account. Set ${TESTING_FORCE_FLAG_ENV}=1 to force it on for a test.`);
|
|
94
|
+
if (options.transport === "direct" && !flagged)
|
|
95
|
+
throw explainDirectSshNotEnabled(DIRECT_SSH_FEATURE);
|
|
96
|
+
if (options.transport === "direct" && !to.direct)
|
|
97
|
+
throw explainNoDirectAddress(to.nodeId, to.guestId !== null);
|
|
98
|
+
const directUser = SSH_USER;
|
|
99
|
+
const wantDirect = flagged && options.transport !== "tunnel" ? to.direct : null;
|
|
100
|
+
const tunnel = options.transport === "direct" ? null : tunnelRoute(to, passthrough, flagged);
|
|
101
|
+
const runOnce = wantsOwnSshLogging(passthrough);
|
|
102
|
+
if (cannotTakeOverTheTerminal) {
|
|
103
|
+
const address = wantDirect && (tunnel === null || !runOnce) ? await firstReachable(wantDirect) : null;
|
|
104
|
+
const primary = address ? directRoute(address, wantDirect.port, to.host, directUser, passthrough) : tunnel;
|
|
105
|
+
if (!primary)
|
|
106
|
+
throw explainNoDirectAddress(to.nodeId, false, wantDirect?.addresses.length ?? 0);
|
|
107
|
+
const fallback = primary === tunnel || runOnce ? null : tunnel;
|
|
108
|
+
emit({
|
|
109
|
+
machine: to.label,
|
|
110
|
+
node_id: to.nodeId,
|
|
111
|
+
organization_id: to.orgId,
|
|
112
|
+
...(to.guestId ? { guest_id: to.guestId } : {}),
|
|
113
|
+
hostname: primary.host,
|
|
114
|
+
user: primary.user,
|
|
115
|
+
transport: primary.transport,
|
|
116
|
+
ssh_args: primary.args,
|
|
117
|
+
fallback: fallback ? { hostname: fallback.host, user: fallback.user, transport: fallback.transport, ssh_args: fallback.args } : null,
|
|
118
|
+
}, () => {
|
|
119
|
+
process.stdout.write(`ssh ${primary.args.map(shellQuote).join(" ")}\n`);
|
|
120
|
+
if (fallback)
|
|
121
|
+
process.stdout.write(`# only if that exits 255 before logging in: ssh ${fallback.args.map(shellQuote).join(" ")}\n`);
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
let fallback = tunnel;
|
|
126
|
+
if (fallback?.transport === "cloudflared" && !cloudflaredInstalled()) {
|
|
127
|
+
if (!wantDirect)
|
|
128
|
+
throw explainCloudflaredMissing();
|
|
129
|
+
warn("cloudflared is not installed, so there is no Cloudflare Tunnel fallback if the direct path fails.");
|
|
130
|
+
fallback = null;
|
|
131
|
+
}
|
|
132
|
+
const logged = flagged && !runOnce;
|
|
133
|
+
const inOrg = to.orgId ? ` in organization ${listedOrgLabel(to.orgId)}` : "";
|
|
134
|
+
const plan = {
|
|
135
|
+
direct: wantDirect && (!runOnce || fallback === null) ? { endpoint: wantDirect, route: (address) => directRoute(address, wantDirect.port, to.host, directUser, passthrough) } : null,
|
|
136
|
+
fallback,
|
|
137
|
+
reconnect: flagged && options.reconnect && !hasRemoteCommand(passthrough),
|
|
138
|
+
target: `${to.label} (${to.host})${inOrg}`,
|
|
139
|
+
};
|
|
140
|
+
const tunnelName = fallback?.transport === "bastion" ? "bastion" : "Cloudflare Tunnel";
|
|
141
|
+
if (flagged && runOnce)
|
|
142
|
+
log(`Paths: ${plan.direct ? "direct" : tunnelName} only, once: ssh's own -v/-q/-y/-E/-S rule out a fallback.`);
|
|
143
|
+
else if (flagged)
|
|
144
|
+
log(`Paths: ${plan.direct ? `${plan.direct.endpoint.addresses.length} direct address${plan.direct.endpoint.addresses.length === 1 ? "" : "es"}` : "no direct address"}${fallback ? `, ${tunnelName} fallback` : ""}.`);
|
|
145
|
+
const code = await runSession(plan, {
|
|
146
|
+
probe: probeSsh,
|
|
147
|
+
run: runSsh,
|
|
148
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
149
|
+
now: () => Date.now(),
|
|
150
|
+
log,
|
|
151
|
+
warn,
|
|
152
|
+
die: (signal) => process.kill(process.pid, signal),
|
|
153
|
+
}, logged);
|
|
154
|
+
process.exit(code);
|
|
155
|
+
}
|
|
156
|
+
const shellQuote = (arg) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`);
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs, boolFlag, strFlag } from "./lib/args.js";
|
|
3
|
+
import { configureOutput, fail, CliError } from "./lib/output.js";
|
|
4
|
+
import { machinesList, machinesGet, machinesGuests, machinesQualify } from "./commands/machines.js";
|
|
5
|
+
import { pluginsList, pluginsInstall, pluginsRemove } from "./commands/plugins.js";
|
|
6
|
+
import { commandsList, commandsGet } from "./commands/commands.js";
|
|
7
|
+
import { modelsList, modelsInstall, modelsRemove, modelsContext } from "./commands/models.js";
|
|
8
|
+
import { runtimesList, runtimesRestart, runtimesCheck, runtimesUpgrade, runtimesPolicy } from "./commands/runtimes.js";
|
|
9
|
+
import { logsCmd } from "./commands/logs.js";
|
|
10
|
+
import { sshMachine } from "./commands/ssh.js";
|
|
11
|
+
import { login, logout, whoami, status, configCmd, doctor, help } from "./commands/misc.js";
|
|
12
|
+
import { InferenceGateway, gatewayKey, savedGatewayKey, saveGatewayKey, clearGatewayKey } from "./lib/inference.js";
|
|
13
|
+
import { prepareNativeAgent, launchNativeAgent, printNativeSetupResult, installCodexProfile } from "./commands/native-agents.js";
|
|
14
|
+
import { installOpenCodeConfig, detectOpenCodeVersion } from "./commands/opencode.js";
|
|
15
|
+
import { saveClaudeDesktopConnection, activateClaudeDesktopConnection, restoreClaudeDesktopConnection } from "./commands/claude-desktop.js";
|
|
16
|
+
import { findCodexDesktop, saveCodexDesktop, installCodexDesktopLauncher, launchCodexDesktop } from "./commands/codex-desktop.js";
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import { VERSION } from "./lib/version.js";
|
|
19
|
+
import { readSecretFromStdin } from "./lib/stdin.js";
|
|
20
|
+
async function main() {
|
|
21
|
+
const raw = process.argv.slice(2);
|
|
22
|
+
const passthroughSeparator = raw.indexOf("--");
|
|
23
|
+
const ownArgs = passthroughSeparator >= 0 ? raw.slice(0, passthroughSeparator) : raw;
|
|
24
|
+
const passthrough = passthroughSeparator >= 0 ? raw.slice(passthroughSeparator + 1) : [];
|
|
25
|
+
const { positionals, flags } = parseArgs(ownArgs, new Set(["machine", "token", "limit", "model", "gateway-url", "output-dir", "client-version", "tokens", "desktop-app", "setup-name", "target-version", "mode", "hour-utc", "direct-addr"]));
|
|
26
|
+
configureOutput({ json: boolFlag(flags, "json"), pretty: boolFlag(flags, "pretty"), quiet: boolFlag(flags, "quiet") });
|
|
27
|
+
if (boolFlag(flags, "version"))
|
|
28
|
+
return void process.stdout.write(`${VERSION}\n`);
|
|
29
|
+
const [cmd, sub, ...rest] = positionals;
|
|
30
|
+
if (!cmd || boolFlag(flags, "help"))
|
|
31
|
+
return help();
|
|
32
|
+
switch (cmd) {
|
|
33
|
+
case "mcp": {
|
|
34
|
+
if (sub !== "media")
|
|
35
|
+
throw new CliError("Usage: b3iq mcp media", "usage");
|
|
36
|
+
const { runMediaMcp } = await import("./commands/media-mcp.js");
|
|
37
|
+
return runMediaMcp({ ...(boolFlag(flags, "saved-key") ? { key: await savedGatewayKey() } : {}), gateway: strFlag(flags, "gateway-url"), machine: strFlag(flags, "machine"), outputDir: strFlag(flags, "output-dir") });
|
|
38
|
+
}
|
|
39
|
+
case "inference": {
|
|
40
|
+
const options = { gateway: strFlag(flags, "gateway-url"), machine: strFlag(flags, "machine"), outputDir: strFlag(flags, "output-dir") };
|
|
41
|
+
if (sub === "models")
|
|
42
|
+
return void process.stdout.write(JSON.stringify(await new InferenceGateway(options).models()) + "\n");
|
|
43
|
+
if (sub === "credential") {
|
|
44
|
+
if (process.stdout.isTTY)
|
|
45
|
+
throw new CliError("Credential output is only for a native client's private helper pipe.", "usage");
|
|
46
|
+
return void process.stdout.write(await (boolFlag(flags, "saved") ? savedGatewayKey() : gatewayKey()));
|
|
47
|
+
}
|
|
48
|
+
if (sub === "key-save") {
|
|
49
|
+
await saveGatewayKey(await readSecretFromStdin("dashboard API key"));
|
|
50
|
+
return void process.stderr.write("Dashboard API key saved privately.\n");
|
|
51
|
+
}
|
|
52
|
+
if (sub === "key-clear")
|
|
53
|
+
return clearGatewayKey();
|
|
54
|
+
if (sub === "restore") {
|
|
55
|
+
if (rest[0] !== "claude" || rest.length !== 1 || !boolFlag(flags, "desktop") || Object.keys(flags).some(key => !["desktop", "json", "pretty", "quiet"].includes(key)))
|
|
56
|
+
throw new CliError("Usage: b3iq inference restore claude --desktop (with Claude closed).", "usage");
|
|
57
|
+
return void process.stdout.write(JSON.stringify(await restoreClaudeDesktopConnection()) + "\n");
|
|
58
|
+
}
|
|
59
|
+
const client = sub === "setup" ? rest[0] : sub;
|
|
60
|
+
if (client !== "codex" && client !== "claude" && client !== "opencode")
|
|
61
|
+
throw new CliError("Usage: b3iq inference models|setup <codex|claude|opencode>|codex|claude|opencode --model <served-id>", "usage");
|
|
62
|
+
const desktop = boolFlag(flags, "desktop"), install = boolFlag(flags, "install"), requestedVersion = strFlag(flags, "client-version");
|
|
63
|
+
if (flags["client-version"] === true)
|
|
64
|
+
throw new CliError("Provide --client-version 1 or 2.", "usage");
|
|
65
|
+
const activate = boolFlag(flags, "activate");
|
|
66
|
+
const setupName = strFlag(flags, "setup-name");
|
|
67
|
+
if ("setup-name" in flags && (client !== "codex" || sub !== "setup" || !desktop || !setupName || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(setupName)))
|
|
68
|
+
throw new CliError("--setup-name requires Codex desktop setup and 1–64 lowercase letters, digits, underscores or hyphens, starting with a letter or digit.", "usage");
|
|
69
|
+
if ("activate" in flags && ((client !== "claude" && client !== "codex") || sub !== "setup" || !desktop || !install))
|
|
70
|
+
throw new CliError("Explicit activation requires b3iq inference setup claude|codex --desktop --install --activate --model <id>.", "usage");
|
|
71
|
+
if ("desktop-app" in flags && (client !== "codex" || !desktop || !strFlag(flags, "desktop-app")))
|
|
72
|
+
throw new CliError("--desktop-app requires Codex desktop setup and an application path.", "usage");
|
|
73
|
+
if ("client-version" in flags && client !== "opencode")
|
|
74
|
+
throw new CliError("--client-version applies to OpenCode only.", "usage");
|
|
75
|
+
if (desktop && sub !== "setup" || install && (!desktop || sub !== "setup"))
|
|
76
|
+
throw new CliError("Desktop saving uses: b3iq inference setup codex|claude|opencode --desktop --install --model <id>.", "usage");
|
|
77
|
+
if (requestedVersion !== undefined && requestedVersion !== "1" && requestedVersion !== "2")
|
|
78
|
+
throw new CliError("OpenCode --client-version must be 1 or 2.", "usage");
|
|
79
|
+
const model = strFlag(flags, "model");
|
|
80
|
+
if (!model)
|
|
81
|
+
throw new CliError("Provide --model with an exact served model ID.", "usage");
|
|
82
|
+
const version = requestedVersion ? (requestedVersion === "1" ? 1 : 2)
|
|
83
|
+
: client === "opencode" && sub !== "setup" ? await detectOpenCodeVersion() : 2;
|
|
84
|
+
const codexApp = client === "codex" && desktop ? await findCodexDesktop(strFlag(flags, "desktop-app")) : undefined;
|
|
85
|
+
const prepared = await prepareNativeAgent(client, model, options, undefined, { version, desktop, setupName });
|
|
86
|
+
if (codexApp) {
|
|
87
|
+
prepared.codexDesktop = await saveCodexDesktop(prepared.config, prepared.dir, codexApp);
|
|
88
|
+
prepared.installedConfig = `${prepared.codexDesktop.engine_home}/config.toml`;
|
|
89
|
+
if (install)
|
|
90
|
+
await installCodexDesktopLauncher(prepared.codexDesktop);
|
|
91
|
+
if (activate)
|
|
92
|
+
await launchCodexDesktop(prepared.codexDesktop);
|
|
93
|
+
}
|
|
94
|
+
if (install && client === "claude") {
|
|
95
|
+
const saved = await saveClaudeDesktopConnection(prepared.claudeDesktop);
|
|
96
|
+
prepared.claudeDesktop.saved = saved;
|
|
97
|
+
prepared.installedConfig = saved.path;
|
|
98
|
+
prepared.backup = saved.backup;
|
|
99
|
+
if (activate)
|
|
100
|
+
await activateClaudeDesktopConnection(prepared.claudeDesktop);
|
|
101
|
+
}
|
|
102
|
+
if (install && client === "opencode") {
|
|
103
|
+
const installed = await installOpenCodeConfig(JSON.parse(await readFile(prepared.config, "utf8")), prepared.openCodeVersion, prepared.env.B3IQ_GATEWAY_API_KEY);
|
|
104
|
+
prepared.installedConfig = installed.config;
|
|
105
|
+
prepared.backup = installed.backup;
|
|
106
|
+
}
|
|
107
|
+
if (sub === "setup") {
|
|
108
|
+
if (client === "codex" && !desktop)
|
|
109
|
+
await installCodexProfile(prepared);
|
|
110
|
+
return printNativeSetupResult(client, prepared, activate);
|
|
111
|
+
}
|
|
112
|
+
return launchNativeAgent(client, prepared, passthrough);
|
|
113
|
+
}
|
|
114
|
+
case "login": {
|
|
115
|
+
if ("token" in flags || sub) {
|
|
116
|
+
throw new CliError("An API key on the command line lands in shell history. Pipe it instead: `b3iq login --token-stdin < key.txt`, or set B3IQ_TOKEN.", "usage");
|
|
117
|
+
}
|
|
118
|
+
return login(boolFlag(flags, "token-stdin") ? await readSecretFromStdin("API key") : undefined);
|
|
119
|
+
}
|
|
120
|
+
case "logout":
|
|
121
|
+
return logout(boolFlag(flags, "revoke"));
|
|
122
|
+
case "whoami":
|
|
123
|
+
return whoami();
|
|
124
|
+
case "status":
|
|
125
|
+
return status();
|
|
126
|
+
case "doctor":
|
|
127
|
+
return doctor();
|
|
128
|
+
case "config":
|
|
129
|
+
return configCmd([sub, ...rest].filter((v) => typeof v === "string"));
|
|
130
|
+
case "machines":
|
|
131
|
+
if (sub === "list" || sub === undefined)
|
|
132
|
+
return machinesList();
|
|
133
|
+
if (sub === "get")
|
|
134
|
+
return machinesGet(rest[0]);
|
|
135
|
+
if (sub === "qualify")
|
|
136
|
+
return machinesQualify(rest[0], !boolFlag(flags, "no-wait"));
|
|
137
|
+
if (sub === "guests")
|
|
138
|
+
return machinesGuests(rest[0]);
|
|
139
|
+
throw new CliError(`Unknown: machines ${sub}. Try: list, get <machine>, qualify <machine>, guests <machine>.`, "usage", 1);
|
|
140
|
+
case "plugins":
|
|
141
|
+
if (sub === "list" || sub === undefined)
|
|
142
|
+
return pluginsList(strFlag(flags, "machine"));
|
|
143
|
+
if (sub === "install")
|
|
144
|
+
return pluginsInstall(rest[0], strFlag(flags, "machine"), !boolFlag(flags, "no-wait"));
|
|
145
|
+
if (sub === "remove" || sub === "uninstall")
|
|
146
|
+
return pluginsRemove(rest[0], strFlag(flags, "machine"), !boolFlag(flags, "no-wait"));
|
|
147
|
+
throw new CliError(`Unknown: plugins ${sub}. Try: list, install <plugin>, remove <plugin>.`, "usage", 1);
|
|
148
|
+
case "commands":
|
|
149
|
+
if (sub === "list" || sub === undefined)
|
|
150
|
+
return commandsList(strFlag(flags, "machine"), strFlag(flags, "limit"));
|
|
151
|
+
if (sub === "get")
|
|
152
|
+
return commandsGet(rest[0], strFlag(flags, "machine"));
|
|
153
|
+
throw new CliError(`Unknown: commands ${sub}. Try: list, get <command-id>.`, "usage", 1);
|
|
154
|
+
case "models":
|
|
155
|
+
if (sub === "list" || sub === undefined)
|
|
156
|
+
return modelsList(strFlag(flags, "machine"));
|
|
157
|
+
if (sub === "install")
|
|
158
|
+
return modelsInstall(rest[0], strFlag(flags, "machine"), !boolFlag(flags, "no-wait"));
|
|
159
|
+
if (sub === "context") {
|
|
160
|
+
if (rest.length !== 1 || flags.machine === true || (flags.reset !== undefined && flags.reset !== true) || (flags["no-wait"] !== undefined && flags["no-wait"] !== true)) {
|
|
161
|
+
throw new CliError("Usage: b3iq models context <model> --machine <m> [--tokens <n>|--reset] [--no-wait]. Boolean flags take no value.", "usage", 1);
|
|
162
|
+
}
|
|
163
|
+
return modelsContext(rest[0], strFlag(flags, "machine"), flags.tokens, boolFlag(flags, "reset"), !boolFlag(flags, "no-wait"));
|
|
164
|
+
}
|
|
165
|
+
if (sub === "remove" || sub === "uninstall")
|
|
166
|
+
return modelsRemove(rest[0], strFlag(flags, "machine"), !boolFlag(flags, "no-wait"));
|
|
167
|
+
throw new CliError(`Unknown: models ${sub}. Try: list, install <model>, context <model>, remove <model>.`, "usage", 1);
|
|
168
|
+
case "runtimes":
|
|
169
|
+
if (sub === "check")
|
|
170
|
+
return runtimesCheck(strFlag(flags, "machine"));
|
|
171
|
+
if (sub === "upgrade")
|
|
172
|
+
return runtimesUpgrade(rest[0], strFlag(flags, "machine"), strFlag(flags, "target-version"), !boolFlag(flags, "no-wait"));
|
|
173
|
+
if (sub === "policy")
|
|
174
|
+
return runtimesPolicy(rest[0], strFlag(flags, "machine"), strFlag(flags, "mode"), strFlag(flags, "hour-utc"));
|
|
175
|
+
if (sub === "list" || sub === undefined)
|
|
176
|
+
return runtimesList(strFlag(flags, "machine"));
|
|
177
|
+
if (sub === "restart")
|
|
178
|
+
return runtimesRestart(rest[0], strFlag(flags, "machine"), !boolFlag(flags, "no-wait"));
|
|
179
|
+
throw new CliError(`Unknown: runtimes ${sub}. Try: list, check, restart <runtime>, upgrade <runtime> --target-version <v>, policy <runtime> --mode manual|scheduled.`, "usage", 1);
|
|
180
|
+
case "logs":
|
|
181
|
+
return logsCmd(strFlag(flags, "limit"), boolFlag(flags, "follow"));
|
|
182
|
+
case "ssh": {
|
|
183
|
+
if (boolFlag(flags, "direct") && boolFlag(flags, "tunnel"))
|
|
184
|
+
throw new CliError("--direct and --tunnel cannot be used together.", "usage", 1);
|
|
185
|
+
const transport = boolFlag(flags, "direct") ? "direct" : boolFlag(flags, "tunnel") ? "tunnel" : "auto";
|
|
186
|
+
if (flags["direct-addr"] === true)
|
|
187
|
+
throw new CliError("--direct-addr takes an address: --direct-addr <ip[:port]>.", "usage", 1);
|
|
188
|
+
return sshMachine(sub, passthrough, { transport, reconnect: !boolFlag(flags, "no-reconnect"), directAddr: strFlag(flags, "direct-addr") });
|
|
189
|
+
}
|
|
190
|
+
default:
|
|
191
|
+
throw new CliError(`Unknown command "${cmd}". Run \`b3iq --help\`.`, "usage", 1);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
main().catch(fail);
|
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { explainApiStatus, explainAuthFailure, explainNetworkFailure, explainNotLoggedIn } from "./diagnosis.js";
|
|
2
|
+
import { CliError, warn } from "./output.js";
|
|
3
|
+
import { isLoopbackHost, resolveSettings } from "./config.js";
|
|
4
|
+
export function assertApiUrl(apiUrl) {
|
|
5
|
+
let url;
|
|
6
|
+
try {
|
|
7
|
+
url = new URL(apiUrl);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
url = undefined;
|
|
11
|
+
}
|
|
12
|
+
const scheme = url?.protocol;
|
|
13
|
+
if (!url || url.username || url.password || !(scheme === "https:" || (scheme === "http:" && isLoopbackHost(url.hostname)))) {
|
|
14
|
+
throw new CliError("The control plane URL (B3IQ_API_URL or config api_url) must be https://, or http:// on localhost only, without credentials.", "usage");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function structuredDetail(text) {
|
|
18
|
+
try {
|
|
19
|
+
const body = JSON.parse(text);
|
|
20
|
+
const envelope = body.error && typeof body.error === "object" ? body.error : undefined;
|
|
21
|
+
const value = [body.detail, typeof body.error === "string" ? body.error : undefined, envelope?.detail, envelope?.message, body.message].find((item) => typeof item === "string" && item.trim());
|
|
22
|
+
return typeof value === "string" ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const EXPIRY_WARNING_DAYS = 14;
|
|
29
|
+
let expiryWarned = false;
|
|
30
|
+
function warnIfLoginEndsSoon(expiresAt) {
|
|
31
|
+
if (expiryWarned || !expiresAt)
|
|
32
|
+
return;
|
|
33
|
+
const left = Date.parse(expiresAt) - Date.now();
|
|
34
|
+
if (!(left > 0 && left <= EXPIRY_WARNING_DAYS * 86_400_000))
|
|
35
|
+
return;
|
|
36
|
+
expiryWarned = true;
|
|
37
|
+
const days = Math.max(1, Math.floor(left / 86_400_000));
|
|
38
|
+
warn(`this login expires in ${days} day${days === 1 ? "" : "s"} — run \`b3iq login\` to renew it`);
|
|
39
|
+
}
|
|
40
|
+
export function __resetExpiryWarningForTests() {
|
|
41
|
+
expiryWarned = false;
|
|
42
|
+
}
|
|
43
|
+
async function request(path, init = {}, retryOn429 = true, as) {
|
|
44
|
+
const settings = resolveSettings();
|
|
45
|
+
const { apiUrl } = settings;
|
|
46
|
+
assertApiUrl(apiUrl);
|
|
47
|
+
const token = as ? as.token : settings.token;
|
|
48
|
+
const orgId = as ? as.orgId : settings.orgId;
|
|
49
|
+
const savedExpiresAt = as ? undefined : settings.savedExpiresAt;
|
|
50
|
+
if (!token)
|
|
51
|
+
throw explainNotLoggedIn();
|
|
52
|
+
warnIfLoginEndsSoon(savedExpiresAt);
|
|
53
|
+
const headers = new Headers(init.headers);
|
|
54
|
+
headers.set("accept", "application/json");
|
|
55
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
56
|
+
if (orgId)
|
|
57
|
+
headers.set("x-org-id", orgId);
|
|
58
|
+
let res;
|
|
59
|
+
try {
|
|
60
|
+
res = await fetch(`${apiUrl}${path}`, { ...init, headers });
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error instanceof CliError)
|
|
64
|
+
throw error;
|
|
65
|
+
throw explainNetworkFailure(error, "control plane");
|
|
66
|
+
}
|
|
67
|
+
if (res.status === 429 && retryOn429) {
|
|
68
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
69
|
+
return request(path, init, false, as);
|
|
70
|
+
}
|
|
71
|
+
const requestId = res.headers.get("x-request-id") ?? undefined;
|
|
72
|
+
const text = await res.text();
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const detail = structuredDetail(text);
|
|
75
|
+
if (res.status === 401 || res.status === 403)
|
|
76
|
+
throw explainAuthFailure(detail, res.status, requestId, orgId, savedExpiresAt);
|
|
77
|
+
throw explainApiStatus(detail, res.status, requestId);
|
|
78
|
+
}
|
|
79
|
+
return (text ? JSON.parse(text) : {});
|
|
80
|
+
}
|
|
81
|
+
export const api = {
|
|
82
|
+
nodes: () => request("/api/dev/nodes"),
|
|
83
|
+
node: (id) => request(`/api/dev/nodes/${encodeURIComponent(id)}`),
|
|
84
|
+
nodeGuests: (id) => request(`/api/dev/nodes/${encodeURIComponent(id)}/guests`),
|
|
85
|
+
rentedGuests: () => request("/api/dev/guests"),
|
|
86
|
+
models: () => request("/api/dev/models/available"),
|
|
87
|
+
settings: (as) => request("/api/dev/settings", {}, true, as),
|
|
88
|
+
features: (signal) => request("/v1/public/features", signal ? { signal } : {}),
|
|
89
|
+
currentCredential: (as) => request("/api/dev/credential", {}, true, as),
|
|
90
|
+
revokeCurrentCredential: (as) => request("/api/dev/credential/revoke", { method: "POST" }, true, as),
|
|
91
|
+
enqueueCommand: (nodeId, type, params) => request(`/api/dev/nodes/${encodeURIComponent(nodeId)}/commands`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "content-type": "application/json" },
|
|
94
|
+
body: JSON.stringify({ type, params }),
|
|
95
|
+
}),
|
|
96
|
+
nodeCommands: (nodeId, limit = 20) => request(`/api/dev/nodes/${encodeURIComponent(nodeId)}/commands?limit=${limit}`),
|
|
97
|
+
nodeCommand: (nodeId, commandId) => request(`/api/dev/nodes/${encodeURIComponent(nodeId)}/commands/${encodeURIComponent(commandId)}`),
|
|
98
|
+
plugins: () => request("/api/dev/plugins"),
|
|
99
|
+
installPlugin: (nodeId, name) => request(`/api/dev/nodes/${encodeURIComponent(nodeId)}/plugins/${encodeURIComponent(name)}`, { method: "POST" }),
|
|
100
|
+
uninstallPlugin: (nodeId, name) => request(`/api/dev/nodes/${encodeURIComponent(nodeId)}/plugins/${encodeURIComponent(name)}`, { method: "DELETE" }),
|
|
101
|
+
qualify: (nodeId) => request(`/api/dev/nodes/${encodeURIComponent(nodeId)}/qualify`, { method: "POST" }),
|
|
102
|
+
logs: (limit = 50) => request(`/api/dev/logs?limit=${limit}`),
|
|
103
|
+
};
|
|
104
|
+
export async function whoAmI(as) {
|
|
105
|
+
try {
|
|
106
|
+
const current = await api.currentCredential(as);
|
|
107
|
+
return {
|
|
108
|
+
user: {
|
|
109
|
+
id: current.user.id,
|
|
110
|
+
...(current.user.email ? { email: current.user.email } : {}),
|
|
111
|
+
...(current.user.display_name ? { displayName: current.user.display_name } : {}),
|
|
112
|
+
...(current.user.avatar_url ? { avatarUrl: current.user.avatar_url } : {}),
|
|
113
|
+
},
|
|
114
|
+
organization: current.credential?.organization ?? null,
|
|
115
|
+
expiresAt: current.credential?.expires_at ?? null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (!(error instanceof CliError) || error.diagnosis?.observed?.http_status !== 404)
|
|
120
|
+
throw error;
|
|
121
|
+
const { user } = await api.settings(as);
|
|
122
|
+
return { user, organization: null, expiresAt: null };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function unauthedPost(path, body) {
|
|
126
|
+
const { apiUrl } = resolveSettings();
|
|
127
|
+
assertApiUrl(apiUrl);
|
|
128
|
+
let res;
|
|
129
|
+
try {
|
|
130
|
+
res = await fetch(`${apiUrl}${path}`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
133
|
+
body: JSON.stringify(body),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error instanceof CliError)
|
|
138
|
+
throw error;
|
|
139
|
+
throw explainNetworkFailure(error, "control plane");
|
|
140
|
+
}
|
|
141
|
+
const text = await res.text();
|
|
142
|
+
if (!res.ok)
|
|
143
|
+
throw explainApiStatus(structuredDetail(text), res.status, res.headers.get("x-request-id") ?? undefined);
|
|
144
|
+
return JSON.parse(text);
|
|
145
|
+
}
|
|
146
|
+
export const deviceAuth = {
|
|
147
|
+
start: (label) => unauthedPost("/api/dev/cli/device", { label, accepts: ["api_key"] }),
|
|
148
|
+
poll: (deviceCode) => unauthedPost("/api/dev/cli/device/token", { device_code: deviceCode }),
|
|
149
|
+
};
|
package/dist/lib/args.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const SHORT = { q: "quiet", h: "help", v: "version" };
|
|
2
|
+
export function parseArgs(argv, flagsThatTakeAValue = new Set()) {
|
|
3
|
+
const positionals = [];
|
|
4
|
+
const flags = {};
|
|
5
|
+
for (let i = 0; i < argv.length; i++) {
|
|
6
|
+
const tok = argv[i];
|
|
7
|
+
if (tok === "--") {
|
|
8
|
+
positionals.push(...argv.slice(i + 1));
|
|
9
|
+
break;
|
|
10
|
+
}
|
|
11
|
+
if (tok.startsWith("--")) {
|
|
12
|
+
const body = tok.slice(2);
|
|
13
|
+
const eq = body.indexOf("=");
|
|
14
|
+
if (eq >= 0) {
|
|
15
|
+
flags[body.slice(0, eq)] = body.slice(eq + 1);
|
|
16
|
+
}
|
|
17
|
+
else if (flagsThatTakeAValue.has(body) && i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
|
18
|
+
flags[body] = argv[++i];
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
flags[body] = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
else if (tok.startsWith("-") && tok.length > 1) {
|
|
25
|
+
for (const ch of tok.slice(1))
|
|
26
|
+
flags[SHORT[ch] ?? ch] = true;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
positionals.push(tok);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { positionals, flags };
|
|
33
|
+
}
|
|
34
|
+
export const boolFlag = (flags, name) => flags[name] === true;
|
|
35
|
+
export const strFlag = (flags, name) => typeof flags[name] === "string" ? flags[name] : undefined;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { api } from "./api.js";
|
|
2
|
+
import { explainCommandEnded, explainCommandTimeout } from "./diagnosis.js";
|
|
3
|
+
import { log } from "./output.js";
|
|
4
|
+
const POLL_MS = 3_000;
|
|
5
|
+
export async function waitForCommand(nodeId, commandId, opts) {
|
|
6
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
7
|
+
let lastStatus;
|
|
8
|
+
while (Date.now() < deadline) {
|
|
9
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
10
|
+
const { commands } = await api.nodeCommands(nodeId, 50);
|
|
11
|
+
const row = commands.find((c) => c.command_id === commandId);
|
|
12
|
+
if (!row)
|
|
13
|
+
continue;
|
|
14
|
+
if (row.status !== lastStatus) {
|
|
15
|
+
lastStatus = row.status;
|
|
16
|
+
log(`${opts.describe}: ${row.status}`);
|
|
17
|
+
}
|
|
18
|
+
if (row.status === "completed")
|
|
19
|
+
return row;
|
|
20
|
+
if (row.status === "failed" || row.status === "cancelled") {
|
|
21
|
+
throw explainCommandEnded(opts.describe, row.status, row.error, commandId, nodeId);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
throw explainCommandTimeout({
|
|
25
|
+
describe: opts.describe,
|
|
26
|
+
commandId,
|
|
27
|
+
nodeId,
|
|
28
|
+
timeoutMs: opts.timeoutMs,
|
|
29
|
+
...(lastStatus !== undefined ? { lastStatus } : {}),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export async function queuedThenWait(nodeId, queued, opts) {
|
|
33
|
+
log(`${opts.describe} queued (${queued.command_id})`);
|
|
34
|
+
if (!opts.wait)
|
|
35
|
+
return { command_id: queued.command_id, type: queued.type, status: "queued" };
|
|
36
|
+
return waitForCommand(nodeId, queued.command_id, opts);
|
|
37
|
+
}
|
|
38
|
+
export async function enqueueAndWait(nodeId, type, params, opts) {
|
|
39
|
+
const { command_id } = await api.enqueueCommand(nodeId, type, params);
|
|
40
|
+
return queuedThenWait(nodeId, { command_id, type, status: "queued" }, opts);
|
|
41
|
+
}
|