modelmux-cli 0.0.0-stage → 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.
Files changed (46) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +24 -2
  3. package/dist/_shared/providers/claude.json +34 -0
  4. package/dist/_shared/providers/codex.json +35 -0
  5. package/dist/_shared/release.json +4 -0
  6. package/dist/_shared/schema/provider.schema.json +81 -0
  7. package/dist/_shared/templates/compose-service.json +12 -0
  8. package/dist/_shared/templates/config/curl.json +8 -0
  9. package/dist/_shared/templates/config/env.json +8 -0
  10. package/dist/_shared/templates/config/langchain.json +8 -0
  11. package/dist/_shared/templates/config/litellm.json +8 -0
  12. package/dist/_shared/templates/config/openai-python.json +8 -0
  13. package/dist/args.js +422 -0
  14. package/dist/bin.js +4 -0
  15. package/dist/browser.js +51 -0
  16. package/dist/cli.js +60 -0
  17. package/dist/clientconfig.js +89 -0
  18. package/dist/commands/basic.js +72 -0
  19. package/dist/commands/configCmd.js +62 -0
  20. package/dist/commands/doctor.js +194 -0
  21. package/dist/commands/login.js +185 -0
  22. package/dist/commands/logout.js +57 -0
  23. package/dist/commands/up.js +204 -0
  24. package/dist/commands/upgrade.js +46 -0
  25. package/dist/config.js +97 -0
  26. package/dist/console.js +93 -0
  27. package/dist/context.js +36 -0
  28. package/dist/docker.js +294 -0
  29. package/dist/errors.js +49 -0
  30. package/dist/files.js +45 -0
  31. package/dist/health.js +78 -0
  32. package/dist/interrupt.js +43 -0
  33. package/dist/main.js +85 -0
  34. package/dist/paths.js +51 -0
  35. package/dist/ports.js +25 -0
  36. package/dist/prompt.js +69 -0
  37. package/dist/providers.js +178 -0
  38. package/dist/redact.js +33 -0
  39. package/dist/release.js +52 -0
  40. package/dist/secretsStore.js +61 -0
  41. package/dist/stack.js +220 -0
  42. package/dist/terminal.js +46 -0
  43. package/dist/text.js +40 -0
  44. package/dist/updates.js +59 -0
  45. package/dist/version.js +15 -0
  46. package/package.json +41 -4
