mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
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:
@@ -77,6 +77,8 @@ describe("canonical CLI surface", () => {
|
||||
"ls",
|
||||
"logs",
|
||||
"install",
|
||||
"status",
|
||||
"update",
|
||||
"reload",
|
||||
"enable",
|
||||
"disable",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user