mirror of
https://github.com/lmstudio-ai/lms.git
synced 2025-09-19 21:40:02 +08:00
Environment support for multiple hosts or ports (#278)
* Environment support for multiple hosts or ports * Better error handling * Minor changes * Correct handling for local * Add inspect command * Address comments * Address comments more
This commit is contained in:
155
src/EnvironmentManager.ts
Normal file
155
src/EnvironmentManager.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { readFile, writeFile, mkdir, access, unlink, readdir } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { lmsConfigFolder } from "./lmstudioPaths.js";
|
||||
import { z } from "zod";
|
||||
import { type SimpleLogger } from "@lmstudio/lms-common";
|
||||
const environmentConfigSchema = z.object({
|
||||
name: z.string(),
|
||||
host: z.string(),
|
||||
port: z.number().int().min(0).max(65535),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export type EnvironmentConfig = z.infer<typeof environmentConfigSchema>;
|
||||
|
||||
export const DEFAULT_LOCAL_ENVIRONMENT_NAME = "local";
|
||||
|
||||
const DEFAULT_ENVIRONMENT_CONFIG: EnvironmentConfig = {
|
||||
name: DEFAULT_LOCAL_ENVIRONMENT_NAME,
|
||||
host: "localhost",
|
||||
port: 1234,
|
||||
description: "Default local environment",
|
||||
};
|
||||
|
||||
export class EnvironmentManager {
|
||||
private environmentsDir: string;
|
||||
private currentEnvFile: string;
|
||||
|
||||
public constructor(private readonly logger: SimpleLogger) {
|
||||
const configDir = lmsConfigFolder;
|
||||
this.environmentsDir = join(configDir, "environments");
|
||||
this.currentEnvFile = join(configDir, "current-env");
|
||||
}
|
||||
|
||||
private async ensureDirExists(): Promise<void> {
|
||||
await mkdir(this.environmentsDir, { recursive: true });
|
||||
}
|
||||
|
||||
public async addEnvironment(config: EnvironmentConfig): Promise<void> {
|
||||
await this.ensureDirExists();
|
||||
const envPath = join(this.environmentsDir, `${config.name}.json`);
|
||||
try {
|
||||
await access(envPath);
|
||||
throw new Error(`Environment ${config.name} already exists.`);
|
||||
} catch {
|
||||
await writeFile(envPath, JSON.stringify(config, null, 2), "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
public async removeEnvironment(name: string): Promise<void> {
|
||||
const envPath = join(this.environmentsDir, `${name}.json`);
|
||||
try {
|
||||
await unlink(envPath);
|
||||
// Check if this was the current environment
|
||||
try {
|
||||
const currentEnv = await readFile(this.currentEnvFile, "utf-8");
|
||||
if (currentEnv === name) {
|
||||
await writeFile(this.currentEnvFile, DEFAULT_LOCAL_ENVIRONMENT_NAME, "utf-8");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
await writeFile(this.currentEnvFile, DEFAULT_LOCAL_ENVIRONMENT_NAME, "utf-8");
|
||||
} else {
|
||||
// Re-throw other types of errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
throw new Error(`Environment ${name} does not exist.`);
|
||||
} else {
|
||||
throw new Error(`Failed to remove environment ${name}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async setCurrentEnvironment(name: string): Promise<void> {
|
||||
if (name === "local") {
|
||||
// Special case for local environment
|
||||
await writeFile(this.currentEnvFile, DEFAULT_LOCAL_ENVIRONMENT_NAME, "utf-8");
|
||||
return;
|
||||
}
|
||||
const envPath = join(this.environmentsDir, `${name}.json`);
|
||||
try {
|
||||
const data = await readFile(envPath, "utf-8");
|
||||
environmentConfigSchema.parse(JSON.parse(data)); // Validate schema
|
||||
await writeFile(this.currentEnvFile, name, "utf-8");
|
||||
} catch {
|
||||
throw new Error(`Environment ${name} does not exist.`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getCurrentEnvironment(): Promise<EnvironmentConfig> {
|
||||
let envName: string;
|
||||
|
||||
// Check if LMS_ENV is set in the environment variables
|
||||
// This takes precedence over the currentEnvFile
|
||||
if (
|
||||
process.env.LMS_ENV &&
|
||||
process.env.LMS_ENV !== "undefined" &&
|
||||
process.env.LMS_ENV !== "null"
|
||||
) {
|
||||
envName = process.env.LMS_ENV;
|
||||
} else {
|
||||
try {
|
||||
envName = (await readFile(this.currentEnvFile, "utf-8")).trim();
|
||||
} catch {
|
||||
envName = DEFAULT_LOCAL_ENVIRONMENT_NAME;
|
||||
}
|
||||
}
|
||||
if (envName === undefined || envName === "" || envName === DEFAULT_LOCAL_ENVIRONMENT_NAME) {
|
||||
return DEFAULT_ENVIRONMENT_CONFIG;
|
||||
}
|
||||
|
||||
const env = await this.tryGetEnvironment(envName);
|
||||
if (env === undefined) {
|
||||
this.logger.warn(`Environment ${envName} not found, falling back to local.`);
|
||||
await writeFile(this.currentEnvFile, DEFAULT_LOCAL_ENVIRONMENT_NAME, "utf-8");
|
||||
return DEFAULT_ENVIRONMENT_CONFIG;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
public async getAllEnvironments(): Promise<EnvironmentConfig[]> {
|
||||
await this.ensureDirExists();
|
||||
const files = await readdir(this.environmentsDir);
|
||||
const environments: EnvironmentConfig[] = [DEFAULT_ENVIRONMENT_CONFIG];
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".json")) {
|
||||
try {
|
||||
const data = await readFile(join(this.environmentsDir, file), "utf-8");
|
||||
const parsed = environmentConfigSchema.parse(JSON.parse(data));
|
||||
environments.push(parsed);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to load environment from ${file}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return environments;
|
||||
}
|
||||
|
||||
public async tryGetEnvironment(name: string): Promise<EnvironmentConfig | undefined> {
|
||||
if (name === DEFAULT_LOCAL_ENVIRONMENT_NAME) {
|
||||
return DEFAULT_ENVIRONMENT_CONFIG; // Return default local environment
|
||||
}
|
||||
await this.ensureDirExists();
|
||||
const envPath = join(this.environmentsDir, `${name}.json`);
|
||||
try {
|
||||
const data = await readFile(envPath, "utf-8");
|
||||
return environmentConfigSchema.parse(JSON.parse(data));
|
||||
} catch {
|
||||
return undefined; // Environment does not exist
|
||||
}
|
||||
}
|
||||
}
|
@ -1,15 +1,13 @@
|
||||
import { apiServerPorts, type SimpleLogger, text } from "@lmstudio/lms-common";
|
||||
import { LMStudioClient, type LMStudioClientConstructorOpts } from "@lmstudio/sdk";
|
||||
import chalk from "chalk";
|
||||
import { spawn } from "child_process";
|
||||
import { option, optional, string } from "cmd-ts";
|
||||
import { randomBytes } from "crypto";
|
||||
import { readFile } from "fs/promises";
|
||||
import { appInstallLocationFilePath, lmsKey2Path } from "./lmstudioPaths.js";
|
||||
import { type LogLevelArgs } from "./logLevel.js";
|
||||
import { checkHttpServer } from "./subcommands/server.js";
|
||||
import { refinedNumber } from "./types/refinedNumber.js";
|
||||
|
||||
import { DEFAULT_LOCAL_ENVIRONMENT_NAME, EnvironmentManager } from "./EnvironmentManager.js";
|
||||
import { option, optional, string } from "cmd-ts";
|
||||
interface AppInstallLocation {
|
||||
path: string;
|
||||
argv: Array<string>;
|
||||
@ -17,30 +15,20 @@ interface AppInstallLocation {
|
||||
}
|
||||
|
||||
export const createClientArgs = {
|
||||
host: option({
|
||||
env: option({
|
||||
type: optional(string),
|
||||
long: "host",
|
||||
long: "env",
|
||||
description: text`
|
||||
If you wish to connect to a remote LM Studio instance, specify the host here. Note that, in
|
||||
If you wish to connect to a remote LM Studio instance, specify the env here. Note that, in
|
||||
this case, lms will connect using client identifier "lms-cli-remote-<random chars>", which
|
||||
will not be a privileged client, and will restrict usage of functionalities such as
|
||||
"lms push".
|
||||
`,
|
||||
}),
|
||||
port: option({
|
||||
type: optional(refinedNumber({ integer: true, min: 0, max: 65535 })),
|
||||
long: "port",
|
||||
description: text`
|
||||
The port where LM Studio can be reached. If not provided and the host is set to "127.0.0.1"
|
||||
(default), the last used port will be used; otherwise, 1234 will be used.
|
||||
"lms push". Know more about envs using lms env.
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
interface CreateClientArgs {
|
||||
yes?: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
async function isLocalServerAtPortLMStudioServerOrThrow(port: number) {
|
||||
@ -107,20 +95,22 @@ export async function createClient(
|
||||
args: CreateClientArgs & LogLevelArgs,
|
||||
_opts: CreateClientOpts = {},
|
||||
) {
|
||||
let { host, port } = args;
|
||||
let isRemote = true;
|
||||
if (host === undefined) {
|
||||
isRemote = false;
|
||||
host = "127.0.0.1";
|
||||
} else if (host.includes("://")) {
|
||||
logger.error("Host should not include the protocol.");
|
||||
process.exit(1);
|
||||
} else if (host.includes(":")) {
|
||||
logger.error(
|
||||
`Host should not include the port number. Use ${chalk.yellowBright("--port")} instead.`,
|
||||
);
|
||||
const envManager = new EnvironmentManager(logger);
|
||||
let currentEnv = await envManager.getCurrentEnvironment();
|
||||
let host = currentEnv.host;
|
||||
let port = currentEnv.port;
|
||||
if (args.env) {
|
||||
// If the user specified an environment, we will override the current environment with the specified one.
|
||||
const specificedEnv = await envManager.tryGetEnvironment(args.env);
|
||||
if (specificedEnv === undefined) {
|
||||
logger.error(`Environment '${args.env}' not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
host = specificedEnv.host;
|
||||
port = specificedEnv.port;
|
||||
currentEnv = specificedEnv;
|
||||
}
|
||||
const isRemote = currentEnv.name !== DEFAULT_LOCAL_ENVIRONMENT_NAME;
|
||||
let auth: LMStudioClientConstructorOpts;
|
||||
if (isRemote) {
|
||||
// If connecting to a remote server, we will use a random client identifier.
|
||||
@ -146,7 +136,7 @@ export async function createClient(
|
||||
};
|
||||
}
|
||||
}
|
||||
if (port === undefined && host === "127.0.0.1") {
|
||||
if (isRemote === false) {
|
||||
// We will now attempt to connect to the local API server.
|
||||
const localPort = await tryFindLocalAPIServer();
|
||||
|
||||
@ -186,11 +176,6 @@ export async function createClient(
|
||||
|
||||
logger.error("");
|
||||
}
|
||||
|
||||
if (port === undefined) {
|
||||
port = 1234;
|
||||
}
|
||||
|
||||
logger.debug(`Connecting to server at ${host}:${port}`);
|
||||
if (!(await checkHttpServer(logger, port, host))) {
|
||||
logger.error(
|
||||
|
@ -16,6 +16,7 @@ import { server } from "./subcommands/server.js";
|
||||
import { status } from "./subcommands/status.js";
|
||||
import { unload } from "./subcommands/unload.js";
|
||||
import { printVersion, version } from "./subcommands/version.js";
|
||||
import { env } from "./subcommands/env.js";
|
||||
|
||||
if (process.argv.length === 2) {
|
||||
printVersion();
|
||||
@ -44,6 +45,7 @@ const cli = subcommands({
|
||||
flags: flagsCommand,
|
||||
bootstrap,
|
||||
version,
|
||||
env,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -2,6 +2,7 @@ import { findLMStudioHome } from "@lmstudio/lms-common-server";
|
||||
import { join } from "path";
|
||||
|
||||
const lmstudioHome = findLMStudioHome();
|
||||
export const lmsConfigFolder = join(lmstudioHome, "lms"); //TODO: Temporary path
|
||||
export const pluginsFolderPath = join(lmstudioHome, "extensions", "plugins");
|
||||
export const lmsKey2Path = join(lmstudioHome, ".internal", "lms-key-2");
|
||||
export const cliPrefPath = join(lmstudioHome, ".internal", "cli-pref.json");
|
||||
|
206
src/subcommands/env.ts
Normal file
206
src/subcommands/env.ts
Normal file
@ -0,0 +1,206 @@
|
||||
import { text } from "@lmstudio/lms-common";
|
||||
import { command, option, optional, positional, string, subcommands } from "cmd-ts";
|
||||
import { EnvironmentManager } from "../EnvironmentManager.js";
|
||||
import { createLogger, logLevelArgs } from "../logLevel.js";
|
||||
import { refinedNumber } from "../types/refinedNumber.js";
|
||||
|
||||
const addEnvCommand = command({
|
||||
name: "add",
|
||||
description: "Add a new environment",
|
||||
args: {
|
||||
name: positional({
|
||||
type: string,
|
||||
displayName: "name",
|
||||
description: "Environment name",
|
||||
}),
|
||||
host: option({
|
||||
type: string,
|
||||
long: "host",
|
||||
description: "Host address",
|
||||
}),
|
||||
port: option({
|
||||
type: refinedNumber({ integer: true, min: 0, max: 65535 }),
|
||||
long: "port",
|
||||
description: "Port number",
|
||||
}),
|
||||
description: option({
|
||||
type: optional(string),
|
||||
long: "description",
|
||||
description: "Environment description",
|
||||
}),
|
||||
...logLevelArgs,
|
||||
},
|
||||
handler: async ({ name, host, port, description, ...logArgs }) => {
|
||||
const logger = createLogger(logArgs);
|
||||
const envManager = new EnvironmentManager(logger);
|
||||
try {
|
||||
await envManager.addEnvironment({
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
description,
|
||||
});
|
||||
logger.info(`Environment '${name}' added successfully`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to add environment: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const removeEnvCommand = command({
|
||||
name: "remove",
|
||||
description: "Remove an environment",
|
||||
args: {
|
||||
name: positional({
|
||||
type: string,
|
||||
displayName: "name",
|
||||
description: "Environment name to remove",
|
||||
}),
|
||||
...logLevelArgs,
|
||||
},
|
||||
handler: async ({ name, ...logArgs }) => {
|
||||
const logger = createLogger(logArgs);
|
||||
const envManager = new EnvironmentManager(logger);
|
||||
try {
|
||||
await envManager.removeEnvironment(name);
|
||||
logger.info(`Environment '${name}' removed successfully`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove environment: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const listEnvCommand = command({
|
||||
name: "ls",
|
||||
description: "List all environments",
|
||||
args: {
|
||||
...logLevelArgs,
|
||||
},
|
||||
handler: async logArgs => {
|
||||
const logger = createLogger(logArgs);
|
||||
const envManager = new EnvironmentManager(logger);
|
||||
try {
|
||||
const environments = await envManager.getAllEnvironments();
|
||||
const current = await envManager.getCurrentEnvironment();
|
||||
|
||||
if (environments.length === 0) {
|
||||
logger.info("No environments found");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Available environments:");
|
||||
for (const env of environments) {
|
||||
const isCurrent = env.name === current.name;
|
||||
const marker = isCurrent ? "* " : " ";
|
||||
const desc = env.description ? ` - ${env.description}` : "";
|
||||
logger.info(`${marker}${env.name} (${env.host}:${env.port})${desc}`);
|
||||
}
|
||||
|
||||
// Show default local environment if not in list
|
||||
const hasLocal = environments.some(env => env.name === "local");
|
||||
if (!hasLocal) {
|
||||
const marker = current.name === "local" ? "* " : " ";
|
||||
logger.info(`${marker}local (localhost:1234) - Default local environment`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list environments: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const useEnvCommand = command({
|
||||
name: "use",
|
||||
description: "Switch to an environment",
|
||||
args: {
|
||||
name: positional({
|
||||
type: string,
|
||||
displayName: "name",
|
||||
description: "Environment name to switch to",
|
||||
}),
|
||||
...logLevelArgs,
|
||||
},
|
||||
handler: async ({ name, ...logArgs }) => {
|
||||
const logger = createLogger(logArgs);
|
||||
const envManager = new EnvironmentManager(logger);
|
||||
try {
|
||||
await envManager.setCurrentEnvironment(name);
|
||||
logger.info(`Switched to environment '${name}'`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to switch environment: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const currentEnvCommand = command({
|
||||
name: "current",
|
||||
description: "Show current environment",
|
||||
args: {
|
||||
...logLevelArgs,
|
||||
},
|
||||
handler: async logArgs => {
|
||||
const logger = createLogger(logArgs);
|
||||
const envManager = new EnvironmentManager(logger);
|
||||
try {
|
||||
const current = await envManager.getCurrentEnvironment();
|
||||
const desc = current.description ? ` - ${current.description}` : "";
|
||||
logger.info(`Current environment: ${current.name} (${current.host}:${current.port})${desc}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get current environment: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const inspectEnvCommand = command({
|
||||
name: "inspect",
|
||||
description: "Show detailed information about an environment",
|
||||
args: {
|
||||
name: positional({
|
||||
type: string,
|
||||
displayName: "name",
|
||||
description: "Environment name to inspect",
|
||||
}),
|
||||
...logLevelArgs,
|
||||
},
|
||||
handler: async ({ name, ...logArgs }) => {
|
||||
const logger = createLogger(logArgs);
|
||||
const envManager = new EnvironmentManager(logger);
|
||||
try {
|
||||
const env = await envManager.tryGetEnvironment(name);
|
||||
if (!env) {
|
||||
logger.error(`Environment '${name}' not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info(`Environment: ${env.name}`);
|
||||
logger.info(`Host: ${env.host}`);
|
||||
logger.info(`Port: ${env.port}`);
|
||||
if (env.description !== undefined) {
|
||||
logger.info(`Description: ${env.description}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to inspect environment: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const env = subcommands({
|
||||
name: "env",
|
||||
description: text`
|
||||
Manage LM Studio environments. Environments allow you to switch between different
|
||||
LM Studio instances (local or remote) easily.
|
||||
`,
|
||||
cmds: {
|
||||
add: addEnvCommand,
|
||||
remove: removeEnvCommand,
|
||||
ls: listEnvCommand,
|
||||
use: useEnvCommand,
|
||||
current: currentEnvCommand,
|
||||
inspect: inspectEnvCommand,
|
||||
},
|
||||
});
|
@ -2,36 +2,24 @@ import { text } from "@lmstudio/lms-common";
|
||||
import boxen from "boxen";
|
||||
import chalk from "chalk";
|
||||
import { command } from "cmd-ts";
|
||||
import { createClient, createClientArgs } from "../createClient.js";
|
||||
import { createClient } from "../createClient.js";
|
||||
import { EnvironmentManager } from "../EnvironmentManager.js";
|
||||
import { formatSizeBytesWithColor1000 } from "../formatSizeBytes1000.js";
|
||||
import { createLogger, logLevelArgs } from "../logLevel.js";
|
||||
import { checkHttpServer, getServerConfig } from "./server.js";
|
||||
import { checkHttpServer } from "./server.js";
|
||||
|
||||
export const status = command({
|
||||
name: "status",
|
||||
description: "Prints the status of LM Studio",
|
||||
args: {
|
||||
...logLevelArgs,
|
||||
...createClientArgs,
|
||||
},
|
||||
async handler(args) {
|
||||
const logger = createLogger(args);
|
||||
let { host, port } = args;
|
||||
if (host === undefined) {
|
||||
host = "127.0.0.1";
|
||||
}
|
||||
if (port === undefined) {
|
||||
if (host === "127.0.0.1") {
|
||||
try {
|
||||
port = (await getServerConfig(logger)).port;
|
||||
} catch (e) {
|
||||
logger.debug(`Failed to read last status`, e);
|
||||
port = 1234;
|
||||
}
|
||||
} else {
|
||||
port = 1234;
|
||||
}
|
||||
}
|
||||
const envManager = new EnvironmentManager(logger);
|
||||
const currentEnv = await envManager.getCurrentEnvironment();
|
||||
const host = currentEnv.host;
|
||||
const port = currentEnv.port;
|
||||
const running = await checkHttpServer(logger, port, host);
|
||||
let content = "";
|
||||
if (running) {
|
||||
|
Reference in New Issue
Block a user