mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user