Merge pull request #103 from lmstudio-ai/ryan/ocelot

Refactor Pt. 2
This commit is contained in:
ryan-the-crayon
2024-11-19 13:14:38 -05:00
committed by GitHub
10 changed files with 318 additions and 2 deletions

View File

@ -18,6 +18,7 @@
"@lmstudio/lms-isomorphic": "^0.3.2",
"@lmstudio/lms-lmstudio": "^0.0.13",
"@lmstudio/sdk": "^0.2.0",
"@lmstudio/lms-es-plugin-runner": "^0.0.1",
"boxen": "^5.1.2",
"chalk": "^4.1.2",
"cmd-ts": "^0.13.0",

View File

@ -10,7 +10,7 @@ export async function askQuestion(prompt: string): Promise<boolean> {
cleaner.register(() => rl.close());
let answer: boolean | undefined;
do {
const answerText = await rl.question(prompt);
const answerText = await rl.question(prompt + " (Y/N): ");
if (answerText.toUpperCase() === "Y") {
answer = true;
} else if (answerText.toUpperCase() === "N") {

10
src/exists.ts Normal file
View File

@ -0,0 +1,10 @@
import { access } from "fs/promises";
export async function exists(path: string) {
try {
await access(path);
return true;
} catch {
return false;
}
}

47
src/findProjectFolder.ts Normal file
View File

@ -0,0 +1,47 @@
import { type SimpleLogger } from "@lmstudio/lms-common";
import { access } from "fs/promises";
import { dirname, join, resolve } from "path/posix";
/**
* From the given folder, recursively travels back up, until finds one folder with manifest.json.
*/
export async function findProjectFolder(logger: SimpleLogger, cwd: string) {
let currentDir = resolve(cwd);
let maximumDepth = 20;
while (maximumDepth > 0) {
maximumDepth--;
const manifestPath = join(currentDir, "manifest.json");
logger.debug("Trying to access", manifestPath);
try {
await access(manifestPath);
logger.debug("Found manifest.json at", currentDir);
return currentDir;
} catch (err) {
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
// Reached the root directory without finding manifest.json
return null;
}
currentDir = parentDir;
}
}
logger.debug("Reached maximum depth without finding manifest.json");
return null;
}
export async function findProjectFolderOrExit(logger: SimpleLogger, cwd: string) {
const projectFolder = await findProjectFolder(logger, cwd);
if (projectFolder === null) {
logger.errorText`
Could not find the project folder. Please invoke this command in a folder with a
manifest.json file.
`;
logger.errorText`
To create an empty plugin, use the \`lms create\` command, or create a new plugin in
LM Studio.
`;
process.exit(1);
}
return projectFolder;
}

View File

@ -29,6 +29,8 @@ const cli = subcommands({
unload,
create,
log,
// dev,
// push,
import: importCmd,
version,
bootstrap,

4
src/lmstudioPaths.ts Normal file
View File

@ -0,0 +1,4 @@
import { homedir } from "os";
import { join } from "path";
export const pluginsFolderPath = join(homedir(), ".cache", "lm-studio", "extensions", "plugins");

230
src/subcommands/dev.ts Normal file
View File

@ -0,0 +1,230 @@
import { SimpleLogger, text, Validator } from "@lmstudio/lms-common";
import { EsPluginRunnerWatcher, UtilBinary } from "@lmstudio/lms-es-plugin-runner";
import { pluginManifestSchema } from "@lmstudio/lms-shared-types/dist/PluginManifest";
import {
type LMStudioClient,
type PluginManifest,
type RegisterDevelopmentPluginOpts,
} from "@lmstudio/sdk";
import { type ChildProcessWithoutNullStreams } from "child_process";
import { boolean, command, flag } from "cmd-ts";
import { cp, mkdir, readFile } from "fs/promises";
import { join } from "path";
import { cwd } from "process";
import { askQuestion } from "../confirm";
import { createClient, createClientArgs } from "../createClient";
import { exists } from "../exists";
import { findProjectFolderOrExit } from "../findProjectFolder";
import { pluginsFolderPath } from "../lmstudioPaths";
import { createLogger, logLevelArgs } from "../logLevel";
type PluginProcessStatus = "stopped" | "starting" | "running" | "restarting";
class PluginProcess {
public constructor(
private readonly client: LMStudioClient,
private readonly registerDevelopmentPluginOpts: RegisterDevelopmentPluginOpts,
private readonly cwd: string,
private readonly logger: SimpleLogger,
) {}
private readonly node = new UtilBinary("node");
private readonly args = ["--enable-source-maps", join(".lmstudio", "dev.js")];
private readonly serverLogger = new SimpleLogger("plugin-server", this.logger);
private readonly stderrLogger = new SimpleLogger("stderr", this.logger);
private currentProcess: ChildProcessWithoutNullStreams | null = null;
private status: PluginProcessStatus = "stopped";
private unregister: (() => Promise<void>) | null = null;
private firstTime = true;
private async startProcess() {
this.status = "starting";
const { unregister, clientIdentifier, clientPasskey } =
await this.client.plugins.registerDevelopmentPlugin(this.registerDevelopmentPluginOpts);
if (this.firstTime) {
const manifest = this.registerDevelopmentPluginOpts.manifest;
const identifier = `${manifest.owner}/${manifest.name}`;
await this.client.system.notify({
title: `Plugin "${identifier}" started`,
description: "This plugin is run by lms CLI development server.",
});
this.firstTime = false;
}
this.unregister = unregister;
this.currentProcess = this.node.spawn(this.args, {
env: {
FORCE_COLOR: "1",
...process.env,
LMS_PLUGIN_CLIENT_IDENTIFIER: clientIdentifier,
LMS_PLUGIN_CLIENT_PASSKEY: clientPasskey,
},
cwd: this.cwd,
});
this.currentProcess.stdout.on("data", data => this.logger.info(data.toString("utf-8").trim()));
this.currentProcess.stderr.on("data", data =>
this.stderrLogger.error(data.toString("utf-8").trim()),
);
this.currentProcess.on("exit", async (code, signal) => {
await this.unregister?.();
this.unregister = null;
if (code !== null) {
this.serverLogger.warn(`Plugin process exited with code ${code}`);
} else {
if (signal === "SIGKILL") {
// OK to ignore because we killed it
} else {
this.serverLogger.warn(`Plugin process exited with signal ${signal}`);
}
}
if (this.status === "restarting") {
this.startProcess();
} else {
this.status = "stopped";
}
});
this.status = "running";
}
public run() {
switch (this.status) {
case "stopped": {
this.startProcess();
break;
}
case "starting": {
// Already starting. Do nothing.
break;
}
case "running": {
this.status = "restarting";
if (this.unregister === null) {
this.currentProcess?.kill("SIGKILL");
this.currentProcess = null;
} else {
this.unregister().then(() => {
this.unregister = null;
this.currentProcess?.kill("SIGKILL");
this.currentProcess = null;
});
}
break;
}
case "restarting": {
// Already restarting. Do nothing.
break;
}
}
}
}
export const dev = command({
name: "dev",
description: "Starts the development server for the plugin in the current folder.",
args: {
install: flag({
type: boolean,
long: "install",
short: "i",
description: text`
When specified, instead of starting the development server, installs the plugin to
LM Studio.
`,
}),
yes: flag({
type: boolean,
long: "yes",
short: "y",
description: text`
Suppress all confirmations and warnings. Useful for scripting.
- When used with --install, it will overwrite the plugin without asking.
`,
}),
...logLevelArgs,
...createClientArgs,
},
handler: async args => {
const logger = createLogger(args);
const client = await createClient(logger, args);
const projectPath = await findProjectFolderOrExit(logger, cwd());
const { install, yes } = args;
const manifestPath = join(projectPath, "manifest.json");
const manifestParseResult = pluginManifestSchema.safeParse(
JSON.parse(await readFile(manifestPath, "utf-8")),
);
if (!manifestParseResult.success) {
logger.error("Failed to parse the manifest file.");
logger.error(Validator.prettyPrintZod("manifest", manifestParseResult.error));
process.exit(1);
}
const manifest = manifestParseResult.data;
if (install) {
process.exit(await handleInstall(projectPath, manifest, logger, client, { yes }));
} else {
await handleDevServer(projectPath, manifest, logger, client);
}
},
});
async function handleInstall(
projectPath: string,
manifest: PluginManifest,
logger: SimpleLogger,
client: LMStudioClient,
{ yes }: { yes: boolean },
): Promise<number> {
// Currently, we naively copy paste the entire plugin folder to LM Studio, and then trigger a
// plugin re-index.
logger.info(`Installing the plugin ${manifest.owner}/${manifest.name}...`);
logger.debug("Copying from", projectPath);
const destinationPath = join(pluginsFolderPath, manifest.owner, manifest.name);
logger.debug("To", pluginsFolderPath);
if ((await exists(destinationPath)) && !yes) {
const result = await askQuestion(text`
Plugin ${manifest.owner}/${manifest.name} already exists. Do you want to overwrite it?
`);
if (!result) {
logger.info("Installation cancelled.");
return 1;
}
}
await mkdir(destinationPath, { recursive: true });
const startTime = Date.now();
await cp(projectPath, destinationPath, {
recursive: true,
dereference: true,
});
const endTime = Date.now();
logger.debug(`Copied in ${endTime - startTime}ms.`);
logger.debug("Reindexing plugins...");
await client.plugins.reindexPlugins();
return 0;
}
async function handleDevServer(
projectPath: string,
manifest: PluginManifest,
logger: SimpleLogger,
client: LMStudioClient,
) {
logger.info(`Starting the development server for ${manifest.owner}/${manifest.name}...`);
const esbuild = new UtilBinary("esbuild");
const watcher = new EsPluginRunnerWatcher(esbuild, cwd(), logger);
const pluginProcess = new PluginProcess(client, { manifest }, projectPath, logger);
watcher.updatedEvent.subscribe(() => {
pluginProcess.run();
});
await watcher.start();
client.system.whenDisconnected().then(() => {
logger.info("Disconnected from the server. Stopping the development server.");
process.exit(1);
});
}

View File

@ -273,7 +273,7 @@ export const get = command({
isAskingExitingBehavior = true;
logger.infoWithoutPrefix();
process.stdin.resume();
askQuestion("Continue to download in the background? (Y/N): ").then(confirmed => {
askQuestion("Continue to download in the background?").then(confirmed => {
if (confirmed) {
logger.info("Download will continue in the background.");
process.exit(1);

20
src/subcommands/push.ts Normal file
View File

@ -0,0 +1,20 @@
import { command } from "cmd-ts";
import { cwd } from "process";
import { createClient, createClientArgs } from "../createClient";
import { findProjectFolderOrExit } from "../findProjectFolder";
import { createLogger, logLevelArgs } from "../logLevel";
export const push = command({
name: "push",
description: "Uploads the plugin in the current folder to LM Studio Hub.",
args: {
...logLevelArgs,
...createClientArgs,
},
handler: async args => {
const logger = createLogger(args);
const client = await createClient(logger, args);
const projectPath = await findProjectFolderOrExit(logger, cwd());
await client.repository.push(projectPath);
},
});

View File

@ -24,6 +24,8 @@ export function printVersion() {
console.info(`\x1b[38;5;231mlms - LM Studio CLI - v${getVersion()}\x1b[0m`);
// console.info("Licensed under the MIT License");
console.info(chalk.gray("GitHub: https://github.com/lmstudio-ai/lmstudio-cli"));
console.info(process.execPath);
}
export const version = command({