feat(plugins): install and update Git sources (#3920)

Make every trusted Git repository directly distributable without introducing a registry or package manager. Managed checkouts retain their resolved commit, track branches explicitly, and stage validation before activation so failed updates restore the running version.
This commit is contained in:
Mohamed Boudra
2026-08-27 15:24:01 +08:00
committed by GitHub
parent 4de913fb82
commit ecfb77b014
20 changed files with 1327 additions and 41 deletions
+2
View File
@@ -77,6 +77,8 @@ describe("canonical CLI surface", () => {
"ls",
"logs",
"install",
"status",
"update",
"reload",
"enable",
"disable",
+107 -11
View File
@@ -1,14 +1,27 @@
import { Command } from "commander";
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
import path from "node:path";
import type {
PluginListItem,
PluginLogEntry,
PluginSourceStatusItem,
PluginSourceUpdateItem,
} from "@getpaseo/protocol/messages";
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 } from "./shared.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<PluginListItem> = {
@@ -39,6 +52,33 @@ const pluginLogsSchema: OutputSchema<PluginLogEntry> = {
],
};
function shortCommit(commit: string | undefined): string {
return commit?.slice(0, 12) ?? "-";
}
const pluginStatusSchema: OutputSchema<PluginSourceStatusItem> = {
idField: "id",
columns: [
{ header: "PLUGIN", field: "id", width: 20 },
{ header: "SOURCE", field: "source", width: 10 },
{ header: "CURRENT", field: (plugin) => shortCommit(plugin.currentCommit), width: 14 },
{ header: "LATEST", field: (plugin) => shortCommit(plugin.latestCommit), width: 14 },
{ header: "COMMITS", field: (plugin) => String(plugin.commitsBehind ?? 0), width: 8 },
{ header: "REF", field: (plugin) => plugin.ref ?? "-", width: 24 },
],
};
const pluginUpdateSchema: OutputSchema<PluginSourceUpdateItem> = {
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,
@@ -69,16 +109,59 @@ export async function runPluginLogsCommand(
}
async function install(
directory: string,
source: string,
options: PluginOptions,
_command: Command,
): Promise<SingleResult<PluginListItem>> {
const data = await withPluginManagementClient(options.host, (client) =>
client.installDirectoryPlugin(directory, options.id),
);
const isExplicitPath =
path.isAbsolute(source) ||
source === "." ||
source === ".." ||
source.startsWith("./") ||
source.startsWith("../") ||
source.startsWith(".\\") ||
source.startsWith("..\\");
const canUseLegacyDirectoryInstall = isExplicitPath && !options.ref && !options.path;
const data = canUseLegacyDirectoryInstall
? await withPluginManagementClient(options.host, (client) =>
client.installDirectoryPlugin(source, options.id),
)
: await withPluginSourceClient(options.host, (client) =>
client.installPluginSource({
source,
...(options.id ? { id: options.id } : {}),
...(options.ref ? { ref: options.ref } : {}),
...(options.path ? { pluginPath: options.path } : {}),
}),
);
return { type: "single", data, schema: pluginSchema };
}
async function status(
pluginId: string | undefined,
options: PluginOptions,
_command: Command,
): Promise<ListResult<PluginSourceStatusItem>> {
const data = await withPluginSourceClient(options.host, (client) =>
client.getPluginSourceStatus(pluginId),
);
return { type: "list", data, schema: pluginStatusSchema };
}
async function update(
pluginId: string | undefined,
options: PluginOptions,
_command: Command,
): Promise<ListResult<PluginSourceUpdateItem>> {
if ((pluginId === undefined) === (options.all !== true)) {
throw new Error("Choose one plugin ID or pass --all");
}
const data = await withPluginSourceClient(options.host, (client) =>
client.updatePluginSources(pluginId),
);
return { type: "list", data, schema: pluginUpdateSchema };
}
async function act(
action: "reload" | "enable" | "disable",
pluginId: string,
@@ -105,7 +188,7 @@ async function remove(
}
export function createPluginCommand(): Command {
const plugin = new Command("plugin").description("Manage trusted local plugins");
const plugin = new Command("plugin").description("Manage trusted plugins");
addJsonOption(
plugin
.command("init")
@@ -122,13 +205,26 @@ export function createPluginCommand(): Command {
addJsonAndDaemonHostOptions(
plugin
.command("install")
.description("Install a local plugin directory")
.argument("<directory>", "Host filesystem directory")
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)"),
.alias("add")
.description("Install a plugin from a directory or Git repository")
.argument("<source>", "Host directory, owner/repo shorthand, or Git URL")
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
.option("--ref <ref>", "Git branch, tag, or commit")
.option("--path <path>", "Plugin directory within the repository"),
).action(withOutput(install));
addJsonAndDaemonHostOptions(
plugin.command("status").description("Check plugin source updates").argument("[id]"),
).action(withOutput(status));
addJsonAndDaemonHostOptions(
plugin
.command("update")
.description("Update a Git-managed plugin")
.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 local plugin`).argument("<id>"),
plugin.command(action).description(`${action} a plugin`).argument("<id>"),
).action(
withOutput((id: string, options: PluginOptions, _command: Command) =>
act(action, id, options),
+14 -1
View File
@@ -2,7 +2,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { CommandError } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
type PluginFeature = "pluginManagement" | "pluginLogs";
type PluginFeature = "pluginManagement" | "pluginLogs" | "pluginGitManagement";
async function withPluginClient<T>(
host: string | undefined,
@@ -45,3 +45,16 @@ export async function withPluginLogsClient<T>(
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
return withPluginClient(host, "pluginLogs", "Update the host to view plugin logs.", run);
}
export async function withPluginSourceClient<T>(
host: string | undefined,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
// COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26.
return withPluginClient(
host,
"pluginGitManagement",
"Update the host to install and update Git plugins.",
run,
);
}