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
+9
View File
@@ -63,6 +63,9 @@ $PASEO_HOME/
├── runtime/
│ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
├── plugins/
│ ├── sources.json # Git origin, ref, commit, and managed checkout ownership
│ └── {pluginId}/{version}/checkout/ # Source checkout for one installed Git commit
└── push-tokens.json # Expo push notification tokens
```
@@ -251,6 +254,12 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
All fields are optional with sensible defaults.
Git-managed plugins still appear as directory sources in `config.json`. This keeps the plugin
runtime and protocol config compatible with directory-only clients. `plugins/sources.json` owns the
Git-specific origin, tracking ref, installed commit, repository subdirectory, and checkout root.
Paseo writes it atomically. An update creates and validates a new version directory before changing
the configured directory path; successful activation removes the old version.
### Profile lists
`terminalProfiles` and `agentProfiles` are both whole-list fields: a config patch replaces the
+29 -4
View File
@@ -1,9 +1,9 @@
# Local plugins
# Plugins
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
this first slice does not sandbox it.
Paseo does not sandbox it.
## Install a directory source
@@ -74,8 +74,33 @@ backend code can access the daemon machine, while client contributions run insid
Source changes are explicit. Run `paseo plugin reload <id>` to stop and fully tear down the old
plugin before compiling and starting from disk. A failed reload stays failed; Paseo does not restore
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Remove deletes only its
configuration, never its source directory. The global `pluginsEnabled` switch remains available.
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
never deletes it. The global `pluginsEnabled` switch remains available.
## Install a Git source
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
directory always wins over shorthand resolution.
```bash
paseo plugin add owner/repository
paseo plugin add https://git.example.com/owner/repository.git
paseo plugin add owner/monorepo --path plugins/review
paseo plugin add owner/repository --ref main
paseo plugin status
paseo plugin update review
paseo plugin update --all
```
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
commits. `update` checks out a new version, validates its manifest, compiles both bundles, and starts
it before changing the configured path. A compilation or startup failure restores the running
version. Removing a Git source deletes Paseo's managed checkout.
Git installation does not run a package manager or install script. A distributable source plugin
uses Paseo's host-provided modules or commits any other bundled source it needs. Native React Native
dependencies work only when the Paseo app already ships the native module.
Server contributions can write to stdout and stderr with normal Node logging. Paseo adds `[paseo]`
entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are
+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,
);
}
@@ -1,9 +1,12 @@
#!/usr/bin/env npx tsx
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { connectToDaemon } from "../../src/utils/client.ts";
import { createE2ETestContext } from "../helpers/test-daemon.ts";
@@ -12,8 +15,15 @@ const pluginSource = `export default function contribute(plugin: unknown) {
return () => undefined;
}`;
const execFileAsync = promisify(execFile);
async function git(cwd: string, args: string[]): Promise<void> {
await execFileAsync("git", args, { cwd });
}
async function main(): Promise<void> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-cli-e2e-"));
const gitDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-cli-e2e-"));
const context = await createE2ETestContext({ timeout: 45_000 });
try {
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "cli-e2e" }));
@@ -27,6 +37,40 @@ async function main(): Promise<void> {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.close();
await git(gitDirectory, ["init", "-b", "main"]);
await git(gitDirectory, ["config", "user.name", "Paseo Tests"]);
await git(gitDirectory, ["config", "user.email", "paseo@example.test"]);
await writeFile(
path.join(gitDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "git-cli-e2e" }),
);
await writeFile(path.join(gitDirectory, "index.ts"), pluginSource);
await git(gitDirectory, ["add", "-A"]);
await git(gitDirectory, ["commit", "-m", "initial"]);
const gitInstall = await context.paseo([
"plugin",
"add",
pathToFileURL(gitDirectory).href,
"--json",
]);
assert.equal(gitInstall.exitCode, 0, gitInstall.stderr);
assert.equal(JSON.parse(gitInstall.stdout).source, "git");
await writeFile(
path.join(gitDirectory, "index.ts"),
`${pluginSource}\nconst updated = true;\n`,
);
await git(gitDirectory, ["add", "-A"]);
await git(gitDirectory, ["commit", "-m", "update"]);
const status = await context.paseo(["plugin", "status", "git-cli-e2e", "--json"]);
assert.equal(status.exitCode, 0, status.stderr);
assert.equal(JSON.parse(status.stdout)[0].updateAvailable, true);
const update = await context.paseo(["plugin", "update", "git-cli-e2e", "--json"]);
assert.equal(update.exitCode, 0, update.stderr);
assert.equal(JSON.parse(update.stdout)[0].updated, true);
const reload = await context.paseo(["plugin", "reload", "cli-e2e", "--json"]);
assert.equal(reload.exitCode, 0, reload.stderr);
assert.equal(JSON.parse(reload.stdout).status, "running");
@@ -41,12 +85,15 @@ async function main(): Promise<void> {
const remove = await context.paseo(["plugin", "remove", "cli-e2e", "--json"]);
assert.equal(remove.exitCode, 0, remove.stderr);
const removeGit = await context.paseo(["plugin", "remove", "git-cli-e2e", "--json"]);
assert.equal(removeGit.exitCode, 0, removeGit.stderr);
const list = await context.paseo(["plugin", "ls", "--json"]);
assert.equal(list.exitCode, 0, list.stderr);
assert.deepEqual(JSON.parse(list.stdout), []);
} finally {
await context.stop();
await rm(directory, { recursive: true, force: true });
await rm(gitDirectory, { recursive: true, force: true });
}
}
+45
View File
@@ -105,6 +105,8 @@ import type {
WorkspaceRecoveryState,
PluginListItem,
PluginLogEntry,
PluginSourceStatusItem,
PluginSourceUpdateItem,
AgentSkillSelection,
AgentSkillsStatus,
AgentSkillsSaveResult,
@@ -4944,6 +4946,49 @@ export class DaemonClient {
return payload.plugin;
}
async installPluginSource(input: {
source: string;
id?: string;
ref?: string;
pluginPath?: string;
}): Promise<PluginListItem> {
const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: { type: "plugin.source.install.request", requestId, ...input },
responseType: "plugin.source.install.response",
});
return payload.plugin;
}
async getPluginSourceStatus(pluginId?: string): Promise<PluginSourceStatusItem[]> {
const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "plugin.source.status.request",
requestId,
...(pluginId ? { pluginId } : {}),
},
responseType: "plugin.source.status.response",
});
return payload.plugins;
}
async updatePluginSources(pluginId?: string): Promise<PluginSourceUpdateItem[]> {
const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "plugin.source.update.request",
requestId,
...(pluginId ? { pluginId } : {}),
},
responseType: "plugin.source.update.response",
});
return payload.plugins;
}
async inspectDirectoryPlugin(path: string): Promise<{ id: string }> {
const requestId = this.createRequestId();
return this.sendCorrelatedSessionRequest({
@@ -48,6 +48,47 @@ describe("plugin protocol compatibility", () => {
).toBe("plugin.directory.install.response");
});
it("uses capability-gated source management RPCs", () => {
expect(
SessionInboundMessageSchema.parse({
type: "plugin.source.install.request",
requestId: "request-install",
source: "owner/repository",
ref: "main",
pluginPath: "plugins/review",
}).type,
).toBe("plugin.source.install.request");
expect(
SessionOutboundMessageSchema.parse({
type: "plugin.source.status.response",
payload: {
requestId: "request-status",
plugins: [
{
id: "review",
source: "git",
path: "/plugins/review",
currentCommit: "a".repeat(40),
latestCommit: "b".repeat(40),
commitsBehind: 2,
updateAvailable: true,
},
],
},
}).type,
).toBe("plugin.source.status.response");
const older = StatusMessageSchema.parse({
type: "status",
payload: {
status: "server_info",
serverId: "older-host",
features: { pluginManagement: true },
},
});
expect(older.payload.features?.pluginGitManagement).toBeUndefined();
});
it("uses a namespaced snapshot RPC for structured plugin logs", () => {
expect(
SessionInboundMessageSchema.parse({
+70
View File
@@ -1438,6 +1438,27 @@ export const PluginDirectoryInspectRequestSchema = z.object({
path: z.string().min(1),
});
export const PluginSourceInstallRequestSchema = z.object({
type: z.literal("plugin.source.install.request"),
requestId: z.string(),
source: z.string().min(1),
id: PluginIdSchema.optional(),
ref: z.string().min(1).optional(),
pluginPath: z.string().min(1).optional(),
});
export const PluginSourceStatusRequestSchema = z.object({
type: z.literal("plugin.source.status.request"),
requestId: z.string(),
pluginId: PluginIdSchema.optional(),
});
export const PluginSourceUpdateRequestSchema = z.object({
type: z.literal("plugin.source.update.request"),
requestId: z.string(),
pluginId: PluginIdSchema.optional(),
});
function pluginIdRequest<const Type extends string>(type: Type) {
return z.object({ type: z.literal(type), requestId: z.string(), pluginId: PluginIdSchema });
}
@@ -2996,6 +3017,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PluginLogsGetRequestSchema,
PluginDirectoryInstallRequestSchema,
PluginDirectoryInspectRequestSchema,
PluginSourceInstallRequestSchema,
PluginSourceStatusRequestSchema,
PluginSourceUpdateRequestSchema,
PluginReloadRequestSchema,
PluginEnableRequestSchema,
PluginDisableRequestSchema,
@@ -3348,6 +3372,8 @@ export const ServerInfoStatusPayloadSchema = z
pluginManagement: z.boolean().optional(),
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
pluginLogs: z.boolean().optional(),
// COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26.
pluginGitManagement: z.boolean().optional(),
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20.
// A daemon that predates this flag keeps `addTheme` in the server bundle it compiles,
// so a theme plugin cannot start there at all.
@@ -6114,6 +6140,10 @@ export const PluginListItemSchema = z.object({
path: z.string(),
enabled: z.boolean(),
status: PluginStatusSchema,
source: z.enum(["directory", "git"]).optional(),
remote: z.string().optional(),
ref: z.string().optional(),
commit: z.string().optional(),
error: z.string().optional(),
});
export type PluginListItem = z.infer<typeof PluginListItemSchema>;
@@ -6150,6 +6180,43 @@ export const PluginDirectoryInspectResponseSchema = z.object({
payload: z.object({ requestId: z.string(), id: PluginIdSchema }),
});
export const PluginSourceInstallResponseSchema = z.object({
type: z.literal("plugin.source.install.response"),
payload: z.object({ requestId: z.string(), plugin: PluginListItemSchema }),
});
export const PluginSourceStatusItemSchema = z.object({
id: PluginIdSchema,
source: z.enum(["directory", "git"]),
path: z.string(),
remote: z.string().optional(),
ref: z.string().optional(),
currentCommit: z.string().optional(),
latestCommit: z.string().optional(),
commitsBehind: z.number().int().nonnegative().optional(),
updateAvailable: z.boolean().optional(),
});
export type PluginSourceStatusItem = z.infer<typeof PluginSourceStatusItemSchema>;
export const PluginSourceStatusResponseSchema = z.object({
type: z.literal("plugin.source.status.response"),
payload: z.object({ requestId: z.string(), plugins: z.array(PluginSourceStatusItemSchema) }),
});
export const PluginSourceUpdateItemSchema = z.object({
id: PluginIdSchema,
previousCommit: z.string(),
currentCommit: z.string(),
commits: z.number().int().nonnegative(),
updated: z.boolean(),
});
export type PluginSourceUpdateItem = z.infer<typeof PluginSourceUpdateItemSchema>;
export const PluginSourceUpdateResponseSchema = z.object({
type: z.literal("plugin.source.update.response"),
payload: z.object({ requestId: z.string(), plugins: z.array(PluginSourceUpdateItemSchema) }),
});
function pluginActionResponse<const Type extends string>(type: Type) {
return z.object({
type: z.literal(type),
@@ -6214,6 +6281,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
PluginLogsGetResponseSchema,
PluginDirectoryInstallResponseSchema,
PluginDirectoryInspectResponseSchema,
PluginSourceInstallResponseSchema,
PluginSourceStatusResponseSchema,
PluginSourceUpdateResponseSchema,
PluginReloadResponseSchema,
PluginEnableResponseSchema,
PluginDisableResponseSchema,
+4 -1
View File
@@ -226,6 +226,7 @@ import {
} from "./hub/relationship-remote.js";
import { DaemonExecutions } from "./hub/daemon-executions.js";
import { PluginService } from "./plugins/index.js";
import { ManagedPluginSources } from "./plugins/managed-source.js";
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]";
@@ -609,7 +610,9 @@ export async function createPaseoDaemon(
});
const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
const browserToolsBroker = new BrowserToolsBroker({});
const pluginRuntime = new PluginService(logger, daemonConfigStore, daemonVersion);
const pluginRuntime = new PluginService(logger, daemonConfigStore, daemonVersion, {
managedSources: new ManagedPluginSources(config.paseoHome),
});
const serverId = getOrCreateServerId(config.paseoHome, { logger });
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
@@ -1,12 +1,16 @@
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import pino from "pino";
import { afterEach, describe, expect, it } from "vitest";
import { DaemonConfigStore } from "../daemon-config-store.js";
import { PluginService } from "./index.js";
import { ManagedPluginSources } from "./managed-source.js";
import { runGitCommand } from "../../utils/run-git-command.js";
const roots: string[] = [];
type TestPluginRuntime = NonNullable<ConstructorParameters<typeof PluginService>[3]["runtime"]>;
async function createPlugin(id: string, source: string): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-"));
@@ -36,10 +40,10 @@ function createStore(
function createService(
home: string,
plugins: Record<string, { source: "directory"; path: string; enabled?: boolean }> = {},
runtime?: ConstructorParameters<typeof PluginService>[3],
dependencies: ConstructorParameters<typeof PluginService>[3] = {},
): PluginService {
return bindTestSessionHost(
new PluginService(pino({ level: "silent" }), createStore(home, plugins), "0.4.0", runtime),
new PluginService(pino({ level: "silent" }), createStore(home, plugins), "0.4.0", dependencies),
);
}
@@ -94,7 +98,7 @@ function createPausedRuntime() {
markStarted = resolve;
});
const running = new Set<string>();
const runtime: NonNullable<ConstructorParameters<typeof PluginService>[2]> = {
const runtime: TestPluginRuntime = {
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
@@ -126,7 +130,7 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
});
const starts: string[] = [];
const running = new Set<string>();
const runtime: NonNullable<ConstructorParameters<typeof PluginService>[2]> = {
const runtime: TestPluginRuntime = {
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
@@ -163,7 +167,7 @@ describe("PluginService", () => {
},
];
const cleared: string[] = [];
const runtime: NonNullable<ConstructorParameters<typeof PluginService>[2]> = {
const runtime: TestPluginRuntime = {
catalog: () => [],
invoke: async () => undefined,
getLogs: () => entries,
@@ -180,7 +184,7 @@ describe("PluginService", () => {
const service = createService(
home,
{ example: { source: "directory", path: "/plugins/example", enabled: false } },
runtime,
{ runtime },
);
expect(service.getLogs("example")).toEqual(entries);
@@ -255,6 +259,157 @@ describe("PluginService", () => {
await service.stopAllPlugins();
}, 20_000);
it("prefers an existing directory and installs its selected plugin subdirectory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "owner-repository-"));
roots.push(repository);
const pluginDirectory = path.join(repository, "plugins", "review");
await mkdir(pluginDirectory, { recursive: true });
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "local-monorepo" }),
);
await writeFile(
path.join(pluginDirectory, "index.ts"),
"export default function contribute(plugin: unknown) { void plugin; return () => undefined; }",
);
const service = createService(home);
await service.start();
await expect(
service.installSource({ source: repository, pluginPath: "plugins/review" }),
).resolves.toMatchObject({ id: "local-monorepo", path: pluginDirectory, status: "running" });
await service.stopAllPlugins();
}, 20_000);
it("keeps the running commit when a Git update fails validation", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "paseo-plugin-repository-"));
roots.push(repository);
await runGitCommand(["init", "-b", "main"], { cwd: repository });
await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: repository });
await runGitCommand(["config", "user.email", "paseo@example.test"], { cwd: repository });
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({ id: "git-update" }),
);
await writeFile(
path.join(repository, "index.ts"),
"export default function contribute(plugin: unknown) { void plugin; return () => undefined; }",
);
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "initial"], { cwd: repository });
const store = createStore(home);
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0", {
managedSources: new ManagedPluginSources(home),
}),
);
await service.start();
const installed = await service.installSource({ source: pathToFileURL(repository).href });
const installedPath = installed.path;
const installedCommit = installed.commit;
await writeFile(
path.join(repository, "index.ts"),
'export default function contribute(plugin: unknown) { void plugin; throw new Error("broken update"); }',
);
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "broken update"], { cwd: repository });
await expect(service.updateSources("git-update")).rejects.toThrow();
expect(service.catalog()).toEqual([
expect.objectContaining({ id: "git-update", clientBundle: expect.any(String) }),
]);
expect(service.listPlugins()).toEqual([
expect.objectContaining({
id: "git-update",
path: installedPath,
commit: installedCommit,
status: "running",
}),
]);
await service.stopAllPlugins();
}, 30_000);
it("activates an update when the enabled plugin previously failed to start", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "paseo-plugin-repository-"));
roots.push(repository);
await runGitCommand(["init", "-b", "main"], { cwd: repository });
await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: repository });
await runGitCommand(["config", "user.email", "paseo@example.test"], { cwd: repository });
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({ id: "failed-update" }),
);
await writeFile(path.join(repository, "index.ts"), "export default () => () => {};\n");
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "initial"], { cwd: repository });
const managedSources = new ManagedPluginSources(home);
let initial = await managedSources.prepareInstall({
source: pathToFileURL(repository).href,
});
initial = await managedSources.place("failed-update", initial);
managedSources.commit("failed-update", initial.record);
const running = new Set<string>();
const starts: string[] = [];
let failNextStart = true;
const runtime: TestPluginRuntime = {
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
clearLogs: () => undefined,
validatePlugin: async () => undefined,
startPlugin: async (pluginId, sourcePath, canPublish) => {
starts.push(sourcePath);
if (failNextStart) {
failNextStart = false;
throw new Error("initial start failed");
}
if (canPublish()) running.add(pluginId);
},
stopPluginById: async (pluginId) => running.delete(pluginId),
stopAll: async () => running.clear(),
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
const store = createStore(home, {
"failed-update": { source: "directory", path: initial.directory, enabled: true },
});
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime,
managedSources,
});
await service.start();
expect(service.listPlugins()).toEqual([
expect.objectContaining({ id: "failed-update", status: "failed" }),
]);
await writeFile(
path.join(repository, "index.ts"),
"export default () => () => { new Date(); };\n",
);
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "fixed"], { cwd: repository });
await expect(service.updateSources("failed-update")).resolves.toEqual([
expect.objectContaining({ id: "failed-update", updated: true }),
]);
expect(starts).toHaveLength(2);
expect(starts[1]).not.toBe(initial.directory);
expect(service.listPlugins()).toEqual([
expect.objectContaining({ id: "failed-update", path: starts[1], status: "running" }),
]);
await service.stopAllPlugins();
}, 30_000);
it("disables and removes a plugin without touching its source directory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
@@ -327,7 +482,9 @@ export default function contribute(plugin: unknown) {
});
store.patch({ pluginsEnabled: false });
const paused = createPausedRuntime();
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", paused.runtime);
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime: paused.runtime,
});
await service.start();
store.patch({ pluginsEnabled: true });
@@ -349,7 +506,9 @@ export default function contribute(plugin: unknown) {
slow: { source: "directory", path: "/plugins/slow", enabled: false },
});
const paused = createPausedRuntime();
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", paused.runtime);
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime: paused.runtime,
});
await service.start();
const enabling = expect(service.enablePlugin("slow")).rejects.toThrow(
@@ -372,7 +531,9 @@ export default function contribute(plugin: unknown) {
slow: { source: "directory", path: "/plugins/slow", enabled: false },
});
const paused = createPluginSelectivePausedRuntime("occupier");
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", paused.runtime);
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime: paused.runtime,
});
await service.start();
const occupying = service.enablePlugin("occupier");
+205 -8
View File
@@ -1,12 +1,16 @@
import path from "node:path";
import { stat } from "node:fs/promises";
import type pino from "pino";
import {
PluginIdSchema,
type PluginLogEntry,
type PluginListItem,
type PluginSource,
type PluginSourceStatusItem,
type PluginSourceUpdateItem,
} from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js";
import { readPluginManifest } from "./manifest.js";
import { PluginRuntime } from "./runtime.js";
@@ -15,6 +19,7 @@ interface PluginRuntimePort {
invoke(pluginId: string, method: string, input: unknown): Promise<unknown>;
getLogs(pluginId: string): PluginLogEntry[];
clearLogs(pluginId: string): void;
validatePlugin?(path: string): Promise<void>;
startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise<void>;
stopPluginById(pluginId: string): Promise<boolean>;
stopAll(): Promise<void>;
@@ -22,8 +27,23 @@ interface PluginRuntimePort {
bindPaseoSessionHost(sessionHost: Parameters<PluginRuntime["bindPaseoSessionHost"]>[0]): void;
}
interface PluginServiceDependencies {
runtime?: PluginRuntimePort;
managedSources?: ManagedPluginSources;
}
function resolvePluginStatus(input: {
enabled: boolean;
globallyEnabled: boolean;
running: boolean;
}): PluginListItem["status"] {
if (!input.enabled || !input.globallyEnabled) return "disabled";
return input.running ? "running" : "failed";
}
export class PluginService {
private readonly runtime: PluginRuntimePort;
private readonly managedSources: ManagedPluginSources | null;
private readonly logger: pino.Logger;
private readonly errors = new Map<string, string>();
private readonly listeners = new Set<(pluginId: string) => void>();
@@ -35,10 +55,11 @@ export class PluginService {
logger: pino.Logger,
private readonly configStore: DaemonConfigStore,
daemonVersion: string,
runtime: PluginRuntimePort = new PluginRuntime(logger, daemonVersion),
dependencies: PluginServiceDependencies = {},
) {
this.logger = logger.child({ module: "plugin-service" });
this.runtime = runtime;
this.runtime = dependencies.runtime ?? new PluginRuntime(logger, daemonVersion);
this.managedSources = dependencies.managedSources ?? null;
this.runtime.subscribe((pluginId, error) => {
if (error) this.errors.set(pluginId, error);
this.notify(pluginId);
@@ -77,17 +98,26 @@ export class PluginService {
return Object.entries(config.plugins ?? {})
.map(([id, source]) => {
const enabled = source.enabled !== false;
if (!enabled || config.pluginsEnabled !== true) {
return { id, path: source.path, enabled, status: "disabled" as const };
}
const error = this.errors.get(id);
const item: PluginListItem = {
id,
path: source.path,
enabled,
status: running.has(id) ? ("running" as const) : ("failed" as const),
status: resolvePluginStatus({
enabled,
globallyEnabled: config.pluginsEnabled === true,
running: running.has(id),
}),
};
if (error) item.error = error;
const managed = this.managedSources?.get(id);
if (managed) {
item.source = "git";
const remote = this.managedSources?.displayRemote(id);
if (remote) item.remote = remote;
item.ref = managed.requestedRef ?? managed.trackingBranch ?? managed.commit;
item.commit = managed.commit;
}
const error = this.errors.get(id);
if (error && item.status === "failed") item.error = error;
return item;
})
.sort((left, right) => left.id.localeCompare(right.id));
@@ -131,6 +161,79 @@ export class PluginService {
return readPluginManifest(path.resolve(configuredPath));
}
async installSource(input: {
source: string;
id?: string;
ref?: string;
pluginPath?: string;
}): Promise<PluginListItem> {
const directory = path.resolve(input.source);
const info = await stat(directory).catch(() => null);
if (info?.isDirectory()) {
if (input.ref) throw new Error("Plugin --ref is only valid for Git sources");
const pluginDirectory = resolveLocalPluginPath(directory, input.pluginPath);
return this.installDirectory({ path: pluginDirectory, id: input.id });
}
const managedSources = this.requireManagedSources();
return this.enqueue(async () => {
let candidate = await managedSources.prepareInstall(input);
const pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId);
if (this.configStore.get().plugins?.[pluginId]) {
await managedSources.discard(candidate);
throw new Error(
`Plugin ID "${pluginId}" is already configured; choose another ID with --id`,
);
}
candidate = await managedSources.place(pluginId, candidate);
try {
await this.validateCandidate(candidate);
} catch (error) {
await managedSources.discard(candidate);
throw error;
}
const sources = {
...this.configStore.get().plugins,
[pluginId]: { source: "directory" as const, path: candidate.directory, enabled: true },
};
managedSources.commit(pluginId, candidate.record);
this.configStore.patch({ plugins: sources });
if (this.canPublish(pluginId)) await this.startConfigured(pluginId);
this.notify(pluginId);
const installed = this.requireItem(pluginId);
if (installed.status === "failed") {
throw new Error(installed.error ?? `Plugin failed to start: ${pluginId}`);
}
return installed;
});
}
async statusSources(pluginId?: string): Promise<PluginSourceStatusItem[]> {
return this.enqueue(async () => {
const sources = this.configStore.get().plugins ?? {};
const selected = pluginId
? [[pluginId, this.requireSource(pluginId)] as const]
: Object.entries(sources);
const managedSources = this.requireManagedSources();
const statuses: PluginSourceStatusItem[] = [];
for (const [id, source] of selected) {
statuses.push(await managedSources.status(id, source.path));
}
return statuses.sort((left, right) => left.id.localeCompare(right.id));
});
}
async updateSources(pluginId?: string): Promise<PluginSourceUpdateItem[]> {
return this.enqueue(async () => {
const sources = this.configStore.get().plugins ?? {};
const ids = pluginId
? [pluginId]
: Object.keys(sources).filter((id) => this.managedSources?.get(id));
const updates: PluginSourceUpdateItem[] = [];
for (const id of ids.sort()) updates.push(await this.updateSource(id));
return updates;
});
}
async reloadPlugin(pluginId: string): Promise<PluginListItem> {
return this.enqueue(async () => {
const source = this.requireEnabledSource(pluginId);
@@ -182,6 +285,7 @@ export class PluginService {
this.runtime.clearLogs(pluginId);
this.errors.delete(pluginId);
this.notify(pluginId);
await this.managedSources?.remove(pluginId);
});
}
@@ -263,6 +367,88 @@ export class PluginService {
this.errors.set(pluginId, error instanceof Error ? error.message : String(error));
}
private async updateSource(pluginId: string): Promise<PluginSourceUpdateItem> {
const managedSources = this.requireManagedSources();
const source = this.requireSource(pluginId);
const previous = managedSources.get(pluginId);
if (!previous) throw new Error(`Plugin is not managed by Git: ${pluginId}`);
const prepared = await managedSources.prepareUpdate(pluginId, source.path);
if (!prepared.candidate) {
return {
id: pluginId,
previousCommit: previous.commit,
currentCommit: previous.commit,
commits: 0,
updated: false,
};
}
const candidate = await managedSources.place(pluginId, prepared.candidate);
try {
await this.validateCandidate(candidate);
} catch (error) {
await managedSources.discard(candidate);
throw error;
}
const current = this.configStore.get().plugins?.[pluginId];
if (!current) {
await managedSources.discard(candidate);
throw new Error(`Plugin is no longer configured: ${pluginId}`);
}
const isRunning = this.runtime.catalog().some((plugin) => plugin.id === pluginId);
const isEnabled = current.enabled !== false;
const isGloballyEnabled = this.configStore.get().pluginsEnabled === true;
const shouldActivate = isEnabled && isGloballyEnabled;
if (shouldActivate) {
if (isRunning) await this.runtime.stopPluginById(pluginId);
try {
await this.startExplicit(pluginId, candidate.directory);
} catch (error) {
this.errors.delete(pluginId);
const latest = this.configStore.get().plugins?.[pluginId];
const isStillConfigured = latest !== undefined;
const isStillEnabled = isStillConfigured && latest.enabled !== false;
const isStillGloballyEnabled = this.configStore.get().pluginsEnabled === true;
const canRestore = isRunning && isStillEnabled && isStillGloballyEnabled;
if (canRestore) await this.startExplicit(pluginId, source.path);
await managedSources.discard(candidate);
this.notify(pluginId);
throw error;
}
}
const activatedSource = this.configStore.get().plugins?.[pluginId];
if (!activatedSource) {
await this.runtime.stopPluginById(pluginId);
await managedSources.discard(candidate);
throw new Error(`Plugin is no longer configured: ${pluginId}`);
}
this.patchSource(pluginId, { ...activatedSource, path: candidate.directory });
managedSources.commit(pluginId, candidate.record);
await managedSources.removeVersion(previous);
this.errors.delete(pluginId);
this.notify(pluginId);
return {
id: pluginId,
previousCommit: previous.commit,
currentCommit: candidate.record.commit,
commits: prepared.commits,
updated: true,
};
}
private validateCandidate(candidate: ManagedPluginCandidate): Promise<void> {
if (!this.runtime.validatePlugin) {
throw new Error("Plugin runtime cannot validate managed Git sources");
}
return this.runtime.validatePlugin(candidate.directory);
}
private requireManagedSources(): ManagedPluginSources {
if (!this.managedSources) throw new Error("Git plugin management is unavailable");
return this.managedSources;
}
private requireSource(pluginId: string): PluginSource {
PluginIdSchema.parse(pluginId);
const source = this.configStore.get().plugins?.[pluginId];
@@ -292,3 +478,14 @@ export class PluginService {
for (const listener of this.listeners) listener(pluginId);
}
}
function resolveLocalPluginPath(directory: string, pluginPath: string | undefined): string {
if (!pluginPath) return directory;
if (path.isAbsolute(pluginPath)) throw new Error("Plugin --path must be relative to the source");
const pluginDirectory = path.resolve(directory, pluginPath);
const relative = path.relative(directory, pluginDirectory);
const escapesSource =
relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
if (escapesSource) throw new Error("Plugin --path must stay inside the source directory");
return pluginDirectory;
}
@@ -0,0 +1,94 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { runGitCommand } from "../../utils/run-git-command.js";
import { ManagedPluginSources } from "./managed-source.js";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function createRepository(): Promise<string> {
const repository = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-repository-"));
roots.push(repository);
await runGitCommand(["init", "-b", "main"], { cwd: repository });
await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: repository });
await runGitCommand(["config", "user.email", "paseo@example.test"], { cwd: repository });
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({ id: "managed-example" }),
);
await writeFile(path.join(repository, "index.ts"), "export default () => () => {};\n");
await commitAll(repository, "initial");
return repository;
}
async function commitAll(repository: string, message: string): Promise<string> {
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", message], { cwd: repository });
const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository });
return stdout.trim();
}
describe("managed Git plugin sources", () => {
it("does not expose Git URL credentials when cloning fails", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-home-"));
roots.push(home);
const sources = new ManagedPluginSources(home);
const failure = sources.prepareInstall({
source: "https://oauth2:super-secret@127.0.0.1:1/missing.git",
});
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
});
it("tracks branches, prepares updates, persists commits, and keeps tags pinned", async () => {
const repository = await createRepository();
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-home-"));
roots.push(home);
const remote = pathToFileURL(repository).href;
const sources = new ManagedPluginSources(home);
let candidate = await sources.prepareInstall({ source: remote });
expect(candidate.defaultId).toBe("managed-example");
candidate = await sources.place("managed-example", candidate);
sources.commit("managed-example", candidate.record);
const initial = candidate.record.commit;
await writeFile(
path.join(repository, "index.ts"),
"export default () => () => { new Date(); };\n",
);
const latest = await commitAll(repository, "update");
const status = await sources.status("managed-example", candidate.directory);
expect(status).toMatchObject({
currentCommit: initial,
latestCommit: latest,
commitsBehind: 1,
updateAvailable: true,
});
const prepared = await sources.prepareUpdate("managed-example", candidate.directory);
expect(prepared.commits).toBe(1);
if (!prepared.candidate) throw new Error("Expected an update candidate");
const updated = await sources.place("managed-example", prepared.candidate);
sources.commit("managed-example", updated.record);
expect(await readFile(path.join(updated.directory, "index.ts"), "utf8")).toContain("new Date");
expect(new ManagedPluginSources(home).get("managed-example")?.commit).toBe(latest);
await runGitCommand(["tag", "v1", initial], { cwd: repository });
let pinned = await sources.prepareInstall({ source: remote, ref: "v1" });
pinned = await sources.place("pinned-example", pinned);
sources.commit("pinned-example", pinned.record);
expect(await sources.status("pinned-example", pinned.directory)).toMatchObject({
currentCommit: initial,
latestCommit: initial,
commitsBehind: 0,
updateAvailable: false,
});
}, 30_000);
});
@@ -0,0 +1,386 @@
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { PluginIdSchema, type PluginSourceStatusItem } from "@getpaseo/protocol/messages";
import { runGitCommand } from "../../utils/run-git-command.js";
import { ensurePrivateDirectory, writePrivateFileAtomicSync } from "../private-files.js";
import { readPluginManifest } from "./manifest.js";
const GIT_TIMEOUT_MS = 120_000;
const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" } as const;
const ManagedPluginRecordSchema = z
.object({
remote: z.string().min(1),
requestedRef: z.string().min(1).nullable(),
trackingBranch: z.string().min(1).nullable(),
commit: z.string().regex(/^[0-9a-f]{40,64}$/),
pluginPath: z.string(),
checkoutRoot: z.string().min(1),
})
.strict();
const ManagedPluginRecordsSchema = z.record(PluginIdSchema, ManagedPluginRecordSchema);
export interface ManagedPluginRecord extends z.infer<typeof ManagedPluginRecordSchema> {}
export interface ManagedPluginCandidate {
defaultId: string;
directory: string;
record: ManagedPluginRecord;
versionRoot: string;
}
interface InstallInput {
source: string;
ref?: string;
pluginPath?: string;
}
interface RefResolution {
commit: string;
requestedRef: string | null;
trackingBranch: string | null;
}
export class ManagedPluginSources {
private readonly root: string;
private readonly metadataPath: string;
private readonly records: Record<string, ManagedPluginRecord>;
constructor(paseoHome: string) {
this.root = path.join(paseoHome, "plugins");
this.metadataPath = path.join(this.root, "sources.json");
this.records = this.readRecords();
}
get(pluginId: string): ManagedPluginRecord | null {
return this.records[pluginId] ?? null;
}
displayRemote(pluginId: string): string | null {
const remote = this.records[pluginId]?.remote;
return remote ? redactRemoteCredentials(remote) : null;
}
async prepareInstall(input: InstallInput): Promise<ManagedPluginCandidate> {
const remote = normalizeGitSource(input.source);
const pluginPath = normalizePluginPath(input.pluginPath);
const stagingRoot = await this.createStagingRoot();
try {
const checkoutRoot = path.join(stagingRoot, "checkout");
await clone(remote, checkoutRoot);
const resolution = await resolveRequestedRef(checkoutRoot, input.ref);
await checkout(checkoutRoot, resolution.commit);
const directory = path.resolve(checkoutRoot, pluginPath);
assertPluginPath(checkoutRoot, directory);
const { id: defaultId } = await readPluginManifest(directory);
return {
defaultId,
directory,
record: {
remote,
requestedRef: resolution.requestedRef,
trackingBranch: resolution.trackingBranch,
commit: resolution.commit,
pluginPath,
checkoutRoot,
},
versionRoot: stagingRoot,
};
} catch (error) {
await rm(stagingRoot, { recursive: true, force: true });
throw error;
}
}
async place(
pluginId: string,
candidate: ManagedPluginCandidate,
): Promise<ManagedPluginCandidate> {
const pluginRoot = path.join(this.root, pluginId);
ensurePrivateDirectory(pluginRoot);
const versionRoot = path.join(
pluginRoot,
`${candidate.record.commit.slice(0, 12)}-${randomUUID()}`,
);
await rename(candidate.versionRoot, versionRoot);
const checkoutRoot = path.join(versionRoot, "checkout");
const directory = path.resolve(checkoutRoot, candidate.record.pluginPath);
return {
...candidate,
directory,
versionRoot,
record: { ...candidate.record, checkoutRoot },
};
}
async status(pluginId: string, configuredPath: string): Promise<PluginSourceStatusItem> {
const record = this.records[pluginId];
if (!record) return { id: pluginId, source: "directory", path: configuredPath };
if (!record.trackingBranch) {
return {
id: pluginId,
source: "git",
path: configuredPath,
remote: redactRemoteCredentials(record.remote),
ref: record.requestedRef ?? record.commit,
currentCommit: record.commit,
latestCommit: record.commit,
commitsBehind: 0,
updateAvailable: false,
};
}
await fetchOrigin(record.checkoutRoot);
const latestCommit = await revParse(
record.checkoutRoot,
`refs/remotes/origin/${record.trackingBranch}^{commit}`,
);
const commitsBehind = await countCommits(record.checkoutRoot, record.commit, latestCommit);
return {
id: pluginId,
source: "git",
path: configuredPath,
remote: redactRemoteCredentials(record.remote),
ref: record.requestedRef ?? record.trackingBranch,
currentCommit: record.commit,
latestCommit,
commitsBehind,
updateAvailable: latestCommit !== record.commit,
};
}
async prepareUpdate(
pluginId: string,
configuredPath: string,
): Promise<{ candidate: ManagedPluginCandidate | null; commits: number }> {
const record = this.records[pluginId];
if (!record) throw new Error(`Plugin is not managed by Git: ${pluginId}`);
const status = await this.status(pluginId, configuredPath);
const latestCommit = status.latestCommit;
if (!latestCommit || latestCommit === record.commit) return { candidate: null, commits: 0 };
const candidate = await this.prepareCommit(record, latestCommit);
return { candidate, commits: status.commitsBehind ?? 0 };
}
commit(pluginId: string, record: ManagedPluginRecord): void {
this.records[pluginId] = record;
this.writeRecords();
}
async discard(candidate: ManagedPluginCandidate): Promise<void> {
await rm(candidate.versionRoot, { recursive: true, force: true });
}
async removeVersion(record: ManagedPluginRecord): Promise<void> {
const versionRoot = path.dirname(record.checkoutRoot);
const pluginRoot = path.dirname(versionRoot);
const expectedRoot = path.resolve(this.root);
if (path.dirname(pluginRoot) !== expectedRoot) {
throw new Error(
`Managed plugin checkout is outside the plugin store: ${record.checkoutRoot}`,
);
}
await rm(versionRoot, { recursive: true, force: true });
}
async remove(pluginId: string): Promise<void> {
if (!this.records[pluginId]) return;
delete this.records[pluginId];
this.writeRecords();
await rm(path.join(this.root, pluginId), { recursive: true, force: true });
}
private async prepareCommit(
record: ManagedPluginRecord,
commit: string,
): Promise<ManagedPluginCandidate> {
const stagingRoot = await this.createStagingRoot();
try {
const checkoutRoot = path.join(stagingRoot, "checkout");
await clone(record.remote, checkoutRoot);
const resolvedCommit = await revParse(checkoutRoot, `${commit}^{commit}`);
if (resolvedCommit !== commit)
throw new Error(`Git source no longer contains commit ${commit}`);
await checkout(checkoutRoot, commit);
const directory = path.resolve(checkoutRoot, record.pluginPath);
assertPluginPath(checkoutRoot, directory);
const { id: defaultId } = await readPluginManifest(directory);
return {
defaultId,
directory,
versionRoot: stagingRoot,
record: { ...record, commit, checkoutRoot },
};
} catch (error) {
await rm(stagingRoot, { recursive: true, force: true });
throw error;
}
}
private async createStagingRoot(): Promise<string> {
const stagingRoot = path.join(this.root, ".staging", randomUUID());
await mkdir(stagingRoot, { recursive: true, mode: 0o700 });
return stagingRoot;
}
private readRecords(): Record<string, ManagedPluginRecord> {
if (!existsSync(this.metadataPath)) return {};
return ManagedPluginRecordsSchema.parse(JSON.parse(readFileSync(this.metadataPath, "utf8")));
}
private writeRecords(): void {
writePrivateFileAtomicSync(this.metadataPath, `${JSON.stringify(this.records, null, 2)}\n`);
}
}
function normalizeGitSource(source: string): string {
const github = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(source);
if (github) {
const repository = github[2].replace(/\.git$/, "");
return `https://github.com/${github[1]}/${repository}.git`;
}
const isUrl = /^(?:https?|ssh|git|file):\/\//.test(source);
const isScpStyle = /^[^/@\s]+@[^:\s]+:.+$/.test(source);
if (isUrl || isScpStyle) return source;
throw new Error(`Plugin source is neither an existing directory nor a Git URL: ${source}`);
}
function redactRemoteCredentials(remote: string): string {
if (!/^(?:https?|ssh|git|file):\/\//.test(remote)) return remote;
const url = new URL(remote);
url.username = "";
url.password = "";
return url.href;
}
function normalizePluginPath(pluginPath: string | undefined): string {
if (!pluginPath || pluginPath === ".") return ".";
if (path.isAbsolute(pluginPath))
throw new Error("Plugin --path must be relative to the repository");
const normalized = path.normalize(pluginPath);
if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
throw new Error("Plugin --path must stay inside the repository");
}
return normalized;
}
function assertPluginPath(checkoutRoot: string, directory: string): void {
const relative = path.relative(checkoutRoot, directory);
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new Error("Plugin path escapes the repository");
}
}
async function clone(remote: string, checkoutRoot: string): Promise<void> {
const publicRemote = redactRemoteCredentials(remote);
const cloneRemote = publicRemote === remote ? remote : "https://paseo.invalid/plugin.git";
const envOverlay =
cloneRemote === remote
? GIT_ENV
: {
...GIT_ENV,
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: `url.${remote}.insteadOf`,
GIT_CONFIG_VALUE_0: cloneRemote,
};
try {
await runGitCommand(["clone", "--no-checkout", "--", cloneRemote, checkoutRoot], {
cwd: path.dirname(checkoutRoot),
envOverlay,
timeout: GIT_TIMEOUT_MS,
});
} catch (error) {
throw redactRemoteError(error, remote);
}
}
function redactRemoteError(error: unknown, remote: string): Error {
let message = error instanceof Error ? error.message : String(error);
const publicRemote = redactRemoteCredentials(remote);
message = message.split(remote).join(publicRemote);
if (!/^(?:https?|ssh|git|file):\/\//.test(remote)) return new Error(message);
const url = new URL(remote);
for (const credential of [url.username, url.password]) {
if (!credential) continue;
message = message.split(credential).join("[redacted]");
let decoded = credential;
try {
decoded = decodeURIComponent(credential);
} catch {
// URL credentials can contain a literal percent sign.
}
if (decoded !== credential) message = message.split(decoded).join("[redacted]");
}
return new Error(message);
}
async function checkout(checkoutRoot: string, commit: string): Promise<void> {
await runGitCommand(["checkout", "--detach", commit], {
cwd: checkoutRoot,
envOverlay: GIT_ENV,
timeout: GIT_TIMEOUT_MS,
});
await runGitCommand(["submodule", "update", "--init", "--recursive"], {
cwd: checkoutRoot,
envOverlay: GIT_ENV,
timeout: GIT_TIMEOUT_MS,
});
}
async function resolveRequestedRef(
checkoutRoot: string,
requestedRef: string | undefined,
): Promise<RefResolution> {
if (!requestedRef) {
const { stdout } = await runGitCommand(
["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
{ cwd: checkoutRoot },
);
const trackingBranch = stdout.trim().replace(/^origin\//, "");
const commit = await revParse(checkoutRoot, `refs/remotes/origin/${trackingBranch}^{commit}`);
return { commit, requestedRef: null, trackingBranch };
}
if (requestedRef.startsWith("-")) throw new Error("Plugin Git ref cannot start with '-'");
const branchRef = `refs/remotes/origin/${requestedRef}`;
if (await refExists(checkoutRoot, branchRef)) {
const commit = await revParse(checkoutRoot, `${branchRef}^{commit}`);
return { commit, requestedRef, trackingBranch: requestedRef };
}
const tagRef = `refs/tags/${requestedRef}`;
if (await refExists(checkoutRoot, tagRef)) {
const commit = await revParse(checkoutRoot, `${tagRef}^{commit}`);
return { commit, requestedRef, trackingBranch: null };
}
const commit = await revParse(checkoutRoot, `${requestedRef}^{commit}`);
return { commit, requestedRef, trackingBranch: null };
}
async function refExists(cwd: string, ref: string): Promise<boolean> {
const result = await runGitCommand(["show-ref", "--verify", "--quiet", ref], {
cwd,
acceptExitCodes: [0, 1],
});
return result.exitCode === 0;
}
async function revParse(cwd: string, ref: string): Promise<string> {
const { stdout } = await runGitCommand(["rev-parse", "--verify", ref], { cwd });
return stdout.trim();
}
async function fetchOrigin(cwd: string): Promise<void> {
await runGitCommand(["fetch", "--prune", "--tags", "origin"], {
cwd,
envOverlay: GIT_ENV,
timeout: GIT_TIMEOUT_MS,
});
}
async function countCommits(cwd: string, current: string, latest: string): Promise<number> {
if (current === latest) return 0;
const { stdout } = await runGitCommand(["rev-list", "--count", `${current}..${latest}`], { cwd });
return Number.parseInt(stdout.trim(), 10);
}
@@ -244,6 +244,13 @@ export class PluginRuntime {
this.appendLog(pluginId, "stdout", "[paseo] Plugin ready");
}
async validatePlugin(configuredPath: string): Promise<void> {
const directory = path.resolve(configuredPath);
await readPluginManifest(directory);
const entryPath = await resolveEntryPath(directory);
await compilePlugin(entryPath);
}
async stopPluginById(pluginId: string): Promise<boolean> {
const loaded = this.plugins.get(pluginId);
if (!loaded) return false;
+49
View File
@@ -474,6 +474,18 @@ export interface SessionOptions {
id?: string;
}): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
inspectDirectory(path: string): Promise<{ id: string }>;
installSource(input: {
source: string;
id?: string;
ref?: string;
pluginPath?: string;
}): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
statusSources(
pluginId?: string,
): Promise<import("@getpaseo/protocol/messages").PluginSourceStatusItem[]>;
updateSources(
pluginId?: string,
): Promise<import("@getpaseo/protocol/messages").PluginSourceUpdateItem[]>;
reloadPlugin(pluginId: string): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
enablePlugin(pluginId: string): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
disablePlugin(pluginId: string): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
@@ -2077,6 +2089,43 @@ export class Session {
}
private dispatchPluginDirectoryMessage(msg: SessionInboundMessage): Promise<void> | undefined {
if (msg.type === "plugin.source.install.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime
.installSource({
source: msg.source,
...(msg.id ? { id: msg.id } : {}),
...(msg.ref ? { ref: msg.ref } : {}),
...(msg.pluginPath ? { pluginPath: msg.pluginPath } : {}),
})
.then((plugin) => {
this.emit({
type: "plugin.source.install.response",
payload: { requestId: msg.requestId, plugin },
});
return undefined;
});
}
if (msg.type === "plugin.source.status.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime.statusSources(msg.pluginId).then((plugins) => {
this.emit({
type: "plugin.source.status.response",
payload: { requestId: msg.requestId, plugins },
});
return undefined;
});
}
if (msg.type === "plugin.source.update.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime.updateSources(msg.pluginId).then((plugins) => {
this.emit({
type: "plugin.source.update.response",
payload: { requestId: msg.requestId, plugins },
});
return undefined;
});
}
if (msg.type === "plugin.directory.install.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime.installDirectory({ path: msg.path, id: msg.id }).then((plugin) => {
@@ -1685,6 +1685,7 @@ export class VoiceAssistantWebSocketServer {
// COMPAT(plugins): added in v0.3.0, remove gate after 2027-08-07.
plugins: true,
pluginManagement: true,
pluginGitManagement: true,
pluginLogs: true,
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20.
pluginThemes: true,
+8 -2
View File
@@ -144,11 +144,16 @@ The output includes each script's lifecycle and supervised terminal ID. Services
## Plugins
Create and manage trusted local plugins on a daemon:
Create and manage trusted plugins on a daemon:
```bash
paseo plugin init /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin
paseo plugin add owner/repository
paseo plugin add https://git.example.com/owner/repository.git --ref main
paseo plugin status
paseo plugin update my-plugin
paseo plugin update --all
paseo plugin ls
paseo plugin reload my-plugin
paseo plugin logs my-plugin
@@ -157,7 +162,8 @@ paseo plugin enable my-plugin
paseo plugin remove my-plugin
```
`paseo plugin logs <id>` returns the plugin's recent daemon-side stdout and stderr. Add `--json` for
GitHub shorthand checks an existing host directory first. Use `--path <directory>` for a plugin in
a monorepo. `paseo plugin logs <id>` returns the plugin's recent daemon-side stdout and stderr. Add `--json` for
structured entries or `--host <target>` for another daemon. See the
[Plugin reference](/docs/plugins/reference) for installation, trust, lifecycle, and log-retention
behavior.
+24 -3
View File
@@ -1,6 +1,6 @@
---
title: Plugin quickstart
description: Build, install, and reload a trusted local Paseo plugin with a workspace panel.
description: Build, install, share, and update a trusted Paseo plugin.
nav: Quickstart
order: 45
category: Plugins
@@ -8,8 +8,7 @@ category: Plugins
# Plugin quickstart
> **Experimental:** Plugins are intended for personal, local use and are not designed for
> distribution yet. The plugin API is still evolving, so expect breaking changes and updates to
> **Experimental:** The plugin API is still evolving, so expect breaking changes and updates to
> your plugins as Paseo evolves.
See the [plugin roadmap](https://github.com/getpaseo/paseo/labels/plugins) for planned contribution
@@ -120,6 +119,28 @@ paseo plugin ls
Open a workspace, press **⌘K** on macOS or **Ctrl+K** on Windows and Linux, and choose **Open workspace overview**. It opens as a normal workspace tab. If the item does not appear, confirm that **Enable plugins** is on, the plugin status is `running` in `paseo plugin ls`, and the client is viewing the host where you installed it.
To install a plugin published through GitHub or another Git host:
```bash
paseo plugin add owner/repository
paseo plugin add https://git.example.com/owner/repository.git
paseo plugin add owner/monorepo --path plugins/workspace
paseo plugin add owner/repository --ref main
```
An omitted `--ref` tracks the default branch. Explicit branches track updates; tags and commits are
pinned. Check and apply updates with:
```bash
paseo plugin status
paseo plugin update workspace-plugin
paseo plugin update --all
```
Paseo validates and compiles the new commit before replacing a running version. If startup fails,
the previous version is restored. Git installation runs no package manager or install scripts, so
published plugins must use Paseo's host-provided modules or include the source they bundle.
## Edit and reload
Source changes are explicit:
+14 -1
View File
@@ -610,6 +610,12 @@ failures stay inside the plugin error boundary.
paseo plugin init /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin --id another-runtime-id
paseo plugin add owner/repository
paseo plugin add https://git.example.com/owner/repository.git --ref main
paseo plugin add owner/monorepo --path plugins/review
paseo plugin status [id]
paseo plugin update <id>
paseo plugin update --all
paseo plugin ls
paseo plugin reload my-plugin
paseo plugin logs my-plugin
@@ -618,7 +624,14 @@ paseo plugin enable my-plugin
paseo plugin remove my-plugin
```
Pass `--host <url>` to management commands when the target is not the CLI's default daemon. `remove` deletes only the daemon configuration; it never deletes the source directory. The install-time `--id` is the runtime ID and allows the same directory to be installed more than once.
Pass `--host <url>` to management commands when the target is not the CLI's default daemon. `remove`
never deletes a directory source; it deletes the managed checkout for a Git source. The install-time
`--id` is the runtime ID and allows the same directory or repository to be installed more than once.
An existing directory wins over `owner/repository` GitHub shorthand. Omit `--ref` to track the
default branch. Explicit branches track updates; tags and commits stay pinned. Git installation
runs no package manager and no install scripts. `update` validates and compiles the candidate before
activation, then restores the installed commit if startup fails.
Run `npm run typecheck` before install or reload. Never edit the daemon config directly.