modelmux-cli 0.0.1 → 0.3.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/LICENSE +202 -0
- package/README.md +23 -1
- package/dist/_shared/providers/claude.json +34 -0
- package/dist/_shared/providers/codex.json +35 -0
- package/dist/_shared/release.json +4 -0
- package/dist/_shared/schema/provider.schema.json +81 -0
- package/dist/_shared/templates/compose-service.json +12 -0
- package/dist/_shared/templates/config/curl.json +8 -0
- package/dist/_shared/templates/config/env.json +8 -0
- package/dist/_shared/templates/config/langchain.json +8 -0
- package/dist/_shared/templates/config/litellm.json +8 -0
- package/dist/_shared/templates/config/openai-python.json +8 -0
- package/dist/args.js +422 -0
- package/dist/bin.js +4 -0
- package/dist/browser.js +51 -0
- package/dist/cli.js +60 -0
- package/dist/clientconfig.js +89 -0
- package/dist/commands/basic.js +72 -0
- package/dist/commands/configCmd.js +62 -0
- package/dist/commands/doctor.js +194 -0
- package/dist/commands/login.js +185 -0
- package/dist/commands/logout.js +57 -0
- package/dist/commands/up.js +204 -0
- package/dist/commands/upgrade.js +46 -0
- package/dist/config.js +97 -0
- package/dist/console.js +93 -0
- package/dist/context.js +36 -0
- package/dist/docker.js +294 -0
- package/dist/errors.js +49 -0
- package/dist/files.js +45 -0
- package/dist/health.js +78 -0
- package/dist/interrupt.js +43 -0
- package/dist/main.js +85 -0
- package/dist/paths.js +51 -0
- package/dist/ports.js +25 -0
- package/dist/prompt.js +69 -0
- package/dist/providers.js +178 -0
- package/dist/redact.js +33 -0
- package/dist/release.js +52 -0
- package/dist/secretsStore.js +61 -0
- package/dist/stack.js +220 -0
- package/dist/terminal.js +46 -0
- package/dist/text.js +40 -0
- package/dist/updates.js +59 -0
- package/dist/version.js +15 -0
- package/package.json +34 -2
package/dist/docker.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONLY module that runs other programs: the `docker` CLI.
|
|
3
|
+
*
|
|
4
|
+
* - Commands are argument lists, never shell strings.
|
|
5
|
+
* - Every call has a timeout.
|
|
6
|
+
* - Docker's raw output is shown only with --verbose (and redacted there);
|
|
7
|
+
* failures become `DockerError`s with a plain message and a next step.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
10
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { DockerError, Interrupted, Timeout } from "./errors.js";
|
|
13
|
+
import { interruptSignal } from "./interrupt.js";
|
|
14
|
+
import { shellJoin } from "./text.js";
|
|
15
|
+
export const DEFAULT_TIMEOUT = 60.0;
|
|
16
|
+
export const PULL_TIMEOUT = 1800.0;
|
|
17
|
+
export class Result {
|
|
18
|
+
args;
|
|
19
|
+
returncode;
|
|
20
|
+
stdout;
|
|
21
|
+
stderr;
|
|
22
|
+
constructor(args, returncode, stdout, stderr) {
|
|
23
|
+
this.args = args;
|
|
24
|
+
this.returncode = returncode;
|
|
25
|
+
this.stdout = stdout;
|
|
26
|
+
this.stderr = stderr;
|
|
27
|
+
}
|
|
28
|
+
get ok() {
|
|
29
|
+
return this.returncode === 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function startDockerHint(platform) {
|
|
33
|
+
if (platform === "darwin" || platform === "win32") {
|
|
34
|
+
return "Start Docker Desktop and wait until it says it is running, then try again.";
|
|
35
|
+
}
|
|
36
|
+
return "Start the Docker service (for example: sudo systemctl start docker), then try again.";
|
|
37
|
+
}
|
|
38
|
+
function installHint(platform) {
|
|
39
|
+
if (platform === "darwin" || platform === "win32") {
|
|
40
|
+
return "Install Docker Desktop: https://docs.docker.com/desktop/";
|
|
41
|
+
}
|
|
42
|
+
return "Install Docker Engine: https://docs.docker.com/engine/install/";
|
|
43
|
+
}
|
|
44
|
+
// [pattern in Docker's stderr, message, hint factory]
|
|
45
|
+
const CLASSIFIERS = [
|
|
46
|
+
[
|
|
47
|
+
/permission denied while trying to connect/i,
|
|
48
|
+
"Your user is not allowed to use Docker.",
|
|
49
|
+
() => "Add yourself to the docker group (sudo usermod -aG docker $USER), " +
|
|
50
|
+
"then log out and back in.",
|
|
51
|
+
],
|
|
52
|
+
[
|
|
53
|
+
/cannot connect to the docker daemon|is the docker daemon running|error during connect|docker_engine.*(cannot find|not found)|failed to connect to the docker API/i,
|
|
54
|
+
"Docker is installed but not running.",
|
|
55
|
+
startDockerHint,
|
|
56
|
+
],
|
|
57
|
+
[
|
|
58
|
+
/'compose' is not a docker command|unknown command.*compose|unknown shorthand flag: 'f' in -f/i,
|
|
59
|
+
"Docker Compose v2 is not available.",
|
|
60
|
+
() => "Install the Docker Compose plugin: https://docs.docker.com/compose/install/",
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
/port is already allocated|address already in use|bind: .*in use/i,
|
|
64
|
+
"A port ModelMux needs is already in use.",
|
|
65
|
+
() => "Run 'modelmux doctor' to see which port, or choose another with --port.",
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
/manifest unknown|manifest for .* not found|not found: manifest|failed to resolve reference .*: not found/i,
|
|
69
|
+
"The ModelMux image for this version was not found.",
|
|
70
|
+
() => "Check for a newer modelmux-cli (npm install -g modelmux-cli@latest), or run 'modelmux doctor'.",
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
// GHCR answers a bare "denied" for images that do not exist or are private.
|
|
74
|
+
/pull access denied|unauthorized|error from registry: denied|denied: /i,
|
|
75
|
+
"The image registry refused access (the image may not exist or may be private).",
|
|
76
|
+
() => "Check that you can reach ghcr.io and are not logged in with expired " +
|
|
77
|
+
"credentials (docker logout ghcr.io).",
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
/tls handshake timeout|i\/o timeout|no such host|dial tcp|network is unreachable|proxyconnect|connection refused|temporary failure in name resolution/i,
|
|
81
|
+
"Docker could not reach the internet.",
|
|
82
|
+
() => "Check your connection or proxy (HTTPS_PROXY, and Docker Desktop's proxy " +
|
|
83
|
+
"settings), then try again.",
|
|
84
|
+
],
|
|
85
|
+
[
|
|
86
|
+
/no space left on device/i,
|
|
87
|
+
"Docker has run out of disk space.",
|
|
88
|
+
() => "Free space with 'docker system prune' (it removes unused images), then retry.",
|
|
89
|
+
],
|
|
90
|
+
];
|
|
91
|
+
export function classify(stderr, command, platform = process.platform) {
|
|
92
|
+
for (const [pattern, message, hint] of CLASSIFIERS) {
|
|
93
|
+
if (pattern.test(stderr))
|
|
94
|
+
return new DockerError(message, { hint: hint(platform) });
|
|
95
|
+
}
|
|
96
|
+
return new DockerError(`Docker command failed: docker ${command}.`, {
|
|
97
|
+
hint: "Run again with --verbose to see Docker's output.",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** Python's `shutil.which()`: the first executable `name` on PATH, or null. */
|
|
101
|
+
export function which(name, env = process.env, platform = process.platform) {
|
|
102
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(platform === "win32" ? ";" : ":");
|
|
103
|
+
const exts = platform === "win32"
|
|
104
|
+
? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
|
|
105
|
+
: [""];
|
|
106
|
+
for (const dir of dirs) {
|
|
107
|
+
if (!dir)
|
|
108
|
+
continue;
|
|
109
|
+
for (const ext of exts) {
|
|
110
|
+
const candidate = path.join(dir, name + ext);
|
|
111
|
+
try {
|
|
112
|
+
if (!statSync(candidate).isFile())
|
|
113
|
+
continue;
|
|
114
|
+
if (platform !== "win32")
|
|
115
|
+
accessSync(candidate, constants.X_OK);
|
|
116
|
+
return candidate;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// not here
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
/** Kill a child that is still running. */
|
|
126
|
+
function stop(child) {
|
|
127
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
128
|
+
child.kill("SIGTERM");
|
|
129
|
+
}
|
|
130
|
+
/** Exit code like Python's subprocess: -N when killed by signal N. */
|
|
131
|
+
function exitCode(code, signal) {
|
|
132
|
+
if (code !== null)
|
|
133
|
+
return code;
|
|
134
|
+
const numbers = { SIGHUP: 1, SIGINT: 2, SIGKILL: 9, SIGTERM: 15 };
|
|
135
|
+
return signal ? -(numbers[signal] ?? 1) : -1;
|
|
136
|
+
}
|
|
137
|
+
export class Docker {
|
|
138
|
+
console;
|
|
139
|
+
platform;
|
|
140
|
+
spawnFn;
|
|
141
|
+
whichFn;
|
|
142
|
+
found = null;
|
|
143
|
+
constructor(console, options = {}) {
|
|
144
|
+
this.console = console;
|
|
145
|
+
this.spawnFn = options.spawn ?? nodeSpawn;
|
|
146
|
+
this.whichFn = options.which ?? ((name) => which(name));
|
|
147
|
+
this.platform = options.platform ?? process.platform;
|
|
148
|
+
}
|
|
149
|
+
get binary() {
|
|
150
|
+
if (this.found === null) {
|
|
151
|
+
const found = this.whichFn("docker");
|
|
152
|
+
if (found === null) {
|
|
153
|
+
throw new DockerError("Docker is not installed.", { hint: installHint(this.platform) });
|
|
154
|
+
}
|
|
155
|
+
this.found = found;
|
|
156
|
+
}
|
|
157
|
+
return this.found;
|
|
158
|
+
}
|
|
159
|
+
argv(args) {
|
|
160
|
+
for (const arg of args) {
|
|
161
|
+
if (typeof arg !== "string" || arg.includes("\x00")) {
|
|
162
|
+
throw new DockerError("Internal error: invalid docker argument.");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return [this.binary, ...args];
|
|
166
|
+
}
|
|
167
|
+
spawnError(error, file) {
|
|
168
|
+
if (error.code === "ENOENT") {
|
|
169
|
+
return new DockerError("Docker is not installed.", { hint: installHint(this.platform) });
|
|
170
|
+
}
|
|
171
|
+
if (error.code === "EACCES" || error.code === "EPERM") {
|
|
172
|
+
return new DockerError("The docker command cannot be executed.", {
|
|
173
|
+
hint: `Check permissions of ${file}.`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return new DockerError(`The docker command could not be started (${error.code ?? error.message}).`);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Start `docker <args>` and settle when it exits, times out (`onTimeout`)
|
|
180
|
+
* or is interrupted (`Interrupted`); the child is stopped in both cases.
|
|
181
|
+
*/
|
|
182
|
+
supervise(child, file, timeout, onTimeout) {
|
|
183
|
+
const signal = interruptSignal();
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
let timer;
|
|
186
|
+
const cleanup = () => {
|
|
187
|
+
if (timer !== undefined)
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
signal.removeEventListener("abort", onAbort);
|
|
190
|
+
};
|
|
191
|
+
const onAbort = () => {
|
|
192
|
+
cleanup();
|
|
193
|
+
stop(child);
|
|
194
|
+
reject(new Interrupted());
|
|
195
|
+
};
|
|
196
|
+
if (signal.aborted) {
|
|
197
|
+
onAbort();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
201
|
+
if (timeout !== null) {
|
|
202
|
+
timer = setTimeout(() => {
|
|
203
|
+
cleanup();
|
|
204
|
+
stop(child);
|
|
205
|
+
reject(onTimeout());
|
|
206
|
+
}, timeout * 1000);
|
|
207
|
+
}
|
|
208
|
+
child.once("error", (error) => {
|
|
209
|
+
cleanup();
|
|
210
|
+
reject(this.spawnError(error, file));
|
|
211
|
+
});
|
|
212
|
+
child.once("close", (code, sig) => {
|
|
213
|
+
cleanup();
|
|
214
|
+
resolve(exitCode(code, sig));
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/** Run `docker <args>` and capture its output. */
|
|
219
|
+
async run(args, options = {}) {
|
|
220
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
221
|
+
const check = options.check ?? true;
|
|
222
|
+
const inputText = options.inputText ?? null;
|
|
223
|
+
const [file, ...rest] = this.argv(args);
|
|
224
|
+
this.console.detail("$ docker " + shellJoin(args));
|
|
225
|
+
const child = this.spawnFn(file, rest, {
|
|
226
|
+
stdio: [inputText === null ? "ignore" : "pipe", "pipe", "pipe"],
|
|
227
|
+
windowsHide: true,
|
|
228
|
+
});
|
|
229
|
+
const out = [];
|
|
230
|
+
const err = [];
|
|
231
|
+
child.stdout?.on("data", (chunk) => out.push(chunk));
|
|
232
|
+
child.stderr?.on("data", (chunk) => err.push(chunk));
|
|
233
|
+
if (inputText !== null && child.stdin) {
|
|
234
|
+
child.stdin.on("error", () => undefined); // the program may exit before reading
|
|
235
|
+
child.stdin.end(inputText, "utf8");
|
|
236
|
+
}
|
|
237
|
+
const returncode = await this.supervise(child, file, timeout, () => new DockerError(`Docker did not finish 'docker ${args[0] ?? ""}' in time.`, {
|
|
238
|
+
hint: "Docker may be busy or stuck; check Docker is healthy and try again.",
|
|
239
|
+
}));
|
|
240
|
+
const result = new Result([...args], returncode, Buffer.concat(out).toString("utf8"), Buffer.concat(err).toString("utf8"));
|
|
241
|
+
if (result.stdout.trim())
|
|
242
|
+
this.console.detail(result.stdout.trimEnd());
|
|
243
|
+
if (result.stderr.trim())
|
|
244
|
+
this.console.detail(result.stderr.trimEnd());
|
|
245
|
+
if (check && !result.ok)
|
|
246
|
+
throw classify(result.stderr, args[0] ?? "", this.platform);
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Run `docker <args>` attached to this terminal (logs -f, interactive login).
|
|
251
|
+
*
|
|
252
|
+
* Output is not captured, so it is the user's own Docker output.
|
|
253
|
+
*/
|
|
254
|
+
async passthrough(args, options = {}) {
|
|
255
|
+
const [file, ...rest] = this.argv(args);
|
|
256
|
+
this.console.detail("$ docker " + shellJoin(args));
|
|
257
|
+
const child = this.spawnFn(file, rest, { stdio: "inherit" });
|
|
258
|
+
return this.supervise(child, file, options.timeout ?? null, () => new DockerError("Docker did not finish in time.", { hint: "Try again." }));
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* POSIX: run `docker <args>` (which include `-it`) with our terminal as its
|
|
262
|
+
* input, and relay its output.
|
|
263
|
+
*
|
|
264
|
+
* Docker itself puts the terminal in raw mode and gives the program a
|
|
265
|
+
* terminal, so keystrokes go straight from the user to the program without
|
|
266
|
+
* passing through this process. Only the output passes here: it is written
|
|
267
|
+
* to our terminal unchanged and never stored. `onOutput` sees each chunk
|
|
268
|
+
* and may return extra bytes to display (for example "opened your
|
|
269
|
+
* browser"). Rejects with `Timeout` after `timeout` seconds.
|
|
270
|
+
*/
|
|
271
|
+
async runTty(args, options) {
|
|
272
|
+
const [file, ...rest] = this.argv(args);
|
|
273
|
+
this.console.detail("$ docker " + shellJoin(args));
|
|
274
|
+
const sink = options.stdout ?? process.stdout;
|
|
275
|
+
const child = this.spawnFn(file, rest, { stdio: ["inherit", "pipe", "inherit"] });
|
|
276
|
+
child.stdout?.on("data", (chunk) => {
|
|
277
|
+
sink.write(chunk);
|
|
278
|
+
void Promise.resolve(options.onOutput(chunk)).then((extra) => {
|
|
279
|
+
if (extra)
|
|
280
|
+
sink.write(extra);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
return this.supervise(child, file, options.timeout, () => new Timeout());
|
|
284
|
+
}
|
|
285
|
+
compose(project, file, args, options = {}) {
|
|
286
|
+
return this.run(["compose", "-p", project, "-f", file, ...args], options);
|
|
287
|
+
}
|
|
288
|
+
/** Docker is installed, running, and has Compose v2. Returns the server version. */
|
|
289
|
+
async checkAvailable() {
|
|
290
|
+
const server = (await this.run(["version", "--format", "{{.Server.Version}}"])).stdout.trim();
|
|
291
|
+
await this.run(["compose", "version", "--short"]);
|
|
292
|
+
return server;
|
|
293
|
+
}
|
|
294
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors the CLI reports to the user.
|
|
3
|
+
*
|
|
4
|
+
* Every failure is a `CliError`: a plain message saying what went wrong, an
|
|
5
|
+
* optional hint saying what to do next, and an exit code. `main` is the only
|
|
6
|
+
* place that prints them.
|
|
7
|
+
*/
|
|
8
|
+
export const EXIT_OK = 0;
|
|
9
|
+
export const EXIT_FAILURE = 1;
|
|
10
|
+
export const EXIT_USAGE = 2;
|
|
11
|
+
export const EXIT_DOCKER = 3;
|
|
12
|
+
export const EXIT_INTERRUPTED = 130;
|
|
13
|
+
export class CliError extends Error {
|
|
14
|
+
hint;
|
|
15
|
+
constructor(message, options = {}) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = new.target.name;
|
|
18
|
+
this.hint = options.hint ?? null;
|
|
19
|
+
}
|
|
20
|
+
get exitCode() {
|
|
21
|
+
return EXIT_FAILURE;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** The command was used incorrectly. */
|
|
25
|
+
export class UsageError extends CliError {
|
|
26
|
+
get exitCode() {
|
|
27
|
+
return EXIT_USAGE;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Docker is missing, stopped, or a Docker command failed. */
|
|
31
|
+
export class DockerError extends CliError {
|
|
32
|
+
get exitCode() {
|
|
33
|
+
return EXIT_DOCKER;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Ctrl+C, SIGTERM or a closed terminal (Python's KeyboardInterrupt). */
|
|
37
|
+
export class Interrupted extends Error {
|
|
38
|
+
constructor() {
|
|
39
|
+
super("interrupted");
|
|
40
|
+
this.name = "Interrupted";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** A deadline passed (Python's TimeoutError). */
|
|
44
|
+
export class Timeout extends Error {
|
|
45
|
+
constructor() {
|
|
46
|
+
super("timed out");
|
|
47
|
+
this.name = "Timeout";
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Small, safe file writes: atomic, and private (0600) from the first byte. */
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { chmodSync, closeSync, fsyncSync, openSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export const PRIVATE_MODE = 0o600;
|
|
6
|
+
/**
|
|
7
|
+
* Atomically replace `file` with `data`, created with mode 0600.
|
|
8
|
+
*
|
|
9
|
+
* The temporary file is created with 0600 (never readable by others, even
|
|
10
|
+
* briefly) in the same directory, then renamed over the target.
|
|
11
|
+
*/
|
|
12
|
+
export function writePrivate(file, data) {
|
|
13
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
14
|
+
const fd = openSync(tmp, "wx", PRIVATE_MODE);
|
|
15
|
+
try {
|
|
16
|
+
try {
|
|
17
|
+
writeSync(fd, Buffer.from(data, "utf8"));
|
|
18
|
+
fsyncSync(fd);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
closeSync(fd);
|
|
22
|
+
}
|
|
23
|
+
renameSync(tmp, file);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
try {
|
|
27
|
+
unlinkSync(tmp);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// already gone
|
|
31
|
+
}
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Make an existing file 0600 on POSIX. Returns true if it had to change. */
|
|
36
|
+
export function tighten(file) {
|
|
37
|
+
if (process.platform === "win32")
|
|
38
|
+
return false;
|
|
39
|
+
const mode = statSync(file).mode & 0o777;
|
|
40
|
+
if (mode !== PRIVATE_MODE) {
|
|
41
|
+
chmodSync(file, PRIVATE_MODE);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
package/dist/health.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Talk to a running ModelMux server over HTTP (localhost only). */
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { interruptible, throwIfInterrupted } from "./interrupt.js";
|
|
4
|
+
export const LOCALHOST = "127.0.0.1";
|
|
5
|
+
export function baseUrl(port) {
|
|
6
|
+
return `http://${LOCALHOST}:${port}`;
|
|
7
|
+
}
|
|
8
|
+
/** GET `path` on the local server. Resolves to [status, body]; status 0 if unreachable. */
|
|
9
|
+
export function getJson(port, urlPath, options = {}) {
|
|
10
|
+
return interruptible(request(port, urlPath, options));
|
|
11
|
+
}
|
|
12
|
+
function request(port, urlPath, options = {}) {
|
|
13
|
+
const timeout = options.timeout ?? 3.0;
|
|
14
|
+
const headers = {};
|
|
15
|
+
if (options.apiKey != null)
|
|
16
|
+
headers.Authorization = `Bearer ${options.apiKey}`;
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let settled = false;
|
|
19
|
+
const done = (value) => {
|
|
20
|
+
if (!settled) {
|
|
21
|
+
settled = true;
|
|
22
|
+
resolve(value);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const request = http.get({ host: LOCALHOST, port, path: urlPath, headers, agent: false }, (response) => {
|
|
26
|
+
const chunks = [];
|
|
27
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
28
|
+
response.on("error", () => done([0, null]));
|
|
29
|
+
response.on("end", () => {
|
|
30
|
+
const status = response.statusCode ?? 0;
|
|
31
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
32
|
+
let body = null;
|
|
33
|
+
try {
|
|
34
|
+
body = text ? JSON.parse(text) : null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// urllib: a 2xx with a broken body is "unreachable"; an error status keeps its code.
|
|
38
|
+
done(status >= 200 && status < 300 ? [0, null] : [status, null]);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
done([status, body]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
request.setTimeout(timeout * 1000, () => request.destroy(new Error("timeout")));
|
|
45
|
+
request.on("error", () => done([0, null]));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function isReady(port) {
|
|
49
|
+
const [status, body] = await server.getJson(port, "/health/ready");
|
|
50
|
+
return (status === 200 &&
|
|
51
|
+
typeof body === "object" &&
|
|
52
|
+
body !== null &&
|
|
53
|
+
!Array.isArray(body) &&
|
|
54
|
+
body.status === "ready");
|
|
55
|
+
}
|
|
56
|
+
/** Sleep; Ctrl+C ends it early with `Interrupted`. */
|
|
57
|
+
/** The calls commands make to a running server. Replaceable in tests. */
|
|
58
|
+
export const server = {
|
|
59
|
+
getJson,
|
|
60
|
+
ready: isReady,
|
|
61
|
+
};
|
|
62
|
+
export const sleep = (seconds) => interruptible(new Promise((resolve) => setTimeout(resolve, seconds * 1000)));
|
|
63
|
+
/** Poll `check` until it is true (true), `shouldStop` is true or time runs out (false). */
|
|
64
|
+
export async function waitUntil(check, options) {
|
|
65
|
+
const interval = options.interval ?? 1.0;
|
|
66
|
+
const shouldStop = options.shouldStop ?? (() => false);
|
|
67
|
+
const pause = options.sleep ?? sleep;
|
|
68
|
+
const clock = options.clock ?? (() => performance.now() / 1000);
|
|
69
|
+
const deadline = clock() + options.timeout;
|
|
70
|
+
for (;;) {
|
|
71
|
+
throwIfInterrupted();
|
|
72
|
+
if (await check())
|
|
73
|
+
return true;
|
|
74
|
+
if ((await shouldStop()) || clock() >= deadline)
|
|
75
|
+
return false;
|
|
76
|
+
await pause(interval);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ctrl+C and friends, as one signal every long-running step can watch.
|
|
3
|
+
*
|
|
4
|
+
* Python turns Ctrl+C into KeyboardInterrupt wherever the program is; Node
|
|
5
|
+
* delivers it as an event. `main` installs the SIGINT handler, and running
|
|
6
|
+
* commands, waits and HTTP polls reject with `Interrupted` when it fires, so
|
|
7
|
+
* cleanup (`finally`, removing the login helper) runs and the user sees
|
|
8
|
+
* "Cancelled.".
|
|
9
|
+
*/
|
|
10
|
+
import { Interrupted } from "./errors.js";
|
|
11
|
+
let controller = new AbortController();
|
|
12
|
+
export function interruptSignal() {
|
|
13
|
+
return controller.signal;
|
|
14
|
+
}
|
|
15
|
+
/** Fire the interrupt (SIGINT; SIGTERM/SIGHUP while logging in). */
|
|
16
|
+
export function interrupt() {
|
|
17
|
+
controller.abort(new Interrupted());
|
|
18
|
+
}
|
|
19
|
+
/** Start over with a fresh signal (tests). */
|
|
20
|
+
export function resetInterrupt() {
|
|
21
|
+
controller = new AbortController();
|
|
22
|
+
}
|
|
23
|
+
export function throwIfInterrupted() {
|
|
24
|
+
if (controller.signal.aborted)
|
|
25
|
+
throw new Interrupted();
|
|
26
|
+
}
|
|
27
|
+
/** Run `fn`; if the interrupt fires first, reject with `Interrupted`. */
|
|
28
|
+
export function interruptible(promise) {
|
|
29
|
+
const signal = controller.signal;
|
|
30
|
+
if (signal.aborted)
|
|
31
|
+
return Promise.reject(new Interrupted());
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const onAbort = () => reject(new Interrupted());
|
|
34
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
35
|
+
promise.then((value) => {
|
|
36
|
+
signal.removeEventListener("abort", onAbort);
|
|
37
|
+
resolve(value);
|
|
38
|
+
}, (error) => {
|
|
39
|
+
signal.removeEventListener("abort", onAbort);
|
|
40
|
+
reject(error);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `modelmux`: set up, log in to and run ModelMux.
|
|
3
|
+
*
|
|
4
|
+
* This module only parses arguments and reports errors. Each command lives in
|
|
5
|
+
* `commands/`.
|
|
6
|
+
*/
|
|
7
|
+
import { ParserError, ParserExit } from "./args.js";
|
|
8
|
+
import { asArgs, buildParser } from "./cli.js";
|
|
9
|
+
import * as basic from "./commands/basic.js";
|
|
10
|
+
import * as configCmd from "./commands/configCmd.js";
|
|
11
|
+
import * as doctor from "./commands/doctor.js";
|
|
12
|
+
import * as login from "./commands/login.js";
|
|
13
|
+
import * as logout from "./commands/logout.js";
|
|
14
|
+
import * as up from "./commands/up.js";
|
|
15
|
+
import * as upgrade from "./commands/upgrade.js";
|
|
16
|
+
import { Console } from "./console.js";
|
|
17
|
+
import { CliError, EXIT_FAILURE, EXIT_INTERRUPTED, Interrupted } from "./errors.js";
|
|
18
|
+
import { interrupt } from "./interrupt.js";
|
|
19
|
+
const HANDLERS = {
|
|
20
|
+
up: up.run,
|
|
21
|
+
down: basic.down,
|
|
22
|
+
logs: basic.logs,
|
|
23
|
+
status: basic.status,
|
|
24
|
+
login: login.run,
|
|
25
|
+
logout: logout.run,
|
|
26
|
+
config: configCmd.config,
|
|
27
|
+
doctor: doctor.run,
|
|
28
|
+
upgrade: upgrade.run,
|
|
29
|
+
"key show": configCmd.keyShow,
|
|
30
|
+
};
|
|
31
|
+
export async function main(argv = process.argv.slice(2), streams = {}) {
|
|
32
|
+
const raw = [...argv];
|
|
33
|
+
const out = streams.out ?? process.stdout;
|
|
34
|
+
const err = streams.err ?? process.stderr;
|
|
35
|
+
// Until arguments are parsed, honour --verbose from the raw command line.
|
|
36
|
+
let console = new Console({ out, err, verbose: raw.includes("-v") || raw.includes("--verbose") });
|
|
37
|
+
try {
|
|
38
|
+
let args;
|
|
39
|
+
try {
|
|
40
|
+
args = asArgs(buildParser().parse(raw));
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error instanceof ParserExit) {
|
|
44
|
+
out.write(error.text);
|
|
45
|
+
return error.status;
|
|
46
|
+
}
|
|
47
|
+
if (error instanceof ParserError) {
|
|
48
|
+
err.write(error.text);
|
|
49
|
+
return error.status;
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
console = new Console({ out, err, verbose: args.verbose, color: args.no_color ? false : null });
|
|
54
|
+
const key = args.command === "key" ? `key ${args.key_command}` : args.command;
|
|
55
|
+
const handler = HANDLERS[key];
|
|
56
|
+
if (handler === undefined)
|
|
57
|
+
throw new Error(`no handler for ${key}`);
|
|
58
|
+
return await handler(args, console);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error instanceof CliError) {
|
|
62
|
+
console.error(error.message, error.hint);
|
|
63
|
+
return error.exitCode;
|
|
64
|
+
}
|
|
65
|
+
if (error instanceof Interrupted) {
|
|
66
|
+
console.error("Cancelled.");
|
|
67
|
+
return EXIT_INTERRUPTED;
|
|
68
|
+
}
|
|
69
|
+
console.error("Something unexpected went wrong.", "Run again with --verbose for details, and please report it if it keeps happening.");
|
|
70
|
+
if (console.verbose)
|
|
71
|
+
console.detail(error instanceof Error ? (error.stack ?? String(error)) : String(error));
|
|
72
|
+
return EXIT_FAILURE;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Run as the `modelmux` command. */
|
|
76
|
+
export async function cli() {
|
|
77
|
+
let interrupted = false;
|
|
78
|
+
process.on("SIGINT", () => {
|
|
79
|
+
if (interrupted)
|
|
80
|
+
process.exit(EXIT_INTERRUPTED); // a second Ctrl+C: stop now
|
|
81
|
+
interrupted = true;
|
|
82
|
+
interrupt();
|
|
83
|
+
});
|
|
84
|
+
process.exitCode = await main();
|
|
85
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the CLI keeps its own files.
|
|
3
|
+
*
|
|
4
|
+
* | OS | Directory |
|
|
5
|
+
* |---------|----------------------------------------------------|
|
|
6
|
+
* | Linux | `$XDG_CONFIG_HOME/modelmux` (`~/.config/modelmux`) |
|
|
7
|
+
* | macOS | `~/Library/Application Support/modelmux` |
|
|
8
|
+
* | Windows | `%APPDATA%\modelmux` |
|
|
9
|
+
*
|
|
10
|
+
* `MODELMUX_CLI_HOME` overrides it (tests, portable setups). The directory
|
|
11
|
+
* holds only small files: `config.json`, `secrets.env` (mode 0600) and the
|
|
12
|
+
* generated `compose.yaml`. Logins never live here: they stay in Docker
|
|
13
|
+
* volumes. The same directory as the Python CLI, so both share one setup.
|
|
14
|
+
*/
|
|
15
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
export const APP_NAME = "modelmux";
|
|
19
|
+
export const OVERRIDE_ENV = "MODELMUX_CLI_HOME";
|
|
20
|
+
function expandUser(value, home) {
|
|
21
|
+
if (value === "~")
|
|
22
|
+
return home;
|
|
23
|
+
if (value.startsWith("~/") || value.startsWith("~\\"))
|
|
24
|
+
return path.join(home, value.slice(2));
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
export function configDir(env = process.env, platform = process.platform, home = homedir()) {
|
|
28
|
+
const override = env[OVERRIDE_ENV];
|
|
29
|
+
if (override)
|
|
30
|
+
return expandUser(override, home);
|
|
31
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
32
|
+
let base;
|
|
33
|
+
if (platform === "win32") {
|
|
34
|
+
base = env.APPDATA ? env.APPDATA : p.join(home, "AppData", "Roaming");
|
|
35
|
+
}
|
|
36
|
+
else if (platform === "darwin") {
|
|
37
|
+
base = p.join(home, "Library", "Application Support");
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
41
|
+
base = xdg && path.posix.isAbsolute(xdg) ? xdg : p.join(home, ".config");
|
|
42
|
+
}
|
|
43
|
+
return p.join(base, APP_NAME);
|
|
44
|
+
}
|
|
45
|
+
/** Create `dir` if needed; on POSIX make it accessible to the owner only. */
|
|
46
|
+
export function ensurePrivateDir(dir) {
|
|
47
|
+
mkdirSync(dir, { recursive: true });
|
|
48
|
+
if (process.platform !== "win32")
|
|
49
|
+
chmodSync(dir, 0o700);
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
package/dist/ports.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Is a local port free? (Checked with a socket bind: no extra processes.) */
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { LOCALHOST } from "./health.js";
|
|
4
|
+
/**
|
|
5
|
+
* True unless another program is listening on `host:port`.
|
|
6
|
+
*
|
|
7
|
+
* Ports in TIME_WAIT (for example right after `modelmux down`) count as free,
|
|
8
|
+
* as they do for Docker: on POSIX Node binds with `SO_REUSEADDR`, which
|
|
9
|
+
* ignores TIME_WAIT but still fails against a live listener. On Windows Node
|
|
10
|
+
* binds exclusively, so a port in use is never "stolen".
|
|
11
|
+
*/
|
|
12
|
+
function isFree(port, host = LOCALHOST) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const server = net.createServer();
|
|
15
|
+
server.unref();
|
|
16
|
+
server.once("error", () => resolve(false));
|
|
17
|
+
server.listen({ host, port, exclusive: true }, () => {
|
|
18
|
+
server.close(() => resolve(true));
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** Replaceable in tests. */
|
|
23
|
+
export const ports = {
|
|
24
|
+
portFree: isFree,
|
|
25
|
+
};
|