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.
- package/LICENSE +202 -0
- package/README.md +24 -2
- 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 +41 -4
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/** `modelmux up`: prepare everything and start the logged-in providers. */
|
|
2
|
+
import { saveConfig } from "../config.js";
|
|
3
|
+
import { makeContext } from "../context.js";
|
|
4
|
+
import { CliError, UsageError } from "../errors.js";
|
|
5
|
+
import * as health from "../health.js";
|
|
6
|
+
import { ensurePrivateDir } from "../paths.js";
|
|
7
|
+
import { ports } from "../ports.js";
|
|
8
|
+
import { ensureApiKey } from "../secretsStore.js";
|
|
9
|
+
import { buildCompose, resolveImage, serviceName, writeCompose } from "../stack.js";
|
|
10
|
+
import { repr } from "../text.js";
|
|
11
|
+
export const COMPOSE_UP_TIMEOUT = 300.0;
|
|
12
|
+
/** How long to wait for /health/ready, and how often to ask. Replaceable in tests. */
|
|
13
|
+
export const timing = {
|
|
14
|
+
readyTimeout: 240.0,
|
|
15
|
+
pollInterval: 0.5,
|
|
16
|
+
};
|
|
17
|
+
export function parsePortOverrides(values, known) {
|
|
18
|
+
const ports = new Map();
|
|
19
|
+
for (const value of values) {
|
|
20
|
+
const at = value.indexOf("=");
|
|
21
|
+
const name = at < 0 ? value : value.slice(0, at);
|
|
22
|
+
const raw = at < 0 ? "" : value.slice(at + 1);
|
|
23
|
+
if (at < 0 || !known.has(name) || !/^\d+$/.test(raw) || Number(raw) < 1024 || Number(raw) > 65535) {
|
|
24
|
+
throw new UsageError(`Invalid --port ${repr(value)}.`, {
|
|
25
|
+
hint: "Use --port <provider>=<1024-65535>, e.g. claude=9101.",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
ports.set(name, Number(raw));
|
|
29
|
+
}
|
|
30
|
+
return ports;
|
|
31
|
+
}
|
|
32
|
+
/** Validate and remember --image / --port; return the image to run. */
|
|
33
|
+
export function applySettings(ctx, args) {
|
|
34
|
+
const config = ctx.config;
|
|
35
|
+
const image = resolveImage(config, args.image);
|
|
36
|
+
const ports = parsePortOverrides(args.port ?? [], new Set(ctx.providers.keys()));
|
|
37
|
+
const changed = ports.size > 0 || (args.image !== null && args.image !== config.image);
|
|
38
|
+
for (const [name, port] of ports)
|
|
39
|
+
config.ports.set(name, port);
|
|
40
|
+
if (args.image !== null)
|
|
41
|
+
config.image = args.image;
|
|
42
|
+
const used = [...ctx.providers.keys()].map((name) => config.port(name));
|
|
43
|
+
if (new Set(used).size !== used.length) {
|
|
44
|
+
throw new UsageError("Two providers are set to the same port.", {
|
|
45
|
+
hint: "Choose different --port values.",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (changed)
|
|
49
|
+
saveConfig(ctx.home, config);
|
|
50
|
+
return image;
|
|
51
|
+
}
|
|
52
|
+
export async function startAndWait(ctx, provider) {
|
|
53
|
+
const { console, stack } = ctx;
|
|
54
|
+
const service = serviceName(provider);
|
|
55
|
+
const port = ctx.config.port(provider.name);
|
|
56
|
+
const stopped = async () => {
|
|
57
|
+
const state = (await stack.states()).get(service);
|
|
58
|
+
return state !== undefined && ["exited", "dead"].includes(state.state);
|
|
59
|
+
};
|
|
60
|
+
console.step(`Starting ${provider.displayName} (it checks its login, about 10-30 s)...`);
|
|
61
|
+
const ok = await health.waitUntil(() => health.server.ready(port), {
|
|
62
|
+
timeout: timing.readyTimeout,
|
|
63
|
+
interval: timing.pollInterval,
|
|
64
|
+
shouldStop: stopped,
|
|
65
|
+
});
|
|
66
|
+
if (!ok) {
|
|
67
|
+
if (await stopped()) {
|
|
68
|
+
throw new CliError(`${provider.displayName} stopped while starting.`, {
|
|
69
|
+
hint: `Its login may have expired: modelmux login ${provider.name}. ` +
|
|
70
|
+
`Details: modelmux logs ${provider.name}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
throw new CliError(`${provider.displayName} did not become ready in time.`, {
|
|
74
|
+
hint: `See what it is doing: modelmux logs ${provider.name}`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
console.success(`${provider.displayName} is running at ${health.baseUrl(port)}/v1`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Docker, API key, compose file, image and login volumes: all idempotent.
|
|
81
|
+
*
|
|
82
|
+
* Returns true if the compose file changed (so running containers must be
|
|
83
|
+
* recreated to pick it up).
|
|
84
|
+
*/
|
|
85
|
+
export async function prepare(ctx, image) {
|
|
86
|
+
const { console, stack } = ctx;
|
|
87
|
+
console.step("Checking Docker...");
|
|
88
|
+
await ctx.docker.checkAvailable();
|
|
89
|
+
ensurePrivateDir(ctx.home);
|
|
90
|
+
const key = ensureApiKey(ctx.home);
|
|
91
|
+
if (key.created) {
|
|
92
|
+
console.success("Created an API key for your clients (see it with: modelmux key show).");
|
|
93
|
+
}
|
|
94
|
+
const changed = writeCompose(ctx.home, buildCompose(ctx.providers, ctx.config, image, key.path));
|
|
95
|
+
if (!(await stack.imagePresent(image))) {
|
|
96
|
+
console.step(`Downloading the ModelMux server image ${image} (first time only, ~1.4 GB)...`);
|
|
97
|
+
await stack.ensureImage(image);
|
|
98
|
+
}
|
|
99
|
+
for (const provider of ctx.providers.values())
|
|
100
|
+
await stack.ensureVolume(provider);
|
|
101
|
+
return changed || key.created;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Providers whose container runs and answers /health/ready. Their login is
|
|
105
|
+
* proven (the server's startup check made a real request), so no helper
|
|
106
|
+
* container is needed to check it again.
|
|
107
|
+
*/
|
|
108
|
+
export async function runningAndReady(ctx, providers) {
|
|
109
|
+
const states = await ctx.stack.states();
|
|
110
|
+
const ready = new Set();
|
|
111
|
+
for (const p of providers) {
|
|
112
|
+
const s = states.get(serviceName(p));
|
|
113
|
+
if (s !== undefined && s.state === "running" && (await health.server.ready(ctx.config.port(p.name)))) {
|
|
114
|
+
ready.add(p.name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return ready;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Logged in or not, per provider: running ones count as logged in, the rest
|
|
121
|
+
* are checked in parallel helper containers.
|
|
122
|
+
*/
|
|
123
|
+
export async function checkLogins(ctx, providers, image) {
|
|
124
|
+
const ready = await runningAndReady(ctx, providers);
|
|
125
|
+
const unknown = providers.filter((p) => !ready.has(p.name));
|
|
126
|
+
const answers = await settleAll(unknown.map((p) => ctx.stack.loggedIn(p, image)));
|
|
127
|
+
const result = new Map();
|
|
128
|
+
for (const p of providers)
|
|
129
|
+
if (ready.has(p.name))
|
|
130
|
+
result.set(p.name, true);
|
|
131
|
+
unknown.forEach((p, i) => result.set(p.name, answers[i]));
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
/** Wait for all promises, then return their values or throw the first failure (in order). */
|
|
135
|
+
async function settleAll(promises) {
|
|
136
|
+
const settled = await Promise.allSettled(promises);
|
|
137
|
+
for (const outcome of settled)
|
|
138
|
+
if (outcome.status === "rejected")
|
|
139
|
+
throw outcome.reason;
|
|
140
|
+
return settled.map((outcome) => outcome.value);
|
|
141
|
+
}
|
|
142
|
+
/** Wait for every provider at once; report the first failure afterwards. */
|
|
143
|
+
export async function waitAll(ctx, providers) {
|
|
144
|
+
await settleAll(providers.map((p) => startAndWait(ctx, p)));
|
|
145
|
+
}
|
|
146
|
+
export async function checkPorts(ctx, providers) {
|
|
147
|
+
const states = await ctx.stack.states();
|
|
148
|
+
for (const provider of providers) {
|
|
149
|
+
const running = states.get(serviceName(provider));
|
|
150
|
+
const port = ctx.config.port(provider.name);
|
|
151
|
+
if ((running === undefined || running.state !== "running") && !(await ports.portFree(port))) {
|
|
152
|
+
throw new CliError(`Port ${port} for ${provider.displayName} is already in use by another program.`, {
|
|
153
|
+
hint: `Pick another port: modelmux up --port ${provider.name}=<port>, ` +
|
|
154
|
+
"or run 'modelmux doctor'.",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export async function run(args, console) {
|
|
160
|
+
const ctx = makeContext(console);
|
|
161
|
+
const providers = ctx.providers;
|
|
162
|
+
const unknown = args.providers.filter((name) => !providers.has(name));
|
|
163
|
+
if (unknown.length) {
|
|
164
|
+
throw new UsageError(`Unknown provider ${repr(unknown[0])}.`, {
|
|
165
|
+
hint: `Choose from: ${[...providers.keys()].join(", ")}.`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const image = applySettings(ctx, args);
|
|
169
|
+
const composeChanged = await prepare(ctx, image);
|
|
170
|
+
const names = args.providers.length ? args.providers : [...providers.keys()];
|
|
171
|
+
const targets = names.map((name) => providers.get(name));
|
|
172
|
+
console.step("Checking logins...");
|
|
173
|
+
const loggedIn = await checkLogins(ctx, targets, image);
|
|
174
|
+
const missing = targets.filter((p) => !loggedIn.get(p.name));
|
|
175
|
+
if (args.providers.length && missing.length) {
|
|
176
|
+
throw new CliError(`${missing[0].displayName} is not logged in yet.`, {
|
|
177
|
+
hint: `Log in first: modelmux login ${missing[0].name}`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const toStart = targets.filter((p) => loggedIn.get(p.name));
|
|
181
|
+
if (!toStart.length) {
|
|
182
|
+
console.success("ModelMux is set up. No provider is logged in yet.");
|
|
183
|
+
console.step(`Next: modelmux login ${targets[0].name}`);
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
const ready = composeChanged ? new Set() : await runningAndReady(ctx, toStart);
|
|
187
|
+
const waiting = toStart.filter((p) => !ready.has(p.name));
|
|
188
|
+
if (waiting.length) {
|
|
189
|
+
await checkPorts(ctx, waiting);
|
|
190
|
+
await ctx.stack.compose(["up", "-d", ...toStart.map(serviceName)], { timeout: COMPOSE_UP_TIMEOUT });
|
|
191
|
+
}
|
|
192
|
+
for (const provider of toStart) {
|
|
193
|
+
if (ready.has(provider.name)) {
|
|
194
|
+
const port = ctx.config.port(provider.name);
|
|
195
|
+
console.success(`${provider.displayName} is already running at ${health.baseUrl(port)}/v1`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
await waitAll(ctx, waiting);
|
|
199
|
+
for (const provider of missing) {
|
|
200
|
+
console.step(`${provider.displayName} is not logged in: modelmux login ${provider.name}`);
|
|
201
|
+
}
|
|
202
|
+
console.step("Configure your tools: modelmux config litellm (or openai-python, langchain)");
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `modelmux upgrade`: run the server image that matches this CLI, keeping logins.
|
|
3
|
+
*
|
|
4
|
+
* The image version follows the CLI version: a newer server comes with a newer
|
|
5
|
+
* modelmux-cli. This command makes sure the image pinned for the installed CLI
|
|
6
|
+
* (or an explicit `--image`) is present, recreates the providers that were
|
|
7
|
+
* running, and says when a newer CLI is available.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { saveConfig } from "../config.js";
|
|
11
|
+
import { makeContext } from "../context.js";
|
|
12
|
+
import { resolveImage, serviceName } from "../stack.js";
|
|
13
|
+
import * as updates from "../updates.js";
|
|
14
|
+
import { VERSION } from "../version.js";
|
|
15
|
+
import * as upModule from "./up.js";
|
|
16
|
+
export async function run(args, console) {
|
|
17
|
+
const ctx = makeContext(console);
|
|
18
|
+
const image = resolveImage(ctx.config, args.image);
|
|
19
|
+
if (args.image !== null && args.image !== ctx.config.image) {
|
|
20
|
+
ctx.config.image = args.image;
|
|
21
|
+
saveConfig(ctx.home, ctx.config);
|
|
22
|
+
}
|
|
23
|
+
const states = existsSync(ctx.stack.composeFile)
|
|
24
|
+
? await ctx.stack.states()
|
|
25
|
+
: new Map();
|
|
26
|
+
await upModule.prepare(ctx, image); // downloads the image if needed, rewrites compose.yaml
|
|
27
|
+
console.success(`Server image: ${image}`);
|
|
28
|
+
const running = [...ctx.providers.values()].filter((p) => states.get(serviceName(p))?.state === "running");
|
|
29
|
+
if (running.length) {
|
|
30
|
+
await ctx.stack.compose(["up", "-d", ...running.map(serviceName)], {
|
|
31
|
+
timeout: upModule.COMPOSE_UP_TIMEOUT,
|
|
32
|
+
});
|
|
33
|
+
for (const provider of running)
|
|
34
|
+
await upModule.startAndWait(ctx, provider);
|
|
35
|
+
console.success("Upgraded. Logins were kept.");
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
console.success("Nothing was running; the next 'modelmux up' uses this image.");
|
|
39
|
+
}
|
|
40
|
+
const latest = await updates.registry.latestVersion();
|
|
41
|
+
if (updates.newerAvailable(VERSION, latest)) {
|
|
42
|
+
console.step(`modelmux-cli ${latest} is available (you have ${VERSION}). ` +
|
|
43
|
+
`It comes with a newer server: ${updates.UPGRADE_HINT}`);
|
|
44
|
+
}
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's settings (`config.json`): ports, and an optional image override.
|
|
3
|
+
*
|
|
4
|
+
* Everything has a default, so the file only exists once something is changed
|
|
5
|
+
* or `modelmux up` runs. A damaged file is reported, never silently replaced.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { CliError } from "./errors.js";
|
|
10
|
+
import { writePrivate } from "./files.js";
|
|
11
|
+
import { ensurePrivateDir } from "./paths.js";
|
|
12
|
+
import { loadProviders } from "./providers.js";
|
|
13
|
+
import { repr, sortedStrings } from "./text.js";
|
|
14
|
+
export const CONFIG_FILE = "config.json";
|
|
15
|
+
export const CONFIG_VERSION = 1;
|
|
16
|
+
export class Config {
|
|
17
|
+
ports;
|
|
18
|
+
/** Explicit override; null means "the image pinned for this CLI". */
|
|
19
|
+
image;
|
|
20
|
+
constructor(ports = new Map(), image = null) {
|
|
21
|
+
this.ports = ports;
|
|
22
|
+
this.image = image;
|
|
23
|
+
}
|
|
24
|
+
port(provider) {
|
|
25
|
+
const port = this.ports.get(provider);
|
|
26
|
+
if (port !== undefined)
|
|
27
|
+
return port;
|
|
28
|
+
const known = loadProviders().get(provider);
|
|
29
|
+
if (known === undefined)
|
|
30
|
+
throw new CliError(`Unknown provider ${repr(provider)}.`);
|
|
31
|
+
return known.defaultPort;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class ConfigFileError extends CliError {
|
|
35
|
+
constructor(file, problem) {
|
|
36
|
+
super(`Your ModelMux settings file is damaged (${problem}).`, {
|
|
37
|
+
hint: `Fix or delete ${file} and run the command again.`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function parse(file, data) {
|
|
42
|
+
if (typeof data !== "object" ||
|
|
43
|
+
data === null ||
|
|
44
|
+
Array.isArray(data) ||
|
|
45
|
+
data.version !== CONFIG_VERSION) {
|
|
46
|
+
throw new ConfigFileError(file, "unknown format version");
|
|
47
|
+
}
|
|
48
|
+
const record = data;
|
|
49
|
+
const ports = record.ports ?? {};
|
|
50
|
+
if (typeof ports !== "object" || ports === null || Array.isArray(ports)) {
|
|
51
|
+
throw new ConfigFileError(file, "'ports' must be an object");
|
|
52
|
+
}
|
|
53
|
+
const known = loadProviders();
|
|
54
|
+
const parsed = new Map();
|
|
55
|
+
for (const [name, port] of Object.entries(ports)) {
|
|
56
|
+
if (!known.has(name))
|
|
57
|
+
throw new ConfigFileError(file, `unknown provider ${repr(name)} in 'ports'`);
|
|
58
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
59
|
+
throw new ConfigFileError(file, `invalid port for ${repr(name)}`);
|
|
60
|
+
}
|
|
61
|
+
parsed.set(name, port);
|
|
62
|
+
}
|
|
63
|
+
const image = record.image ?? null;
|
|
64
|
+
if (image !== null && typeof image !== "string") {
|
|
65
|
+
throw new ConfigFileError(file, "'image' must be a string");
|
|
66
|
+
}
|
|
67
|
+
return new Config(parsed, image);
|
|
68
|
+
}
|
|
69
|
+
export function loadConfig(directory) {
|
|
70
|
+
const file = path.join(directory, CONFIG_FILE);
|
|
71
|
+
if (!existsSync(file))
|
|
72
|
+
return new Config();
|
|
73
|
+
let data;
|
|
74
|
+
try {
|
|
75
|
+
data = JSON.parse(readFileSync(file, "utf8"));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new ConfigFileError(file, "not valid JSON");
|
|
79
|
+
}
|
|
80
|
+
return parse(file, data);
|
|
81
|
+
}
|
|
82
|
+
/** Python's `json.dumps(data, indent=2)`: same spacing, non-ASCII escaped. */
|
|
83
|
+
export function pyJson(data) {
|
|
84
|
+
return JSON.stringify(data, null, 2).replace(/[\u0080-]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
85
|
+
}
|
|
86
|
+
export function saveConfig(directory, config) {
|
|
87
|
+
ensurePrivateDir(directory);
|
|
88
|
+
const file = path.join(directory, CONFIG_FILE);
|
|
89
|
+
const ports = {};
|
|
90
|
+
for (const name of sortedStrings(config.ports.keys()))
|
|
91
|
+
ports[name] = config.ports.get(name);
|
|
92
|
+
const data = { version: CONFIG_VERSION, ports };
|
|
93
|
+
if (config.image !== null)
|
|
94
|
+
data.image = config.image;
|
|
95
|
+
writePrivate(file, pyJson(data) + "\n");
|
|
96
|
+
return file;
|
|
97
|
+
}
|
package/dist/console.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal output: short, friendly lines with ✓ / ✗ markers.
|
|
3
|
+
*
|
|
4
|
+
* Colour is used only on a real terminal, never when `NO_COLOR` is set or
|
|
5
|
+
* `TERM=dumb`. Symbols fall back to ASCII when the terminal cannot show them
|
|
6
|
+
* (older Windows consoles).
|
|
7
|
+
*/
|
|
8
|
+
import { redact } from "./redact.js";
|
|
9
|
+
const RESET = "\x1b[0m";
|
|
10
|
+
const STYLES = {
|
|
11
|
+
ok: "\x1b[32m",
|
|
12
|
+
error: "\x1b[31m",
|
|
13
|
+
warn: "\x1b[33m",
|
|
14
|
+
dim: "\x1b[2m",
|
|
15
|
+
bold: "\x1b[1m",
|
|
16
|
+
};
|
|
17
|
+
const SYMBOLS = {
|
|
18
|
+
ok: ["✓", "OK"],
|
|
19
|
+
error: ["✗", "X"],
|
|
20
|
+
warn: ["!", "!"],
|
|
21
|
+
step: ["→", ">"],
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Can this terminal show ✓✗→? Node always writes UTF-8; only legacy Windows
|
|
25
|
+
* consoles (not Windows Terminal or VS Code) show it as garbage.
|
|
26
|
+
*/
|
|
27
|
+
function supportsUnicode(env = process.env) {
|
|
28
|
+
if (process.platform !== "win32")
|
|
29
|
+
return true;
|
|
30
|
+
return Boolean(env.WT_SESSION || env.TERM_PROGRAM === "vscode" || env.TERM === "xterm-256color" || env.CI);
|
|
31
|
+
}
|
|
32
|
+
export class Console {
|
|
33
|
+
out;
|
|
34
|
+
err;
|
|
35
|
+
verbose;
|
|
36
|
+
color;
|
|
37
|
+
unicode;
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
this.out = options.out ?? process.stdout;
|
|
40
|
+
this.err = options.err ?? process.stderr;
|
|
41
|
+
this.verbose = options.verbose ?? false;
|
|
42
|
+
let color = options.color ?? null;
|
|
43
|
+
if (color === null) {
|
|
44
|
+
// Node enables ANSI escape handling in the Windows console by itself.
|
|
45
|
+
color =
|
|
46
|
+
this.out.isTTY === true && !("NO_COLOR" in process.env) && process.env.TERM !== "dumb";
|
|
47
|
+
}
|
|
48
|
+
this.color = color;
|
|
49
|
+
this.unicode = options.unicode ?? supportsUnicode();
|
|
50
|
+
}
|
|
51
|
+
style(kind, text) {
|
|
52
|
+
return this.color ? `${STYLES[kind]}${text}${RESET}` : text;
|
|
53
|
+
}
|
|
54
|
+
symbol(kind) {
|
|
55
|
+
const [fancy, plain] = SYMBOLS[kind];
|
|
56
|
+
return this.unicode ? fancy : plain;
|
|
57
|
+
}
|
|
58
|
+
line(stream, text) {
|
|
59
|
+
stream.write(redact(text) + "\n");
|
|
60
|
+
}
|
|
61
|
+
// ------------------------------------------------------------ public API
|
|
62
|
+
/** Plain output (command results, config snippets). */
|
|
63
|
+
print(text = "") {
|
|
64
|
+
this.line(this.out, text);
|
|
65
|
+
}
|
|
66
|
+
success(text) {
|
|
67
|
+
this.line(this.out, `${this.style("ok", this.symbol("ok"))} ${text}`);
|
|
68
|
+
}
|
|
69
|
+
step(text) {
|
|
70
|
+
this.line(this.out, `${this.style("dim", this.symbol("step"))} ${text}`);
|
|
71
|
+
}
|
|
72
|
+
warn(text) {
|
|
73
|
+
this.line(this.err, `${this.style("warn", this.symbol("warn"))} ${text}`);
|
|
74
|
+
}
|
|
75
|
+
error(text, hint = null) {
|
|
76
|
+
this.line(this.err, `${this.style("error", this.symbol("error"))} ${text}`);
|
|
77
|
+
if (hint)
|
|
78
|
+
this.line(this.err, ` ${this.style("dim", hint)}`);
|
|
79
|
+
}
|
|
80
|
+
/** A hint on stderr, so stdout stays clean for piping (`config > file`). */
|
|
81
|
+
note(text) {
|
|
82
|
+
this.line(this.err, `${this.style("dim", this.symbol("step"))} ${text}`);
|
|
83
|
+
}
|
|
84
|
+
/** Print WITHOUT redaction. Only for an explicit user request to see a secret. */
|
|
85
|
+
reveal(text) {
|
|
86
|
+
this.out.write(text + "\n");
|
|
87
|
+
}
|
|
88
|
+
/** Only shown with --verbose. */
|
|
89
|
+
detail(text) {
|
|
90
|
+
if (this.verbose)
|
|
91
|
+
this.line(this.err, this.style("dim", ` ${text}`));
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Everything a command needs, built in one place (and replaceable in tests). */
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
import { Docker } from "./docker.js";
|
|
4
|
+
import { configDir } from "./paths.js";
|
|
5
|
+
import { loadProviders } from "./providers.js";
|
|
6
|
+
import { Stack } from "./stack.js";
|
|
7
|
+
/** Replaceable in tests: how commands get their Docker. */
|
|
8
|
+
export const factory = {
|
|
9
|
+
makeDocker: (console) => new Docker(console),
|
|
10
|
+
};
|
|
11
|
+
export class Context {
|
|
12
|
+
console;
|
|
13
|
+
docker;
|
|
14
|
+
home;
|
|
15
|
+
cachedConfig = null;
|
|
16
|
+
cachedStack = null;
|
|
17
|
+
constructor(console, docker, home = configDir()) {
|
|
18
|
+
this.console = console;
|
|
19
|
+
this.docker = docker;
|
|
20
|
+
this.home = home;
|
|
21
|
+
}
|
|
22
|
+
get config() {
|
|
23
|
+
this.cachedConfig ??= loadConfig(this.home);
|
|
24
|
+
return this.cachedConfig;
|
|
25
|
+
}
|
|
26
|
+
get providers() {
|
|
27
|
+
return loadProviders();
|
|
28
|
+
}
|
|
29
|
+
get stack() {
|
|
30
|
+
this.cachedStack ??= new Stack(this.docker, this.home);
|
|
31
|
+
return this.cachedStack;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function makeContext(console) {
|
|
35
|
+
return new Context(console, factory.makeDocker(console));
|
|
36
|
+
}
|