feat(plugins): install npm sources and review CLI updates

Treat install selectors as one-time choices. Keep source identity separate from the installed revision, and resolve ordinary updates from the current upstream default. npm only acquires directories; plugin loading remains independent of npm.
This commit is contained in:
Mohamed Boudra
2026-09-16 23:27:45 +02:00
parent 3f0a74d3fb
commit c3a1e0027d
36 changed files with 2965 additions and 425 deletions
@@ -6,7 +6,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import type { TestInfo } from "@playwright/test";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "../support/fixtures";
import { expect, test as base, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
@@ -18,6 +18,31 @@ import {
openSettingsHost,
} from "../support/helpers/settings";
import {
startNpmRegistry,
npmPluginPackages,
} from "../../../../scripts/test-support/npm-registry.mjs";
const test = base.extend<{}, { npmRegistry: Awaited<ReturnType<typeof startNpmRegistry>> }>({
npmRegistry: [
async ({ browserName: _browserName }, provide) => {
const registry = await startNpmRegistry(npmPluginPackages());
try {
await provide(registry);
} finally {
await registry.close();
}
},
{ scope: "worker" },
],
e2eDaemonEnvironment: [
async ({ npmRegistry }, provide) => {
await provide(npmRegistry.env);
},
{ scope: "worker" },
],
});
function observePluginCatalog(page: Page) {
let responses = 0;
page.on("websocket", (socket) => {
@@ -416,3 +441,68 @@ test("installs a Git source after a failed source remains editable", async ({ pa
await rm(disabledDirectory, { recursive: true, force: true });
}
});
for (const viewport of [
{ width: 1280, height: 900 },
{ width: 390, height: 844 },
]) {
test(`installs an npm source and manages its row at ${viewport.width}px`, async ({
page,
}, testInfo) => {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
try {
await page.setViewportSize(viewport);
await client.patchDaemonConfig({ pluginsEnabled: true });
await gotoAppShell(page);
await openNpmPluginSettings(page, viewport.width);
await installPlugin(page, "npm:missing-plugin");
await expect(page.getByTestId("plugin-management-feedback")).toContainText("404");
await expect(page.getByLabel("Plugin source")).toHaveValue("npm:missing-plugin");
await installPlugin(page, "npm:@paseo-fixture/review@^2.0.0");
await expect(page.getByText("Installed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toBeVisible();
await expectSourceHierarchy(
page,
"Installed from the npm fixture registry",
"npm:@paseo-fixture/review · 2.0.0",
);
await expect(
page.getByText("Installed from the npm fixture registry", { exact: true }),
).toBeVisible();
await page.screenshot({
path: testInfo.outputPath(`npm-plugin-${viewport.width}.png`),
animations: "disabled",
});
await selectPluginAction(page, "npm-review", "Reload");
await expect(page.getByText("Reloaded npm-review", { exact: true })).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await selectPluginAction(page, "npm-review", "Remove");
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
} finally {
await client.removePlugin("npm-review").catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close();
}
});
}
async function openNpmPluginSettings(page: Page, width: number) {
if (width < 600) await openCompactPluginSettings(page);
else await openPluginSettings(page);
}
async function expectSourceHierarchy(page: Page, description: string, source: string) {
const descriptionText = page.getByText(description, { exact: false });
const sourceText = page.getByText(source, { exact: false });
const descriptionSize = await descriptionText.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize),
);
const sourceSize = await sourceText.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize),
);
expect(sourceSize).toBeLessThan(descriptionSize);
await expect(sourceText).toHaveCSS("color", "rgb(161, 161, 170)");
await expect(descriptionText).toHaveCSS("color", "rgb(113, 113, 122)");
}
@@ -27,7 +27,8 @@ import { settingsStyles } from "@/styles/settings";
export { SettingsGroup } from "./headings/settings-group";
export { SettingsSection } from "./headings/settings-section";
interface AppSettingsRowProps extends SettingsRowProps {
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint"> {
hint?: ReactNode;
labelAccessory?: ReactNode;
}
@@ -67,7 +68,7 @@ export function SettingsRow({
) : (
<Text style={settingsStyles.rowTitle}>{label}</Text>
)}
{hint ? <Text style={settingsStyles.rowHint}>{hint}</Text> : null}
{typeof hint === "string" ? <Text style={settingsStyles.rowHint}>{hint}</Text> : hint}
{error ? (
<Text accessibilityRole="alert" style={settingsStyles.rowError}>
{error}
@@ -16,7 +16,8 @@ void testI18n;
const runtime = vi.hoisted(() => ({
connected: true,
supported: true,
sourceSupported: true,
sourceSupported: true as boolean | undefined,
gitSupported: true,
logsSupported: true,
client: null as DaemonClient | null,
}));
@@ -29,7 +30,8 @@ vi.mock("@/runtime/host-runtime", () => ({
vi.mock("@/runtime/host-features", () => ({
useHostFeature: (_serverId: string, feature: string) => {
if (feature === "pluginLogs") return runtime.logsSupported;
if (feature === "pluginGitManagement") return runtime.sourceSupported;
if (feature === "pluginGitManagement") return runtime.gitSupported;
if (feature === "pluginSourceInstallation") return runtime.sourceSupported;
return runtime.supported;
},
}));
@@ -155,6 +157,7 @@ describe("HostPluginsPage", () => {
runtime.connected = true;
runtime.supported = true;
runtime.sourceSupported = true;
runtime.gitSupported = true;
runtime.logsSupported = true;
runtime.client = null;
vi.stubGlobal(
@@ -269,7 +272,31 @@ describe("HostPluginsPage", () => {
expect(screen.queryByText("/plugins/failed-example")).toBeNull();
});
it("renders install pending through the real form and mutation", async () => {
it("shows current npm revision and a directory identity without a description", async () => {
const client = createClient();
client.listPlugins.mockResolvedValue([
{
...plugin(),
installation: {
identity: { kind: "npm", packageName: "@getpaseo/example", pluginPath: "." },
currentRevision: "1.1.0",
},
},
{
id: "local",
path: "/plugins/local",
enabled: false,
status: "disabled",
installation: { identity: { kind: "directory", path: "/plugins/local" } },
},
]);
renderPage(client);
expect(await screen.findByText(/npm:@getpaseo\/example · 1.1.0/)).toBeDefined();
expect(screen.getByText("/plugins/local")).toBeDefined();
});
it("renders install pending with source support independently of Git support", async () => {
runtime.gitSupported = false;
const client = createClient();
client.installPluginSource.mockImplementation(() => never<never>());
renderPage(client);
@@ -287,16 +314,23 @@ describe("HostPluginsPage", () => {
expect(screen.getByRole("link", { name: "Docs" })).toBeDefined();
});
it("keeps management available while source installation requires a host update", async () => {
runtime.sourceSupported = false;
const client = createClient();
client.listPlugins.mockResolvedValue([plugin()]);
renderPage(client);
it.each([undefined, false])(
"keeps management available on a Git-capable host with source support=%s",
async (sourceSupport) => {
runtime.sourceSupported = sourceSupport;
runtime.gitSupported = true;
const client = createClient();
client.listPlugins.mockResolvedValue([plugin()]);
renderPage(client);
expect(await screen.findByRole("button", { name: "Actions for example" })).toBeDefined();
expect(screen.getByText("Update this host to install plugins")).toBeDefined();
expect(screen.queryByLabelText("Plugin source")).toBeNull();
});
expect(await screen.findByRole("button", { name: "Actions for example" })).toBeDefined();
expect(screen.getByText("Update this host to install plugins")).toBeDefined();
expect(screen.queryByLabelText("Plugin source")).toBeNull();
expect(client.installPluginSource).not.toHaveBeenCalled();
await selectPluginAction("Reload");
await waitFor(() => expect(client.reloadPlugin).toHaveBeenCalledWith("example"));
},
);
it("hides the logs action when the host does not advertise support", async () => {
runtime.logsSupported = false;
@@ -1,3 +1,4 @@
import { formatPluginInstallation } from "@getpaseo/protocol/plugin-source-reference";
import { PluginSettingsMenuItems } from "@/plugins/settings";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
@@ -144,6 +145,20 @@ function PluginRow({
() => <StatusBadge label={statusLabel} variant={badgeVariant} />,
[badgeVariant, statusLabel],
);
const hint = useMemo(
() =>
plugin.description || plugin.installation ? (
<View>
{plugin.description ? (
<Text style={styles.pluginDescription}>{plugin.description}</Text>
) : null}
{plugin.installation ? (
<Text style={styles.pluginSource}>{formatPluginInstallation(plugin.installation)}</Text>
) : null}
</View>
) : undefined,
[plugin.description, plugin.installation],
);
const toggleLabel = `${plugin.id}: ${t(
plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable",
)}`;
@@ -151,7 +166,7 @@ function PluginRow({
<SettingsRow
label={plugin.id}
labelAccessory={statusBadge}
hint={plugin.description}
hint={hint}
error={clientError ?? plugin.error}
testID={`plugin-row-${plugin.id}`}
>
@@ -253,8 +268,8 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "pluginManagement");
// COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26.
const sourceInstallSupported = useHostFeature(serverId, "pluginGitManagement");
// COMPAT(pluginSourceInstallation): added in v0.8.0; remove gate after 2027-03-16 once daemon floor supports source identifiers.
const sourceInstallSupported = useHostFeature(serverId, "pluginSourceInstallation");
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
const logsSupported = useHostFeature(serverId, "pluginLogs");
const { config, patchConfig } = useDaemonConfig(serverId);
@@ -517,6 +532,16 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
}
const styles = StyleSheet.create((theme) => ({
pluginDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
marginTop: theme.spacing[1],
},
pluginSource: {
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
install: { padding: theme.spacing[4], gap: theme.spacing[3] },
pluginControls: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
menuButton: { padding: theme.spacing[1], borderRadius: theme.borderRadius.sm },
+17
View File
@@ -160,6 +160,23 @@ export function createCli(): Command {
// Added in v0.2.0; remove after 2027-01-17.
program.addCommand(createWorktreeCommand(), { hidden: true });
// Stop root parsing at the command so `plugin update --version` belongs to update.
// Keep global options available after a command, as they were before positional parsing.
program.enablePositionalOptions();
for (const command of program.commands) {
for (const option of program.options) {
if (option.long === "--version") continue;
if (!command.options.some((local) => local.long === option.long)) command.addOption(option);
if (option.long === "--home" || option.long === "--host") continue;
command.on(`option:${option.name()}`, () => {
const key = option.attributeName();
program.setOptionValueWithSource(key, command.opts()[key], "cli");
});
}
if (command.name() !== "plugin")
command.version(VERSION, "-v, --version", "output the version number");
}
const enforceSelectorDuplicates = (command: Command) => {
for (const option of command.options) {
if (option.long !== "--home" && option.long !== "--host") continue;
+184 -6
View File
@@ -11,6 +11,14 @@ const listPlugins = vi.fn(async () => [
source: "git" as const,
commit: "1557a34c91e2abcdef",
ref: "main",
installation: {
identity: {
kind: "git" as const,
remote: "https://example.test/plugin.git",
pluginPath: ".",
},
currentRevision: "1557a34c91e2abcdef",
},
},
{
id: "legacy-plugin",
@@ -40,11 +48,14 @@ const installDirectoryPlugin = vi.fn(async () => ({
enabled: true,
status: "running" as const,
}));
const previewPluginUpdates = vi.fn(async () => []);
const close = vi.fn(async () => undefined);
const features: {
pluginManagement?: boolean;
pluginLogs?: boolean;
pluginGitManagement?: boolean;
pluginSourceInstallation?: boolean;
pluginSourceUpdates?: boolean;
} = {};
vi.mock("../../utils/client.js", () => ({
@@ -54,6 +65,7 @@ vi.mock("../../utils/client.js", () => ({
getPluginLogs,
installDirectoryPlugin,
installPluginSource,
previewPluginUpdates,
close,
})),
}));
@@ -62,6 +74,7 @@ import { render } from "../../output/index.js";
import {
createPluginCommand,
runPluginInstallCommand,
runPluginUpdateCommand,
runPluginListCommand,
runPluginLogsCommand,
} from "./index.js";
@@ -71,6 +84,8 @@ describe("plugin management commands", () => {
features.pluginManagement = false;
features.pluginLogs = false;
features.pluginGitManagement = false;
features.pluginSourceInstallation = false;
features.pluginSourceUpdates = false;
vi.clearAllMocks();
});
@@ -102,7 +117,7 @@ describe("plugin management commands", () => {
const output = render(result, { noColor: true });
expect(output).toContain("SOURCE");
expect(output).toContain("COMMIT");
expect(output).toContain("REVISION");
expect(output).not.toContain("LATEST");
expect(output).toContain("1557a34c91e2");
expect(output).toContain("This plugin was made for an older version of Paseo");
@@ -151,14 +166,14 @@ describe("plugin management commands", () => {
});
it("prints the trust acknowledgement before installing", async () => {
features.pluginGitManagement = true;
features.pluginSourceInstallation = true;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const command = createPluginCommand();
await command.parseAsync(["install", "/plugins/trusted-plugin"], { from: "user" });
expect(stderr).toHaveBeenCalledWith(
expect.stringContaining("Git build commands run unsandboxed on the daemon host"),
expect.stringContaining("preparation commands run unsandboxed on the daemon host"),
);
expect(installPluginSource).toHaveBeenCalledWith({ source: "/plugins/trusted-plugin" });
stderr.mockRestore();
@@ -166,21 +181,77 @@ describe("plugin management commands", () => {
it("requires source support for directory installs without using the legacy RPC", async () => {
features.pluginManagement = true;
features.pluginGitManagement = true;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
await expect(
runPluginInstallCommand("/plugins/trusted-plugin", { daemonTarget }, {} as never),
).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to install or update plugins.",
message: "Update the host to install plugin sources.",
});
expect(installPluginSource).not.toHaveBeenCalled();
expect(installDirectoryPlugin).not.toHaveBeenCalled();
stderr.mockRestore();
});
it.each([undefined, false, true])(
"installs with source support and Git support=%s",
async (gitSupport) => {
features.pluginSourceInstallation = true;
features.pluginGitManagement = gitSupport;
await runPluginInstallCommand("npm:@acme/review@^1.0.0", { daemonTarget }, {} as never);
expect(installPluginSource).toHaveBeenCalledWith({ source: "npm:@acme/review@^1.0.0" });
expect(close).toHaveBeenCalledTimes(1);
},
);
it.each([undefined, false])(
"rejects installs before an RPC when source support=%s despite Git support",
async (sourceSupport) => {
features.pluginGitManagement = true;
features.pluginSourceInstallation = sourceSupport;
await expect(
runPluginInstallCommand("owner/review", { daemonTarget }, {} as never),
).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to install plugin sources.",
});
expect(installPluginSource).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledTimes(1);
},
);
it.each([undefined, false, true])(
"reviews updates with source install support=%s",
async (sourceSupport) => {
features.pluginSourceUpdates = true;
features.pluginSourceInstallation = sourceSupport;
await runPluginUpdateCommand("review", { daemonTarget }, {} as never);
expect(previewPluginUpdates).toHaveBeenCalledWith({ pluginId: "review", target: undefined });
expect(close).toHaveBeenCalledTimes(1);
},
);
it.each([undefined, false])(
"rejects reviewed updates when support=%s despite legacy Git support",
async (gitSupport) => {
features.pluginSourceInstallation = true;
features.pluginGitManagement = true;
features.pluginSourceUpdates = gitSupport;
await expect(
runPluginUpdateCommand("review", { daemonTarget }, {} as never),
).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to review plugin updates.",
});
expect(previewPluginUpdates).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledTimes(1);
},
);
it("folds the legacy --path option into the plugin source reference", async () => {
features.pluginGitManagement = true;
features.pluginSourceInstallation = true;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const command = createPluginCommand();
@@ -195,7 +266,7 @@ describe("plugin management commands", () => {
});
it("keeps an absolute monorepo path as one plugin source reference", async () => {
features.pluginGitManagement = true;
features.pluginSourceInstallation = true;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const command = createPluginCommand();
@@ -207,3 +278,110 @@ describe("plugin management commands", () => {
stderr.mockRestore();
});
});
import { reviewPluginUpdates, type UpdateInteraction } from "./update.js";
import type {
PluginUpdatePreview,
PluginUpdateProposal,
PluginUpdateResult,
} from "@getpaseo/protocol/messages";
function reviewFixture(answer = true, outcome: "update" | "installed-newer" = "update") {
const proposal: PluginUpdateProposal = {
id: "review",
expected: {
identity: { kind: "npm", packageName: "review", pluginPath: "." },
installationRoot: "/owned/one",
revision: "1.0.0",
},
target: {
kind: "npm",
version: "1.1.0",
resolved: "https://registry.npmjs.org/review.tgz",
integrity: "sha512-test",
},
};
const preview: PluginUpdatePreview = {
id: "review",
outcome,
links: ["https://npmjs.com/package/review/v/1.1.0"],
current: { identity: proposal.expected.identity, currentRevision: "1.0.0" },
target: proposal.target,
...(outcome === "update" ? { proposal } : {}),
};
const applied: PluginUpdateProposal[][] = [];
const questions: string[] = [];
const output: string[] = [];
const client = {
previewPluginUpdates: async () => [preview],
applyPluginUpdates: async (
proposals: PluginUpdateProposal[],
): Promise<PluginUpdateResult[]> => {
applied.push(proposals);
return [{ id: "review", outcome: "updated" }];
},
};
const interaction: UpdateInteraction = {
interactive: true,
structured: false,
write: (text) => {
output.push(text);
},
confirm: async (question) => {
questions.push(question);
return answer;
},
};
return { client, interaction, proposal, applied, questions, output };
}
describe("reviewed plugin update workflow", () => {
it("prints target and links, then declines without applying", async () => {
const f = reviewFixture(false);
expect(await reviewPluginUpdates(f.client, { pluginId: "review" }, f.interaction)).toEqual([
{ id: "review", outcome: "declined" },
]);
expect(f.output.join("")).toContain("1.0.0 → 1.1.0");
expect(f.output.join("")).toContain("Review: https://");
expect(f.questions).toEqual(["Update review? [y/N] "]);
expect(f.applied).toEqual([]);
});
it("applies exactly the displayed proposal after approval", async () => {
const f = reviewFixture();
await reviewPluginUpdates(f.client, { pluginId: "review" }, f.interaction);
expect(f.applied).toEqual([[f.proposal]]);
});
it.each([{ yes: true }, { version: "stable" }, { ref: "v1" }])(
"skips questions for %j",
async (options) => {
const f = reviewFixture();
await reviewPluginUpdates(f.client, { pluginId: "review", ...options }, f.interaction);
expect(f.questions).toEqual([]);
expect(f.applied).toEqual([[f.proposal]]);
},
);
it("check takes precedence over yes and explicit targets", async () => {
const f = reviewFixture();
await reviewPluginUpdates(
f.client,
{ pluginId: "review", check: true, yes: true, version: "1.1.0" },
f.interaction,
);
expect(f.applied).toEqual([]);
expect(f.questions).toEqual([]);
});
it("yes never applies an ordinary downgrade", async () => {
const f = reviewFixture(true, "installed-newer");
await reviewPluginUpdates(f.client, { all: true, yes: true }, f.interaction);
expect(f.applied).toEqual([]);
expect(f.questions).toEqual([]);
});
it.each([{ interactive: false }, { structured: true }])(
"requires explicit approval for %j",
async (flags) => {
const f = reviewFixture();
await expect(
reviewPluginUpdates(f.client, { pluginId: "review" }, { ...f.interaction, ...flags }),
).rejects.toThrow("Confirmation required");
expect(f.applied).toEqual([]);
},
);
});
+69 -32
View File
@@ -1,16 +1,18 @@
import { createInterface } from "node:readline/promises";
import { reviewPluginUpdates, type UpdateOutcome } from "./update.js";
import { Command } from "commander";
import type {
PluginListItem,
PluginLogEntry,
PluginSourceUpdateItem,
} from "@getpaseo/protocol/messages";
import { formatPluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
import {
formatPluginSourceReference,
formatPluginIdentity,
} from "@getpaseo/protocol/plugin-source-reference";
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,
withPluginUpdateClient,
withPluginManagementClient,
withPluginSourceClient,
} from "./shared.js";
@@ -21,6 +23,9 @@ interface PluginOptions extends CommandOptions {
ref?: string;
path?: string;
all?: boolean;
version?: string;
check?: boolean;
yes?: boolean;
}
const pluginSchema: OutputSchema<PluginListItem> = {
@@ -29,9 +34,17 @@ const pluginSchema: OutputSchema<PluginListItem> = {
{ header: "PLUGIN", field: "id", width: 20 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "ENABLED", field: (plugin) => (plugin.enabled ? "yes" : "no"), width: 8 },
{ header: "SOURCE", field: (plugin) => plugin.source ?? "directory", width: 10 },
{ header: "COMMIT", field: (plugin) => shortCommit(plugin.commit), width: 14 },
{ header: "REF", field: (plugin) => plugin.ref ?? "-", width: 24 },
{
header: "SOURCE",
field: (plugin) =>
plugin.installation ? formatPluginIdentity(plugin.installation.identity) : "-",
width: 40,
},
{
header: "REVISION",
field: (plugin) => plugin.installation?.currentRevision ?? "-",
width: 16,
},
{ header: "DIRECTORY", field: "path", width: 40 },
{ header: "ERROR", field: (plugin) => plugin.error ?? "", width: 40 },
],
@@ -54,18 +67,17 @@ const pluginLogsSchema: OutputSchema<PluginLogEntry> = {
],
};
function shortCommit(commit: string | undefined): string {
return commit?.slice(0, 12) ?? "-";
}
const pluginUpdateSchema: OutputSchema<PluginSourceUpdateItem> = {
const pluginUpdateSchema: OutputSchema<UpdateOutcome> = {
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 },
{ header: "PLUGIN", field: "id", width: 24 },
{ header: "RESULT", field: "outcome", width: 18 },
{
header: "DETAIL",
field: (item) =>
item.error ?? item.warning ?? item.plugin?.installation?.currentRevision ?? "",
width: 50,
},
],
};
@@ -111,7 +123,7 @@ export async function runPluginInstallCommand(
_command: Command,
): Promise<SingleResult<PluginListItem>> {
process.stderr.write(
"Trusting plugin code: server code and Git build commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n",
"Trusting plugin code: server code and preparation commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n",
);
const sourceReference = formatPluginSourceReference(source, options.path);
const data = await withPluginSourceClient(options.daemonTarget, (client) =>
@@ -124,17 +136,35 @@ export async function runPluginInstallCommand(
return { type: "single", data, schema: pluginSchema };
}
async function update(
export async function runPluginUpdateCommand(
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.daemonTarget, (client) =>
client.updatePluginSources(pluginId),
): Promise<ListResult<UpdateOutcome>> {
const data = await withPluginUpdateClient(options.daemonTarget, (client) =>
reviewPluginUpdates(
client,
{ ...options, pluginId },
{
interactive: process.stdin.isTTY === true,
structured:
options.json === true ||
["json", "yaml"].includes(options.format?.trim().toLowerCase() ?? ""),
write: (text) => process.stderr.write(text),
confirm: async (message) => {
const prompt = createInterface({ input: process.stdin, output: process.stderr });
try {
return /^(y|yes)$/i.test((await prompt.question(message)).trim());
} catch {
return false;
} finally {
prompt.close();
}
},
},
),
);
if (data.some((item) => item.outcome === "error")) process.exitCode = 1;
return { type: "list", data, schema: pluginUpdateSchema };
}
@@ -185,8 +215,11 @@ export function createPluginCommand(): Command {
plugin
.command("install")
.alias("add")
.description("Trust and install a plugin from a directory or Git repository")
.argument("<source>", "Host directory, Git source, or Git source:plugin/path")
.description("Trust and install a plugin from a directory, Git repository, or npm package")
.argument(
"<source>",
"Host directory, Git or npm source, optionally followed by :plugin/path",
)
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
.option("--ref <ref>", "Git branch, tag, or commit")
.option("--path <path>", "Legacy form of the :plugin/path source suffix"),
@@ -194,10 +227,14 @@ export function createPluginCommand(): Command {
addJsonAndDaemonHostOptions(
plugin
.command("update")
.description("Fetch and install Git-managed plugin updates")
.description("Review and update installed plugins")
.argument("[id]")
.option("--all", "Update every Git-managed plugin"),
).action(withOutput(update));
.option("--all", "Review all configured plugins")
.option("--check", "Show available updates without installing")
.option("--yes", "Apply displayed updates without asking")
.option("--ref <ref>", "Apply a Git branch, tag, or commit without asking")
.option("--version <version>", "Apply an npm version, tag, or range without asking"),
).action(withOutput(runPluginUpdateCommand));
for (const action of ["reload", "enable", "disable"] as const) {
addJsonAndDaemonHostOptions(
plugin.command(action).description(`${action} a plugin`).argument("<id>"),
+21 -4
View File
@@ -3,7 +3,11 @@ 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" | "pluginGitManagement";
type PluginFeature =
| "pluginManagement"
| "pluginLogs"
| "pluginSourceUpdates"
| "pluginSourceInstallation";
async function withPluginClient<T>(
target: DaemonTarget,
@@ -51,11 +55,24 @@ export async function withPluginSourceClient<T>(
target: DaemonTarget,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
// COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26.
// COMPAT(pluginSourceInstallation): added in v0.8.0; remove gate after 2027-03-16 once daemon floor supports source identifiers.
return withPluginClient(
target,
"pluginGitManagement",
"Update the host to install or update plugins.",
"pluginSourceInstallation",
"Update the host to install plugin sources.",
run,
);
}
export async function withPluginUpdateClient<T>(
target: DaemonTarget,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
// COMPAT(pluginSourceUpdates): added in v0.8.0; remove after 2027-03-16 once daemon floor supports reviewed updates.
return withPluginClient(
target,
"pluginSourceUpdates",
"Update the host to review plugin updates.",
run,
);
}
+102
View File
@@ -0,0 +1,102 @@
import type {
PluginUpdatePreview,
PluginUpdateResult,
PluginUpdateSelection,
PluginUpdateProposal,
} from "@getpaseo/protocol/messages";
interface UpdateClient {
previewPluginUpdates(input: {
pluginId?: string;
target?: PluginUpdateSelection;
}): Promise<PluginUpdatePreview[]>;
applyPluginUpdates(proposals: PluginUpdateProposal[]): Promise<PluginUpdateResult[]>;
}
export interface UpdateOptions {
pluginId?: string;
all?: boolean;
ref?: string;
version?: string;
check?: boolean;
yes?: boolean;
}
export interface UpdateInteraction {
interactive: boolean;
structured: boolean;
write(text: string): void;
confirm(message: string): Promise<boolean>;
}
export interface UpdateOutcome {
id: string;
outcome: PluginUpdatePreview["outcome"] | PluginUpdateResult["outcome"] | "declined";
current?: PluginUpdatePreview["current"];
target?: PluginUpdatePreview["target"];
links?: string[];
proposal?: PluginUpdateProposal;
plugin?: PluginUpdateResult["plugin"];
error?: string;
warning?: string;
}
export async function reviewPluginUpdates(
client: UpdateClient,
options: UpdateOptions,
interaction: UpdateInteraction,
): Promise<UpdateOutcome[]> {
if ((options.pluginId === undefined) === (options.all !== true))
throw new Error("Choose one plugin ID or pass --all");
if (options.ref && options.version) throw new Error("Choose --ref or --version, not both");
let target: PluginUpdateSelection | undefined;
if (options.ref) target = { kind: "git", ref: options.ref };
if (options.version) target = { kind: "npm", version: options.version };
if (target && options.all)
throw new Error("An explicit target requires one plugin ID, without --all");
const preview = await client.previewPluginUpdates({ pluginId: options.pluginId, target });
if (!interaction.structured) interaction.write(formatUpdatePreview(preview));
if (options.check) return preview;
const proposals = preview.flatMap((item) => (item.proposal ? [item.proposal] : []));
if (!proposals.length) return preview;
if (!options.yes && !target) {
if (interaction.structured || !interaction.interactive)
throw new Error("Confirmation required; rerun with --yes.");
if (
!(await interaction.confirm(
options.all ? "Update these plugins? [y/N] " : `Update ${options.pluginId}? [y/N] `,
))
) {
return preview.map((item) => (item.proposal ? { id: item.id, outcome: "declined" } : item));
}
}
const applied = new Map(
(await client.applyPluginUpdates(proposals)).map((item) => [item.id, item]),
);
return preview.map((item) => applied.get(item.id) ?? item);
}
export function formatUpdatePreview(items: PluginUpdatePreview[]): string {
return items
.map((item) => {
const current = item.current?.currentRevision ?? "unknown";
const target = item.target?.kind === "git" ? item.target.commit : item.target?.version;
let text: string;
switch (item.outcome) {
case "update":
text = `${current} → ${target}`;
break;
case "current":
text = `up to date (${current})`;
break;
case "installed-newer":
text = `installed ${current} is newer than latest ${target}; keeping installed version`;
break;
case "local":
text = "local directory; use Reload after editing";
break;
case "error":
text = item.error ?? "Check failed";
break;
}
return `${item.id}: ${text}\n${item.links.map((link) => `Review: ${link}\n`).join("")}`;
})
.join("\n");
}
+20
View File
@@ -2,9 +2,29 @@ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createCli } from "./cli";
import { createCliParseArgv } from "./run";
describe("runCli", () => {
it("lets plugin update own --version while preserving global output options", async () => {
const cli = createCli()
.exitOverride()
.configureOutput({ writeOut: () => {} });
const update = cli.commands
.find((command) => command.name() === "plugin")!
.commands.find((command) => command.name() === "update")!;
let received: Record<string, unknown> | undefined;
update.action((_id, options) => {
received = options;
});
await cli.parseAsync(
["plugin", "update", "example", "--version", "1.2.0", "--format", "json", "--no-color"],
{ from: "user" },
);
expect(received).toMatchObject({ version: "1.2.0" });
expect(update.optsWithGlobals()).toMatchObject({ format: "json", color: false });
});
it("defaults an empty CLI invocation to onboard", () => {
expect(
createCliParseArgv({
@@ -2,6 +2,10 @@
import { resolveCliVersion } from "../../src/version.js";
import { readPluginManifest } from "../../../server/src/server/plugins/manifest.js";
import {
startNpmRegistry,
npmPluginPackages,
} from "../../../../scripts/test-support/npm-registry.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
@@ -26,7 +30,8 @@ async function git(cwd: string, args: string[]): Promise<void> {
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 });
const registry = await startNpmRegistry(npmPluginPackages());
const context = await createE2ETestContext({ timeout: 45_000, env: registry.env });
try {
const scaffold = path.join(context.workDir, "authored-plugin");
const init = await context.paseo(["plugin", "init", scaffold, "--json"]);
@@ -82,7 +87,7 @@ async function main(): Promise<void> {
const gitInstall = await context.paseo([
"plugin",
"add",
pathToFileURL(gitDirectory).href,
`git:${pathToFileURL(gitDirectory).href}`,
"--json",
]);
assert.equal(gitInstall.exitCode, 0, gitInstall.stderr);
@@ -99,11 +104,11 @@ async function main(): Promise<void> {
assert.equal(JSON.parse(status.stdout)[0].status, "running");
assert.equal(JSON.parse(status.stdout)[0].commit, JSON.parse(gitInstall.stdout).commit);
const update = await context.paseo(["plugin", "update", "git-cli-e2e", "--json"]);
const update = await context.paseo(["plugin", "update", "git-cli-e2e", "--yes", "--json"]);
assert.equal(update.exitCode, 0, update.stderr);
assert.equal(JSON.parse(update.stdout)[0].updated, true);
assert.equal(JSON.parse(update.stdout)[0].outcome, "updated");
const installedCommit = JSON.parse(update.stdout)[0].currentCommit;
const installedCommit = JSON.parse(update.stdout)[0].plugin.installation.currentRevision;
const buildMarker = path.join(context.workDir, "incompatible-build-ran");
await writeFile(
path.join(gitDirectory, "paseo-plugin.json"),
@@ -122,9 +127,15 @@ async function main(): Promise<void> {
);
await git(gitDirectory, ["add", "-A"]);
await git(gitDirectory, ["commit", "-m", "requires a future Paseo"]);
const incompatibleUpdate = await context.paseo(["plugin", "update", "git-cli-e2e", "--json"]);
const incompatibleUpdate = await context.paseo([
"plugin",
"update",
"git-cli-e2e",
"--yes",
"--json",
]);
assert.equal(incompatibleUpdate.exitCode, 1);
assert.match(incompatibleUpdate.stderr, /requires Paseo >=999.0.0/);
assert.match(JSON.parse(incompatibleUpdate.stdout)[0].error, /requires Paseo >=999.0.0/);
await assert.rejects(readFile(buildMarker), { code: "ENOENT" });
const retained = await context.paseo(["plugin", "ls", "git-cli-e2e", "--json"]);
assert.equal(retained.exitCode, 0, retained.stderr);
@@ -160,11 +171,54 @@ async function main(): Promise<void> {
assert.equal(removeGit.exitCode, 0, removeGit.stderr);
const removeScaffold = await context.paseo(["plugin", "remove", "authored-plugin", "--json"]);
assert.equal(removeScaffold.exitCode, 0, removeScaffold.stderr);
for (const source of ["npm:paseo-fixture-plugin@^1.0.0", "npm:@paseo-fixture/review@2.0.0"]) {
const npmInstall = await context.paseo([
"plugin",
"install",
source,
"--path",
".",
"--json",
]);
assert.equal(npmInstall.exitCode, 0, npmInstall.stderr);
assert.equal(JSON.parse(npmInstall.stdout).id, "npm-review");
assert.equal(JSON.parse(npmInstall.stdout).status, "running");
assert.equal(
JSON.parse(npmInstall.stdout).installation.currentRevision,
source.includes("@paseo-fixture") ? "2.0.0" : "1.1.0",
);
const npmDisabled = await context.paseo(["plugin", "disable", "npm-review", "--json"]);
assert.equal(npmDisabled.exitCode, 0, npmDisabled.stderr);
const restart = await context.paseo(["daemon", "restart", "--timeout", "45", "--json"], {
timeout: 60_000,
});
assert.equal(restart.exitCode, 0, restart.stderr);
assert.notEqual(
JSON.parse(restart.stdout).workerPid,
JSON.parse(restart.stdout).previousWorkerPid,
);
const persisted = await context.paseo(["plugin", "ls", "npm-review", "--json"]);
assert.equal(persisted.exitCode, 0, persisted.stderr);
assert.equal(JSON.parse(persisted.stdout)[0].status, "disabled");
assert.equal(JSON.parse(persisted.stdout)[0].path, JSON.parse(npmInstall.stdout).path);
assert.deepEqual(
JSON.parse(persisted.stdout)[0].installation,
JSON.parse(npmInstall.stdout).installation,
);
const npmEnabled = await context.paseo(["plugin", "enable", "npm-review", "--json"]);
assert.equal(npmEnabled.exitCode, 0, npmEnabled.stderr);
assert.equal(JSON.parse(npmEnabled.stdout).status, "running");
const npmReload = await context.paseo(["plugin", "reload", "npm-review", "--json"]);
assert.equal(npmReload.exitCode, 0, npmReload.stderr);
const npmRemove = await context.paseo(["plugin", "remove", "npm-review", "--json"]);
assert.equal(npmRemove.exitCode, 0, npmRemove.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 registry.close();
await rm(directory, { recursive: true, force: true });
await rm(gitDirectory, { recursive: true, force: true });
}
+100 -4
View File
@@ -2341,7 +2341,7 @@ test("file context action RPCs correlate success and error responses", async ()
});
});
test("serializes plugin source suffixes through the legacy path field", async () => {
test("sends plugin source identifiers unchanged for daemon-host resolution", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
@@ -2353,7 +2353,7 @@ test("serializes plugin source suffixes through the legacy path field", async ()
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
mock.triggerOpen({ features: { pluginSourceInstallation: true } });
await connectPromise;
const installPromise = client.installPluginSource({
@@ -2363,8 +2363,7 @@ test("serializes plugin source suffixes through the legacy path field", async ()
expect(request).toEqual({
type: "plugin.source.install.request",
requestId: expect.any(String),
source: "owner/repository",
pluginPath: "plugins/review",
source: "owner/repository:plugins/review",
});
mock.triggerMessage(
wrapSessionMessage({
@@ -6803,3 +6802,100 @@ test("uploadFile stops sending chunks when the connection closes between sends",
.some((frame) => frame.opcode === FileTransferOpcode.FileEnd),
).toBe(false);
});
test("rejects source installation on an older host before sending a request", async () => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "source-gate",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({ features: { pluginGitManagement: true } });
await connecting;
const sentBefore = transport.sent.length;
await expect(client.installPluginSource({ source: "npm:review" })).rejects.toThrow(
"Update the host",
);
expect(transport.sent.length).toBe(sentBefore);
});
test("reviewed plugin updates gate before requests and preserve exact proposal data", async () => {
for (const supported of [false, true]) {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "reviewed-updates",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({
features: {
pluginGitManagement: true,
pluginSourceInstallation: true,
pluginSourceUpdates: supported,
},
});
await connecting;
const proposal = {
id: "review",
expected: {
identity: { kind: "npm" as const, packageName: "review", pluginPath: "." },
installationRoot: "/plugins/review/version-root",
revision: "1.0.0",
},
target: {
kind: "npm" as const,
version: "1.1.0",
resolved: "https://registry.npmjs.org/review/-/review-1.1.0.tgz",
integrity: "sha512-test",
},
};
if (!supported) {
const sent = transport.sent.length;
await expect(client.previewPluginUpdates()).rejects.toThrow("Update the host");
await expect(client.applyPluginUpdates([proposal])).rejects.toThrow("Update the host");
expect(transport.sent.length).toBe(sent);
continue;
}
const checking = client.previewPluginUpdates({ pluginId: "review" });
const check = parseSentFrame(transport.sent.at(-1));
expect(check).toMatchObject({
type: "plugin.source.update.preview.request",
pluginId: "review",
});
const preview = { id: "review", outcome: "update", links: [], proposal };
transport.triggerMessage(
wrapSessionMessage({
type: "plugin.source.update.preview.response",
payload: { requestId: check.requestId, plugins: [preview] },
}),
);
await expect(checking).resolves.toEqual([preview]);
const applying = client.applyPluginUpdates([proposal]);
const apply = parseSentFrame(transport.sent.at(-1));
expect(apply).toEqual({
type: "plugin.source.update.apply.request",
requestId: expect.any(String),
proposals: [proposal],
});
transport.triggerMessage(
wrapSessionMessage({
type: "plugin.source.update.apply.response",
payload: {
requestId: apply.requestId,
plugins: [{ id: "review", outcome: "error", error: "changed since review" }],
},
}),
);
await expect(applying).resolves.toEqual([
{ id: "review", outcome: "error", error: "changed since review" },
]);
}
});
+42 -4
View File
@@ -12,7 +12,6 @@ import type { z } from "zod";
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
import type { ClientCapability } from "@getpaseo/protocol/client-capabilities";
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
import {
AgentCreateFailedStatusPayloadSchema,
AgentCreatedStatusPayloadSchema,
@@ -119,6 +118,10 @@ import type {
PluginLogEntry,
PluginSourceStatusItem,
PluginSourceUpdateItem,
PluginUpdateSelection,
PluginUpdateProposal,
PluginUpdatePreview,
PluginUpdateResult,
AgentSkillSelection,
AgentSkillsStatus,
AgentSkillsSaveResult,
@@ -5297,18 +5300,21 @@ export class DaemonClient {
ref?: string;
}): Promise<PluginListItem> {
const requestId = this.createRequestId();
const reference = parsePluginSourceReference(input.source);
// COMPAT(pluginSourceInstallation): added in v0.8.0; remove after 2027-03-16 once daemon floor supports source identifiers.
if (this.getLastServerInfoMessage()?.features?.pluginSourceInstallation !== true) {
throw new Error("Update the host to install plugin sources.");
}
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "plugin.source.install.request",
requestId,
source: reference.source,
...(reference.pluginPath ? { pluginPath: reference.pluginPath } : {}),
source: input.source,
...(input.id ? { id: input.id } : {}),
...(input.ref ? { ref: input.ref } : {}),
},
responseType: "plugin.source.install.response",
timeout: 5 * 60 * 1000,
});
return payload.plugin;
}
@@ -5327,6 +5333,38 @@ export class DaemonClient {
return payload.plugins;
}
private requirePluginUpdates(): void {
// COMPAT(pluginSourceUpdates): added in v0.8.0; remove after 2027-03-16 once daemon floor supports reviewed updates.
if (this.getLastServerInfoMessage()?.features?.pluginSourceUpdates !== true)
throw new Error("Update the host to review plugin updates.");
}
async previewPluginUpdates(
input: { pluginId?: string; target?: PluginUpdateSelection } = {},
): Promise<PluginUpdatePreview[]> {
this.requirePluginUpdates();
const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: { type: "plugin.source.update.preview.request", requestId, ...input },
responseType: "plugin.source.update.preview.response",
timeout: 300_000,
});
return payload.plugins;
}
async applyPluginUpdates(proposals: PluginUpdateProposal[]): Promise<PluginUpdateResult[]> {
this.requirePluginUpdates();
const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: { type: "plugin.source.update.apply.request", requestId, proposals },
responseType: "plugin.source.update.apply.response",
timeout: 300_000,
});
return payload.plugins;
}
async updatePluginSources(pluginId?: string): Promise<PluginSourceUpdateItem[]> {
const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({
@@ -1,3 +1,4 @@
import { z } from "zod";
import { describe, expect, it } from "vitest";
import {
MutableDaemonConfigSchema,
@@ -293,3 +294,37 @@ describe("plugin protocol compatibility", () => {
).toThrow();
});
});
it("npm metadata leaves legacy plugin rows parseable without extending their closed source enum", () => {
const legacyPlugin = z.object({
id: z.string(),
path: z.string(),
enabled: z.boolean(),
status: z.enum(["running", "disabled", "failed"]),
source: z.enum(["directory", "git"]).optional(),
});
const plugin = {
id: "review",
path: "/plugins/review",
enabled: true,
status: "running",
installation: {
identity: { kind: "npm", packageName: "@acme/review", pluginPath: "." },
currentRevision: "1.2.0",
},
};
const message = SessionOutboundMessageSchema.parse({
type: "plugin.list.response",
payload: { requestId: "npm", plugins: [plugin] },
});
expect(legacyPlugin.parse(plugin)).toEqual({
id: "review",
path: "/plugins/review",
enabled: true,
status: "running",
});
expect(message).toEqual({
type: "plugin.list.response",
payload: { requestId: "npm", plugins: [plugin] },
});
});
+91
View File
@@ -1494,6 +1494,53 @@ export const PluginSourceInstallRequestSchema = z.object({
pluginPath: z.string().min(1).optional(),
});
export const PluginSourceIdentitySchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("directory"), path: z.string() }),
z.object({ kind: z.literal("git"), remote: z.string(), pluginPath: z.string() }),
z.object({ kind: z.literal("npm"), packageName: z.string(), pluginPath: z.string() }),
]);
export const PluginInstallationSchema = z.object({
identity: PluginSourceIdentitySchema,
currentRevision: z.string().optional(),
});
export type PluginInstallation = z.infer<typeof PluginInstallationSchema>;
export const PluginUpdateTargetSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("git"), commit: z.string().regex(/^[0-9a-f]{40,64}$/) }),
z.object({
kind: z.literal("npm"),
version: z.string().min(1),
resolved: z.string().url(),
integrity: z.string().min(1),
}),
]);
export type PluginUpdateTarget = z.infer<typeof PluginUpdateTargetSchema>;
export const PluginUpdateProposalSchema = z.object({
id: PluginIdSchema,
expected: z.object({
identity: PluginSourceIdentitySchema,
installationRoot: z.string(),
revision: z.string(),
}),
target: PluginUpdateTargetSchema,
});
export type PluginUpdateProposal = z.infer<typeof PluginUpdateProposalSchema>;
export const PluginUpdateSelectionSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("git"), ref: z.string().min(1) }),
z.object({ kind: z.literal("npm"), version: z.string().min(1) }),
]);
export type PluginUpdateSelection = z.infer<typeof PluginUpdateSelectionSchema>;
export const PluginUpdatePreviewRequestSchema = z.object({
type: z.literal("plugin.source.update.preview.request"),
requestId: z.string(),
pluginId: PluginIdSchema.optional(),
target: PluginUpdateSelectionSchema.optional(),
});
export const PluginUpdateApplyRequestSchema = z.object({
type: z.literal("plugin.source.update.apply.request"),
requestId: z.string(),
proposals: z.array(PluginUpdateProposalSchema).min(1),
});
export const PluginSourceStatusRequestSchema = z.object({
type: z.literal("plugin.source.status.request"),
requestId: z.string(),
@@ -3201,6 +3248,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PluginSourceInstallRequestSchema,
PluginSourceStatusRequestSchema,
PluginSourceUpdateRequestSchema,
PluginUpdatePreviewRequestSchema,
PluginUpdateApplyRequestSchema,
PluginReloadRequestSchema,
PluginEnableRequestSchema,
PluginDisableRequestSchema,
@@ -3576,6 +3625,8 @@ export const ServerInfoStatusPayloadSchema = z
pluginLogs: z.boolean().optional(),
// COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26.
pluginGitManagement: z.boolean().optional(),
pluginSourceInstallation: z.boolean().optional(),
pluginSourceUpdates: 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.
@@ -6527,6 +6578,13 @@ export const PluginCatalogGetResponseSchema = z.object({
export const PluginStatusSchema = z.enum(["running", "disabled", "failed"]);
export type PluginStatus = z.infer<typeof PluginStatusSchema>;
export const PluginNpmInstallationSchema = z.object({
packageName: z.string(),
requestedSpec: z.string(),
version: z.string(),
integrity: z.string(),
});
export const PluginListItemSchema = z.object({
id: PluginIdSchema,
description: z.string().optional(),
@@ -6534,6 +6592,8 @@ export const PluginListItemSchema = z.object({
enabled: z.boolean(),
status: PluginStatusSchema,
source: z.enum(["directory", "git"]).optional(),
npm: PluginNpmInstallationSchema.optional(),
installation: PluginInstallationSchema.optional(),
remote: z.string().optional(),
ref: z.string().optional(),
commit: z.string().optional(),
@@ -6581,6 +6641,8 @@ export const PluginSourceInstallResponseSchema = z.object({
export const PluginSourceStatusItemSchema = z.object({
id: PluginIdSchema,
source: z.enum(["directory", "git"]),
npm: PluginNpmInstallationSchema.optional(),
installation: PluginInstallationSchema.optional(),
path: z.string(),
remote: z.string().optional(),
ref: z.string().optional(),
@@ -6610,6 +6672,33 @@ export const PluginSourceUpdateResponseSchema = z.object({
payload: z.object({ requestId: z.string(), plugins: z.array(PluginSourceUpdateItemSchema) }),
});
export const PluginUpdatePreviewSchema = z.object({
id: PluginIdSchema,
outcome: z.enum(["update", "current", "installed-newer", "local", "error"]),
current: PluginInstallationSchema.optional(),
target: PluginUpdateTargetSchema.optional(),
links: z.array(z.string()),
proposal: PluginUpdateProposalSchema.optional(),
error: z.string().optional(),
});
export type PluginUpdatePreview = z.infer<typeof PluginUpdatePreviewSchema>;
export const PluginUpdateResultSchema = z.object({
id: PluginIdSchema,
outcome: z.enum(["updated", "error"]),
plugin: PluginListItemSchema.optional(),
error: z.string().optional(),
warning: z.string().optional(),
});
export type PluginUpdateResult = z.infer<typeof PluginUpdateResultSchema>;
export const PluginUpdatePreviewResponseSchema = z.object({
type: z.literal("plugin.source.update.preview.response"),
payload: z.object({ requestId: z.string(), plugins: z.array(PluginUpdatePreviewSchema) }),
});
export const PluginUpdateApplyResponseSchema = z.object({
type: z.literal("plugin.source.update.apply.response"),
payload: z.object({ requestId: z.string(), plugins: z.array(PluginUpdateResultSchema) }),
});
function pluginActionResponse<const Type extends string>(type: Type) {
return z.object({
type: z.literal(type),
@@ -6689,6 +6778,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
PluginSourceInstallResponseSchema,
PluginSourceStatusResponseSchema,
PluginSourceUpdateResponseSchema,
PluginUpdatePreviewResponseSchema,
PluginUpdateApplyResponseSchema,
PluginReloadResponseSchema,
PluginEnableResponseSchema,
PluginDisableResponseSchema,
@@ -3,6 +3,16 @@ import { parsePluginSourceReference } from "./plugin-source-reference.js";
describe("plugin source references", () => {
it.each([
["npm:review@1.2.0:nested", "npm:review@1.2.0", "nested"],
["npm:review@^1.2.0", "npm:review@^1.2.0", undefined],
["npm:@team/review@next:plugins/main", "npm:@team/review@next", "plugins/main"],
["github:owner/repository", "github:owner/repository", undefined],
[
"git:git@example.test:owner/repository.git",
"git:git@example.test:owner/repository.git",
undefined,
],
["git:file:///repo:plugins/main", "git:file:///repo", "plugins/main"],
["owner/repository", "owner/repository", undefined],
["owner/repository:plugins/review", "owner/repository", "plugins/review"],
[
@@ -1,9 +1,17 @@
import type { PluginInstallation } from "./messages.js";
export interface PluginSourceReference {
source: string;
pluginPath: string | undefined;
}
export function parsePluginSourceReference(reference: string): PluginSourceReference {
const prefix = /^(npm:|github:|git:(?!\/\/))/.exec(reference)?.[0];
const source = prefix ? reference.slice(prefix.length) : reference;
const parsed = splitPluginPath(source, { scp: prefix !== "npm:" });
return { ...parsed, source: `${prefix ?? ""}${parsed.source}` };
}
function splitPluginPath(reference: string, options: { scp: boolean }): PluginSourceReference {
const separator = reference.lastIndexOf(":");
if (separator === -1) return { source: reference, pluginPath: undefined };
@@ -18,7 +26,7 @@ export function parsePluginSourceReference(reference: string): PluginSourceRefer
if (pathStart === -1 || separator < pathStart) {
return { source: reference, pluginPath: undefined };
}
} else {
} else if (options.scp) {
const scpSeparator = reference.match(/^[^/@\s]+@[^:\s]+:/)?.[0].length;
if (scpSeparator !== undefined && separator === scpSeparator - 1) {
return { source: reference, pluginPath: undefined };
@@ -45,3 +53,17 @@ function isPortableRelativePluginPath(pluginPath: string): boolean {
if (/^[A-Za-z]:/.test(pluginPath) || pluginPath.includes(":")) return false;
return pluginPath.split(/[\\/]/).every((part) => part !== "" && part !== "." && part !== "..");
}
export function formatPluginIdentity(identity: PluginInstallation["identity"]): string {
if (identity.kind === "directory") return identity.path;
const source = identity.kind === "npm" ? `npm:${identity.packageName}` : `git:${identity.remote}`;
return identity.pluginPath === "." ? source : `${source}:${identity.pluginPath}`;
}
export function formatPluginInstallation(installation: PluginInstallation): string {
const identity = formatPluginIdentity(installation.identity);
const revision =
installation.identity.kind === "git"
? installation.currentRevision?.slice(0, 12)
: installation.currentRevision;
return revision ? `${identity} · ${revision}` : identity;
}
+1
View File
@@ -99,6 +99,7 @@
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"semver": "^7.8.5",
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
@@ -137,6 +137,8 @@ const INBOUND_PERMISSION = {
"plugin.source.install.request": "daemon.manage",
"plugin.source.status.request": "daemon.manage",
"plugin.source.update.request": "daemon.manage",
"plugin.source.update.preview.request": "daemon.manage",
"plugin.source.update.apply.request": "daemon.manage",
"project.add.request": "workspace.manage",
"project.create_directory.request": "workspace.manage",
"project.github.clone.request": "workspace.manage",
@@ -357,6 +359,8 @@ const OUTBOUND_PERMISSION = {
"plugin.source.install.response": "daemon.manage",
"plugin.source.status.response": "daemon.manage",
"plugin.source.update.response": "daemon.manage",
"plugin.source.update.preview.response": "daemon.manage",
"plugin.source.update.apply.response": "daemon.manage",
pong: "daemon.read",
"project.add.response": "workspace.manage",
"project.create_directory.response": "workspace.manage",
@@ -1,4 +1,5 @@
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { rename, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -7,6 +8,10 @@ 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 {
startNpmRegistry,
npmPluginPackages,
} from "../../../../../scripts/test-support/npm-registry.mjs";
import { runGitCommand } from "../../utils/run-git-command.js";
const roots: string[] = [];
@@ -309,7 +314,7 @@ describe("PluginService", () => {
await writeFile(path.join(directory, "index.server.ts"), "export default broken syntax !!!");
await expect(service.reloadPlugin("work-plugin")).rejects.toThrow();
expect(service.catalog()).toEqual([]);
expect(await service.listPlugins()).toEqual([
expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({ id: "work-plugin", status: "failed", error: expect.any(String) }),
]);
@@ -378,6 +383,150 @@ describe("PluginService", () => {
await service.stopAllPlugins();
}, 20_000);
it.each([true, false])(
"updates the shipped legacy Git layout with enabled=%s",
async (enabled) => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-legacy-layout-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "paseo-legacy-remote-"));
roots.push(repository);
const id = "legacy-review";
const pluginPath = "plugins/review";
await mkdir(path.join(repository, pluginPath), { recursive: true });
await writeFile(
path.join(repository, pluginPath, "paseo-plugin.json"),
JSON.stringify({ id, requirements: { paseo: ">=0.8.0" } }),
);
await writeFile(
path.join(repository, pluginPath, "index.server.ts"),
`import { defineSettings } from "@getpaseo/plugin";
import { z } from "zod";
import type { PluginServerContext } from "@getpaseo/plugin/server";
export default function contribute(server: PluginServerContext) {
server.registerSettings(defineSettings({ id: "preferences", scope: "host", version: 1, schema: z.object({ message: z.string().default("default") }) }));
return () => {};
}`,
);
for (const args of [
["init", "-b", "main"],
["add", "."],
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "initial"],
["tag", "v1"],
])
await runGitCommand(args, { cwd: repository });
const initial = (
await runGitCommand(["rev-parse", "HEAD"], { cwd: repository })
).stdout.trim();
const remote = pathToFileURL(repository).href;
const legacyRoot = path.join(home, "plugins", id, `${initial.slice(0, 12)}-${randomUUID()}`);
const checkoutRoot = path.join(legacyRoot, "checkout");
const directory = path.join(checkoutRoot, pluginPath);
await mkdir(legacyRoot, { recursive: true });
await runGitCommand(["clone", remote, checkoutRoot], { cwd: home });
await runGitCommand(["checkout", "--detach", "v1"], { cwd: checkoutRoot });
await writeFile(
path.join(home, "plugins", "sources.json"),
JSON.stringify({
[id]: {
remote,
requestedRef: "v1",
trackingBranch: null,
commit: "f".repeat(40),
pluginPath,
checkoutRoot,
},
}),
);
const settingsDirectory = path.join(home, "plugin-settings");
await mkdir(path.join(settingsDirectory, id), { recursive: true });
const settingsFile = path.join(settingsDirectory, id, "preferences.json");
const savedSettings = JSON.stringify({ version: 1, values: { message: "keep my settings" } });
await writeFile(settingsFile, savedSettings);
const store = createStore(home, { [id]: { source: "directory", path: directory, enabled } });
const open = () =>
bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.8.0", {
managedSources: new ManagedPluginSources(home),
settingsDirectory,
}),
);
let service = open();
try {
await service.start();
const expected = {
identity: { kind: "git", remote, pluginPath },
currentRevision: initial,
};
await rename(repository, `${repository}-offline`);
try {
expect(await new ManagedPluginSources(home).describe(id, directory)).toEqual(expected);
expect(await service.listPlugins()).toMatchObject([
{ id, enabled, status: enabled ? "running" : "disabled", installation: expected },
]);
} finally {
await rename(`${repository}-offline`, repository);
}
await service.stopAllPlugins();
service = open();
await service.start();
await runGitCommand(["checkout", "-b", "new-default"], { cwd: repository });
await writeFile(path.join(repository, pluginPath, "revision.txt"), "new default");
for (const args of [
["add", "."],
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "update"],
])
await runGitCommand(args, { cwd: repository });
const latest = (
await runGitCommand(["rev-parse", "HEAD"], { cwd: repository })
).stdout.trim();
const [preview] = await service.previewUpdates({ pluginId: id });
expect(preview).toMatchObject({
outcome: "update",
current: expected,
target: { kind: "git", commit: latest },
});
const [updated] = await service.applyUpdates([preview!.proposal!]);
expect(updated).toMatchObject({
outcome: "updated",
plugin: {
id,
enabled,
status: enabled ? "running" : "disabled",
installation: { identity: expected.identity, currentRevision: latest },
},
});
await expect(stat(legacyRoot)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(settingsFile, "utf8")).toBe(savedSettings);
await service.disablePlugin(id);
await service.enablePlugin(id);
expect(await service.reloadPlugin(id)).toMatchObject({ status: "running" });
expect(await service.invokePluginRpc(id, "settings.preferences.read", {})).toMatchObject({
status: "ready",
values: { message: "keep my settings" },
});
await service.removePlugin(id);
await expect(stat(path.join(home, "plugins", id))).rejects.toMatchObject({
code: "ENOENT",
});
expect(await stat(repository)).toBeDefined();
const sources = new ManagedPluginSources(home);
// A record cannot confer ownership on another plugin's tree or a malformed version name.
sources.commit(id, { kind: "git", remote });
for (const outside of [
path.join(home, "plugins", "other", path.basename(legacyRoot), "checkout"),
path.join(home, "plugins", id, "f".repeat(36), "checkout"),
path.join(home, "plugins", id, `${initial.slice(0, 12)}-invalid`, "checkout"),
])
await expect(sources.describe(id, outside)).rejects.toThrow(
"outside its managed installation",
);
} finally {
await service.stopAllPlugins();
}
},
30_000,
);
it("keeps the running commit when a Git update build command fails", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
@@ -424,12 +573,14 @@ describe("PluginService", () => {
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "broken update"], { cwd: repository });
await expect(service.updateSources("git-update")).rejects.toThrow();
expect(await applyReviewedUpdate(service, "git-update")).toMatchObject([
{ outcome: "error", error: expect.stringContaining("build exploded") },
]);
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
expect(service.catalog()).toEqual([
expect.objectContaining({ id: "git-update", clientBundle: expect.any(String) }),
]);
expect(await service.listPlugins()).toEqual([
expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({
id: "git-update",
path: installedPath,
@@ -518,8 +669,8 @@ describe("PluginService", () => {
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "prepared update"], { cwd: repository });
await expect(service.updateSources("prepared-git-plugin")).resolves.toEqual([
expect.objectContaining({ id: "prepared-git-plugin", updated: true }),
await expect(applyReviewedUpdate(service, "prepared-git-plugin")).resolves.toEqual([
expect.objectContaining({ id: "prepared-git-plugin", outcome: "updated" }),
]);
expect(events).toEqual(["validate:prepared", "start", "validate:updated", "start"]);
await service.stopAllPlugins();
@@ -578,7 +729,7 @@ describe("PluginService", () => {
managedSources,
});
await service.start();
expect(await service.listPlugins()).toEqual([
expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({ id: "failed-update", status: "failed" }),
]);
@@ -589,17 +740,87 @@ describe("PluginService", () => {
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 }),
await expect(applyReviewedUpdate(service, "failed-update")).resolves.toEqual([
expect.objectContaining({ id: "failed-update", outcome: "updated" }),
]);
expect(starts).toHaveLength(2);
expect(starts[1]).not.toBe(initial.directory);
expect(await service.listPlugins()).toEqual([
expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({ id: "failed-update", path: starts[1], status: "running" }),
]);
await service.stopAllPlugins();
}, 30_000);
it.each([false, true])(
"cleans a failed update when restoration also fails=%s",
async (failRestore) => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-recovery-"));
roots.push(home);
const repository = await createPlugin("recovery", "export default () => () => {};\n");
for (const args of [
["init", "-b", "main"],
["add", "."],
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "initial"],
])
await runGitCommand(args, { cwd: repository });
const running = new Set<string>();
let starts = 0;
const runtime: TestPluginRuntime = {
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
clearLogs: () => undefined,
validatePlugin: async () => undefined,
startPlugin: async (id) => {
starts++;
if (starts === 2) throw new Error("candidate activation failed");
if (starts === 3 && failRestore) throw new Error("prior activation failed");
running.add(id);
},
stopPluginById: async (id) => running.delete(id),
stopAll: async () => running.clear(),
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
const service = createService(
home,
{},
{ runtime, managedSources: new ManagedPluginSources(home) },
);
await service.start();
const installed = await service.installSource({ source: pathToFileURL(repository).href });
await writeFile(path.join(repository, "revision.txt"), "new");
for (const args of [
["add", "."],
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "update"],
])
await runGitCommand(args, { cwd: repository });
const result = await applyReviewedUpdate(service, "recovery");
expect(result).toMatchObject([
{
outcome: "error",
error: expect.stringContaining(
failRestore ? "restoring previous plugin also failed" : "candidate activation failed",
),
},
]);
expect(await service.listPlugins()).toMatchObject([
{
path: installed.path,
enabled: true,
status: failRestore ? "failed" : "running",
installation: installed.installation,
},
]);
expect(await readdir(path.join(home, "plugins", "recovery"))).toEqual([
path.basename(path.dirname(installed.path)),
]);
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
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);
@@ -625,7 +846,7 @@ export default function contribute(plugin: unknown) {
expect(await readFile(cleanupFile, "utf8")).toBe("cleaned");
await service.removePlugin("cleanup-plugin");
expect(await service.listPlugins()).toEqual([]);
expect(await service.listPlugins()).toMatchObject([]);
await expect(stat(directory)).resolves.toMatchObject({});
await service.stopAllPlugins();
}, 20_000);
@@ -684,7 +905,7 @@ export default function contribute(plugin: unknown) {
await service.stopAllPlugins();
expect(service.catalog()).toEqual([]);
expect(await service.listPlugins()).toEqual([
expect(await service.listPlugins()).toMatchObject([
{ id: "slow", path: "/plugins/slow", enabled: true, status: "disabled" },
]);
});
@@ -799,7 +1020,7 @@ export default function contribute(plugin: unknown) {
await expect(service.installDirectory({ path: startupFailure })).rejects.toThrow(
"startup exploded",
);
expect(await service.listPlugins()).toEqual([
expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({
id: "legacy-plugin",
status: "failed",
@@ -848,3 +1069,282 @@ export default function contribute(plugin: unknown) {
expect((await readFile(cleanupFile, "utf8")).trim().split("\n")).toHaveLength(4);
});
});
describe("npm plugin installation", () => {
it("preserves dependencies and metadata, and reloads without npm", async () => {
const packages = npmPluginPackages();
const fixture = packages[1];
packages.push({ ...fixture, version: "2.0.0", tags: ["future"] });
const failures: Array<[string, Record<string, string>]> = [
["bad-manifest", { "paseo-plugin.json": "{}" }],
[
"bad-build",
{
"paseo-plugin.json": JSON.stringify({
id: "bad-build",
requirements: { paseo: ">=0.4.0" },
build: [[process.execPath, "-e", "throw new Error('preparation failed')"]],
}),
},
],
[
"bad-requirements",
{
"paseo-plugin.json": JSON.stringify({
id: "bad-requirements",
requirements: { paseo: ">=999.0.0" },
}),
},
],
["bad-code", { "index.server.ts": "invalid syntax !!!" }],
[
"bad-activation",
{ "index.server.ts": "export default () => { throw new Error('activation failed'); };" },
],
];
const failedPackages = [];
for (const [name, overrides] of failures) {
failedPackages.push({ ...fixture, name, files: { ...fixture.files, ...overrides } });
}
const registry = await startNpmRegistry([
...packages,
{
name: "paseo-prepared-plugin",
version: "1.0.0",
dependencies: { "paseo-fixture-dependency": "1.0.0" },
scripts: { postinstall: "node -e \"throw new Error('npm lifecycle must not run')\"" },
files: {
"nested/paseo-plugin.json": JSON.stringify({
id: "prepared",
requirements: { paseo: ">=0.4.0" },
build: [
[
process.execPath,
"-e",
"require('node:fs').mkdirSync('server'); require('node:fs').writeFileSync('server/generated.js', 'export default 42;');",
],
],
}),
"nested/index.server.ts": `import generated from "./server/generated.js";
import dependency from "paseo-fixture-dependency";
import { z } from "zod";
export default function contribute() {
if (generated !== 42 || z.string().parse(dependency) !== "dependency loaded") throw new Error("Preparation failed");
return () => {};
}`,
},
},
...failedPackages,
]);
const home = await mkdtemp(path.join(tmpdir(), "paseo-npm-service-"));
roots.push(home);
const previousConfig = process.env.npm_config_userconfig;
const previousPath = process.env.PATH;
process.env.npm_config_userconfig = registry.userconfig;
const store = createStore(home);
const open = () =>
bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.8.0", {
managedSources: new ManagedPluginSources(home),
}),
);
let service = open();
try {
await service.start();
const installed = await service.installSource({ source: "npm:paseo-fixture-plugin@^1.0.0" });
expect(installed).toMatchObject({
id: "npm-review",
status: "running",
installation: {
identity: { kind: "npm", packageName: "paseo-fixture-plugin", pluginPath: "." },
currentRevision: "1.1.0",
},
});
expect(installed.commit).toBeUndefined();
expect(installed.remote).toBeUndefined();
expect(
service
.getLogs("npm-review")
.map((entry) => entry.message)
.join("\n"),
).toContain("dependency loaded");
const installRoot = path.dirname(path.dirname(installed.path));
expect(
JSON.parse(await readFile(path.join(home, "plugins", "sources.json"), "utf8"))[
"npm-review"
],
).toEqual({ kind: "npm" });
expect(
JSON.parse(await readFile(path.join(installRoot, "package-lock.json"), "utf8")).packages[
"node_modules/paseo-fixture-dependency"
].version,
).toBe("1.0.0");
for (const [source, id, version] of [
["npm:paseo-fixture-plugin@1.0.0", "exact", "1.0.0"],
["paseo-fixture-plugin@stable", "tag", "1.0.0"],
["npm:@paseo-fixture/review", "scoped", "2.0.0"],
]) {
expect(await service.installSource({ source, id })).toMatchObject({
id,
status: "running",
installation: { currentRevision: version },
});
}
const [preview] = await service.previewUpdates({ pluginId: "exact" });
expect(preview).toMatchObject({
outcome: "update",
current: { currentRevision: "1.0.0" },
target: { version: "1.1.0" },
});
const before = (await service.listPlugins()).find((item) => item.id === "exact")!;
await service.disablePlugin("exact");
registry.setTag("paseo-fixture-plugin", "latest", "2.0.0");
const results = await service.applyUpdates([
{ ...preview!.proposal!, id: "missing-plugin" },
preview!.proposal!,
]);
expect(results).toMatchObject([
{ outcome: "error" },
{
outcome: "updated",
plugin: { enabled: false, installation: { currentRevision: "1.1.0" } },
},
]);
expect(results[1]?.plugin?.installation?.identity).toEqual(before.installation?.identity);
registry.setTag("paseo-fixture-plugin", "latest", "1.1.0");
expect(await service.applyUpdates([preview!.proposal!])).toMatchObject([
{ outcome: "error", error: expect.stringContaining("changed since review") },
]);
await service.enablePlugin("exact");
const [future] = await service.previewUpdates({
pluginId: "exact",
target: { kind: "npm", version: "future" },
});
expect(await service.applyUpdates([future!.proposal!])).toMatchObject([
{ outcome: "updated", plugin: { installation: { currentRevision: "2.0.0" } } },
]);
const [newer] = await service.previewUpdates({ pluginId: "exact" });
expect(newer).toMatchObject({
outcome: "installed-newer",
current: { currentRevision: "2.0.0" },
target: { version: "1.1.0" },
});
expect(newer?.proposal).toBeUndefined();
const [downgrade] = await service.previewUpdates({
pluginId: "exact",
target: { kind: "npm", version: "1.0.0" },
});
const wrongArtifact = {
...downgrade!.proposal!,
target: { ...downgrade!.proposal!.target, integrity: "sha512-invalid" },
};
expect(await service.applyUpdates([wrongArtifact])).toMatchObject([{ outcome: "error" }]);
expect(await service.applyUpdates([downgrade!.proposal!])).toMatchObject([
{ outcome: "updated", plugin: { installation: { currentRevision: "1.0.0" } } },
]);
expect((await service.previewUpdates({ pluginId: "exact" }))[0]?.outcome).toBe("update");
await expect(service.updateSources("exact")).rejects.toThrow("Update the client");
const prepared = await service.installSource({
source: "npm:paseo-prepared-plugin@1.0.0:nested",
});
expect(prepared).toMatchObject({ id: "prepared", status: "running" });
expect(await readFile(path.join(prepared.path, "server/generated.js"), "utf8")).toBe(
"export default 42;",
);
expect(prepared.installation).toMatchObject({ identity: { pluginPath: "nested" } });
await service.removePlugin("prepared");
expect(registry.requests).not.toContain("/@getpaseo/plugin");
await expect(service.installSource({ source: "npm:missing-plugin" })).rejects.toThrow();
await expect(
service.installSource({ source: "npm:paseo-fixture-plugin", ref: "main" }),
).rejects.toThrow("--ref is only valid for Git");
await expect(service.installSource({ source: "npm:paseo-fixture-plugin" })).rejects.toThrow(
"already configured",
);
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
for (const name of [
"bad-manifest",
"bad-build",
"bad-requirements",
"bad-code",
"bad-activation",
]) {
await expect(service.installSource({ source: `npm:${name}`, id: name })).rejects.toThrow();
expect((await service.listPlugins()).map((plugin) => plugin.id)).toEqual([
"exact",
"npm-review",
"scoped",
"tag",
]);
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
}
await service.disablePlugin("scoped");
await service.stopAllPlugins();
const recordsPath = path.join(home, "plugins", "sources.json");
const oldRecords = JSON.parse(await readFile(recordsPath, "utf8"));
Object.assign(oldRecords["npm-review"], {
requestedSpec: "0.0.1",
version: "0.0.1",
packageName: "obsolete",
installRoot: "/obsolete",
integrity: "obsolete",
});
await writeFile(recordsPath, JSON.stringify(oldRecords));
process.env.PATH = path.join(home, "no-executables");
service = open();
await service.start();
expect(await service.listPlugins()).toMatchObject(
expect.arrayContaining([
expect.objectContaining({
id: "npm-review",
status: "running",
installation: installed.installation,
}),
expect.objectContaining({ id: "scoped", enabled: false, status: "disabled" }),
]),
);
expect(await service.reloadPlugin("npm-review")).toMatchObject({
status: "running",
installation: installed.installation,
});
await expect(
service.installSource({ source: "npm:paseo-fixture-plugin", id: "missing-npm" }),
).rejects.toThrow("npm is required on the daemon host");
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
const packagePath = path.join(installed.path, "package.json");
const packageJson = await readFile(packagePath, "utf8");
await writeFile(
packagePath,
JSON.stringify({ ...JSON.parse(packageJson), version: "99.0.0" }),
);
const corrupt = (await service.listPlugins()).find((plugin) => plugin.id === "npm-review")!;
expect(corrupt.status).toBe("running");
expect(corrupt.installation).toEqual({ identity: installed.installation!.identity });
expect((await service.previewUpdates({ pluginId: "npm-review" }))[0]).toMatchObject({
outcome: "error",
error: expect.stringContaining("Installed revision is unavailable"),
});
expect(await service.reloadPlugin("npm-review")).toMatchObject({ status: "running" });
await writeFile(packagePath, packageJson);
await service.removePlugin("npm-review");
await expect(stat(installRoot)).rejects.toMatchObject({ code: "ENOENT" });
expect(
JSON.parse(await readFile(path.join(home, "plugins", "sources.json"), "utf8"))[
"npm-review"
],
).toBeUndefined();
} finally {
process.env.PATH = previousPath;
if (previousConfig === undefined) delete process.env.npm_config_userconfig;
else process.env.npm_config_userconfig = previousConfig;
await service.stopAllPlugins();
await registry.close();
}
}, 120_000);
});
async function applyReviewedUpdate(service: PluginService, pluginId: string) {
const [preview] = await service.previewUpdates({ pluginId });
expect(preview?.outcome).toBe("update");
return service.applyUpdates([preview!.proposal!]);
}
+114 -68
View File
@@ -10,6 +10,10 @@ import {
type PluginSource,
type PluginSourceStatusItem,
type PluginSourceUpdateItem,
type PluginUpdateSelection,
type PluginUpdateProposal,
type PluginUpdatePreview,
type PluginUpdateResult,
} from "@getpaseo/protocol/messages";
import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
@@ -163,13 +167,15 @@ export class PluginService {
};
const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null);
if (manifest?.description) item.description = manifest.description;
const managed = this.managedSources?.get(id);
if (managed) {
item.installation = await this.managedSources
?.describe(id, source.path)
.catch(() => undefined);
if (!this.managedSources)
item.installation = { identity: { kind: "directory", path: path.resolve(source.path) } };
if (item.installation?.identity.kind === "git") {
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;
item.remote = item.installation.identity.remote;
item.commit = item.installation.currentRevision;
}
const error = this.errors.get(id);
if (error && item.status === "failed") item.error = error;
@@ -224,12 +230,14 @@ export class PluginService {
ref?: string;
}): Promise<PluginListItem> {
const directDirectory = path.resolve(input.source);
const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source);
const directInfo = await stat(directDirectory).catch(() => null);
const reference = directInfo?.isDirectory()
? { source: input.source, pluginPath: undefined }
: parsePluginSourceReference(input.source);
const directory = path.resolve(reference.source);
const info = directInfo?.isDirectory() ? directInfo : await stat(directory).catch(() => null);
let info = directInfo;
if (!info?.isDirectory() && !explicit) 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, reference.pluginPath);
@@ -245,32 +253,36 @@ export class PluginService {
let pluginId: string;
try {
await this.checkRequirements(candidate.directory);
await runPluginBuild(candidate.directory, candidate.build, this.logger);
pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId);
if (this.configStore.get().plugins?.[pluginId]) {
throw new Error(
`Plugin ID "${pluginId}" is already configured; choose another ID with --id`,
);
}
await runPluginBuild(candidate.directory, candidate.build, this.logger);
candidate = await managedSources.place(pluginId, candidate);
await this.validateCandidate(candidate);
await managedSources.verifyCandidate(pluginId, 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 = await this.requireItem(pluginId);
if (installed.status === "failed") {
throw new Error(installed.error ?? `Plugin failed to start: ${pluginId}`);
this.patchSource(pluginId, { source: "directory", path: candidate.directory, enabled: true });
try {
if (this.canPublish(pluginId)) await this.startExplicit(pluginId, candidate.directory);
managedSources.commit(pluginId, candidate.record);
} catch (error) {
await this.stopPlugin(pluginId);
const sources = { ...this.configStore.get().plugins };
delete sources[pluginId];
this.configStore.patch({ plugins: sources });
await managedSources.discard(candidate);
this.errors.delete(pluginId);
this.notify(pluginId);
throw error;
}
return installed;
this.notify(pluginId);
return this.requireItem(pluginId);
});
}
@@ -289,15 +301,54 @@ export class PluginService {
});
}
async updateSources(pluginId?: string): Promise<PluginSourceUpdateItem[]> {
// COMPAT(plugin-immediate-update): added in v0.8.0; remove after 2027-03-16 when clients use reviewed targets.
async updateSources(_pluginId?: string): Promise<PluginSourceUpdateItem[]> {
throw new Error("Update the client to review plugin updates before applying them.");
}
async previewUpdates(input: {
pluginId?: string;
target?: PluginUpdateSelection;
}): Promise<PluginUpdatePreview[]> {
if (input.target && !input.pluginId)
throw new Error("An explicit update target requires one plugin ID");
const sources = this.configStore.get().plugins ?? {};
const ids = input.pluginId ? [input.pluginId] : Object.keys(sources).sort();
return Promise.all(
ids.map(async (id) => {
try {
return await this.requireManagedSources().preview(
id,
this.requireSource(id).path,
input.target,
);
} catch (error) {
return {
id,
outcome: "error" as const,
links: [],
error: error instanceof Error ? error.message : String(error),
};
}
}),
);
}
async applyUpdates(proposals: PluginUpdateProposal[]): Promise<PluginUpdateResult[]> {
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;
const results: PluginUpdateResult[] = [];
for (const proposal of proposals) {
try {
results.push(await this.updateSource(proposal));
} catch (error) {
results.push({
id: proposal.id,
outcome: "error",
error: error instanceof Error ? error.message : String(error),
});
}
}
return results;
});
}
@@ -533,59 +584,51 @@ export class PluginService {
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
}
private async updateSource(pluginId: string): Promise<PluginSourceUpdateItem> {
private async updateSource(proposal: PluginUpdateProposal): Promise<PluginUpdateResult> {
const pluginId = proposal.id;
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,
};
}
let candidate = prepared.candidate;
let candidate = await managedSources.prepareUpdate(proposal, source.path);
try {
await this.checkRequirements(candidate.directory);
await runPluginBuild(candidate.directory, candidate.build, this.logger);
candidate = await managedSources.place(pluginId, candidate);
await this.validateCandidate(candidate);
await managedSources.verifyCandidate(pluginId, candidate, proposal.target);
await managedSources.assertCurrent(proposal, this.requireSource(pluginId).path);
} 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 current = this.requireSource(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;
const shouldActivate =
current.enabled !== false && this.configStore.get().pluginsEnabled === true;
if (shouldActivate) {
if (isRunning) await this.stopPlugin(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);
let recoveryError: unknown;
try {
this.errors.delete(pluginId);
if (isRunning && this.canPublish(pluginId))
await this.startExplicit(pluginId, source.path);
} catch (restoreError) {
recoveryError = restoreError;
} finally {
await managedSources.discard(candidate);
this.notify(pluginId);
}
if (recoveryError)
throw new Error(
`Update failed: ${error instanceof Error ? error.message : String(error)}; restoring previous plugin also failed: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`,
{ cause: error },
);
throw error;
}
}
const activatedSource = this.configStore.get().plugins?.[pluginId];
if (!activatedSource) {
await this.stopPlugin(pluginId);
@@ -593,28 +636,31 @@ export class PluginService {
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);
let warning: string | undefined;
try {
await managedSources.removeVersion(pluginId, source.path);
} catch (error) {
warning = `Plugin updated, but previous installation cleanup failed: ${error instanceof Error ? error.message : String(error)}`;
}
return {
id: pluginId,
previousCommit: previous.commit,
currentCommit: candidate.record.commit,
commits: prepared.commits,
updated: true,
outcome: "updated",
plugin: await this.requireItem(pluginId),
...(warning ? { warning } : {}),
};
}
private validateCandidate(candidate: ManagedPluginCandidate): Promise<void> {
if (!this.runtime.validatePlugin) {
throw new Error("Plugin runtime cannot validate managed Git sources");
throw new Error("Plugin runtime cannot validate managed sources");
}
return this.runtime.validatePlugin(candidate.directory);
}
private requireManagedSources(): ManagedPluginSources {
if (!this.managedSources) throw new Error("Git plugin management is unavailable");
if (!this.managedSources) throw new Error("Plugin source management is unavailable");
return this.managedSources;
}
@@ -1,4 +1,6 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, rename, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -41,24 +43,72 @@ describe("managed Git plugin sources", () => {
const sources = new ManagedPluginSources(home);
const failure = sources.prepareInstall({
source: "https://oauth2:super-secret@127.0.0.1:1/missing.git",
source: "git: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 () => {
it("normalizes explicit GitHub identifiers and shorthand to the same Git source", async () => {
const repository = await createRepository();
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-github-home-"));
roots.push(home);
const overlay = {
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: `url.${pathToFileURL(repository).href}.insteadOf`,
GIT_CONFIG_VALUE_0: "https://github.com/fixture/repository.git",
};
const previous = Object.fromEntries(Object.keys(overlay).map((key) => [key, process.env[key]]));
Object.assign(process.env, overlay);
try {
const sources = new ManagedPluginSources(home);
for (const source of [
"github:fixture/repository",
"fixture/repository",
"git:fixture/repository",
]) {
const candidate = await sources.prepareInstall({ source });
expect(candidate.record).toMatchObject({
kind: "git",
remote: "https://github.com/fixture/repository.git",
});
await sources.discard(candidate);
}
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}, 30_000);
it("offers current default HEAD after installing a tag", 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 });
let candidate = await sources.prepareInstall({ source: `git:${remote}` });
expect(candidate.defaultId).toBe("managed-example");
candidate = await sources.place("managed-example", candidate);
sources.commit("managed-example", candidate.record);
const metadataPath = path.join(home, "plugins", "sources.json");
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
delete metadata["managed-example"].kind;
Object.assign(metadata["managed-example"], {
commit: "obsolete",
requestedRef: "v0",
trackingBranch: "missing",
checkoutRoot: "/obsolete",
});
await writeFile(metadataPath, JSON.stringify(metadata));
expect(
await new ManagedPluginSources(home).describe("managed-example", candidate.directory),
).toMatchObject({ identity: { kind: "git", remote } });
const initial = candidate.record.commit;
if (candidate.record.kind !== "git") throw new Error("Expected Git source");
const initial = (await sources.describe("managed-example", candidate.directory))
.currentRevision!;
await writeFile(
path.join(repository, "index.server.ts"),
"export default () => () => { new Date(); };\n",
@@ -68,29 +118,160 @@ describe("managed Git plugin sources", () => {
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);
const preview = await new ManagedPluginSources(home).preview(
"managed-example",
candidate.directory,
);
expect(preview.outcome).toBe("update");
const prepared = await sources.prepareUpdate(preview.proposal!, candidate.directory);
const updated = await sources.place("managed-example", prepared);
sources.commit("managed-example", updated.record);
expect(await readFile(path.join(updated.directory, "index.server.ts"), "utf8")).toContain(
"new Date",
);
expect(new ManagedPluginSources(home).get("managed-example")?.commit).toBe(latest);
expect(
await new ManagedPluginSources(home).describe("managed-example", updated.directory),
).toMatchObject({ currentRevision: latest });
expect(JSON.parse(await readFile(metadataPath, "utf8"))["managed-example"]).toEqual({
kind: "git",
remote,
});
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({
let tagged = await sources.prepareInstall({ source: remote, ref: "v1" });
tagged = await sources.place("tagged-example", tagged);
sources.commit("tagged-example", tagged.record);
expect(await sources.status("tagged-example", tagged.directory)).toMatchObject({
currentCommit: initial,
latestCommit: initial,
commitsBehind: 0,
updateAvailable: false,
latestCommit: latest,
updateAvailable: true,
});
}, 30_000);
it("resolves branch, tag and commit installs against changed default HEAD and preserves the reviewed commit", async () => {
const repository = await createRepository();
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-review-"));
roots.push(home);
const remote = pathToFileURL(repository).href;
const initial = (await runGitCommand(["rev-parse", "HEAD"], { cwd: repository })).stdout.trim();
await runGitCommand(["tag", "v1"], { cwd: repository });
const sources = new ManagedPluginSources(home);
const installations = [];
for (const [index, ref] of [undefined, "main", "v1", initial].entries()) {
const id = `review-${index}`;
const candidate = await sources.place(
id,
await sources.prepareInstall({ source: remote, ref }),
);
sources.commit(id, candidate.record);
installations.push({ id, candidate });
}
await runGitCommand(["checkout", "-b", "new-default"], { cwd: repository });
await writeFile(path.join(repository, "revision.txt"), "reviewed");
const reviewed = await commitAll(repository, "new default");
const previews = await Promise.all(
installations.map(({ id, candidate }) => sources.preview(id, candidate.directory)),
);
for (const preview of previews)
expect(preview).toMatchObject({
outcome: "update",
current: { currentRevision: initial },
target: { commit: reviewed },
});
await writeFile(path.join(repository, "revision.txt"), "moved after review");
const moved = await commitAll(repository, "move again");
const restarted = new ManagedPluginSources(home);
const { id, candidate } = installations[0]!;
const updated = await restarted.place(
id,
await restarted.prepareUpdate(previews[0]!.proposal!, candidate.directory),
);
expect(await restarted.describe(id, updated.directory)).toEqual({
identity: previews[0]!.current!.identity,
currentRevision: reviewed,
});
expect(await readFile(path.join(updated.directory, "revision.txt"), "utf8")).toBe("reviewed");
expect(await restarted.preview(id, updated.directory)).toMatchObject({
target: { commit: moved },
});
await expect(
restarted.assertCurrent(previews[0]!.proposal!, updated.directory),
).rejects.toThrow("changed since review");
const sameRevision = await restarted.place(
id,
await restarted.prepareInstall({ source: remote, ref: initial }),
);
await expect(
restarted.assertCurrent(previews[0]!.proposal!, sameRevision.directory),
).rejects.toThrow("changed since review");
expect(
await restarted.preview(id, updated.directory, { kind: "git", ref: initial.slice(0, 8) }),
).toMatchObject({ target: { commit: initial } });
}, 30_000);
it("uses retained acquisition remote when credential rewriting changes origin, preserving nested identity", async () => {
const repository = await createRepository();
const nested = path.join(repository, "plugins", "review");
await mkdir(nested, { recursive: true });
for (const name of ["paseo-plugin.json", "index.server.ts"])
await rename(path.join(repository, name), path.join(nested, name));
await commitAll(repository, "nested plugin");
await runGitCommand(["update-server-info"], { cwd: repository });
const server = createServer((request, response) => {
const pathname = new URL(request.url!, "http://localhost").pathname;
const file = path.resolve(repository, ".git", `.${pathname}`);
if (!file.startsWith(path.join(repository, ".git") + path.sep)) {
response.writeHead(404).end();
return;
}
try {
response.end(readFileSync(file));
} catch {
response.writeHead(404).end();
}
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected HTTP port");
const remote = `http://fixture:fake-secret@127.0.0.1:${address.port}/`;
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-credential-"));
roots.push(home);
try {
const sources = new ManagedPluginSources(home);
const candidate = await sources.place(
"nested",
await sources.prepareInstall({ source: remote, pluginPath: "plugins/review" }),
);
sources.commit("nested", candidate.record);
expect(
(
await runGitCommand(["remote", "get-url", "origin"], { cwd: candidate.directory })
).stdout.trim(),
).toBe("https://paseo.invalid/plugin.git");
const before = await sources.describe("nested", candidate.directory);
expect(before.identity).toEqual({
kind: "git",
remote: `http://127.0.0.1:${address.port}/`,
pluginPath: "plugins/review",
});
await writeFile(path.join(nested, "revision.txt"), "new");
const latest = await commitAll(repository, "advance");
await runGitCommand(["update-server-info"], { cwd: repository });
const restarted = new ManagedPluginSources(home);
const preview = await restarted.preview("nested", candidate.directory);
expect(preview.target).toEqual({ kind: "git", commit: latest });
const updated = await restarted.place(
"nested",
await restarted.prepareUpdate(preview.proposal!, candidate.directory),
);
expect(await restarted.describe("nested", updated.directory)).toEqual({
identity: before.identity,
currentRevision: latest,
});
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}, 30_000);
});
@@ -1,32 +1,32 @@
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, rename, rm } from "node:fs/promises";
import { mkdir, realpath, rename, rm } from "node:fs/promises";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import compareVersions from "semver/functions/compare.js";
import { z } from "zod";
import { PluginIdSchema, type PluginSourceStatusItem } from "@getpaseo/protocol/messages";
import {
PluginIdSchema,
type PluginInstallation,
type PluginSourceStatusItem,
type PluginUpdatePreview,
type PluginUpdateProposal,
type PluginUpdateSelection,
type PluginUpdateTarget,
} from "@getpaseo/protocol/messages";
import { runGitCommand } from "../../utils/run-git-command.js";
import { ensurePrivateDirectory, writePrivateFileAtomicSync } from "../private-files.js";
import { readPluginManifest } from "./manifest.js";
import type { PluginManifest } from "./manifest.js";
import { readPluginManifest, type PluginManifest } from "./manifest.js";
import { acquireNpm, isNpmSource, readNpmArtifact, resolveNpm } from "./managed-source/npm.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> {}
// COMPAT(plugin-source-record): v0.8.0; accept and strip pre-correction selector/revision/root copies until daemon floor supports artifact-derived metadata.
const ManagedPluginRecordSchema = z.union([
z.object({ kind: z.literal("git").default("git"), remote: z.string().min(1) }),
z.object({ kind: z.literal("npm") }),
]);
export type ManagedPluginRecord = z.infer<typeof ManagedPluginRecordSchema>;
export interface ManagedPluginCandidate {
build: PluginManifest["build"];
defaultId: string;
@@ -34,67 +34,138 @@ export interface ManagedPluginCandidate {
record: ManagedPluginRecord;
versionRoot: string;
}
interface InstallInput {
source: string;
ref?: string;
pluginPath?: string;
}
interface RefResolution {
commit: string;
requestedRef: string | null;
trackingBranch: string | null;
interface OwnedLocation {
versionRoot: string;
sourceRoot: string;
identity: PluginInstallation["identity"];
}
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.root = path.resolve(paseoHome, "plugins");
this.metadataPath = path.join(this.root, "sources.json");
this.records = this.readRecords();
this.records = existsSync(this.metadataPath)
? z
.record(PluginIdSchema, ManagedPluginRecordSchema)
.parse(JSON.parse(readFileSync(this.metadataPath, "utf8")))
: {};
}
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, build } = await readPluginManifest(directory);
private locate(
pluginId: string,
configuredPath: string,
record = this.records[pluginId],
): OwnedLocation {
if (!record) throw new Error(`Plugin is a local directory: ${pluginId}`);
const pluginRoot = path.join(this.root, PluginIdSchema.parse(pluginId));
const parts = path.relative(pluginRoot, path.resolve(configuredPath)).split(path.sep);
// COMPAT(plugin-git-layout): added in v0.8.0; retain while pre-correction installations exist.
// Git originally used <12-hex-commit>-<uuid>; the prefix is a directory name, never revision truth.
const versionId =
record.kind === "git" ? (parts[0] ?? "").replace(/^[0-9a-f]{12}-/, "") : (parts[0] ?? "");
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(versionId) ||
parts.some((part) => part === "..")
)
throw new Error(`Plugin directory is outside its managed installation: ${pluginId}`);
const versionRoot = path.join(pluginRoot, parts[0]!);
if (record.kind === "git") {
if (parts[1] !== "checkout")
throw new Error(`Invalid Git installation directory: ${pluginId}`);
return {
build,
defaultId,
directory,
record: {
remote,
requestedRef: resolution.requestedRef,
trackingBranch: resolution.trackingBranch,
commit: resolution.commit,
pluginPath,
checkoutRoot,
versionRoot,
sourceRoot: path.join(versionRoot, "checkout"),
identity: {
kind: "git",
remote: redactRemoteCredentials(record.remote),
pluginPath: parts.slice(2).join("/") || ".",
},
versionRoot: stagingRoot,
};
}
if (parts[1] !== "node_modules" || !parts[2])
throw new Error(`Invalid npm installation directory: ${pluginId}`);
const packageParts = parts[2].startsWith("@") ? 2 : 1;
const packageName = parts.slice(2, 2 + packageParts).join("/");
if (!isNpmSource(packageName)) throw new Error(`Invalid npm package directory: ${pluginId}`);
return {
versionRoot,
sourceRoot: path.join(versionRoot, "node_modules", packageName),
identity: {
kind: "npm",
packageName,
pluginPath: parts.slice(2 + packageParts).join("/") || ".",
},
};
}
async describe(pluginId: string, configuredPath: string): Promise<PluginInstallation> {
if (!this.records[pluginId])
return { identity: { kind: "directory", path: path.resolve(configuredPath) } };
const location = this.locate(pluginId, configuredPath);
try {
return {
identity: location.identity,
currentRevision: await this.revision(location, configuredPath),
};
} catch {
return { identity: location.identity };
}
}
private async revision(location: OwnedLocation, configuredPath: string): Promise<string> {
await assertRealContainment(this.root, location.versionRoot);
await assertRealContainment(location.versionRoot, configuredPath);
return location.identity.kind === "npm"
? (await readNpmArtifact(location.versionRoot, location.identity.packageName)).version
: revParse(location.sourceRoot, "HEAD");
}
async prepareInstall(
input: InstallInput,
target?: PluginUpdateTarget,
): Promise<ManagedPluginCandidate> {
const pluginPath = normalizePluginPath(input.pluginPath);
const versionRoot = await this.createStagingRoot();
try {
let record: ManagedPluginRecord;
let sourceRoot: string;
if (isNpmSource(input.source)) {
if (input.ref)
throw new Error(
"Plugin --ref is only valid for Git sources; use npm:package@version for npm",
);
const artifact = await acquireNpm(
input.source,
versionRoot,
target?.kind === "npm" ? target : undefined,
);
sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName);
record = { kind: "npm" };
} else {
const remote = normalizeGitSource(input.source);
sourceRoot = path.join(versionRoot, "checkout");
await clone(remote, sourceRoot);
const commit = await resolveRequestedRef(sourceRoot, input.ref);
if (target?.kind === "git" && commit !== target.commit)
throw new Error("Git target changed since review");
await checkout(sourceRoot, commit);
record = { kind: "git", remote };
}
const directory = path.resolve(sourceRoot, pluginPath);
assertPluginPath(sourceRoot, directory);
await assertRealContainment(sourceRoot, directory);
const { id: defaultId, build } = await readPluginManifest(directory);
return { build, defaultId, directory, record, versionRoot };
} catch (error) {
await rm(stagingRoot, { recursive: true, force: true });
await rm(versionRoot, { recursive: true, force: true });
throw error;
}
}
@@ -103,144 +174,236 @@ export class ManagedPluginSources {
pluginId: string,
candidate: ManagedPluginCandidate,
): Promise<ManagedPluginCandidate> {
const pluginRoot = path.join(this.root, pluginId);
const pluginRoot = path.join(this.root, PluginIdSchema.parse(pluginId));
ensurePrivateDirectory(pluginRoot);
const versionRoot = path.join(
pluginRoot,
`${candidate.record.commit.slice(0, 12)}-${randomUUID()}`,
);
await assertRealContainment(this.root, pluginRoot);
const versionRoot = path.join(pluginRoot, randomUUID());
const relativeDirectory = path.relative(candidate.versionRoot, candidate.directory);
await rename(candidate.versionRoot, versionRoot);
const checkoutRoot = path.join(versionRoot, "checkout");
const directory = path.resolve(checkoutRoot, candidate.record.pluginPath);
return { ...candidate, directory: path.join(versionRoot, relativeDirectory), versionRoot };
}
async preview(
pluginId: string,
configuredPath: string,
selection?: PluginUpdateSelection,
): Promise<PluginUpdatePreview> {
const current = await this.describe(pluginId, configuredPath);
if (current.identity.kind === "directory") {
if (selection) throw new Error(`Plugin is a local directory: ${pluginId}`);
return { id: pluginId, outcome: "local", current, links: [] };
}
if (!current.currentRevision)
throw new Error(
`Installed revision is unavailable for ${pluginId}; restore its acquisition artifacts before updating`,
);
if (selection && selection.kind !== current.identity.kind)
throw new Error(`Update target does not match ${current.identity.kind} source: ${pluginId}`);
const location = this.locate(pluginId, configuredPath);
let target: PluginUpdateTarget;
let outcome: PluginUpdatePreview["outcome"];
if (current.identity.kind === "npm") {
target = {
kind: "npm",
...(await resolveNpm(
current.identity.packageName,
selection?.kind === "npm" ? selection.version : "latest",
this.root,
)),
};
const comparison = compareVersions(target.version, current.currentRevision);
if (comparison === 0) outcome = "current";
else if (comparison < 0 && !selection) outcome = "installed-newer";
else outcome = "update";
} else {
const record = this.records[pluginId];
if (record?.kind !== "git") throw new Error("Git source is no longer configured");
target = {
kind: "git",
commit: await resolveRemoteCommit(
record.remote,
this.root,
selection?.kind === "git" ? selection.ref : undefined,
),
};
outcome = target.commit === current.currentRevision ? "current" : "update";
}
const links = reviewLinks(current, target);
return {
...candidate,
directory,
versionRoot,
record: { ...candidate.record, checkoutRoot },
id: pluginId,
outcome,
current,
target,
links,
...(outcome === "update"
? {
proposal: {
id: pluginId,
expected: {
identity: current.identity,
installationRoot: location.versionRoot,
revision: current.currentRevision,
},
target,
},
}
: {}),
};
}
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 assertCurrent(proposal: PluginUpdateProposal, configuredPath: string): Promise<void> {
const current = await this.describe(proposal.id, configuredPath);
const location = this.locate(proposal.id, configuredPath);
if (
!isDeepStrictEqual(current.identity, proposal.expected.identity) ||
current.currentRevision !== proposal.expected.revision ||
location.versionRoot !== proposal.expected.installationRoot
)
throw new Error(`Plugin ${proposal.id} changed since review; check again`);
if (proposal.target.kind !== current.identity.kind)
throw new Error("Update target does not match the installed source");
}
async prepareUpdate(
pluginId: string,
proposal: PluginUpdateProposal,
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 };
): Promise<ManagedPluginCandidate> {
await this.assertCurrent(proposal, configuredPath);
const identity = this.locate(proposal.id, configuredPath).identity;
const record = this.records[proposal.id];
if (identity.kind === "git" && record?.kind === "git" && proposal.target.kind === "git")
return this.prepareInstall(
{
source: `git:${record.remote}`,
ref: proposal.target.commit,
pluginPath: identity.pluginPath,
},
proposal.target,
);
if (identity.kind === "npm" && proposal.target.kind === "npm")
return this.prepareInstall(
{
source: `npm:${identity.packageName}@${proposal.target.version}`,
pluginPath: identity.pluginPath,
},
proposal.target,
);
throw new Error("Update target does not match the installed source");
}
async verifyCandidate(
pluginId: string,
candidate: ManagedPluginCandidate,
target?: PluginUpdateTarget,
): Promise<void> {
const location = this.locate(pluginId, candidate.directory, candidate.record);
const revision = await this.revision(location, candidate.directory);
if (target?.kind === "git" && revision !== target.commit)
throw new Error("Preparation changed the reviewed Git commit");
if (target?.kind === "npm" && location.identity.kind === "npm") {
const artifact = await readNpmArtifact(location.versionRoot, location.identity.packageName);
if (
artifact.version !== target.version ||
artifact.integrity !== target.integrity ||
artifact.resolved !== target.resolved
)
throw new Error("Preparation changed the reviewed npm artifact");
}
}
async status(pluginId: string, configuredPath: string): Promise<PluginSourceStatusItem> {
const preview = await this.preview(pluginId, configuredPath);
const installation = preview.current!;
return {
id: pluginId,
source: installation.identity.kind === "git" ? "git" : "directory",
path: configuredPath,
installation,
...(installation.identity.kind === "git"
? {
remote: installation.identity.remote,
currentCommit: installation.currentRevision,
latestCommit: preview.target?.kind === "git" ? preview.target.commit : undefined,
updateAvailable: preview.outcome === "update",
}
: {}),
};
}
commit(pluginId: string, record: ManagedPluginRecord): void {
this.records[pluginId] = record;
this.records[pluginId] = ManagedPluginRecordSchema.parse(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 removeVersion(pluginId: string, configuredPath: string): Promise<void> {
const location = this.locate(pluginId, configuredPath);
await assertRealContainment(this.root, path.dirname(location.versionRoot));
await rm(location.versionRoot, { recursive: true, force: true });
}
async remove(pluginId: string): Promise<void> {
if (!this.records[pluginId]) return;
const pluginRoot = path.join(this.root, PluginIdSchema.parse(pluginId));
if (existsSync(pluginRoot)) {
await assertRealContainment(this.root, pluginRoot);
await rm(pluginRoot, { recursive: true, force: true });
}
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, build } = await readPluginManifest(directory);
return {
build,
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 });
await assertRealContainment(this.root, stagingRoot);
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`);
}
}
async function assertRealContainment(root: string, directory: string): Promise<void> {
assertPluginPath(await realpath(root), await realpath(directory));
}
function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): string[] {
if (
current.identity.kind === "npm" &&
target.kind === "npm" &&
new URL(target.resolved).hostname === "registry.npmjs.org"
)
return [current.currentRevision, target.version].map(
(version) =>
`https://www.npmjs.com/package/${current.identity.kind === "npm" ? current.identity.packageName : ""}/v/${encodeURIComponent(version!)}`,
);
if (current.identity.kind !== "git" || target.kind !== "git") return [];
const remote = current.identity.remote
.replace(/^git@([^:]+):/, "https://$1/")
.replace(/^ssh:\/\/(?:git@)?/, "https://")
.replace(/\.git$/, "");
try {
const url = new URL(remote);
if (url.hostname === "github.com")
return [`${url.origin}${url.pathname}/compare/${current.currentRevision}...${target.commit}`];
if (url.hostname === "gitlab.com")
return [
`${url.origin}${url.pathname}/-/compare/${current.currentRevision}...${target.commit}`,
];
} catch {
/* Local remotes have no review website. */
}
return [];
}
function normalizeGitSource(source: string): string {
if (source.startsWith("github:")) {
const shorthand = source.slice(7);
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) {
throw new Error("Use github:owner/repository for a GitHub plugin source");
}
source = shorthand;
} else if (source.startsWith("git:") && !source.startsWith("git://")) {
source = source.slice(4);
}
const github = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(source);
if (github) {
const repository = github[2].replace(/\.git$/, "");
@@ -337,29 +500,70 @@ async function checkout(checkoutRoot: string, commit: string): Promise<void> {
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 };
}
): Promise<string> {
if (!requestedRef) return revParse(checkoutRoot, "refs/remotes/origin/HEAD^{commit}");
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 };
for (const ref of [
`refs/remotes/origin/${requestedRef}`,
`refs/tags/${requestedRef}`,
requestedRef,
]) {
if (await refExists(checkoutRoot, ref)) return revParse(checkoutRoot, `${ref}^{commit}`);
}
const tagRef = `refs/tags/${requestedRef}`;
if (await refExists(checkoutRoot, tagRef)) {
const commit = await revParse(checkoutRoot, `${tagRef}^{commit}`);
return { commit, requestedRef, trackingBranch: null };
return revParse(checkoutRoot, `${requestedRef}^{commit}`);
}
async function resolveRemoteCommit(remote: string, cwd: string, ref?: string): Promise<string> {
if (ref?.startsWith("-")) throw new Error("Plugin Git ref cannot start with '-'");
if (ref && /^[0-9a-f]{40,64}$/.test(ref)) return ref;
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 {
const names = ref
? [`refs/heads/${ref}`, `refs/tags/${ref}^{}`, `refs/tags/${ref}`, ref]
: ["HEAD"];
const { stdout } = await runGitCommand(["ls-remote", "--symref", "--", cloneRemote, ...names], {
cwd,
envOverlay,
timeout: GIT_TIMEOUT_MS,
});
const entries = new Map(
stdout
.trim()
.split("\n")
.map((line) => {
const [commit, name] = line.split("\t");
return [name, commit];
}),
);
for (const name of names) {
const commit = entries.get(name);
if (commit && /^[0-9a-f]{40,64}$/.test(commit)) return commit;
}
if (ref) {
// Abbreviated commits and revision expressions are not advertised by ls-remote.
// Resolve them in a temporary object database without preparing or touching the installed plugin.
const staging = path.join(cwd, ".staging", randomUUID());
await mkdir(staging, { recursive: true, mode: 0o700 });
try {
await clone(remote, path.join(staging, "checkout"));
return await resolveRequestedRef(path.join(staging, "checkout"), ref);
} finally {
await rm(staging, { recursive: true, force: true });
}
}
throw new Error("Cannot resolve Git default HEAD");
} catch (error) {
throw redactRemoteError(error, remote);
}
const commit = await revParse(checkoutRoot, `${requestedRef}^{commit}`);
return { commit, requestedRef, trackingBranch: null };
}
async function refExists(cwd: string, ref: string): Promise<boolean> {
@@ -374,17 +578,3 @@ 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);
}
@@ -0,0 +1,157 @@
import compareVersions from "semver/functions/compare.js";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { execCommand } from "../../../utils/spawn.js";
import { findExecutable } from "../../../executable-resolution/executable-resolution.js";
const LockfileSchema = z.object({
lockfileVersion: z.number().min(2),
packages: z.record(
z.string(),
z.object({
version: z.string().optional(),
resolved: z.string().optional(),
integrity: z.string().optional(),
}),
),
});
const NPM_SOURCE_PATTERN =
/^(?<name>(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)(?:@(?<spec>[^:@/\\]+))?$/;
export function isNpmSource(source: string): boolean {
return source.startsWith("npm:") || NPM_SOURCE_PATTERN.test(source);
}
function parseNpmSource(source: string): { packageName: string; requestedSpec: string } {
const spec = source.replace(/^npm:/, "");
const match = NPM_SOURCE_PATTERN.exec(spec);
if (!match?.groups) {
throw new Error(
`Invalid npm plugin source: ${source}. Use npm:package or npm:@scope/package@version, tag, or range.`,
);
}
const requestedSpec = match.groups.spec ?? "latest";
if (!requestedSpec.trim() || requestedSpec !== requestedSpec.trim()) {
throw new Error("An npm version, tag, or range must be non-empty without surrounding spaces");
}
return { packageName: match.groups.name, requestedSpec };
}
export async function acquireNpm(source: string, installRoot: string, target?: NpmArtifact) {
const { packageName, requestedSpec } = parseNpmSource(source);
await writeFile(
path.join(installRoot, "package.json"),
JSON.stringify({
name: "paseo-plugin-installation",
version: "1.0.0",
private: true,
dependencies: { [packageName]: target?.resolved ?? requestedSpec },
}),
);
const npm = await requireNpm();
await execCommand(
npm,
[
"install",
"--ignore-scripts",
"--legacy-peer-deps",
"--no-audit",
"--no-fund",
"--package-lock=true",
"--lockfile-version=3",
"--include=prod",
"--omit=dev",
"--global=false",
"--workspaces=false",
],
{ cwd: installRoot, timeout: 120_000, maxBuffer: 1024 * 1024 },
);
const installed = await readNpmArtifact(installRoot, packageName);
if (
target &&
(installed.version !== target.version ||
installed.integrity !== target.integrity ||
installed.resolved !== target.resolved)
) {
throw new Error(`npm artifact changed since review for ${packageName}`);
}
return { packageName, ...installed };
}
export interface NpmArtifact {
version: string;
resolved: string;
integrity: string;
}
export async function readNpmArtifact(
installRoot: string,
packageName: string,
): Promise<NpmArtifact> {
const lock = LockfileSchema.parse(
JSON.parse(await readFile(path.join(installRoot, "package-lock.json"), "utf8")),
);
const artifact = lock.packages[`node_modules/${packageName}`];
if (!artifact?.version || !artifact.resolved || !artifact.integrity)
throw new Error(`Missing npm artifact for ${packageName}`);
const installed = z
.object({ name: z.string(), version: z.string() })
.parse(
JSON.parse(
await readFile(path.join(installRoot, "node_modules", packageName, "package.json"), "utf8"),
),
);
const root = z
.object({ dependencies: z.record(z.string(), z.string()) })
.parse(JSON.parse(await readFile(path.join(installRoot, "package.json"), "utf8")));
if (
Object.keys(root.dependencies).length !== 1 ||
!(packageName in root.dependencies) ||
installed.name !== packageName ||
installed.version !== artifact.version
) {
throw new Error(
`Installed npm package does not match its acquisition artifacts: ${packageName}`,
);
}
return { version: artifact.version, resolved: artifact.resolved, integrity: artifact.integrity };
}
export async function resolveNpm(
packageName: string,
selector: string,
cwd: string,
): Promise<NpmArtifact> {
// Validate selectors using the same grammar as installation; never interpret an update as another source.
parseNpmSource(`npm:${packageName}@${selector}`);
const { stdout } = await execCommand(
await requireNpm(),
["view", `${packageName}@${selector}`, "version", "dist", "--json"],
{ cwd, timeout: 120_000, maxBuffer: 1024 * 1024 },
);
const schema = z.object({
version: z.string(),
dist: z.object({ tarball: z.string().url(), integrity: z.string().min(1) }),
});
const raw: unknown = JSON.parse(stdout);
const entries = z.array(schema).parse(Array.isArray(raw) ? raw : [raw]);
const latest = entries.sort((a, b) => compareVersions(b.version, a.version))[0];
if (!latest) throw new Error(`No npm version matches ${packageName}@${selector}`);
return {
version: latest.version,
resolved: latest.dist.tarball,
integrity: latest.dist.integrity,
};
}
async function requireNpm(): Promise<string> {
const npm = await findExecutable("npm");
if (!npm)
throw new Error(
"npm is required on the daemon host to acquire or check npm plugins. Install Node.js with npm and make npm available on the daemon's PATH.",
);
return npm;
}
+29
View File
@@ -491,6 +491,13 @@ export interface SessionOptions {
statusSources(
pluginId?: string,
): Promise<import("@getpaseo/protocol/messages").PluginSourceStatusItem[]>;
previewUpdates(input: {
pluginId?: string;
target?: import("@getpaseo/protocol/messages").PluginUpdateSelection;
}): Promise<import("@getpaseo/protocol/messages").PluginUpdatePreview[]>;
applyUpdates(
proposals: import("@getpaseo/protocol/messages").PluginUpdateProposal[],
): Promise<import("@getpaseo/protocol/messages").PluginUpdateResult[]>;
updateSources(
pluginId?: string,
): Promise<import("@getpaseo/protocol/messages").PluginSourceUpdateItem[]>;
@@ -2469,6 +2476,28 @@ export class Session {
return undefined;
});
}
if (msg.type === "plugin.source.update.preview.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime
.previewUpdates({ pluginId: msg.pluginId, target: msg.target })
.then((plugins) => {
this.emit({
type: "plugin.source.update.preview.response",
payload: { requestId: msg.requestId, plugins },
});
return undefined;
});
}
if (msg.type === "plugin.source.update.apply.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime.applyUpdates(msg.proposals).then((plugins) => {
this.emit({
type: "plugin.source.update.apply.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) => {
@@ -1721,6 +1721,8 @@ export class VoiceAssistantWebSocketServer {
plugins: true,
pluginManagement: true,
pluginGitManagement: true,
pluginSourceInstallation: true,
pluginSourceUpdates: true,
pluginLogs: true,
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20.
pluginThemes: true,