modelmux-cli 0.0.1 → 0.3.1

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 +23 -1
  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 +34 -2
@@ -0,0 +1,72 @@
1
+ /** `modelmux down`, `logs` and `status`. */
2
+ import { existsSync } from "node:fs";
3
+ import { makeContext } from "../context.js";
4
+ import { CliError } from "../errors.js";
5
+ import * as health from "../health.js";
6
+ import { resolveImage, serviceName } from "../stack.js";
7
+ export const NOT_SET_UP = "ModelMux is not set up on this machine yet.";
8
+ export async function down(_args, console) {
9
+ const ctx = makeContext(console);
10
+ if (!existsSync(ctx.stack.composeFile)) {
11
+ console.success("ModelMux is not running.");
12
+ return 0;
13
+ }
14
+ await ctx.stack.compose(["down"], { timeout: 120 });
15
+ console.success("Stopped ModelMux. Your logins are kept.");
16
+ return 0;
17
+ }
18
+ export async function logs(args, console) {
19
+ const ctx = makeContext(console);
20
+ const stack = ctx.stack;
21
+ if (!existsSync(stack.composeFile))
22
+ throw new CliError(NOT_SET_UP, { hint: "Start it with: modelmux up" });
23
+ const services = args.provider ? [serviceName(ctx.providers.get(args.provider))] : [];
24
+ const follow = args.follow ? ["--follow"] : [];
25
+ return ctx.docker.passthrough([
26
+ "compose", "-p", "modelmux", "-f", stack.composeFile,
27
+ "logs", args.provider ? "--no-log-prefix" : "--timestamps",
28
+ "--tail", String(Math.max(args.tail, 0)), ...follow, ...services,
29
+ ]);
30
+ }
31
+ export async function status(_args, console) {
32
+ const ctx = makeContext(console);
33
+ await ctx.docker.checkAvailable();
34
+ const stack = ctx.stack;
35
+ const states = await stack.states();
36
+ let image;
37
+ try {
38
+ image = resolveImage(ctx.config);
39
+ }
40
+ catch (error) {
41
+ if (!(error instanceof CliError))
42
+ throw error;
43
+ image = null;
44
+ }
45
+ const rows = [["PROVIDER", "CONTAINER", "LOGIN", "HEALTH", "URL"]];
46
+ for (const [name, provider] of ctx.providers) {
47
+ const state = states.get(serviceName(provider));
48
+ const port = ctx.config.port(name);
49
+ const container = state ? state.state : "not started";
50
+ const login = image === null ? "?" : (await stack.loggedIn(provider, image)) ? "yes" : "no";
51
+ let healthy;
52
+ if (state && state.state === "running")
53
+ healthy = (await health.server.ready(port)) ? "ready" : "starting";
54
+ else
55
+ healthy = "-";
56
+ const url = healthy === "ready" ? `${health.baseUrl(port)}/v1` : "-";
57
+ rows.push([name, container, login, healthy, url]);
58
+ }
59
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((row) => row[i].length)));
60
+ for (const row of rows) {
61
+ console.print(row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd());
62
+ }
63
+ if (image === null)
64
+ console.step("No server image chosen yet: run 'modelmux up' first.");
65
+ for (const [name, provider] of ctx.providers) {
66
+ const state = states.get(serviceName(provider));
67
+ if (state && ["exited", "dead"].includes(state.state)) {
68
+ console.step(`${provider.displayName} has stopped. If its login expired: modelmux login ${name}`);
69
+ }
70
+ }
71
+ return 0;
72
+ }
@@ -0,0 +1,62 @@
1
+ /** `modelmux key show` and `modelmux config <target>`. */
2
+ import { KEY_ENV, loadTemplate, render } from "../clientconfig.js";
3
+ import { makeContext } from "../context.js";
4
+ import { CliError } from "../errors.js";
5
+ import * as health from "../health.js";
6
+ import { getProvider } from "../providers.js";
7
+ import { readApiKey } from "../secretsStore.js";
8
+ function key(home) {
9
+ const found = readApiKey(home);
10
+ if (found === null) {
11
+ throw new CliError("ModelMux is not set up yet, so there is no API key.", { hint: "Run: modelmux up" });
12
+ }
13
+ return found;
14
+ }
15
+ export async function keyShow(_args, console) {
16
+ const ctx = makeContext(console);
17
+ console.reveal(key(ctx.home).value);
18
+ console.note("Treat this like a password. Clients send it as: Authorization: Bearer <key>");
19
+ return 0;
20
+ }
21
+ export async function modelsFor(port, apiKey) {
22
+ const [status, body] = await health.server.getJson(port, "/v1/models", { apiKey });
23
+ if (status !== 200 || typeof body !== "object" || body === null || Array.isArray(body))
24
+ return [];
25
+ const data = body.data;
26
+ if (!Array.isArray(data))
27
+ return [];
28
+ return data
29
+ .filter((m) => typeof m === "object" && m !== null && !Array.isArray(m) && typeof m.id === "string")
30
+ .map((m) => m.id);
31
+ }
32
+ export async function config(args, console) {
33
+ const ctx = makeContext(console);
34
+ const template = loadTemplate(args.target);
35
+ const apiKey = key(ctx.home);
36
+ const names = args.provider ? [args.provider] : [...ctx.providers.keys()];
37
+ const targets = [];
38
+ for (const name of names) {
39
+ const provider = getProvider(name);
40
+ const port = ctx.config.port(name);
41
+ let models = await modelsFor(port, apiKey.value);
42
+ if (!models.length) {
43
+ models = [provider.exampleModel];
44
+ console.note(`${provider.displayName} is not running; using its example model '${provider.exampleModel}'.`);
45
+ }
46
+ targets.push({ provider: name, displayName: provider.displayName, baseUrl: `${health.baseUrl(port)}/v1`, models });
47
+ }
48
+ const text = render(template, targets, args.reveal_key ? apiKey.value : null);
49
+ if (args.reveal_key) {
50
+ console.reveal(text.replace(/\n+$/, ""));
51
+ }
52
+ else {
53
+ console.print(text.replace(/\n+$/, ""));
54
+ if (template.keyStyle !== "env") {
55
+ console.note(`Set ${KEY_ENV} first: export ${KEY_ENV}=$(modelmux key show)`);
56
+ }
57
+ else {
58
+ console.note("Add the key yourself (modelmux key show), or use --reveal-key.");
59
+ }
60
+ }
61
+ return 0;
62
+ }
@@ -0,0 +1,194 @@
1
+ /** `modelmux doctor`: diagnose common problems, each with a suggested fix. */
2
+ import { existsSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { makeContext } from "../context.js";
5
+ import { CliError, DockerError } from "../errors.js";
6
+ import * as health from "../health.js";
7
+ import { ports } from "../ports.js";
8
+ import { readApiKey, SECRETS_FILE } from "../secretsStore.js";
9
+ import { resolveImage, serviceName } from "../stack.js";
10
+ import * as updates from "../updates.js";
11
+ import { VERSION } from "../version.js";
12
+ export class Report {
13
+ console;
14
+ failures = 0;
15
+ warnings = 0;
16
+ constructor(console) {
17
+ this.console = console;
18
+ }
19
+ ok(text) {
20
+ this.console.success(text);
21
+ }
22
+ warn(text, hint = null) {
23
+ this.warnings += 1;
24
+ this.console.warn(text + (hint ? `\n ${hint}` : ""));
25
+ }
26
+ fail(text, hint = null) {
27
+ this.failures += 1;
28
+ this.console.error(text, hint);
29
+ }
30
+ }
31
+ /** The server's own reason for refusing to start, from its recent logs. */
32
+ export async function startupFailureReason(ctx, provider) {
33
+ const result = await ctx.stack.compose(["logs", "--no-log-prefix", "--tail", "200", serviceName(provider)], { check: false });
34
+ let reason = null;
35
+ for (const line of result.stdout.split(/\r\n|\r|\n/)) {
36
+ let entry;
37
+ try {
38
+ entry = JSON.parse(line);
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ if (typeof entry === "object" && entry !== null && !Array.isArray(entry)) {
44
+ const record = entry;
45
+ if (record.event === "probe_failed") {
46
+ reason = typeof record.reason === "string" ? record.reason : reason;
47
+ }
48
+ }
49
+ }
50
+ return reason;
51
+ }
52
+ async function checkDocker(ctx, report) {
53
+ let version;
54
+ try {
55
+ version = await ctx.docker.checkAvailable();
56
+ }
57
+ catch (error) {
58
+ if (!(error instanceof DockerError))
59
+ throw error;
60
+ report.fail(error.message, error.hint);
61
+ return false;
62
+ }
63
+ report.ok(`Docker ${version} is running, with Compose v2`);
64
+ return true;
65
+ }
66
+ async function checkNetworkAndVersion(report) {
67
+ if (await updates.registry.registryReachable()) {
68
+ report.ok("The image registry (ghcr.io) is reachable");
69
+ }
70
+ else {
71
+ report.warn("Cannot reach ghcr.io (needed to download the server image)", "Check your connection or proxy (HTTPS_PROXY, and Docker's proxy settings).");
72
+ }
73
+ const latest = await updates.registry.latestVersion();
74
+ if (latest === null) {
75
+ report.warn("Could not check npm for a newer modelmux-cli");
76
+ }
77
+ else if (updates.newerAvailable(VERSION, latest)) {
78
+ report.warn(`modelmux-cli ${latest} is available (you have ${VERSION})`, updates.UPGRADE_HINT);
79
+ }
80
+ else {
81
+ report.ok(`modelmux-cli ${VERSION} is the latest version`);
82
+ }
83
+ }
84
+ function checkFiles(ctx, report) {
85
+ const keyPath = path.join(ctx.home, SECRETS_FILE);
86
+ if (!existsSync(keyPath)) {
87
+ report.fail("ModelMux is not set up on this machine yet", "Run: modelmux up");
88
+ return false;
89
+ }
90
+ if (process.platform !== "win32") {
91
+ for (const [file, mode] of [[ctx.home, 0o700], [keyPath, 0o600]]) {
92
+ if ((statSync(file).mode & 0o7777) !== mode) {
93
+ report.fail(`${file} is readable by other users`, `Fix it: chmod ${mode.toString(8)} '${file}'`);
94
+ return true;
95
+ }
96
+ }
97
+ }
98
+ report.ok(`Settings and API key are in ${ctx.home}`);
99
+ return true;
100
+ }
101
+ async function portConflict(ctx, provider, report) {
102
+ const port = ctx.config.port(provider.name);
103
+ if (await ports.portFree(port))
104
+ return false;
105
+ report.fail(`${provider.displayName}: port ${port} is used by another program`, `Choose another: modelmux up --port ${provider.name}=<port>`);
106
+ return true;
107
+ }
108
+ export function stoppedHint(provider, reason) {
109
+ if (reason && reason.includes("auth")) {
110
+ return `The login has expired or was revoked: modelmux login ${provider.name} --force`;
111
+ }
112
+ if (reason && ["version", "flag", "feature", "config key"].some((word) => reason.includes(word))) {
113
+ return "The CLI inside the image is not supported: modelmux upgrade";
114
+ }
115
+ return `See its logs: modelmux logs ${provider.name}`;
116
+ }
117
+ async function checkRunning(ctx, provider, apiKey, report) {
118
+ const { name, displayName: display } = provider;
119
+ const port = ctx.config.port(name);
120
+ if (!(await health.server.ready(port))) {
121
+ report.warn(`${display}: running but not ready yet`, `Watch it: modelmux logs ${name} -f`);
122
+ return;
123
+ }
124
+ const [status] = await health.server.getJson(port, "/v1/models", { apiKey });
125
+ if (status === 200) {
126
+ report.ok(`${display}: logged in, running and answering at ${health.baseUrl(port)}/v1`);
127
+ }
128
+ else if (status === 401) {
129
+ report.fail(`${display}: the server does not accept the saved API key`, "Recreate it with the current key: modelmux up");
130
+ }
131
+ else {
132
+ report.fail(`${display}: the API did not answer correctly (HTTP ${status})`, `See its logs: modelmux logs ${name}`);
133
+ }
134
+ }
135
+ async function checkProvider(ctx, provider, image, apiKey, report) {
136
+ const display = provider.displayName;
137
+ if (!(await ctx.stack.loggedIn(provider, image))) {
138
+ report.warn(`${display}: not logged in`, `Run: modelmux login ${provider.name}`);
139
+ await portConflict(ctx, provider, report);
140
+ return;
141
+ }
142
+ const state = (await ctx.stack.states()).get(serviceName(provider));
143
+ if (state === undefined) {
144
+ if (!(await portConflict(ctx, provider, report))) {
145
+ report.warn(`${display}: logged in but not running`, "Start it: modelmux up");
146
+ }
147
+ return;
148
+ }
149
+ if (state.state !== "running") {
150
+ const reason = await startupFailureReason(ctx, provider);
151
+ const detail = reason ? ` (${reason})` : "";
152
+ report.fail(`${display}: the server stopped${detail}`, stoppedHint(provider, reason));
153
+ return;
154
+ }
155
+ await checkRunning(ctx, provider, apiKey, report);
156
+ }
157
+ export async function run(_args, console) {
158
+ const ctx = makeContext(console);
159
+ const report = new Report(console);
160
+ const dockerOk = await checkDocker(ctx, report);
161
+ await checkNetworkAndVersion(report);
162
+ const setUp = checkFiles(ctx, report);
163
+ if (dockerOk && setUp) {
164
+ let image = null;
165
+ try {
166
+ image = resolveImage(ctx.config);
167
+ }
168
+ catch (error) {
169
+ if (!(error instanceof CliError))
170
+ throw error;
171
+ report.fail(error.message, error.hint);
172
+ }
173
+ if (image !== null) {
174
+ if (await ctx.stack.imagePresent(image))
175
+ report.ok(`Server image ${image} is present`);
176
+ else
177
+ report.warn(`Server image ${image} is not downloaded yet`, "Run: modelmux up");
178
+ const key = readApiKey(ctx.home);
179
+ for (const provider of ctx.providers.values()) {
180
+ await checkProvider(ctx, provider, image, key ? key.value : "", report);
181
+ }
182
+ }
183
+ }
184
+ console.print();
185
+ if (report.failures) {
186
+ console.error(`${report.failures} problem(s) found.`);
187
+ return 1;
188
+ }
189
+ if (report.warnings)
190
+ console.success(`No problems, ${report.warnings} note(s).`);
191
+ else
192
+ console.success("Everything looks good.");
193
+ return 0;
194
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `modelmux login <provider>`: guided login, then a real test.
3
+ *
4
+ * The provider's own login command runs in a short-lived, hardened helper
5
+ * container with only that provider's login volume. On Linux and macOS
6
+ * Docker gives it a terminal (`-it`) fed straight from the user's terminal,
7
+ * while its output passes through here so the login link can be spotted and
8
+ * opened in the browser. On Windows (or with `--raw`) the user's console is
9
+ * attached directly.
10
+ *
11
+ * Keystrokes, pasted codes and API keys are never stored or logged.
12
+ */
13
+ import { randomBytes } from "node:crypto";
14
+ import { openBrowser } from "../browser.js";
15
+ import { makeContext } from "../context.js";
16
+ import { CliError, DockerError, Interrupted, Timeout, UsageError } from "../errors.js";
17
+ import * as health from "../health.js";
18
+ import { interrupt, resetInterrupt } from "../interrupt.js";
19
+ import { ask as askLine, readSecret } from "../prompt.js";
20
+ import { getProvider } from "../providers.js";
21
+ import { registerSecret } from "../redact.js";
22
+ import { resolveImage, serviceName } from "../stack.js";
23
+ import { LinkScanner } from "../terminal.js";
24
+ import { repr } from "../text.js";
25
+ import * as upModule from "./up.js";
26
+ export const SECRET_LOGIN_TIMEOUT = 120.0;
27
+ /** Replaceable in tests. */
28
+ export const io = {
29
+ openBrowser,
30
+ readSecret,
31
+ ask: askLine,
32
+ stdinIsTTY: () => process.stdin.isTTY === true,
33
+ };
34
+ export function useTty(args) {
35
+ return process.platform !== "win32" && !args.raw;
36
+ }
37
+ export function chooseMethod(provider, requested) {
38
+ const name = requested || provider.defaultLoginMethod;
39
+ const method = provider.loginMethods.get(name);
40
+ if (method === undefined) {
41
+ const options = [...provider.loginMethods.values()].map((m) => `${m.name} (${m.description})`).join(", ");
42
+ throw new UsageError(`${provider.displayName} has no login method ${repr(name)}.`, {
43
+ hint: `Choose: ${options}`,
44
+ });
45
+ }
46
+ return method;
47
+ }
48
+ /**
49
+ * (Re)start the provider and wait for /health/ready: the server's startup
50
+ * check makes one tiny real request, so ready means the login works.
51
+ */
52
+ export async function ensureRunningAndTested(ctx, provider) {
53
+ const port = ctx.config.port(provider.name);
54
+ const state = (await ctx.stack.states()).get(serviceName(provider));
55
+ if (state !== undefined && state.state === "running" && (await health.server.ready(port))) {
56
+ ctx.console.success(`${provider.displayName} is running at ${health.baseUrl(port)}/v1`);
57
+ }
58
+ else {
59
+ await upModule.checkPorts(ctx, [provider]);
60
+ await ctx.stack.compose(["up", "-d", "--force-recreate", serviceName(provider)], {
61
+ timeout: upModule.COMPOSE_UP_TIMEOUT,
62
+ });
63
+ await upModule.startAndWait(ctx, provider);
64
+ }
65
+ ctx.console.success(`${provider.displayName} is logged in and tested.`);
66
+ ctx.console.step("Configure your tools: modelmux config litellm (or openai-python, langchain)");
67
+ }
68
+ export function linkHandler(provider, noBrowser) {
69
+ const scanner = new LinkScanner(provider.linkPattern);
70
+ return async (data) => {
71
+ const link = scanner.feed(data);
72
+ if (link === null)
73
+ return null;
74
+ if (noBrowser)
75
+ return Buffer.from("\r\n-> Open the link above in your browser.\r\n");
76
+ let opened = false;
77
+ try {
78
+ opened = await io.openBrowser(link);
79
+ }
80
+ catch {
81
+ opened = false; // no usable browser here
82
+ }
83
+ if (opened)
84
+ return Buffer.from("\r\n-> Opened your browser. If it did not open, use the link above.\r\n");
85
+ return Buffer.from("\r\n-> Could not open a browser here: open the link above yourself.\r\n");
86
+ };
87
+ }
88
+ /**
89
+ * Treat SIGTERM and SIGHUP (terminal closed) like Ctrl+C while logging in,
90
+ * so the helper container is always removed and the user sees "Cancelled.".
91
+ */
92
+ export async function cancelOnSignals(work) {
93
+ if (process.platform === "win32")
94
+ return work();
95
+ const signals = ["SIGTERM", "SIGHUP"];
96
+ const handler = () => interrupt();
97
+ for (const sig of signals)
98
+ process.on(sig, handler);
99
+ try {
100
+ return await work();
101
+ }
102
+ finally {
103
+ for (const sig of signals)
104
+ process.removeListener(sig, handler);
105
+ }
106
+ }
107
+ async function interactiveLogin(ctx, provider, method, image, args) {
108
+ if (!io.stdinIsTTY()) {
109
+ throw new UsageError("Logging in needs an interactive terminal.", {
110
+ hint: `Run 'modelmux login ${provider.name}' in a terminal.`,
111
+ });
112
+ }
113
+ const name = `modelmux-login-${provider.name}-${randomBytes(4).toString("hex")}`;
114
+ const runArgs = ctx.stack.helperArgs(provider, image, method.command, ["-it", "--name", name]);
115
+ try {
116
+ return await cancelOnSignals(async () => {
117
+ if (useTty(args)) {
118
+ return ctx.docker.runTty(runArgs, {
119
+ onOutput: linkHandler(provider, args.no_browser),
120
+ timeout: provider.loginTimeout,
121
+ });
122
+ }
123
+ ctx.console.step("Follow the instructions below; open the link they show in your browser.");
124
+ return ctx.docker.passthrough(runArgs, { timeout: provider.loginTimeout });
125
+ });
126
+ }
127
+ catch (error) {
128
+ if (error instanceof Timeout || error instanceof DockerError) {
129
+ await remove(ctx, name);
130
+ if (error instanceof DockerError && !error.message.includes("in time"))
131
+ throw error;
132
+ throw new CliError("The login timed out.", { hint: `Start again: modelmux login ${provider.name}` });
133
+ }
134
+ if (error instanceof Interrupted)
135
+ await remove(ctx, name);
136
+ throw error;
137
+ }
138
+ }
139
+ async function secretLogin(ctx, provider, method, image) {
140
+ const prompt = method.secretStdin || "Secret";
141
+ const secret = (await io.readSecret(`${prompt} (input is hidden): `)).trim();
142
+ if (!secret)
143
+ throw new UsageError(`No ${prompt} entered.`);
144
+ registerSecret(secret);
145
+ const runArgs = ctx.stack.helperArgs(provider, image, method.command, ["-i"]);
146
+ const result = await ctx.docker.run(runArgs, {
147
+ inputText: secret + "\n",
148
+ check: false,
149
+ timeout: SECRET_LOGIN_TIMEOUT,
150
+ });
151
+ return result.returncode;
152
+ }
153
+ /** Remove the helper container, even after Ctrl+C. */
154
+ async function remove(ctx, container) {
155
+ resetInterrupt(); // the cleanup itself must not be cancelled
156
+ await ctx.docker.run(["rm", "-f", container], { check: false });
157
+ }
158
+ export async function run(args, console) {
159
+ const ctx = makeContext(console);
160
+ const provider = getProvider(args.provider);
161
+ const method = chooseMethod(provider, args.method);
162
+ const image = resolveImage(ctx.config);
163
+ await upModule.prepare(ctx, image);
164
+ const stack = ctx.stack;
165
+ if (!args.force && (await stack.loggedIn(provider, image))) {
166
+ console.success(`${provider.displayName} is already logged in (use --force to log in again).`);
167
+ await ensureRunningAndTested(ctx, provider);
168
+ return 0;
169
+ }
170
+ console.step(`Logging in to ${provider.displayName}: ${method.description}.`);
171
+ if (method.secretStdin)
172
+ await secretLogin(ctx, provider, method, image);
173
+ else
174
+ await interactiveLogin(ctx, provider, method, image, args);
175
+ console.print();
176
+ if (!(await stack.loggedIn(provider, image))) {
177
+ throw new CliError(`The ${provider.displayName} login did not complete.`, {
178
+ hint: `Try again: modelmux login ${provider.name} (add --raw to see only the ` +
179
+ "provider's own output).",
180
+ });
181
+ }
182
+ console.success(`Logged in to ${provider.displayName}.`);
183
+ await ensureRunningAndTested(ctx, provider);
184
+ return 0;
185
+ }
@@ -0,0 +1,57 @@
1
+ /** `modelmux logout <provider>`: stop it and delete its saved login. */
2
+ import { existsSync } from "node:fs";
3
+ import { makeContext } from "../context.js";
4
+ import { CliError, UsageError } from "../errors.js";
5
+ import { ask } from "../prompt.js";
6
+ import { getProvider } from "../providers.js";
7
+ import { resolveImage, serviceName, volumeName } from "../stack.js";
8
+ /** Replaceable in tests. */
9
+ export const io = {
10
+ ask,
11
+ stdinIsTTY: () => process.stdin.isTTY === true,
12
+ };
13
+ export async function confirm(question) {
14
+ if (!io.stdinIsTTY()) {
15
+ throw new UsageError("Logout needs confirmation.", {
16
+ hint: "Add --yes to confirm when not in a terminal.",
17
+ });
18
+ }
19
+ return ["y", "yes"].includes((await io.ask(`${question} [y/N] `)).trim().toLowerCase());
20
+ }
21
+ export async function run(args, console) {
22
+ const ctx = makeContext(console);
23
+ const provider = getProvider(args.provider);
24
+ const stack = ctx.stack;
25
+ if (!(await stack.volumeExists(provider))) {
26
+ console.success(`${provider.displayName} is not logged in; nothing to remove.`);
27
+ return 0;
28
+ }
29
+ if (!args.yes && !(await confirm(`Remove the saved ${provider.displayName} login?`))) {
30
+ console.step("Nothing changed.");
31
+ return 0;
32
+ }
33
+ if (existsSync(stack.composeFile)) {
34
+ await stack.compose(["rm", "--stop", "--force", serviceName(provider)], { check: false });
35
+ }
36
+ let image;
37
+ try {
38
+ image = resolveImage(ctx.config);
39
+ }
40
+ catch (error) {
41
+ if (!(error instanceof CliError))
42
+ throw error;
43
+ image = null;
44
+ }
45
+ if (image !== null) {
46
+ // Best effort: lets the provider revoke the token, not just forget it.
47
+ const result = await ctx.docker.run(stack.helperArgs(provider, image, provider.logoutCommand), {
48
+ check: false,
49
+ timeout: 60,
50
+ });
51
+ if (!result.ok)
52
+ console.detail("The provider's own logout did not succeed; removing the login anyway.");
53
+ }
54
+ await ctx.docker.run(["volume", "rm", volumeName(provider)]);
55
+ console.success(`Logged out of ${provider.displayName} and removed its saved login.`);
56
+ return 0;
57
+ }