mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +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:
+9
-7
@@ -64,8 +64,8 @@ $PASEO_HOME/
|
||||
│ └── managed-processes/
|
||||
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
|
||||
├── plugins/
|
||||
│ ├── sources.json # Git origin, ref, commit, and managed checkout ownership
|
||||
│ └── {pluginId}/{version}/checkout/ # Source checkout for one installed Git commit
|
||||
│ ├── sources.json # Managed kind and Git acquisition remote
|
||||
│ └── {pluginId}/{uuid}/ # Git checkout or npm package/lockfile/dependency tree
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
```
|
||||
|
||||
@@ -254,11 +254,13 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
Git-managed plugins still appear as directory sources in `config.json`. This keeps the plugin
|
||||
runtime and protocol config compatible with directory-only clients. `plugins/sources.json` owns the
|
||||
Git-specific origin, tracking ref, installed commit, repository subdirectory, and checkout root.
|
||||
Paseo writes it atomically. An update creates and validates a new version directory before changing
|
||||
the configured directory path; successful activation removes the old version.
|
||||
Managed plugins appear as directory sources in `config.json`; it owns the active path and enabled
|
||||
state. `plugins/sources.json` is written atomically and stores only managed kind and the Git acquisition
|
||||
remote. Installed revision, package/subdirectory identity and ownership root come from retained
|
||||
artifacts and the fixed managed layout; [managed source ownership](plugins.md#managed-source-ownership)
|
||||
explains their authority. An update prepares a new directory before replacing the active path and
|
||||
removing the previous version. Old record fields are accepted at the store boundary and ignored;
|
||||
there is no startup migration or persistent update policy.
|
||||
|
||||
### Profile lists
|
||||
|
||||
|
||||
+29
-3
@@ -4,7 +4,7 @@ Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Com
|
||||
client slash commands, timeline items, header buttons, composer pills, app themes, composer attachment sources, and settings screens.
|
||||
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
|
||||
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||
|
||||
## Install a directory source
|
||||
|
||||
@@ -103,10 +103,36 @@ paseo plugin update --all
|
||||
|
||||
Append `:relative/path` to the source when the plugin lives below the repository root.
|
||||
|
||||
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
|
||||
tags and commits stay pinned. `ls` reports the installed commit without contacting the remote.
|
||||
`--ref` chooses the initial branch, tag, or commit once. Ordinary updates resolve the remote's
|
||||
current default HEAD and ask for approval. `ls` reports the installed commit without contacting the remote.
|
||||
Removing a Git source deletes Paseo's managed checkout.
|
||||
|
||||
## Managed source ownership
|
||||
|
||||
The [public source reference](../public-docs/plugins/v0.8/reference.md#plugin-sources) owns identifier
|
||||
syntax, npm prerequisites, and publishing instructions. Both clients send the source unchanged
|
||||
through `installPluginSource`; only the daemon resolves host paths and acquires sources.
|
||||
|
||||
`ManagedPluginSources` owns acquisition and offline source description. Config stores the active
|
||||
directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed
|
||||
because authenticated clones may have a rewritten placeholder origin. Fixed owned directory layout
|
||||
provides the selected subdirectory, npm package name and cleanup root. Git HEAD and the installed
|
||||
npm package.json checked against package-lock.json provide the current revision. Keep the complete
|
||||
npm dependency tree and lockfile after staging activation. No install selector governs updates.
|
||||
|
||||
The loader sees only the configured directory and never invokes npm. npm lifecycle scripts are
|
||||
disabled during acquisition; manifest preparation owns required commands before validation.
|
||||
|
||||
List responses retain the closed `source: directory | git` enum. The optional `installation`
|
||||
projection carries identity and current revision. Old npm metadata remains accepted but is no longer
|
||||
emitted. Installation uses `features.pluginSourceInstallation`; reviewed preview/apply uses
|
||||
`features.pluginSourceUpdates`, gated by the SDK and CLI entry. The old immediate-update request
|
||||
returns update-client guidance. Basic management remains independently available.
|
||||
|
||||
Preview returns exact targets and expected installed identity/root/revision. Apply validates those
|
||||
values, acquires the displayed artifact or commit, then rechecks state before activation. No review
|
||||
session is stored in the daemon. PluginService owns activation/recovery and independent bulk results.
|
||||
|
||||
### Declare Git preparation
|
||||
|
||||
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency
|
||||
|
||||
Generated
+13
@@ -39022,6 +39022,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",
|
||||
@@ -39728,6 +39729,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
Copyright (c) 2025-present Mohamed Boudra
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
* Third-party components incorporated into Paseo remain licensed under
|
||||
the licenses provided by their respective copyright holders.
|
||||
* Except for those third-party components, Paseo is licensed under the
|
||||
Apache License, Version 2.0, reproduced below.
|
||||
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,10 +1,29 @@
|
||||
# Modal UI example
|
||||
|
||||
Install this directory as a local plugin, then open **Modal examples** from the sidebar or a workspace tab.
|
||||
Install on a Paseo host with npm source installation support and npm available:
|
||||
|
||||
```sh
|
||||
paseo plugin install npm:@getpaseo/plugin-example-modal-ui@0.1.1
|
||||
```
|
||||
|
||||
In host **Settings → Plugins**, paste the same `npm:` identifier into **Plugin source**.
|
||||
Enable plugins, then open **Modal examples** from the sidebar or a workspace tab.
|
||||
Choose **Open Form**, type into **Paste here**, and watch the **Input:** text update.
|
||||
The installed plugin ID is `modal-ui-example`. Host Settings shows the package identity and
|
||||
installed version. With reviewed-update support, run `paseo plugin update modal-ui-example --check`
|
||||
to check for a newer version, then `paseo plugin update modal-ui-example` to review and approve it.
|
||||
An explicit install version selects the initial content; later updates still check the latest release.
|
||||
|
||||
The package ships the existing TypeScript entry and client sources. Paseo compiles them through
|
||||
its directory plugin loader; no package build or preparation command is required. React,
|
||||
React Native, and `@getpaseo/plugin` are peer contracts supplied by the host. npm is needed for
|
||||
installation, not loading or reloading. You can also install this directory while developing.
|
||||
See the [source reference](https://paseo.sh/docs/plugins/v0.8/reference#plugin-sources)
|
||||
for supported identifiers and preparation rules.
|
||||
|
||||
The examples demonstrate default and custom padding, a full-width body, author-owned ScrollView and
|
||||
FlatList scrolling, horizontal tabs, and clipboard actions with a keyboard-aware input. See the
|
||||
[host UI reference](../../public-docs/plugins/v0.8/reference.md#host-ui) for the API contract.
|
||||
[host UI reference](https://paseo.sh/docs/plugins/v0.8/reference#host-ui) for the API contract.
|
||||
|
||||
The browser regression installs this exact example in an isolated daemon:
|
||||
|
||||
@@ -19,7 +38,7 @@ list, drag down on a row to dismiss the sheet. In Form,
|
||||
press **Copy text**, long-press the input, and choose **Paste**. The input should contain
|
||||
“Copied from Paseo”. With the system keyboard enabled, focusing the input should keep it visible.
|
||||
|
||||
Run the [native sheet regression](../../packages/app/e2e/mobile/modal-sheet/README.md) to check body
|
||||
Run the [native sheet regression](https://github.com/getpaseo/paseo/blob/main/packages/app/e2e/mobile/modal-sheet/README.md) to check body
|
||||
dismissal, list scrolling and horizontal tabs together.
|
||||
|
||||
These captures show Android copy/paste and custom padding on browser and wide native layouts.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin-example-modal-ui",
|
||||
"version": "0.1.1",
|
||||
"description": "Interactive Paseo plugin example for modal forms, scrolling, and clipboard actions",
|
||||
"homepage": "https://paseo.sh/docs/plugins/v0.8",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/getpaseo/paseo.git",
|
||||
"directory": "plugin-examples/modal-ui"
|
||||
},
|
||||
"files": [
|
||||
"paseo-plugin.json",
|
||||
"index.client.tsx",
|
||||
"client/",
|
||||
"screenshots/",
|
||||
"LICENSE"
|
||||
],
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/plugin": ">=0.8.0",
|
||||
"react": ">=19",
|
||||
"react-native": ">=0.81"
|
||||
}
|
||||
}
|
||||
@@ -2,5 +2,6 @@
|
||||
"id": "modal-ui-example",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"description": "Interactive modal forms, scrolling lists, and clipboard examples"
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ API behind `Platform.OS` with a native fallback. See
|
||||
|
||||
## Install and try it
|
||||
|
||||
Plugins are trusted, unsandboxed code: server code and Git preparation commands run with the daemon
|
||||
Plugins are trusted, unsandboxed code: server code and preparation commands run with the daemon
|
||||
user's access on the daemon machine, and client code runs inside the Paseo app. Installing a plugin
|
||||
means you trust that codebase, its dependencies, and its future updates.
|
||||
|
||||
@@ -128,7 +128,7 @@ Or install the directory from the app:
|
||||
2. Paste `/absolute/path/to/workspace-plugin` into **Plugin source**.
|
||||
3. Select **Install plugin**. The app uses the plugin ID from `paseo-plugin.json`.
|
||||
|
||||
The source field also accepts Git repositories and plugin subdirectories. See
|
||||
The source field also accepts Git repositories, npm packages, and plugin subdirectories. See
|
||||
[Plugin sources](/docs/plugins/v0.8/reference#plugin-sources) for the accepted syntax and resolution
|
||||
rules. If the form asks you to update the host, update that host before installing; the existing
|
||||
management actions remain available.
|
||||
@@ -233,6 +233,20 @@ workspace overview**. The panel opens as a workspace tab.
|
||||
|
||||
## Install a published plugin
|
||||
|
||||
Install a trusted npm plugin using npm on the daemon host:
|
||||
|
||||
```bash
|
||||
paseo plugin install npm:@acme/paseo-review@1.2.0
|
||||
```
|
||||
|
||||
Or paste the same identifier into **Settings → Plugins → Plugin source** and select **Install
|
||||
plugin**. The installed row uses the manifest ID and provides the same enable switch, Logs, Reload,
|
||||
and Remove actions as a directory plugin. npm is needed for acquisition; subsequent loading and
|
||||
reload use the installed files.
|
||||
|
||||
See [plugin sources](/docs/plugins/v0.8/reference#plugin-sources) for exact identifiers, registry
|
||||
configuration, dependencies, and what authors must publish.
|
||||
|
||||
Plugins published in a Git repository install by shorthand or URL:
|
||||
|
||||
```bash
|
||||
@@ -242,8 +256,8 @@ paseo plugin add owner/monorepo:plugins/workspace
|
||||
paseo plugin add owner/repository --ref main
|
||||
```
|
||||
|
||||
Append `:relative/path` when the plugin lives below the repository root. Without `--ref`, the
|
||||
default branch is tracked; a branch tracks updates, while a tag or commit stays pinned.
|
||||
Append `:relative/path` when the plugin lives below the repository root. `--ref` selects the initial
|
||||
Git content once; omitting it installs the default HEAD. See the [source reference](/docs/plugins/v0.8/reference#plugin-sources) for exact syntax.
|
||||
|
||||
```bash
|
||||
paseo plugin ls
|
||||
@@ -251,7 +265,11 @@ paseo plugin update workspace-plugin
|
||||
paseo plugin update --all
|
||||
```
|
||||
|
||||
`ls` reports runtime state, source details, and the installed commit without contacting the remote.
|
||||
`ls` reports source identity and the current installed revision without contacting the remote.
|
||||
`update` shows a proposed npm or Git revision and asks for approval. npm offers a newer latest
|
||||
release; Git offers the current default HEAD, regardless of the initial install selector.
|
||||
Use `--check` to preview only or `--yes` to approve without a prompt.
|
||||
See the [CLI reference](/docs/plugins/v0.8/reference#cli-reference) for explicit targets and bulk updates.
|
||||
|
||||
Paseo compiles TypeScript itself, so most plugins need no build step. A repository that must
|
||||
install a dependency Paseo does not provide, or generate files, declares
|
||||
|
||||
@@ -1891,44 +1891,96 @@ Paste one of these source identifiers into **Settings → Plugins**, or pass it
|
||||
Absolute host paths are recommended because relative paths resolve against the daemon's working
|
||||
directory. The app does not expand `~`; your shell may expand it before the CLI runs.
|
||||
|
||||
| Source | Accepted form | Example |
|
||||
| -------------------------- | --------------------------------------------------------------- | ----------------------------------------- |
|
||||
| Host directory | Absolute or relative path on the daemon host | `/srv/paseo/plugins/review` |
|
||||
| GitHub repository | `owner/repository` | `acme/paseo-review` |
|
||||
| Git URL | `https://`, `http://`, `ssh://`, `git://`, or `file://` URL | `https://git.example.com/acme/review.git` |
|
||||
| SCP-style Git repository | `user@host:path` | `git@git.example.com:acme/review.git` |
|
||||
| Plugin below a source root | Add `:relative/plugin/path` to a directory or repository source | `acme/monorepo:plugins/review` |
|
||||
| Source | Accepted form | Example |
|
||||
| -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| Host directory | Absolute or relative path on the daemon host | `/srv/paseo/plugins/review` |
|
||||
| GitHub repository | `github:owner/repository` or `owner/repository` | `github:acme/paseo-review` |
|
||||
| Git repository | `git:<URL or SCP source>`; the prefix is optional for URLs and SCP sources | `git:https://git.example.com/acme/review.git` |
|
||||
| npm package | `npm:<name>[@<version, tag, or range>]`; `npm:` is optional | `npm:@acme/paseo-review@^1.2.0` |
|
||||
| Plugin below a source root | Append `:relative/plugin/path` to any source | `github:acme/monorepo:plugins/review` |
|
||||
|
||||
Git URLs use `https://`, `http://`, `ssh://`, `git://`, or `file://`. SCP sources use
|
||||
`user@host:path`. `file://` selects Git acquisition, not directory installation.
|
||||
|
||||
npm names are lowercase unscoped `name` or scoped `@scope/name`. Each component starts with a
|
||||
letter or digit and then contains letters, digits, `.`, `_`, or `-`. After the package name, an
|
||||
optional `@` introduces an exact version, distribution tag, or npm semver range. Omitting it means
|
||||
`latest`. Quote shell arguments containing spaces or comparison operators. npm aliases, tarball
|
||||
URLs, and npm's `file:` specifications are not plugin source identifiers; use a directory or Git
|
||||
source for those locations. Use the `npm:` prefix for an unscoped package with both a selector
|
||||
and subdirectory (`npm:review@1.2.0:nested`); without it, `user@host:path` is an SCP Git source.
|
||||
The package registry validates the selected version, tag, or range.
|
||||
|
||||
Paseo resolves an identifier in this order:
|
||||
|
||||
1. If the complete identifier names an existing daemon-host directory, Paseo uses that directory.
|
||||
This check comes first, so an existing literal directory containing `:` wins.
|
||||
2. Otherwise, Paseo recognizes a final `:relative/plugin/path` only when the suffix is relative and
|
||||
contains no empty, `.` or `..` segments. A lone `.` selects the source root. Both `/` and `\`
|
||||
separate suffix segments; use `/` for paths that must be portable across hosts because `\` also
|
||||
follows the daemon host's path semantics. A suffix that does not match these rules remains part
|
||||
of the source. URL ports and the separator in an SCP-style repository also remain part of the Git
|
||||
source. A recognized suffix selects that subdirectory after the source is acquired.
|
||||
3. If the remaining source names an existing daemon-host directory, Paseo installs from it.
|
||||
4. Exact `owner/repository` syntax is expanded to a GitHub HTTPS URL.
|
||||
5. The listed Git URL and SCP-style forms are cloned. Any other source is rejected.
|
||||
1. An existing directory matching the complete identifier on the daemon host wins, including a
|
||||
literal directory containing `:`.
|
||||
2. Otherwise, recognize `npm:`, `github:`, or `git:` before interpreting a subdirectory suffix.
|
||||
`git://` is a Git URL scheme. An explicit prefix selects acquisition of that kind.
|
||||
3. Recognize a final `:relative/plugin/path` only when its suffix contains no empty, `.` or `..`
|
||||
segments. A lone `.` selects the source root. Both `/` and `\` separate suffix segments; use `/`
|
||||
across hosts. URL ports and the separator in an SCP source stay part of the source. A suffix
|
||||
that does not satisfy these rules stays part of the identifier.
|
||||
4. Without an explicit prefix, an existing directory matching the remaining source wins.
|
||||
5. Resolve Git URLs and SCP sources as Git; expand exact `owner/repository` shorthand to GitHub
|
||||
HTTPS. `github:` requires that shorthand; `git:` accepts it as well as URLs and SCP sources.
|
||||
6. Resolve a remaining npm package name with its optional selector through the host's registry.
|
||||
Reject anything else.
|
||||
|
||||
Directory lookup happens on the daemon host; neither the app nor CLI checks the caller's filesystem.
|
||||
The app always uses the ID from `paseo-plugin.json`. The CLI also accepts `--id <runtime-id>` to
|
||||
override it:
|
||||
Directory lookup happens on the daemon host. The app uses the `paseo-plugin.json` ID; the CLI
|
||||
accepts `--id <runtime-id>` to override it. An existing installation ID is rejected without changing
|
||||
its enabled state or files.
|
||||
|
||||
```bash
|
||||
paseo plugin install /srv/paseo/plugins/review
|
||||
paseo plugin install acme/paseo-review
|
||||
paseo plugin install https://git.example.com:8443/acme/monorepo.git:plugins/review --ref main
|
||||
paseo plugin install github:acme/paseo-review
|
||||
paseo plugin install git:https://git.example.com:8443/acme/monorepo.git:plugins/review --ref main
|
||||
paseo plugin install git@git.example.com:acme/review.git
|
||||
paseo plugin install file:///srv/repos/monorepo:plugins/review
|
||||
paseo plugin install acme/paseo-review --id review-staging
|
||||
paseo plugin install npm:paseo-review@1.2.0
|
||||
paseo plugin install npm:@acme/paseo-review@next
|
||||
paseo plugin install 'npm:@acme/paseo-review@>=1.2.0 <2.0.0' --id review-staging
|
||||
paseo plugin install npm:@acme/plugins@^1.2.0:plugins/review
|
||||
```
|
||||
|
||||
`--ref` applies only to Git sources and accepts a branch, tag, or commit. Without it, Paseo tracks
|
||||
the remote's default branch. An explicit branch remains tracked; tags and commits are pinned. The
|
||||
legacy `--path relative/plugin/path` option is equivalent to the `:relative/plugin/path` suffix.
|
||||
`--ref` applies only to Git and accepts a branch, tag, or commit for this installation. Without it,
|
||||
Paseo installs the remote's default HEAD. Installation selectors do not constrain later updates. The legacy
|
||||
`--path relative/plugin/path` option is equivalent to a subdirectory suffix, including for npm.
|
||||
|
||||
### npm installation and publishing
|
||||
|
||||
Install Node.js with npm on the **daemon host** and make `npm` available on the daemon's `PATH`.
|
||||
The daemon uses that host's npm user/global configuration and environment for registry selection
|
||||
and authentication, including scope-specific registries. The client does not download packages or
|
||||
run npm. Loading, enabling, and reloading an installed plugin do not need npm.
|
||||
|
||||
The daemon installs each candidate and its production dependencies in an isolated directory. It
|
||||
keeps the complete dependency tree and `package-lock.json` when activating it. The installed
|
||||
package and lockfile provide its current version and artifact integrity. A version, tag, or range
|
||||
chooses content for this installation only.
|
||||
|
||||
Publish the existing [plugin project format](#project-files): `paseo-plugin.json`, at least one
|
||||
`index.server.ts` or `index.client.tsx` entry (either accepts `.ts` or `.tsx`), and every imported
|
||||
file and asset under the appropriate `server/`, `client/`, or `shared/` directory. npm's `main` and
|
||||
`exports` fields do not select Paseo entry points. Use the package's `files` list to include those
|
||||
files and inspect `npm pack --dry-run` before publishing.
|
||||
|
||||
- Put non-host runtime dependencies in `dependencies`. npm installs their transitive dependencies.
|
||||
- Keep the SDK, React, React Native, TanStack Query, and Zod in `devDependencies` for authoring;
|
||||
Paseo supplies their runtime instances. Development dependencies are not installed for consumers,
|
||||
and npm peer auto-installation is disabled. List required non-host peer modules as dependencies.
|
||||
- Package lifecycle scripts (`install`, `postinstall`, `prepare`, and dependency scripts) do not run
|
||||
during acquisition. Declare required installation preparation in the plugin manifest's
|
||||
[`build` argv commands](#cli-reference). Those commands run after the Paseo requirements check.
|
||||
A native dependency that requires rebuilding needs an explicit preparation command.
|
||||
- Prefer publishing ready-to-use files. Prebuilt JavaScript belongs under its runtime directory,
|
||||
imported by the normal TypeScript entry. Preserve host module imports and runtime boundaries;
|
||||
Paseo still validates and compiles the entries. A package containing only `dist/index.js` is not
|
||||
a Paseo plugin.
|
||||
|
||||
A failed download, dependency installation, manifest check, preparation command, compilation, or
|
||||
activation discards the candidate. Other installed plugins keep running. Removing an npm plugin
|
||||
deletes its managed files; removing a directory plugin keeps your source directory.
|
||||
|
||||
## CLI reference
|
||||
|
||||
@@ -1941,7 +1993,10 @@ paseo plugin add https://git.example.com/owner/repository.git --ref main
|
||||
paseo plugin add owner/monorepo:plugins/review
|
||||
paseo plugin ls [id]
|
||||
paseo plugin update <id>
|
||||
paseo plugin update --all
|
||||
paseo plugin update --all --check
|
||||
paseo plugin update --all --yes
|
||||
paseo plugin update my-plugin --version 1.2.0
|
||||
paseo plugin update my-plugin --ref v2
|
||||
paseo plugin reload my-plugin
|
||||
paseo plugin logs my-plugin
|
||||
paseo plugin disable my-plugin
|
||||
@@ -1949,14 +2004,36 @@ paseo plugin enable my-plugin
|
||||
paseo plugin remove my-plugin
|
||||
```
|
||||
|
||||
`ls` reports runtime state, source details, and the installed commit without contacting the remote.
|
||||
Use `update` when you want Paseo to contact a tracked Git remote and install an available update.
|
||||
`ls` and Settings show source identity and the current installed revision without contacting the
|
||||
remote. Identity includes the selected subdirectory and excludes installation selectors.
|
||||
|
||||
`update <id>` checks for an update, shows the current and proposed revision with available review
|
||||
links, and asks for approval. Declining leaves the installed content unchanged.
|
||||
|
||||
- npm checks the package's `latest` version. It offers only a newer version; an installed version
|
||||
newer than latest stays installed. Host npm registry/auth configuration governs resolution.
|
||||
- Git checks the remote's current default HEAD, regardless of the branch, tag, or commit selected
|
||||
during installation.
|
||||
- Directory plugins are skipped; edit the directory and use `reload`.
|
||||
|
||||
`--check` only previews, including with an explicit target or `--yes`. `--yes` skips the question.
|
||||
`--all` checks each configured plugin and reports independent results; one failure does not stop
|
||||
others. Neither flag permits ordinary npm downgrades.
|
||||
|
||||
`--version <version|tag|range>` or `--ref <branch|tag|commit>` selects one matching plugin's update
|
||||
content and applies it without another question. An explicit npm version can be older. The next
|
||||
ordinary update checks latest again. Explicit targets cannot be combined with `--all`.
|
||||
JSON and noninteractive ordinary updates require `--yes`.
|
||||
|
||||
Approval acquires exactly the reviewed commit or npm artifact. If it is unavailable or the installed
|
||||
plugin changed while reviewing, the update fails and asks you to check again. Failed preparation or
|
||||
activation retains the previous installation. Manual app update review is not available yet.
|
||||
|
||||
Put `--host <url>` before a management command when the target is not the CLI's default daemon. `remove`
|
||||
never deletes a directory source; it deletes the managed checkout for a Git source. The install-time
|
||||
never deletes a directory source; it deletes managed files for Git and npm sources. The install-time
|
||||
`--id` is the runtime ID and allows the same directory or repository to be installed more than once.
|
||||
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Server code and Git preparation commands run unsandboxed with the daemon user's access on the daemon host; client contributions run inside Paseo. Dependencies and future updates are part of that decision. With the global `--host` option, commands run on the remote daemon host.
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Server code and preparation commands run unsandboxed with the daemon user's access on the daemon host; client contributions run inside Paseo. Dependencies and future updates are part of that decision. With the global `--host` option, commands run on the remote daemon host.
|
||||
|
||||
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency
|
||||
that Paseo does not provide, generate source or assets, or perform another required preparation
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface FixturePackage {
|
||||
name: string;
|
||||
version: string;
|
||||
files: Record<string, string>;
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
scripts?: Record<string, string>;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/** A registry serving real npm-packed artifacts, including integrity and host auth configuration. */
|
||||
export async function startNpmRegistry(packages: FixturePackage[]) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-npm-registry-"));
|
||||
const artifacts = new Map<string, Buffer>();
|
||||
const manifests = new Map<
|
||||
string,
|
||||
{ name: string; versions: Record<string, unknown>; "dist-tags": Record<string, string> }
|
||||
>();
|
||||
const requests: string[] = [];
|
||||
const server = createServer((request, response) => {
|
||||
const url = decodeURIComponent(request.url ?? "");
|
||||
requests.push(url);
|
||||
if (request.headers.authorization !== "Bearer fixture-token") {
|
||||
response.writeHead(401).end(JSON.stringify({ error: "Registry token required" }));
|
||||
return;
|
||||
}
|
||||
const artifact = artifacts.get(url);
|
||||
if (artifact) {
|
||||
response.writeHead(200, { "content-type": "application/octet-stream" }).end(artifact);
|
||||
return;
|
||||
}
|
||||
const manifest = manifests.get(url.slice(1));
|
||||
response.writeHead(manifest ? 200 : 404, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(manifest ?? { error: "Package not found" }));
|
||||
});
|
||||
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 registry port");
|
||||
const url = `http://127.0.0.1:${address.port}`;
|
||||
const userconfig = path.join(root, "npmrc");
|
||||
await writeFile(
|
||||
userconfig,
|
||||
`registry=${url}/\n//127.0.0.1:${address.port}/:_authToken=fixture-token\ncache=${root}/cache\n`,
|
||||
);
|
||||
try {
|
||||
for (const [index, pkg] of packages.entries()) {
|
||||
const directory = path.join(root, String(index));
|
||||
await mkdir(directory);
|
||||
const manifest = {
|
||||
name: pkg.name,
|
||||
version: pkg.version,
|
||||
dependencies: pkg.dependencies,
|
||||
peerDependencies: pkg.peerDependencies,
|
||||
scripts: pkg.scripts,
|
||||
};
|
||||
await writeFile(path.join(directory, "package.json"), JSON.stringify(manifest));
|
||||
for (const [file, content] of Object.entries(pkg.files)) {
|
||||
await mkdir(path.dirname(path.join(directory, file)), { recursive: true });
|
||||
await writeFile(path.join(directory, file), content);
|
||||
}
|
||||
const { stdout } = await execFileAsync("npm", ["pack", "--ignore-scripts", "--json"], {
|
||||
cwd: directory,
|
||||
});
|
||||
const packed: Array<{ filename: string }> = JSON.parse(stdout);
|
||||
const artifact = await readFile(path.join(directory, packed[0].filename));
|
||||
const artifactPath = `/artifacts/${index}.tgz`;
|
||||
artifacts.set(artifactPath, artifact);
|
||||
const published = {
|
||||
...manifest,
|
||||
dist: {
|
||||
tarball: `${url}${artifactPath}`,
|
||||
integrity: `sha512-${createHash("sha512").update(artifact).digest("base64")}`,
|
||||
},
|
||||
};
|
||||
const existing = manifests.get(pkg.name) ?? { name: pkg.name, versions: {}, "dist-tags": {} };
|
||||
const versions = existing.versions;
|
||||
const tags = existing["dist-tags"];
|
||||
versions[pkg.version] = published;
|
||||
for (const tag of pkg.tags ?? ["latest"]) tags[tag] = pkg.version;
|
||||
manifests.set(pkg.name, existing);
|
||||
}
|
||||
} catch (error) {
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
url,
|
||||
userconfig,
|
||||
requests,
|
||||
env: { npm_config_userconfig: userconfig },
|
||||
setTag(packageName: string, tag: string, version: string) {
|
||||
const manifest = manifests.get(packageName);
|
||||
if (!manifest?.versions[version])
|
||||
throw new Error(`Unknown fixture package: ${packageName}@${version}`);
|
||||
manifest["dist-tags"][tag] = version;
|
||||
},
|
||||
async close() {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await rm(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function npmPluginPackages() {
|
||||
const files = {
|
||||
"paseo-plugin.json": JSON.stringify({
|
||||
id: "npm-review",
|
||||
description: "Installed from the npm fixture registry",
|
||||
requirements: { paseo: ">=0.4.0" },
|
||||
}),
|
||||
"index.server.ts": `import value from "paseo-fixture-dependency";
|
||||
export default function contribute() {
|
||||
if (value !== "dependency loaded") throw new Error("Dependency did not resolve");
|
||||
console.log(value);
|
||||
return () => {};
|
||||
}`,
|
||||
};
|
||||
return [
|
||||
{
|
||||
name: "paseo-fixture-dependency",
|
||||
version: "1.0.0",
|
||||
files: { "index.js": 'module.exports = "dependency loaded";' },
|
||||
},
|
||||
{
|
||||
name: "paseo-fixture-plugin",
|
||||
version: "1.0.0",
|
||||
files,
|
||||
dependencies: { "paseo-fixture-dependency": "1.0.0" },
|
||||
tags: ["stable"],
|
||||
},
|
||||
{
|
||||
name: "paseo-fixture-plugin",
|
||||
version: "1.1.0",
|
||||
files,
|
||||
dependencies: { "paseo-fixture-dependency": "1.0.0" },
|
||||
tags: ["latest", "next"],
|
||||
},
|
||||
{
|
||||
name: "@paseo-fixture/review",
|
||||
version: "2.0.0",
|
||||
files,
|
||||
dependencies: { "paseo-fixture-dependency": "1.0.0" },
|
||||
peerDependencies: { "@getpaseo/plugin": "*" },
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user