import { Command } from "commander"; import path from "node:path"; import type { PluginListItem, PluginLogEntry, PluginSourceUpdateItem, } from "@getpaseo/protocol/messages"; import { formatPluginSourceReference, parsePluginSourceReference, } from "@getpaseo/protocol/plugin-source-reference"; import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js"; import { withOutput } from "../../output/index.js"; import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js"; import { scaffoldPluginDirectory, type PluginScaffold } from "./scaffold.js"; import { withPluginLogsClient, withPluginManagementClient, withPluginSourceClient, } from "./shared.js"; interface PluginOptions extends CommandOptions { host?: string; id?: string; ref?: string; path?: string; all?: boolean; } const pluginSchema: OutputSchema = { idField: "id", columns: [ { header: "PLUGIN", field: "id", width: 20 }, { header: "STATUS", field: "status", width: 10 }, { header: "ENABLED", field: (plugin) => (plugin.enabled ? "yes" : "no"), width: 8 }, { header: "SOURCE", field: (plugin) => plugin.source ?? "directory", width: 10 }, { header: "COMMIT", field: (plugin) => shortCommit(plugin.commit), width: 14 }, { header: "REF", field: (plugin) => plugin.ref ?? "-", width: 24 }, { header: "DIRECTORY", field: "path", width: 40 }, { header: "ERROR", field: (plugin) => plugin.error ?? "", width: 40 }, ], }; const scaffoldSchema: OutputSchema = { idField: "id", columns: [ { header: "PLUGIN", field: "id", width: 20 }, { header: "DIRECTORY", field: "directory", width: 60 }, ], }; const pluginLogsSchema: OutputSchema = { idField: (entry) => String(entry.sequence), columns: [ { header: "TIME", field: "timestamp", width: 24 }, { header: "STREAM", field: "stream", width: 8 }, { header: "MESSAGE", field: "message", width: 80 }, ], }; function shortCommit(commit: string | undefined): string { return commit?.slice(0, 12) ?? "-"; } const pluginUpdateSchema: OutputSchema = { idField: "id", columns: [ { header: "PLUGIN", field: "id", width: 20 }, { header: "PREVIOUS", field: (plugin) => shortCommit(plugin.previousCommit), width: 14 }, { header: "CURRENT", field: (plugin) => shortCommit(plugin.currentCommit), width: 14 }, { header: "COMMITS", field: (plugin) => String(plugin.commits), width: 8 }, { header: "UPDATED", field: (plugin) => (plugin.updated ? "yes" : "no"), width: 8 }, ], }; export async function runPluginInitCommand( directory: string, options: PluginOptions, _command: Command, ): Promise> { return { type: "single", data: await scaffoldPluginDirectory(directory, options.id), schema: scaffoldSchema, }; } export async function runPluginListCommand( pluginId: string | undefined, options: PluginOptions, _command: Command, ): Promise> { const plugins = await withPluginManagementClient(options.daemonTarget, (client) => client.listPlugins(), ); const data = pluginId ? plugins.filter((plugin) => plugin.id === pluginId) : plugins; if (pluginId && data.length === 0) throw new Error(`Plugin is not configured: ${pluginId}`); return { type: "list", data, schema: pluginSchema }; } export async function runPluginLogsCommand( pluginId: string, options: PluginOptions, _command: Command, ): Promise> { const data = await withPluginLogsClient(options.daemonTarget, (client) => client.getPluginLogs(pluginId), ); return { type: "list", data, schema: pluginLogsSchema }; } async function install( source: string, options: PluginOptions, _command: Command, ): Promise> { process.stderr.write( "Trusting plugin code: server code and Git build commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n", ); const isExplicitPath = path.isAbsolute(source) || source === "." || source === ".." || source.startsWith("./") || source.startsWith("../") || source.startsWith(".\\") || source.startsWith("..\\"); const hasPluginPathSuffix = parsePluginSourceReference(source).pluginPath !== undefined; const canUseLegacyDirectoryInstall = isExplicitPath && !hasPluginPathSuffix && !options.ref && !options.path; const sourceReference = formatPluginSourceReference(source, options.path); const data = canUseLegacyDirectoryInstall ? await withPluginManagementClient(options.daemonTarget, (client) => client.installDirectoryPlugin(source, options.id), ) : await withPluginSourceClient(options.daemonTarget, (client) => client.installPluginSource({ source: sourceReference, ...(options.id ? { id: options.id } : {}), ...(options.ref ? { ref: options.ref } : {}), }), ); return { type: "single", data, schema: pluginSchema }; } async function update( pluginId: string | undefined, options: PluginOptions, _command: Command, ): Promise> { if ((pluginId === undefined) === (options.all !== true)) { throw new Error("Choose one plugin ID or pass --all"); } const data = await withPluginSourceClient(options.daemonTarget, (client) => client.updatePluginSources(pluginId), ); return { type: "list", data, schema: pluginUpdateSchema }; } async function act( action: "reload" | "enable" | "disable", pluginId: string, options: PluginOptions, ): Promise> { const data = await withPluginManagementClient(options.daemonTarget, (client) => client[`${action}Plugin`](pluginId), ); return { type: "single", data, schema: pluginSchema }; } async function remove( pluginId: string, options: PluginOptions, _command: Command, ): Promise> { const data = await withPluginManagementClient(options.daemonTarget, async (client) => { const current = (await client.listPlugins()).find((plugin) => plugin.id === pluginId); if (!current) throw new Error(`Plugin is not configured: ${pluginId}`); await client.removePlugin(pluginId); return { ...current, enabled: false, status: "disabled" as const }; }); return { type: "single", data, schema: pluginSchema }; } export function createPluginCommand(): Command { const plugin = new Command("plugin").description("Manage trusted, unsandboxed plugins"); addJsonOption( plugin .command("init") .description("Create a typecheckable local plugin") .argument("") .option("--id ", "Manifest plugin ID (defaults to the directory name)"), ).action(withOutput(runPluginInitCommand)); addJsonAndDaemonHostOptions( plugin.command("ls").description("List configured plugins").argument("[id]"), ).action(withOutput(runPluginListCommand)); addJsonAndDaemonHostOptions(plugin.command("status", { hidden: true }).argument("[id]")).action( withOutput(runPluginListCommand), ); addJsonAndDaemonHostOptions( plugin.command("logs").description("Show recent plugin output").argument(""), ).action(withOutput(runPluginLogsCommand)); addJsonAndDaemonHostOptions( plugin .command("install") .alias("add") .description("Trust and install a plugin from a directory or Git repository") .argument("", "Host directory, Git source, or Git source:plugin/path") .option("--id ", "Runtime plugin ID (defaults to paseo-plugin.json id)") .option("--ref ", "Git branch, tag, or commit") .option("--path ", "Legacy form of the :plugin/path source suffix"), ).action(withOutput(install)); addJsonAndDaemonHostOptions( plugin .command("update") .description("Fetch and install Git-managed plugin updates") .argument("[id]") .option("--all", "Update every Git-managed plugin"), ).action(withOutput(update)); for (const action of ["reload", "enable", "disable"] as const) { addJsonAndDaemonHostOptions( plugin.command(action).description(`${action} a plugin`).argument(""), ).action( withOutput((id: string, options: PluginOptions, _command: Command) => act(action, id, options), ), ); } addJsonAndDaemonHostOptions( plugin.command("remove").description("Remove plugin configuration").argument(""), ).action(withOutput(remove)); return plugin; }