diff --git a/docs/data-model.md b/docs/data-model.md index edf1ebb657..1dce303489 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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 diff --git a/docs/plugins.md b/docs/plugins.md index 8680b58559..6b21d8550c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 ` 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 diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index b205278f68..fd779016e0 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -77,6 +77,8 @@ describe("canonical CLI surface", () => { "ls", "logs", "install", + "status", + "update", "reload", "enable", "disable", diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index 0d96d09722..c11f1568f6 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -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 = { @@ -39,6 +52,33 @@ const pluginLogsSchema: OutputSchema = { ], }; +function shortCommit(commit: string | undefined): string { + return commit?.slice(0, 12) ?? "-"; +} + +const pluginStatusSchema: OutputSchema = { + 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 = { + 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> { - 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> { + 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> { + 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("", "Host filesystem directory") - .option("--id ", "Runtime plugin ID (defaults to paseo-plugin.json id)"), + .alias("add") + .description("Install a plugin from a directory or Git repository") + .argument("", "Host directory, owner/repo shorthand, or Git URL") + .option("--id ", "Runtime plugin ID (defaults to paseo-plugin.json id)") + .option("--ref ", "Git branch, tag, or commit") + .option("--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(""), + plugin.command(action).description(`${action} a plugin`).argument(""), ).action( withOutput((id: string, options: PluginOptions, _command: Command) => act(action, id, options), diff --git a/packages/cli/src/commands/plugin/shared.ts b/packages/cli/src/commands/plugin/shared.ts index cd74dc7efe..c85a8d7e32 100644 --- a/packages/cli/src/commands/plugin/shared.ts +++ b/packages/cli/src/commands/plugin/shared.ts @@ -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( host: string | undefined, @@ -45,3 +45,16 @@ export async function withPluginLogsClient( // 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( + host: string | undefined, + run: (client: DaemonClient) => Promise, +): Promise { + // 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, + ); +} diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts index 51ecfcf94d..bebc675405 100644 --- a/packages/cli/tests/e2e/plugin-lifecycle.test.ts +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -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 { + await execFileAsync("git", args, { cwd }); +} + async function main(): Promise { 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 { 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 { 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 }); } } diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 0921f01360..5c5c289eb7 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -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 { + 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 { + 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 { + 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({ diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts index 2d91502705..5c5efb1e1b 100644 --- a/packages/protocol/src/messages.plugins.test.ts +++ b/packages/protocol/src/messages.plugins.test.ts @@ -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({ diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index a9b46a6fc9..540f7af62c 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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(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; @@ -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; + +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; + +export const PluginSourceUpdateResponseSchema = z.object({ + type: z.literal("plugin.source.update.response"), + payload: z.object({ requestId: z.string(), plugins: z.array(PluginSourceUpdateItemSchema) }), +}); + function pluginActionResponse(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, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 3033be77d5..837bd7640d 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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); diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 755ddb054d..d3618f9d74 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -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[3]["runtime"]>; async function createPlugin(id: string, source: string): Promise { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-")); @@ -36,10 +40,10 @@ function createStore( function createService( home: string, plugins: Record = {}, - runtime?: ConstructorParameters[3], + dependencies: ConstructorParameters[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(); - const runtime: NonNullable[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(); - const runtime: NonNullable[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[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(); + 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"); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 513531b34f..844dd8db5a 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -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; getLogs(pluginId: string): PluginLogEntry[]; clearLogs(pluginId: string): void; + validatePlugin?(path: string): Promise; startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise; stopPluginById(pluginId: string): Promise; stopAll(): Promise; @@ -22,8 +27,23 @@ interface PluginRuntimePort { bindPaseoSessionHost(sessionHost: Parameters[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(); 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 { + 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 { + 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 { + 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 { 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 { + 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 { + 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; +} diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts new file mode 100644 index 0000000000..411e93e195 --- /dev/null +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -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 { + 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 { + 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); +}); diff --git a/packages/server/src/server/plugins/managed-source.ts b/packages/server/src/server/plugins/managed-source.ts new file mode 100644 index 0000000000..5142028999 --- /dev/null +++ b/packages/server/src/server/plugins/managed-source.ts @@ -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 {} + +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; + + 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 { + 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 { + 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 { + 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 { + await rm(candidate.versionRoot, { recursive: true, force: true }); + } + + async removeVersion(record: ManagedPluginRecord): Promise { + 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 { + 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 { + 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 { + const stagingRoot = path.join(this.root, ".staging", randomUUID()); + await mkdir(stagingRoot, { recursive: true, mode: 0o700 }); + return stagingRoot; + } + + private readRecords(): Record { + 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 { + 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 { + 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 { + 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 { + 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 { + const { stdout } = await runGitCommand(["rev-parse", "--verify", ref], { cwd }); + return stdout.trim(); +} + +async function fetchOrigin(cwd: string): Promise { + await runGitCommand(["fetch", "--prune", "--tags", "origin"], { + cwd, + envOverlay: GIT_ENV, + timeout: GIT_TIMEOUT_MS, + }); +} + +async function countCommits(cwd: string, current: string, latest: string): Promise { + if (current === latest) return 0; + const { stdout } = await runGitCommand(["rev-list", "--count", `${current}..${latest}`], { cwd }); + return Number.parseInt(stdout.trim(), 10); +} diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index c6edb0bb7c..31c9d87b95 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -244,6 +244,13 @@ export class PluginRuntime { this.appendLog(pluginId, "stdout", "[paseo] Plugin ready"); } + async validatePlugin(configuredPath: string): Promise { + const directory = path.resolve(configuredPath); + await readPluginManifest(directory); + const entryPath = await resolveEntryPath(directory); + await compilePlugin(entryPath); + } + async stopPluginById(pluginId: string): Promise { const loaded = this.plugins.get(pluginId); if (!loaded) return false; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index af3902898a..5a8490c27c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -474,6 +474,18 @@ export interface SessionOptions { id?: string; }): Promise; inspectDirectory(path: string): Promise<{ id: string }>; + installSource(input: { + source: string; + id?: string; + ref?: string; + pluginPath?: string; + }): Promise; + statusSources( + pluginId?: string, + ): Promise; + updateSources( + pluginId?: string, + ): Promise; reloadPlugin(pluginId: string): Promise; enablePlugin(pluginId: string): Promise; disablePlugin(pluginId: string): Promise; @@ -2077,6 +2089,43 @@ export class Session { } private dispatchPluginDirectoryMessage(msg: SessionInboundMessage): Promise | 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) => { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index b3e910f341..81c8ff9b5f 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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, diff --git a/public-docs/cli.md b/public-docs/cli.md index bac0102ecc..4b5b23d4cd 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -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 ` returns the plugin's recent daemon-side stdout and stderr. Add `--json` for +GitHub shorthand checks an existing host directory first. Use `--path ` for a plugin in +a monorepo. `paseo plugin logs ` returns the plugin's recent daemon-side stdout and stderr. Add `--json` for structured entries or `--host ` for another daemon. See the [Plugin reference](/docs/plugins/reference) for installation, trust, lifecycle, and log-retention behavior. diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index f387a365f5..acbeb13444 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -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: diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index b913daf116..2ce2c69f20 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -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 +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 ` 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 ` 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.