Files
ryan-the-crayon bca35e0cce Transition to rollup to support exports (#106)
* Use .js for imports

* WIP

* WIP

* Finish transitioning to esm

* Disable experimental commands

* Bring back bootstrap
2024-11-26 11:43:21 -05:00

232 lines
7.5 KiB
TypeScript

import { SimpleLogger, text, Validator } from "@lmstudio/lms-common";
import { EsPluginRunnerWatcher } from "@lmstudio/lms-es-plugin-runner/runner-watcher";
import { UtilBinary } from "@lmstudio/lms-es-plugin-runner/util-binary";
import { pluginManifestSchema } from "@lmstudio/lms-shared-types";
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.js";
import { createClient, createClientArgs } from "../createClient.js";
import { exists } from "../exists.js";
import { findProjectFolderOrExit } from "../findProjectFolder.js";
import { pluginsFolderPath } from "../lmstudioPaths.js";
import { createLogger, logLevelArgs } from "../logLevel.js";
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);
});
}