mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
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:
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>"),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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] },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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!]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user