Install npm plugins and review source updates (#4975)

* feat(plugins): unify source installation and simplify plugin settings

* 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.

* fix(plugins): preserve CLI diagnostics and exercise reviewed updates

Move new source capability coverage to real browser journeys. Update the requirements regression to exercise preview and apply, and preserve the existing duplicate-selector diagnostic across positional option scopes.

* test(plugins): remove unused pending helper

* test(plugins): pack registry fixtures with the Windows command runner

* test(plugins): assert failed client contribution setup

* docs(plugins): consolidate guides and document npm publishing

* docs(plugins): introduce publishing choices before npm instructions

* test(plugins): own browser journey cleanup in a fixture

* feat(plugins): prepare scaffolded packages for publishing
This commit is contained in:
Mohamed Boudra
2026-09-17 10:22:20 +02:00
committed by GitHub
parent 1e4ba65c6d
commit 047f40e623
76 changed files with 4452 additions and 2361 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ app:
sdk: sdk:
- "packages/plugin/**" - "packages/plugin/**"
- "plugin-examples/**" - "plugin-examples/**"
- "public-docs/plugins/v0.8/**" - "public-docs/plugins/**"
- "packages/client/**" - "packages/client/**"
- "packages/highlight/**" - "packages/highlight/**"
- "packages/protocol/**" - "packages/protocol/**"
+2 -3
View File
@@ -50,10 +50,9 @@ Run agents in parallel on your own machines. Ship from your phone or your desk.
## Plugins ## Plugins
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
TypeScript plugins. Install from a local directory or Git repository with `paseo plugin add <source>`. TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install <source>`.
See the [plugin docs](https://paseo.sh/docs/plugins) for your Paseo version, or start with the Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon
[0.8 beta quickstart](https://paseo.sh/docs/plugins/v0.8). Plugins run with access to your daemon
machine and inside connected clients; install only code you trust. machine and inside connected clients; install only code you trust.
## Getting Started ## Getting Started
+9 -7
View File
@@ -64,8 +64,8 @@ $PASEO_HOME/
│ └── managed-processes/ │ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap │ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
├── plugins/ ├── plugins/
│ ├── sources.json # Git origin, ref, commit, and managed checkout ownership │ ├── sources.json # Managed kind and Git acquisition remote
│ └── {pluginId}/{version}/checkout/ # Source checkout for one installed Git commit │ └── {pluginId}/{uuid}/ # Git checkout or npm package/lockfile/dependency tree
└── push-tokens.json # Expo push notification tokens └── 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. All fields are optional with sensible defaults.
Git-managed plugins still appear as directory sources in `config.json`. This keeps the plugin Managed plugins appear as directory sources in `config.json`; it owns the active path and enabled
runtime and protocol config compatible with directory-only clients. `plugins/sources.json` owns the state. `plugins/sources.json` is written atomically and stores only managed kind and the Git acquisition
Git-specific origin, tracking ref, installed commit, repository subdirectory, and checkout root. remote. Installed revision, package/subdirectory identity and ownership root come from retained
Paseo writes it atomically. An update creates and validates a new version directory before changing artifacts and the fixed managed layout; [managed source ownership](plugins.md#managed-source-ownership)
the configured directory path; successful activation removes the old version. 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 ### Profile lists
+42 -16
View File
@@ -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. 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. 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 ## Install a directory source
@@ -68,7 +68,7 @@ runtime modules, so consumers do not install these packages when adding the plug
``` ```
Declare the supported Paseo range and keep it current when adopting newer APIs. See the Declare the supported Paseo range and keep it current when adopting newer APIs. See the
[requirements contract](../public-docs/plugins/v0.8/reference.md#requirements), including legacy [requirements contract](../public-docs/plugins/reference.md#requirements), including legacy
manifests and prerelease matching. manifests and prerelease matching.
The config key is the runtime plugin ID. The manifest ID is the default selected during install; The config key is the runtime plugin ID. The manifest ID is the default selected during install;
@@ -103,10 +103,36 @@ paseo plugin update --all
Append `:relative/path` to the source when the plugin lives below the repository root. 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; `--ref` chooses the initial branch, tag, or commit once. Ordinary updates resolve the remote's
tags and commits stay pinned. `ls` reports the installed commit without contacting the remote. 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. Removing a Git source deletes Paseo's managed checkout.
## Managed source ownership
The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. 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 ### Declare Git preparation
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency Most plugins should omit `build`. Use it only when the staged checkout must install a dependency
@@ -162,10 +188,10 @@ Shared files import contract helpers and types from `@getpaseo/plugin`. Server h
set; an unknown name renders nothing so it cannot break the plugin surface. set; an unknown name renders nothing so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the `<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
[host UI contract](../public-docs/plugins/v0.8/reference.md#host-ui). [host UI contract](../public-docs/plugins/reference.md#host-ui).
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`. `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
See `public-docs/plugins/v0.8/reference.md`. See `public-docs/plugins/reference.md`.
### SDK import boundaries ### SDK import boundaries
@@ -206,7 +232,7 @@ The scaffold omits `"DOM"` from `tsconfig.json` and does not use `/// <reference
browser globals are not available across the plugin. Put sanctioned web-only APIs in browser globals are not available across the plugin. Put sanctioned web-only APIs in
`client/web.ts`, declare only the globals that module uses, gate each export with `client/web.ts`, declare only the globals that module uses, gate each export with
`Platform.OS === "web"`, and provide a native implementation or no-op. See the `Platform.OS === "web"`, and provide a native implementation or no-op. See the
[public plugin reference](../public-docs/plugins/v0.8/reference.md#works-on-mobile) for the complete [public plugin reference](../public-docs/plugins/reference.md#works-on-mobile) for the complete
pattern. pattern.
```ts ```ts
@@ -256,7 +282,7 @@ Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo o
Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation
bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not
resolve ownership through a mutable current-plugin global. Keep observation ownership in this module resolve ownership through a mutable current-plugin global. Keep observation ownership in this module
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/v0.8/reference.md#discover-hosts-and-target-another-host). and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
@@ -266,7 +292,7 @@ catalog is complete.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/v0.8/reference.md#discover-hosts-and-target-another-host). query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
Workspace panels, Command Center items, and client slash commands are client contributions. The Workspace panels, Command Center items, and client slash commands are client contributions. The
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
@@ -285,14 +311,14 @@ existing agent-context instances, but it cannot create an agent panel without an
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations. Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Surface and panel navigation They use typed plugin RPC only for plugin-specific backend work. Surface and panel navigation
belongs to the app; plugins do not receive Expo Router or workspace-layout store access. belongs to the app; plugins do not receive Expo Router or workspace-layout store access.
See the public [navigation fields](../public-docs/plugins/v0.8/reference.md#surfaces-and-sidebar-items) See the public [navigation fields](../public-docs/plugins/reference.md#surfaces-and-sidebar-items)
and [external links and workspace browsers](../public-docs/plugins/v0.8/reference.md#external-links-and-workspace-browsers) and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
for the author-facing contract. for the author-facing contract.
## Lifecycle hooks ## Lifecycle hooks
Server entries register lifecycle observers with `server.on()` and request transforms with Server entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/v0.8/reference.md#lifecycle-hooks) `server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks. eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
@@ -340,7 +366,7 @@ and `plugin-examples/provider-acp-transformer`.
Provider-emitted plugin timeline items use the same renderer registration as transformed and Provider-emitted plugin timeline items use the same renderer registration as transformed and
daemon-appended plugin items. The direct example includes both sides. The renderer-only daemon-appended plugin items. The direct example includes both sides. The renderer-only
`plugin-examples/inline-thinking` example shows that timeline presentation remains independent of a `plugin-examples/inline-thinking` example shows that timeline presentation remains independent of a
provider implementation. The public [provider plugin guide](../public-docs/plugins/v0.8/providers.md) provider implementation. The public [provider plugin guide](../public-docs/plugins/providers.md)
owns author workflow, lifecycle, testing, and distribution guidance. owns author workflow, lifecycle, testing, and distribution guidance.
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`. `ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
@@ -353,7 +379,7 @@ SVG or URL.
## Contribute buttons ## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in Header buttons and composer pills share the client-only descriptor and registration lifecycle in
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/v0.8/reference.md#header-buttons) `packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/reference.md#header-buttons)
owns the author API and placement rules. Keep presentation policy in this module so another owns the author API and placement rules. Keep presentation policy in this module so another
placement can reuse behavior without copying registration or action state. placement can reuse behavior without copying registration or action state.
@@ -477,7 +503,7 @@ drops the optional presentation fields.
Register ordinary components with `client.addSettingsScreen` and open them with `openSettings`. Register ordinary components with `client.addSettingsScreen` and open them with `openSettings`.
The host settings shell owns navigation and layout; plugin content must not add another page The host settings shell owns navigation and layout; plugin content must not add another page
scroll view or header. See the [author contract](../public-docs/plugins/v0.8/reference.md#settings-screens) scroll view or header. See the [author contract](../public-docs/plugins/reference.md#settings-screens)
and `plugin-examples/settings` for the named UI components and persistence API. and `plugin-examples/settings` for the named UI components and persistence API.
Settings storage is scoped to the runtime installation ID, never the source path or manifest ID. Settings storage is scoped to the runtime installation ID, never the source path or manifest ID.
@@ -512,7 +538,7 @@ stable rather than arrival-ordered. The app resolves that id
against the installed catalog on every change; an id nothing contributes falls back to the default against the installed catalog on every change; an id nothing contributes falls back to the default
preference instead of painting the reserved slot's placeholder colors. preference instead of painting the reserved slot's placeholder colors.
Existing plugin authors should follow the standalone [v0.8 runtime-entry migration guide](../public-docs/plugins/v0.8/migration.md). Existing plugin authors should follow the standalone [v0.8 runtime-entry migration guide](../public-docs/plugins/migration.md).
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
attachment-source example, `plugin-examples/timeline-items` for timeline projection, and attachment-source example, `plugin-examples/timeline-items` for timeline projection, and
+13
View File
@@ -39022,6 +39022,7 @@
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9", "rotating-file-stream": "^3.2.9",
"semver": "^7.8.5",
"sherpa-onnx-node": "1.12.28", "sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2", "strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2", "tree-kill": "^1.2.2",
@@ -39728,6 +39729,18 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "packages/server/node_modules/strip-ansi": {
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
@@ -1,17 +1,109 @@
import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { expect, test, type Page } from "../support/fixtures"; import { pathToFileURL } from "node:url";
import type { TestInfo } from "@playwright/test";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { expect, test as base, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app"; import { gotoAppShell, openSettings } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id"; import { getServerId } from "../support/helpers/server-id";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
import { import {
expectSettingsHeader, expectSettingsHeader,
openCompactSettings,
openHostSection, openHostSection,
openSettingsHost, openSettingsHost,
} from "../support/helpers/settings"; } from "../support/helpers/settings";
import {
startNpmRegistry,
npmPluginPackages,
} from "../../../../scripts/test-support/npm-registry.mjs";
interface PluginEnvironment {
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
directory: string;
}
const test = base.extend<
{ pluginEnvironment: PluginEnvironment },
{ npmRegistry: Awaited<ReturnType<typeof startNpmRegistry>> }
>({
pluginEnvironment: async ({ e2eWorker: _e2eWorker }, provide) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugins-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
try {
await provide({ client, directory });
} finally {
try {
const current = await client.getDaemonConfig();
const existingIds = new Set(Object.keys(previous.config.plugins ?? {}));
for (const id of Object.keys(current.config.plugins ?? {})) {
if (!existingIds.has(id)) await client.removePlugin(id);
}
} finally {
try {
await client.patchDaemonConfig({
pluginsEnabled: previous.config.pluginsEnabled ?? false,
});
} finally {
try {
await client.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
}
}
}
},
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" },
],
});
async function advertisePluginCapabilities(
page: Page,
features: Record<string, boolean | undefined>,
): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (typeof message !== "string") {
browser.send(message);
return;
}
const envelope = JSON.parse(message);
if (
envelope.message?.type === "status" &&
envelope.message.payload?.status === "server_info"
) {
Object.assign(envelope.message.payload.features, features);
}
browser.send(JSON.stringify(envelope));
});
});
}
function observePluginCatalog(page: Page) { function observePluginCatalog(page: Page) {
let responses = 0; let responses = 0;
page.on("websocket", (socket) => { page.on("websocket", (socket) => {
@@ -72,11 +164,28 @@ async function openPluginSettings(page: Page): Promise<void> {
await expectSettingsHeader(page, "Plugins"); await expectSettingsHeader(page, "Plugins");
} }
async function openCompactPluginSettings(page: Page): Promise<void> {
const serverId = getServerId();
await openCompactSettings(page, buildOpenProjectRoute());
await openHostSection(page, serverId, "plugins");
await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toBeVisible();
}
async function reloadPlugin(page: Page): Promise<void> { async function reloadPlugin(page: Page): Promise<void> {
await page.getByRole("button", { name: "Reload", exact: true }).click(); await selectPluginAction(page, "e2e-plugin", "Reload");
await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible(); await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible();
} }
async function openPluginActions(page: Page, pluginId: string): Promise<void> {
await page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true }).click();
await expect(page.getByRole("menuitem").first()).toBeVisible();
}
async function selectPluginAction(page: Page, pluginId: string, action: string): Promise<void> {
await openPluginActions(page, pluginId);
await page.getByRole("menuitem", { name: action, exact: true }).click();
}
async function expectContributionBody(page: Page, expectedBody: string): Promise<void> { async function expectContributionBody(page: Page, expectedBody: string): Promise<void> {
const body = page.getByText(expectedBody, { exact: true }).filter({ visible: true }); const body = page.getByText(expectedBody, { exact: true }).filter({ visible: true });
await expect(body).toHaveCount(1); await expect(body).toHaveCount(1);
@@ -115,99 +224,434 @@ async function expectContributionRemoved(page: Page, title: string): Promise<voi
await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible(); await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible();
} }
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({ async function capturePluginInstallForm(
page, page: Page,
}) => { testInfo: TestInfo,
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-")); name: "wide" | "wide-menu" | "compact-error" | "compact-success" | "compact-menu",
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); ): Promise<void> {
const previous = await client.getDaemonConfig(); await page.screenshot({
path: testInfo.outputPath(`plugin-source-install-${name}.png`),
fullPage: true,
animations: "disabled",
});
}
async function installPlugin(page: Page, source: string): Promise<void> {
await page.getByLabel("Plugin source").fill(source);
await page.getByRole("button", { name: "Install plugin" }).click();
}
async function expectPluginSourceDocsOpen(page: Page): Promise<void> {
const requestedPage = page
.context()
.waitForEvent(
"request",
(request) => request.isNavigationRequest() && request.url().startsWith("https://paseo.sh/"),
);
const docsPagePromise = page.context().waitForEvent("page");
await page.getByRole("link", { name: "Docs", exact: true }).click();
const request = await requestedPage;
const docsPage = await docsPagePromise;
try {
expect(new URL(request.url()).pathname).toBe("/docs/plugins/reference");
// The deployed site can redirect while the matching website change is still in this PR.
await docsPage.waitForURL(
(url) => url.origin === "https://paseo.sh" && url.hash === "#plugin-sources",
{ waitUntil: "commit" },
);
} finally {
await docsPage.close();
}
}
async function createGitPluginRepository(root: string): Promise<string> {
const repository = path.join(root, "repository");
await mkdir(repository);
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({
id: "git-e2e-plugin",
description: "Adds review tools from a Git repository",
requirements: pluginRequirements,
}),
);
await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Git plugin"));
execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Paseo Tests"], {
cwd: repository,
stdio: "ignore",
});
execFileSync("git", ["config", "user.email", "paseo@example.test"], {
cwd: repository,
stdio: "ignore",
});
execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" });
return repository;
}
async function createDirectoryPlugin(
root: string,
id: string,
description: string | undefined,
title: string,
): Promise<string> {
const directory = await mkdtemp(path.join(root, "plugin-row-"));
await writeFile( await writeFile(
path.join(directory, "paseo-plugin.json"), path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "e2e-plugin", requirements: pluginRequirements }), JSON.stringify({ id, description, requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`import React from "react";
import { Text } from "react-native";
export default function contribute(plugin) {
function Surface() { return <Text>${title}</Text>; }
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" });
return () => undefined;
}`,
);
return directory;
}
async function installFailedPlugin(
page: Page,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
directory: string,
): Promise<void> {
await client.installPluginSource({ source: directory });
await writeFile(path.join(directory, "index.client.tsx"), "export default broken !!!");
await client.reloadPlugin("failed-preview-plugin");
await expect(page.getByLabel("failed-preview-plugin failed")).toBeVisible();
}
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
page,
pluginEnvironment,
}, testInfo) => {
const directory = await installLocalPluginWithStatusExamples(page, pluginEnvironment, testInfo);
await reloadAndRecoverLocalPlugin(page, pluginEnvironment.client, directory);
await toggleRemoveAndReinstallLocalPlugin(page, directory);
});
test("installs a Git source after a failed source remains editable", async ({
page,
pluginEnvironment,
}, testInfo) => {
const source = await prepareCompactGitInstall(page, pluginEnvironment);
await retryGitInstallAndInspectRows(page, source, testInfo);
});
for (const sourceSupport of [undefined, false]) {
test(`keeps installed plugins manageable without source capability (${sourceSupport})`, async ({
page,
pluginEnvironment,
}) => {
const directory = await prepareLegacyPluginHost(page, pluginEnvironment, sourceSupport);
await verifyLegacyPluginManagement(page, directory);
});
}
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,
pluginEnvironment,
}, testInfo) => {
await prepareNpmPluginHost(page, pluginEnvironment, viewport);
await retryNpmInstallAndInspectRow(page, viewport.width, testInfo);
await reloadAndRemoveNpmPlugin(page);
});
}
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)");
}
async function installLocalPluginWithStatusExamples(
page: Page,
pluginEnvironment: PluginEnvironment,
testInfo: TestInfo,
) {
const directory = await mkdtemp(path.join(pluginEnvironment.directory, "local-"));
const disabledDirectory = await createDirectoryPlugin(
pluginEnvironment.directory,
"disabled-preview-plugin-with-a-long-name",
"Keeps optional previews off until this plugin is enabled",
"Preview plugin",
);
const failedDirectory = await createDirectoryPlugin(
pluginEnvironment.directory,
"failed-preview-plugin",
"Demonstrates a plugin that needs attention",
"Failed preview plugin",
);
const { client } = pluginEnvironment;
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: "e2e-plugin",
description: "Exercises the complete local plugin lifecycle",
requirements: pluginRequirements,
}),
); );
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1")); await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
try { const catalog = observePluginCatalog(page);
const catalog = observePluginCatalog(page); await gotoAppShell(page);
await gotoAppShell(page); await openPluginSettings(page);
await openPluginSettings(page); await catalog.waitForInitialFetch();
await catalog.waitForInitialFetch(); await expect(page.getByRole("textbox", { name: "Plugin installation ID" })).toHaveCount(0);
await client.patchDaemonConfig({ pluginsEnabled: false }); await expectPluginSourceDocsOpen(page);
await page.getByRole("switch", { name: "Enable plugins" }).click(); await client.patchDaemonConfig({ pluginsEnabled: false });
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible(); await page.getByRole("switch", { name: "Enable plugins" }).click();
await page.getByLabel("Plugin directory").fill(directory); await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
await page.getByLabel("Plugin installation ID").focus(); await client.installPluginSource({ source: disabledDirectory });
await expect(page.getByLabel("Plugin installation ID")).toHaveValue("e2e-plugin"); await client.disablePlugin("disabled-preview-plugin-with-a-long-name");
await page.getByRole("button", { name: "Install directory" }).click(); await installFailedPlugin(page, client, failedDirectory);
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible(); await page.getByLabel("Plugin source").fill(directory);
await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); await page.getByRole("button", { name: "Install plugin" }).click();
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0"); await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await expect(page.getByText("Exercises the complete local plugin lifecycle")).toBeVisible();
await expect(
page.getByText("Keeps optional previews off until this plugin is enabled"),
).toBeVisible();
await expect(page.getByText("Demonstrates a plugin that needs attention")).toBeVisible();
await expect(page.getByLabel("disabled-preview-plugin-with-a-long-name disabled")).toBeVisible();
await expect(page.getByLabel("failed-preview-plugin failed")).toBeVisible();
await capturePluginInstallForm(page, testInfo, "wide");
await openPluginActions(page, "e2e-plugin");
await capturePluginInstallForm(page, testInfo, "wide-menu");
await page.getByRole("menuitem", { name: "Logs", exact: true }).click();
await expect(page.getByRole("dialog")).toContainText("Logs: e2e-plugin");
await page.getByRole("button", { name: "Close", exact: true }).click();
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
await openPluginSettings(page); return directory;
await reloadPlugin(page); }
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2")); async function reloadAndRecoverLocalPlugin(
await openPluginSettings(page); page: Page,
await reloadPlugin(page); client: PluginEnvironment["client"],
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2"); directory: string,
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible(); ) {
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
await writeFile( await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2"));
path.join(directory, "index.client.tsx"), await openPluginSettings(page);
pluginSource("Broken surface", "render exploded"), await reloadPlugin(page);
); await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
await openPluginSettings(page); await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
await reloadPlugin(page);
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
await openContribution(page, "Healthy surface", "Healthy contribution");
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Recovered surface")); await writeFile(
await reloadActiveContribution(page, client, "Recovered surface cleanup 4"); path.join(directory, "index.client.tsx"),
await expect( pluginSource("Broken surface", "render exploded"),
page.getByRole("button", { name: "Broken surface", exact: true }), );
).not.toBeVisible(); await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
await openContribution(page, "Healthy surface", "Healthy contribution");
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!"); await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Recovered surface"));
await openPluginSettings(page); await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
await page.getByRole("button", { name: "Reload", exact: true }).click(); await expect(page.getByRole("button", { name: "Broken surface", exact: true })).not.toBeVisible();
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Recovered surface");
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3")); await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!");
await openPluginSettings(page); await openPluginSettings(page);
await page.getByRole("button", { name: "Reload", exact: true }).click(); await selectPluginAction(page, "e2e-plugin", "Reload");
await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5"); await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Recovered surface");
await openPluginSettings(page); await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3"));
await page.getByRole("button", { name: "Disable", exact: true }).click(); await openPluginSettings(page);
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible(); await selectPluginAction(page, "e2e-plugin", "Reload");
await leavePluginSettings(page); await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await expectContributionRemoved(page, "Plugin v3"); await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
}
await openPluginSettings(page); async function toggleRemoveAndReinstallLocalPlugin(page: Page, directory: string) {
await page.getByRole("button", { name: "Enable", exact: true }).click(); await openPluginSettings(page);
await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); await page.getByRole("switch", { name: "e2e-plugin: Disable", exact: true }).click();
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6"); await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Plugin v3");
await openPluginSettings(page); await openPluginSettings(page);
page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("switch", { name: "e2e-plugin: Enable", exact: true }).click();
await page.getByRole("button", { name: "Remove", exact: true }).click(); await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await expect(page.getByText("No plugins configured", { exact: true })).toBeVisible(); await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6");
await page.getByLabel("Plugin directory").fill(directory); await openPluginSettings(page);
await page.getByRole("button", { name: "Install directory" }).click(); page.once("dialog", (dialog) => dialog.accept());
await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); await selectPluginAction(page, "e2e-plugin", "Remove");
await gotoAppShell(page); await expect(page.getByTestId("plugin-row-e2e-plugin")).toHaveCount(0);
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
} finally { await installPlugin(page, directory);
await client.removePlugin("e2e-plugin").catch(() => undefined); await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); await gotoAppShell(page);
await client.close().catch(() => undefined); await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
await rm(directory, { recursive: true, force: true }); }
}
}); async function prepareCompactGitInstall(page: Page, pluginEnvironment: PluginEnvironment) {
const root = pluginEnvironment.directory;
const repository = await createGitPluginRepository(root);
const longDirectory = await createDirectoryPlugin(
pluginEnvironment.directory,
"compact-plugin-with-a-realistically-long-name",
"Shows how a realistically long plugin description wraps on a compact screen",
"Compact long plugin",
);
const disabledDirectory = await createDirectoryPlugin(
pluginEnvironment.directory,
"compact-disabled-plugin",
"Remains installed and ready to enable later",
"Compact disabled plugin",
);
const missingSource = path.join(root, "missing-plugin");
const { client } = pluginEnvironment;
await page.setViewportSize({ width: 390, height: 844 });
await client.installPluginSource({ source: longDirectory });
await client.installPluginSource({ source: disabledDirectory });
await client.disablePlugin("compact-disabled-plugin");
await gotoAppShell(page);
await openCompactPluginSettings(page);
await expect(page.getByRole("textbox", { name: "Plugin installation ID" })).toHaveCount(0);
await client.patchDaemonConfig({ pluginsEnabled: false });
await page.getByRole("switch", { name: "Enable plugins" }).click();
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
return { repository, missingSource };
}
async function retryGitInstallAndInspectRows(
page: Page,
source: { repository: string; missingSource: string },
testInfo: TestInfo,
) {
const { repository, missingSource } = source;
await installPlugin(page, missingSource);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
"Plugin source is neither an existing directory nor a Git URL",
);
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
await capturePluginInstallForm(page, testInfo, "compact-error");
await installPlugin(page, pathToFileURL(repository).href);
await expect(page.getByText("Installed git-e2e-plugin", { exact: true })).toBeVisible();
await expect(page.getByLabel("git-e2e-plugin running")).toBeVisible();
await expect(
page.getByText("Shows how a realistically long plugin description wraps on a compact screen"),
).toBeVisible();
await expect(page.getByLabel("compact-disabled-plugin disabled")).toBeVisible();
await expect(page.getByLabel("Plugin source")).toHaveValue("");
await capturePluginInstallForm(page, testInfo, "compact-success");
await openPluginActions(page, "git-e2e-plugin");
const removeAction = page.getByRole("menuitem", { name: "Remove", exact: true });
await expect(removeAction).toBeInViewport({ ratio: 1 });
await waitForSettledPosition(removeAction);
await capturePluginInstallForm(page, testInfo, "compact-menu");
await page.keyboard.press("Escape");
}
async function prepareLegacyPluginHost(
page: Page,
pluginEnvironment: PluginEnvironment,
sourceSupport: boolean | undefined,
) {
const directory = await createDirectoryPlugin(
pluginEnvironment.directory,
"legacy-source-plugin",
undefined,
"Legacy plugin",
);
const { client } = pluginEnvironment;
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installPluginSource({ source: directory });
await advertisePluginCapabilities(page, {
pluginSourceInstallation: sourceSupport,
pluginGitManagement: true,
});
await gotoAppShell(page);
await openPluginSettings(page);
return directory;
}
async function verifyLegacyPluginManagement(page: Page, directory: string) {
await expect(
page.getByText("Update this host to install plugins", { exact: true }),
).toBeVisible();
await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toHaveCount(0);
await expect(page.getByText(directory, { exact: true })).toBeVisible();
await selectPluginAction(page, "legacy-source-plugin", "Reload");
await expect(page.getByText("Reloaded legacy-source-plugin", { exact: true })).toBeVisible();
}
async function prepareNpmPluginHost(
page: Page,
pluginEnvironment: PluginEnvironment,
viewport: { width: number; height: number },
) {
const { client } = pluginEnvironment;
await page.setViewportSize(viewport);
await advertisePluginCapabilities(page, { pluginGitManagement: false });
await client.patchDaemonConfig({ pluginsEnabled: true });
await gotoAppShell(page);
await openNpmPluginSettings(page, viewport.width);
}
async function retryNpmInstallAndInspectRow(page: Page, width: number, testInfo: TestInfo) {
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-${width}.png`),
animations: "disabled",
});
}
async function reloadAndRemoveNpmPlugin(page: Page) {
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);
}
@@ -22,6 +22,7 @@ type NewWorkspaceDaemonClient = Pick<
| "getPaseoWorktreeList" | "getPaseoWorktreeList"
| "getDaemonConfig" | "getDaemonConfig"
| "installDirectoryPlugin" | "installDirectoryPlugin"
| "installPluginSource"
| "disablePlugin" | "disablePlugin"
| "enablePlugin" | "enablePlugin"
| "inspectWorkspaceRecovery" | "inspectWorkspaceRecovery"
@@ -101,14 +101,19 @@ export async function expectAppMismatch(page: Page) {
export async function correctRequirementAndReload(page: Page, directory: string) { export async function correctRequirementAndReload(page: Page, directory: string) {
await writeRequirements(directory, pluginRequirements.paseo); await writeRequirements(directory, pluginRequirements.paseo);
await page.getByRole("button", { name: "Reload", exact: true }).click(); await reloadRequirementsPlugin(page);
await expect(page.getByLabel("requirements-example running", { exact: true })).toBeVisible(); await expect(page.getByLabel("requirements-example running", { exact: true })).toBeVisible();
await expect(page.getByText(/Your app is/)).toHaveCount(0); await expect(page.getByText(/Your app is/)).toHaveCount(0);
} }
export async function rejectDaemonMismatch(page: Page, directory: string) { export async function rejectDaemonMismatch(page: Page, directory: string) {
await writeRequirements(directory, ">=100.0.0"); await writeRequirements(directory, ">=100.0.0");
await page.getByRole("button", { name: "Reload", exact: true }).click(); await reloadRequirementsPlugin(page);
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible(); await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
await expect(page.getByText(/Your daemon is 99.0.0/).first()).toBeVisible(); await expect(page.getByText(/Your daemon is 99.0.0/).first()).toBeVisible();
} }
async function reloadRequirementsPlugin(page: Page) {
await page.getByRole("button", { name: "Actions for requirements-example", exact: true }).click();
await page.getByRole("menuitem", { name: "Reload", exact: true }).click();
}
@@ -42,7 +42,8 @@ export async function openCompactDisplaySettings(page: Page) {
} }
async function openPluginScreen(page: Page) { async function openPluginScreen(page: Page) {
await openHostSection(page, getServerId(), "plugins"); await openHostSection(page, getServerId(), "plugins");
await page.getByRole("button", { name: "Open", exact: true }).click(); await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible(); await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
} }
export async function groupByWorkspace(page: Page) { export async function groupByWorkspace(page: Page) {
@@ -98,8 +99,10 @@ export async function discardTitle(page: Page) {
} }
export async function returnAndReopenSettings(page: Page) { export async function returnAndReopenSettings(page: Page) {
await goBackInSettings(page); await goBackInSettings(page);
await expect(page.getByRole("button", { name: "Open", exact: true })).toBeVisible(); const actions = page.getByRole("button", { name: "Actions for settings-example", exact: true });
await page.getByRole("button", { name: "Open", exact: true }).click(); await expect(actions).toBeVisible();
await actions.click();
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible(); await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
} }
export async function expectSettingsUnavailable(page: Page) { export async function expectSettingsUnavailable(page: Page) {
+29 -3
View File
@@ -27,6 +27,11 @@ import { settingsStyles } from "@/styles/settings";
export { SettingsGroup } from "./headings/settings-group"; export { SettingsGroup } from "./headings/settings-group";
export { SettingsSection } from "./headings/settings-section"; export { SettingsSection } from "./headings/settings-section";
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint"> {
hint?: ReactNode;
labelAccessory?: ReactNode;
}
export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) { export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) {
return ( return (
<View style={settingsStyles.card} testID={testID}> <View style={settingsStyles.card} testID={testID}>
@@ -42,14 +47,28 @@ export function SettingsCard({ children, testID }: { children: ReactNode; testID
); );
} }
export function SettingsRow({ label, hint, error, children, testID }: SettingsRowProps) { export function SettingsRow({
label,
labelAccessory,
hint,
error,
children,
testID,
}: AppSettingsRowProps) {
const compact = useIsCompactFormFactor(); const compact = useIsCompactFormFactor();
const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]); const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]);
return ( return (
<View style={rowStyle} testID={testID}> <View style={rowStyle} testID={testID}>
<View style={styles.label}> <View style={styles.label}>
<Text style={settingsStyles.rowTitle}>{label}</Text> {labelAccessory ? (
{hint ? <Text style={settingsStyles.rowHint}>{hint}</Text> : null} <View style={styles.labelRow}>
<Text style={[settingsStyles.rowTitle, styles.accessoryLabel]}>{label}</Text>
{labelAccessory}
</View>
) : (
<Text style={settingsStyles.rowTitle}>{label}</Text>
)}
{typeof hint === "string" ? <Text style={settingsStyles.rowHint}>{hint}</Text> : hint}
{error ? ( {error ? (
<Text accessibilityRole="alert" style={settingsStyles.rowError}> <Text accessibilityRole="alert" style={settingsStyles.rowError}>
{error} {error}
@@ -176,6 +195,13 @@ export function SettingsAction({ actionLabel, onPress, disabled, ...row }: Setti
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
compactRow: { flexWrap: "wrap", gap: theme.spacing[3] }, compactRow: { flexWrap: "wrap", gap: theme.spacing[3] },
label: { flexGrow: 1, flexShrink: 1, flexBasis: 160, marginRight: theme.spacing[3] }, label: { flexGrow: 1, flexShrink: 1, flexBasis: 160, marginRight: theme.spacing[3] },
labelRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
gap: theme.spacing[2],
},
accessoryLabel: { flexShrink: 1, minWidth: 0 },
control: { flexShrink: 1, maxWidth: "100%" }, control: { flexShrink: 1, maxWidth: "100%" },
value: { color: theme.colors.foreground, fontSize: theme.fontSize.base }, value: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
input: { minWidth: 180 }, input: { minWidth: 180 },
+14 -2
View File
@@ -27,12 +27,13 @@ import {
interface FieldProps { interface FieldProps {
label: string; label: string;
children: ReactNode; children: ReactNode;
trailing?: ReactNode;
hint?: string; hint?: string;
error?: string | null; error?: string | null;
testID?: string; testID?: string;
} }
export function Field({ label, children, hint, error, testID }: FieldProps) { export function Field({ label, children, trailing, hint, error, testID }: FieldProps) {
const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]); const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]);
const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]); const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]);
const subtext = useMemo(() => { const subtext = useMemo(() => {
@@ -55,7 +56,10 @@ export function Field({ label, children, hint, error, testID }: FieldProps) {
return ( return (
<View style={styles.container} testID={testID}> <View style={styles.container} testID={testID}>
<Text style={styles.label}>{label}</Text> <View style={styles.header}>
<Text style={styles.label}>{label}</Text>
{trailing}
</View>
{children} {children}
{subtext} {subtext}
</View> </View>
@@ -226,7 +230,15 @@ const styles = StyleSheet.create((theme) => ({
container: { container: {
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
label: { label: {
flexShrink: 1,
minWidth: 0,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
+6 -6
View File
@@ -2064,12 +2064,10 @@ export const en = {
"They run on this host and inside the app without sandboxing. Install only code you trust.", "They run on this host and inside the app without sandboxing. Install only code you trust.",
globalTitle: "Enable plugins", globalTitle: "Enable plugins",
globalHint: "Global switch for every configured plugin", globalHint: "Global switch for every configured plugin",
directoryLabel: "Plugin directory", sourceLabel: "Plugin source",
directoryPlaceholder: "/absolute/path/on/host", sourcePlaceholder: "Directory, Git URL, or npm package",
idLabel: "Plugin installation ID", docs: "Docs",
idHint: "Leave blank to use paseo-plugin.json", install: "Install plugin",
idPlaceholder: "Manifest default",
install: "Install directory",
installing: "Installing…", installing: "Installing…",
logs: { logs: {
action: "Logs", action: "Logs",
@@ -2082,6 +2080,7 @@ export const en = {
}, },
status: { running: "running", disabled: "disabled", failed: "failed" }, status: { running: "running", disabled: "disabled", failed: "failed" },
actions: { actions: {
menu: "Actions for {{id}}",
reload: "Reload", reload: "Reload",
reloading: "Reloading…", reloading: "Reloading…",
enable: "Enable", enable: "Enable",
@@ -2107,6 +2106,7 @@ export const en = {
offlineTitle: "Plugin host is offline", offlineTitle: "Plugin host is offline",
offlineDescription: "Reconnect to this host to manage its plugins.", offlineDescription: "Reconnect to this host to manage its plugins.",
updateTitle: "Update this host to manage plugins", updateTitle: "Update this host to manage plugins",
sourceUpdateTitle: "Update this host to install plugins",
loading: "Loading plugins…", loading: "Loading plugins…",
errorTitle: "Unable to load plugins", errorTitle: "Unable to load plugins",
retry: "Retry", retry: "Retry",
@@ -1,5 +1,4 @@
const shared = { const shared = {
directoryPlaceholder: "/absolute/path/on/host",
logs: { logs: {
action: "Logs", action: "Logs",
title: "Logs: {{id}}", title: "Logs: {{id}}",
@@ -26,14 +25,14 @@ export const pluginSettings = {
"تعمل على هذا المضيف وداخل التطبيق دون عزل. ثبّت فقط التعليمات البرمجية التي تثق بها.", "تعمل على هذا المضيف وداخل التطبيق دون عزل. ثبّت فقط التعليمات البرمجية التي تثق بها.",
globalTitle: "تفعيل الإضافات", globalTitle: "تفعيل الإضافات",
globalHint: "مفتاح عام لكل الإضافات المضبوطة", globalHint: "مفتاح عام لكل الإضافات المضبوطة",
directoryLabel: "مجلد الإضافة", sourceLabel: "مصدر الإضافة",
idLabel: "معرّف تثبيت الإضافة", sourcePlaceholder: "مجلد أو رابط Git أو حزمة npm",
idHint: "اتركه فارغًا لاستخدام paseo-plugin.json", docs: "الوثائق",
idPlaceholder: "القيمة الافتراضية للبيان", install: "تثبيت الإضافة",
install: "تثبيت المجلد",
installing: "جارٍ التثبيت…", installing: "جارٍ التثبيت…",
status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" }, status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" },
actions: { actions: {
menu: "إجراءات {{id}}",
reload: "إعادة تحميل", reload: "إعادة تحميل",
reloading: "جارٍ إعادة التحميل…", reloading: "جارٍ إعادة التحميل…",
enable: "تفعيل", enable: "تفعيل",
@@ -59,6 +58,7 @@ export const pluginSettings = {
offlineTitle: "مضيف الإضافات غير متصل", offlineTitle: "مضيف الإضافات غير متصل",
offlineDescription: "أعد الاتصال بهذا المضيف لإدارة إضافاته.", offlineDescription: "أعد الاتصال بهذا المضيف لإدارة إضافاته.",
updateTitle: "حدّث هذا المضيف لإدارة الإضافات", updateTitle: "حدّث هذا المضيف لإدارة الإضافات",
sourceUpdateTitle: "حدّث هذا المضيف لتثبيت الإضافات",
loading: "جارٍ تحميل الإضافات…", loading: "جارٍ تحميل الإضافات…",
errorTitle: "تعذّر تحميل الإضافات", errorTitle: "تعذّر تحميل الإضافات",
retry: "إعادة المحاولة", retry: "إعادة المحاولة",
@@ -79,14 +79,14 @@ export const pluginSettings = {
"Se ejecutan en este host y dentro de la app sin aislamiento. Instala solo código de confianza.", "Se ejecutan en este host y dentro de la app sin aislamiento. Instala solo código de confianza.",
globalTitle: "Activar plugins", globalTitle: "Activar plugins",
globalHint: "Interruptor global para todos los plugins configurados", globalHint: "Interruptor global para todos los plugins configurados",
directoryLabel: "Directorio del plugin", sourceLabel: "Fuente del plugin",
idLabel: "ID de instalación del plugin", sourcePlaceholder: "Directorio, URL de Git o paquete npm",
idHint: "Déjalo vacío para usar paseo-plugin.json", docs: "Documentación",
idPlaceholder: "Valor del manifiesto", install: "Instalar plugin",
install: "Instalar directorio",
installing: "Instalando…", installing: "Instalando…",
status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" }, status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" },
actions: { actions: {
menu: "Acciones para {{id}}",
reload: "Recargar", reload: "Recargar",
reloading: "Recargando…", reloading: "Recargando…",
enable: "Activar", enable: "Activar",
@@ -112,6 +112,7 @@ export const pluginSettings = {
offlineTitle: "El host de plugins está sin conexión", offlineTitle: "El host de plugins está sin conexión",
offlineDescription: "Vuelve a conectar este host para gestionar sus plugins.", offlineDescription: "Vuelve a conectar este host para gestionar sus plugins.",
updateTitle: "Actualiza este host para gestionar plugins", updateTitle: "Actualiza este host para gestionar plugins",
sourceUpdateTitle: "Actualiza este host para instalar plugins",
loading: "Cargando plugins…", loading: "Cargando plugins…",
errorTitle: "No se pudieron cargar los plugins", errorTitle: "No se pudieron cargar los plugins",
retry: "Reintentar", retry: "Reintentar",
@@ -132,14 +133,14 @@ export const pluginSettings = {
"Ils s’exécutent sur cet hôte et dans l’app sans isolation. N’installez que du code fiable.", "Ils s’exécutent sur cet hôte et dans l’app sans isolation. N’installez que du code fiable.",
globalTitle: "Activer les plugins", globalTitle: "Activer les plugins",
globalHint: "Interrupteur global pour tous les plugins configurés", globalHint: "Interrupteur global pour tous les plugins configurés",
directoryLabel: "Dossier du plugin", sourceLabel: "Source du plugin",
idLabel: "ID d’installation du plugin", sourcePlaceholder: "Dossier, URL Git ou paquet npm",
idHint: "Laissez vide pour utiliser paseo-plugin.json", docs: "Documentation",
idPlaceholder: "Valeur du manifeste", install: "Installer le plugin",
install: "Installer le dossier",
installing: "Installation…", installing: "Installation…",
status: { running: "actif", disabled: "désactivé", failed: "échec" }, status: { running: "actif", disabled: "désactivé", failed: "échec" },
actions: { actions: {
menu: "Actions pour {{id}}",
reload: "Recharger", reload: "Recharger",
reloading: "Rechargement…", reloading: "Rechargement…",
enable: "Activer", enable: "Activer",
@@ -165,6 +166,7 @@ export const pluginSettings = {
offlineTitle: "L’hôte des plugins est hors ligne", offlineTitle: "L’hôte des plugins est hors ligne",
offlineDescription: "Reconnectez cet hôte pour gérer ses plugins.", offlineDescription: "Reconnectez cet hôte pour gérer ses plugins.",
updateTitle: "Mettez cet hôte à jour pour gérer les plugins", updateTitle: "Mettez cet hôte à jour pour gérer les plugins",
sourceUpdateTitle: "Mettez cet hôte à jour pour installer des plugins",
loading: "Chargement des plugins…", loading: "Chargement des plugins…",
errorTitle: "Impossible de charger les plugins", errorTitle: "Impossible de charger les plugins",
retry: "Réessayer", retry: "Réessayer",
@@ -185,14 +187,14 @@ export const pluginSettings = {
"ホストとアプリ内で分離されずに実行されます。信頼できるコードのみインストールしてください。", "ホストとアプリ内で分離されずに実行されます。信頼できるコードのみインストールしてください。",
globalTitle: "プラグインを有効化", globalTitle: "プラグインを有効化",
globalHint: "設定済みプラグインすべての共通スイッチ", globalHint: "設定済みプラグインすべての共通スイッチ",
directoryLabel: "プラグインディレクトリ", sourceLabel: "プラグインソース",
idLabel: "プラグインのインストール ID", sourcePlaceholder: "ディレクトリ、Git URL、npm パッケージ",
idHint: "paseo-plugin.json を使う場合は空欄にします", docs: "ドキュメント",
idPlaceholder: "マニフェストの既定値", install: "プラグインをインストール",
install: "ディレクトリをインストール",
installing: "インストール中…", installing: "インストール中…",
status: { running: "実行中", disabled: "無効", failed: "失敗" }, status: { running: "実行中", disabled: "無効", failed: "失敗" },
actions: { actions: {
menu: "{{id}} のアクション",
reload: "再読み込み", reload: "再読み込み",
reloading: "再読み込み中…", reloading: "再読み込み中…",
enable: "有効化", enable: "有効化",
@@ -218,6 +220,7 @@ export const pluginSettings = {
offlineTitle: "プラグインホストはオフラインです", offlineTitle: "プラグインホストはオフラインです",
offlineDescription: "プラグインを管理するにはホストに再接続してください。", offlineDescription: "プラグインを管理するにはホストに再接続してください。",
updateTitle: "プラグインを管理するにはホストを更新してください", updateTitle: "プラグインを管理するにはホストを更新してください",
sourceUpdateTitle: "プラグインをインストールするにはこのホストを更新してください",
loading: "プラグインを読み込み中…", loading: "プラグインを読み込み中…",
errorTitle: "プラグインを読み込めません", errorTitle: "プラグインを読み込めません",
retry: "再試行", retry: "再試行",
@@ -237,14 +240,14 @@ export const pluginSettings = {
trustedDescription: "호스트와 앱 안에서 격리 없이 실행됩니다. 신뢰하는 코드만 설치하세요.", trustedDescription: "호스트와 앱 안에서 격리 없이 실행됩니다. 신뢰하는 코드만 설치하세요.",
globalTitle: "플러그인 활성화", globalTitle: "플러그인 활성화",
globalHint: "설정된 모든 플러그인의 전역 스위치", globalHint: "설정된 모든 플러그인의 전역 스위치",
directoryLabel: "플러그인 디렉터리", sourceLabel: "플러그인 소스",
idLabel: "플러그인 설치 ID", sourcePlaceholder: "디렉터리, Git URL 또는 npm 패키지",
idHint: "paseo-plugin.json을 사용하려면 비워 두세요", docs: "문서",
idPlaceholder: "매니페스트 기본값", install: "플러그인 설치",
install: "디렉터리 설치",
installing: "설치 중…", installing: "설치 중…",
status: { running: "실행 중", disabled: "비활성화", failed: "실패" }, status: { running: "실행 중", disabled: "비활성화", failed: "실패" },
actions: { actions: {
menu: "{{id}} 작업",
reload: "다시 로드", reload: "다시 로드",
reloading: "다시 로드 중…", reloading: "다시 로드 중…",
enable: "활성화", enable: "활성화",
@@ -270,6 +273,7 @@ export const pluginSettings = {
offlineTitle: "플러그인 호스트가 오프라인입니다", offlineTitle: "플러그인 호스트가 오프라인입니다",
offlineDescription: "플러그인을 관리하려면 호스트에 다시 연결하세요.", offlineDescription: "플러그인을 관리하려면 호스트에 다시 연결하세요.",
updateTitle: "플러그인을 관리하려면 호스트를 업데이트하세요", updateTitle: "플러그인을 관리하려면 호스트를 업데이트하세요",
sourceUpdateTitle: "플러그인을 설치하려면 이 호스트를 업데이트하세요",
loading: "플러그인 로드 중…", loading: "플러그인 로드 중…",
errorTitle: "플러그인을 로드할 수 없습니다", errorTitle: "플러그인을 로드할 수 없습니다",
retry: "다시 시도", retry: "다시 시도",
@@ -290,14 +294,14 @@ export const pluginSettings = {
"Eles rodam neste host e dentro do app sem isolamento. Instale apenas código confiável.", "Eles rodam neste host e dentro do app sem isolamento. Instale apenas código confiável.",
globalTitle: "Ativar plugins", globalTitle: "Ativar plugins",
globalHint: "Controle global para todos os plugins configurados", globalHint: "Controle global para todos os plugins configurados",
directoryLabel: "Diretório do plugin", sourceLabel: "Fonte do plugin",
idLabel: "ID de instalação do plugin", sourcePlaceholder: "Diretório, URL Git ou pacote npm",
idHint: "Deixe vazio para usar paseo-plugin.json", docs: "Documentação",
idPlaceholder: "Padrão do manifesto", install: "Instalar plugin",
install: "Instalar diretório",
installing: "Instalando…", installing: "Instalando…",
status: { running: "em execução", disabled: "desativado", failed: "falhou" }, status: { running: "em execução", disabled: "desativado", failed: "falhou" },
actions: { actions: {
menu: "Ações para {{id}}",
reload: "Recarregar", reload: "Recarregar",
reloading: "Recarregando…", reloading: "Recarregando…",
enable: "Ativar", enable: "Ativar",
@@ -323,6 +327,7 @@ export const pluginSettings = {
offlineTitle: "O host de plugins está offline", offlineTitle: "O host de plugins está offline",
offlineDescription: "Reconecte este host para gerenciar seus plugins.", offlineDescription: "Reconecte este host para gerenciar seus plugins.",
updateTitle: "Atualize este host para gerenciar plugins", updateTitle: "Atualize este host para gerenciar plugins",
sourceUpdateTitle: "Atualize este host para instalar plugins",
loading: "Carregando plugins…", loading: "Carregando plugins…",
errorTitle: "Não foi possível carregar os plugins", errorTitle: "Não foi possível carregar os plugins",
retry: "Tentar novamente", retry: "Tentar novamente",
@@ -343,14 +348,14 @@ export const pluginSettings = {
"Они выполняются на хосте и в приложении без изоляции. Устанавливайте только доверенный код.", "Они выполняются на хосте и в приложении без изоляции. Устанавливайте только доверенный код.",
globalTitle: "Включить плагины", globalTitle: "Включить плагины",
globalHint: "Общий переключатель для всех настроенных плагинов", globalHint: "Общий переключатель для всех настроенных плагинов",
directoryLabel: "Каталог плагина", sourceLabel: "Источник плагина",
idLabel: "ID установки плагина", sourcePlaceholder: "Каталог, URL Git или пакет npm",
idHint: "Оставьте пустым, чтобы использовать paseo-plugin.json", docs: "Документация",
idPlaceholder: "Значение из манифеста", install: "Установить плагин",
install: "Установить каталог",
installing: "Установка…", installing: "Установка…",
status: { running: "работает", disabled: "отключён", failed: "ошибка" }, status: { running: "работает", disabled: "отключён", failed: "ошибка" },
actions: { actions: {
menu: "Действия для {{id}}",
reload: "Перезагрузить", reload: "Перезагрузить",
reloading: "Перезагрузка…", reloading: "Перезагрузка…",
enable: "Включить", enable: "Включить",
@@ -376,6 +381,7 @@ export const pluginSettings = {
offlineTitle: "Хост плагинов не в сети", offlineTitle: "Хост плагинов не в сети",
offlineDescription: "Подключите хост снова, чтобы управлять плагинами.", offlineDescription: "Подключите хост снова, чтобы управлять плагинами.",
updateTitle: "Обновите хост для управления плагинами", updateTitle: "Обновите хост для управления плагинами",
sourceUpdateTitle: "Обновите этот хост для установки плагинов",
loading: "Загрузка плагинов…", loading: "Загрузка плагинов…",
errorTitle: "Не удалось загрузить плагины", errorTitle: "Не удалось загрузить плагины",
retry: "Повторить", retry: "Повторить",
@@ -395,14 +401,14 @@ export const pluginSettings = {
trustedDescription: "它们会在此主机和应用内运行,且没有隔离。请仅安装你信任的代码。", trustedDescription: "它们会在此主机和应用内运行,且没有隔离。请仅安装你信任的代码。",
globalTitle: "启用插件", globalTitle: "启用插件",
globalHint: "所有已配置插件的全局开关", globalHint: "所有已配置插件的全局开关",
directoryLabel: "插件目录", sourceLabel: "插件来源",
idLabel: "插件安装 ID", sourcePlaceholder: "目录、Git URL 或 npm 包",
idHint: "留空以使用 paseo-plugin.json", docs: "文档",
idPlaceholder: "清单默认值", install: "安装插件",
install: "安装目录",
installing: "正在安装…", installing: "正在安装…",
status: { running: "运行中", disabled: "已禁用", failed: "失败" }, status: { running: "运行中", disabled: "已禁用", failed: "失败" },
actions: { actions: {
menu: "{{id}} 的操作",
reload: "重新加载", reload: "重新加载",
reloading: "正在重新加载…", reloading: "正在重新加载…",
enable: "启用", enable: "启用",
@@ -428,6 +434,7 @@ export const pluginSettings = {
offlineTitle: "插件主机离线", offlineTitle: "插件主机离线",
offlineDescription: "重新连接此主机以管理其插件。", offlineDescription: "重新连接此主机以管理其插件。",
updateTitle: "更新此主机以管理插件", updateTitle: "更新此主机以管理插件",
sourceUpdateTitle: "更新此主机以安装插件",
loading: "正在加载插件…", loading: "正在加载插件…",
errorTitle: "无法加载插件", errorTitle: "无法加载插件",
retry: "重试", retry: "重试",
@@ -71,7 +71,7 @@ it("rejects catalogs without requirements from pre-0.8 daemons", () => {
result.installCatalog("host", [{ id: "example", clientBundle }], { client }); result.installCatalog("host", [{ id: "example", clientBundle }], { client });
expect(starts()).toBe(0); expect(starts()).toBe(0);
expect(result.getEvaluationError("host", "example")).toContain( expect(result.getEvaluationError("host", "example")).toContain(
"https://paseo.sh/docs/plugins/v0.8/migration", "https://paseo.sh/docs/plugins/migration",
); );
}); });
+14 -7
View File
@@ -4,8 +4,8 @@ import { Platform, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { router } from "expo-router"; import { router } from "expo-router";
import type { PluginHostProps } from "@getpaseo/plugin/client"; import type { PluginHostProps } from "@getpaseo/plugin/client";
import { SettingsAction } from "@/components/settings";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostFeature } from "@/runtime/host-features"; import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
@@ -22,35 +22,42 @@ interface SettingsIdentity {
screenId: string; screenId: string;
} }
function SettingsLink({ function PluginSettingsMenuItem({
serverId, serverId,
pluginId, pluginId,
screenId, screenId,
title, title,
}: SettingsIdentity & { title: string }) { disabled,
const { t } = useTranslation(); }: SettingsIdentity & { title: string; disabled?: boolean }) {
const open = useCallback( const open = useCallback(
() => router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)), () => router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)),
[serverId, pluginId, screenId], [serverId, pluginId, screenId],
); );
return ( return (
<SettingsAction label={title} actionLabel={t("settings.plugins.screens.open")} onPress={open} /> <DropdownMenuItem onSelect={open} disabled={disabled}>
{title}
</DropdownMenuItem>
); );
} }
export function PluginSettingsLinks({ serverId, pluginId }: Omit<SettingsIdentity, "screenId">) { export function PluginSettingsMenuItems({
serverId,
pluginId,
disabled,
}: Omit<SettingsIdentity, "screenId"> & { disabled?: boolean }) {
const plugin = useInstalledPlugin(serverId, pluginId); const plugin = useInstalledPlugin(serverId, pluginId);
const supported = useHostFeature(serverId, "pluginSettings"); const supported = useHostFeature(serverId, "pluginSettings");
if (!supported || !plugin) return null; if (!supported || !plugin) return null;
return ( return (
<> <>
{plugin.settingsScreens.map((screen) => ( {plugin.settingsScreens.map((screen) => (
<SettingsLink <PluginSettingsMenuItem
key={screen.id} key={screen.id}
serverId={serverId} serverId={serverId}
pluginId={pluginId} pluginId={pluginId}
screenId={screen.id} screenId={screen.id}
title={screen.title} title={screen.title}
disabled={disabled}
/> />
))} ))}
</> </>
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { openPluginInstallForm } from "./plugin-install-form-model";
describe("plugin install form", () => {
it("keeps the edited source in the submission until explicitly reset", () => {
const model = openPluginInstallForm();
model.setSource("owner/review");
expect(model.getSubmission()).toEqual({ source: "owner/review" });
model.reset();
expect(model.getState()).toEqual({
source: "",
canSubmit: false,
resetKey: 1,
});
});
it("trims the submitted source", () => {
const model = openPluginInstallForm();
model.setSource(" /plugins/review ");
expect(model.getSubmission()).toEqual({ source: "/plugins/review" });
});
});
@@ -0,0 +1,53 @@
export interface PluginInstallFormState {
source: string;
canSubmit: boolean;
resetKey: number;
}
export interface PluginInstallSubmission {
source: string;
}
export interface PluginInstallFormModel {
getState(): PluginInstallFormState;
subscribe(listener: () => void): () => void;
close(): void;
setSource(value: string): void;
getSubmission(): PluginInstallSubmission;
reset(): void;
}
export function openPluginInstallForm(): PluginInstallFormModel {
const listeners = new Set<() => void>();
let state: PluginInstallFormState = {
source: "",
canSubmit: false,
resetKey: 0,
};
const publish = (next: Pick<PluginInstallFormState, "source" | "resetKey">) => {
state = { ...next, canSubmit: next.source.trim().length > 0 };
for (const listener of listeners) listener();
};
return {
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
close() {
listeners.clear();
},
setSource(source) {
publish({ ...state, source });
},
getSubmission() {
const source = state.source.trim();
return { source };
},
reset() {
publish({ source: "", resetKey: state.resetKey + 1 });
},
};
}
@@ -26,8 +26,10 @@ vi.mock("@/runtime/host-runtime", () => ({
})); }));
vi.mock("@/runtime/host-features", () => ({ vi.mock("@/runtime/host-features", () => ({
useHostFeature: (_serverId: string, feature: string) => useHostFeature: (_serverId: string, feature: string) => {
feature === "pluginLogs" ? runtime.logsSupported : runtime.supported, if (feature === "pluginLogs") return runtime.logsSupported;
return runtime.supported;
},
})); }));
vi.mock("@/components/adaptive-modal-sheet", async () => { vi.mock("@/components/adaptive-modal-sheet", async () => {
@@ -89,19 +91,21 @@ vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
})); }));
function never<T>(): Promise<T> {
return new Promise<T>(() => undefined);
}
function plugin(enabled = true): PluginListItem { function plugin(enabled = true): PluginListItem {
return { return {
id: "example", id: "example",
description: "Reviews changes before merge",
path: "/plugins/example", path: "/plugins/example",
enabled, enabled,
status: enabled ? "running" : "disabled", status: enabled ? "running" : "disabled",
}; };
} }
async function selectPluginAction(action: string): Promise<void> {
fireEvent.click(await screen.findByRole("button", { name: "Actions for example" }));
fireEvent.click(await screen.findByRole("menuitem", { name: action }));
}
function createClient() { function createClient() {
return { return {
observeEvents: () => observeEvents: () =>
@@ -112,8 +116,7 @@ function createClient() {
getDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })), getDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })),
patchDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })), patchDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })),
listPlugins: vi.fn(async (): Promise<PluginListItem[]> => []), listPlugins: vi.fn(async (): Promise<PluginListItem[]> => []),
inspectDirectoryPlugin: vi.fn(async () => ({ id: "example" })), installPluginSource: vi.fn(async () => plugin()),
installDirectoryPlugin: vi.fn(async () => plugin()),
reloadPlugin: vi.fn(async () => plugin()), reloadPlugin: vi.fn(async () => plugin()),
enablePlugin: vi.fn(async () => plugin()), enablePlugin: vi.fn(async () => plugin()),
disablePlugin: vi.fn(async () => plugin(false)), disablePlugin: vi.fn(async () => plugin(false)),
@@ -180,36 +183,16 @@ describe("HostPluginsPage", () => {
}); });
it.each([ it.each([
["reloadPlugin", "Reload", "Reloading…", true], ["reloadPlugin", "Reload"],
["disablePlugin", "Disable", "Disabling…", true], ["removePlugin", "Remove"],
["enablePlugin", "Enable", "Enabling…", false], ] as const)("runs %s from the plugin actions menu", async (method, action) => {
["removePlugin", "Remove", "Removing…", true],
] as const)("renders %s pending on its plugin row", async (method, trigger, pending, enabled) => {
const client = createClient(); const client = createClient();
client.listPlugins.mockResolvedValue([plugin(enabled)]); client.listPlugins.mockResolvedValue([plugin()]);
client[method].mockImplementation(() => never<never>());
renderPage(client); renderPage(client);
fireEvent.click(await screen.findByRole("button", { name: trigger })); await selectPluginAction(action);
const pendingControl = await screen.findByRole("button", { name: pending }); await waitFor(() => expect(client[method]).toHaveBeenCalledTimes(1));
expect(pendingControl.getAttribute("aria-disabled")).toBe("true");
expect(client[method]).toHaveBeenCalledTimes(1);
});
it("renders install pending through the real form and mutation", async () => {
const client = createClient();
client.installDirectoryPlugin.mockImplementation(() => never<never>());
renderPage(client);
fireEvent.change(screen.getByLabelText("Plugin directory"), {
target: { value: "/plugins/example" },
});
fireEvent.click(screen.getByRole("button", { name: "Install directory" }));
const pendingControl = await screen.findByRole("button", { name: "Installing…" });
expect(pendingControl.getAttribute("aria-disabled")).toBe("true");
expect(client.installDirectoryPlugin).toHaveBeenCalledWith("/plugins/example", undefined);
}); });
it("hides the logs action when the host does not advertise support", async () => { it("hides the logs action when the host does not advertise support", async () => {
@@ -219,7 +202,8 @@ describe("HostPluginsPage", () => {
renderPage(client); renderPage(client);
await screen.findByText("example"); await screen.findByText("example");
expect(screen.queryByRole("button", { name: "Logs" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Actions for example" }));
expect(screen.queryByRole("menuitem", { name: "Logs" })).toBeNull();
}); });
it("opens readable stdout and stderr logs and refreshes them", async () => { it("opens readable stdout and stderr logs and refreshes them", async () => {
@@ -241,7 +225,7 @@ describe("HostPluginsPage", () => {
]); ]);
renderPage(client); renderPage(client);
fireEvent.click(await screen.findByRole("button", { name: "Logs" })); await selectPluginAction("Logs");
expect(await screen.findByRole("dialog")).toBeDefined(); expect(await screen.findByRole("dialog")).toBeDefined();
expect(screen.getByText("Logs: example")).toBeDefined(); expect(screen.getByText("Logs: example")).toBeDefined();
@@ -260,7 +244,7 @@ describe("HostPluginsPage", () => {
client.getPluginLogs.mockRejectedValueOnce(new Error("logs exploded")); client.getPluginLogs.mockRejectedValueOnce(new Error("logs exploded"));
renderPage(client); renderPage(client);
fireEvent.click(await screen.findByRole("button", { name: "Logs" })); await selectPluginAction("Logs");
expect(await screen.findByText("Unable to load plugin logs")).toBeDefined(); expect(await screen.findByText("Unable to load plugin logs")).toBeDefined();
expect(screen.getByText("logs exploded")).toBeDefined(); expect(screen.getByText("logs exploded")).toBeDefined();
+189 -116
View File
@@ -1,27 +1,39 @@
import { PluginSettingsLinks } from "@/plugins/settings"; import { formatPluginInstallation } from "@getpaseo/protocol/plugin-source-reference";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PluginSettingsMenuItems } from "@/plugins/settings";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages"; import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
import { MoreHorizontal, Trash2 } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { SettingsCard, SettingsRow } from "@/components/settings";
import { Alert } from "@/components/ui/alert"; import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ExternalLink } from "@/components/ui/external-link";
import { Field, FormTextInput } from "@/components/ui/form-field"; import { Field, FormTextInput } from "@/components/ui/form-field";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useFetchQuery } from "@/data/query"; import { useFetchQuery } from "@/data/query";
import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useHostFeature } from "@/runtime/host-features"; import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { SettingsSection } from "@/components/settings/headings/settings-section"; import { SettingsSection } from "@/components/settings/headings/settings-section";
import { resolvePluginPageState } from "@/screens/settings/plugins-page-state"; import { resolvePluginPageState } from "@/screens/settings/plugins-page-state";
import { openPluginInstallForm } from "@/screens/settings/plugin-install-form-model";
import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry"; import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
const pluginQueryKey = (serverId: string) => ["plugins", serverId] as const; const pluginQueryKey = (serverId: string) => ["plugins", serverId] as const;
const PLUGIN_SOURCE_DOCS_URL = "https://paseo.sh/docs/plugins/reference#plugin-sources";
type PluginRowAction = "reload" | "enable" | "disable" | "remove"; type PluginRowAction = "reload" | "enable" | "disable" | "remove";
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
@@ -35,6 +47,71 @@ function pluginRowAction(action: string | undefined): PluginRowAction | undefine
return undefined; return undefined;
} }
function PluginActionsMenu({
plugin,
serverId,
pending,
pendingAction,
onAction,
onOpenLogs,
supportsLogs,
}: {
plugin: PluginListItem;
serverId: string;
pending: boolean;
pendingAction?: PluginRowAction;
onAction(action: PluginRowAction, plugin: PluginListItem): void;
onOpenLogs(pluginId: string): void;
supportsLogs: boolean;
}) {
const { t } = useTranslation();
const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]);
const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]);
const openLogs = useCallback(() => onOpenLogs(plugin.id), [onOpenLogs, plugin.id]);
const removeIcon = useMemo(() => <Trash2 size={16} color={styles.dangerIcon.color} />, []);
const menuLabel = t("settings.plugins.actions.menu", { id: plugin.id });
return (
<DropdownMenu compactMode="sheet">
<DropdownMenuTrigger
accessibilityRole="button"
accessibilityLabel={menuLabel}
disabled={pending}
hitSlop={8}
style={styles.menuButton}
>
<MoreHorizontal size={18} color={styles.menuIcon.color} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} sheetTitle={menuLabel}>
<PluginSettingsMenuItems serverId={serverId} pluginId={plugin.id} disabled={pending} />
{supportsLogs ? (
<DropdownMenuItem onSelect={openLogs} disabled={pending}>
{t("settings.plugins.logs.action")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onSelect={reload}
disabled={pending || !plugin.enabled}
status={pendingAction === "reload" ? "pending" : "idle"}
pendingLabel={t("settings.plugins.actions.reloading")}
>
{t("settings.plugins.actions.reload")}
</DropdownMenuItem>
<DropdownMenuItem
destructive
leading={removeIcon}
onSelect={remove}
disabled={pending}
status={pendingAction === "remove" ? "pending" : "idle"}
pendingLabel={t("settings.plugins.actions.removing")}
>
{t("settings.plugins.actions.remove")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function PluginRow({ function PluginRow({
plugin, plugin,
serverId, serverId,
@@ -55,64 +132,62 @@ function PluginRow({
supportsLogs: boolean; supportsLogs: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]);
const toggle = useCallback( const toggle = useCallback(
() => onAction(plugin.enabled ? "disable" : "enable", plugin), (enabled: boolean) => onAction(enabled ? "enable" : "disable", plugin),
[onAction, plugin], [onAction, plugin],
); );
const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]);
const openLogs = useCallback(() => onOpenLogs(plugin.id), [onOpenLogs, plugin.id]);
const status = clientError ? "failed" : plugin.status; const status = clientError ? "failed" : plugin.status;
let badgeVariant: "success" | "error" | "muted" = "muted"; let badgeVariant: "success" | "error" | "muted" = "muted";
if (status === "running") badgeVariant = "success"; if (status === "running") badgeVariant = "success";
else if (status === "failed") badgeVariant = "error"; else if (status === "failed") badgeVariant = "error";
const statusLabel = t(`settings.plugins.status.${status}`); const statusLabel = t(`settings.plugins.status.${status}`);
let toggleLabel = t( const statusBadge = useMemo(
plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable", () => <StatusBadge label={statusLabel} variant={badgeVariant} />,
[badgeVariant, statusLabel],
); );
if (pendingAction === "enable") toggleLabel = t("settings.plugins.actions.enabling"); const hint = useMemo(
else if (pendingAction === "disable") toggleLabel = t("settings.plugins.actions.disabling"); () =>
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",
)}`;
return ( return (
<View> <SettingsRow
<View style={styles.pluginRow} accessibilityLabel={`${plugin.id} ${statusLabel}`}> label={plugin.id}
<View style={settingsStyles.rowContent}> labelAccessory={statusBadge}
<View style={styles.pluginTitle}> hint={hint}
<Text style={settingsStyles.rowTitle}>{plugin.id}</Text> error={clientError ?? plugin.error}
<StatusBadge label={statusLabel} variant={badgeVariant} /> testID={`plugin-row-${plugin.id}`}
</View> >
<Text style={settingsStyles.rowHint}>{plugin.path}</Text> <View style={styles.pluginControls} accessibilityLabel={`${plugin.id} ${statusLabel}`}>
{clientError || plugin.error ? ( <Switch
<Text style={styles.error}>{clientError ?? plugin.error}</Text> value={plugin.enabled}
) : null} onValueChange={toggle}
</View> disabled={pending}
<View style={styles.actions}> accessibilityLabel={toggleLabel}
{supportsLogs ? ( />
<Button variant="outline" size="sm" onPress={openLogs} disabled={pending}> <PluginActionsMenu
{t("settings.plugins.logs.action")} plugin={plugin}
</Button> serverId={serverId}
) : null} pending={pending}
<Button pendingAction={pendingAction}
variant="outline" onAction={onAction}
size="sm" onOpenLogs={onOpenLogs}
onPress={reload} supportsLogs={supportsLogs}
disabled={pending || !plugin.enabled} />
>
{pendingAction === "reload"
? t("settings.plugins.actions.reloading")
: t("settings.plugins.actions.reload")}
</Button>
<Button variant="outline" size="sm" onPress={toggle} disabled={pending}>
{toggleLabel}
</Button>
<Button variant="outline" size="sm" onPress={remove} disabled={pending}>
{pendingAction === "remove"
? t("settings.plugins.actions.removing")
: t("settings.plugins.actions.remove")}
</Button>
</View>
</View> </View>
<PluginSettingsLinks serverId={serverId} pluginId={plugin.id} /> </SettingsRow>
</View>
); );
} }
@@ -193,16 +268,30 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId); const connected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "pluginManagement"); const supported = useHostFeature(serverId, "pluginManagement");
// 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. // COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
const logsSupported = useHostFeature(serverId, "pluginLogs"); const logsSupported = useHostFeature(serverId, "pluginLogs");
const { config, patchConfig } = useDaemonConfig(serverId); const { config, patchConfig } = useDaemonConfig(serverId);
const refreshQueue = useRef(Promise.resolve()); const refreshQueue = useRef(Promise.resolve());
useInstalledPlugins(); useInstalledPlugins();
const queryKey = useMemo(() => pluginQueryKey(serverId), [serverId]); const queryKey = useMemo(() => pluginQueryKey(serverId), [serverId]);
const [directory, setDirectory] = useState(""); const [installForm] = useState(openPluginInstallForm);
const [pluginId, setPluginId] = useState(""); const installState = useSyncExternalStore(
const [directoryResetKey, setDirectoryResetKey] = useState(0); installForm.subscribe,
const [pluginIdResetKey, setPluginIdResetKey] = useState(0); installForm.getState,
installForm.getState,
);
const sourceDocsLink = useMemo(
() => (
<ExternalLink
href={PLUGIN_SOURCE_DOCS_URL}
label={t("settings.plugins.docs")}
testID="plugin-source-docs-link"
/>
),
[t],
);
const [logsPluginId, setLogsPluginId] = useState<string | null>(null); const [logsPluginId, setLogsPluginId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>( const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>(
null, null,
@@ -226,6 +315,9 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
refreshQueue.current = refreshQueue.current.then(refetch, refetch); refreshQueue.current = refreshQueue.current.then(refetch, refetch);
await refreshQueue.current; await refreshQueue.current;
}, [client, refetchPlugins]); }, [client, refetchPlugins]);
useEffect(() => {
return () => installForm.close();
}, [installForm]);
useEffect(() => { useEffect(() => {
if (!client || !connected || !supported) return; if (!client || !connected || !supported) return;
const observation = client.observeEvents(["status.plugin_catalog_changed"]); const observation = client.observeEvents(["status.plugin_catalog_changed"]);
@@ -255,33 +347,18 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
onError: (error) => setFeedback({ kind: "error", message: errorMessage(error) }), onError: (error) => setFeedback({ kind: "error", message: errorMessage(error) }),
}); });
const install = useCallback(() => { const install = useCallback(() => {
const path = directory.trim(); if (!client || !sourceInstallSupported || !installState.canSubmit) return;
if (!client || !path) return; const input = installForm.getSubmission();
setFeedback(null); setFeedback(null);
mutation.mutate({ mutation.mutate({
action: "install", action: "install",
run: async () => { run: async () => {
const installed = await client.installDirectoryPlugin(path, pluginId.trim() || undefined); const installed = await client.installPluginSource(input);
setDirectory(""); installForm.reset();
setPluginId("");
setDirectoryResetKey((key) => key + 1);
setPluginIdResetKey((key) => key + 1);
return t("settings.plugins.feedback.installed", { id: installed.id }); return t("settings.plugins.feedback.installed", { id: installed.id });
}, },
}); });
}, [client, directory, mutation, pluginId, t]); }, [client, installForm, installState.canSubmit, mutation, sourceInstallSupported, t]);
const prefillManifestId = useCallback(() => {
const path = directory.trim();
if (!client || !path || pluginId.trim()) return;
void client
.inspectDirectoryPlugin(path)
.then(({ id }) => {
setPluginId(id);
setPluginIdResetKey((key) => key + 1);
return undefined;
})
.catch(() => undefined);
}, [client, directory, pluginId]);
const action = useCallback( const action = useCallback(
(name: PluginRowAction, plugin: PluginListItem) => { (name: PluginRowAction, plugin: PluginListItem) => {
if (!client) return; if (!client) return;
@@ -360,7 +437,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
); );
} else if (pageState !== "loading") { } else if (pageState !== "loading") {
catalogContent = ( catalogContent = (
<View style={settingsStyles.card}> <SettingsCard>
{plugins.data?.length ? ( {plugins.data?.length ? (
plugins.data.map((plugin) => { plugins.data.map((plugin) => {
const clientError = pluginRegistry.getEvaluationError(serverId, plugin.id); const clientError = pluginRegistry.getEvaluationError(serverId, plugin.id);
@@ -384,7 +461,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
<Text style={settingsStyles.rowHint}>{t("settings.plugins.states.empty")}</Text> <Text style={settingsStyles.rowHint}>{t("settings.plugins.states.empty")}</Text>
</View> </View>
)} )}
</View> </SettingsCard>
); );
} }
@@ -410,38 +487,29 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
/> />
</View> </View>
</View> </View>
<View style={[settingsStyles.card, styles.install]}> {sourceInstallSupported ? (
<Field label={t("settings.plugins.directoryLabel")}> <View style={[settingsStyles.card, styles.install]}>
<FormTextInput <Field label={t("settings.plugins.sourceLabel")} trailing={sourceDocsLink}>
initialValue="" <FormTextInput
resetKey={directoryResetKey} initialValue=""
onChangeText={setDirectory} resetKey={installState.resetKey}
onBlur={prefillManifestId} onChangeText={installForm.setSource}
placeholder={t("settings.plugins.directoryPlaceholder")} placeholder={t("settings.plugins.sourcePlaceholder")}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
editable={!mutation.isPending} editable={!mutation.isPending}
accessibilityLabel={t("settings.plugins.directoryLabel")} accessibilityLabel={t("settings.plugins.sourceLabel")}
/> />
</Field> </Field>
<Field label={t("settings.plugins.idLabel")} hint={t("settings.plugins.idHint")}> <Button onPress={install} disabled={!installState.canSubmit || mutation.isPending}>
<FormTextInput {mutation.isPending && mutation.variables?.action === "install"
initialValue={pluginId} ? t("settings.plugins.installing")
resetKey={pluginIdResetKey} : t("settings.plugins.install")}
onChangeText={setPluginId} </Button>
placeholder={t("settings.plugins.idPlaceholder")} </View>
autoCapitalize="none" ) : (
autoCorrect={false} <Alert variant="warning" title={t("settings.plugins.states.sourceUpdateTitle")} />
editable={!mutation.isPending} )}
accessibilityLabel={t("settings.plugins.idLabel")}
/>
</Field>
<Button onPress={install} disabled={!directory.trim() || mutation.isPending}>
{mutation.isPending && mutation.variables?.action === "install"
? t("settings.plugins.installing")
: t("settings.plugins.install")}
</Button>
</View>
{feedback ? ( {feedback ? (
<Alert <Alert
variant={feedback.kind} variant={feedback.kind}
@@ -464,16 +532,21 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
install: { padding: theme.spacing[4], gap: theme.spacing[3] }, pluginDescription: {
pluginRow: { color: theme.colors.foregroundMuted,
padding: theme.spacing[4], fontSize: theme.fontSize.base,
gap: theme.spacing[3], marginTop: theme.spacing[1],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
}, },
pluginTitle: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, pluginSource: {
actions: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2] }, color: theme.colors.foregroundExtraMuted,
error: { color: theme.colors.statusDanger, fontSize: theme.fontSize.sm }, 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 },
menuIcon: { color: theme.colors.foregroundMuted },
dangerIcon: { color: theme.colors.statusDanger },
empty: { padding: theme.spacing[4], alignItems: "center" }, empty: { padding: theme.spacing[4], alignItems: "center" },
logsState: { logsState: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
+17
View File
@@ -160,6 +160,23 @@ export function createCli(): Command {
// Added in v0.2.0; remove after 2027-01-17. // Added in v0.2.0; remove after 2027-01-17.
program.addCommand(createWorktreeCommand(), { hidden: true }); 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) => { const enforceSelectorDuplicates = (command: Command) => {
for (const option of command.options) { for (const option of command.options) {
if (option.long !== "--home" && option.long !== "--host") continue; if (option.long !== "--home" && option.long !== "--host") continue;
+211 -14
View File
@@ -11,6 +11,14 @@ const listPlugins = vi.fn(async () => [
source: "git" as const, source: "git" as const,
commit: "1557a34c91e2abcdef", commit: "1557a34c91e2abcdef",
ref: "main", ref: "main",
installation: {
identity: {
kind: "git" as const,
remote: "https://example.test/plugin.git",
pluginPath: ".",
},
currentRevision: "1557a34c91e2abcdef",
},
}, },
{ {
id: "legacy-plugin", id: "legacy-plugin",
@@ -28,23 +36,26 @@ const getPluginLogs = vi.fn(async () => [
message: "ready", message: "ready",
}, },
]); ]);
const installDirectoryPlugin = vi.fn(async () => ({
id: "trusted-plugin",
path: "/plugins/trusted-plugin",
enabled: true,
status: "running" as const,
}));
const installPluginSource = vi.fn(async () => ({ const installPluginSource = vi.fn(async () => ({
id: "trusted-plugin", id: "trusted-plugin",
path: "/plugins/trusted-plugin", path: "/plugins/trusted-plugin",
enabled: true, enabled: true,
status: "running" as const, status: "running" as const,
})); }));
const installDirectoryPlugin = vi.fn(async () => ({
id: "legacy-plugin",
path: "/plugins/legacy-plugin",
enabled: true,
status: "running" as const,
}));
const previewPluginUpdates = vi.fn(async () => []);
const close = vi.fn(async () => undefined); const close = vi.fn(async () => undefined);
const features: { const features: {
pluginManagement?: boolean; pluginManagement?: boolean;
pluginLogs?: boolean; pluginLogs?: boolean;
pluginGitManagement?: boolean; pluginGitManagement?: boolean;
pluginSourceInstallation?: boolean;
pluginSourceUpdates?: boolean;
} = {}; } = {};
vi.mock("../../utils/client.js", () => ({ vi.mock("../../utils/client.js", () => ({
@@ -54,18 +65,27 @@ vi.mock("../../utils/client.js", () => ({
getPluginLogs, getPluginLogs,
installDirectoryPlugin, installDirectoryPlugin,
installPluginSource, installPluginSource,
previewPluginUpdates,
close, close,
})), })),
})); }));
import { render } from "../../output/index.js"; import { render } from "../../output/index.js";
import { createPluginCommand, runPluginListCommand, runPluginLogsCommand } from "./index.js"; import {
createPluginCommand,
runPluginInstallCommand,
runPluginUpdateCommand,
runPluginListCommand,
runPluginLogsCommand,
} from "./index.js";
describe("plugin management commands", () => { describe("plugin management commands", () => {
beforeEach(() => { beforeEach(() => {
features.pluginManagement = false; features.pluginManagement = false;
features.pluginLogs = false; features.pluginLogs = false;
features.pluginGitManagement = false; features.pluginGitManagement = false;
features.pluginSourceInstallation = false;
features.pluginSourceUpdates = false;
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -97,7 +117,7 @@ describe("plugin management commands", () => {
const output = render(result, { noColor: true }); const output = render(result, { noColor: true });
expect(output).toContain("SOURCE"); expect(output).toContain("SOURCE");
expect(output).toContain("COMMIT"); expect(output).toContain("REVISION");
expect(output).not.toContain("LATEST"); expect(output).not.toContain("LATEST");
expect(output).toContain("1557a34c91e2"); expect(output).toContain("1557a34c91e2");
expect(output).toContain("This plugin was made for an older version of Paseo"); expect(output).toContain("This plugin was made for an older version of Paseo");
@@ -146,22 +166,93 @@ describe("plugin management commands", () => {
}); });
it("prints the trust acknowledgement before installing", async () => { it("prints the trust acknowledgement before installing", async () => {
features.pluginManagement = true; features.pluginSourceInstallation = true;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const command = createPluginCommand(); const command = createPluginCommand();
await command.parseAsync(["install", "/plugins/trusted-plugin"], { from: "user" }); await command.parseAsync(["install", "/plugins/trusted-plugin"], { from: "user" });
expect(stderr).toHaveBeenCalledWith( 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(installDirectoryPlugin).toHaveBeenCalledWith("/plugins/trusted-plugin", undefined); expect(installPluginSource).toHaveBeenCalledWith({ source: "/plugins/trusted-plugin" });
stderr.mockRestore(); stderr.mockRestore();
}); });
it("folds the legacy --path option into the plugin source reference", async () => { it("requires source support for directory installs without using the legacy RPC", async () => {
features.pluginManagement = true;
features.pluginGitManagement = true; features.pluginGitManagement = true;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => 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 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.pluginSourceInstallation = true;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const command = createPluginCommand(); const command = createPluginCommand();
await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], { await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], {
@@ -175,7 +266,7 @@ describe("plugin management commands", () => {
}); });
it("keeps an absolute monorepo path as one plugin source reference", async () => { 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 stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const command = createPluginCommand(); const command = createPluginCommand();
@@ -184,7 +275,113 @@ describe("plugin management commands", () => {
expect(installPluginSource).toHaveBeenCalledWith({ expect(installPluginSource).toHaveBeenCalledWith({
source: "/plugins/monorepo:plugins/review", source: "/plugins/monorepo:plugins/review",
}); });
expect(installDirectoryPlugin).not.toHaveBeenCalled();
stderr.mockRestore(); 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([]);
},
);
});
+75 -57
View File
@@ -1,13 +1,10 @@
import { createInterface } from "node:readline/promises";
import { reviewPluginUpdates, type UpdateOutcome } from "./update.js";
import { Command } from "commander"; import { Command } from "commander";
import path from "node:path"; import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
import type {
PluginListItem,
PluginLogEntry,
PluginSourceUpdateItem,
} from "@getpaseo/protocol/messages";
import { import {
formatPluginSourceReference, formatPluginSourceReference,
parsePluginSourceReference, formatPluginIdentity,
} from "@getpaseo/protocol/plugin-source-reference"; } from "@getpaseo/protocol/plugin-source-reference";
import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js"; import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js";
import { withOutput } from "../../output/index.js"; import { withOutput } from "../../output/index.js";
@@ -15,6 +12,7 @@ import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-
import { scaffoldPluginDirectory, type PluginScaffold } from "./scaffold.js"; import { scaffoldPluginDirectory, type PluginScaffold } from "./scaffold.js";
import { import {
withPluginLogsClient, withPluginLogsClient,
withPluginUpdateClient,
withPluginManagementClient, withPluginManagementClient,
withPluginSourceClient, withPluginSourceClient,
} from "./shared.js"; } from "./shared.js";
@@ -25,6 +23,9 @@ interface PluginOptions extends CommandOptions {
ref?: string; ref?: string;
path?: string; path?: string;
all?: boolean; all?: boolean;
version?: string;
check?: boolean;
yes?: boolean;
} }
const pluginSchema: OutputSchema<PluginListItem> = { const pluginSchema: OutputSchema<PluginListItem> = {
@@ -33,9 +34,17 @@ const pluginSchema: OutputSchema<PluginListItem> = {
{ header: "PLUGIN", field: "id", width: 20 }, { header: "PLUGIN", field: "id", width: 20 },
{ header: "STATUS", field: "status", width: 10 }, { header: "STATUS", field: "status", width: 10 },
{ header: "ENABLED", field: (plugin) => (plugin.enabled ? "yes" : "no"), width: 8 }, { 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: "SOURCE",
{ header: "REF", field: (plugin) => plugin.ref ?? "-", width: 24 }, 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: "DIRECTORY", field: "path", width: 40 },
{ header: "ERROR", field: (plugin) => plugin.error ?? "", width: 40 }, { header: "ERROR", field: (plugin) => plugin.error ?? "", width: 40 },
], ],
@@ -58,18 +67,17 @@ const pluginLogsSchema: OutputSchema<PluginLogEntry> = {
], ],
}; };
function shortCommit(commit: string | undefined): string { const pluginUpdateSchema: OutputSchema<UpdateOutcome> = {
return commit?.slice(0, 12) ?? "-";
}
const pluginUpdateSchema: OutputSchema<PluginSourceUpdateItem> = {
idField: "id", idField: "id",
columns: [ columns: [
{ header: "PLUGIN", field: "id", width: 20 }, { header: "PLUGIN", field: "id", width: 24 },
{ header: "PREVIOUS", field: (plugin) => shortCommit(plugin.previousCommit), width: 14 }, { header: "RESULT", field: "outcome", width: 18 },
{ header: "CURRENT", field: (plugin) => shortCommit(plugin.currentCommit), width: 14 }, {
{ header: "COMMITS", field: (plugin) => String(plugin.commits), width: 8 }, header: "DETAIL",
{ header: "UPDATED", field: (plugin) => (plugin.updated ? "yes" : "no"), width: 8 }, field: (item) =>
item.error ?? item.warning ?? item.plugin?.installation?.currentRevision ?? "",
width: 50,
},
], ],
}; };
@@ -109,51 +117,54 @@ export async function runPluginLogsCommand(
return { type: "list", data, schema: pluginLogsSchema }; return { type: "list", data, schema: pluginLogsSchema };
} }
async function install( export async function runPluginInstallCommand(
source: string, source: string,
options: PluginOptions, options: PluginOptions,
_command: Command, _command: Command,
): Promise<SingleResult<PluginListItem>> { ): Promise<SingleResult<PluginListItem>> {
process.stderr.write( 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 isExplicitPath =
path.isAbsolute(source) ||
source === "." ||
source === ".." ||
source.startsWith("./") ||
source.startsWith("../") ||
source.startsWith(".\\") ||
source.startsWith("..\\");
const hasPluginPathSuffix = parsePluginSourceReference(source).pluginPath !== undefined;
const canUseLegacyDirectoryInstall =
isExplicitPath && !hasPluginPathSuffix && !options.ref && !options.path;
const sourceReference = formatPluginSourceReference(source, options.path); const sourceReference = formatPluginSourceReference(source, options.path);
const data = canUseLegacyDirectoryInstall const data = await withPluginSourceClient(options.daemonTarget, (client) =>
? await withPluginManagementClient(options.daemonTarget, (client) => client.installPluginSource({
client.installDirectoryPlugin(source, options.id), source: sourceReference,
) ...(options.id ? { id: options.id } : {}),
: await withPluginSourceClient(options.daemonTarget, (client) => ...(options.ref ? { ref: options.ref } : {}),
client.installPluginSource({ }),
source: sourceReference, );
...(options.id ? { id: options.id } : {}),
...(options.ref ? { ref: options.ref } : {}),
}),
);
return { type: "single", data, schema: pluginSchema }; return { type: "single", data, schema: pluginSchema };
} }
async function update( export async function runPluginUpdateCommand(
pluginId: string | undefined, pluginId: string | undefined,
options: PluginOptions, options: PluginOptions,
_command: Command, _command: Command,
): Promise<ListResult<PluginSourceUpdateItem>> { ): Promise<ListResult<UpdateOutcome>> {
if ((pluginId === undefined) === (options.all !== true)) { const data = await withPluginUpdateClient(options.daemonTarget, (client) =>
throw new Error("Choose one plugin ID or pass --all"); reviewPluginUpdates(
} client,
const data = await withPluginSourceClient(options.daemonTarget, (client) => { ...options, pluginId },
client.updatePluginSources(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 }; return { type: "list", data, schema: pluginUpdateSchema };
} }
@@ -204,19 +215,26 @@ export function createPluginCommand(): Command {
plugin plugin
.command("install") .command("install")
.alias("add") .alias("add")
.description("Trust and install a plugin from a directory or Git repository") .description("Trust and install a plugin from a directory, Git repository, or npm package")
.argument("<source>", "Host directory, Git source, or Git source:plugin/path") .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("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
.option("--ref <ref>", "Git branch, tag, or commit") .option("--ref <ref>", "Git branch, tag, or commit")
.option("--path <path>", "Legacy form of the :plugin/path source suffix"), .option("--path <path>", "Legacy form of the :plugin/path source suffix"),
).action(withOutput(install)); ).action(withOutput(runPluginInstallCommand));
addJsonAndDaemonHostOptions( addJsonAndDaemonHostOptions(
plugin plugin
.command("update") .command("update")
.description("Fetch and install Git-managed plugin updates") .description("Review and update installed plugins")
.argument("[id]") .argument("[id]")
.option("--all", "Update every Git-managed plugin"), .option("--all", "Review all configured plugins")
).action(withOutput(update)); .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) { for (const action of ["reload", "enable", "disable"] as const) {
addJsonAndDaemonHostOptions( addJsonAndDaemonHostOptions(
plugin.command(action).description(`${action} a plugin`).argument("<id>"), plugin.command(action).description(`${action} a plugin`).argument("<id>"),
@@ -58,6 +58,16 @@ describe("plugin scaffold", () => {
name: "hello-plugin", name: "hello-plugin",
private: true, private: true,
version: "0.0.0", version: "0.0.0",
files: [
"paseo-plugin.json",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
"index.server.tsx",
"client/",
"server/",
"shared/",
],
scripts: { typecheck: "tsc --noEmit" }, scripts: { typecheck: "tsc --noEmit" },
devDependencies: { devDependencies: {
"@getpaseo/plugin": cliPackageJson.version, "@getpaseo/plugin": cliPackageJson.version,
@@ -147,6 +147,16 @@ export async function scaffoldPluginDirectory(
name: id, name: id,
private: true, private: true,
version: "0.0.0", version: "0.0.0",
files: [
"paseo-plugin.json",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
"index.server.tsx",
"client/",
"server/",
"shared/",
],
scripts: { typecheck: "tsc --noEmit" }, scripts: { typecheck: "tsc --noEmit" },
devDependencies: { devDependencies: {
"@getpaseo/plugin": version, "@getpaseo/plugin": version,
+21 -4
View File
@@ -3,7 +3,11 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { CommandError } from "../../output/index.js"; import type { CommandError } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js"; import { connectToDaemon } from "../../utils/client.js";
type PluginFeature = "pluginManagement" | "pluginLogs" | "pluginGitManagement"; type PluginFeature =
| "pluginManagement"
| "pluginLogs"
| "pluginSourceUpdates"
| "pluginSourceInstallation";
async function withPluginClient<T>( async function withPluginClient<T>(
target: DaemonTarget, target: DaemonTarget,
@@ -51,11 +55,24 @@ export async function withPluginSourceClient<T>(
target: DaemonTarget, target: DaemonTarget,
run: (client: DaemonClient) => Promise<T>, run: (client: DaemonClient) => Promise<T>,
): 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( return withPluginClient(
target, target,
"pluginGitManagement", "pluginSourceInstallation",
"Update the host to install and update Git plugins.", "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, run,
); );
} }
+102
View File
@@ -0,0 +1,102 @@
import type {
PluginUpdatePreview,
PluginUpdateResult,
PluginUpdateSelection,
PluginUpdateProposal,
} from "@getpaseo/protocol/messages";
interface UpdateClient {
previewPluginUpdates(input: {
pluginId?: string;
target?: PluginUpdateSelection;
}): Promise<PluginUpdatePreview[]>;
applyPluginUpdates(proposals: PluginUpdateProposal[]): Promise<PluginUpdateResult[]>;
}
export interface UpdateOptions {
pluginId?: string;
all?: boolean;
ref?: string;
version?: string;
check?: boolean;
yes?: boolean;
}
export interface UpdateInteraction {
interactive: boolean;
structured: boolean;
write(text: string): void;
confirm(message: string): Promise<boolean>;
}
export interface UpdateOutcome {
id: string;
outcome: PluginUpdatePreview["outcome"] | PluginUpdateResult["outcome"] | "declined";
current?: PluginUpdatePreview["current"];
target?: PluginUpdatePreview["target"];
links?: string[];
proposal?: PluginUpdateProposal;
plugin?: PluginUpdateResult["plugin"];
error?: string;
warning?: string;
}
export async function reviewPluginUpdates(
client: UpdateClient,
options: UpdateOptions,
interaction: UpdateInteraction,
): Promise<UpdateOutcome[]> {
if ((options.pluginId === undefined) === (options.all !== true))
throw new Error("Choose one plugin ID or pass --all");
if (options.ref && options.version) throw new Error("Choose --ref or --version, not both");
let target: PluginUpdateSelection | undefined;
if (options.ref) target = { kind: "git", ref: options.ref };
if (options.version) target = { kind: "npm", version: options.version };
if (target && options.all)
throw new Error("An explicit target requires one plugin ID, without --all");
const preview = await client.previewPluginUpdates({ pluginId: options.pluginId, target });
if (!interaction.structured) interaction.write(formatUpdatePreview(preview));
if (options.check) return preview;
const proposals = preview.flatMap((item) => (item.proposal ? [item.proposal] : []));
if (!proposals.length) return preview;
if (!options.yes && !target) {
if (interaction.structured || !interaction.interactive)
throw new Error("Confirmation required; rerun with --yes.");
if (
!(await interaction.confirm(
options.all ? "Update these plugins? [y/N] " : `Update ${options.pluginId}? [y/N] `,
))
) {
return preview.map((item) => (item.proposal ? { id: item.id, outcome: "declined" } : item));
}
}
const applied = new Map(
(await client.applyPluginUpdates(proposals)).map((item) => [item.id, item]),
);
return preview.map((item) => applied.get(item.id) ?? item);
}
export function formatUpdatePreview(items: PluginUpdatePreview[]): string {
return items
.map((item) => {
const current = item.current?.currentRevision ?? "unknown";
const target = item.target?.kind === "git" ? item.target.commit : item.target?.version;
let text: string;
switch (item.outcome) {
case "update":
text = `${current} → ${target}`;
break;
case "current":
text = `up to date (${current})`;
break;
case "installed-newer":
text = `installed ${current} is newer than latest ${target}; keeping installed version`;
break;
case "local":
text = "local directory; use Reload after editing";
break;
case "error":
text = item.error ?? "Check failed";
break;
}
return `${item.id}: ${text}\n${item.links.map((link) => `Review: ${link}\n`).join("")}`;
})
.join("\n");
}
+20
View File
@@ -2,9 +2,29 @@ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createCli } from "./cli";
import { createCliParseArgv } from "./run"; import { createCliParseArgv } from "./run";
describe("runCli", () => { 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", () => { it("defaults an empty CLI invocation to onboard", () => {
expect( expect(
createCliParseArgv({ createCliParseArgv({
@@ -31,7 +31,10 @@ describe("global command options", () => {
it("rejects conflicting parent and command selectors", async () => { it("rejects conflicting parent and command selectors", async () => {
await expect( await expect(
parseHost(["--host", "first:6767", "ls", "--host", "last:6767"]), parseHost(["--host", "first:6767", "ls", "--host", "last:6767"]),
).rejects.toMatchObject({ code: "TARGET_AMBIGUOUS" }); ).rejects.toMatchObject({
code: "TARGET_AMBIGUOUS",
message: "Conflicting duplicate --host selectors.",
});
}); });
it("lets local-only commands ignore a global host", async () => { it("lets local-only commands ignore a global host", async () => {
+4 -2
View File
@@ -47,8 +47,10 @@ export function withGlobalOptions<Args extends unknown[], Result>(
if (typeof value === "string") selectors[key].add(value); if (typeof value === "string") selectors[key].add(value);
} }
} }
if (selectors.home.size > 1 || selectors.host.size > 1) for (const key of ["home", "host"] as const) {
throw { code: "TARGET_AMBIGUOUS", message: "Conflicting duplicate daemon selectors." }; if (selectors[key].size > 1)
throw { code: "TARGET_AMBIGUOUS", message: `Conflicting duplicate --${key} selectors.` };
}
const localOnly = localCommands.has(command); const localOnly = localCommands.has(command);
options.daemonTarget = selectDaemonTarget(options, process.env, localOnly); options.daemonTarget = selectDaemonTarget(options, process.env, localOnly);
mergedArgs[mergedArgs.length - 2] = options; mergedArgs[mergedArgs.length - 2] = options;
@@ -2,6 +2,10 @@
import { resolveCliVersion } from "../../src/version.js"; import { resolveCliVersion } from "../../src/version.js";
import { readPluginManifest } from "../../../server/src/server/plugins/manifest.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 assert from "node:assert/strict";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; 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> { async function main(): Promise<void> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-cli-e2e-")); const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-cli-e2e-"));
const gitDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-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 { try {
const scaffold = path.join(context.workDir, "authored-plugin"); const scaffold = path.join(context.workDir, "authored-plugin");
const init = await context.paseo(["plugin", "init", scaffold, "--json"]); const init = await context.paseo(["plugin", "init", scaffold, "--json"]);
@@ -82,7 +87,7 @@ async function main(): Promise<void> {
const gitInstall = await context.paseo([ const gitInstall = await context.paseo([
"plugin", "plugin",
"add", "add",
pathToFileURL(gitDirectory).href, `git:${pathToFileURL(gitDirectory).href}`,
"--json", "--json",
]); ]);
assert.equal(gitInstall.exitCode, 0, gitInstall.stderr); 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].status, "running");
assert.equal(JSON.parse(status.stdout)[0].commit, JSON.parse(gitInstall.stdout).commit); 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(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"); const buildMarker = path.join(context.workDir, "incompatible-build-ran");
await writeFile( await writeFile(
path.join(gitDirectory, "paseo-plugin.json"), path.join(gitDirectory, "paseo-plugin.json"),
@@ -122,9 +127,15 @@ async function main(): Promise<void> {
); );
await git(gitDirectory, ["add", "-A"]); await git(gitDirectory, ["add", "-A"]);
await git(gitDirectory, ["commit", "-m", "requires a future Paseo"]); 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.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" }); await assert.rejects(readFile(buildMarker), { code: "ENOENT" });
const retained = await context.paseo(["plugin", "ls", "git-cli-e2e", "--json"]); const retained = await context.paseo(["plugin", "ls", "git-cli-e2e", "--json"]);
assert.equal(retained.exitCode, 0, retained.stderr); assert.equal(retained.exitCode, 0, retained.stderr);
@@ -160,11 +171,54 @@ async function main(): Promise<void> {
assert.equal(removeGit.exitCode, 0, removeGit.stderr); assert.equal(removeGit.exitCode, 0, removeGit.stderr);
const removeScaffold = await context.paseo(["plugin", "remove", "authored-plugin", "--json"]); const removeScaffold = await context.paseo(["plugin", "remove", "authored-plugin", "--json"]);
assert.equal(removeScaffold.exitCode, 0, removeScaffold.stderr); 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"]); const list = await context.paseo(["plugin", "ls", "--json"]);
assert.equal(list.exitCode, 0, list.stderr); assert.equal(list.exitCode, 0, list.stderr);
assert.deepEqual(JSON.parse(list.stdout), []); assert.deepEqual(JSON.parse(list.stdout), []);
} finally { } finally {
await context.stop(); await context.stop();
await registry.close();
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
await rm(gitDirectory, { recursive: true, force: true }); await rm(gitDirectory, { recursive: true, force: true });
} }
+100 -4
View File
@@ -2341,7 +2341,7 @@ test("file context action RPCs correlate success and error responses", async ()
}); });
}); });
test("serializes plugin source suffixes through the legacy path field", async () => { test("sends plugin source identifiers unchanged for daemon-host resolution", async () => {
const mock = createMockTransport(); const mock = createMockTransport();
const client = new DaemonClient({ const client = new DaemonClient({
url: "ws://test", url: "ws://test",
@@ -2353,7 +2353,7 @@ test("serializes plugin source suffixes through the legacy path field", async ()
clients.push(client); clients.push(client);
const connectPromise = client.connect(); const connectPromise = client.connect();
mock.triggerOpen(); mock.triggerOpen({ features: { pluginSourceInstallation: true } });
await connectPromise; await connectPromise;
const installPromise = client.installPluginSource({ const installPromise = client.installPluginSource({
@@ -2363,8 +2363,7 @@ test("serializes plugin source suffixes through the legacy path field", async ()
expect(request).toEqual({ expect(request).toEqual({
type: "plugin.source.install.request", type: "plugin.source.install.request",
requestId: expect.any(String), requestId: expect.any(String),
source: "owner/repository", source: "owner/repository:plugins/review",
pluginPath: "plugins/review",
}); });
mock.triggerMessage( mock.triggerMessage(
wrapSessionMessage({ wrapSessionMessage({
@@ -6803,3 +6802,100 @@ test("uploadFile stops sending chunks when the connection closes between sends",
.some((frame) => frame.opcode === FileTransferOpcode.FileEnd), .some((frame) => frame.opcode === FileTransferOpcode.FileEnd),
).toBe(false); ).toBe(false);
}); });
test("rejects source installation on an older host before sending a request", async () => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "source-gate",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({ features: { pluginGitManagement: true } });
await connecting;
const sentBefore = transport.sent.length;
await expect(client.installPluginSource({ source: "npm:review" })).rejects.toThrow(
"Update the host",
);
expect(transport.sent.length).toBe(sentBefore);
});
test("reviewed plugin updates gate before requests and preserve exact proposal data", async () => {
for (const supported of [false, true]) {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "reviewed-updates",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({
features: {
pluginGitManagement: true,
pluginSourceInstallation: true,
pluginSourceUpdates: supported,
},
});
await connecting;
const proposal = {
id: "review",
expected: {
identity: { kind: "npm" as const, packageName: "review", pluginPath: "." },
installationRoot: "/plugins/review/version-root",
revision: "1.0.0",
},
target: {
kind: "npm" as const,
version: "1.1.0",
resolved: "https://registry.npmjs.org/review/-/review-1.1.0.tgz",
integrity: "sha512-test",
},
};
if (!supported) {
const sent = transport.sent.length;
await expect(client.previewPluginUpdates()).rejects.toThrow("Update the host");
await expect(client.applyPluginUpdates([proposal])).rejects.toThrow("Update the host");
expect(transport.sent.length).toBe(sent);
continue;
}
const checking = client.previewPluginUpdates({ pluginId: "review" });
const check = parseSentFrame(transport.sent.at(-1));
expect(check).toMatchObject({
type: "plugin.source.update.preview.request",
pluginId: "review",
});
const preview = { id: "review", outcome: "update", links: [], proposal };
transport.triggerMessage(
wrapSessionMessage({
type: "plugin.source.update.preview.response",
payload: { requestId: check.requestId, plugins: [preview] },
}),
);
await expect(checking).resolves.toEqual([preview]);
const applying = client.applyPluginUpdates([proposal]);
const apply = parseSentFrame(transport.sent.at(-1));
expect(apply).toEqual({
type: "plugin.source.update.apply.request",
requestId: expect.any(String),
proposals: [proposal],
});
transport.triggerMessage(
wrapSessionMessage({
type: "plugin.source.update.apply.response",
payload: {
requestId: apply.requestId,
plugins: [{ id: "review", outcome: "error", error: "changed since review" }],
},
}),
);
await expect(applying).resolves.toEqual([
{ id: "review", outcome: "error", error: "changed since review" },
]);
}
});
+42 -4
View File
@@ -12,7 +12,6 @@ import type { z } from "zod";
import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
import type { ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { ClientCapability } from "@getpaseo/protocol/client-capabilities";
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
import { import {
AgentCreateFailedStatusPayloadSchema, AgentCreateFailedStatusPayloadSchema,
AgentCreatedStatusPayloadSchema, AgentCreatedStatusPayloadSchema,
@@ -119,6 +118,10 @@ import type {
PluginLogEntry, PluginLogEntry,
PluginSourceStatusItem, PluginSourceStatusItem,
PluginSourceUpdateItem, PluginSourceUpdateItem,
PluginUpdateSelection,
PluginUpdateProposal,
PluginUpdatePreview,
PluginUpdateResult,
AgentSkillSelection, AgentSkillSelection,
AgentSkillsStatus, AgentSkillsStatus,
AgentSkillsSaveResult, AgentSkillsSaveResult,
@@ -5297,18 +5300,21 @@ export class DaemonClient {
ref?: string; ref?: string;
}): Promise<PluginListItem> { }): Promise<PluginListItem> {
const requestId = this.createRequestId(); 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({ const payload = await this.sendCorrelatedSessionRequest({
requestId, requestId,
message: { message: {
type: "plugin.source.install.request", type: "plugin.source.install.request",
requestId, requestId,
source: reference.source, source: input.source,
...(reference.pluginPath ? { pluginPath: reference.pluginPath } : {}),
...(input.id ? { id: input.id } : {}), ...(input.id ? { id: input.id } : {}),
...(input.ref ? { ref: input.ref } : {}), ...(input.ref ? { ref: input.ref } : {}),
}, },
responseType: "plugin.source.install.response", responseType: "plugin.source.install.response",
timeout: 5 * 60 * 1000,
}); });
return payload.plugin; return payload.plugin;
} }
@@ -5327,6 +5333,38 @@ export class DaemonClient {
return payload.plugins; 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[]> { async updatePluginSources(pluginId?: string): Promise<PluginSourceUpdateItem[]> {
const requestId = this.createRequestId(); const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({ const payload = await this.sendCorrelatedSessionRequest({
+1 -1
View File
@@ -9,7 +9,7 @@ describe("plugin runtime-entry migration guide", () => {
if (!context) throw new Error("PluginClientContext was not found"); if (!context) throw new Error("PluginClientContext was not found");
const methods = [...context.matchAll(/^\s+(add[A-Z]\w*)\(/gm)].map((match) => match[1]); const methods = [...context.matchAll(/^\s+(add[A-Z]\w*)\(/gm)].map((match) => match[1]);
const migration = await readFile( const migration = await readFile(
fileURLToPath(new URL("../../../public-docs/plugins/v0.8/migration.md", import.meta.url)), fileURLToPath(new URL("../../../public-docs/plugins/migration.md", import.meta.url)),
"utf8", "utf8",
); );
const table = migration.slice( const table = migration.slice(
@@ -1,3 +1,4 @@
import { z } from "zod";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
MutableDaemonConfigSchema, MutableDaemonConfigSchema,
@@ -96,6 +97,22 @@ describe("plugin protocol compatibility", () => {
type: "plugin.directory.install.response", type: "plugin.directory.install.response",
payload: { payload: {
requestId: "request-1", requestId: "request-1",
plugin: {
id: "example-work",
description: "Reviews changes before merge",
path: "/plugins/example",
enabled: true,
status: "running",
},
},
}).type,
).toBe("plugin.directory.install.response");
expect(
SessionOutboundMessageSchema.parse({
type: "plugin.directory.install.response",
payload: {
requestId: "request-old-shape",
plugin: { plugin: {
id: "example-work", id: "example-work",
path: "/plugins/example", path: "/plugins/example",
@@ -277,3 +294,37 @@ describe("plugin protocol compatibility", () => {
).toThrow(); ).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] },
});
});
+94
View File
@@ -1494,6 +1494,53 @@ export const PluginSourceInstallRequestSchema = z.object({
pluginPath: z.string().min(1).optional(), 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({ export const PluginSourceStatusRequestSchema = z.object({
type: z.literal("plugin.source.status.request"), type: z.literal("plugin.source.status.request"),
requestId: z.string(), requestId: z.string(),
@@ -3201,6 +3248,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PluginSourceInstallRequestSchema, PluginSourceInstallRequestSchema,
PluginSourceStatusRequestSchema, PluginSourceStatusRequestSchema,
PluginSourceUpdateRequestSchema, PluginSourceUpdateRequestSchema,
PluginUpdatePreviewRequestSchema,
PluginUpdateApplyRequestSchema,
PluginReloadRequestSchema, PluginReloadRequestSchema,
PluginEnableRequestSchema, PluginEnableRequestSchema,
PluginDisableRequestSchema, PluginDisableRequestSchema,
@@ -3576,6 +3625,10 @@ export const ServerInfoStatusPayloadSchema = z
pluginLogs: z.boolean().optional(), pluginLogs: z.boolean().optional(),
// COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26. // COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26.
pluginGitManagement: z.boolean().optional(), pluginGitManagement: z.boolean().optional(),
// COMPAT(pluginSourceInstallation): added in v0.8.0; remove gate after 2027-03-16 once daemon floor supports source identifiers.
pluginSourceInstallation: z.boolean().optional(),
// COMPAT(pluginSourceUpdates): added in v0.8.0; remove gate after 2027-03-16 once daemon floor supports reviewed updates.
pluginSourceUpdates: z.boolean().optional(),
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20. // 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, // A daemon that predates this flag keeps `addTheme` in the server bundle it compiles,
// so a theme plugin cannot start there at all. // so a theme plugin cannot start there at all.
@@ -6527,12 +6580,22 @@ export const PluginCatalogGetResponseSchema = z.object({
export const PluginStatusSchema = z.enum(["running", "disabled", "failed"]); export const PluginStatusSchema = z.enum(["running", "disabled", "failed"]);
export type PluginStatus = z.infer<typeof PluginStatusSchema>; 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({ export const PluginListItemSchema = z.object({
id: PluginIdSchema, id: PluginIdSchema,
description: z.string().optional(),
path: z.string(), path: z.string(),
enabled: z.boolean(), enabled: z.boolean(),
status: PluginStatusSchema, status: PluginStatusSchema,
source: z.enum(["directory", "git"]).optional(), source: z.enum(["directory", "git"]).optional(),
npm: PluginNpmInstallationSchema.optional(),
installation: PluginInstallationSchema.optional(),
remote: z.string().optional(), remote: z.string().optional(),
ref: z.string().optional(), ref: z.string().optional(),
commit: z.string().optional(), commit: z.string().optional(),
@@ -6580,6 +6643,8 @@ export const PluginSourceInstallResponseSchema = z.object({
export const PluginSourceStatusItemSchema = z.object({ export const PluginSourceStatusItemSchema = z.object({
id: PluginIdSchema, id: PluginIdSchema,
source: z.enum(["directory", "git"]), source: z.enum(["directory", "git"]),
npm: PluginNpmInstallationSchema.optional(),
installation: PluginInstallationSchema.optional(),
path: z.string(), path: z.string(),
remote: z.string().optional(), remote: z.string().optional(),
ref: z.string().optional(), ref: z.string().optional(),
@@ -6609,6 +6674,33 @@ export const PluginSourceUpdateResponseSchema = z.object({
payload: z.object({ requestId: z.string(), plugins: z.array(PluginSourceUpdateItemSchema) }), 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) { function pluginActionResponse<const Type extends string>(type: Type) {
return z.object({ return z.object({
type: z.literal(type), type: z.literal(type),
@@ -6688,6 +6780,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
PluginSourceInstallResponseSchema, PluginSourceInstallResponseSchema,
PluginSourceStatusResponseSchema, PluginSourceStatusResponseSchema,
PluginSourceUpdateResponseSchema, PluginSourceUpdateResponseSchema,
PluginUpdatePreviewResponseSchema,
PluginUpdateApplyResponseSchema,
PluginReloadResponseSchema, PluginReloadResponseSchema,
PluginEnableResponseSchema, PluginEnableResponseSchema,
PluginDisableResponseSchema, PluginDisableResponseSchema,
@@ -4,7 +4,7 @@ import { assertPluginCompatibility, validatePluginRequirements } from "./plugin-
describe.each(["daemon", "app"] as const)("plugin requirements on %s", (runtime) => { describe.each(["daemon", "app"] as const)("plugin requirements on %s", (runtime) => {
it("rejects legacy manifests on the first breaking release with migration instructions", () => { it("rejects legacy manifests on the first breaking release with migration instructions", () => {
expect(() => assertPluginCompatibility({ id: "legacy", version: "0.8.0", runtime })).toThrow( expect(() => assertPluginCompatibility({ id: "legacy", version: "0.8.0", runtime })).toThrow(
/legacy.*<0\.8\.0.*0\.8\.0.*https:\/\/paseo.sh\/docs\/plugins\/v0.8\/migration/, /legacy.*<0\.8\.0.*0\.8\.0.*https:\/\/paseo.sh\/docs\/plugins\/migration/,
); );
}); });
+1 -1
View File
@@ -33,7 +33,7 @@ export function assertPluginCompatibility(input: PluginCompatibilityInput): void
if (satisfies(version, range) || satisfies(stableCore, range)) return; if (satisfies(version, range) || satisfies(stableCore, range)) return;
const action = const action =
input.requirements?.paseo === undefined input.requirements?.paseo === undefined
? "This plugin has no requirements.paseo and targets Paseo before 0.8. Ask its author to migrate it: https://paseo.sh/docs/plugins/v0.8/migration" ? "This plugin has no requirements.paseo and targets Paseo before 0.8. Ask its author to migrate it: https://paseo.sh/docs/plugins/migration"
: `Use a compatible plugin version or update the ${input.runtime}.`; : `Use a compatible plugin version or update the ${input.runtime}.`;
throw new Error( throw new Error(
`Plugin "${input.id}" requires Paseo ${range}. Your ${input.runtime} is ${input.version}. ${action}`, `Plugin "${input.id}" requires Paseo ${range}. Your ${input.runtime} is ${input.version}. ${action}`,
@@ -3,6 +3,16 @@ import { parsePluginSourceReference } from "./plugin-source-reference.js";
describe("plugin source references", () => { describe("plugin source references", () => {
it.each([ 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", "owner/repository", undefined],
["owner/repository:plugins/review", "owner/repository", "plugins/review"], ["owner/repository:plugins/review", "owner/repository", "plugins/review"],
[ [
@@ -1,9 +1,17 @@
import type { PluginInstallation } from "./messages.js";
export interface PluginSourceReference { export interface PluginSourceReference {
source: string; source: string;
pluginPath: string | undefined; pluginPath: string | undefined;
} }
export function parsePluginSourceReference(reference: string): PluginSourceReference { 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(":"); const separator = reference.lastIndexOf(":");
if (separator === -1) return { source: reference, pluginPath: undefined }; if (separator === -1) return { source: reference, pluginPath: undefined };
@@ -18,7 +26,7 @@ export function parsePluginSourceReference(reference: string): PluginSourceRefer
if (pathStart === -1 || separator < pathStart) { if (pathStart === -1 || separator < pathStart) {
return { source: reference, pluginPath: undefined }; return { source: reference, pluginPath: undefined };
} }
} else { } else if (options.scp) {
const scpSeparator = reference.match(/^[^/@\s]+@[^:\s]+:/)?.[0].length; const scpSeparator = reference.match(/^[^/@\s]+@[^:\s]+:/)?.[0].length;
if (scpSeparator !== undefined && separator === scpSeparator - 1) { if (scpSeparator !== undefined && separator === scpSeparator - 1) {
return { source: reference, pluginPath: undefined }; return { source: reference, pluginPath: undefined };
@@ -45,3 +53,17 @@ function isPortableRelativePluginPath(pluginPath: string): boolean {
if (/^[A-Za-z]:/.test(pluginPath) || pluginPath.includes(":")) return false; if (/^[A-Za-z]:/.test(pluginPath) || pluginPath.includes(":")) return false;
return pluginPath.split(/[\\/]/).every((part) => part !== "" && part !== "." && part !== ".."); return pluginPath.split(/[\\/]/).every((part) => part !== "" && part !== "." && part !== "..");
} }
export function formatPluginIdentity(identity: PluginInstallation["identity"]): string {
if (identity.kind === "directory") return identity.path;
const source = identity.kind === "npm" ? `npm:${identity.packageName}` : `git:${identity.remote}`;
return identity.pluginPath === "." ? source : `${source}:${identity.pluginPath}`;
}
export function formatPluginInstallation(installation: PluginInstallation): string {
const identity = formatPluginIdentity(installation.identity);
const revision =
installation.identity.kind === "git"
? installation.currentRevision?.slice(0, 12)
: installation.currentRevision;
return revision ? `${identity} · ${revision}` : identity;
}
+1
View File
@@ -99,6 +99,7 @@
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9", "rotating-file-stream": "^3.2.9",
"semver": "^7.8.5",
"sherpa-onnx-node": "1.12.28", "sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2", "strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2", "tree-kill": "^1.2.2",
@@ -137,6 +137,8 @@ const INBOUND_PERMISSION = {
"plugin.source.install.request": "daemon.manage", "plugin.source.install.request": "daemon.manage",
"plugin.source.status.request": "daemon.manage", "plugin.source.status.request": "daemon.manage",
"plugin.source.update.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.add.request": "workspace.manage",
"project.create_directory.request": "workspace.manage", "project.create_directory.request": "workspace.manage",
"project.github.clone.request": "workspace.manage", "project.github.clone.request": "workspace.manage",
@@ -357,6 +359,8 @@ const OUTBOUND_PERMISSION = {
"plugin.source.install.response": "daemon.manage", "plugin.source.install.response": "daemon.manage",
"plugin.source.status.response": "daemon.manage", "plugin.source.status.response": "daemon.manage",
"plugin.source.update.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", pong: "daemon.read",
"project.add.response": "workspace.manage", "project.add.response": "workspace.manage",
"project.create_directory.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 { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
@@ -7,6 +8,10 @@ import { afterEach, describe, expect, it } from "vitest";
import { DaemonConfigStore } from "../daemon-config-store.js"; import { DaemonConfigStore } from "../daemon-config-store.js";
import { PluginService } from "./index.js"; import { PluginService } from "./index.js";
import { ManagedPluginSources } from "./managed-source.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"; import { runGitCommand } from "../../utils/run-git-command.js";
const roots: string[] = []; const roots: string[] = [];
@@ -309,7 +314,7 @@ describe("PluginService", () => {
await writeFile(path.join(directory, "index.server.ts"), "export default broken syntax !!!"); await writeFile(path.join(directory, "index.server.ts"), "export default broken syntax !!!");
await expect(service.reloadPlugin("work-plugin")).rejects.toThrow(); await expect(service.reloadPlugin("work-plugin")).rejects.toThrow();
expect(service.catalog()).toEqual([]); expect(service.catalog()).toEqual([]);
expect(service.listPlugins()).toEqual([ expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({ id: "work-plugin", status: "failed", error: expect.any(String) }), expect.objectContaining({ id: "work-plugin", status: "failed", error: expect.any(String) }),
]); ]);
@@ -321,6 +326,39 @@ describe("PluginService", () => {
await service.stopAllPlugins(); await service.stopAllPlugins();
}, 20_000); }, 20_000);
it("lists manifest descriptions for running and disabled plugins without hiding malformed entries", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const running = await createPlugin("running", "export default () => () => {};");
const disabled = await createPlugin("disabled", "export default () => () => {};");
const malformed = await createPlugin("malformed", "export default () => () => {};");
await writeFile(
path.join(running, "paseo-plugin.json"),
JSON.stringify({ id: "running", description: "Runs checks" }),
);
await writeFile(
path.join(disabled, "paseo-plugin.json"),
JSON.stringify({ id: "disabled", description: "Waits until enabled" }),
);
await writeFile(path.join(malformed, "paseo-plugin.json"), "{");
const service = createService(home, {
running: { source: "directory", path: running },
disabled: { source: "directory", path: disabled, enabled: false },
malformed: { source: "directory", path: malformed },
});
await service.start();
expect(
(await service.listPlugins()).map(({ id, description }) => ({ id, description })),
).toEqual([
{ id: "disabled", description: "Waits until enabled" },
{ id: "malformed", description: undefined },
{ id: "running", description: "Runs checks" },
]);
await service.stopAllPlugins();
});
it("prefers an existing directory and installs its selected plugin subdirectory", async () => { it("prefers an existing directory and installs its selected plugin subdirectory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home); roots.push(home);
@@ -345,6 +383,150 @@ describe("PluginService", () => {
await service.stopAllPlugins(); await service.stopAllPlugins();
}, 20_000); }, 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 () => { it("keeps the running commit when a Git update build command fails", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home); roots.push(home);
@@ -391,12 +573,14 @@ describe("PluginService", () => {
await runGitCommand(["add", "-A"], { cwd: repository }); await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "broken update"], { 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(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
expect(service.catalog()).toEqual([ expect(service.catalog()).toEqual([
expect.objectContaining({ id: "git-update", clientBundle: expect.any(String) }), expect.objectContaining({ id: "git-update", clientBundle: expect.any(String) }),
]); ]);
expect(service.listPlugins()).toEqual([ expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({ expect.objectContaining({
id: "git-update", id: "git-update",
path: installedPath, path: installedPath,
@@ -485,8 +669,8 @@ describe("PluginService", () => {
await runGitCommand(["add", "-A"], { cwd: repository }); await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "prepared update"], { cwd: repository }); await runGitCommand(["commit", "-m", "prepared update"], { cwd: repository });
await expect(service.updateSources("prepared-git-plugin")).resolves.toEqual([ await expect(applyReviewedUpdate(service, "prepared-git-plugin")).resolves.toEqual([
expect.objectContaining({ id: "prepared-git-plugin", updated: true }), expect.objectContaining({ id: "prepared-git-plugin", outcome: "updated" }),
]); ]);
expect(events).toEqual(["validate:prepared", "start", "validate:updated", "start"]); expect(events).toEqual(["validate:prepared", "start", "validate:updated", "start"]);
await service.stopAllPlugins(); await service.stopAllPlugins();
@@ -545,7 +729,7 @@ describe("PluginService", () => {
managedSources, managedSources,
}); });
await service.start(); await service.start();
expect(service.listPlugins()).toEqual([ expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({ id: "failed-update", status: "failed" }), expect.objectContaining({ id: "failed-update", status: "failed" }),
]); ]);
@@ -556,17 +740,87 @@ describe("PluginService", () => {
await runGitCommand(["add", "-A"], { cwd: repository }); await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "fixed"], { cwd: repository }); await runGitCommand(["commit", "-m", "fixed"], { cwd: repository });
await expect(service.updateSources("failed-update")).resolves.toEqual([ await expect(applyReviewedUpdate(service, "failed-update")).resolves.toEqual([
expect.objectContaining({ id: "failed-update", updated: true }), expect.objectContaining({ id: "failed-update", outcome: "updated" }),
]); ]);
expect(starts).toHaveLength(2); expect(starts).toHaveLength(2);
expect(starts[1]).not.toBe(initial.directory); expect(starts[1]).not.toBe(initial.directory);
expect(service.listPlugins()).toEqual([ expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({ id: "failed-update", path: starts[1], status: "running" }), expect.objectContaining({ id: "failed-update", path: starts[1], status: "running" }),
]); ]);
await service.stopAllPlugins(); await service.stopAllPlugins();
}, 30_000); }, 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 () => { it("disables and removes a plugin without touching its source directory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home); roots.push(home);
@@ -592,7 +846,7 @@ export default function contribute(plugin: unknown) {
expect(await readFile(cleanupFile, "utf8")).toBe("cleaned"); expect(await readFile(cleanupFile, "utf8")).toBe("cleaned");
await service.removePlugin("cleanup-plugin"); await service.removePlugin("cleanup-plugin");
expect(service.listPlugins()).toEqual([]); expect(await service.listPlugins()).toMatchObject([]);
await expect(stat(directory)).resolves.toMatchObject({}); await expect(stat(directory)).resolves.toMatchObject({});
await service.stopAllPlugins(); await service.stopAllPlugins();
}, 20_000); }, 20_000);
@@ -624,7 +878,7 @@ export default function contribute(plugin: unknown) {
); );
store.patch({ pluginsEnabled: true }); store.patch({ pluginsEnabled: true });
await service.reloadPlugin("first"); await service.reloadPlugin("first");
expect(service.listPlugins().map(({ id, status }) => ({ id, status }))).toEqual([ expect((await service.listPlugins()).map(({ id, status }) => ({ id, status }))).toEqual([
{ id: "first", status: "running" }, { id: "first", status: "running" },
{ id: "second", status: "running" }, { id: "second", status: "running" },
]); ]);
@@ -651,7 +905,7 @@ export default function contribute(plugin: unknown) {
await service.stopAllPlugins(); await service.stopAllPlugins();
expect(service.catalog()).toEqual([]); expect(service.catalog()).toEqual([]);
expect(service.listPlugins()).toEqual([ expect(await service.listPlugins()).toMatchObject([
{ id: "slow", path: "/plugins/slow", enabled: true, status: "disabled" }, { id: "slow", path: "/plugins/slow", enabled: true, status: "disabled" },
]); ]);
}); });
@@ -761,17 +1015,17 @@ export default function contribute(plugin: unknown) {
"Plugin entry points are missing", "Plugin entry points are missing",
); );
await expect(service.installDirectory({ path: legacy })).rejects.toThrow( await expect(service.installDirectory({ path: legacy })).rejects.toThrow(
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/v0.8/migration", "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
); );
await expect(service.installDirectory({ path: startupFailure })).rejects.toThrow( await expect(service.installDirectory({ path: startupFailure })).rejects.toThrow(
"startup exploded", "startup exploded",
); );
expect(service.listPlugins()).toEqual([ expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({ expect.objectContaining({
id: "legacy-plugin", id: "legacy-plugin",
status: "failed", status: "failed",
error: error:
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/v0.8/migration", "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
}), }),
expect.objectContaining({ expect.objectContaining({
id: "missing-entry", id: "missing-entry",
@@ -815,3 +1069,282 @@ export default function contribute(plugin: unknown) {
expect((await readFile(cleanupFile, "utf8")).trim().split("\n")).toHaveLength(4); 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!]);
}
+125 -76
View File
@@ -10,6 +10,10 @@ import {
type PluginSource, type PluginSource,
type PluginSourceStatusItem, type PluginSourceStatusItem,
type PluginSourceUpdateItem, type PluginSourceUpdateItem,
type PluginUpdateSelection,
type PluginUpdateProposal,
type PluginUpdatePreview,
type PluginUpdateResult,
} from "@getpaseo/protocol/messages"; } from "@getpaseo/protocol/messages";
import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference"; import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
@@ -145,11 +149,11 @@ export class PluginService {
}); });
} }
listPlugins(): PluginListItem[] { async listPlugins(): Promise<PluginListItem[]> {
const config = this.configStore.get(); const config = this.configStore.get();
const running = new Set(this.runtime.catalog().map((plugin) => plugin.id)); const running = new Set(this.runtime.catalog().map((plugin) => plugin.id));
return Object.entries(config.plugins ?? {}) const plugins = await Promise.all(
.map(([id, source]) => { Object.entries(config.plugins ?? {}).map(async ([id, source]) => {
const enabled = source.enabled !== false; const enabled = source.enabled !== false;
const item: PluginListItem = { const item: PluginListItem = {
id, id,
@@ -161,19 +165,24 @@ export class PluginService {
running: running.has(id), running: running.has(id),
}), }),
}; };
const managed = this.managedSources?.get(id); const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null);
if (managed) { if (manifest?.description) item.description = manifest.description;
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"; item.source = "git";
const remote = this.managedSources?.displayRemote(id); item.remote = item.installation.identity.remote;
if (remote) item.remote = remote; item.commit = item.installation.currentRevision;
item.ref = managed.requestedRef ?? managed.trackingBranch ?? managed.commit;
item.commit = managed.commit;
} }
const error = this.errors.get(id); const error = this.errors.get(id);
if (error && item.status === "failed") item.error = error; if (error && item.status === "failed") item.error = error;
return item; return item;
}) }),
.sort((left, right) => left.id.localeCompare(right.id)); );
return plugins.sort((left, right) => left.id.localeCompare(right.id));
} }
getLogs(pluginId: string): PluginLogEntry[] { getLogs(pluginId: string): PluginLogEntry[] {
@@ -203,7 +212,7 @@ export class PluginService {
this.configStore.patch({ plugins: sources }); this.configStore.patch({ plugins: sources });
if (this.canPublish(pluginId)) await this.startConfigured(pluginId); if (this.canPublish(pluginId)) await this.startConfigured(pluginId);
this.notify(pluginId); this.notify(pluginId);
const installed = this.requireItem(pluginId); const installed = await this.requireItem(pluginId);
if (installed.status === "failed") { if (installed.status === "failed") {
throw new Error(installed.error ?? `Plugin failed to start: ${pluginId}`); throw new Error(installed.error ?? `Plugin failed to start: ${pluginId}`);
} }
@@ -221,12 +230,14 @@ export class PluginService {
ref?: string; ref?: string;
}): Promise<PluginListItem> { }): Promise<PluginListItem> {
const directDirectory = path.resolve(input.source); const directDirectory = path.resolve(input.source);
const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source);
const directInfo = await stat(directDirectory).catch(() => null); const directInfo = await stat(directDirectory).catch(() => null);
const reference = directInfo?.isDirectory() const reference = directInfo?.isDirectory()
? { source: input.source, pluginPath: undefined } ? { source: input.source, pluginPath: undefined }
: parsePluginSourceReference(input.source); : parsePluginSourceReference(input.source);
const directory = path.resolve(reference.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 (info?.isDirectory()) {
if (input.ref) throw new Error("Plugin --ref is only valid for Git sources"); if (input.ref) throw new Error("Plugin --ref is only valid for Git sources");
const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath); const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath);
@@ -242,32 +253,36 @@ export class PluginService {
let pluginId: string; let pluginId: string;
try { try {
await this.checkRequirements(candidate.directory); await this.checkRequirements(candidate.directory);
await runPluginBuild(candidate.directory, candidate.build, this.logger);
pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId); pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId);
if (this.configStore.get().plugins?.[pluginId]) { if (this.configStore.get().plugins?.[pluginId]) {
throw new Error( throw new Error(
`Plugin ID "${pluginId}" is already configured; choose another ID with --id`, `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); candidate = await managedSources.place(pluginId, candidate);
await this.validateCandidate(candidate); await this.validateCandidate(candidate);
await managedSources.verifyCandidate(pluginId, candidate);
} catch (error) { } catch (error) {
await managedSources.discard(candidate); await managedSources.discard(candidate);
throw error; throw error;
} }
const sources = { this.patchSource(pluginId, { source: "directory", path: candidate.directory, enabled: true });
...this.configStore.get().plugins, try {
[pluginId]: { source: "directory" as const, path: candidate.directory, enabled: true }, if (this.canPublish(pluginId)) await this.startExplicit(pluginId, candidate.directory);
}; managedSources.commit(pluginId, candidate.record);
managedSources.commit(pluginId, candidate.record); } catch (error) {
this.configStore.patch({ plugins: sources }); await this.stopPlugin(pluginId);
if (this.canPublish(pluginId)) await this.startConfigured(pluginId); const sources = { ...this.configStore.get().plugins };
this.notify(pluginId); delete sources[pluginId];
const installed = this.requireItem(pluginId); this.configStore.patch({ plugins: sources });
if (installed.status === "failed") { await managedSources.discard(candidate);
throw new Error(installed.error ?? `Plugin failed to start: ${pluginId}`); this.errors.delete(pluginId);
this.notify(pluginId);
throw error;
} }
return installed; this.notify(pluginId);
return this.requireItem(pluginId);
}); });
} }
@@ -286,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 () => { return this.enqueue(async () => {
const sources = this.configStore.get().plugins ?? {}; const results: PluginUpdateResult[] = [];
const ids = pluginId for (const proposal of proposals) {
? [pluginId] try {
: Object.keys(sources).filter((id) => this.managedSources?.get(id)); results.push(await this.updateSource(proposal));
const updates: PluginSourceUpdateItem[] = []; } catch (error) {
for (const id of ids.sort()) updates.push(await this.updateSource(id)); results.push({
return updates; id: proposal.id,
outcome: "error",
error: error instanceof Error ? error.message : String(error),
});
}
}
return results;
}); });
} }
@@ -530,59 +584,51 @@ export class PluginService {
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); 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 managedSources = this.requireManagedSources();
const source = this.requireSource(pluginId); const source = this.requireSource(pluginId);
const previous = managedSources.get(pluginId); let candidate = await managedSources.prepareUpdate(proposal, source.path);
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;
try { try {
await this.checkRequirements(candidate.directory); await this.checkRequirements(candidate.directory);
await runPluginBuild(candidate.directory, candidate.build, this.logger); await runPluginBuild(candidate.directory, candidate.build, this.logger);
candidate = await managedSources.place(pluginId, candidate); candidate = await managedSources.place(pluginId, candidate);
await this.validateCandidate(candidate); await this.validateCandidate(candidate);
await managedSources.verifyCandidate(pluginId, candidate, proposal.target);
await managedSources.assertCurrent(proposal, this.requireSource(pluginId).path);
} catch (error) { } catch (error) {
await managedSources.discard(candidate); await managedSources.discard(candidate);
throw error; throw error;
} }
const current = this.configStore.get().plugins?.[pluginId]; const current = this.requireSource(pluginId);
if (!current) {
await managedSources.discard(candidate);
throw new Error(`Plugin is no longer configured: ${pluginId}`);
}
const isRunning = this.runtime.catalog().some((plugin) => plugin.id === pluginId); const isRunning = this.runtime.catalog().some((plugin) => plugin.id === pluginId);
const isEnabled = current.enabled !== false; const shouldActivate =
const isGloballyEnabled = this.configStore.get().pluginsEnabled === true; current.enabled !== false && this.configStore.get().pluginsEnabled === true;
const shouldActivate = isEnabled && isGloballyEnabled;
if (shouldActivate) { if (shouldActivate) {
if (isRunning) await this.stopPlugin(pluginId); if (isRunning) await this.stopPlugin(pluginId);
try { try {
await this.startExplicit(pluginId, candidate.directory); await this.startExplicit(pluginId, candidate.directory);
} catch (error) { } catch (error) {
this.errors.delete(pluginId); let recoveryError: unknown;
const latest = this.configStore.get().plugins?.[pluginId]; try {
const isStillConfigured = latest !== undefined; this.errors.delete(pluginId);
const isStillEnabled = isStillConfigured && latest.enabled !== false; if (isRunning && this.canPublish(pluginId))
const isStillGloballyEnabled = this.configStore.get().pluginsEnabled === true; await this.startExplicit(pluginId, source.path);
const canRestore = isRunning && isStillEnabled && isStillGloballyEnabled; } catch (restoreError) {
if (canRestore) await this.startExplicit(pluginId, source.path); recoveryError = restoreError;
await managedSources.discard(candidate); } finally {
this.notify(pluginId); 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; throw error;
} }
} }
const activatedSource = this.configStore.get().plugins?.[pluginId]; const activatedSource = this.configStore.get().plugins?.[pluginId];
if (!activatedSource) { if (!activatedSource) {
await this.stopPlugin(pluginId); await this.stopPlugin(pluginId);
@@ -590,28 +636,31 @@ export class PluginService {
throw new Error(`Plugin is no longer configured: ${pluginId}`); throw new Error(`Plugin is no longer configured: ${pluginId}`);
} }
this.patchSource(pluginId, { ...activatedSource, path: candidate.directory }); this.patchSource(pluginId, { ...activatedSource, path: candidate.directory });
managedSources.commit(pluginId, candidate.record);
await managedSources.removeVersion(previous);
this.errors.delete(pluginId); this.errors.delete(pluginId);
this.notify(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 { return {
id: pluginId, id: pluginId,
previousCommit: previous.commit, outcome: "updated",
currentCommit: candidate.record.commit, plugin: await this.requireItem(pluginId),
commits: prepared.commits, ...(warning ? { warning } : {}),
updated: true,
}; };
} }
private validateCandidate(candidate: ManagedPluginCandidate): Promise<void> { private validateCandidate(candidate: ManagedPluginCandidate): Promise<void> {
if (!this.runtime.validatePlugin) { 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); return this.runtime.validatePlugin(candidate.directory);
} }
private requireManagedSources(): ManagedPluginSources { 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; return this.managedSources;
} }
@@ -634,8 +683,8 @@ export class PluginService {
}); });
} }
private requireItem(pluginId: string): PluginListItem { private async requireItem(pluginId: string): Promise<PluginListItem> {
const item = this.listPlugins().find((plugin) => plugin.id === pluginId); const item = (await this.listPlugins()).find((plugin) => plugin.id === pluginId);
if (!item) throw new Error(`Plugin is not configured: ${pluginId}`); if (!item) throw new Error(`Plugin is not configured: ${pluginId}`);
return item; return item;
} }
@@ -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 { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
@@ -41,24 +43,72 @@ describe("managed Git plugin sources", () => {
const sources = new ManagedPluginSources(home); const sources = new ManagedPluginSources(home);
const failure = sources.prepareInstall({ 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/); 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 repository = await createRepository();
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-home-")); const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-home-"));
roots.push(home); roots.push(home);
const remote = pathToFileURL(repository).href; const remote = pathToFileURL(repository).href;
const sources = new ManagedPluginSources(home); 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"); expect(candidate.defaultId).toBe("managed-example");
candidate = await sources.place("managed-example", candidate); candidate = await sources.place("managed-example", candidate);
sources.commit("managed-example", candidate.record); 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( await writeFile(
path.join(repository, "index.server.ts"), path.join(repository, "index.server.ts"),
"export default () => () => { new Date(); };\n", "export default () => () => { new Date(); };\n",
@@ -68,29 +118,160 @@ describe("managed Git plugin sources", () => {
expect(status).toMatchObject({ expect(status).toMatchObject({
currentCommit: initial, currentCommit: initial,
latestCommit: latest, latestCommit: latest,
commitsBehind: 1,
updateAvailable: true, updateAvailable: true,
}); });
const prepared = await sources.prepareUpdate("managed-example", candidate.directory); const preview = await new ManagedPluginSources(home).preview(
expect(prepared.commits).toBe(1); "managed-example",
if (!prepared.candidate) throw new Error("Expected an update candidate"); candidate.directory,
const updated = await sources.place("managed-example", prepared.candidate); );
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); sources.commit("managed-example", updated.record);
expect(await readFile(path.join(updated.directory, "index.server.ts"), "utf8")).toContain( expect(await readFile(path.join(updated.directory, "index.server.ts"), "utf8")).toContain(
"new Date", "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 }); await runGitCommand(["tag", "v1", initial], { cwd: repository });
let pinned = await sources.prepareInstall({ source: remote, ref: "v1" }); let tagged = await sources.prepareInstall({ source: remote, ref: "v1" });
pinned = await sources.place("pinned-example", pinned); tagged = await sources.place("tagged-example", tagged);
sources.commit("pinned-example", pinned.record); sources.commit("tagged-example", tagged.record);
expect(await sources.status("pinned-example", pinned.directory)).toMatchObject({ expect(await sources.status("tagged-example", tagged.directory)).toMatchObject({
currentCommit: initial, currentCommit: initial,
latestCommit: initial, latestCommit: latest,
commitsBehind: 0, updateAvailable: true,
updateAvailable: false,
}); });
}, 30_000); }, 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 { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs"; 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 path from "node:path";
import { isDeepStrictEqual } from "node:util";
import compareVersions from "semver/functions/compare.js";
import { z } from "zod"; 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 { runGitCommand } from "../../utils/run-git-command.js";
import { ensurePrivateDirectory, writePrivateFileAtomicSync } from "../private-files.js"; import { ensurePrivateDirectory, writePrivateFileAtomicSync } from "../private-files.js";
import { readPluginManifest } from "./manifest.js"; import { readPluginManifest, type PluginManifest } from "./manifest.js";
import type { PluginManifest } from "./manifest.js"; import { acquireNpm, isNpmSource, readNpmArtifact, resolveNpm } from "./managed-source/npm.js";
const GIT_TIMEOUT_MS = 120_000; const GIT_TIMEOUT_MS = 120_000;
const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" } as const; const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" } as const;
// 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 const ManagedPluginRecordSchema = z.union([
.object({ z.object({ kind: z.literal("git").default("git"), remote: z.string().min(1) }),
remote: z.string().min(1), z.object({ kind: z.literal("npm") }),
requestedRef: z.string().min(1).nullable(), ]);
trackingBranch: z.string().min(1).nullable(), export type ManagedPluginRecord = z.infer<typeof ManagedPluginRecordSchema>;
commit: z.string().regex(/^[0-9a-f]{40,64}$/),
pluginPath: z.string(),
checkoutRoot: z.string().min(1),
})
.strict();
const ManagedPluginRecordsSchema = z.record(PluginIdSchema, ManagedPluginRecordSchema);
export interface ManagedPluginRecord extends z.infer<typeof ManagedPluginRecordSchema> {}
export interface ManagedPluginCandidate { export interface ManagedPluginCandidate {
build: PluginManifest["build"]; build: PluginManifest["build"];
defaultId: string; defaultId: string;
@@ -34,67 +34,138 @@ export interface ManagedPluginCandidate {
record: ManagedPluginRecord; record: ManagedPluginRecord;
versionRoot: string; versionRoot: string;
} }
interface InstallInput { interface InstallInput {
source: string; source: string;
ref?: string; ref?: string;
pluginPath?: string; pluginPath?: string;
} }
interface OwnedLocation {
interface RefResolution { versionRoot: string;
commit: string; sourceRoot: string;
requestedRef: string | null; identity: PluginInstallation["identity"];
trackingBranch: string | null;
} }
export class ManagedPluginSources { export class ManagedPluginSources {
private readonly root: string; private readonly root: string;
private readonly metadataPath: string; private readonly metadataPath: string;
private readonly records: Record<string, ManagedPluginRecord>; private readonly records: Record<string, ManagedPluginRecord>;
constructor(paseoHome: string) { constructor(paseoHome: string) {
this.root = path.join(paseoHome, "plugins"); this.root = path.resolve(paseoHome, "plugins");
this.metadataPath = path.join(this.root, "sources.json"); 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 { private locate(
return this.records[pluginId] ?? null; pluginId: string,
} configuredPath: string,
record = this.records[pluginId],
displayRemote(pluginId: string): string | null { ): OwnedLocation {
const remote = this.records[pluginId]?.remote; if (!record) throw new Error(`Plugin is a local directory: ${pluginId}`);
return remote ? redactRemoteCredentials(remote) : null; 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.
async prepareInstall(input: InstallInput): Promise<ManagedPluginCandidate> { // Git originally used <12-hex-commit>-<uuid>; the prefix is a directory name, never revision truth.
const remote = normalizeGitSource(input.source); const versionId =
const pluginPath = normalizePluginPath(input.pluginPath); record.kind === "git" ? (parts[0] ?? "").replace(/^[0-9a-f]{12}-/, "") : (parts[0] ?? "");
const stagingRoot = await this.createStagingRoot(); if (
try { !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(versionId) ||
const checkoutRoot = path.join(stagingRoot, "checkout"); parts.some((part) => part === "..")
await clone(remote, checkoutRoot); )
const resolution = await resolveRequestedRef(checkoutRoot, input.ref); throw new Error(`Plugin directory is outside its managed installation: ${pluginId}`);
await checkout(checkoutRoot, resolution.commit); const versionRoot = path.join(pluginRoot, parts[0]!);
const directory = path.resolve(checkoutRoot, pluginPath); if (record.kind === "git") {
assertPluginPath(checkoutRoot, directory); if (parts[1] !== "checkout")
const { id: defaultId, build } = await readPluginManifest(directory); throw new Error(`Invalid Git installation directory: ${pluginId}`);
return { return {
build, versionRoot,
defaultId, sourceRoot: path.join(versionRoot, "checkout"),
directory, identity: {
record: { kind: "git",
remote, remote: redactRemoteCredentials(record.remote),
requestedRef: resolution.requestedRef, pluginPath: parts.slice(2).join("/") || ".",
trackingBranch: resolution.trackingBranch,
commit: resolution.commit,
pluginPath,
checkoutRoot,
}, },
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) { } catch (error) {
await rm(stagingRoot, { recursive: true, force: true }); await rm(versionRoot, { recursive: true, force: true });
throw error; throw error;
} }
} }
@@ -103,144 +174,236 @@ export class ManagedPluginSources {
pluginId: string, pluginId: string,
candidate: ManagedPluginCandidate, candidate: ManagedPluginCandidate,
): Promise<ManagedPluginCandidate> { ): Promise<ManagedPluginCandidate> {
const pluginRoot = path.join(this.root, pluginId); const pluginRoot = path.join(this.root, PluginIdSchema.parse(pluginId));
ensurePrivateDirectory(pluginRoot); ensurePrivateDirectory(pluginRoot);
const versionRoot = path.join( await assertRealContainment(this.root, pluginRoot);
pluginRoot, const versionRoot = path.join(pluginRoot, randomUUID());
`${candidate.record.commit.slice(0, 12)}-${randomUUID()}`, const relativeDirectory = path.relative(candidate.versionRoot, candidate.directory);
);
await rename(candidate.versionRoot, versionRoot); await rename(candidate.versionRoot, versionRoot);
const checkoutRoot = path.join(versionRoot, "checkout"); return { ...candidate, directory: path.join(versionRoot, relativeDirectory), versionRoot };
const directory = path.resolve(checkoutRoot, candidate.record.pluginPath); }
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 { return {
...candidate, id: pluginId,
directory, outcome,
versionRoot, current,
record: { ...candidate.record, checkoutRoot }, 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> { async assertCurrent(proposal: PluginUpdateProposal, configuredPath: string): Promise<void> {
const record = this.records[pluginId]; const current = await this.describe(proposal.id, configuredPath);
if (!record) return { id: pluginId, source: "directory", path: configuredPath }; const location = this.locate(proposal.id, configuredPath);
if (!record.trackingBranch) { if (
return { !isDeepStrictEqual(current.identity, proposal.expected.identity) ||
id: pluginId, current.currentRevision !== proposal.expected.revision ||
source: "git", location.versionRoot !== proposal.expected.installationRoot
path: configuredPath, )
remote: redactRemoteCredentials(record.remote), throw new Error(`Plugin ${proposal.id} changed since review; check again`);
ref: record.requestedRef ?? record.commit, if (proposal.target.kind !== current.identity.kind)
currentCommit: record.commit, throw new Error("Update target does not match the installed source");
latestCommit: record.commit,
commitsBehind: 0,
updateAvailable: false,
};
}
await fetchOrigin(record.checkoutRoot);
const latestCommit = await revParse(
record.checkoutRoot,
`refs/remotes/origin/${record.trackingBranch}^{commit}`,
);
const commitsBehind = await countCommits(record.checkoutRoot, record.commit, latestCommit);
return {
id: pluginId,
source: "git",
path: configuredPath,
remote: redactRemoteCredentials(record.remote),
ref: record.requestedRef ?? record.trackingBranch,
currentCommit: record.commit,
latestCommit,
commitsBehind,
updateAvailable: latestCommit !== record.commit,
};
} }
async prepareUpdate( async prepareUpdate(
pluginId: string, proposal: PluginUpdateProposal,
configuredPath: string, configuredPath: string,
): Promise<{ candidate: ManagedPluginCandidate | null; commits: number }> { ): Promise<ManagedPluginCandidate> {
const record = this.records[pluginId]; await this.assertCurrent(proposal, configuredPath);
if (!record) throw new Error(`Plugin is not managed by Git: ${pluginId}`); const identity = this.locate(proposal.id, configuredPath).identity;
const status = await this.status(pluginId, configuredPath); const record = this.records[proposal.id];
const latestCommit = status.latestCommit; if (identity.kind === "git" && record?.kind === "git" && proposal.target.kind === "git")
if (!latestCommit || latestCommit === record.commit) return { candidate: null, commits: 0 }; return this.prepareInstall(
const candidate = await this.prepareCommit(record, latestCommit); {
return { candidate, commits: status.commitsBehind ?? 0 }; 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 { commit(pluginId: string, record: ManagedPluginRecord): void {
this.records[pluginId] = record; this.records[pluginId] = ManagedPluginRecordSchema.parse(record);
this.writeRecords(); this.writeRecords();
} }
async discard(candidate: ManagedPluginCandidate): Promise<void> { async discard(candidate: ManagedPluginCandidate): Promise<void> {
await rm(candidate.versionRoot, { recursive: true, force: true }); await rm(candidate.versionRoot, { recursive: true, force: true });
} }
async removeVersion(pluginId: string, configuredPath: string): Promise<void> {
async removeVersion(record: ManagedPluginRecord): Promise<void> { const location = this.locate(pluginId, configuredPath);
const versionRoot = path.dirname(record.checkoutRoot); await assertRealContainment(this.root, path.dirname(location.versionRoot));
const pluginRoot = path.dirname(versionRoot); await rm(location.versionRoot, { recursive: true, force: true });
const expectedRoot = path.resolve(this.root);
if (path.dirname(pluginRoot) !== expectedRoot) {
throw new Error(
`Managed plugin checkout is outside the plugin store: ${record.checkoutRoot}`,
);
}
await rm(versionRoot, { recursive: true, force: true });
} }
async remove(pluginId: string): Promise<void> { async remove(pluginId: string): Promise<void> {
if (!this.records[pluginId]) return; 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]; delete this.records[pluginId];
this.writeRecords(); 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> { private async createStagingRoot(): Promise<string> {
const stagingRoot = path.join(this.root, ".staging", randomUUID()); const stagingRoot = path.join(this.root, ".staging", randomUUID());
await mkdir(stagingRoot, { recursive: true, mode: 0o700 }); await mkdir(stagingRoot, { recursive: true, mode: 0o700 });
await assertRealContainment(this.root, stagingRoot);
return 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 { private writeRecords(): void {
writePrivateFileAtomicSync(this.metadataPath, `${JSON.stringify(this.records, null, 2)}\n`); 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 { 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); const github = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(source);
if (github) { if (github) {
const repository = github[2].replace(/\.git$/, ""); const repository = github[2].replace(/\.git$/, "");
@@ -337,29 +500,70 @@ async function checkout(checkoutRoot: string, commit: string): Promise<void> {
async function resolveRequestedRef( async function resolveRequestedRef(
checkoutRoot: string, checkoutRoot: string,
requestedRef: string | undefined, requestedRef: string | undefined,
): Promise<RefResolution> { ): Promise<string> {
if (!requestedRef) { if (!requestedRef) return revParse(checkoutRoot, "refs/remotes/origin/HEAD^{commit}");
const { stdout } = await runGitCommand(
["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
{ cwd: checkoutRoot },
);
const trackingBranch = stdout.trim().replace(/^origin\//, "");
const commit = await revParse(checkoutRoot, `refs/remotes/origin/${trackingBranch}^{commit}`);
return { commit, requestedRef: null, trackingBranch };
}
if (requestedRef.startsWith("-")) throw new Error("Plugin Git ref cannot start with '-'"); if (requestedRef.startsWith("-")) throw new Error("Plugin Git ref cannot start with '-'");
const branchRef = `refs/remotes/origin/${requestedRef}`; for (const ref of [
if (await refExists(checkoutRoot, branchRef)) { `refs/remotes/origin/${requestedRef}`,
const commit = await revParse(checkoutRoot, `${branchRef}^{commit}`); `refs/tags/${requestedRef}`,
return { commit, requestedRef, trackingBranch: requestedRef }; requestedRef,
]) {
if (await refExists(checkoutRoot, ref)) return revParse(checkoutRoot, `${ref}^{commit}`);
} }
const tagRef = `refs/tags/${requestedRef}`; return revParse(checkoutRoot, `${requestedRef}^{commit}`);
if (await refExists(checkoutRoot, tagRef)) { }
const commit = await revParse(checkoutRoot, `${tagRef}^{commit}`); async function resolveRemoteCommit(remote: string, cwd: string, ref?: string): Promise<string> {
return { commit, requestedRef, trackingBranch: null }; 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> { 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 }); const { stdout } = await runGitCommand(["rev-parse", "--verify", ref], { cwd });
return stdout.trim(); 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;
}
@@ -45,6 +45,26 @@ describe("plugin manifest", () => {
} }
}); });
it("reads an optional description", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "described", description: "Reviews changes before merge" }),
);
await expect(readPluginManifest(directory)).resolves.toEqual({
id: "described",
description: "Reviews changes before merge",
});
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "described", description: " " }),
);
await expect(readPluginManifest(directory)).rejects.toThrow();
});
it("accepts only non-empty argv arrays for build commands", async () => { it("accepts only non-empty argv arrays for build commands", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory); directories.push(directory);
@@ -11,6 +11,7 @@ const PluginBuildCommandSchema = z
const PluginManifestSchema = z const PluginManifestSchema = z
.object({ .object({
id: PluginIdSchema, id: PluginIdSchema,
description: z.string().trim().min(1).optional(),
requirements: PluginRequirementsSchema.strict().optional(), requirements: PluginRequirementsSchema.strict().optional(),
build: z.array(PluginBuildCommandSchema).min(1).optional(), build: z.array(PluginBuildCommandSchema).min(1).optional(),
}) })
@@ -77,10 +77,10 @@ it("marks pre-0.8 plugins failed on startup and recovers after migration and rel
const root = await directory(); const root = await directory();
await writePlugin(root); await writePlugin(root);
const { service } = await host("0.8.0", root); const { service } = await host("0.8.0", root);
expect(service.listPlugins()).toEqual([ expect(await service.listPlugins()).toEqual([
expect.objectContaining({ expect.objectContaining({
status: "failed", status: "failed",
error: expect.stringContaining("https://paseo.sh/docs/plugins/v0.8/migration"), error: expect.stringContaining("https://paseo.sh/docs/plugins/migration"),
}), }),
]); ]);
await writePlugin(root, ">=0.8.0"); await writePlugin(root, ">=0.8.0");
@@ -97,7 +97,7 @@ it("still requires entry migration when an old plugin adds a compatible requirem
await writeFile(path.join(root, "index.ts"), "export default () => () => {};"); await writeFile(path.join(root, "index.ts"), "export default () => () => {};");
const { service } = await host(); const { service } = await host();
await expect(service.installDirectory({ path: root })).rejects.toThrow( await expect(service.installDirectory({ path: root })).rejects.toThrow(
"https://paseo.sh/docs/plugins/v0.8/migration", "https://paseo.sh/docs/plugins/migration",
); );
}); });
@@ -120,8 +120,12 @@ it("rejects Git install and update before build commands, preserving the running
[process.execPath, "-e", 'require("node:fs").writeFileSync(process.argv[1], "ran")', marker], [process.execPath, "-e", 'require("node:fs").writeFileSync(process.argv[1], "ran")', marker],
]); ]);
await commit(); await commit();
await expect(service.updateSources("example")).rejects.toThrow("requires Paseo >=0.9.0"); const [preview] = await service.previewUpdates({ pluginId: "example" });
expect(service.listPlugins()).toEqual([installed]); expect(preview).toMatchObject({ outcome: "update" });
await expect(service.applyUpdates([preview!.proposal!])).resolves.toMatchObject([
{ id: "example", outcome: "error", error: expect.stringContaining("requires Paseo >=0.9.0") },
]);
expect(await service.listPlugins()).toEqual([installed]);
expect(service.catalog()).toHaveLength(1); expect(service.catalog()).toHaveLength(1);
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]); expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" });
@@ -129,5 +133,5 @@ it("rejects Git install and update before build commands, preserving the running
"requires Paseo >=0.9.0", "requires Paseo >=0.9.0",
); );
await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" });
expect(service.listPlugins()).toEqual([installed]); expect(await service.listPlugins()).toEqual([installed]);
}); });
@@ -1422,7 +1422,7 @@ export default function contribute(server: { registerProvider(provider: Provider
const runtime = createTestRuntime(); const runtime = createTestRuntime();
await expect(runtime.startPlugin("legacy", directory)).rejects.toThrow( await expect(runtime.startPlugin("legacy", directory)).rejects.toThrow(
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/v0.8/migration", "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
); );
}); });
@@ -276,7 +276,7 @@ async function resolveEntryPaths(directory: string): Promise<{
const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]); const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]);
if (legacyEntry) { if (legacyEntry) {
throw new Error( throw new Error(
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/v0.8/migration", "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
); );
} }
throw new Error( throw new Error(
+1 -1
View File
@@ -500,7 +500,7 @@ test("routes plugin requests and releases its owned catalog subscription on clea
return request; return request;
}, },
emit: () => {}, emit: () => {},
listPlugins: () => [plugin], listPlugins: async () => [plugin],
getLogs: () => [ getLogs: () => [
{ {
sequence: 1, sequence: 1,
+36 -5
View File
@@ -476,7 +476,7 @@ export interface SessionOptions {
pluginRuntime?: { pluginRuntime?: {
before: import("./plugins/lifecycle/index.js").PluginLifecycle["before"]; before: import("./plugins/lifecycle/index.js").PluginLifecycle["before"];
emit: import("./plugins/lifecycle/index.js").PluginLifecycle["emit"]; emit: import("./plugins/lifecycle/index.js").PluginLifecycle["emit"];
listPlugins(): import("@getpaseo/protocol/messages").PluginListItem[]; listPlugins(): Promise<import("@getpaseo/protocol/messages").PluginListItem[]>;
getLogs(pluginId: string): import("@getpaseo/protocol/messages").PluginLogEntry[]; getLogs(pluginId: string): import("@getpaseo/protocol/messages").PluginLogEntry[];
installDirectory(input: { installDirectory(input: {
path: string; path: string;
@@ -491,6 +491,13 @@ export interface SessionOptions {
statusSources( statusSources(
pluginId?: string, pluginId?: string,
): Promise<import("@getpaseo/protocol/messages").PluginSourceStatusItem[]>; ): 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( updateSources(
pluginId?: string, pluginId?: string,
): Promise<import("@getpaseo/protocol/messages").PluginSourceUpdateItem[]>; ): Promise<import("@getpaseo/protocol/messages").PluginSourceUpdateItem[]>;
@@ -2359,11 +2366,13 @@ export class Session {
private dispatchPluginMessage(msg: SessionInboundMessage): Promise<void> | undefined { private dispatchPluginMessage(msg: SessionInboundMessage): Promise<void> | undefined {
if (msg.type === "plugin.list.request") { if (msg.type === "plugin.list.request") {
this.emit({ return (this.pluginRuntime?.listPlugins() ?? Promise.resolve([])).then((plugins) => {
type: "plugin.list.response", this.emit({
payload: { requestId: msg.requestId, plugins: this.pluginRuntime?.listPlugins() ?? [] }, type: "plugin.list.response",
payload: { requestId: msg.requestId, plugins },
});
return undefined;
}); });
return undefined;
} }
if (msg.type === "plugin.logs.get.request") { if (msg.type === "plugin.logs.get.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
@@ -2467,6 +2476,28 @@ export class Session {
return undefined; 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 (msg.type === "plugin.source.update.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime.updateSources(msg.pluginId).then((plugins) => { return this.pluginRuntime.updateSources(msg.pluginId).then((plugins) => {
@@ -1721,6 +1721,8 @@ export class VoiceAssistantWebSocketServer {
plugins: true, plugins: true,
pluginManagement: true, pluginManagement: true,
pluginGitManagement: true, pluginGitManagement: true,
pluginSourceInstallation: true,
pluginSourceUpdates: true,
pluginLogs: true, pluginLogs: true,
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20. // COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20.
pluginThemes: true, pluginThemes: true,
+8 -8
View File
@@ -157,15 +157,15 @@ export function getDoc(slug: string): Doc | undefined {
return getDocs().find((d) => d.slug === slug); return getDocs().find((d) => d.slug === slug);
} }
const legacyPluginDocRedirects: Readonly<Record<string, string>> = {
"/docs/plugins/reference": "/docs/plugins/v0.7/reference",
"/docs/plugins/reference.md": "/docs/plugins/v0.7/reference.md",
"/docs/plugins/migration": "/docs/plugins/v0.8/migration",
"/docs/plugins/migration.md": "/docs/plugins/v0.8/migration.md",
};
export function getLegacyDocsRedirect(pathname: string): string | undefined { export function getLegacyDocsRedirect(pathname: string): string | undefined {
return legacyPluginDocRedirects[pathname]; const match =
/^\/docs\/plugins\/v0\.[78](?:(\.md)|\/(index|reference|providers|migration)(\.md)?)?\/?$/.exec(
pathname,
);
if (!match) return undefined;
const page = match[2] && match[2] !== "index" ? `/${match[2]}` : "";
const extension = match[1] || match[3] ? ".md" : "";
return `/docs/plugins${page}${extension}`;
} }
function formatLabel(segment: string): string { function formatLabel(segment: string): string {
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildDocsNavTree, getDocs, getLegacyDocsRedirect } from "./docs"; import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect } from "./docs";
describe("versioned plugin documentation", () => { describe("plugin documentation", () => {
it("keeps stable and beta releases together in the Plugins navigation", () => { it("groups current guides by task without version navigation", () => {
const plugins = buildDocsNavTree(getDocs()).find( const plugins = buildDocsNavTree(getDocs()).find(
(node) => node.type === "category" && node.label === "Plugins", (node) => node.type === "category" && node.label === "Plugins",
); );
@@ -10,35 +10,32 @@ describe("versioned plugin documentation", () => {
expect(plugins).toMatchObject({ expect(plugins).toMatchObject({
type: "category", type: "category",
children: [ children: [
{ type: "page", label: "Versions", href: "/docs/plugins" }, { type: "page", label: "Quickstart", href: "/docs/plugins" },
{ { type: "page", label: "Publishing", href: "/docs/plugins/publishing" },
type: "group", { type: "page", label: "Provider plugins", href: "/docs/plugins/providers" },
label: "Paseo v0.7 — Current", { type: "page", label: "Migration", href: "/docs/plugins/migration" },
href: "/docs/plugins/v0.7", { type: "page", label: "Reference", href: "/docs/plugins/reference" },
children: [{ type: "page", label: "Reference", href: "/docs/plugins/v0.7/reference" }],
},
{
type: "group",
label: "Paseo v0.8 — Beta",
href: "/docs/plugins/v0.8",
children: [
{ type: "page", label: "Provider plugins", href: "/docs/plugins/v0.8/providers" },
{ type: "page", label: "Migration", href: "/docs/plugins/v0.8/migration" },
{ type: "page", label: "Reference", href: "/docs/plugins/v0.8/reference" },
],
},
], ],
}); });
}); });
it("keeps existing plugin documentation URLs working", () => { it.each(["v0.7", "v0.8"])("redirects %s links to existing current pages", (version) => {
expect(getLegacyDocsRedirect("/docs/plugins/reference")).toBe("/docs/plugins/v0.7/reference"); for (const page of ["", "/index", "/reference", "/providers", "/migration"]) {
expect(getLegacyDocsRedirect("/docs/plugins/reference.md")).toBe( const target = `/docs/plugins${page === "/index" ? "" : page}`;
"/docs/plugins/v0.7/reference.md", expect(getDoc(target.slice("/docs/".length))).toBeDefined();
); for (const extension of ["", ".md"]) {
expect(getLegacyDocsRedirect("/docs/plugins/migration")).toBe("/docs/plugins/v0.8/migration"); const source = `/docs/plugins/${version}${page}${extension}`;
expect(getLegacyDocsRedirect("/docs/plugins/migration.md")).toBe( expect(getLegacyDocsRedirect(source)).toBe(`${target}${extension}`);
"/docs/plugins/v0.8/migration.md", expect(getLegacyDocsRedirect(`${source}/`)).toBe(`${target}${extension}`);
); }
}
});
it("serves canonical pages without redirect loops", () => {
for (const page of ["", "/publishing", "/reference", "/providers", "/migration"]) {
expect(getLegacyDocsRedirect(`/docs/plugins${page}`)).toBeUndefined();
expect(getLegacyDocsRedirect(`/docs/plugins${page}.md`)).toBeUndefined();
}
expect(getLegacyDocsRedirect("/docs/plugins/v0.8/unknown")).toBeUndefined();
}); });
}); });
@@ -20,7 +20,7 @@ The callback receives the request after earlier plugins have transformed it. The
preserve their settings; the explicit values replace only the named fields. Paseo validates and saves preserve their settings; the explicit values replace only the named fields. Paseo validates and saves
the resulting configuration before starting the agent. Existing agents are unchanged. the resulting configuration before starting the agent. Existing agents are unchanged.
See the [lifecycle reference](../../public-docs/plugins/v0.8/reference.md#lifecycle-hooks) for ordering See the [lifecycle reference](../../public-docs/plugins/reference.md#lifecycle-hooks) for ordering
and callback shapes. [Lifecycle actions](../lifecycle-actions) demonstrates provider switching and and callback shapes. [Lifecycle actions](../lifecycle-actions) demonstrates provider switching and
environment injection. Its provider-switch callback clears provider options when changing Codex to environment injection. Its provider-switch callback clears provider options when changing Codex to
Claude, so use these examples separately when trying the Codex-specific settings. Claude, so use these examples separately when trying the Codex-specific settings.
+2 -2
View File
@@ -24,8 +24,8 @@ The example contributes one header button at a time. Additional actions live und
The client entry owns the active example. [client/examples.tsx](client/examples.tsx) contains the The client entry owns the active example. [client/examples.tsx](client/examples.tsx) contains the
descriptors, updates, and React content. Return `.remove()` cleanup when the example moves or stops. descriptors, updates, and React content. Return `.remove()` cleanup when the example moves or stops.
See the [button reference](../../public-docs/plugins/v0.8/reference.md#button-descriptor) and See the [button reference](../../public-docs/plugins/reference.md#button-descriptor) and
[composer migration](../../public-docs/plugins/v0.8/migration.md#composer-pills). [composer migration](../../public-docs/plugins/migration.md#composer-pills).
From the repository root, `npm run typecheck --workspace=@getpaseo/plugin` checks this example From the repository root, `npm run typecheck --workspace=@getpaseo/plugin` checks this example
against the SDK. Existing plugin projects must update `@getpaseo/plugin` before running their own against the SDK. Existing plugin projects must update `@getpaseo/plugin` before running their own
+1 -1
View File
@@ -33,4 +33,4 @@ Claude agent.
See [agent configuration](../agent-configuration) for a focused example that injects MCP servers and See [agent configuration](../agent-configuration) for a focused example that injects MCP servers and
changes Codex sandbox and approval options while preserving other settings. changes Codex sandbox and approval options while preserving other settings.
See the [lifecycle reference](../../public-docs/plugins/v0.8/reference.md#lifecycle-hooks) for the API. See the [lifecycle reference](../../public-docs/plugins/reference.md#lifecycle-hooks) for the API.
+1 -1
View File
@@ -15,7 +15,7 @@ The same entries appear in the target daemon's `daemon.log`. The plugin log view
tail; use the daemon log for longer captures. Log lines are capped at 16 KiB, so a large timeline can tail; use the daemon log for longer captures. Log lines are capped at 16 KiB, so a large timeline can
be truncated. be truncated.
See the [lifecycle reference](../../public-docs/plugins/v0.8/reference.md#lifecycle-hooks) for callback See the [lifecycle reference](../../public-docs/plugins/reference.md#lifecycle-hooks) for callback
shapes and delivery rules. The real-provider test in shapes and delivery rules. The real-provider test in
`packages/server/src/server/plugins/lifecycle.real.e2e.test.ts` runs both examples on an isolated daemon `packages/server/src/server/plugins/lifecycle.real.e2e.test.ts` runs both examples on an isolated daemon
and saves evidence under `.dev/lifecycle-proof/`. and saves evidence under `.dev/lifecycle-proof/`.
+211
View File
@@ -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.
+22 -3
View File
@@ -1,10 +1,29 @@
# Modal UI example # 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/reference#plugin-sources)
for supported identifiers and preparation rules.
The examples demonstrate default and custom padding, a full-width body, author-owned ScrollView and 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 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/reference#host-ui) for the API contract.
The browser regression installs this exact example in an isolated daemon: 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 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. “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. dismissal, list scrolling and horizontal tabs together.
These captures show Android copy/paste and custom padding on browser and wide native layouts. These captures show Android copy/paste and custom padding on browser and wide native layouts.
+29
View File
@@ -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",
"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 -1
View File
@@ -2,5 +2,6 @@
"id": "modal-ui-example", "id": "modal-ui-example",
"requirements": { "requirements": {
"paseo": ">=0.8.0" "paseo": ">=0.8.0"
} },
"description": "Interactive modal forms, scrolling lists, and clipboard examples"
} }
+1 -1
View File
@@ -187,7 +187,7 @@ GitHub shorthand checks an existing host directory first. Append `:<directory>`
monorepo. `paseo plugin ls [id]` does not contact the remote. `paseo plugin logs <id>` returns the monorepo. `paseo plugin ls [id]` does not contact the remote. `paseo plugin logs <id>` returns the
plugin's recent daemon-side stdout and stderr. Add `--json` for structured entries, or run plugin's recent daemon-side stdout and stderr. Add `--json` for structured entries, or run
`paseo --host <target> plugin logs <id>` for another daemon. See the `paseo --host <target> plugin logs <id>` for another daemon. See the
[Plugin reference](/docs/plugins/v0.7/reference) for installation, trust, lifecycle, and log-retention [Plugin reference](/docs/plugins/reference) for installation, trust, lifecycle, and log-retention
behavior. behavior.
## Listing agents ## Listing agents
+289 -11
View File
@@ -1,21 +1,299 @@
--- ---
title: Paseo plugin documentation title: Plugin quickstart
description: Choose the plugin API documentation for your Paseo release. description: Build, install, share, and update a trusted Paseo plugin.
nav: Versions nav: Quickstart
order: 44 order: 44
category: Plugins category: Plugins
--- ---
# Paseo plugin documentation # Plugin quickstart
Choose the documentation that matches your Paseo release. The plugin API is experimental and can A plugin is a TypeScript project installed into one Paseo daemon. It can add
change between releases. [surfaces and sidebar items](/docs/plugins/reference#surfaces-and-sidebar-items),
[workspace panels](/docs/plugins/reference#workspace-panels),
[Command Center items](/docs/plugins/reference#command-center-items),
[slash commands](/docs/plugins/reference#slash-commands),
[composer pills](/docs/plugins/reference#composer-pills),
[timeline items](/docs/plugins/reference#timeline-items),
[themes](/docs/plugins/reference#contribute-a-theme),
[attachment sources](/docs/plugins/reference#add-a-composer-attachment-source), and
[daemon-side RPCs](/docs/plugins/reference#add-plugin-specific-backend-behavior). It can also
[connect a coding agent as a provider](/docs/plugins/providers). Client
contributions run on every Paseo client connected to that daemon, including mobile.
## [Paseo v0.7 — Current](/docs/plugins/v0.7) This guide scaffolds a plugin, runs it, and adds a workspace panel to it.
Build and maintain plugins for the latest stable Paseo release. ## Create a plugin
## [Paseo v0.8 — Beta](/docs/plugins/v0.8) Use an absolute path on the daemon machine:
Build plugins for Paseo 0.8 beta, with separate client and server entries. Existing plugin authors should ```bash
follow the [v0.8 migration guide](/docs/plugins/v0.8/migration). paseo plugin init /absolute/path/to/workspace-plugin
cd /absolute/path/to/workspace-plugin
npm install
```
`init` writes a strict TypeScript project and does not run the package manager. `npm install` adds
development dependencies for typechecking and tests only; Paseo supplies the plugin SDK, React,
React Native, TanStack Query, and Zod at runtime.
The scaffold is a working plugin: a sidebar surface with a button that asks the daemon for a
greeting through an RPC.
```text
workspace-plugin/
paseo-plugin.json # plugin ID and supported Paseo versions
index.client.tsx # runs in the Paseo app
index.server.ts # runs in a daemon subprocess
client/greeting.tsx # the surface component
client/web.ts # the only file allowed to touch browser APIs
server/greeting.ts # the RPC handler
shared/greeting.ts # the RPC contract, imported by both
package.json
tsconfig.json
```
Each entry default-exports one function that registers contributions and returns a cleanup
function. `index.client.tsx` registers the surface and the sidebar item that opens it:
```tsx
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { GreetingSurface } from "./client/greeting";
export default function contribute(client: PluginClientContext) {
client.addSurface("greeting", GreetingSurface);
client.addSidebarItem({
id: "greeting",
title: "Greeting",
icon: "MessageCircle",
surface: "greeting",
});
return () => {};
}
```
`index.server.ts` registers the handler for the contract in `shared/greeting.ts`:
```ts
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { createGreeting } from "./server/greeting";
import { greetingRpc } from "./shared/greeting";
export default function contribute(server: PluginServerContext) {
server.handle(greetingRpc, createGreeting);
return () => {};
}
```
The directory is the boundary. Code under `client/` compiles only into the app bundle, code under
`server/` only into the daemon bundle, and `shared/` into both. Importing across that line, adding
a code file at the root, or importing a `node:` module from client code is a compile error. A
plugin with no daemon-side work can omit `index.server.ts`; a plugin with no UI can omit
`index.client.tsx`.
Client code runs on phones as well as in browsers. The project typechecks without the DOM library,
so `document` and `window` are errors outside `client/web.ts`, which shows how to gate a browser
API behind `Platform.OS` with a native fallback. See
[Cross-platform rules](/docs/plugins/reference#cross-platform-rules) before writing UI.
## Install and try it
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.
Turn on **Enable plugins** under **Settings → Plugins** on the daemon you are installing into. It is
the global switch for every plugin on that daemon. It is also the root `pluginsEnabled` field in the
daemon's `config.json`; after editing the file, apply it with `paseo reload --json`. An automated
tool must read the current value and get your explicit permission before turning it on.
Then typecheck and install:
```bash
npm run typecheck
paseo plugin install /absolute/path/to/workspace-plugin
paseo plugin ls
```
Or install the directory from the app:
1. Open **Settings → Plugins** on the target host.
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, npm packages, and plugin subdirectories. See
[Plugin sources](/docs/plugins/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.
Each installed plugin row shows its status and description. Use its switch to enable or disable it,
and open its three-dot menu for settings, logs, reload, and removal.
`paseo plugin ls` should report the plugin as `running`. Open Paseo, choose **Greeting** in the
sidebar, and press **Create greeting**. The message comes back from the daemon subprocess through
the RPC.
If the sidebar item is missing, check that **Enable plugins** is on, the plugin is `running`, and
the client is viewing the host you installed into. `paseo plugin logs workspace-plugin` shows the
daemon-side output, including load errors.
## Add a workspace panel
A workspace panel opens as a tab next to agents, terminals, and files. Create `client/overview.tsx`:
```tsx
import { type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { Text, View } from "react-native";
export function WorkspaceOverview({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
const workspace = useWorkspace(workspaceId, ({ name, directory }) => ({
name,
directory,
}));
const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
gap: layout.compact ? 8 : 12,
backgroundColor: theme.colors.surface0,
},
title: { color: theme.colors.foreground, fontSize: layout.compact ? 20 : 24 },
label: { color: theme.colors.foregroundMuted },
detail: { color: theme.colors.foreground },
}),
[theme, layout.compact],
);
return (
<View style={styles.screen}>
<Text style={styles.title}>{workspace?.name}</Text>
<Text style={styles.label}>Directory</Text>
<Text style={styles.detail}>{workspace?.directory}</Text>
</View>
);
}
```
`useWorkspace` reads the fields the panel renders from the app's cached state, without an RPC and
without re-rendering when unrelated fields change. Every `Text` takes its color from
`theme.colors`, and `layout.compact` drives spacing, so the panel works in every Paseo theme and on
phones. See [Theme and layout](/docs/plugins/reference#theme-and-layout) for the token list.
Register the panel and a Command Center item that opens it by adding to `index.client.tsx`:
```tsx
import { WorkspaceOverview } from "./client/overview";
// Inside contribute(client), after the existing registrations:
client.addWorkspacePanel({
id: "overview",
title: "Workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
locations: ["workspace", "explorer"],
Component: WorkspaceOverview,
});
client.addCommandCenterItem({
id: "open-overview",
title: "Open workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
onSelect({ openPanel }) {
openPanel("overview");
},
});
```
`icon` is a [Lucide](https://lucide.dev/icons/) icon name.
## Edit and reload
Source changes take effect only when you reload the plugin:
```bash
npm run typecheck
paseo plugin reload workspace-plugin
```
A reload stops the old plugin, runs its cleanup, compiles the current source, and starts it again.
A failed reload stays failed and reports its error in `paseo plugin ls`; fix the source and reload
again.
Open a workspace, press **⌘K** on macOS or **Ctrl+K** on Windows and Linux, and choose **Open
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/reference#plugin-sources) for identifier syntax. To share your own plugin, follow
[Publish a plugin](/docs/plugins/publishing).
Plugins published in a Git repository install by shorthand or URL:
```bash
paseo plugin add owner/repository
paseo plugin add https://gitlab.com/group/repository.git
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. `--ref` selects the initial
Git content once; omitting it installs the default HEAD. See the [source reference](/docs/plugins/reference#plugin-sources) for exact syntax.
```bash
paseo plugin ls
paseo plugin update workspace-plugin
paseo plugin update --all
```
`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/reference#cli-reference) for explicit targets and bulk updates.
## Read backend logs
Daemon-side handlers and cleanup can use normal Node logging:
```ts
console.log("Refreshing issues");
console.error("Issue refresh failed", error);
```
Read the recent output by opening the plugin's three-dot menu under **Settings → Plugins** and
selecting **Logs**, or use the CLI:
```bash
paseo plugin logs workspace-plugin
paseo plugin logs workspace-plugin --json
```
The tail includes `[paseo]` loading, ready, stopping, and stopped entries, plus compilation and load
failures, and it survives reloads and crashes. Client-side output stays in the app. See
[Debug backend output](/docs/plugins/reference#debug-backend-output) for retention and what not to
log.
## Next
- [Publish a plugin](/docs/plugins/publishing): share through npm, a private registry, or Git.
- [Build a provider plugin](/docs/plugins/providers): connect an agent directly or through ACP,
render provider-owned timeline items, test it, and publish it.
- [Plugin reference](/docs/plugins/reference): every contribution type, its fields, the runtime
modules, hosts, and the CLI.
- [Migrate a plugin to runtime entries](/docs/plugins/migration): move a plugin written against the
single `index.ts` entry, step by step.
- [TypeScript SDK](/docs/sdk): the workspace, agent, provider, and config API available as `paseo`
in client and server code.
@@ -2,13 +2,13 @@
title: Migrate a plugin to runtime entries title: Migrate a plugin to runtime entries
description: Mechanical migration from a mixed plugin entry to explicit client and server entries. description: Mechanical migration from a mixed plugin entry to explicit client and server entries.
nav: Migration nav: Migration
order: 48 order: 47
category: Plugins category: Plugins
--- ---
# Migrate a plugin to runtime entries # Migrate a plugin to runtime entries
> **For Paseo v0.8 beta.** This migration is not required for Paseo v0.7. Use this guide to update plugins written for Paseo 0.7 or earlier to the current client/server entry format, introduced in 0.8.
Give this page to a coding agent with the plugin directory as its working directory. Execute the Give this page to a coding agent with the plugin directory as its working directory. Execute the
steps in order. Do not keep a compatibility entry. steps in order. Do not keep a compatibility entry.
@@ -285,12 +285,11 @@ After migrating the entries and imports, add the minimum runtime version to `pas
} }
``` ```
Keep your existing ID and build commands. Missing `requirements.paseo` means `<0.8.0`, so Paseo 0.8 Keep your existing ID and build commands. Missing `requirements.paseo` means `<0.8.0`, so Paseo 0.8 and later
rejects the plugin even if its files have been moved. Adding the field alone does not migrate the reject the plugin even if its files have been moved. Adding the field alone does not migrate the
code. Update the local `@getpaseo/plugin` development dependency to the version you target and code. Update the local `@getpaseo/plugin` development dependency to the version you target and
reinstall dependencies before typechecking. reinstall dependencies before typechecking.
For a 0.8 beta, use its explicit version in the SDK dependency and `>=0.8.0` in the manifest.
See [requirements](reference#requirements) for range and prerelease semantics. See [requirements](reference#requirements) for range and prerelease semantics.
## 8. Verify the migration ## 8. Verify the migration
@@ -2,18 +2,16 @@
title: Build a provider plugin title: Build a provider plugin
description: Add a coding agent to Paseo directly or adapt an ACP agent. description: Add a coding agent to Paseo directly or adapt an ACP agent.
nav: Provider plugins nav: Provider plugins
order: 47 order: 46
category: Plugins category: Plugins
--- ---
# Build a provider plugin # Build a provider plugin
> **For Paseo v0.8 beta.** Start with the Start with the [plugin quickstart](/docs/plugins) if you have not built a Paseo plugin before.
> [plugin quickstart](/docs/plugins/v0.8) if you have not built a Paseo plugin before.
A provider plugin connects a coding agent to Paseo without adding it to Paseo core. Publish the A provider plugin connects a coding agent to Paseo without adding it to Paseo core. [Publish the plugin](/docs/plugins/publishing) on npm or in a Git repository so users can
plugin in a Git repository and users can install and update it with `paseo plugin add` and install it with `paseo plugin install`.
`paseo plugin update`.
Choose one implementation path: Choose one implementation path:
@@ -398,15 +396,8 @@ Test the provider against the real agent, not only mocked frames:
5. render every custom timeline item on desktop and mobile-width layouts; 5. render every custom timeline item on desktop and mobile-width layouts;
6. reload and remove the plugin while a session is active and confirm the session terminates. 6. reload and remove the plugin while a session is active and confirm the session terminates.
Push the plugin to a Git repository. Users install it with: Follow [Publish a plugin](/docs/plugins/publishing) to distribute the provider through npm or Git.
Keep agent compatibility and releases in the plugin project.
```bash See the [plugin reference](/docs/plugins/reference#providers) for the exact runtime and SVG
paseo plugin add owner/repository
paseo plugin update my-provider-plugin
```
Keep vendor compatibility and releases in that repository. Paseo core should only change when the
provider boundary cannot express a user-facing capability shared by more than one provider.
See the [plugin reference](/docs/plugins/v0.8/reference#providers) for the exact runtime and SVG
rules. rules.
+166
View File
@@ -0,0 +1,166 @@
---
title: Publish a plugin
description: Share a Paseo plugin on npm, through a private registry, or from a Git repository.
nav: Publishing
order: 45
category: Plugins
---
# Publish a plugin
Publish your plugin so other people can install and use it in Paseo. Start with a working
[plugin project](/docs/plugins), then choose where to share it:
- [npm](#publish-on-npm): publish a package on the public npm registry.
- [GitHub or Git](#share-through-github-or-git): let users install from a repository.
## Publish on npm
The scaffold prepares the package files and development dependencies. You choose the package name
and release version.
### 1. Set your package name and version
From the plugin directory, set your package details and allow publication. Replace `@acme` with your npm scope:
```bash
npm pkg set name=@acme/paseo-review version=1.0.0
npm pkg delete private
```
### 2. Check and publish
```bash
npm run typecheck
npm pack --dry-run
npm publish --access public
```
Check the pack output includes any assets you added to the project.
### 3. Test the published plugin
On a daemon host with npm available:
```bash
paseo plugin install npm:@acme/paseo-review@1.0.0
```
Users can also paste `npm:@acme/paseo-review` into **Settings → Plugins → Plugin source**.
:::example[Package configuration]
The scaffold includes this `files` list in `package.json`:
```json
{
"files": [
"paseo-plugin.json",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
"index.server.tsx",
"client/",
"server/",
"shared/"
]
}
```
- Add any assets stored outside these directories to `files`.
- Keep the scaffold's SDK and host libraries in `devDependencies`.
- Add other runtime libraries with `npm install <package>`. Paseo installs their dependencies too.
- The npm package name identifies the source. The manifest's `id` identifies the installed plugin.
See the [project reference](/docs/plugins/reference#project-files) for entry points and runtime boundaries.
:::
### Plugins with a build step
Paseo compiles TypeScript. An ordinary plugin needs no separate build before publication.
If your plugin generates files, include the generated output in the package.
**Installation scripts do not run automatically.** If a dependency needs host-specific setup,
declare a [preparation command](/docs/plugins/reference#cli-reference).
:::example[Generated files and dependencies]
- Generate code and assets before running `npm publish`.
- Keep generated JavaScript in its runtime directory and import it from the TypeScript entry.
- Include generated assets in `files`.
- Keep host-provided modules external when producing your own bundle.
- Remove Git-only dependency-install commands from the published manifest; npm installation already
installs production dependencies.
Paseo skips npm lifecycle scripts during installation, including dependency scripts. For example,
a native dependency that needs rebuilding requires an explicit preparation command.
:::
:::example[Publish a private package with GitHub Packages]
You can publish a company plugin to GitHub Packages. Follow the npm steps above, using your
organization's scope, and replace the publish command with:
```bash
npm publish --registry=https://npm.pkg.github.com
```
[Configure GitHub authentication and package access](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry)
before publishing.
To install the plugin, configure npm **on the daemon host, as the user running Paseo**:
1. Add your organization's registry to `~/.npmrc`:
```ini
@acme:registry=https://npm.pkg.github.com
```
2. Log in with your GitHub username and a personal access token (classic) as the password.
The token needs `read:packages` and access to the package.
```bash
npm login --scope=@acme --auth-type=legacy --registry=https://npm.pkg.github.com
```
3. Install the plugin:
```bash
paseo plugin install npm:@acme/paseo-review
```
Paseo uses the host's npm registry settings and credentials for installation and updates.
In the app, enter only the source identifier.
:::
## Share through GitHub or Git
Push the plugin project to a repository. Users can install it with:
```bash
paseo plugin install github:acme/paseo-review
```
For another Git host:
```bash
paseo plugin install git:https://git.example.com/acme/paseo-review.git
```
If your plugin has runtime npm dependencies, commit `package-lock.json` and add a preparation
command to `paseo-plugin.json`:
```json
{
"build": [["npm", "ci", "--omit=dev"]]
}
```
- `npm ci` installs the versions in the committed lockfile.
- `--omit=dev` excludes development tools.
- npm must be available on the daemon host.
Plugins that only use Paseo's host libraries need no preparation command.
@@ -8,9 +8,9 @@ category: Plugins
# Plugin reference # Plugin reference
> **For Paseo v0.8 beta.** Return to the [v0.8 quickstart](/docs/plugins/v0.8). Start with the [plugin quickstart](/docs/plugins) to create your first plugin.
Migrating an existing plugin? Follow the standalone [runtime-entry migration guide](/docs/plugins/v0.8/migration). Migrating an existing plugin? Follow the standalone [runtime-entry migration guide](/docs/plugins/migration).
Local plugins are directory sources installed into one Paseo daemon. A plugin can contribute: Local plugins are directory sources installed into one Paseo daemon. A plugin can contribute:
@@ -42,12 +42,23 @@ my-plugin/
tsconfig.json tsconfig.json
``` ```
The required root manifest is `paseo-plugin.json`. It contains the default plugin ID and supported Paseo versions: The required root manifest is `paseo-plugin.json`:
```json ```json
{ "id": "my-plugin", "requirements": { "paseo": ">=0.8.0" } } {
"id": "my-plugin",
"description": "Reviews changes before merge",
"requirements": { "paseo": ">=0.8.0" }
}
``` ```
| Field | Required | Behavior |
| -------------- | -------- | ---------------------------------------------------------------------- |
| `id` | Yes | Default installation ID. |
| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. |
| `requirements` | No | Supported Paseo versions, described below. |
| `build` | No | Preparation commands, described in the CLI reference. |
### Requirements ### Requirements
`requirements` is an optional object. Its currently supported key, `paseo`, accepts an npm semver `requirements` is an optional object. Its currently supported key, `paseo`, accepts an npm semver
@@ -67,8 +78,8 @@ Prerelease Paseo versions also satisfy a range their stable core (`major.minor.p
for typechecking. Raise the minimum when adopting a newer API. Add an upper bound when a later for typechecking. Raise the minimum when adopting a newer API. Add an upper bound when a later
release is incompatible; a minimum alone does not promise protection from future breaking changes. release is incompatible; a minimum alone does not promise protection from future breaking changes.
The daemon checks its version before installing, running Git build commands, or loading a plugin, The daemon checks its version before installing, running preparation commands, or loading a plugin,
and checks again on startup, enable, and reload. A rejected Git update keeps the installed revision. and checks again on startup, enable, and reload. A rejected update keeps the installed revision.
Each connected app checks its own version before evaluating client code and shows incompatibility Each connected app checks its own version before evaluating client code and shows incompatibility
in Settings → Plugins. A compatible daemon does not make an older app compatible. A plugin with no in Settings → Plugins. A compatible daemon does not make an older app compatible. A plugin with no
client entry does not require the connected app to match. client entry does not require the connected app to match.
@@ -85,7 +96,7 @@ and cannot show this new diagnostic.
| `index.server.ts` | Daemon subprocess | `PluginServerContext` | When the plugin contributes handlers, hooks, settings persistence, or providers | | `index.server.ts` | Daemon subprocess | `PluginServerContext` | When the plugin contributes handlers, hooks, settings persistence, or providers |
At least one entry is required; both accept `.ts` or `.tsx`. A directory that still has only the At least one entry is required; both accept `.ts` or `.tsx`. A directory that still has only the
old `index.ts` fails to load and points at the [migration guide](/docs/plugins/v0.8/migration). old `index.ts` fails to load and points at the [migration guide](/docs/plugins/migration).
Plugin, surface, sidebar-item, workspace-panel, Command Center item, attachment-source, and Plugin, surface, sidebar-item, workspace-panel, Command Center item, attachment-source, and
slash-command IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens. slash-command IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens.
@@ -264,7 +275,7 @@ process, credential, and other machine-local work under `server/`. A plugin with
### Providers ### Providers
Follow [Build a provider plugin](/docs/plugins/v0.8/providers) for direct and ACP implementations, Follow [Build a provider plugin](/docs/plugins/providers) for direct and ACP implementations,
session lifecycle, composer settings, timeline renderers, testing, and distribution. session lifecycle, composer settings, timeline renderers, testing, and distribution.
Call `server.registerProvider()` with a `ProviderRegistration` from Call `server.registerProvider()` with a `ProviderRegistration` from
@@ -1873,6 +1884,88 @@ remove Command Center items and clear the installation's query state. An already
remains as unavailable until its matching contribution returns or the user closes it. Panel render remains as unavailable until its matching contribution returns or the user closes it. Panel render
failures stay inside the plugin error boundary. failures stay inside the plugin error boundary.
## Plugin sources
Paste one of these source identifiers into **Settings → Plugins**, or pass it to
`paseo plugin install`. `paseo plugin add <source>` and `paseo plugin install <source>` are aliases.
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 | `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. 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. 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 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 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 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.
For package contents, dependencies, preparation, and private registries, see
[Publish a plugin](/docs/plugins/publishing).
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 ## CLI reference
```bash ```bash
@@ -1884,7 +1977,10 @@ paseo plugin add https://git.example.com/owner/repository.git --ref main
paseo plugin add owner/monorepo:plugins/review paseo plugin add owner/monorepo:plugins/review
paseo plugin ls [id] paseo plugin ls [id]
paseo plugin update <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 reload my-plugin
paseo plugin logs my-plugin paseo plugin logs my-plugin
paseo plugin disable my-plugin paseo plugin disable my-plugin
@@ -1892,18 +1988,36 @@ paseo plugin enable my-plugin
paseo plugin remove my-plugin paseo plugin remove my-plugin
``` ```
`ls` reports runtime state, source details, and the installed commit without contacting the remote. `ls` and Settings show source identity and the current installed revision without contacting the
Use `update` when you want Paseo to contact a tracked Git remote and install an available update. 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` 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. `--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.
An existing directory wins over `owner/repository` GitHub shorthand. Append `:relative/path` when
the plugin lives below the repository root. Omit `--ref` to track the default branch. Explicit
branches track updates; tags and commits stay pinned.
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency 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 that Paseo does not provide, generate source or assets, or perform another required preparation
@@ -1913,10 +2027,7 @@ step:
{ {
"id": "review", "id": "review",
"requirements": { "paseo": ">=0.8.0" }, "requirements": { "paseo": ">=0.8.0" },
"build": [ "build": [["npm", "ci", "--omit=dev"]]
["npm", "ci"],
["npm", "run", "build"]
]
} }
``` ```
@@ -1927,7 +2038,8 @@ compilation, activation, or replacement. A failing command reports its output, d
candidate, and leaves the installed/running version intact. The daemon log records each command and candidate, and leaves the installed/running version intact. The daemon log records each command and
output; with the global `--host` option, execution is on that daemon host. output; with the global `--host` option, execution is on that daemon host.
Run `npm run typecheck` before install or reload. Manage plugin source entries with the CLI or Settings. Run `npm run typecheck` before install or reload. Manage plugin source entries with the CLI or
Settings; see [Plugin sources](#plugin-sources) for install syntax.
The daemon-wide **Enable plugins** switch lives under **Settings → Plugins**. A configured plugin remains `disabled` until that switch and the plugin's own enabled state are both on. The daemon-wide **Enable plugins** switch lives under **Settings → Plugins**. A configured plugin remains `disabled` until that switch and the plugin's own enabled state are both on.
@@ -1939,7 +2051,7 @@ Use `paseo plugin ls` to read the current status and error.
| Symptom | Check | | Symptom | Check |
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `This plugin was made for an older version of Paseo` | The directory has only an `index.ts` entry. Follow the [migration guide](/docs/plugins/v0.8/migration). | | `This plugin was made for an older version of Paseo` | The directory has only an `index.ts` entry. Follow the [migration guide](/docs/plugins/migration). |
| `Plugin entry points are missing` | Neither `index.client.tsx` nor `index.server.ts` exists with that exact name. | | `Plugin entry points are missing` | Neither `index.client.tsx` nor `index.server.ts` exists with that exact name. |
| `server-only module cannot be imported into the plugin client bundle` | Client code imports `server/`. Move the work behind an RPC and import its contract from `shared/`. | | `server-only module cannot be imported into the plugin client bundle` | Client code imports `server/`. Move the work behind an RPC and import its contract from `shared/`. |
| `client-only module cannot be imported into the plugin server bundle` | Server code imports `client/`. Register that contribution from `index.client.tsx` instead. | | `client-only module cannot be imported into the plugin server bundle` | Server code imports `client/`. Register that contribution from `index.client.tsx` instead. |
-203
View File
@@ -1,203 +0,0 @@
---
title: Plugin quickstart
description: Build, install, share, and update a trusted Paseo plugin.
nav: Paseo v0.7 — Current
order: 45
category: Plugins
---
# Plugin quickstart
> **For Paseo v0.7.x.** This is the current stable plugin API.
> **Experimental:** The plugin API is still evolving, so expect breaking changes and updates to
> your plugins as Paseo evolves.
See the [plugin roadmap](https://github.com/getpaseo/paseo/labels/plugins) for planned contribution
surfaces and their current status.
Paseo plugins add native workspace panels, composer pills, Command Center items, global surfaces, app themes, daemon behavior, and composer attachment sources. They run on every Paseo client connected to the host, including mobile.
> **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 `--host`, commands run on the remote daemon host.
On the target host, open **Settings → Plugins** and turn on **Enable plugins**. This is the global switch for every configured plugin on that daemon.
You can also change the root `pluginsEnabled` field in the daemon's `config.json`, then apply it without restarting:
```bash
paseo reload --json
```
Enabling starts configured plugins; disabling tears them down. Automation must inspect the current value first and obtain your explicit permission before changing a disabled or omitted value to `true`.
## Create a plugin
Use an absolute path on the daemon machine:
```bash
paseo plugin init /absolute/path/to/workspace-plugin
cd /absolute/path/to/workspace-plugin
npm install
```
`init` creates a strict TypeScript project. It does not run the package manager. `npm install`
installs development dependencies for local typechecking and tests. Paseo supplies the plugin SDK,
React, React Native, TanStack Query, and Zod at runtime; plugins do not need a `build` hook for these
modules. `index.ts` registers contributions; client UI lives in `*.client.tsx` files.
Plugins run on desktop, browser, iOS, and Android. Paseo ships several themes. Color every `Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`. Hardcoded black text fails in dark themes.
Replace `main.client.tsx` with:
```tsx
import { type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin";
import { useMemo } from "react";
import { Text, View } from "react-native";
export function WorkspaceOverview({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
const workspace = useWorkspace(workspaceId, ({ name, directory }) => ({
name,
directory,
}));
const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
gap: layout.compact ? 8 : 12,
backgroundColor: theme.colors.surface0,
},
title: { color: theme.colors.foreground, fontSize: layout.compact ? 20 : 24 },
label: { color: theme.colors.foregroundMuted },
detail: { color: theme.colors.foreground },
}),
[theme, layout.compact],
);
return (
<View style={styles.screen}>
<Text style={styles.title}>{workspace?.name}</Text>
<Text style={styles.label}>Directory</Text>
<Text style={styles.detail}>{workspace?.directory}</Text>
</View>
);
}
```
Replace `index.ts` with:
```ts
import type { PluginContext } from "@getpaseo/plugin";
import { WorkspaceOverview } from "./main.client";
export default function contribute(plugin: PluginContext) {
plugin.addWorkspacePanel({
id: "overview",
title: "Workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
locations: ["workspace", "explorer"],
Component: WorkspaceOverview,
});
plugin.addCommandCenterItem({
id: "open-overview",
title: "Open workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
onSelect({ openPanel }) {
openPanel("overview");
},
});
return () => {};
}
```
The icon is a [Lucide](https://lucide.dev/icons/) icon name. `*.client.tsx` files can use React Native runtime APIs; Paseo excludes them from the daemon bundle. Panel props contain stable IDs; `useWorkspace` selects the cached fields the component needs without fetching through RPC or re-rendering for unrelated workspace changes. See [Theme and layout](/docs/plugins/v0.7/reference#theme-and-layout) for the required tokens.
## Check and install it
```bash
npm run typecheck
paseo plugin install /absolute/path/to/workspace-plugin
paseo plugin ls
```
Open a workspace, press **⌘K** on macOS or **Ctrl+K** on Windows and Linux, and choose **Open workspace overview**. It opens as a normal workspace tab. If the item does not appear, confirm that **Enable plugins** is on, the plugin status is `running` in `paseo plugin ls`, and the client is viewing the host where you installed it.
To install a plugin published through GitHub or another Git host:
```bash
paseo plugin add owner/repository
paseo plugin add https://gitlab.com/group/repository.git
paseo plugin add https://git.example.com/owner/repository.git
paseo plugin add owner/monorepo:plugins/workspace
paseo plugin add owner/repository --ref main
```
Append `:relative/path` to the source when the plugin lives below the repository root.
An omitted `--ref` tracks the default branch. Explicit branches track updates; tags and commits are
pinned. Apply updates with:
```bash
paseo plugin update workspace-plugin
paseo plugin update --all
```
Most plugins should omit `build`. Paseo compiles TypeScript and TSX and supplies its runtime modules.
Declare preparation only when the staged checkout must install another dependency, generate source,
or perform another required build step:
```json
{
"id": "workspace-plugin",
"build": [
["npm", "ci"],
["npm", "run", "build"]
]
}
```
Each `build` entry is a non-empty argv array, executed directly without a shell from the staged
plugin directory. Paseo never chooses a package manager or infers commands from lockfiles. On
install and update it resolves the exact commit, runs these commands, then validates, compiles, and
activates the candidate. A failed command discards the candidate and keeps the installed/running
version. The daemon log records the exact argv and output; `--host` runs them on the remote daemon
host.
## Edit and reload
Source changes are explicit:
```bash
npm run typecheck
paseo plugin reload workspace-plugin
```
A reload stops the old plugin, runs its cleanup, compiles the current source, and starts it again. A failed reload stays failed and reports its load error; fix the source and reload again.
## Debug backend output
Use normal Node logging in daemon-side handlers and cleanup:
```ts
console.log("Refreshing issues");
console.error("Issue refresh failed", error);
```
Read recent stdout and stderr from **Settings → Plugins → Logs** or the CLI:
```bash
paseo plugin logs workspace-plugin
paseo plugin logs workspace-plugin --json
```
The log tail includes `[paseo]` loading, ready, stopping, and stopped entries, plus compilation and
load failures. It survives reloads and crashes. Inspect it when a plugin fails to start or an RPC
rejects. See [Debug backend output](/docs/plugins/v0.7/reference#debug-backend-output) for retention and
security behavior.
## Next
- [Plugin reference](/docs/plugins/v0.7/reference), add daemon behavior, use the Paseo SDK, contribute themes and attachments, and manage lifecycle.
- [TypeScript SDK](/docs/sdk), the workspace, agent, provider, and config API exposed inside plugins.
File diff suppressed because it is too large Load Diff
-276
View File
@@ -1,276 +0,0 @@
---
title: Plugin quickstart
description: Build, install, share, and update a trusted Paseo plugin.
nav: Paseo v0.8 — Beta
order: 46
category: Plugins
---
# Plugin quickstart
> **For Paseo v0.8 beta.** Use the [v0.7 docs](/docs/plugins/v0.7)
> if you run the stable release.
> **Experimental:** The plugin API is still evolving, so expect breaking changes and updates to
> your plugins as Paseo evolves. See the [plugin roadmap](https://github.com/getpaseo/paseo/labels/plugins)
> for planned contribution surfaces.
A plugin is a TypeScript project installed into one Paseo daemon. It can add
[surfaces and sidebar items](/docs/plugins/v0.8/reference#surfaces-and-sidebar-items),
[workspace panels](/docs/plugins/v0.8/reference#workspace-panels),
[Command Center items](/docs/plugins/v0.8/reference#command-center-items),
[slash commands](/docs/plugins/v0.8/reference#slash-commands),
[composer pills](/docs/plugins/v0.8/reference#composer-pills),
[timeline items](/docs/plugins/v0.8/reference#timeline-items),
[themes](/docs/plugins/v0.8/reference#contribute-a-theme),
[attachment sources](/docs/plugins/v0.8/reference#add-a-composer-attachment-source), and
[daemon-side RPCs](/docs/plugins/v0.8/reference#add-plugin-specific-backend-behavior). It can also
[connect a coding agent as a provider](/docs/plugins/v0.8/providers). Client
contributions run on every Paseo client connected to that daemon, including mobile.
This guide scaffolds a plugin, runs it, and adds a workspace panel to it.
## Create a plugin
Use an absolute path on the daemon machine:
```bash
paseo plugin init /absolute/path/to/workspace-plugin
cd /absolute/path/to/workspace-plugin
npm install
```
`init` writes a strict TypeScript project and does not run the package manager. `npm install` adds
development dependencies for typechecking and tests only; Paseo supplies the plugin SDK, React,
React Native, TanStack Query, and Zod at runtime.
The scaffold is a working plugin: a sidebar surface with a button that asks the daemon for a
greeting through an RPC.
```text
workspace-plugin/
paseo-plugin.json # plugin ID and supported Paseo versions
index.client.tsx # runs in the Paseo app
index.server.ts # runs in a daemon subprocess
client/greeting.tsx # the surface component
client/web.ts # the only file allowed to touch browser APIs
server/greeting.ts # the RPC handler
shared/greeting.ts # the RPC contract, imported by both
package.json
tsconfig.json
```
Each entry default-exports one function that registers contributions and returns a cleanup
function. `index.client.tsx` registers the surface and the sidebar item that opens it:
```tsx
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { GreetingSurface } from "./client/greeting";
export default function contribute(client: PluginClientContext) {
client.addSurface("greeting", GreetingSurface);
client.addSidebarItem({
id: "greeting",
title: "Greeting",
icon: "MessageCircle",
surface: "greeting",
});
return () => {};
}
```
`index.server.ts` registers the handler for the contract in `shared/greeting.ts`:
```ts
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { createGreeting } from "./server/greeting";
import { greetingRpc } from "./shared/greeting";
export default function contribute(server: PluginServerContext) {
server.handle(greetingRpc, createGreeting);
return () => {};
}
```
The directory is the boundary. Code under `client/` compiles only into the app bundle, code under
`server/` only into the daemon bundle, and `shared/` into both. Importing across that line, adding
a code file at the root, or importing a `node:` module from client code is a compile error. A
plugin with no daemon-side work can omit `index.server.ts`; a plugin with no UI can omit
`index.client.tsx`.
Client code runs on phones as well as in browsers. The project typechecks without the DOM library,
so `document` and `window` are errors outside `client/web.ts`, which shows how to gate a browser
API behind `Platform.OS` with a native fallback. See
[Cross-platform rules](/docs/plugins/v0.8/reference#cross-platform-rules) before writing UI.
## Install and try it
Plugins are trusted, unsandboxed code: server code and Git 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.
Turn on **Enable plugins** under **Settings → Plugins** on the daemon you are installing into. It is
the global switch for every plugin on that daemon. It is also the root `pluginsEnabled` field in the
daemon's `config.json`; after editing the file, apply it with `paseo reload --json`. An automated
tool must read the current value and get your explicit permission before turning it on.
Then typecheck and install:
```bash
npm run typecheck
paseo plugin install /absolute/path/to/workspace-plugin
paseo plugin ls
```
`paseo plugin ls` should report the plugin as `running`. Open Paseo, choose **Greeting** in the
sidebar, and press **Create greeting**. The message comes back from the daemon subprocess through
the RPC.
If the sidebar item is missing, check that **Enable plugins** is on, the plugin is `running`, and
the client is viewing the host you installed into. `paseo plugin logs workspace-plugin` shows the
daemon-side output, including load errors.
## Add a workspace panel
A workspace panel opens as a tab next to agents, terminals, and files. Create `client/overview.tsx`:
```tsx
import { type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { Text, View } from "react-native";
export function WorkspaceOverview({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
const workspace = useWorkspace(workspaceId, ({ name, directory }) => ({
name,
directory,
}));
const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
gap: layout.compact ? 8 : 12,
backgroundColor: theme.colors.surface0,
},
title: { color: theme.colors.foreground, fontSize: layout.compact ? 20 : 24 },
label: { color: theme.colors.foregroundMuted },
detail: { color: theme.colors.foreground },
}),
[theme, layout.compact],
);
return (
<View style={styles.screen}>
<Text style={styles.title}>{workspace?.name}</Text>
<Text style={styles.label}>Directory</Text>
<Text style={styles.detail}>{workspace?.directory}</Text>
</View>
);
}
```
`useWorkspace` reads the fields the panel renders from the app's cached state, without an RPC and
without re-rendering when unrelated fields change. Every `Text` takes its color from
`theme.colors`, and `layout.compact` drives spacing, so the panel works in every Paseo theme and on
phones. See [Theme and layout](/docs/plugins/v0.8/reference#theme-and-layout) for the token list.
Register the panel and a Command Center item that opens it by adding to `index.client.tsx`:
```tsx
import { WorkspaceOverview } from "./client/overview";
// Inside contribute(client), after the existing registrations:
client.addWorkspacePanel({
id: "overview",
title: "Workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
locations: ["workspace", "explorer"],
Component: WorkspaceOverview,
});
client.addCommandCenterItem({
id: "open-overview",
title: "Open workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
onSelect({ openPanel }) {
openPanel("overview");
},
});
```
`icon` is a [Lucide](https://lucide.dev/icons/) icon name.
## Edit and reload
Source changes take effect only when you reload the plugin:
```bash
npm run typecheck
paseo plugin reload workspace-plugin
```
A reload stops the old plugin, runs its cleanup, compiles the current source, and starts it again.
A failed reload stays failed and reports its error in `paseo plugin ls`; fix the source and reload
again.
Open a workspace, press **⌘K** on macOS or **Ctrl+K** on Windows and Linux, and choose **Open
workspace overview**. The panel opens as a workspace tab.
## Install a published plugin
Plugins published in a Git repository install by shorthand or URL:
```bash
paseo plugin add owner/repository
paseo plugin add https://gitlab.com/group/repository.git
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.
```bash
paseo plugin ls
paseo plugin update workspace-plugin
paseo plugin update --all
```
`ls` reports runtime state, source details, and the installed commit without contacting the remote.
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
[`build` commands](/docs/plugins/v0.8/reference#cli-reference) in its manifest.
## Read backend logs
Daemon-side handlers and cleanup can use normal Node logging:
```ts
console.log("Refreshing issues");
console.error("Issue refresh failed", error);
```
Read the recent output from **Settings → Plugins → Logs** or the CLI:
```bash
paseo plugin logs workspace-plugin
paseo plugin logs workspace-plugin --json
```
The tail includes `[paseo]` loading, ready, stopping, and stopped entries, plus compilation and load
failures, and it survives reloads and crashes. Client-side output stays in the app. See
[Debug backend output](/docs/plugins/v0.8/reference#debug-backend-output) for retention and what not to
log.
## Next
- [Build a provider plugin](/docs/plugins/v0.8/providers): connect an agent directly or through ACP,
render provider-owned timeline items, test it, and publish it.
- [Plugin reference](/docs/plugins/v0.8/reference): every contribution type, its fields, the runtime
modules, hosts, and the CLI.
- [Migrate a plugin to runtime entries](/docs/plugins/v0.8/migration): move a plugin written against the
single `index.ts` entry, step by step.
- [TypeScript SDK](/docs/sdk): the workspace, agent, provider, and config API available as `paseo`
in client and server code.
+1 -1
View File
@@ -172,7 +172,7 @@ test("PR routing declares stable behavior ownership", () => {
sdk: [ sdk: [
"packages/plugin/**", "packages/plugin/**",
"plugin-examples/**", "plugin-examples/**",
"public-docs/plugins/v0.8/**", "public-docs/plugins/**",
"packages/client/**", "packages/client/**",
"packages/highlight/**", "packages/highlight/**",
"packages/protocol/**", "packages/protocol/**",
+164
View File
@@ -0,0 +1,164 @@
import { execCommand } from "../../packages/server/src/utils/spawn.js";
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";
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 execCommand("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": "*" },
},
];
}
+19 -12
View File
@@ -14,11 +14,12 @@ Build or manage the requested plugin directly. Use the current public docs to ca
Fetch [https://paseo.sh/llms.txt](https://paseo.sh/llms.txt) first. Select and fetch the current plugin Markdown pages from that index before changing a plugin: Fetch [https://paseo.sh/llms.txt](https://paseo.sh/llms.txt) first. Select and fetch the current plugin Markdown pages from that index before changing a plugin:
- [Plugin quickstart](https://paseo.sh/docs/plugins.md) ([browser page](https://paseo.sh/docs/plugins)) - [Plugin quickstart](https://paseo.sh/docs/plugins.md) ([browser page](https://paseo.sh/docs/plugins))
- [Plugin reference](https://paseo.sh/docs/plugins/v0.8/reference.md) ([browser page](https://paseo.sh/docs/plugins/v0.8/reference)) - [Publishing](https://paseo.sh/docs/plugins/publishing.md): npm package contents, dependencies, Git preparation, and private registries.
- [Plugin reference](https://paseo.sh/docs/plugins/reference.md) ([browser page](https://paseo.sh/docs/plugins/reference))
Use the deployed docs when they disagree with this skill. Do not send the user away to read them instead of completing the work. Use the deployed docs when they disagree with this skill. Do not send the user away to read them instead of completing the work.
In the Paseo repository, use `public-docs/plugins/v0.8/reference.md` for the checkout's API, including In the Paseo repository, use `public-docs/plugins/reference.md` for the checkout's API, including
unreleased changes. Use `docs/plugins.md` for maintainer guidance. Complete contracts belong in the unreleased changes. Use `docs/plugins.md` for maintainer guidance. Complete contracts belong in the
public docs; this skill indexes the references and examples. public docs; this skill indexes the references and examples.
@@ -38,8 +39,8 @@ Pick the contribution that matches the request. Each row names the registration,
| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` | | Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` |
| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` | | Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` |
| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior | | Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior |
| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/v0.8/reference.md#lifecycle-hooks) | | Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) |
| Creation and launch hooks | `server.before` | Change agent config, provider options, MCP servers, environment, or workspace isolation before the operation | [Before hooks](https://paseo.sh/docs/plugins/v0.8/reference.md#before-hooks) | | Creation and launch hooks | `server.before` | Change agent config, provider options, MCP servers, environment, or workspace isolation before the operation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) |
| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK | | Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK |
| Lifecycle task | Example | | Lifecycle task | Example |
@@ -83,7 +84,7 @@ by the CLI version. Raise the minimum when adopting newer APIs; add an upper bou
Paseo release is incompatible. Use npm semver ranges and explicitly include beta versions when Paseo release is incompatible. Use npm semver ranges and explicitly include beta versions when
targeting betas. Missing requirements mean `<0.8.0`; complete the 0.8 entry migration before adding targeting betas. Missing requirements mean `<0.8.0`; complete the 0.8 entry migration before adding
`>=0.8.0`. Verify compatibility with both the daemon and the app running client contributions. `>=0.8.0`. Verify compatibility with both the daemon and the app running client contributions.
See [requirements](https://paseo.sh/docs/plugins/v0.8/reference#requirements). See [requirements](https://paseo.sh/docs/plugins/reference#requirements).
Each runtime has its own optional entry. A plugin must have at least one. Both entries accept Each runtime has its own optional entry. A plugin must have at least one. Both entries accept
`.ts` or `.tsx`; use `.tsx` when an entry imports components. `.ts` or `.tsx`; use `.tsx` when an entry imports components.
@@ -245,7 +246,7 @@ export default function contribute(client: PluginClientContext) {
Icons are Lucide icon names. `theme` is a typed `PluginTheme` on every surface and panel. Primary text uses `theme.colors.foreground`; labels use `theme.colors.foregroundMuted`; the root view uses `theme.colors.surface0`. `layout.compact` is true on mobile and narrow windows. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client. Icons are Lucide icon names. `theme` is a typed `PluginTheme` on every surface and panel. Primary text uses `theme.colors.foreground`; labels use `theme.colors.foregroundMuted`; the root view uses `theme.colors.surface0`. `layout.compact` is true on mobile and narrow windows. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client.
Before writing imports, classify each module as shared, client, or server. Follow the Before writing imports, classify each module as shared, client, or server. Follow the
[SDK import boundaries](https://paseo.sh/docs/plugins/v0.8/reference.md#runtime-modules), including [SDK import boundaries](https://paseo.sh/docs/plugins/reference.md#runtime-modules), including
transitive and type dependencies. The root is shared-only; hooks and client contexts belong to transitive and type dependencies. The root is shared-only; hooks and client contexts belong to
`@getpaseo/plugin/client`, server contexts to `/server`, and host UI to `/client/react-native` or `/client/ui`. `@getpaseo/plugin/client`, server contexts to `/server`, and host UI to `/client/react-native` or `/client/ui`.
Install dependencies locally for typechecking; Paseo supplies host runtime modules. JSX uses the Install dependencies locally for typechecking; Paseo supplies host runtime modules. JSX uses the
@@ -592,15 +593,16 @@ When the same sidebar contribution exists on several connected hosts, Paseo show
## Typecheck and manage ## Typecheck and manage
Always typecheck before install or reload: When editing a plugin, typecheck its source before install or reload:
```bash ```bash
npm run typecheck npm run typecheck
paseo plugin install /absolute/path/to/plugin paseo plugin install /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin --id another-runtime-id paseo plugin install /absolute/path/to/plugin --id another-runtime-id
paseo plugin add owner/repository # Git source; append :path for a monorepo subdirectory paseo plugin install npm:@acme/paseo-review
paseo plugin add owner/repository --ref main # branches track, tags and commits pin paseo plugin install npm:@acme/paseo-review@1.2.0
paseo plugin status paseo plugin install github:owner/repository
paseo plugin install github:owner/repository --ref main
paseo plugin update my-plugin paseo plugin update my-plugin
paseo plugin ls paseo plugin ls
paseo plugin reload my-plugin paseo plugin reload my-plugin
@@ -610,11 +612,16 @@ paseo plugin enable my-plugin
paseo plugin remove my-plugin paseo plugin remove my-plugin
``` ```
Use `--host <url>` when managing a daemon other than the CLI default. A Git source that must install or generate something declares `build` in `paseo-plugin.json` as a list of argv arrays; Paseo runs them without a shell on install and update and keeps the old version if one fails. Plugin source edits require `paseo plugin reload`; config changes to the global switch require `paseo reload`. A failed plugin reload stays failed; inspect `paseo plugin ls` for the load error and `paseo plugin logs <id>` for subprocess output, fix the source, typecheck, and reload again. `remove` deletes configuration, never the source directory. For npm, ensure npm is on the daemon's `PATH`; use that host's registry configuration and credentials. Install selectors choose
content once; they do not pin updates. `install` and `add` are aliases. Follow the
[publishing guide](https://paseo.sh/docs/plugins/publishing.md) for Paseo's package contents and
preparation requirements; standard npm publishing commands apply.
Use `--host <url>` when managing a daemon other than the CLI default. A Git source that must install or generate something declares `build` in `paseo-plugin.json` as a list of argv arrays; Paseo runs them without a shell on install and update and keeps the old version if one fails. Plugin source edits require `paseo plugin reload`; config changes to the global switch require `paseo reload`. A failed plugin reload stays failed; inspect `paseo plugin ls` for the load error and `paseo plugin logs <id>` for subprocess output, fix the source, typecheck, and reload again. `remove` keeps local source directories and deletes managed Git/npm installations.
Do not restart the daemon to load source changes. Restarting it can kill the agent performing the work. Do not restart the daemon to load source changes. Restarting it can kill the agent performing the work.
For an old mixed entry, follow the standalone [v0.8 runtime-entry migration guide](https://paseo.sh/docs/plugins/v0.8/migration) mechanically. For an old mixed entry, follow the standalone [runtime-entry migration guide](https://paseo.sh/docs/plugins/migration) mechanically.
## Verify the outcome ## Verify the outcome