package/dist/prompt.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Questions on the terminal: a yes/no answer (Python's `input`) and a hidden
3
+ * secret (Python's `getpass`). Neither answer is stored or logged.
4
+ */
5
+ import { createInterface } from "node:readline";
6
+ import { Interrupted } from "./errors.js";
7
+ /** Read one line from stdin (no echo control). Rejects at end of input. */
8
+ function readLine(prompt, output) {
9
+ return new Promise((resolve, reject) => {
10
+ const rl = createInterface({ input: process.stdin, output, terminal: false });
11
+ let answered = false;
12
+ output.write(prompt);
13
+ rl.once("line", (line) => {
14
+ answered = true;
15
+ rl.close();
16
+ resolve(line);
17
+ });
18
+ rl.once("close", () => {
19
+ if (!answered)
20
+ reject(new Error("EOF when reading a line"));
21
+ });
22
+ });
23
+ }
24
+ /** Python's `input(question)`: the prompt goes to stdout. */
25
+ export function ask(question) {
26
+ return readLine(question, process.stdout);
27
+ }
28
+ /** Python's `getpass.getpass(prompt)`: read a line without echoing it. */
29
+ export function readSecret(prompt) {
30
+ const stdin = process.stdin;
31
+ if (!stdin.isTTY) {
32
+ process.stderr.write("Warning: Password input may be echoed.\n");
33
+ return readLine(prompt, process.stderr);
34
+ }
35
+ return new Promise((resolve, reject) => {
36
+ let value = "";
37
+ process.stderr.write(prompt);
38
+ stdin.setRawMode(true);
39
+ stdin.resume();
40
+ stdin.setEncoding("utf8");
41
+ const finish = (error) => {
42
+ stdin.removeListener("data", onData);
43
+ stdin.setRawMode(false);
44
+ stdin.pause();
45
+ process.stderr.write("\n");
46
+ if (error)
47
+ reject(error);
48
+ else
49
+ resolve(value);
50
+ };
51
+ const onData = (chunk) => {
52
+ for (const ch of chunk) {
53
+ if (ch === "\r" || ch === "\n")
54
+ return finish(null);
55
+ if (ch === "\x03")
56
+ return finish(new Interrupted()); // Ctrl+C
57
+ if (ch === "\x04" && value === "")
58
+ return finish(new Error("EOF when reading a line"));
59
+ if (ch === "\x7f" || ch === "\b")
60
+ value = value.slice(0, -1);
61
+ else if (ch === "\x15")
62
+ value = ""; // Ctrl+U
63
+ else if (ch >= " ")
64
+ value += ch;
65
+ }
66
+ };
67
+ stdin.on("data", onData);
68
+ });
69
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Provider definitions, loaded from the shared JSON files.
3
+ *
4
+ * The files are validated strictly here (no dependency needed); the test suite
5
+ * additionally validates them against `shared/schema/provider.schema.json`.
6
+ */
7
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { CliError, UsageError } from "./errors.js";
11
+ import { repr, sortedStrings } from "./text.js";
12
+ const IDENTIFIER = /^[a-z][a-z0-9-]{0,31}$/;
13
+ const VOLUME = /^[a-z0-9][a-z0-9_.-]{0,62}$/;
14
+ const ARG = /^[A-Za-z0-9._=/-]+$/;
15
+ /** The bundled shared data, or the repository's /shared when run from source. */
16
+ export function sharedDir() {
17
+ const here = path.dirname(fileURLToPath(import.meta.url));
18
+ const bundled = path.join(here, "_shared");
19
+ if (existsSync(bundled) && statSync(bundled).isDirectory())
20
+ return bundled;
21
+ return path.resolve(here, "..", "..", "..", "shared"); // cli/node/{src,dist} -> repo root
22
+ }
23
+ export class ProviderFileError extends CliError {
24
+ constructor(file, problem) {
25
+ super(`Provider definition ${path.basename(file)} is invalid: ${problem}`, {
26
+ hint: "This is a packaging bug; please report it.",
27
+ });
28
+ }
29
+ }
30
+ function isDict(value) {
31
+ return typeof value === "object" && value !== null && !Array.isArray(value);
32
+ }
33
+ function requireValue(data, key, kind, file) {
34
+ const value = data[key];
35
+ const ok = kind === "str"
36
+ ? typeof value === "string"
37
+ : kind === "int"
38
+ ? Number.isInteger(value)
39
+ : isDict(value);
40
+ if (!ok)
41
+ throw new ProviderFileError(file, `'${key}' must be a ${kind}`);
42
+ return value;
43
+ }
44
+ function command(value, file, where) {
45
+ if (!Array.isArray(value) ||
46
+ value.length === 0 ||
47
+ !value.every((a) => typeof a === "string" && ARG.test(a))) {
48
+ throw new ProviderFileError(file, `'${where}' must be a non-empty list of plain arguments`);
49
+ }
50
+ return Object.freeze([...value]);
51
+ }
52
+ function errorName(error) {
53
+ if (error instanceof SyntaxError)
54
+ return "JSONDecodeError";
55
+ const code = error?.code;
56
+ if (code === "ENOENT")
57
+ return "FileNotFoundError";
58
+ if (code === "EACCES" || code === "EPERM")
59
+ return "PermissionError";
60
+ if (code === "EISDIR")
61
+ return "IsADirectoryError";
62
+ return "OSError";
63
+ }
64
+ function parse(file) {
65
+ let data;
66
+ try {
67
+ data = JSON.parse(readFileSync(file, "utf8"));
68
+ }
69
+ catch (error) {
70
+ throw new ProviderFileError(file, `cannot be read (${errorName(error)})`);
71
+ }
72
+ if (!isDict(data) || data.schema_version !== 1) {
73
+ throw new ProviderFileError(file, "unsupported schema_version");
74
+ }
75
+ const name = requireValue(data, "name", "str", file);
76
+ if (!IDENTIFIER.test(name) || path.basename(file, ".json") !== name) {
77
+ throw new ProviderFileError(file, "'name' must be an identifier matching the file name");
78
+ }
79
+ const volume = requireValue(data, "volume", "str", file);
80
+ if (!VOLUME.test(volume))
81
+ throw new ProviderFileError(file, "'volume' is not a valid volume name");
82
+ const home = requireValue(data, "home", "str", file);
83
+ if (!home.startsWith("/")) {
84
+ throw new ProviderFileError(file, "'home' must be an absolute container path");
85
+ }
86
+ const port = requireValue(data, "default_port", "int", file);
87
+ if (port < 1024 || port > 65535) {
88
+ throw new ProviderFileError(file, "'default_port' must be between 1024 and 65535");
89
+ }
90
+ const login = requireValue(data, "login", "dict", file);
91
+ const methods = new Map();
92
+ for (const [methodName, method] of Object.entries(requireValue(login, "methods", "dict", file))) {
93
+ if (!IDENTIFIER.test(methodName) || !isDict(method)) {
94
+ throw new ProviderFileError(file, `invalid login method ${repr(methodName)}`);
95
+ }
96
+ const secret = method.secret_stdin;
97
+ methods.set(methodName, {
98
+ name: methodName,
99
+ description: requireValue(method, "description", "str", file),
100
+ command: command(method.command, file, `login.methods.${methodName}`),
101
+ secretStdin: typeof secret === "string" ? secret : null,
102
+ });
103
+ }
104
+ const defaultMethod = requireValue(login, "default_method", "str", file);
105
+ if (!methods.has(defaultMethod)) {
106
+ throw new ProviderFileError(file, "'login.default_method' is not one of the methods");
107
+ }
108
+ let linkPattern;
109
+ try {
110
+ linkPattern = new RegExp(requireValue(login, "link_pattern", "str", file), "g");
111
+ }
112
+ catch (error) {
113
+ if (error instanceof ProviderFileError)
114
+ throw error;
115
+ throw new ProviderFileError(file, "'login.link_pattern' is not a valid regex");
116
+ }
117
+ const status = requireValue(data, "status", "dict", file);
118
+ const logout = requireValue(data, "logout", "dict", file);
119
+ const docsUrl = data.docs_url;
120
+ return Object.freeze({
121
+ name,
122
+ displayName: requireValue(data, "display_name", "str", file),
123
+ driver: requireValue(data, "driver", "str", file),
124
+ defaultPort: port,
125
+ volume,
126
+ home,
127
+ exampleModel: requireValue(data, "example_model", "str", file),
128
+ loginMethods: methods,
129
+ defaultLoginMethod: defaultMethod,
130
+ linkPattern,
131
+ loginTimeout: requireValue(login, "timeout_seconds", "int", file),
132
+ statusCommand: command(status.command, file, "status.command"),
133
+ loggedInExitCode: requireValue(status, "logged_in_exit_code", "int", file),
134
+ logoutCommand: command(logout.command, file, "logout.command"),
135
+ docsUrl: typeof docsUrl === "string" ? docsUrl : null,
136
+ });
137
+ }
138
+ const cache = new Map();
139
+ /** All providers by name, sorted by name. Cached per directory. */
140
+ export function loadProviders(directory) {
141
+ const dir = directory ?? path.join(sharedDir(), "providers");
142
+ const cached = cache.get(dir);
143
+ if (cached)
144
+ return cached;
145
+ let names = [];
146
+ try {
147
+ names = sortedStrings(readdirSync(dir).filter((f) => f.endsWith(".json")));
148
+ }
149
+ catch {
150
+ names = [];
151
+ }
152
+ const providers = new Map();
153
+ for (const provider of names.map((f) => parse(path.join(dir, f)))) {
154
+ providers.set(provider.name, provider);
155
+ }
156
+ if (providers.size === 0) {
157
+ throw new CliError("No provider definitions found.", {
158
+ hint: "This is a packaging bug; please report it.",
159
+ });
160
+ }
161
+ const volumes = [...providers.values()].map((p) => p.volume);
162
+ if (new Set(volumes).size !== volumes.length) {
163
+ throw new CliError("Two providers share a login volume.", { hint: "Please report this bug." });
164
+ }
165
+ const sorted = new Map(sortedStrings(providers.keys()).map((n) => [n, providers.get(n)]));
166
+ cache.set(dir, sorted);
167
+ return sorted;
168
+ }
169
+ export function getProvider(name) {
170
+ const providers = loadProviders();
171
+ const provider = providers.get(name);
172
+ if (provider === undefined) {
173
+ throw new UsageError(`Unknown provider ${repr(name)}.`, {
174
+ hint: `Choose one of: ${[...providers.keys()].join(", ")}.`,
175
+ });
176
+ }
177
+ return provider;
178
+ }
package/dist/redact.js ADDED
@@ -0,0 +1,33 @@
1
+ /** Mask secrets in anything the CLI prints (including --verbose output). */
2
+ export const MASK = "[REDACTED]";
3
+ const PATTERNS = [
4
+ [/\b(bearer)\s+[A-Za-z0-9._~+/=-]+/gi, `$1 ${MASK}`],
5
+ [/\bsk-[A-Za-z0-9_-]{8,}/g, MASK],
6
+ [/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]*/g, MASK],
7
+ // The server's key list: only values that look like keys, not "$(...)" hints.
8
+ [/\b(MODELMUX_API_KEYS=)[A-Za-z0-9_,-]{16,}/g, `$1${MASK}`],
9
+ ];
10
+ const secrets = new Set();
11
+ /** Mask these exact values everywhere from now on (e.g. the server API key). */
12
+ export function registerSecret(...values) {
13
+ for (const value of values) {
14
+ if (value.length >= 8)
15
+ secrets.add(value);
16
+ }
17
+ }
18
+ export function registered() {
19
+ return new Set(secrets);
20
+ }
21
+ /** Forget every registered secret (tests). */
22
+ export function clearSecrets() {
23
+ secrets.clear();
24
+ }
25
+ export function redact(text) {
26
+ for (const secret of [...secrets].sort((a, b) => b.length - a.length)) {
27
+ text = text.split(secret).join(MASK);
28
+ }
29
+ for (const [pattern, replacement] of PATTERNS) {
30
+ text = text.replace(pattern, replacement);
31
+ }
32
+ return text;
33
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The release this CLI belongs to, from `shared/release.json`.
3
+ *
4
+ * The release workflow writes the server image pinned by digest
5
+ * (`ghcr.io/smit153/modelmux@sha256:...`) into that file before building, so
6
+ * every CLI implementation (Python and Node) runs the same image. In a
7
+ * development checkout `image` is null: pass `--image` instead.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { CliError } from "./errors.js";
12
+ import { sharedDir } from "./providers.js";
13
+ let cached = null;
14
+ export function releaseData() {
15
+ if (cached !== null)
16
+ return cached;
17
+ let data;
18
+ try {
19
+ data = JSON.parse(readFileSync(path.join(sharedDir(), "release.json"), "utf8"));
20
+ }
21
+ catch {
22
+ throw new CliError("The release data is missing or damaged.", {
23
+ hint: "Please report this bug.",
24
+ });
25
+ }
26
+ if (typeof data !== "object" ||
27
+ data === null ||
28
+ Array.isArray(data) ||
29
+ typeof data.version !== "string") {
30
+ throw new CliError("The release data is invalid.", { hint: "Please report this bug." });
31
+ }
32
+ const record = data;
33
+ const image = record.image ?? null;
34
+ if (image !== null && !(typeof image === "string" && image.includes("@sha256:"))) {
35
+ throw new CliError("The pinned server image is not pinned by digest.", {
36
+ hint: "Please report this bug.",
37
+ });
38
+ }
39
+ cached = { version: record.version, image: typeof image === "string" ? image : null };
40
+ return cached;
41
+ }
42
+ /** Forget the cached release data (tests). */
43
+ export function resetReleaseCache() {
44
+ cached = null;
45
+ }
46
+ function readPinnedImage() {
47
+ return releaseData().image;
48
+ }
49
+ /** Replaceable in tests. */
50
+ export const release = {
51
+ pinnedImage: readPinnedImage,
52
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The API key clients use to call ModelMux (not a provider credential).
3
+ *
4
+ * Generated once with `crypto`, stored in `secrets.env` with mode 0600 in the
5
+ * format compose reads as an env file. The value is registered for redaction
6
+ * as soon as it is loaded, so it never appears in output unless the user
7
+ * explicitly asks for it (`modelmux key show`).
8
+ */
9
+ import { randomBytes } from "node:crypto";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import path from "node:path";
12
+ import { CliError } from "./errors.js";
13
+ import { tighten, writePrivate } from "./files.js";
14
+ import { ensurePrivateDir } from "./paths.js";
15
+ import { registerSecret } from "./redact.js";
16
+ export const SECRETS_FILE = "secrets.env";
17
+ export const VARIABLE = "MODELMUX_API_KEYS";
18
+ export const KEY_BYTES = 32; // base64url(32 bytes) -> 43 characters; the server requires >= 32
19
+ const LINE = new RegExp(`^${VARIABLE}=([A-Za-z0-9_-]{32,})$`);
20
+ export class SecretsFileError extends CliError {
21
+ constructor(file) {
22
+ super("The saved ModelMux API key is unreadable or damaged.", {
23
+ hint: `Delete ${file} and run 'modelmux up' to create a new key ` +
24
+ "(clients using the old key will need the new one).",
25
+ });
26
+ }
27
+ }
28
+ export function readApiKey(directory) {
29
+ const file = path.join(directory, SECRETS_FILE);
30
+ if (!existsSync(file))
31
+ return null;
32
+ const fixed = tighten(file);
33
+ let lines;
34
+ try {
35
+ lines = readFileSync(file, "utf8")
36
+ .split(/\r\n|\r|\n/)
37
+ .map((ln) => ln.trim())
38
+ .filter((ln) => ln !== "");
39
+ }
40
+ catch {
41
+ throw new SecretsFileError(file);
42
+ }
43
+ const match = lines.length === 1 ? LINE.exec(lines[0]) : null;
44
+ if (match === null)
45
+ throw new SecretsFileError(file);
46
+ const value = match[1];
47
+ registerSecret(value);
48
+ return { value, path: file, created: false, permissionsFixed: fixed };
49
+ }
50
+ /** Return the saved key, creating one on first use. */
51
+ export function ensureApiKey(directory) {
52
+ const existing = readApiKey(directory);
53
+ if (existing !== null)
54
+ return existing;
55
+ ensurePrivateDir(directory);
56
+ const value = randomBytes(KEY_BYTES).toString("base64url");
57
+ registerSecret(value);
58
+ const file = path.join(directory, SECRETS_FILE);
59
+ writePrivate(file, `${VARIABLE}=${value}\n`);
60
+ return { value, path: file, created: true, permissionsFixed: false };
61
+ }
package/dist/stack.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * The ModelMux containers on this machine: image, compose file, login volumes.
3
+ *
4
+ * All containers belong to the compose project `modelmux`. Each provider has
5
+ * one service (`modelmux-<provider>`) and one *external* login volume
6
+ * (`modelmux_<provider>-home`) that only `modelmux logout` deletes.
7
+ */
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { pyJson } from "./config.js";
11
+ import { PULL_TIMEOUT } from "./docker.js";
12
+ import { CliError, DockerError, UsageError } from "./errors.js";
13
+ import { writePrivate } from "./files.js";
14
+ import { sharedDir } from "./providers.js";
15
+ import { release } from "./release.js";
16
+ import { repr, sortedStrings } from "./text.js";
17
+ export const PROJECT = "modelmux";
18
+ export const COMPOSE_FILE = "compose.yaml"; // JSON content; YAML is a superset of JSON
19
+ export const CONTAINER_PORT = 8000;
20
+ // tmpfs mount spec inside helper containers (not a host path)
21
+ export const CONTAINER_TMP = "/tmp:rw,noexec,nosuid,nodev,size=64m";
22
+ const IMAGE_REF = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,254}$/;
23
+ export function volumeName(provider) {
24
+ return `${PROJECT}_${provider.volume}`;
25
+ }
26
+ export function serviceName(provider) {
27
+ return `${PROJECT}-${provider.name}`;
28
+ }
29
+ /** The image to run: --image, then the saved override, then the pinned release image. */
30
+ export function resolveImage(config, override = null) {
31
+ const image = override || config.image || release.pinnedImage();
32
+ if (!image) {
33
+ throw new UsageError("This development build of modelmux has no pinned server image.", {
34
+ hint: "Pass one explicitly, e.g. --image modelmux:dev (it is remembered).",
35
+ });
36
+ }
37
+ if (!IMAGE_REF.test(image))
38
+ throw new UsageError(`${repr(image)} is not a valid image reference.`);
39
+ return image;
40
+ }
41
+ export function serviceTemplate() {
42
+ const file = path.join(sharedDir(), "templates", "compose-service.json");
43
+ const data = JSON.parse(readFileSync(file, "utf8"));
44
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
45
+ throw new CliError("The compose template is invalid.", { hint: "Please report this bug." });
46
+ }
47
+ return data;
48
+ }
49
+ export function buildCompose(providers, config, image, secretsFile) {
50
+ const template = serviceTemplate();
51
+ const labels = (template.labels ?? {});
52
+ const services = {};
53
+ const volumes = {};
54
+ for (const name of sortedStrings(providers.keys())) {
55
+ const provider = providers.get(name);
56
+ services[serviceName(provider)] = {
57
+ ...template,
58
+ image,
59
+ environment: { MODELMUX_DRIVER: provider.driver },
60
+ env_file: [secretsFile],
61
+ volumes: [`${provider.volume}:${provider.home}`],
62
+ ports: [`127.0.0.1:${config.port(name)}:${CONTAINER_PORT}`],
63
+ networks: [PROJECT],
64
+ labels: { ...labels, "io.modelmux.provider": name },
65
+ };
66
+ volumes[provider.volume] = { external: true, name: volumeName(provider) };
67
+ }
68
+ return {
69
+ name: PROJECT,
70
+ services,
71
+ volumes,
72
+ networks: { [PROJECT]: { driver: "bridge" } },
73
+ };
74
+ }
75
+ /** Write the compose file. Returns true if its content changed. */
76
+ export function writeCompose(directory, compose) {
77
+ const file = path.join(directory, COMPOSE_FILE);
78
+ const header = "# Generated by modelmux. Do not edit: changes are overwritten by 'modelmux up'.\n";
79
+ const content = header + pyJson(compose) + "\n";
80
+ try {
81
+ if (readFileSync(file, "utf8") === content)
82
+ return false;
83
+ }
84
+ catch {
85
+ // missing or unreadable: write it
86
+ }
87
+ writePrivate(file, content);
88
+ return true;
89
+ }
90
+ export class Stack {
91
+ docker;
92
+ home;
93
+ constructor(docker, home) {
94
+ this.docker = docker;
95
+ this.home = home;
96
+ }
97
+ get composeFile() {
98
+ return path.join(this.home, COMPOSE_FILE);
99
+ }
100
+ // ------------------------------------------------------------ image
101
+ async imagePresent(image) {
102
+ return (await this.docker.run(["image", "inspect", image], { check: false })).ok;
103
+ }
104
+ /** Pull `image` if it is not present. Returns true if it was pulled. */
105
+ async ensureImage(image) {
106
+ if (await this.imagePresent(image))
107
+ return false;
108
+ try {
109
+ await this.docker.run(["pull", image], { timeout: PULL_TIMEOUT });
110
+ }
111
+ catch (error) {
112
+ if (!(error instanceof DockerError) || image.split("@", 1)[0].includes("/"))
113
+ throw error;
114
+ // A local-only name such as modelmux:dev.
115
+ throw new DockerError(`The image ${image} is not on this machine and could not be downloaded.`, { hint: `Build it first: docker build -f docker/Dockerfile -t ${image} .` });
116
+ }
117
+ return true;
118
+ }
119
+ // ------------------------------------------------------------ volumes
120
+ async volumeExists(provider) {
121
+ return (await this.docker.run(["volume", "inspect", volumeName(provider)], { check: false })).ok;
122
+ }
123
+ async ensureVolume(provider) {
124
+ if (!(await this.volumeExists(provider))) {
125
+ await this.docker.run([
126
+ "volume", "create",
127
+ "--label", "io.modelmux.managed=true",
128
+ "--label", `io.modelmux.provider=${provider.name}`,
129
+ volumeName(provider),
130
+ ]);
131
+ }
132
+ }
133
+ // ------------------------------------------------------------ helper containers
134
+ /**
135
+ * `docker run` arguments for a short-lived, hardened helper container.
136
+ *
137
+ * `extra` adds run options such as `-it` or `--name`.
138
+ */
139
+ helperArgs(provider, image, command, extra = []) {
140
+ return [
141
+ "run", "--rm", ...extra,
142
+ "--read-only", "--tmpfs", CONTAINER_TMP,
143
+ "--cap-drop", "ALL", "--security-opt", "no-new-privileges:true",
144
+ "--user", "10001:10001",
145
+ "--label", "io.modelmux.managed=true",
146
+ "-v", `${volumeName(provider)}:${provider.home}`,
147
+ "-e", `HOME=${provider.home}`,
148
+ "--entrypoint", command[0],
149
+ image, ...command.slice(1),
150
+ ];
151
+ }
152
+ async loggedIn(provider, image) {
153
+ if (!(await this.volumeExists(provider)))
154
+ return false;
155
+ const result = await this.docker.run(this.helperArgs(provider, image, provider.statusCommand), {
156
+ check: false,
157
+ timeout: 90,
158
+ });
159
+ return result.returncode === provider.loggedInExitCode;
160
+ }
161
+ // ------------------------------------------------------------ compose
162
+ compose(args, options = {}) {
163
+ return this.docker.compose(PROJECT, this.composeFile, args, options);
164
+ }
165
+ /** Current containers of the project, by service name. Empty if none. */
166
+ async states() {
167
+ if (!existsSync(this.composeFile))
168
+ return new Map();
169
+ const result = await this.compose(["ps", "--all", "--format", "json"], { check: false });
170
+ if (!result.ok)
171
+ return new Map();
172
+ return parsePs(result.stdout);
173
+ }
174
+ }
175
+ /** `docker compose ps --format json` output: one object per line, or one array. */
176
+ export function parsePs(output) {
177
+ const text = output.trim();
178
+ const states = new Map();
179
+ if (!text)
180
+ return states;
181
+ let items;
182
+ try {
183
+ items = text.startsWith("[")
184
+ ? JSON.parse(text)
185
+ : text
186
+ .split(/\r\n|\r|\n/)
187
+ .filter((line) => line.trim())
188
+ .map((line) => JSON.parse(line));
189
+ }
190
+ catch {
191
+ return states;
192
+ }
193
+ if (!Array.isArray(items))
194
+ return states;
195
+ for (const item of items) {
196
+ if (typeof item !== "object" || item === null || Array.isArray(item))
197
+ continue;
198
+ const record = item;
199
+ if (typeof record.Service !== "string")
200
+ continue;
201
+ const code = record.ExitCode;
202
+ states.set(record.Service, {
203
+ service: record.Service,
204
+ state: pyStr(record.State ?? ""),
205
+ health: pyStr(record.Health ?? ""),
206
+ exitCode: Number.isInteger(code) ? code : null,
207
+ });
208
+ }
209
+ return states;
210
+ }
211
+ /** Python's `str()` for the JSON scalars Docker reports. */
212
+ function pyStr(value) {
213
+ if (value === null)
214
+ return "None";
215
+ if (value === true)
216
+ return "True";
217
+ if (value === false)
218
+ return "False";
219
+ return typeof value === "string" ? value : JSON.stringify(value);
220
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Find the login link in a provider CLI's terminal output.
3
+ *
4
+ * Output arrives in arbitrary chunks and contains escape sequences: colours
5
+ * (CSI) and OSC 8 hyperlinks, which carry the URL a second time. Escapes are
6
+ * removed from a rolling buffer, and a link is only reported once it is
7
+ * complete, i.e. followed by whitespace, so a URL split across reads is never
8
+ * opened half-finished.
9
+ */
10
+ const OSC = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
11
+ const CSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
12
+ const OTHER_ESC = /\x1b[@-Z\\-_]/g;
13
+ const BUFFER_LIMIT = 64 * 1024;
14
+ // Python's str.isspace() for the characters a terminal can produce.
15
+ const SPACE = /^[\s\x1c-\x1f\x85]$/u;
16
+ /** Remove escape sequences (bytes are handled as latin1 so offsets stay byte offsets). */
17
+ export function stripEscapes(data) {
18
+ let text = data.toString("latin1");
19
+ text = text.replace(OSC, "").replace(CSI, "").replace(OTHER_ESC, "");
20
+ return new TextDecoder("utf-8").decode(Buffer.from(text, "latin1"));
21
+ }
22
+ export class LinkScanner {
23
+ pattern;
24
+ raw = Buffer.alloc(0);
25
+ link = null;
26
+ constructor(pattern) {
27
+ this.pattern = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g");
28
+ }
29
+ /** Add output. Returns the link the first time a complete one is seen. */
30
+ feed(data) {
31
+ if (this.link !== null)
32
+ return null;
33
+ const joined = Buffer.concat([this.raw, data]);
34
+ this.raw = joined.subarray(Math.max(0, joined.length - BUFFER_LIMIT));
35
+ const text = stripEscapes(this.raw);
36
+ this.pattern.lastIndex = 0;
37
+ for (const match of text.matchAll(this.pattern)) {
38
+ const end = match.index + match[0].length;
39
+ if (end < text.length && SPACE.test(text[end])) {
40
+ this.link = match[0];
41
+ return this.link;
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+ }