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:
rugvedS07
2025-07-29 13:36:45 -04:00
committed by GitHub
parent 5b378e826c
commit ecf151cd15
6 changed files with 393 additions and 56 deletions

155
src/EnvironmentManager.ts Normal file
View 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
}
}
}

View File

@ -1,15 +1,13 @@
import { apiServerPorts, type SimpleLogger, text } from "@lmstudio/lms-common"; import { apiServerPorts, type SimpleLogger, text } from "@lmstudio/lms-common";
import { LMStudioClient, type LMStudioClientConstructorOpts } from "@lmstudio/sdk"; import { LMStudioClient, type LMStudioClientConstructorOpts } from "@lmstudio/sdk";
import chalk from "chalk";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { option, optional, string } from "cmd-ts";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { readFile } from "fs/promises"; import { readFile } from "fs/promises";
import { appInstallLocationFilePath, lmsKey2Path } from "./lmstudioPaths.js"; import { appInstallLocationFilePath, lmsKey2Path } from "./lmstudioPaths.js";
import { type LogLevelArgs } from "./logLevel.js"; import { type LogLevelArgs } from "./logLevel.js";
import { checkHttpServer } from "./subcommands/server.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 { interface AppInstallLocation {
path: string; path: string;
argv: Array<string>; argv: Array<string>;
@ -17,30 +15,20 @@ interface AppInstallLocation {
} }
export const createClientArgs = { export const createClientArgs = {
host: option({ env: option({
type: optional(string), type: optional(string),
long: "host", long: "env",
description: text` 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 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 will not be a privileged client, and will restrict usage of functionalities such as
"lms push". "lms push". Know more about envs using lms env.
`,
}),
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.
`, `,
}), }),
}; };
interface CreateClientArgs { interface CreateClientArgs {
yes?: boolean; yes?: boolean;
host?: string;
port?: number;
} }
async function isLocalServerAtPortLMStudioServerOrThrow(port: number) { async function isLocalServerAtPortLMStudioServerOrThrow(port: number) {
@ -107,20 +95,22 @@ export async function createClient(
args: CreateClientArgs & LogLevelArgs, args: CreateClientArgs & LogLevelArgs,
_opts: CreateClientOpts = {}, _opts: CreateClientOpts = {},
) { ) {
let { host, port } = args; const envManager = new EnvironmentManager(logger);
let isRemote = true; let currentEnv = await envManager.getCurrentEnvironment();
if (host === undefined) { let host = currentEnv.host;
isRemote = false; let port = currentEnv.port;
host = "127.0.0.1"; if (args.env) {
} else if (host.includes("://")) { // If the user specified an environment, we will override the current environment with the specified one.
logger.error("Host should not include the protocol."); const specificedEnv = await envManager.tryGetEnvironment(args.env);
process.exit(1); if (specificedEnv === undefined) {
} else if (host.includes(":")) { logger.error(`Environment '${args.env}' not found`);
logger.error(
`Host should not include the port number. Use ${chalk.yellowBright("--port")} instead.`,
);
process.exit(1); process.exit(1);
} }
host = specificedEnv.host;
port = specificedEnv.port;
currentEnv = specificedEnv;
}
const isRemote = currentEnv.name !== DEFAULT_LOCAL_ENVIRONMENT_NAME;
let auth: LMStudioClientConstructorOpts; let auth: LMStudioClientConstructorOpts;
if (isRemote) { if (isRemote) {
// If connecting to a remote server, we will use a random client identifier. // 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. // We will now attempt to connect to the local API server.
const localPort = await tryFindLocalAPIServer(); const localPort = await tryFindLocalAPIServer();
@ -186,11 +176,6 @@ export async function createClient(
logger.error(""); logger.error("");
} }
if (port === undefined) {
port = 1234;
}
logger.debug(`Connecting to server at ${host}:${port}`); logger.debug(`Connecting to server at ${host}:${port}`);
if (!(await checkHttpServer(logger, port, host))) { if (!(await checkHttpServer(logger, port, host))) {
logger.error( logger.error(

View File

@ -16,6 +16,7 @@ import { server } from "./subcommands/server.js";
import { status } from "./subcommands/status.js"; import { status } from "./subcommands/status.js";
import { unload } from "./subcommands/unload.js"; import { unload } from "./subcommands/unload.js";
import { printVersion, version } from "./subcommands/version.js"; import { printVersion, version } from "./subcommands/version.js";
import { env } from "./subcommands/env.js";
if (process.argv.length === 2) { if (process.argv.length === 2) {
printVersion(); printVersion();
@ -44,6 +45,7 @@ const cli = subcommands({
flags: flagsCommand, flags: flagsCommand,
bootstrap, bootstrap,
version, version,
env,
}, },
}); });

View File

@ -2,6 +2,7 @@ import { findLMStudioHome } from "@lmstudio/lms-common-server";
import { join } from "path"; import { join } from "path";
const lmstudioHome = findLMStudioHome(); const lmstudioHome = findLMStudioHome();
export const lmsConfigFolder = join(lmstudioHome, "lms"); //TODO: Temporary path
export const pluginsFolderPath = join(lmstudioHome, "extensions", "plugins"); export const pluginsFolderPath = join(lmstudioHome, "extensions", "plugins");
export const lmsKey2Path = join(lmstudioHome, ".internal", "lms-key-2"); export const lmsKey2Path = join(lmstudioHome, ".internal", "lms-key-2");
export const cliPrefPath = join(lmstudioHome, ".internal", "cli-pref.json"); export const cliPrefPath = join(lmstudioHome, ".internal", "cli-pref.json");

206
src/subcommands/env.ts Normal file
View 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,
},
});

View File

@ -2,36 +2,24 @@ import { text } from "@lmstudio/lms-common";
import boxen from "boxen"; import boxen from "boxen";
import chalk from "chalk"; import chalk from "chalk";
import { command } from "cmd-ts"; 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 { formatSizeBytesWithColor1000 } from "../formatSizeBytes1000.js";
import { createLogger, logLevelArgs } from "../logLevel.js"; import { createLogger, logLevelArgs } from "../logLevel.js";
import { checkHttpServer, getServerConfig } from "./server.js"; import { checkHttpServer } from "./server.js";
export const status = command({ export const status = command({
name: "status", name: "status",
description: "Prints the status of LM Studio", description: "Prints the status of LM Studio",
args: { args: {
...logLevelArgs, ...logLevelArgs,
...createClientArgs,
}, },
async handler(args) { async handler(args) {
const logger = createLogger(args); const logger = createLogger(args);
let { host, port } = args; const envManager = new EnvironmentManager(logger);
if (host === undefined) { const currentEnv = await envManager.getCurrentEnvironment();
host = "127.0.0.1"; const host = currentEnv.host;
} const port = currentEnv.port;
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 running = await checkHttpServer(logger, port, host); const running = await checkHttpServer(logger, port, host);
let content = ""; let content = "";
if (running) { if (running) {