Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] cfddde086a test(cli): clean up the SSH status fixture when daemon startup fails 2026-10-07 00:26:02 +02:00
paseo-bot[bot] f2e2d58ba7 fix(cli): let daemon status reach a host over SSH
daemon status gave its connection 1.5s, including the SSH handshake for
an ssh:// host, so a host whose SSH stream takes 2.5s to open was
reported unreachable while other commands reached it. An explicit host
now gets the same connect budget as other commands; the local instance
probe keeps 1.5s.
2026-10-07 00:26:02 +02:00
Mohamed Boudra 2d85fe4e04 feat(plugins): declare display metadata in plugin manifests (#6263)
* feat(plugins): declare display metadata in plugin manifests

* style(website): format plugin page description

* fix(plugins): preserve default names and clarify registry follow-up

* test: fix stale plugin card selectors and terminal marker race
2026-10-06 23:18:24 +01:00
Mohamed Boudra 1463396ebc fix(website): plugin search ranks name matches first
Search filtered by substring and the browse page then sorted by installs,
so an exact name like "OMP" landed behind plugins that only mentioned it
in a description. Results now rank by the field matched (name, author,
description), then match quality from the shared text matcher, with the
tab's order breaking ties.
2026-10-06 23:50:27 +02:00
Mohamed Boudra d4ea77b910 fix(website): plugin install command block fits the command 2026-10-06 23:26:35 +02:00
Mohamed Boudra f715221de8 feat(website): plugins page copy describes browsing community plugins 2026-10-06 23:26:35 +02:00
Mohamed Boudra aedba5ec27 fix(website): plugin page content spans the same width as the header and footer
The page capped itself at max-w-3xl inside the site shell, so the install command, gallery, and README stopped short of the nav's right edge.
2026-10-06 23:19:16 +02:00
Mohamed Boudra bdc58fb07c feat(website): plugins hero, author and description on cards, install counts with an icon
The plugins page opens with the directory copy, plugin and author counts,
Submit a plugin and Read the docs, and search. Featured and What's new cards
show the description and the author with avatar; the trailing slot carries
installs on Featured and the added date on What's new. Every install count
on the site is the same download icon and number.
2026-10-06 23:18:20 +02:00
Mohamed Boudra 3f1a04304d website: plugin gallery peeks a third tile, viewer steps with arrows (#6261)
* website: gallery peeks a third tile, viewer steps with arrows

With three or more items the strip sizes tiles at 42% from md up so a
third tile is cut at the right edge. The viewer gains Previous and Next
buttons and Left/Right arrow keys, wrapping at the ends; moving away
from a video unmounts it, which stops playback.

* website: format the plugins directory route
2026-10-06 21:49:25 +01:00
Mohamed Boudra 50cf244e86 website e2e: expect the current Featured subtitle 2026-10-06 22:31:42 +02:00
Mohamed Boudra aaeefdc00b website: 80px between the site header and the page content 2026-10-06 22:30:40 +02:00
Mohamed Boudra c5deaa0516 Revert "website: plugins section spacing with margins, not flex gaps"
This reverts commit 72c180aaf7.
2026-10-06 22:30:39 +02:00
Mohamed Boudra 72c180aaf7 website: plugins section spacing with margins, not flex gaps
PluginSection takes its header as a prop and puts the content 32px
below it with a margin. Sections on the index are 56px apart via
space-y. The browse page h1 sits 32px above the sort tabs, matching
the index headings.
2026-10-06 22:23:50 +02:00
Mohamed Boudra 66b2567be3 Featured: subtitle and a See all plugins link 2026-10-06 22:20:10 +02:00
Mohamed Boudra cb2b569bbd Featured subtitle: hand-picked plugins that show what is possible 2026-10-06 22:15:58 +02:00
paseo-bot[bot] 2ecf3ba783 Keep an archived agent archived when a send to it fails (#6255)
* Keep an archived agent archived when a send to it fails

Sending a message to an archived agent unarchives it before loading the
session. When loading failed, for example because the agent's working
directory was removed, the agent stayed unarchived. The send now restores
the original archive timestamp when it fails, matching resume_agent.

Closes #6245

* Restore the archive only when the send cannot load the agent

Concurrent sends share one load, so a failed load leaves no session for
the restore to close. A failure after the load no longer re-archives an
agent another send may already be running.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 21:56:27 +02:00
Mohamed Boudra 4515061f88 website: submit-based plugin search on the home page, back button works on browse (#6256)
* website: submit-based plugin search on the home page, back button works on browse

* website: cover Forward in the browse search history test
2026-10-06 20:53:42 +01:00
Mohamed Boudra 3c478e5967 website: plugins page spacing, featured section, media with video, and viewer (#6254)
* website: match left and right edge padding on ranked plugin rows

The rank sat right-aligned in a fixed 16px column and the row had 8px of
padding, so a single digit started ~15.6px from the row edge while the
install count ended 8px from it. The rank now sits at the row's padding,
which is 16px on both sides, and the list's negative margin matches so
the rank still lines up with the section heading. Tabular figures keep
the tile column aligned without the fixed width.

* website: give plugins section headings room above their content

Section headings sat 16px above their cards while sections sat 56px
apart, so a heading read as closer to the section above it. A shared
PluginSection now owns the header-to-content gap (32px), used by the
index sections and by the browse results under the sort tabs.

* website: center the Build your own plugin card

The heading, description, and buttons were left-aligned in a full-width
card, leaving most of it empty. Center them and keep the description at
its readable measure.

* website: order What's new by first-listing date

newestFirst and addedAgo read submittedAt. The registry publishes the
first-listing date as publishedAt, so What's new now sorts and dates by
it. The fixture's publishedAt values become first-listing dates.

* website: plugin media with video tiles and a click-to-view viewer

The registry's published screenshots field becomes media and may hold
mp4 and webm videos. Cards and link previews use the first image, the
thumbnail route serves only image entries, and the detail gallery shows
videos as tiles alongside images. Any tile opens one viewer. An image
fits the viewport but never exceeds its natural size. A video plays with
controls and scales to the largest size that fits, small sources included.

The daemon resolver now validates only the ID and artifact it installs.

* server: drop the unreleased pluginRegistryEnabled config key

The key only existed in unreleased betas and nothing reads it. The
config schema is strict, so a config that still sets it now fails
validation and names the key.

* website: Featured section on the plugins index

The registry index gains a required featured list of plugin IDs. The
index shows those plugins above What's new, in the registry's order,
skipping IDs that are not listed, and hides the section when none are.
The index sections now share one container for their spacing.
2026-10-06 20:53:34 +01:00
paseo-bot[bot] 05637a7f05 Show a Claude subagent's auto-mode report as markdown instead of a raw SubagentHandback card (#6248)
* Show a Claude subagent's handback report as its final message

Since Claude Code 2.1.271, a subagent in auto mode delivers its report
through a SubagentHandback tool call instead of its final text. Paseo
showed that call as a generic tool card with the report as escaped JSON.
Live sidechain frames and replayed history now map the call to the
subagent's final assistant message and hide its acknowledgement.

Closes #6241

* Keep a handback report's leading whitespace and hide its acknowledgement by call id

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 20:54:16 +02:00
paseo-ai[bot] 13d21f60f1 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-06 17:40:48 +00:00
Mohamed Boudra fc3b2c4eb7 Sanitize plugin overview rendering: no raw HTML, https links only, no relative links (#6244)
* fix(plugins): restrict overview content to safe Markdown and HTTPS destinations

Registry overviews are author-controlled content read away from their repositories. Apply a shared presentation policy instead of the documentation renderer so relative links, non-HTTPS URLs, raw HTML, and author styles never become active overview content.

* fix(plugins): choose the first allowed screenshot for previews
2026-10-06 18:31:53 +01:00
paseo-bot[bot] a9fef4879a Show an archived ACP agent's history after its worktree is removed (#6228)
* Show an archived ACP agent's history after its worktree is removed

Opening an archived ACP agent loads its history by resuming the provider
session for reading. The provider process was spawned in the agent's
stored working directory, so once that directory was removed (for
example when its worktree workspace was archived) the spawn failed with
ENOENT and the chat opened empty.

A history load now starts the provider process from the home directory
when the working directory is gone, and still names the original
directory in session/load. An interactive resume keeps requiring the
directory.

Closes #5449

* Only treat a missing working directory as gone for a history load

A stat failure other than a missing path, such as a permission error,
now reaches the caller instead of moving the provider to the home
directory.

* Start the history process from home when the stored path is not a directory

A file left where an archived agent's worktree was made the history load
spawn in it and fail with ENOTDIR.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 18:54:59 +02:00
Mohamed Boudra a292b402cf Resolve local plugin directories in the CLI's working directory (#6239)
* fix(cli): resolve local plugin directories from the caller's cwd

Relative plugin sources were sent unchanged, so a daemon started elsewhere inspected the wrong directory. Resolve directory sources and expand the caller's home before sending, preserving subdirectory suffixes and managed source identifiers.

* fix(cli): preserve absolute plugin paths across host platforms

A remote daemon may use another operating system. Recognize absolute POSIX and Windows directory sources before resolving relative paths on the CLI host.
2026-10-06 17:31:57 +01:00
Mohamed Boudra 64c9f263e8 Thumbnail plugin card screenshots through Cloudflare image transformations (#6240)
* perf(website): thumbnail plugin card screenshots

* fix(website): preserve and sandbox plugin screenshot fallbacks
2026-10-06 17:30:18 +01:00
paseo-bot[bot]andDillon 48059aeae3 Show the PR panel's state, activity verbs, and thread badges in the app language (#6225)
* fix(app): show PR panel state, activity verbs, and thread badges in the app language

The PR panel's state label, the verb after each activity author, and the
Resolved and Outdated thread badges were English literals, so they stayed
English in every other language. The state and verb translations already
existed and were unused; the badges get new keys in every locale.

Closes #6214

* fix(app): translate every PR panel verb and state, and phrase them after the author

The zh-CN state and review labels still held English ("Open", "Draft",
"已 review"), and the Spanish and Portuguese verbs were adjectives that
do not read after an author's name. The translation test now checks
each verb and state on its own, so one English label fails it.

Co-authored-by: Dillon <dillonzq@outlook.com>

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Dillon <dillonzq@outlook.com>
2026-10-06 18:03:10 +02:00
Mohamed Boudra 7fd469ae19 Enable plugin registry installs by default (#6224)
* feat(plugins): enable registry installs by default

* fix(plugins): explain explicit Git source for missing registry IDs

* fix(plugins): tailor missing registry guidance to installation

* test(app): sample footer geometry in one browser frame

* fix(plugins): make registry source resolution deterministic

Directory existence must not change the meaning of a registry identifier shared by CLI, RPC, and UI installs. Classify explicit host paths in the source parser and explain recovery from registry transport and revision-selection failures.

* fix(plugins): recognize dot and Windows directory sources

* test(cli): wait for readable daemon ownership before cancellation

The cancellation test raced PID-file creation against its first write and parsed empty JSON in CI. Use the public daemon-instance reader to wait for and capture the supervisor PID before asserting cancellation cleanup.

* test(app): expect missing directory install guidance
2026-10-06 16:52:02 +01:00
paseo-bot[bot] 56d5680742 fix(highlight): keep Astro expressions colored through nested template strings (#6219)
An Astro { } expression containing a template string inside ${} closed at
the first } in that nested string, leaving the rest of the expression
uncolored. Astro and Vue now share one scanner for embedded JavaScript,
which already skips template substitutions.

Closes #6209

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 17:03:17 +02:00
Mohamed Boudra 06bde1e57a Scaffold author overviews for plugin listings (#6226)
* feat(plugins): scaffold author listing overviews

* docs(plugins): refine the author overview contract

* docs(plugins): require repository overviews for registry listings

* docs(plugins): keep listing guidance focused on authors
2026-10-06 15:58:26 +01:00
Mohamed Boudra 396a2bc0b2 fix(website): count a plugin install once per IP per hour (#6230)
Repeated install-intent resolves inflated plugin rankings. Deduplicate reports by plugin and hashed Cloudflare IP for one hour, and skip counting when the IP header is absent.
2026-10-06 15:34:15 +01:00
98 changed files with 4875 additions and 754 deletions
+2
View File
@@ -27,6 +27,8 @@
### Changed
- Enabled the plugin registry by default: `paseo plugin add owner/slug` installs a reviewed artifact even when a local directory matches; use explicit local paths (including `.`, `..`, and Windows drive or UNC paths), and use `github:owner/repository` for direct GitHub installs. Registry connection, missing-ID, and `--ref` errors include recovery steps.
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975))
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
+17 -12
View File
@@ -117,15 +117,19 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
## Install from a registry
Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
artifact, and GitHub shorthand requires `github:`. Registry installs
`paseo plugin add owner/slug` installs the registry's reviewed artifact by default.
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
selection is unavailable for registry installs; install an explicit source to select your own.
Registry overviews are untrusted author content, even before installation. Render them with
`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative
URLs, and isolated external links. The website adapter and corpus tests live in
`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is
`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share
this policy. Installed-plugin descriptions in the app remain plain text.
Use `host/owner/slug` for a registry at `https://host`, or set
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
Private registry credentials live in daemon config under
@@ -138,13 +142,13 @@ owns static hosting, record shapes, pins, and advisory install counts.
## Install a Git source
GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git
URL. An existing directory still wins over source resolution.
GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git
URL.
```bash
paseo plugin install owner/repository
paseo plugin install git:owner/repository
paseo plugin install https://gitlab.com/group/repository.git
paseo plugin install owner/monorepo:plugins/review
paseo plugin install git:owner/monorepo:plugins/review
paseo plugin install github:owner/repository --ref main
```
@@ -155,8 +159,9 @@ and ask for approval. `ls` reports the installed commit without contacting the r
## 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.
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. The CLI resolves directory sources before
calling `installPluginSource`; see the public source reference for CLI and app path semantics.
Only the daemon reads plugin files 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
+1 -1
View File
@@ -1 +1 @@
sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE=
sha256-zL3+gbScK8eMkjQUtKDX4UJTXt4BMeqMSyeWB017MA4=
+1
View File
@@ -40174,6 +40174,7 @@
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.1",
"parse5": "^7.3.0",
"typescript": "^5.9.3",
"vite": "^7.3.3",
"vite-tsconfig-paths": "^5.1.4",
@@ -1,3 +1,4 @@
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
@@ -557,7 +558,7 @@ async function retryGitInstallAndInspectRows(
await installPlugin(page, missingSource);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
"Plugin source is neither an existing directory nor a Git URL",
`Plugin directory does not exist: ${missingSource}`,
);
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
await capturePluginInstallForm(page, testInfo, "compact-error");
@@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) {
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
}
test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n");
await installPluginWithUntrustedDescription(pluginEnvironment, description);
await gotoAppShell(page);
await openPluginSettings(page);
const text = page.getByText(description, { exact: true });
await expect(text).toBeVisible();
// This field must remain a text node, with no Markdown links, images, or HTML children.
expect(await text.evaluate((element) => element.childElementCount)).toBe(0);
});
async function installPluginWithUntrustedDescription(
environment: PluginEnvironment,
description: string,
) {
const directory = await createDirectoryPlugin(
environment.directory,
"plain-text-plugin",
description,
"Plain text plugin",
);
await environment.client.installPluginSource({ source: directory });
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test";
import { getStateLabel } from "@/git/pull-request-panel/data";
import { en } from "@/i18n/resources/en";
import { openPullRequestPanel } from "./workspace-tabs";
export async function openPrPane(page: Page): Promise<void> {
@@ -14,7 +14,7 @@ export async function expectPrPaneState(
page: Page,
state: "open" | "merged" | "closed" | "draft",
): Promise<void> {
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
await expect(page.getByTestId("pr-pane-state")).toHaveText(en.workspace.git.pr.states[state], {
timeout: 15_000,
});
}
@@ -244,13 +244,16 @@ const FOOTER_GEOMETRY_TOLERANCE = 0.01;
* Settings together at the end.
*/
export async function expectFooterIconRow(page: Page): Promise<void> {
const boxes = (
await Promise.all(
FOOTER_ICON_TEST_IDS.map((testID) =>
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
),
)
).map((box) => box!);
// Read one browser-frame snapshot: the compact drawer can translate between separate RPCs.
const boxes = await page
.locator(FOOTER_ICON_TEST_IDS.map((testID) => `[data-testid="${testID}"]:visible`).join(","))
.evaluateAll((elements) =>
elements.map((element) => {
const { x, y, width, height } = element.getBoundingClientRect();
return { testID: element.getAttribute("data-testid"), x, y, width, height };
}),
);
expect(boxes.map((box) => box.testID)).toEqual(FOOTER_ICON_TEST_IDS);
const [first] = boxes;
for (const box of boxes) {
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
@@ -9,12 +9,13 @@ import {
isPipelineActiveStatus,
mapPipelineStatus,
} from "@/git/forges/gitlab";
import { i18n } from "@/i18n/i18next";
import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors";
import {
deriveAvatarColor,
formatAge,
getActivityVerb,
getStateLabel,
getActivityVerbKey,
getStateLabelKey,
mapPrPaneData,
} from "./data";
@@ -779,37 +780,34 @@ describe("formatAge", () => {
});
});
describe("getStateLabel", () => {
const english = i18n.getFixedT("en");
const french = i18n.getFixedT("fr");
describe("getStateLabelKey", () => {
it.each([
["open", "Open"],
["draft", "Draft"],
["merged", "Merged"],
["closed", "Closed"],
] as const)("maps %s → %s", (state, expected) => {
expect(getStateLabel(state)).toBe(expected);
["open", "Open", "Ouverte"],
["draft", "Draft", "Brouillon"],
["merged", "Merged", "Fusionnée"],
["closed", "Closed", "Fermée"],
] as const)("labels %s as %s, and %s in French", (state, englishLabel, frenchLabel) => {
expect(english(getStateLabelKey(state))).toBe(englishLabel);
expect(french(getStateLabelKey(state))).toBe(frenchLabel);
});
});
describe("getActivityVerb", () => {
it("returns Commented for comment kind", () => {
expect(getActivityVerb({ kind: "comment" })).toBe("Commented");
});
it("returns Approved for approved review", () => {
expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved");
});
it("returns Requested changes for changes_requested review", () => {
expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe(
describe("getActivityVerbKey", () => {
it.each([
[{ kind: "comment" }, "Commented", "A commenté"],
[{ kind: "review", reviewState: "approved" }, "Approved", "A approuvé"],
[
{ kind: "review", reviewState: "changes_requested" },
"Requested changes",
);
});
it("returns Reviewed for a commented review with body (generic case)", () => {
expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed");
});
it("returns Reviewed when reviewState is undefined", () => {
expect(getActivityVerb({ kind: "review" })).toBe("Reviewed");
"A demandé des modifications",
],
[{ kind: "review", reviewState: "commented" }, "Reviewed", "A relu"],
[{ kind: "review" }, "Reviewed", "A relu"],
] as const)("labels %o as %s, and %s in French", (item, englishVerb, frenchVerb) => {
expect(english(getActivityVerbKey(item))).toBe(englishVerb);
expect(french(getActivityVerbKey(item))).toBe(frenchVerb);
});
});
+12 -10
View File
@@ -318,16 +318,18 @@ function parsePullRequestNumber(url: string): number | null {
}
}
export function getStateLabel(state: PrState): string {
if (state === "draft") return "Draft";
if (state === "merged") return "Merged";
if (state === "closed") return "Closed";
return "Open";
export function getStateLabelKey(state: PrState) {
if (state === "draft") return "workspace.git.pr.states.draft";
if (state === "merged") return "workspace.git.pr.states.merged";
if (state === "closed") return "workspace.git.pr.states.closed";
return "workspace.git.pr.states.open";
}
export function getActivityVerb(item: Pick<PrPaneActivity, "kind" | "reviewState">): string {
if (item.kind === "comment") return "Commented";
if (item.reviewState === "approved") return "Approved";
if (item.reviewState === "changes_requested") return "Requested changes";
return "Reviewed";
export function getActivityVerbKey(item: Pick<PrPaneActivity, "kind" | "reviewState">) {
if (item.kind === "comment") return "workspace.git.pr.activity.commented";
if (item.reviewState === "approved") return "workspace.git.pr.activity.approved";
if (item.reviewState === "changes_requested") {
return "workspace.git.pr.activity.requestedChanges";
}
return "workspace.git.pr.activity.reviewed";
}
@@ -63,7 +63,7 @@ import {
canAddPullRequestActivityToChat,
} from "./context-attachment";
import { ChecksSection, getCheckIdentity } from "./checks-section";
import { getActivityVerb, getStateLabel } from "./data";
import { getActivityVerbKey, getStateLabelKey } from "./data";
import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
import { CheckPresentationIcon } from "@/git/check-presentation.view";
@@ -538,7 +538,7 @@ export function PullRequestPane({
<View style={styles.metaLine}>
<StateIcon size={14} uniProps={statePresentation.iconColor} />
<Text style={stateLabelStyle(data.state)} testID="pr-pane-state">
{getStateLabel(data.state)}
{t(getStateLabelKey(data.state))}
</Text>
{nativeHeaderMeta}
{repoIdentity ? (
@@ -760,7 +760,8 @@ function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: nu
}
function ActivityVerb({ activity }: { activity: PrPaneActivity }) {
const verb = getActivityVerb(activity).toLowerCase();
const { t } = useTranslation();
const verb = t(getActivityVerbKey(activity)).toLowerCase();
if (activity.kind === "review" && activity.reviewState === "approved") {
return (
<View style={styles.verbGroup}>
@@ -1065,8 +1066,12 @@ function ThreadBlock({
? formatPullRequestThreadPath(thread.location)
: t("workspace.git.pr.thread.discussion")}
</Text>
{thread.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
{thread.location?.isOutdated ? <StatusBadge label="Outdated" /> : null}
{thread.isResolved ? (
<StatusBadge label={t("workspace.git.pr.thread.resolved")} variant="success" />
) : null}
{thread.location?.isOutdated ? (
<StatusBadge label={t("workspace.git.pr.thread.outdated")} />
) : null}
<View style={styles.headerTrailing}>
{collapsed ? (
<View style={styles.threadCount}>
+19
View File
@@ -76,6 +76,7 @@ const untranslatedLocalFallbacks = [
const pullRequestPanelSources = [
"git/pull-request-panel/pane.tsx",
"git/pull-request-panel/data.ts",
"git/pull-request-panel/checks-section.tsx",
"components/sidebar/sidebar-status-list.tsx",
] as const;
@@ -87,6 +88,16 @@ const untranslatedPullRequestPanelLabels = [
"Adding...",
"Comment actions",
"Thread actions",
"Commented",
"Approved",
"Requested changes",
"Reviewed",
"Draft",
"Merged",
"Closed",
"Open",
"Resolved",
"Outdated",
] as const;
function findUntranslatedPullRequestPanelLabels(): string[] {
@@ -244,6 +255,14 @@ describe("translation resources", () => {
expect(pr.actions.addingToChat).not.toBe(englishPr.actions.addingToChat);
expect(pr.accessibility.commentActions).not.toBe(englishPr.accessibility.commentActions);
expect(pr.accessibility.threadActions).not.toBe(englishPr.accessibility.threadActions);
for (const verb of Object.keys(englishPr.activity) as (keyof typeof englishPr.activity)[]) {
expect(pr.activity[verb]).not.toBe(englishPr.activity[verb]);
}
for (const state of Object.keys(englishPr.states) as (keyof typeof englishPr.states)[]) {
expect(pr.states[state]).not.toBe(englishPr.states[state]);
}
expect(pr.thread.resolved).not.toBe(englishPr.thread.resolved);
expect(pr.thread.outdated).not.toBe(englishPr.thread.outdated);
expect(resource.sidebar.statusGroupAccessibility).not.toBe(
en.sidebar.statusGroupAccessibility,
);
+2
View File
@@ -1070,6 +1070,8 @@ export const ar: TranslationResources = {
},
thread: {
discussion: "سلسلة المناقشة",
resolved: "تم الحل",
outdated: "قديم",
},
errors: {
statusLoadFailed: "غير قادر على تحميل حالة طلب السحب",
+3 -1
View File
@@ -1078,6 +1078,8 @@ export const en = {
},
thread: {
discussion: "Discussion thread",
resolved: "Resolved",
outdated: "Outdated",
},
errors: {
statusLoadFailed: "Unable to load pull request status",
@@ -2160,7 +2162,7 @@ export const en = {
globalTitle: "Enable plugins",
globalHint: "Global switch for every configured plugin",
sourceLabel: "Plugin source",
sourcePlaceholder: "Directory, Git URL, or npm package",
sourcePlaceholder: "owner/slug, directory, Git URL, or npm package",
docs: "Docs",
install: "Install plugin",
installing: "Installing…",
+6 -4
View File
@@ -1093,16 +1093,18 @@ export const es: TranslationResources = {
open: "Abierto",
},
activity: {
commented: "Comentado",
approved: "Aprobado",
requestedChanges: "Cambios solicitados",
reviewed: "Revisado",
commented: "Comentó",
approved: "Aprobó",
requestedChanges: "Solicitó cambios",
reviewed: "Revisó",
},
time: {
justNow: "En este momento",
},
thread: {
discussion: "Hilo de discusión",
resolved: "Resuelto",
outdated: "Desactualizado",
},
errors: {
statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción",
+2
View File
@@ -1094,6 +1094,8 @@ export const fr: TranslationResources = {
},
thread: {
discussion: "Fil de discussion",
resolved: "Résolu",
outdated: "Obsolète",
},
errors: {
statusLoadFailed: "Impossible de charger le statut de la pull request",
+2
View File
@@ -1083,6 +1083,8 @@ export const ja: TranslationResources = {
},
thread: {
discussion: "ディスカッションスレッド",
resolved: "解決済み",
outdated: "古い",
},
errors: {
statusLoadFailed: "プルリクエストのステータスを読み込めません",
+2
View File
@@ -1077,6 +1077,8 @@ export const ko: TranslationResources = {
},
thread: {
discussion: "토론 스레드",
resolved: "해결됨",
outdated: "오래됨",
},
errors: {
statusLoadFailed: "풀 리퀘스트 상태를 불러올 수 없습니다",
@@ -27,7 +27,7 @@ export const pluginSettings = {
globalTitle: "تفعيل الإضافات",
globalHint: "مفتاح عام لكل الإضافات المضبوطة",
sourceLabel: "مصدر الإضافة",
sourcePlaceholder: "مجلد أو رابط Git أو حزمة npm",
sourcePlaceholder: "owner/slug, مجلد أو رابط Git أو حزمة npm",
docs: "الوثائق",
install: "تثبيت الإضافة",
installing: "جارٍ التثبيت…",
@@ -82,7 +82,7 @@ export const pluginSettings = {
globalTitle: "Activar plugins",
globalHint: "Interruptor global para todos los plugins configurados",
sourceLabel: "Fuente del plugin",
sourcePlaceholder: "Directorio, URL de Git o paquete npm",
sourcePlaceholder: "owner/slug, Directorio, URL de Git o paquete npm",
docs: "Documentación",
install: "Instalar plugin",
installing: "Instalando…",
@@ -137,7 +137,7 @@ export const pluginSettings = {
globalTitle: "Activer les plugins",
globalHint: "Interrupteur global pour tous les plugins configurés",
sourceLabel: "Source du plugin",
sourcePlaceholder: "Dossier, URL Git ou paquet npm",
sourcePlaceholder: "owner/slug, Dossier, URL Git ou paquet npm",
docs: "Documentation",
install: "Installer le plugin",
installing: "Installation…",
@@ -192,7 +192,7 @@ export const pluginSettings = {
globalTitle: "プラグインを有効化",
globalHint: "設定済みプラグインすべての共通スイッチ",
sourceLabel: "プラグインソース",
sourcePlaceholder: "ディレクトリ、Git URL、npm パッケージ",
sourcePlaceholder: "owner/slug, ディレクトリ、Git URL、npm パッケージ",
docs: "ドキュメント",
install: "プラグインをインストール",
installing: "インストール中…",
@@ -246,7 +246,7 @@ export const pluginSettings = {
globalTitle: "플러그인 활성화",
globalHint: "설정된 모든 플러그인의 전역 스위치",
sourceLabel: "플러그인 소스",
sourcePlaceholder: "디렉터리, Git URL 또는 npm 패키지",
sourcePlaceholder: "owner/slug, 디렉터리, Git URL 또는 npm 패키지",
docs: "문서",
install: "플러그인 설치",
installing: "설치 중…",
@@ -301,7 +301,7 @@ export const pluginSettings = {
globalTitle: "Ativar plugins",
globalHint: "Controle global para todos os plugins configurados",
sourceLabel: "Fonte do plugin",
sourcePlaceholder: "Diretório, URL Git ou pacote npm",
sourcePlaceholder: "owner/slug, Diretório, URL Git ou pacote npm",
docs: "Documentação",
install: "Instalar plugin",
installing: "Instalando…",
@@ -356,7 +356,7 @@ export const pluginSettings = {
globalTitle: "Включить плагины",
globalHint: "Общий переключатель для всех настроенных плагинов",
sourceLabel: "Источник плагина",
sourcePlaceholder: "Каталог, URL Git или пакет npm",
sourcePlaceholder: "owner/slug, Каталог, URL Git или пакет npm",
docs: "Документация",
install: "Установить плагин",
installing: "Установка…",
@@ -410,7 +410,7 @@ export const pluginSettings = {
globalTitle: "启用插件",
globalHint: "所有已配置插件的全局开关",
sourceLabel: "插件来源",
sourcePlaceholder: "目录、Git URL 或 npm 包",
sourcePlaceholder: "owner/slug, 目录、Git URL 或 npm 包",
docs: "文档",
install: "安装插件",
installing: "正在安装…",
+4 -2
View File
@@ -1084,15 +1084,17 @@ export const ptBR: TranslationResources = {
},
activity: {
commented: "Comentou",
approved: "Aprovado",
approved: "Aprovou",
requestedChanges: "Solicitou alterações",
reviewed: "Revisado",
reviewed: "Revisou",
},
time: {
justNow: "agora mesmo",
},
thread: {
discussion: "Tópico de discussão",
resolved: "Resolvido",
outdated: "Desatualizado",
},
errors: {
statusLoadFailed: "Não foi possível carregar o status da pull request",
+2
View File
@@ -1087,6 +1087,8 @@ export const ru: TranslationResources = {
},
thread: {
discussion: "Ветка обсуждения",
resolved: "Решено",
outdated: "Устарело",
},
errors: {
statusLoadFailed: "Не удалось загрузить статус PR",
+6 -4
View File
@@ -1046,22 +1046,24 @@ export const zhCN: TranslationResources = {
},
},
states: {
draft: "Draft",
merged: "已 merge",
draft: "草稿",
merged: "已合并",
closed: "已关闭",
open: "Open",
open: "开放",
},
activity: {
commented: "已评论",
approved: "已批准",
requestedChanges: "请求修改",
reviewed: "已 review",
reviewed: "已审查",
},
time: {
justNow: "刚刚",
},
thread: {
discussion: "讨论主题",
resolved: "已解决",
outdated: "已过时",
},
errors: {
statusLoadFailed: "无法加载 Pull Request 状态",
@@ -358,14 +358,21 @@ test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])(
stdio: "ignore",
});
const exited = new Promise((resolve) => child!.once("exit", resolve));
// The lock file exists before its contents identify the supervisor.
let supervisorPid: number | undefined;
await expect
.poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 })
.toBe(true);
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
.poll(
async () => {
supervisorPid = (await readDaemonInstance(home))?.pid;
return supervisorPid;
},
{ timeout: 10_000 },
)
.toBeTypeOf("number");
await f.ok(["status", "--home", home]);
child.kill("SIGINT");
await exited;
expect(() => process.kill(lock.pid, 0)).toThrow();
expect(() => process.kill(supervisorPid!, 0)).toThrow();
expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped");
} finally {
child?.kill("SIGTERM");
+6 -1
View File
@@ -58,7 +58,12 @@ async function probeDaemonStatus(
let live: Record<string, unknown> = {};
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
try {
client = await connectToDaemon({ target, instance: instance ?? undefined, timeout: 1_500 });
// An explicit host may sit behind SSH or a relay, so it gets the usual connect budget.
client = await connectToDaemon({
target,
instance: instance ?? undefined,
timeout: target.kind === "endpoint" ? undefined : 1_500,
});
} catch (error) {
const failure = buildDaemonConnectionCommandError({ target, error });
if (target.kind === "endpoint") throw failure;
@@ -255,12 +255,12 @@ describe("plugin management commands", () => {
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const command = createPluginCommand();
await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], {
await command.parseAsync(["install", "git:owner/monorepo", "--path", "plugins/review"], {
from: "user",
});
expect(installPluginSource).toHaveBeenCalledWith({
source: "owner/monorepo:plugins/review",
source: "git:owner/monorepo:plugins/review",
});
stderr.mockRestore();
});
+19 -3
View File
@@ -1,9 +1,12 @@
import path from "node:path";
import { expandUserPath } from "../../classify.js";
import { createInterface } from "node:readline/promises";
import { reviewPluginUpdates, type UpdateOutcome } from "./update.js";
import { Command } from "commander";
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
import {
formatPluginSourceReference,
parsePluginSourceReference,
formatPluginIdentity,
} from "@getpaseo/protocol/plugin-source-reference";
import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js";
@@ -125,7 +128,18 @@ export async function runPluginInstallCommand(
process.stderr.write(
"Trusting plugin code: server code and preparation commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n",
);
const sourceReference = formatPluginSourceReference(source, options.path);
const reference = parsePluginSourceReference(source);
let resolvedSource = source;
if (reference.kind === "directory") {
const directory = expandUserPath(reference.source);
// An absolute path can target a daemon on a different operating system.
const absoluteDirectory =
path.posix.isAbsolute(directory) || path.win32.isAbsolute(directory)
? directory
: path.resolve(directory);
resolvedSource = formatPluginSourceReference(absoluteDirectory, reference.pluginPath);
}
const sourceReference = formatPluginSourceReference(resolvedSource, options.path);
const data = await withPluginSourceClient(options.daemonTarget, (client) =>
client.installPluginSource({
source: sourceReference,
@@ -215,10 +229,12 @@ export function createPluginCommand(): Command {
plugin
.command("install")
.alias("add")
.description("Trust and install a plugin from a directory, Git repository, or npm package")
.description(
"Trust and install a plugin from a registry, directory, Git repository, or npm package",
)
.argument(
"<source>",
"Host directory, GitHub owner/repo, git: or npm: source, optionally followed by :plugin/path",
"Registry owner/slug or host/owner/slug, host directory, explicit git:owner/repo or Git URL, or npm: source",
)
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
.option("--ref <ref>", "Git branch, tag, or commit")
@@ -1,4 +1,4 @@
import { execFile } from "node:child_process";
import { exec, execFile } from "node:child_process";
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
@@ -9,6 +9,7 @@ import { scaffoldPluginDirectory } from "./scaffold.js";
const directories: string[] = [];
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc");
async function typecheckPlugin(directory: string): Promise<void> {
@@ -28,6 +29,47 @@ afterEach(async () => {
});
describe("plugin scaffold", () => {
it("creates an author overview beside the manifest", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "hello-plugin");
await scaffoldPluginDirectory(directory);
const overview = await readFile(path.join(directory, "OVERVIEW.md"), "utf8");
const comments = overview.match(/<!--[\s\S]*?-->/g) ?? [];
expect(comments).toHaveLength(1);
expect(overview.replace(/<!--[\s\S]*?-->/g, "").trim()).toBe("");
expect(overview).toContain("required to list");
expect(overview).toContain("README");
expect(overview).toContain("choosing a plugin in Paseo");
expect(overview).toContain("Length follows complexity");
expect(overview).toContain("1. Describe what your plugin is and does");
expect(overview).toContain("2. Explain how it works only when it is not obvious");
expect(overview).toContain("3. Explain setup when needed");
expect(overview).toContain("4. Explain capabilities and settings");
expect(overview).toContain("permissions");
expect(overview).toContain("known limits");
expect(overview).not.toContain("This plugin entry was imported");
const packageJson = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
expect(packageJson.files).toContain("OVERVIEW.md");
expect(await readdir(directory)).toContain("paseo-plugin.json");
});
it("includes the author overview in the packed npm artifact", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "packed-plugin");
await scaffoldPluginDirectory(directory);
const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", {
cwd: directory,
});
const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[];
expect(artifact.files.map((file) => file.path)).toEqual(
expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]),
);
});
it(
"creates a standalone split-runtime project that typechecks",
{ timeout: 15_000 },
@@ -51,6 +93,8 @@ describe("plugin scaffold", () => {
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual(
{
id: "hello-plugin",
$comment: expect.stringContaining('"icon": "assets/icon.png"'),
media: [],
requirements: { paseo: `>=${cliPackageJson.version}` },
},
);
@@ -60,6 +104,7 @@ describe("plugin scaffold", () => {
version: "0.0.0",
files: [
"paseo-plugin.json",
"OVERVIEW.md",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
+41 -1
View File
@@ -3,6 +3,34 @@ import path from "node:path";
import { PluginIdSchema } from "@getpaseo/protocol/messages";
import { resolveCliVersion } from "../../version.js";
const OVERVIEW = `<!--
OVERVIEW.md is required to list your plugin in the registry. Keep it beside
paseo-plugin.json in your repository at the pinned commit and in your npm package.
Replace this comment with useful facts before publishing.
This overview is for people choosing a plugin in Paseo, on the plugin page under
its install command. README assumes a GitHub audience and includes technical
details, installation instructions, and badges. Long, AI-generated READMEs leave
people reading past that material to understand what the plugin does.
Write these parts in order, including only what helps someone decide to install:
1. Describe what your plugin is and does in plain terms first.
2. Explain how it works only when it is not obvious.
3. Explain setup when needed: settings, accounts, tokens, providers, tools, or
other plugins. Include applicable daemon version and operating system
requirements. Setup guidance is allowed; installation instructions are not.
4. Explain capabilities and settings worth understanding, what each option does,
what the plugin reads or sends and where, permissions, and known limits.
Length follows complexity; a theme needs one paragraph. Choose headings only
when they help. Use sentence case and plain factual language, with no em dashes.
Omit installation commands, badges, changelog, contributing or license sections,
marketing, and unsupported claims. Avoid implementation filler such as empty
cleanup functions, theme-token field lists, or lists of absent features.
-->
`;
const TSCONFIG = {
compilerOptions: {
target: "ES2020",
@@ -155,6 +183,7 @@ export async function scaffoldPluginDirectory(
version: "0.0.0",
files: [
"paseo-plugin.json",
"OVERVIEW.md",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
@@ -177,8 +206,19 @@ export async function scaffoldPluginDirectory(
const files = new Map<string, string>([
[
"paseo-plugin.json",
`${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`,
`${JSON.stringify(
{
id,
$comment:
'Add "name": "My plugin" to set a display name. Add "icon": "assets/icon.png" for a PNG inside the package. Add image or video paths (relative to this manifest) or HTTPS URLs to media. Include local assets in package.json files.',
media: [],
requirements: { paseo: `>=${version}` },
},
null,
2,
)}\n`,
],
["OVERVIEW.md", OVERVIEW],
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
["index.client.tsx", CLIENT_ENTRY],
@@ -0,0 +1,57 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { runLocalPaseo } from "./helpers/local-cli.ts";
import { startTestDaemon } from "./helpers/test-daemon.ts";
console.log("=== Daemon Status over SSH ===\n");
// The stub stands in for OpenSSH; Windows cannot run it as `ssh` from PATH.
if (process.platform === "win32") {
console.log("Skipped on Windows\n");
process.exit(0);
}
const SSH_SETUP_MS = 2500;
const root = await mkdtemp(join(tmpdir(), "paseo-status-ssh-"));
let daemon: Awaited<ReturnType<typeof startTestDaemon>> | undefined;
try {
daemon = await startTestDaemon();
// Opens the -W stream only after the delay a real SSH handshake takes.
await writeFile(
join(root, "ssh"),
`#!${process.execPath}
const net = require("node:net");
const args = process.argv.slice(2);
const [host, port] = args[args.indexOf("-W") + 1].split(":");
setTimeout(() => {
const socket = net.connect(Number(port), host);
process.stdin.pipe(socket);
socket.pipe(process.stdout);
socket.on("close", () => process.exit(0));
}, ${SSH_SETUP_MS});
`,
{ mode: 0o700 },
);
console.log("Test 1: status reaches a daemon whose SSH stream takes 2.5s to open");
const result = await runLocalPaseo(
["daemon", "status", "--host", `ssh://build-box?daemonPort=${daemon.port}`, "--json"],
{ PATH: `${root}${delimiter}${process.env.PATH ?? ""}` },
);
assert.strictEqual(result.exitCode, 0, result.stderr);
const status = JSON.parse(result.stdout);
assert.strictEqual(status.connectedDaemon, "reachable", JSON.stringify(status));
assert.strictEqual(typeof status.serverId, "string");
console.log("✓ status waits for the SSH stream like other commands\n");
} finally {
await daemon?.stop();
await rm(root, { recursive: true, force: true });
}
console.log("=== Daemon Status over SSH Tests Passed ===");
@@ -8,11 +8,13 @@ import {
} from "../../../../scripts/test-support/npm-registry.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { once } from "node:events";
import { WebSocket, WebSocketServer } from "ws";
import { connectToDaemon } from "../../src/utils/client.ts";
import { createE2ETestContext } from "../helpers/test-daemon.ts";
@@ -45,6 +47,81 @@ async function main(): Promise<void> {
);
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
// Observe real CLI requests while forwarding them to the isolated daemon.
const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 });
const sources: string[] = [];
proxy.on("connection", (socket, request) => {
const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, {
headers: { authorization: request.headers.authorization ?? "" },
});
const ready = once(upstream, "open");
socket.on("message", async (data, isBinary) => {
const message = JSON.parse(data.toString());
if (
message.type === "session" &&
message.message.type === "plugin.source.install.request"
) {
sources.push(message.message.source);
}
await ready;
upstream.send(data, { binary: isBinary });
});
upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary }));
socket.on("close", () => upstream.close());
upstream.on("close", () => socket.close());
});
await once(proxy, "listening");
const address = proxy.address();
assert.ok(address && typeof address !== "string");
const sibling = path.join(context.workDir, "x");
const caller = path.join(context.workDir, "caller");
await mkdir(caller);
await mkdir(path.join(sibling, "sub"), { recursive: true });
for (const target of [caller, path.join(sibling, "sub"), context.paseoHome]) {
await writeFile(
path.join(target, "paseo-plugin.json"),
JSON.stringify({
id: "relative-cli",
requirements: { paseo: `>=${resolveCliVersion()}` },
}),
);
await writeFile(path.join(target, "index.server.ts"), pluginSource);
}
assert.notEqual(caller, process.cwd()); // The daemon inherits the test runner's cwd.
try {
for (const [source, expectedSource, expectedDirectory, extraArgs] of [
[".", caller, caller, []],
["../x:sub", `${sibling}:sub`, path.join(sibling, "sub"), []],
["../x", `${sibling}:sub`, path.join(sibling, "sub"), ["--path", "sub"]],
["~", context.paseoHome, context.paseoHome, []],
["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []],
] as const) {
sources.length = 0;
const result = await context.paseo(
["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"],
{ cwd: caller },
);
assert.deepEqual(sources, [expectedSource]);
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(JSON.parse(result.stdout).path, expectedDirectory);
const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]);
assert.equal(removed.exitCode, 0, removed.stderr);
}
// An absolute path belongs to the daemon's platform, which can differ from the CLI's.
const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`;
sources.length = 0;
const missing = await context.paseo(
["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"],
{ cwd: caller },
);
assert.deepEqual(sources, [windowsSource]);
assert.equal(missing.exitCode, 1);
assert.match(missing.stderr, /Plugin directory does not exist/);
} finally {
for (const socket of proxy.clients) socket.close();
await promisify(proxy.close.bind(proxy))();
}
const install = await context.paseo(["plugin", "install", directory, "--json"]);
assert.equal(install.exitCode, 0, install.stderr);
assert.equal(JSON.parse(install.stdout).id, "cli-e2e");
@@ -144,6 +144,19 @@ const title = "Hello";
);
});
it("keeps an Astro expression open through nested template strings", () => {
const code = "<p>{`a ${`}`} b`}</p>";
const tokens = highlightCode(code, "Nested.astro").flat();
expect(tokens).toEqual(
expect.arrayContaining([
{ text: "`}`", style: "string" },
{ text: " b`", style: "string" },
]),
);
});
it("highlights Vue SFCs across script, template, and style", () => {
const code = [
'<script lang="ts">',
+2 -78
View File
@@ -14,6 +14,7 @@ import {
import { parser as cssParser } from "@lezer/css";
import { parser as htmlParser } from "@lezer/html";
import { parser as jsParser } from "@lezer/javascript";
import { findClosingBrace } from "../js-scanner.js";
interface Range {
from: number;
@@ -23,88 +24,11 @@ interface Range {
const jsxParser = jsParser.configure({ dialect: "ts jsx" });
const typescriptParser = jsParser.configure({ dialect: "ts" });
function isSpace(code: number): boolean {
return code === 32 || code === 9 || code === 10 || code === 13;
}
function isRegexStart(text: string, position: number): boolean {
let previous = position - 1;
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
if (previous < 0) return true;
const character = text[previous];
if (/[)\]}<"'`\d]/.test(character)) return false;
const code = text.charCodeAt(previous);
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
if (!/[A-Za-z_$]/.test(character)) return true;
let start = previous;
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
const keyword = text.slice(start + 1, previous + 1);
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
keyword,
);
}
function skipQuotedText(text: string, opening: number): number {
const quote = text.charCodeAt(opening);
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 92) position++;
else if (code === quote) return position;
}
return text.length - 1;
}
function skipLineComment(text: string, opening: number): number {
const newline = text.indexOf("\n", opening + 2);
return newline >= 0 ? newline : text.length - 1;
}
function skipBlockComment(text: string, opening: number): number {
const closing = text.indexOf("*/", opening + 2);
return closing >= 0 ? closing + 1 : text.length - 1;
}
function skipRegex(text: string, opening: number): number {
let isInCharacterClass = false;
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 10 || code === 13) return position;
if (code === 92) position++;
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
else if (!isInCharacterClass && code === 47) return position;
}
return text.length - 1;
}
function findClosingBrace(text: string, opening: number): number {
let depth = 0;
for (let position = opening; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 47 && text.charCodeAt(position + 1) === 47) {
position = skipLineComment(text, position);
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
position = skipBlockComment(text, position);
} else if (code === 47 && isRegexStart(text, position)) {
position = skipRegex(text, position);
} else if (code === 34 || code === 39 || code === 96) {
position = skipQuotedText(text, position);
} else if (code === 123) {
depth++;
} else if (code === 125 && --depth === 0) {
return position;
}
}
return -1;
}
function findExpressions(text: string): Range[] {
const ranges: Range[] = [];
for (let position = 0; position < text.length; position++) {
if (text.charCodeAt(position) !== 123) continue;
const closing = findClosingBrace(text, position);
const closing = findClosingBrace(text, position + 1);
if (closing < 0) break;
ranges.push({ from: position, to: closing });
position = closing;
+100
View File
@@ -0,0 +1,100 @@
// Scans JavaScript embedded in a template (Astro `{ }` expressions, Vue `{{ }}`
// interpolations) for the brace that closes it, without parsing the code.
function isSpace(code: number): boolean {
return code === 32 || code === 9 || code === 10 || code === 13;
}
function isRegexStart(text: string, position: number): boolean {
let previous = position - 1;
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
if (previous < 0) return true;
const character = text[previous];
if (/[)\]}<"'`\d]/.test(character)) return false;
const code = text.charCodeAt(previous);
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
if (!/[A-Za-z_$]/.test(character)) return true;
let start = previous;
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
const keyword = text.slice(start + 1, previous + 1);
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
keyword,
);
}
// Template strings nested deeper than this are treated as running to the end.
const MAX_TEMPLATE_NESTING = 32;
function skipQuotedText(text: string, opening: number, nesting = 0): number {
const quote = text.charCodeAt(opening);
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 92) position++;
else if (code === quote) return position;
else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) {
position = skipTemplateSubstitution(text, position + 2, nesting);
}
}
return text.length - 1;
}
function skipTemplateSubstitution(text: string, start: number, nesting: number): number {
if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1;
const closing = findClosingBrace(text, start, () => true, nesting + 1);
return closing >= 0 ? closing : text.length - 1;
}
function skipLineComment(text: string, opening: number): number {
const newline = text.indexOf("\n", opening + 2);
return newline >= 0 ? newline : text.length - 1;
}
function skipBlockComment(text: string, opening: number): number {
const closing = text.indexOf("*/", opening + 2);
return closing >= 0 ? closing + 1 : text.length - 1;
}
function skipRegex(text: string, opening: number): number {
let isInCharacterClass = false;
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 10 || code === 13) return position;
if (code === 92) position++;
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
else if (!isInCharacterClass && code === 47) return position;
}
return text.length - 1;
}
// Finds the first `}` outside nested braces, strings, comments, and regexes
// for which `isEnd` holds.
export function findClosingBrace(
text: string,
start: number,
isEnd: (position: number) => boolean = () => true,
nesting = 0,
): number {
let depth = 0;
for (let position = start; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 47 && text.charCodeAt(position + 1) === 47) {
position = skipLineComment(text, position);
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
position = skipBlockComment(text, position);
} else if (code === 47 && isRegexStart(text, position)) {
position = skipRegex(text, position);
} else if (code === 34 || code === 39 || code === 96) {
position = skipQuotedText(text, position, nesting);
} else if (code === 123) {
depth++;
} else if (code === 125 && depth === 0) {
if (isEnd(position)) return position;
} else if (code === 125) {
depth--;
}
}
return -1;
}
+1 -98
View File
@@ -8,6 +8,7 @@ import {
import { parser as cssParser } from "@lezer/css";
import { parser as htmlParser } from "@lezer/html";
import { parser as jsParser } from "@lezer/javascript";
import { findClosingBrace } from "../js-scanner.js";
interface Range {
from: number;
@@ -17,104 +18,6 @@ interface Range {
const typescriptParser = jsParser.configure({ dialect: "ts" });
const expressionParser = jsParser.configure({ top: "SingleExpression" });
function isSpace(code: number): boolean {
return code === 32 || code === 9 || code === 10 || code === 13;
}
function isRegexStart(text: string, position: number): boolean {
let previous = position - 1;
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
if (previous < 0) return true;
const character = text[previous];
if (/[)\]}<"'`\d]/.test(character)) return false;
const code = text.charCodeAt(previous);
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
if (!/[A-Za-z_$]/.test(character)) return true;
let start = previous;
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
const keyword = text.slice(start + 1, previous + 1);
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
keyword,
);
}
// Template strings nested deeper than this are treated as running to the end.
const MAX_TEMPLATE_NESTING = 32;
function skipQuotedText(text: string, opening: number, nesting = 0): number {
const quote = text.charCodeAt(opening);
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 92) position++;
else if (code === quote) return position;
else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) {
position = skipTemplateSubstitution(text, position + 2, nesting);
}
}
return text.length - 1;
}
function skipTemplateSubstitution(text: string, start: number, nesting: number): number {
if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1;
const closing = findClosingBrace(text, start, () => true, nesting + 1);
return closing >= 0 ? closing : text.length - 1;
}
function skipLineComment(text: string, opening: number): number {
const newline = text.indexOf("\n", opening + 2);
return newline >= 0 ? newline : text.length - 1;
}
function skipBlockComment(text: string, opening: number): number {
const closing = text.indexOf("*/", opening + 2);
return closing >= 0 ? closing + 1 : text.length - 1;
}
function skipRegex(text: string, opening: number): number {
let isInCharacterClass = false;
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 10 || code === 13) return position;
if (code === 92) position++;
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
else if (!isInCharacterClass && code === 47) return position;
}
return text.length - 1;
}
// Finds the first `}` outside nested braces, strings, comments, and regexes
// for which `isEnd` holds.
function findClosingBrace(
text: string,
start: number,
isEnd: (position: number) => boolean,
nesting = 0,
): number {
let depth = 0;
for (let position = start; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 47 && text.charCodeAt(position + 1) === 47) {
position = skipLineComment(text, position);
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
position = skipBlockComment(text, position);
} else if (code === 47 && isRegexStart(text, position)) {
position = skipRegex(text, position);
} else if (code === 34 || code === 39 || code === 96) {
position = skipQuotedText(text, position, nesting);
} else if (code === 123) {
depth++;
} else if (code === 125 && depth === 0) {
if (isEnd(position)) return position;
} else if (code === 125) {
depth--;
}
}
return -1;
}
function findInterpolationEnd(text: string, start: number): number {
return findClosingBrace(text, start, (position) => text.charCodeAt(position + 1) === 125);
}
@@ -23,6 +23,27 @@ describe("plugin protocol compatibility", () => {
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
},
);
it("preserves manifest metadata in plugin list responses", () => {
const message = {
type: "plugin.list.response",
payload: {
requestId: "metadata",
plugins: [
{
id: "review",
name: "Review tools",
icon: "assets/icon.png",
media: ["screenshot.png", "https://example.com/demo.mp4"],
path: "/plugins/review",
enabled: false,
status: "disabled",
},
],
},
};
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
});
it("parses plugin timeline append messages and advertises the capability", () => {
expect(
SessionInboundMessageSchema.parse({
+3
View File
@@ -6632,6 +6632,9 @@ export const PluginNpmInstallationSchema = z.object({
export const PluginListItemSchema = z.object({
id: PluginIdSchema,
name: z.string().optional(),
icon: z.string().optional(),
media: z.array(z.string()).optional(),
description: z.string().optional(),
path: z.string(),
enabled: z.boolean(),
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { pluginOverviewUrl } from "./plugin-overview.js";
describe("plugin overview destinations", () => {
it.each([
undefined,
"",
"./docs",
"../docs",
"/login",
"//example.com",
"#section",
"?login",
"http://example.com",
"mailto:hello@example.com",
"ftp://example.com",
"javascript:alert(1)",
"data:image/png;base64,AAAA",
"vbscript:msgbox(1)",
"javascript&#58;alert(1)",
"javascript%3Aalert(1)",
"javascript%253Aalert(1)",
"java\tscript:alert(1)",
" https://example.com",
"https://example.com\n",
"https:example.com",
"https:///example.com",
"https://paseo.sh@evil.example",
"https://example.com\\@evil.example",
"https://",
])("leaves %s as text", (url) => {
expect(pluginOverviewUrl(url)).toBeUndefined();
});
it.each([
"https://example.com/path?q=a&b=c#section",
"HTTPS://example.com/path",
"https://example.com/%3Cscript%3E",
])("allows %s without decoding it again", (url) => {
expect(pluginOverviewUrl(url)).toBe(url);
});
});
+49
View File
@@ -0,0 +1,49 @@
/** Presentation contract for author-controlled registry content, independent of the UI runtime. */
export const pluginOverviewPolicy = {
rawHtml: false,
elements: [
"p",
"br",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"ul",
"ol",
"li",
"strong",
"em",
"del",
"pre",
"code",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
],
link: { rel: "noopener noreferrer nofollow", target: "_blank" },
} as const;
/** Validate decoded Markdown destinations; never resolve against the reader's page or repository. */
export function pluginOverviewUrl(value: string | undefined): string | undefined {
// URL parsers discard controls and normalize backslashes; reject those ambiguous spellings.
// eslint-disable-next-line no-control-regex
if (!value || !/^https:\/\/[^/?#]/i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value))
return undefined;
try {
const url = new URL(value);
if (url.protocol !== "https:" || !url.hostname || url.username || url.password)
return undefined;
return value;
} catch {
return undefined;
}
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { pluginMediaKind } from "./plugin-registry.js";
describe("plugin media kind", () => {
it.each([
["https://cdn.example.com/a/preview.png", "image"],
["https://cdn.example.com/a/preview.JPG", "image"],
["https://cdn.example.com/a/preview.jpeg", "image"],
["https://cdn.example.com/a/preview.webp", "image"],
["https://cdn.example.com/a/preview.gif", "image"],
["https://cdn.example.com/a/demo.mp4", "video"],
["https://cdn.example.com/a/demo.WebM", "video"],
["https://cdn.example.com/a/demo.mp4?raw=true#t=2", "video"],
["https://cdn.example.com/mp4/preview.png?format=mp4", "image"],
] as const)("reads %s as %s from its path extension", (url, kind) => {
expect(pluginMediaKind(url)).toBe(kind);
});
});
+11 -1
View File
@@ -39,13 +39,21 @@ export const PublishedPluginSchema = z.object({
repository: z.object({ url: z.string().url(), commit: z.string().optional() }),
artifact: PluginRegistryArtifactSchema,
icon: z.string().url().optional(),
screenshots: z.array(z.string().url()),
/** Images and videos in display order; see `pluginMediaKind`. */
media: z.array(z.string().url()),
submittedAt: z.string(),
reviewedAt: z.string(),
updatedAt: z.string(),
publishedAt: z.string(),
installs: z.number().int().nonnegative().optional(),
});
const VIDEO_EXTENSIONS = new Set(["mp4", "webm"]);
/** Whether a registry media entry is a video or an image, from its path extension. */
export function pluginMediaKind(url: string): "image" | "video" {
const extension = new URL(url).pathname.split(".").pop()?.toLowerCase() ?? "";
return VIDEO_EXTENSIONS.has(extension) ? "video" : "image";
}
export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() });
export const PluginRegistryIndexSchema = z.object({
schemaVersion: z.literal(1),
@@ -54,6 +62,8 @@ export const PluginRegistryIndexSchema = z.object({
z.object({ slug: z.string(), label: z.string(), description: z.string().optional() }),
),
plugins: z.array(PublishedPluginSchema),
/** Plugin IDs the maintainer features, in display order. An ID may name a plugin that is not listed. */
featured: z.array(z.string()),
generatedAt: z.string(),
});
export type PluginRegistryIdentity = z.infer<typeof PluginRegistryIdentitySchema>;
@@ -3,6 +3,35 @@ import { describe, expect, it } from "vitest";
import { parsePluginSourceReference } from "./plugin-source-reference.js";
describe("plugin source references", () => {
it.each([
".",
"..",
"./owner/slug",
"../owner/slug",
"/owner/slug",
"~/owner/slug",
"~",
"C:\\",
"C:/",
"D:\\plugins\\repository",
"D:/plugins/repository",
"\\\\server\\share",
"\\\\server\\share\\plugins\\review",
".\\plugins\\review",
"..\\plugins\\review",
])("recognizes the explicit directory %s by shape", (source) => {
expect(parsePluginSourceReference(source)).toEqual({
kind: "directory",
source,
pluginPath: undefined,
});
expect(parsePluginSourceReference(`${source}:plugins/review`)).toEqual({
kind: "directory",
source,
pluginPath: "plugins/review",
});
});
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],
@@ -16,6 +45,7 @@ describe("plugin source references", () => {
["git:file:///repo:plugins/main", "git:file:///repo", "plugins/main"],
["plugins.example.com:8443/owner/slug", "plugins.example.com:8443/owner/slug", undefined],
["owner/repository", "owner/repository", undefined],
["plugins/foo", "plugins/foo", undefined],
["owner/repository:plugins/review", "owner/repository", "plugins/review"],
[
"https://example.test:8443/owner/repository.git",
@@ -39,10 +69,8 @@ describe("plugin source references", () => {
"file:///D:/plugins/repository",
"plugins/review",
],
["D:\\plugins\\repository", "D:\\plugins\\repository", undefined],
["D:\\plugins\\repository:plugins/review", "D:\\plugins\\repository", "plugins/review"],
])("parses %s", (reference, source, pluginPath) => {
expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath });
expect(parsePluginSourceReference(reference)).toEqual({ kind: "managed", source, pluginPath });
});
});
@@ -1,19 +1,27 @@
import { parsePluginRegistryReference } from "./plugin-registry.js";
import type { PluginInstallation } from "./messages.js";
export interface PluginSourceReference {
kind: "directory" | "managed";
source: string;
pluginPath: string | undefined;
}
export function parsePluginSourceReference(reference: string): PluginSourceReference {
if (parsePluginRegistryReference(reference)) return { source: reference, pluginPath: undefined };
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}` };
if (!prefix && /^(\.\.?(?:[/\\]|$)|\/|~|[A-Za-z]:[/\\]|\\\\)/.test(parsed.source)) {
return { kind: "directory", ...parsed };
}
if (parsePluginRegistryReference(reference))
return { kind: "managed", source: reference, pluginPath: undefined };
return { kind: "managed", ...parsed, source: `${prefix ?? ""}${parsed.source}` };
}
function splitPluginPath(reference: string, options: { scp: boolean }): PluginSourceReference {
function splitPluginPath(
reference: string,
options: { scp: boolean },
): Pick<PluginSourceReference, "source" | "pluginPath"> {
const separator = reference.lastIndexOf(":");
if (separator === -1) return { source: reference, pluginPath: undefined };
File diff suppressed because it is too large Load Diff
@@ -309,18 +309,30 @@ export async function sendPromptToAgent(
const unarchive = params.unarchive ?? true;
const record = await params.agentStorage.get(params.agentId);
let archivedAtToRestore: string | null = null;
if (record?.archivedAt) {
if (!unarchive) {
return { disposition: "turn_started" };
}
await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId);
if (await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId)) {
archivedAtToRestore = record.archivedAt;
}
}
await ensureAgentLoaded(params.agentId, {
agentManager: params.agentManager,
agentStorage: params.agentStorage,
logger: params.logger,
});
try {
await ensureAgentLoaded(params.agentId, {
agentManager: params.agentManager,
agentStorage: params.agentStorage,
logger: params.logger,
});
} catch (error) {
// A send that could not load the agent leaves it where it was: still archived.
// Concurrent sends share this load, so none of them holds a live session.
if (archivedAtToRestore) {
await params.agentManager.archiveSnapshot(params.agentId, archivedAtToRestore);
}
throw error;
}
if (params.sessionMode) {
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
@@ -76,6 +76,8 @@ import {
type AgentPersistenceHandle,
type AgentPromptContentBlock,
type AgentPromptInput,
type AgentResumePurpose,
type AgentResumeSessionOptions,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
@@ -148,6 +150,18 @@ function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promis
});
}
async function isDirectory(target: string): Promise<boolean> {
try {
return (await fs.stat(target)).isDirectory();
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return false;
}
throw error;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
@@ -497,6 +511,7 @@ interface ACPAgentSessionOptions {
capabilities: AgentCapabilityFlags;
extensionCommandsParser?: ACPExtensionCommandsParser;
handle?: AgentPersistenceHandle;
resumePurpose?: AgentResumePurpose;
agentId?: string;
launchEnv?: Record<string, string>;
waitForInitialCommands?: boolean;
@@ -1006,6 +1021,7 @@ export class ACPAgentClient implements AgentClient {
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
options?: AgentResumeSessionOptions,
): Promise<AgentSession> {
if (handle.provider !== this.provider) {
throw new Error(`Cannot resume ${handle.provider} handle with ${this.provider} provider`);
@@ -1047,6 +1063,7 @@ export class ACPAgentClient implements AgentClient {
providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers,
},
handle,
resumePurpose: options?.purpose,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
extensionCommandsParser: this.extensionCommandsParser,
@@ -1700,6 +1717,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private readonly terminalEntries = new Map<string, TerminalEntry>();
private readonly persistedHistory: AgentTimelineItem[] = [];
private readonly initialHandle?: AgentPersistenceHandle;
private readonly resumePurpose: AgentResumePurpose;
private readonly config: AgentSessionConfig;
private child: ChildProcessWithoutNullStreams | null = null;
@@ -1753,6 +1771,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.agentId = options.agentId;
this.launchEnv = options.launchEnv;
this.initialHandle = options.handle;
this.resumePurpose = options.resumePurpose ?? "interactive";
this.config = { ...config, provider: options.provider };
this.currentMode = config.modeId ?? null;
this.currentModel = config.model ?? null;
@@ -2770,6 +2789,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return {};
}
// Loading an archived agent's history runs nothing in its working directory, which
// may have been removed with its worktree. session/load still names the original
// directory; only the process starts from the home directory.
private async resolveProcessCwd(): Promise<string> {
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
return this.config.cwd;
}
return homedir();
}
private async spawnProcess(): Promise<SpawnedACPProcess> {
const prefix = await resolveProviderLaunch({
commandConfig: this.runtimeSettings?.command,
@@ -2783,7 +2812,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const command = prefix.command;
const args = [...prefix.args, ...this.defaultCommand.slice(1)];
const child = spawnProcess(command, args, {
cwd: this.config.cwd,
cwd: await this.resolveProcessCwd(),
...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings,
overlays: [this.launchEnv],
@@ -712,4 +712,56 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
expect(timeline.length).toBeGreaterThan(0);
expect(timeline[0]).toMatchObject({ id: TOOL_USE_ID });
});
test("replays a SubagentHandback report as the subagent's final message", async () => {
const handbackId = "toolu_handback";
const report = "## Verdict\n\n- **Coherent**";
writeSession({
parentLines: [taskToolUse(), taskToolResult()],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [
JSON.stringify({
type: "assistant",
isSidechain: true,
agentId: AGENT_ID,
message: {
role: "assistant",
content: [
{
type: "tool_use",
id: handbackId,
name: "SubagentHandback",
input: { message: report },
},
],
},
}),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: handbackId,
content: [
{
type: "text",
text: '{"success":true,"message":"Report delivered to your caller."}',
},
],
},
],
},
}),
],
});
const items = (await replayDescriptors()).flatMap((event) =>
event.event.type === "timeline" ? [event.event.item] : [],
);
expect(items).toEqual([{ type: "assistant_message", text: report }]);
});
});
@@ -0,0 +1,56 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
import { ClaudeAgentClient } from "./agent.js";
const ROOT_PROMPT = `Use Claude Code's native Agent tool exactly once, never Paseo tools, with this complete task:
Do not use any tools. Reply with exactly this markdown report and nothing else:
## HANDBACK_HEADING
- **HANDBACK_BOLD** item
Then reply exactly ROOT_DONE.`;
function subagentItems(events: AgentStreamEvent[]): AgentTimelineItem[] {
return events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event.item] : [],
);
}
function expectHandbackAsReport(items: AgentTimelineItem[]): void {
expect(items.filter((item) => item.type === "tool_call")).toEqual([]);
expect(
items.flatMap((item) => (item.type === "assistant_message" ? [item.text] : [])).join(""),
).toContain("## HANDBACK_HEADING\n- **HANDBACK_BOLD** item");
}
test("an auto-mode Claude subagent's handback report shows as its final message", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-subagent-handback-"));
const client = new ClaudeAgentClient({ logger: pino({ level: "warn" }) });
const session = await client.createSession({ provider: "claude", cwd, modeId: "auto" });
try {
const events = await collectSessionTurnEvents(session, ROOT_PROMPT);
expectHandbackAsReport(subagentItems(events));
const persistence = session.describePersistence();
expect(persistence).not.toBeNull();
await session.close();
const restored = await client.resumeSession(persistence!, { cwd });
try {
const replayed: AgentStreamEvent[] = [];
for await (const event of restored.streamHistory()) replayed.push(event);
expectHandbackAsReport(subagentItems(replayed));
} finally {
await restored.close();
}
} finally {
await session.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 240_000);
@@ -44,6 +44,7 @@ import {
} from "./model-manifest.js";
import { parsePartialJsonObject } from "./partial-json.js";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
import { readClaudeSubagentHandback } from "./subagent-handback.js";
import { ClaudeTaskState } from "./task-state.js";
import {
ClaudeTaskProtocolSource,
@@ -2084,6 +2085,7 @@ class ClaudeAgentSession implements AgentSession {
private toolUseCache = new Map<string, ToolUseCacheEntry>();
private toolUseIndexToId = new Map<number, string>();
private toolUseInputBuffers = new Map<string, string>();
private subagentHandbackIds = new Set<string>();
private pendingPermissions = new Map<string, PendingPermission>();
private activeForegroundTurnId: string | null = null;
private autonomousTurn: AutonomousTurnState | null = null;
@@ -5184,7 +5186,7 @@ class ClaudeAgentSession implements AgentSession {
// User SDK entries can arrive as multiple text blocks, but Paseo treats them as one message.
const userTextParts: string[] = [];
for (const block of content) {
if (!isClaudeContentChunk(block)) {
if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) {
continue;
}
this.mapBlockToTimeline(block, {
@@ -5272,6 +5274,24 @@ class ClaudeAgentSession implements AgentSession {
}
}
// A subagent's handback call is its final message, and the call's result is only an
// acknowledgement, so neither becomes a tool call.
private mapSubagentHandbackBlock(block: ClaudeContentChunk, items: AgentTimelineItem[]): boolean {
if (typeof block.tool_use_id === "string" && this.subagentHandbackIds.has(block.tool_use_id)) {
this.subagentHandbackIds.delete(block.tool_use_id);
return true;
}
const handback = readClaudeSubagentHandback(block);
if (!handback) {
return false;
}
this.subagentHandbackIds.add(handback.callId);
if (handback.report) {
items.push({ type: "assistant_message", text: handback.report });
}
return true;
}
private handleToolUseStart(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
const entry = this.upsertToolUseEntry(block);
if (!entry) {
@@ -35,4 +35,64 @@ describe("ClaudeSidechainTracker", () => {
},
});
});
it("shows a SubagentHandback report as the subagent's final message", () => {
const tracker = new ClaudeSidechainTracker({ getToolInput: () => null });
const report = " indented code\n\n## Verdict\n\n- **Coherent**";
const frames = [
{
type: "stream_event",
parent_tool_use_id: "task-1",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "handback-1",
name: "SubagentHandback",
input: {},
},
},
},
{
type: "assistant",
parent_tool_use_id: "task-1",
message: {
id: "msg-1",
content: [
{
type: "tool_use",
id: "handback-1",
name: "SubagentHandback",
input: { message: report },
},
],
},
},
{
type: "user",
parent_tool_use_id: "task-1",
message: {
content: [
{
type: "tool_result",
tool_use_id: "handback-1",
tool_name: "SubagentHandback",
content: [{ type: "text", text: '{"success":true}' }],
},
],
},
},
];
const childItems = frames
.flatMap((frame) => tracker.handleMessage(frame as unknown as SDKMessage, "task-1"))
.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "timeline"
? [event.event.item]
: [],
);
expect(childItems).toEqual([{ type: "assistant_message", text: report, messageId: "msg-1" }]);
});
});
@@ -6,6 +6,10 @@ import {
mapClaudeRunningToolCall,
} from "./tool-call-mapper.js";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import {
isClaudeSubagentHandbackToolName,
readClaudeSubagentHandback,
} from "./subagent-handback.js";
import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
@@ -91,7 +95,7 @@ export class ClaudeSidechainTracker {
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
const actionCandidates = this.extractSubAgentActionCandidates(message);
const childTimelineItems = [
...this.extractSubAgentTimelineItems(message),
...this.extractSubAgentTimelineItems(message, state),
...this.extractSubAgentToolResults(message, state),
];
let actionUpdated = false;
@@ -233,7 +237,10 @@ export class ClaudeSidechainTracker {
this.activeSidechains.clear();
}
private extractSubAgentTimelineItems(message: SDKMessage): AgentTimelineItem[] {
private extractSubAgentTimelineItems(
message: SDKMessage,
state: SubAgentActivityState,
): AgentTimelineItem[] {
if (message.type !== "assistant" || !Array.isArray(message.message?.content)) {
return [];
}
@@ -253,6 +260,18 @@ export class ClaudeSidechainTracker {
} else if (block.type === "thinking") {
const text = readTrimmedString(block.thinking);
if (text) items.push({ type: "reasoning", text });
} else {
const handback = readClaudeSubagentHandback(block);
if (!handback) continue;
// The handback's result is only an acknowledgement, never a tool card.
state.completedActionKeys.add(handback.callId);
if (handback.report) {
items.push({
type: "assistant_message",
text: handback.report,
...(messageId ? { messageId } : {}),
});
}
}
}
return items;
@@ -391,6 +410,12 @@ export class ClaudeSidechainTracker {
}
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
return this.extractToolActionCandidates(message).filter(
(action) => !isClaudeSubagentHandbackToolName(action.toolName),
);
}
private extractToolActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
if (message.type === "assistant") {
return this.extractAssistantMessageActions(message);
}
@@ -0,0 +1,27 @@
/**
* Since Claude Code 2.1.271, a subagent in auto mode delivers its report to its caller through a
* `SubagentHandback` tool call instead of its final text. The report is the subagent's final
* message, so Paseo shows it as one and hides the call and its acknowledgement.
*/
const SUBAGENT_HANDBACK_TOOL_NAME = "SubagentHandback";
export interface ClaudeSubagentHandback {
callId: string;
/** Null while the call is still streaming its input. */
report: string | null;
}
export function isClaudeSubagentHandbackToolName(name: unknown): boolean {
return name === SUBAGENT_HANDBACK_TOOL_NAME;
}
export function readClaudeSubagentHandback(block: {
type: string;
[key: string]: unknown;
}): ClaudeSubagentHandback | null {
if (block.type !== "tool_use" || !isClaudeSubagentHandbackToolName(block.name)) return null;
if (typeof block.id !== "string" || block.id.length === 0) return null;
const input = block.input as { message?: unknown } | null | undefined;
const report = typeof input?.message === "string" ? input.message : "";
return { callId: block.id, report: report.trim().length > 0 ? report : null };
}
@@ -0,0 +1,154 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
describe("GenericACPAgentClient archived history", () => {
test("replays an archived session whose working directory was removed", async () => {
await withFakeACPAgent(async ({ command, removedCwd, loadRequestsPath }) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.resumeSession(
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
undefined,
undefined,
{ purpose: "history" },
);
try {
expect(await collectTimelineText(session.streamHistory())).toEqual([
"user_message:What is in the README?",
"assistant_message:It describes the project.",
]);
const loadRequests = JSON.parse(await readFile(loadRequestsPath, "utf8"));
expect(loadRequests).toEqual([{ sessionId: "archived-session", cwd: removedCwd }]);
} finally {
await session.close();
}
});
});
test("replays an archived session whose working directory path is now a file", async () => {
await withFakeACPAgent(async ({ command, removedCwd }) => {
await writeFile(removedCwd, "not a directory", "utf8");
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.resumeSession(
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
undefined,
undefined,
{ purpose: "history" },
);
try {
expect(await collectTimelineText(session.streamHistory())).toEqual([
"user_message:What is in the README?",
"assistant_message:It describes the project.",
]);
} finally {
await session.close();
}
});
});
test("still requires the working directory to resume a session that will run", async () => {
await withFakeACPAgent(async ({ command, removedCwd }) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
await expect(
client.resumeSession(
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
undefined,
undefined,
{ purpose: "interactive" },
),
).rejects.toThrow("ENOENT");
});
});
});
async function collectTimelineText(events: AsyncGenerator<AgentStreamEvent>): Promise<string[]> {
const text: string[] = [];
for await (const event of events) {
if (event.type !== "timeline") {
continue;
}
if (event.item.type === "user_message" || event.item.type === "assistant_message") {
text.push(`${event.item.type}:${event.item.text}`);
}
}
return text;
}
async function withFakeACPAgent(
run: (input: {
command: [string, ...string[]];
removedCwd: string;
loadRequestsPath: string;
}) => Promise<void>,
): Promise<void> {
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-archived-history-"));
try {
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
const loadRequestsPath = path.join(testDir, "load-requests.json");
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
await run({
command: [process.execPath, scriptPath, loadRequestsPath],
removedCwd: path.join(testDir, "removed-worktree"),
loadRequestsPath,
});
} finally {
await rm(testDir, { recursive: true, force: true });
}
}
const fakeACPAgentScript = `
const fs = require("node:fs");
const readline = require("node:readline");
const loadRequestsPath = process.argv[2];
const loadRequests = [];
const rl = readline.createInterface({ input: process.stdin });
function write(message) {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
}
function replay(sessionId, sessionUpdate, text) {
write({
method: "session/update",
params: { sessionId, update: { sessionUpdate, content: { type: "text", text } } },
});
}
rl.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === "initialize") {
write({
id: message.id,
result: {
protocolVersion: message.params?.protocolVersion ?? 1,
agentCapabilities: { loadSession: true, sessionCapabilities: { close: {} } },
},
});
return;
}
if (message.method === "session/load") {
const { sessionId, cwd } = message.params;
loadRequests.push({ sessionId, cwd });
fs.writeFileSync(loadRequestsPath, JSON.stringify(loadRequests));
replay(sessionId, "user_message_chunk", "What is in the README?");
replay(sessionId, "agent_message_chunk", "It describes the project.");
write({ id: message.id, result: {} });
return;
}
if (message.id !== undefined) {
write({ id: message.id, result: {} });
}
});
`;
-2
View File
@@ -414,7 +414,6 @@ export interface PaseoDaemonConfig {
plugins?: Record<string, PluginSource>;
pluginRegistries?: PluginRegistries;
pluginRegistryUrl?: string;
pluginRegistryEnabled?: boolean;
staticDir: string;
mcpDebug: boolean;
isDev?: boolean;
@@ -625,7 +624,6 @@ export async function createPaseoDaemon(
usageSession: (id) => agentManager.usageSession(id),
},
managedSources: new ManagedPluginSources(config.paseoHome, {
enabled: config.pluginRegistryEnabled ?? false,
registries: config.pluginRegistries,
defaultUrl: config.pluginRegistryUrl,
}),
@@ -30,7 +30,6 @@ export const DAEMON_SETTING_ENV_KEYS = [
"PASEO_LOG_ROTATE_SIZE",
"PASEO_PASSWORD",
"PASEO_PLUGIN_REGISTRY",
"PASEO_PLUGIN_REGISTRY_ENABLED",
"PASEO_RELAY_ENABLED",
"PASEO_RELAY_ENDPOINT",
"PASEO_RELAY_PUBLIC_ENDPOINT",
@@ -211,15 +211,6 @@ test("loads private plugin registry settings through the configuration boundary"
});
expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal");
expect(config.pluginRegistries).toEqual(pluginRegistries);
expect(config.pluginRegistryEnabled).toBe(false);
await writeFile(
path.join(home, "config.json"),
JSON.stringify({ pluginRegistries, pluginRegistryEnabled: true }),
);
expect(loadConfig(home, { env: {} }).pluginRegistryEnabled).toBe(true);
expect(
loadConfig(home, { env: { PASEO_PLUGIN_REGISTRY_ENABLED: "false" } }).pluginRegistryEnabled,
).toBe(false);
} finally {
await rm(home, { recursive: true, force: true });
}
-4
View File
@@ -628,10 +628,6 @@ export function resolveConfigFromPersisted(
plugins: persisted.plugins,
pluginRegistries: persisted.pluginRegistries,
pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY,
pluginRegistryEnabled:
parseBooleanEnv(env.PASEO_PLUGIN_REGISTRY_ENABLED) ??
persisted.pluginRegistryEnabled ??
false,
mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"),
@@ -831,6 +831,29 @@ test("send_agent_message auto-unarchives archived agents", async () => {
}
}, 180000);
test("send_agent_message leaves an archived agent archived when its directory is gone", async () => {
const cwd = tmpCwd();
try {
const created = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd,
},
});
const archived = await ctx.client.archiveAgent(created.id);
rmSync(cwd, { recursive: true, force: true });
await expect(ctx.client.sendMessage(created.id, "hello")).rejects.toThrow(
"Working directory does not exist",
);
const afterSend = await ctx.client.fetchAgent({ agentId: created.id });
expect(afterSend?.agent.archivedAt).toBe(archived.archivedAt);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 30000);
test("refresh_agent auto-unarchives archived agents", async () => {
const cwd = tmpCwd();
try {
@@ -308,7 +308,6 @@ export const PersistedConfigSchema = z
providers: ProvidersSchema.optional(),
pluginRegistries: PluginRegistriesSchema.optional(),
pluginRegistryEnabled: z.boolean().optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
worktrees: WorktreesConfigSchema.optional(),
@@ -10,6 +10,7 @@ import {
stat,
writeFile,
} from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -418,7 +419,7 @@ describe("PluginService", () => {
await service.stopAllPlugins();
}, 20_000);
it("lists manifest descriptions for running and disabled plugins without hiding malformed entries", async () => {
it("lists manifest metadata 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 () => () => {};");
@@ -426,7 +427,13 @@ describe("PluginService", () => {
const malformed = await createPlugin("malformed", "export default () => () => {};");
await writeFile(
path.join(running, "paseo-plugin.json"),
JSON.stringify({ id: "running", description: "Runs checks" }),
JSON.stringify({
id: "running",
description: "Runs checks",
name: "Checks",
icon: "icon.png",
media: ["screenshot.png", "https://example.com/demo.mp4"],
}),
);
await writeFile(
path.join(disabled, "paseo-plugin.json"),
@@ -442,16 +449,123 @@ describe("PluginService", () => {
await service.start();
expect(
(await service.listPlugins()).map(({ id, description }) => ({ id, description })),
(await service.listPlugins()).map(({ id, description, name, icon, media }) => ({
id,
description,
name,
icon,
media,
})),
).toEqual([
{ id: "disabled", description: "Waits until enabled" },
{ id: "malformed", description: undefined },
{ id: "running", description: "Runs checks" },
{
id: "running",
description: "Runs checks",
name: "Checks",
icon: "icon.png",
media: ["screenshot.png", "https://example.com/demo.mp4"],
},
]);
await service.stopAllPlugins();
});
it("prefers an existing directory and installs its selected plugin subdirectory", async () => {
it("resolves a bare registry id even when a matching directory exists in the daemon cwd", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
const owner = path.join(process.cwd(), `registry-shadow-${randomUUID()}`);
await mkdir(owner);
roots.push(home, owner);
const source = `${path.basename(owner)}/example`;
const repository = await createPlugin("registry-example", "export default () => () => {};\n");
await mkdir(path.join(owner, "example"));
await cp(repository, path.join(owner, "example"), { recursive: true });
await runGitCommand(["init", "-b", "main"], { cwd: repository });
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(
[
"-c",
"user.name=Paseo Tests",
"-c",
"user.email=paseo@example.test",
"commit",
"-m",
"fixture",
],
{ cwd: repository },
);
const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository });
const requests: Array<string | undefined> = [];
const server = createServer((request, response) => {
requests.push(request.url);
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify({
id: source,
name: "Example",
description: "Test",
categories: [],
author: { github: path.basename(owner) },
repository: { url: "https://github.com/fixture/example" },
artifact: {
kind: "git",
remote: pathToFileURL(repository).href,
commit: stdout.trim(),
pluginPath: ".",
},
media: [],
submittedAt: "2026-10-03",
reviewedAt: "2026-10-03",
updatedAt: "2026-10-03",
publishedAt: "2026-10-03",
readme: "# Test",
}),
);
});
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("Missing registry address");
const url = `http://127.0.0.1:${address.port}`;
const store = createStore(home);
store.patch({ pluginsEnabled: false });
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0", {
managedSources: new ManagedPluginSources(home, { defaultUrl: url }),
}),
);
try {
await service.start();
await expect(service.installSource({ source })).resolves.toMatchObject({
id: "registry-example",
status: "disabled",
installation: {
identity: { kind: "git", registry: { url, id: source } },
currentRevision: stdout.trim(),
},
});
expect(requests).toEqual([`/plugins/${source}.json`]);
await expect(
service.installSource({ source: `./${source}`, id: "local-example" }),
).resolves.toMatchObject({
installation: { identity: { kind: "directory", path: path.join(owner, "example") } },
});
expect(requests).toEqual([`/plugins/${source}.json`]);
} finally {
await service.stopAllPlugins();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}, 30_000);
it("names a missing explicit directory instead of trying a managed source", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const directory = path.join(home, "missing");
const service = createService(home);
await expect(service.installSource({ source: directory })).rejects.toThrow(
`Plugin directory does not exist: ${directory}`,
);
});
it("installs an explicit directory and its selected plugin subdirectory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "owner-repository-"));
+12 -11
View File
@@ -21,6 +21,7 @@ import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js";
import { readPluginManifest } from "./manifest.js";
import { expandTilde } from "../../utils/path.js";
import { runPluginBuild } from "./preparation.js";
import { PluginRuntime } from "./runtime.js";
import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js";
@@ -215,7 +216,12 @@ export class PluginService {
}),
};
const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null);
if (manifest?.description) item.description = manifest.description;
if (manifest) {
item.name = manifest.name;
item.description = manifest.description;
item.icon = manifest.icon;
item.media = manifest.media;
}
item.installation = await this.managedSources
?.describe(id, source.path)
.catch(() => undefined);
@@ -286,16 +292,11 @@ export class PluginService {
if (input.id && this.builtinPluginIds.has(input.id)) {
throw new Error(`Plugin ID "${input.id}" is reserved for a built-in plugin`);
}
const directDirectory = path.resolve(input.source);
const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source);
const directInfo = await stat(directDirectory).catch(() => null);
const reference = directInfo?.isDirectory()
? { source: input.source, pluginPath: undefined }
: parsePluginSourceReference(input.source);
const directory = path.resolve(reference.source);
let info = directInfo;
if (!info?.isDirectory() && !explicit) info = await stat(directory).catch(() => null);
if (info?.isDirectory()) {
const reference = parsePluginSourceReference(input.source);
if (reference.kind === "directory") {
const directory = path.resolve(expandTilde(reference.source));
const info = await stat(directory).catch(() => null);
if (!info?.isDirectory()) throw new Error(`Plugin directory does not exist: ${directory}`);
if (input.ref) throw new Error("Plugin --ref is only valid for Git sources");
const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath);
return this.installDirectory({ path: pluginDirectory, id: input.id });
@@ -280,7 +280,60 @@ describe("managed Git plugin sources", () => {
});
describe("registry plugin sources", () => {
it("treats owner/repository as GitHub shorthand without contacting the registry while registry installs are off", async () => {
it("explains how to choose a revision when --ref is used with a registry id", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-ref-home-"));
roots.push(home);
const sources = new ManagedPluginSources(home);
await expect(
sources.prepareInstall({ source: "fixture/example", ref: "main" }),
).rejects.toThrow(
"Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.",
);
});
it("names the registry URL and recovery steps when the registry fetch fails", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-offline-home-"));
roots.push(home);
const server = createServer((request) => request.socket.destroy());
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("Missing registry address");
const url = `http://127.0.0.1:${address.port}`;
try {
const sources = new ManagedPluginSources(home, { defaultUrl: url });
await expect(sources.prepareInstall({ source: "fixture/example" })).rejects.toThrow(
`Could not reach plugin registry ${url}/plugins/fixture/example.json. Plugin fixture/example was not installed. Retry or use an explicit npm: or github: source.`,
);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("explains explicit local and GitHub sources when a registry ID is missing", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-missing-home-"));
roots.push(home);
const requests: Array<string | undefined> = [];
const server = createServer((request, response) => {
requests.push(request.url);
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("Missing registry address");
try {
const sources = new ManagedPluginSources(home, {
defaultUrl: `http://127.0.0.1:${address.port}`,
});
await expect(sources.prepareInstall({ source: "fixture/missing" })).rejects.toThrow(
"If you intended a local directory, use ./fixture/missing. If you intended a GitHub source, use git:fixture/missing, or a full Git URL for another Git host.",
);
expect(requests).toEqual(["/plugins/fixture/missing.json"]);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("keeps explicit Git sources and persisted Git installs independent of the registry", async () => {
const repository = await createRepository();
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-off-home-"));
roots.push(home);
@@ -298,12 +351,34 @@ describe("registry plugin sources", () => {
const sources = new ManagedPluginSources(home, {
defaultUrl: `http://127.0.0.1:${address.port}`,
});
const candidate = await sources.prepareInstall({ source: "fixture/repository" });
expect(candidate.record).toEqual({
kind: "git",
remote: "https://github.com/fixture/repository.git",
});
await sources.discard(candidate);
for (const source of [
"git:fixture/repository",
"https://github.com/fixture/repository.git",
]) {
const candidate = await sources.place(
"managed-example",
await sources.prepareInstall({ source }),
);
expect(candidate.record).toEqual({
kind: "git",
remote: "https://github.com/fixture/repository.git",
});
sources.commit("managed-example", candidate.record);
const restarted = new ManagedPluginSources(home, {
defaultUrl: `http://127.0.0.1:${address.port}/changed`,
});
expect((await restarted.preview("managed-example", candidate.directory)).outcome).toBe(
"current",
);
expect(
(await restarted.describe("managed-example", candidate.directory)).identity,
).toEqual({
kind: "git",
remote: "https://github.com/fixture/repository.git",
pluginPath: ".",
});
await sources.discard(candidate);
}
});
expect(requests).toEqual([]);
} finally {
@@ -332,12 +407,17 @@ describe("registry plugin sources", () => {
intent: string | string[] | undefined;
authorization: string | undefined;
}> = [];
let missing = false;
const server = createServer((req, res) => {
requests.push({
path: req.url,
intent: req.headers["x-paseo-install"],
authorization: req.headers.authorization,
});
if (missing) {
res.writeHead(404).end();
return;
}
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
@@ -353,7 +433,7 @@ describe("registry plugin sources", () => {
commit,
pluginPath: "packages/example",
},
screenshots: [],
media: [],
submittedAt: "2026-10-03",
reviewedAt: "2026-10-03",
updatedAt: "2026-10-03",
@@ -369,7 +449,6 @@ describe("registry plugin sources", () => {
const url = `http://${host}/internal`;
try {
const sources = new ManagedPluginSources(home, {
enabled: true,
defaultUrl: url,
registries: { [host]: { authorization: "Bearer test" } },
});
@@ -380,7 +459,11 @@ describe("registry plugin sources", () => {
).toBe("managed-example");
await sources.verifyCandidate("managed-example", candidate);
sources.commit("managed-example", candidate.record);
const preview = await sources.preview("managed-example", candidate.directory);
const restarted = new ManagedPluginSources(home, {
defaultUrl: "http://127.0.0.1:1/changed",
registries: { [host]: { authorization: "Bearer test" } },
});
const preview = await restarted.preview("managed-example", candidate.directory);
expect(preview.outcome).toBe("current");
expect(preview.current?.identity).toEqual({
kind: "git",
@@ -388,6 +471,10 @@ describe("registry plugin sources", () => {
pluginPath: "packages/example",
registry: { url, id: "acme/example" },
});
missing = true;
await expect(restarted.preview("managed-example", candidate.directory)).rejects.toThrow(
"Check that the installed plugin is still published in this registry before updating",
);
expect(requests).toEqual([
{ path: "/internal/plugins/acme/example.json", intent: "1", authorization: "Bearer test" },
{
@@ -395,6 +482,11 @@ describe("registry plugin sources", () => {
intent: undefined,
authorization: "Bearer test",
},
{
path: "/internal/plugins/acme/example.json",
intent: undefined,
authorization: "Bearer test",
},
]);
} finally {
await new Promise<void>((resolve, reject) =>
@@ -424,27 +516,17 @@ it("installs and updates only the npm artifacts pinned by the plugin registry",
const server = createServer((request, response) => {
intents.push(request.headers["x-paseo-install"]);
response.end(
// Install reads only the ID and artifact, so the document omits the directory's display fields.
JSON.stringify({
id: "acme/example",
name: "Example",
description: "Test",
categories: [],
author: { github: "acme" },
repository: { url: "https://github.com/acme/example" },
artifact: { kind: "npm", package: "paseo-fixture-plugin", ...pin },
screenshots: [],
submittedAt: "2026-10-03",
reviewedAt: "2026-10-03",
updatedAt: "2026-10-03",
publishedAt: "2026-10-03",
readme: "# Example",
}),
);
});
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("Missing registry address");
const options = { enabled: true, defaultUrl: `http://127.0.0.1:${address.port}` };
const options = { defaultUrl: `http://127.0.0.1:${address.port}` };
try {
const sources = new ManagedPluginSources(home, options);
const candidate = await sources.place(
@@ -147,11 +147,13 @@ export class ManagedPluginSources {
input: InstallInput,
target?: PluginUpdateTarget,
): Promise<ManagedPluginCandidate> {
const registry = this.registryOptions.enabled
? parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl)
: null;
const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl);
if (registry) {
if (input.ref || input.pluginPath)
if (input.ref)
throw new Error(
"Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.",
);
if (input.pluginPath)
throw new Error("Registry sources use their reviewed artifact path and revision");
const resolved = await resolveRegistryPlugin(registry, this.registryOptions, true);
const candidate = await this.prepareInstall(resolved.input, resolved.target);
@@ -175,7 +177,7 @@ export class ManagedPluginSources {
sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName);
record = { kind: "npm" };
} else {
const remote = normalizeGitSource(input.source, !this.registryOptions.enabled);
const remote = normalizeGitSource(input.source);
sourceRoot = path.join(versionRoot, "checkout");
await clone(remote, sourceRoot);
const commit = await resolveRequestedRef(sourceRoot, input.ref);
@@ -227,7 +229,7 @@ export class ManagedPluginSources {
const location = this.locate(pluginId, configuredPath);
let target: PluginUpdateTarget;
let outcome: PluginUpdatePreview["outcome"];
if (this.registryOptions.enabled && current.identity.registry) {
if (current.identity.registry) {
if (selection) throw new Error("Registry updates use only the reviewed registry pin");
const resolved = await resolveRegistryPlugin(
current.identity.registry,
@@ -304,7 +306,7 @@ export class ManagedPluginSources {
await this.assertCurrent(proposal, configuredPath);
const identity = this.locate(proposal.id, configuredPath).identity;
const record = this.records[proposal.id];
if (this.registryOptions.enabled && identity.kind !== "directory" && identity.registry) {
if (identity.kind !== "directory" && identity.registry) {
const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false);
assertRegistryArtifactIdentity(identity, resolved.artifact);
if (!isDeepStrictEqual(resolved.target, proposal.target))
@@ -436,10 +438,9 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s
}
return [];
}
/** Bare owner/repo is GitHub shorthand unless registry references own that syntax. */
function normalizeGitSource(source: string, bareGitHubShorthand: boolean): string {
const expandsShorthand =
bareGitHubShorthand || source.startsWith("github:") || source.startsWith("git:");
/** Only explicit Git prefixes expand GitHub shorthand. */
function normalizeGitSource(source: string): string {
const expandsShorthand = source.startsWith("github:") || source.startsWith("git:");
if (source.startsWith("github:")) {
const shorthand = source.slice(7);
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) {
@@ -1,13 +1,19 @@
import {
PublishedPluginDetailSchema,
PluginRegistryArtifactSchema,
PluginRegistryIdSchema,
type PluginRegistries,
type PluginRegistryIdentity,
} from "@getpaseo/protocol/plugin-registry";
import type { PluginUpdateTarget } from "@getpaseo/protocol/messages";
import { z } from "zod";
// Install reads only these fields; the rest of a published plugin is the directory's display data.
const RegistryInstallDocumentSchema = z.object({
id: PluginRegistryIdSchema,
artifact: PluginRegistryArtifactSchema,
});
export interface RegistryOptions {
/** Off: bare owner/repo is GitHub shorthand and no registry is contacted. */
enabled?: boolean;
defaultUrl?: string;
registries?: PluginRegistries;
}
@@ -29,14 +35,29 @@ export async function resolveRegistryPlugin(
const headers: Record<string, string> = {};
if (authorization) headers.Authorization = authorization;
if (install) headers["X-Paseo-Install"] = "1";
const response = await fetch(`${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`, {
headers,
redirect: "error",
signal: AbortSignal.timeout(30000),
});
const url = `${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`;
let response: Response;
try {
response = await fetch(url, {
headers,
redirect: "error",
signal: AbortSignal.timeout(30000),
});
} catch (cause) {
const guidance = install
? `Plugin ${identity.id} was not installed. Retry or use an explicit npm: or github: source.`
: `Could not check updates for ${identity.id}. Retry later.`;
throw new Error(`Could not reach plugin registry ${url}. ${guidance}`, { cause });
}
if (response.status === 404) {
const guidance = install
? `If you intended a local directory, use ./${identity.id}. If you intended a GitHub source, use git:${identity.id}, or a full Git URL for another Git host.`
: "Check that the installed plugin is still published in this registry before updating.";
throw new Error(`Plugin ${identity.id} was not found in registry ${base.host}. ${guidance}`);
}
if (!response.ok)
throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`);
const plugin = PublishedPluginDetailSchema.parse(await response.json());
const plugin = RegistryInstallDocumentSchema.parse(await response.json());
if (plugin.id !== identity.id) throw new Error("Registry returned a different plugin ID");
const artifact = plugin.artifact;
const target: PluginUpdateTarget =
@@ -24,6 +24,69 @@ describe("plugin manifest", () => {
});
});
it("reads display metadata and ignores future top-level fields", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory);
const manifest = {
id: "review",
name: "Review tools",
icon: "assets/icon.png",
media: ["assets/screenshot.webp", "demo.mp4", "https://example.com/media?id=1"],
};
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ ...manifest, futureField: { enabled: true } }),
);
await expect(readPluginManifest(directory)).resolves.toEqual(manifest);
});
it.each([
{ name: " " },
{ name: 42 },
{ icon: "" },
{ icon: 42 },
{ icon: "icon.svg" },
{ icon: "/icon.png" },
{ icon: "../icon.png" },
{ icon: "assets/../../icon.png" },
{ icon: "https://example.com/icon.png" },
{ icon: "C:/icon.png" },
{ icon: "assets\\icon.png" },
{ icon: "icon.png?query" },
{ media: "screenshot.png" },
{ media: [42] },
{ media: [""] },
{ media: ["../demo.mp4"] },
{ media: ["/demo.mp4"] },
{ media: ["http://example.com/demo.mp4"] },
{ media: ["https://"] },
{ media: ["//example.com/demo.mp4"] },
{ media: ["file:///demo.mp4"] },
{ media: ["data:image/png;base64,abc"] },
{ media: ["."] },
{ media: ["assets/"] },
{ id: "INVALID" },
{ description: 42 },
{ requirements: { paseoo: ">=0.11.0" } },
])("rejects invalid known fields: %j", async (fields) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "example", futureField: true, ...fields }),
);
await expect(readPluginManifest(directory)).rejects.toThrow();
});
it("accepts omitted metadata and empty media", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory);
for (const manifest of [{ id: "example" }, { id: "example", media: [] }]) {
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify(manifest));
await expect(readPluginManifest(directory)).resolves.toEqual(manifest);
}
});
it("reads and validates requirements before any plugin code runs", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory);
+27 -8
View File
@@ -8,14 +8,33 @@ const MANIFEST_FILENAME = "paseo-plugin.json";
const PluginBuildCommandSchema = z
.array(z.string().refine((argument) => argument.trim().length > 0))
.min(1);
const PluginManifestSchema = z
.object({
id: PluginIdSchema,
description: z.string().trim().min(1).optional(),
requirements: PluginRequirementsSchema.strict().optional(),
build: z.array(PluginBuildCommandSchema).min(1).optional(),
})
.strict();
// Manifest asset paths use forward slashes and stay inside the plugin package.
const PluginAssetPathSchema = z
.string()
.min(1)
.refine(
(value) =>
value === value.trim() &&
!value.startsWith("/") &&
!/[\\:?#\p{Cc}]/u.test(value) &&
!value.split("/").includes("..") &&
!value.endsWith("/") &&
value.split("/").at(-1) !== ".",
"Expected a relative file path inside the plugin package",
);
const PluginManifestSchema = z.object({
id: PluginIdSchema,
name: z.string().trim().min(1).optional(),
description: z.string().trim().min(1).optional(),
icon: PluginAssetPathSchema.refine(
(value) => /\.png$/i.test(value),
"Expected a relative path to a PNG",
).optional(),
media: z.array(z.union([PluginAssetPathSchema, z.url({ protocol: /^https$/ })])).optional(),
// A misspelled requirement must not silently disable compatibility checks.
requirements: PluginRequirementsSchema.strict().optional(),
build: z.array(PluginBuildCommandSchema).min(1).optional(),
});
export type PluginManifest = z.infer<typeof PluginManifestSchema>;
@@ -510,9 +510,7 @@ it("starts the default shell through the worker and accepts quoted commands", as
session.send({ type: "input", data: `${command}\r` });
await waitForCondition(() => existsSync(markerPath), 10000);
expect(readFileSync(markerPath, "utf8")).toBe("shell-ok");
await expect.poll(() => readFileSync(markerPath, "utf8"), { timeout: 10000 }).toBe("shell-ok");
});
it("lists subdirectory terminals when querying the workspace root", async () => {
+22
View File
@@ -0,0 +1,22 @@
# Paseo website
## Plugin install counts
Clients report an install by resolving a plugin with `X-Paseo-Install: 1` or
`?intent=install`, using `/api/plugins/resolve/<owner>/<slug>` or
`/plugins/<owner>/<slug>.json`. Ordinary resolves do not count. These numbers
measure install intent, not successful artifact installation or npm downloads.
The worker requires Cloudflare's `CF-Connecting-IP` header to count a report.
It stores the plugin ID plus a SHA-256 hash of the IP in a `WEBSITE_CACHE` KV key
with a one hour TTL, never the raw IP. An existing key skips the count; repeats
do not extend the hour. Missing or empty IP headers do not count.
Accepted reports increment the existing all-time total and UTC daily buckets in
`WEBSITE_CACHE`. Daily buckets retain 60 days and supply the website's seven-day
and thirty-day rankings. `/api/plugins/installs` still returns plugin IDs mapped
to all-time totals. The [registry build](https://github.com/getpaseo/plugins/blob/main/scripts/build.ts)
reads that endpoint into the published plugin details and index; the website
reads windowed counts from KV through its five-minute cache. Existing counts
remain intact. The KV guard is advisory: concurrent requests and KV propagation
are not serialized.
@@ -0,0 +1,32 @@
{
"id": "security/overview",
"name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
"description": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
"artifact": {
"kind": "npm",
"package": "@alhassanaraouf/paseo-base2tone-theme",
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz",
"integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg=="
},
"license": "MIT",
"repository": {
"url": "javascript:alert(1)"
},
"categories": ["themes"],
"author": {
"github": "security",
"name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">"
},
"media": [
"data:image/svg+xml,<svg onload=\"globalThis.__overviewExecuted=1\"/>",
"http://example.com/image.png"
],
"submittedAt": "2026-09-24T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-24T17:12:05.467Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "## Overview security\n\n</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
"icon": "data:image/png;base64,AAAA"
}
+348 -12
View File
@@ -1,4 +1,6 @@
import { expect, test, type Page } from "playwright/test";
import securityPlugin from "./overview.fixture.json" with { type: "json" };
import { expect, test, type Page, type Locator } from "playwright/test";
import registry from "./registry.fixture.json" with { type: "json" };
import { CATEGORIES } from "../src/plugins/categories";
async function openPlugins(page: Page) {
@@ -6,6 +8,13 @@ async function openPlugins(page: Page) {
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
}
function pluginCards(page: Page, section: string): Locator {
return page
.getByRole("region", { name: section })
.getByRole("link")
.filter({ hasNotText: /^See all$/ });
}
test("browses from the directory into a category, a plugin, and its author", async ({
page,
context,
@@ -34,7 +43,7 @@ test("browses from the directory into a category, a plugin, and its author", asy
await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1);
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1);
await expect(
page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }),
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Copy to clipboard" }).click();
await expect
@@ -78,7 +87,7 @@ test("filters by search and clears to all plugins", async ({ page }) => {
await page.goto("/");
await openPlugins(page);
await searchPlugins(page, "graphite");
await submitSearch(page, "graphite");
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/);
await expect(
page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }),
@@ -96,7 +105,7 @@ test("filters by search and clears to all plugins", async ({ page }) => {
test("keeps the directory's ranking window when searching", async ({ page }) => {
await page.goto("/plugins?window=month");
await searchPlugins(page, "graphite");
await submitSearch(page, "graphite");
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/);
await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute(
"aria-current",
@@ -120,13 +129,52 @@ test("clears the search with the clear button", async ({ page }) => {
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
});
test("replaces history while typing a search", async ({ page }) => {
test("searches from the directory on submit, and Back returns to the directory", async ({
page,
}) => {
await page.goto("/");
await openPlugins(page);
const entries = await historyLength(page);
await typeSearch(page, "gra");
await expect(page).toHaveURL(/\/plugins$/);
expect(await historyLength(page)).toBe(entries);
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/);
await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible();
expect(await historyLength(page)).toBe(entries + 1);
await page.goBack();
await expect(page).toHaveURL(/\/plugins$/);
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
});
test("filters browse results as you type in one history entry", async ({ page }) => {
await page.goto("/");
await page.goto("/plugins/all");
await searchPlugins(page, "graphite");
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/);
const entries = await historyLength(page);
await typeSearch(page, "gra");
await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/);
await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible();
await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible();
await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0);
expect(await historyLength(page)).toBe(entries + 1);
await page.goBack();
await expect(page).toHaveURL(/\/$/);
await expect(page).toHaveURL(/\/plugins\/all$/);
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("");
await page.goForward();
await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/);
await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible();
await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra");
await page.reload();
await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible();
await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra");
});
test("keeps old category links working", async ({ page }) => {
@@ -167,7 +215,7 @@ test.describe("search engine visits without JavaScript", () => {
expect(response?.headers()["x-robots-tag"]).toBeUndefined();
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible();
await expect(
page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }),
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
).toBeVisible();
await expectPageMetadata(
page,
@@ -260,6 +308,19 @@ async function searchPlugins(page: Page, term: string) {
await page.getByRole("searchbox", { name: "Search plugins" }).fill(term);
}
async function submitSearch(page: Page, term: string) {
await searchPlugins(page, term);
await page.keyboard.press("Enter");
}
async function typeSearch(page: Page, term: string) {
await page.getByRole("searchbox", { name: "Search plugins" }).pressSequentially(term);
}
async function historyLength(page: Page): Promise<number> {
return page.evaluate(() => window.history.length);
}
async function expectPageMetadata(page: Page, title: string, path: string) {
await expect(page).toHaveTitle(title);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
@@ -306,6 +367,157 @@ test("keeps the directory unlinked until the coordinated announcement", async ({
test.describe("registry fixture layout", () => {
test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture");
test("uses density-aware card thumbnails and keeps detail screenshots original", async ({
page,
}) => {
const plugin = registry.plugins.find((entry) => entry.id === "alhassanaraouf/base2tone")!;
const source = plugin.media[0];
await openPlugins(page);
await expectThumbnailCard(
page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Base2Tone/ }),
source,
plugin.id,
);
await page.goto("/plugins/all");
const card = page.getByRole("main").getByRole("link", { name: /Base2Tone/ });
await expectThumbnailCard(card, source, plugin.id);
await card.click();
await expect(
page.getByRole("img", { name: "Base2Tone screenshot 1", exact: true }),
).toHaveAttribute("src", source);
});
test("shows images and videos as gallery tiles in registry order and opens each in the viewer", async ({
page,
}) => {
const plugin = registry.plugins.find((entry) => entry.id === "omercnet/dracula")!;
const [firstImage, video] = plugin.media;
await page.goto(`/plugins/${plugin.id}`);
const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ });
await expect(tiles).toHaveCount(3);
const layout = await tiles.evaluateAll(galleryTiles);
expect(layout.map((tile) => tile.href)).toEqual(plugin.media);
expect(new Set(layout.map((tile) => tile.size)).size).toBe(1);
const preview = tiles.nth(1).locator("video");
await expect(preview).toHaveAttribute("preload", "metadata");
await expect(preview).not.toHaveAttribute("controls");
await expect(preview).not.toHaveAttribute("autoplay");
expect(await preview.evaluate((element: HTMLVideoElement) => element.muted)).toBe(true);
const viewer = page.getByRole("dialog");
await tiles.nth(1).click();
const playing = viewer.getByLabel("Dracula video 2");
await expect(playing).toHaveAttribute("src", video);
await expect(playing).toHaveAttribute("controls", "");
await expect.poll(() => playing.evaluate(isPaused)).toBe(false);
// The fixture video is 320x240, so it has to scale up to fill the viewer.
await expect.poll(() => playing.evaluate(viewerFill)).toBeCloseTo(1, 2);
await page.keyboard.press("Escape");
await expect(viewer).toBeHidden();
await expect(page.locator("dialog video")).toHaveCount(0);
await tiles.first().click();
await expect(viewer.getByRole("img", { name: "Dracula screenshot 1" })).toHaveAttribute(
"src",
firstImage,
);
await viewer.getByRole("button", { name: "Close" }).click();
await expect(viewer).toBeHidden();
expect(page.context().pages()).toHaveLength(1);
const newTab = page.context().waitForEvent("page");
await tiles.first().click({ modifiers: ["ControlOrMeta"] });
await expect(await newTab).toHaveURL(firstImage);
await expect(viewer).toBeHidden();
});
test("peeks the next gallery tile at the strip's edge, and fills the row with two", async ({
page,
}) => {
// Wide screens show two tiles and part of the third; phones show one and part of the second.
for (const [width, expected] of [
[1280, [1, 1, "peeks"]],
[375, [1, "peeks", 0]],
] as const) {
await page.setViewportSize({ width, height: 720 });
await page.goto("/plugins/omercnet/dracula");
const three = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ });
const shares = await three.evaluateAll(visibleShares);
expect(shares.map((share) => (share > 0.1 && share < 0.5 ? "peeks" : share))).toEqual(
expected,
);
}
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto("/plugins/gpambrozio/herald");
const two = page.getByRole("link", { name: /^Herald screenshot \d$/ });
expect(await two.evaluateAll(visibleShares)).toEqual([1, 1]);
});
test("steps through the viewer with arrow keys and buttons, wrapping at the ends", async ({
page,
}) => {
await page.goto("/plugins/omercnet/dracula");
const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ });
const viewer = page.getByRole("dialog");
const shows = (name: string) =>
expect(
viewer.getByRole("img", { name, exact: true }).or(viewer.getByLabel(name, { exact: true })),
).toBeVisible();
await tiles.nth(1).click();
await expect.poll(() => viewer.getByLabel("Dracula video 2").evaluate(isPaused)).toBe(false);
await page.keyboard.press("ArrowRight");
await shows("Dracula screenshot 3");
await expect(page.locator("dialog video")).toHaveCount(0);
await page.keyboard.press("ArrowRight");
await shows("Dracula screenshot 1");
await viewer.getByRole("img").click();
await page.keyboard.press("ArrowLeft");
await shows("Dracula screenshot 3");
await viewer.getByRole("button", { name: "Previous" }).click();
await shows("Dracula video 2");
await viewer.getByRole("button", { name: "Next" }).click();
await shows("Dracula screenshot 3");
await viewer.getByRole("button", { name: "Next" }).click();
await shows("Dracula screenshot 1");
await page.keyboard.press("Escape");
await expect(viewer).toBeHidden();
});
test("keeps a video that fails to load visible in the viewer", async ({ page }) => {
const plugin = registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!;
await page.route(plugin.media[0], (route) => route.abort());
await page.goto(`/plugins/${plugin.id}`);
await page.getByRole("link", { name: "launchd Jobs video 1" }).click();
const video = page.getByRole("dialog").getByLabel("launchd Jobs video 1");
await expect(video).toBeVisible();
await expect(video).toHaveCSS("opacity", "1");
await expect(page.getByRole("button", { name: "Next" })).toHaveCount(0);
});
test("shows the plugin tile on cards when its media has no image", async ({ page }) => {
await page.goto("/plugins/all");
const card = page.getByRole("main").getByRole("link", { name: /launchd Jobs/ });
await expect(card.locator("img, video")).toHaveCount(0);
await card.click();
await expect(
page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"),
).toHaveAttribute(
"src",
registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!.media[0],
);
});
test("features listed plugins above What's new in the registry's order", async ({ page }) => {
await openPlugins(page);
await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured");
const featured = page.getByRole("region", { name: "Featured" });
await expect(featured).toContainText("A selection of hand picked plugins");
await expect(pluginCards(page, "Featured")).toHaveText([/Dracula/, /Herald/, /launchd Jobs/]);
});
test("lists the nine categories in order with counts, and the newest plugins first", async ({
page,
}) => {
@@ -315,8 +527,132 @@ test.describe("registry fixture layout", () => {
CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)),
);
await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0");
await expect(
page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Added/ }),
).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]);
await expect(pluginCards(page, "What’s new")).toHaveText([
/Base2Tone/,
/Sayr/,
/PromptKit/,
/Defer/,
]);
});
});
function galleryTiles(links: Element[]) {
return links.map((link) => ({
href: link.getAttribute("href"),
size: `${link.clientWidth}x${link.clientHeight}`,
}));
}
/** How much of each tile's width the gallery strip shows, rounded to hundredths. */
function visibleShares(links: Element[]) {
const strip = links[0].parentElement!.getBoundingClientRect();
return links.map((link) => {
const tile = link.getBoundingClientRect();
const shown = Math.min(tile.right, strip.right) - Math.max(tile.left, strip.left);
return Math.round((Math.max(shown, 0) / tile.width) * 100) / 100;
});
}
/** The share of the viewer's room the media fills along its limiting axis; 1 fills it. */
function viewerFill(media: HTMLElement) {
const box = media.getBoundingClientRect();
return Math.max(box.width / (window.innerWidth * 0.9), box.height / (window.innerHeight * 0.85));
}
function isPaused(video: HTMLVideoElement) {
return video.paused;
}
async function expectThumbnailCard(card: Locator, source: string, id: string) {
// Card screenshots are decorative and hidden from the accessibility tree.
const image = card.locator("img").first();
const path = (width: number) =>
`/plugins/thumb/${width}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`;
await expect(image).toHaveAttribute("src", path(592));
await expect(image).toHaveAttribute("srcset", `${path(592)} 1x, ${path(1184)} 2x`);
await expect(image).toHaveAttribute("loading", "lazy");
await expect(image).toHaveAttribute("decoding", "async");
await image.scrollIntoViewIfNeeded();
await expect
.poll(
() =>
image.evaluate((element: HTMLImageElement) => element.complete && element.naturalWidth > 0),
{ timeout: 15_000 },
)
.toBe(true);
const box = await image.evaluate((element) => {
const rect = element.parentElement!.getBoundingClientRect();
return { width: rect.width, height: rect.height };
});
expect(box.width).toBeGreaterThan(0);
expect(box.width / box.height).toBeCloseTo(1.6, 2);
}
test("keeps author content inert through SSR and hydration", async ({
page,
request,
}, testInfo) => {
await verifyPluginOverviewResponse(request);
await openUntrustedPlugin(page);
await expectPluginOverviewSafe(page);
await page.reload();
await expectPluginOverviewSafe(page);
await testInfo.attach("plugin-overview", {
body: await page.screenshot(),
contentType: "image/png",
});
});
async function verifyPluginOverviewResponse(request: import("playwright/test").APIRequestContext) {
const response = await request.get("/plugins/security/overview");
expect(response.status()).toBe(200);
const html = await response.text();
expect(html).not.toContain("<script>globalThis.__overviewExecuted=1</script>");
expect(html).toContain("\\x3C/script>");
}
async function openUntrustedPlugin(page: Page) {
await page.goto("/plugins/security/overview");
await expect(page.getByRole("heading", { name: "Overview security", exact: true })).toBeVisible();
}
async function expectPluginOverviewSafe(page: Page) {
expect(
await page.evaluate(() => (globalThis as { __overviewExecuted?: number }).__overviewExecuted),
).toBeUndefined();
// Security assertions inspect every emitted attribute, including inaccessible injected elements.
const violations = await page.locator(".docs-prose").evaluate((root) => {
const bad: string[] = [];
for (const element of root.querySelectorAll("*")) {
if (
["SCRIPT", "IFRAME", "SVG", "FORM", "INPUT", "META", "BASE", "STYLE"].includes(
element.tagName,
)
)
bad.push(element.tagName);
for (const attr of element.attributes) {
if (/^on|^style$|^srcdoc$/i.test(attr.name)) bad.push(attr.name);
if (["href", "src"].includes(attr.name) && !attr.value.startsWith("https://"))
bad.push(attr.value);
}
if (
element.tagName === "A" &&
(element.getAttribute("rel") !== "noopener noreferrer nofollow" ||
element.getAttribute("target") !== "_blank")
)
bad.push("unsafe anchor");
}
return bad;
});
expect(violations).toEqual([]);
await expect(page.getByRole("link", { name: "Source", exact: true })).toHaveCount(0);
await expect(page.getByRole("img", { name: /screenshot/ })).toHaveCount(0);
await expect(page.getByRole("heading", { level: 1 })).toHaveText(securityPlugin.name);
await expect(
page.getByRole("link", { name: securityPlugin.author.name, exact: true }),
).toHaveAttribute("href", "/plugins/security");
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
"content",
securityPlugin.description,
);
}
+12 -1
View File
@@ -1,6 +1,15 @@
import { createServer } from "node:http";
import registry from "./registry.fixture.json" with { type: "json" };
import securityPlugin from "./overview.fixture.json" with { type: "json" };
import overviewCorpus from "../../protocol/tests/fixtures/plugin-overview.json" with { type: "json" };
securityPlugin.readme +=
"\n\n" +
overviewCorpus
.map(({ markdown }) => markdown + "\n\n```html\n" + markdown + "\n```")
.join("\n\n");
// Serve the published registry contract so SSR and browser navigation use the same data.
createServer((request, response) => {
response.setHeader("Content-Type", "application/json");
@@ -8,7 +17,9 @@ createServer((request, response) => {
response.end(JSON.stringify(registry));
return;
}
const plugin = registry.plugins.find((entry) => request.url === `/plugins/${entry.id}.json`);
const plugin = [securityPlugin, ...registry.plugins].find(
(entry) => request.url === `/plugins/${entry.id}.json`,
);
response.statusCode = plugin ? 200 : 404;
response.end(JSON.stringify(plugin ?? { error: "Plugin not found" }));
}).listen(8188, "127.0.0.1");
+44 -37
View File
@@ -5,6 +5,12 @@
"url": "https://plugins.paseo.sh"
},
"generatedAt": "2026-10-04T15:55:47.882Z",
"featured": [
"omercnet/dracula",
"gpambrozio/herald",
"acme/not-published",
"gpambrozio/launchd-jobs"
],
"categories": [
{
"slug": "daemon-management",
@@ -74,12 +80,12 @@
"name": "alhassanaraouf",
"github": "alhassanaraouf"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png"
],
"submittedAt": "2026-09-24T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-24T17:12:05.467Z",
"publishedAt": "2026-09-24T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md."
@@ -106,13 +112,13 @@
"name": "Tom Lundy",
"github": "dorasto"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/sayr-plguin-1.png",
"https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png"
],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-22T10:39:28.935Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks"
@@ -139,13 +145,13 @@
"name": "gpambrozio",
"github": "gpambrozio"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/screenshot.png",
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png"
],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-01T17:46:20.500Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions"
@@ -172,13 +178,13 @@
"name": "gpambrozio",
"github": "gpambrozio"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/panel.png",
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png"
],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-27T19:03:31.943Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel"
@@ -205,12 +211,12 @@
"name": "gpambrozio",
"github": "gpambrozio"
},
"screenshots": [
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/launchd-jobs/docs/screenshot.png"
"media": [
"https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.mp4"
],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-22T17:56:41.061Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel"
@@ -237,12 +243,12 @@
"name": "gpambrozio",
"github": "gpambrozio"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png"
],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-22T18:03:14.979Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter"
@@ -269,10 +275,10 @@
"name": "gpambrozio",
"github": "gpambrozio"
},
"screenshots": [],
"media": [],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-01T22:25:58.192Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it"
@@ -298,13 +304,13 @@
"name": "hungcuong9125",
"github": "hungcuong9125"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-provider-cli.png",
"https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png"
],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-30T02:57:56.473Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer."
@@ -331,13 +337,13 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-roster.png",
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png"
],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:36:35.639Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster."
@@ -364,13 +370,13 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-wide.png",
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png"
],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:37:29.149Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace."
@@ -397,13 +403,14 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot.png",
"https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.webm",
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png"
],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:37:18.185Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo."
@@ -430,13 +437,13 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png",
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png"
],
"submittedAt": "2026-09-18T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:36:06.720Z",
"publishedAt": "2026-09-18T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees"
@@ -463,13 +470,13 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/plugin-manager.png",
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png"
],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:36:36.267Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling."
@@ -496,10 +503,10 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [],
"media": [],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:37:04.327Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces."
@@ -526,13 +533,13 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-wide.png",
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png"
],
"submittedAt": "2026-09-18T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:38:48.289Z",
"publishedAt": "2026-09-18T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client."
@@ -559,10 +566,10 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [],
"media": [],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:36:15.719Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host."
@@ -588,12 +595,12 @@
"name": "Tom Gringauz",
"github": "tomgrin10"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png"
],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-27T09:59:16.553Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset."
@@ -619,10 +626,10 @@
"name": "Tom Gringauz",
"github": "tomgrin10"
},
"screenshots": [],
"media": [],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-23T10:36:50.112Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo."
+1
View File
@@ -38,6 +38,7 @@
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.1",
"parse5": "^7.3.0",
"typescript": "^5.9.3",
"vite": "^7.3.3",
"vite-tsconfig-paths": "^5.1.4",
@@ -18,7 +18,7 @@ export function SiteShell({ children, width }: SiteShellProps) {
return (
<div className="min-h-screen bg-background">
<main className={mainClasses}>
<div className="mb-12">
<div className="mb-20">
<SiteHeader />
</div>
{children}
@@ -3,6 +3,7 @@ import { type ReactNode, useMemo } from "react";
import { type Author, authorAvatarUrl } from "./registry";
const SIZE_CLASS = {
xs: "h-4 w-4 text-[8px]",
sm: "h-5 w-5 text-[10px]",
lg: "h-14 w-14 text-xl",
} as const;
+43 -38
View File
@@ -3,8 +3,10 @@ import { SiteShell } from "~/components/site-shell";
import { ContributeLinks } from "./contribute-links";
import type { InstallCounts } from "./installs";
import { type BrowseQuery, browseHref } from "./links";
import { InstallCount } from "./install-count";
import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card";
import { PluginSearch } from "./plugin-search";
import { PluginSection } from "./section";
import {
addedAgo,
CATEGORIES,
@@ -45,17 +47,18 @@ export function BrowsePage({
query: BrowseQuery;
}) {
const category = query.category ? getCategory(query.category) : null;
const matches = query.q ? searchPlugins(plugins, query.q) : plugins;
const scoped = category ? getPluginsInCategory(matches, category.slug) : matches;
const results =
query.sort === "new" ? newestFirst(scoped) : mostInstalled(scoped, installs, query.window);
const ordered =
query.sort === "new" ? newestFirst(plugins) : mostInstalled(plugins, installs, query.window);
// Search ranks by relevance; the tab's order breaks ties.
const matches = query.q ? searchPlugins(ordered, query.q) : ordered;
const results = category ? getPluginsInCategory(matches, category.slug) : matches;
const title = category?.label ?? "All plugins";
const clearHref = browseHref({ ...query, q: undefined });
return (
<SiteShell width="wide">
<div className="lg:flex lg:gap-12">
<aside className="mb-8 lg:mb-0 lg:w-48 lg:flex-shrink-0">
<PluginSearch scope={query} className="mb-4 lg:mb-6" />
<PluginSearch scope={query} live className="mb-4 lg:mb-6" />
<CategoryNav plugins={matches} query={query} />
</aside>
<div className="min-w-0 flex-1">
@@ -63,7 +66,7 @@ export function BrowsePage({
<h1 className="text-3xl font-medium tracking-tight">
{query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title}
<span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground">
{scoped.length}
{results.length}
</span>
</h1>
{query.q && (
@@ -72,36 +75,38 @@ export function BrowsePage({
</a>
)}
</div>
<SortTabs query={query} />
{results.length === 0 && query.q && (
<div className="mt-8 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center">
<p className="text-sm text-muted-foreground">No plugins match.</p>
<a
href={clearHref}
className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
>
Clear filters
</a>
</div>
)}
{results.length === 0 && !query.q && (
<p className="mt-8 text-sm text-muted-foreground">No plugins in this category yet.</p>
)}
{results.length > 0 && (
<div className={`mt-8 ${PLUGIN_GRID_CLASS}`}>
{results.map((plugin) =>
query.sort === "new" ? (
<PluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
) : (
<PluginCard
key={plugin.id}
plugin={plugin}
installs={installs[plugin.id]?.[query.window] ?? 0}
/>
),
)}
</div>
)}
<PluginSection>
<SortTabs query={query} />
{results.length === 0 && query.q && (
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center">
<p className="text-sm text-muted-foreground">No plugins match.</p>
<a
href={clearHref}
className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
>
Clear filters
</a>
</div>
)}
{results.length === 0 && !query.q && (
<p className="text-sm text-muted-foreground">No plugins in this category yet.</p>
)}
{results.length > 0 && (
<div className={PLUGIN_GRID_CLASS}>
{results.map((plugin) =>
query.sort === "new" ? (
<PluginCard key={plugin.id} plugin={plugin}>
{addedAgo(plugin, now)}
</PluginCard>
) : (
<PluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />
</PluginCard>
),
)}
</div>
)}
</PluginSection>
</div>
</div>
</SiteShell>
@@ -186,7 +191,7 @@ function SortTabs({ query }: { query: BrowseQuery }) {
<WindowSwitch current={query.window} hrefs={windowHrefs} />
);
return (
<>
<div>
<div className="flex items-baseline justify-between gap-6 border-b border-white/10">
<div className="flex gap-6">
<a
@@ -207,6 +212,6 @@ function SortTabs({ query }: { query: BrowseQuery }) {
{windowSwitch && <div className="hidden pb-2 sm:block">{windowSwitch}</div>}
</div>
{windowSwitch && <div className="mt-4 sm:hidden">{windowSwitch}</div>}
</>
</div>
);
}
@@ -21,27 +21,36 @@ export function ContributeLinks() {
);
}
/** Closing section of the directory: what a plugin can do, with Read the docs and Submit buttons. */
/** "Submit a plugin" and "Read the docs", as a primary and a secondary button. */
export function ContributeButtons() {
return (
<>
<a href={SUBMIT_URL} className={PRIMARY_BUTTON_CLASS}>
Submit a plugin
</a>
<a href={DOCS_URL} className={SECONDARY_BUTTON_CLASS}>
Read the docs
</a>
</>
);
}
/** Closing section of the directory: what a plugin can do, with Submit and Read the docs buttons. */
export function ContributeSection() {
return (
<section
aria-labelledby="build-your-own"
className="mt-20 rounded-xl border border-white/10 bg-white/[0.03] px-6 py-8 sm:px-8"
className="mt-20 rounded-xl border border-white/10 bg-white/[0.03] px-6 py-8 text-center sm:px-8"
>
<h2 id="build-your-own" className="text-lg font-medium">
Build your own plugin
</h2>
<p className="mt-2 max-w-lg text-sm text-muted-foreground">
<p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">
Plugins add themes, workspace panels, slash commands, and agent hooks to Paseo. Write one
with the plugin docs, then submit it to get it listed here.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<a href={DOCS_URL} className={PRIMARY_BUTTON_CLASS}>
Read the docs
</a>
<a href={SUBMIT_URL} className={SECONDARY_BUTTON_CLASS}>
Submit a plugin
</a>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<ContributeButtons />
</div>
</section>
);
+37
View File
@@ -0,0 +1,37 @@
import { ContributeButtons } from "./contribute-links";
import type { BrowseQuery } from "./links";
import { PluginSearch } from "./plugin-search";
interface PluginsHeroProps {
pluginCount: number;
authorCount: number;
/** Where the search box sends its term. */
searchScope: BrowseQuery;
}
function plural(count: number, word: string): string {
return `${count} ${word}${count === 1 ? "" : "s"}`;
}
/** Header of the plugin directory: title, counts, and copy, then the contribute buttons and search. */
export function PluginsHero({ pluginCount, authorCount, searchScope }: PluginsHeroProps) {
return (
<header>
<h1 className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-3xl font-medium tracking-tight">Plugins</span>
<span className="text-sm font-normal tabular-nums text-extra-muted-foreground">
{plural(pluginCount, "plugin")} by {plural(authorCount, "author")}
</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-muted-foreground">
Browse community plugins that extend Paseo with new functionality.
</p>
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-3">
<ContributeButtons />
</div>
<PluginSearch scope={searchScope} className="w-full sm:w-64" />
</div>
</header>
);
}
@@ -0,0 +1,13 @@
import { Download } from "lucide-react";
import { formatInstalls } from "./registry";
/** An install count as a download icon and the number; inherits the surrounding text style. */
export function InstallCount({ count }: { count: number }) {
return (
<span className="inline-flex items-center gap-1 tabular-nums">
<Download aria-hidden className="h-3 w-3" />
{formatInstalls(count)}
<span className="sr-only">installs</span>
</span>
);
}
+20
View File
@@ -12,6 +12,26 @@ const WINDOW_DAYS = { week: 7, month: 30 } as const;
const totalKey = (id: string) => `plugin-installs:${id}`;
const dailyKey = (id: string) => `plugin-installs-daily:${id}`;
export async function recordClientInstall({
cache,
id,
ip,
}: {
cache: KVNamespace;
id: string;
ip: string | null;
}): Promise<void> {
if (!ip) return;
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ip));
const hash = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
const key = `plugin-installs-client:${id}:${hash}`;
if ((await cache.get(key)) !== null) return;
await cache.put(key, "1", { expirationTtl: 3600 });
await recordInstall(cache, id, new Date());
}
export async function recordInstall(cache: KVNamespace, id: string, now: Date): Promise<void> {
const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]);
await Promise.all([
+7 -2
View File
@@ -11,14 +11,19 @@ import {
function registryBase(): string {
return (env as RegistryEnvironment).PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins";
}
/** Listed plugins in index order, their install counts per window, and the server's clock. */
/** Listed plugins in index order, featured plugin IDs, install counts per window, and the server's clock. */
export const getRegistry = createServerFn({ method: "GET" }).handler(async () => {
const context = getWebsiteCacheContext();
const [index, installs] = await Promise.all([
loadRegistryIndex(registryBase(), context),
loadInstallCounts(registryBase(), context),
]);
return { plugins: index.plugins, installs, now: new Date().toISOString() };
return {
plugins: index.plugins,
featured: index.featured,
installs,
now: new Date().toISOString(),
};
});
export const getRegistryPlugin = createServerFn({ method: "GET" })
.validator((id: string) => id)
@@ -0,0 +1,231 @@
import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry";
import { ChevronLeft, ChevronRight, Play, X } from "lucide-react";
import {
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
type SyntheticEvent,
useCallback,
useMemo,
useRef,
useState,
} from "react";
const TILE_CLASS =
"relative block aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%]";
// Two tiles fill the row; with more, a third peeks at the right edge to show the strip scrolls.
const FILLING_TILE_CLASS = `${TILE_CLASS} md:w-[calc(50%-0.375rem)]`;
const PEEKING_TILE_CLASS = `${TILE_CLASS} md:w-[42%]`;
// The viewer's room: media never exceeds this box, which leaves a margin around it.
const VIEW_WIDTH = "90vw";
const VIEW_HEIGHT = "85vh";
const VIEW_CLASS = "max-h-[85vh] max-w-[90vw] rounded-lg object-contain";
const VIEWER_BUTTON_CLASS =
"fixed rounded-full bg-black/60 p-2 text-white transition-colors hover:bg-black/80";
const ARROW_STEPS: Partial<Record<string, number>> = { ArrowLeft: -1, ArrowRight: 1 };
interface MediaItem {
url: string;
kind: "image" | "video";
/** Image alt text, or the video's accessible name. */
label: string;
}
/** Plugin media in registry order. Each tile opens the viewer; without JavaScript it links to the file. */
export function MediaGallery({ name, media }: { name: string; media: string[] }) {
const dialog = useRef<HTMLDialogElement>(null);
const entries = useMemo(
() => media.map((url, index) => mediaEntry(name, url, index)),
[name, media],
);
const items = useMemo(() => entries.filter((entry) => typeof entry !== "string"), [entries]);
const [viewing, setViewing] = useState<number | null>(null);
const item = viewing === null ? undefined : items[viewing];
const open = useCallback(
(opened: MediaItem) => {
setViewing(items.indexOf(opened));
dialog.current?.showModal();
},
[items],
);
const close = useCallback(() => dialog.current?.close(), []);
// Unmounting the viewed media stops a playing video.
const handleClose = useCallback(() => setViewing(null), []);
const closeOnBackdrop = useCallback((event: MouseEvent<HTMLDialogElement>) => {
if (event.target === event.currentTarget) event.currentTarget.close();
}, []);
const step = useCallback(
(by: number) =>
setViewing((index) => (index === null ? null : (index + by + items.length) % items.length)),
[items.length],
);
const showPrevious = useCallback(() => step(-1), [step]);
const showNext = useCallback(() => step(1), [step]);
// Captured so arrow keys move between items even while a focused video would seek.
const stepWithArrowKeys = useCallback(
(event: KeyboardEvent<HTMLDialogElement>) => {
const by = ARROW_STEPS[event.key];
if (by === undefined || items.length < 2) return;
event.preventDefault();
step(by);
},
[items.length, step],
);
if (media.length === 0) return null;
const tileClass = items.length > 2 ? PEEKING_TILE_CLASS : FILLING_TILE_CLASS;
return (
<>
<div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0">
{entries.map((entry) =>
typeof entry === "string" ? (
<p key={entry}>{entry}</p>
) : (
<MediaTile key={entry.url} item={entry} className={tileClass} onOpen={open} />
),
)}
</div>
{/* Focusable so arrow keys still reach it after a click on non-focusable media. */}
<dialog
ref={dialog}
tabIndex={-1}
aria-label={item?.label}
onClose={handleClose}
onClick={closeOnBackdrop}
onKeyDownCapture={stepWithArrowKeys}
className="m-auto overflow-visible bg-transparent p-0 outline-none backdrop:bg-black/80"
>
{item?.kind === "image" && (
<img key={viewing} src={item.url} alt={item.label} className={VIEW_CLASS} />
)}
{item?.kind === "video" && <ViewerVideo key={viewing} url={item.url} label={item.label} />}
{items.length > 1 && (
<>
<button
type="button"
aria-label="Previous"
onClick={showPrevious}
className={`${VIEWER_BUTTON_CLASS} left-4 top-1/2 -translate-y-1/2`}
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
aria-label="Next"
onClick={showNext}
className={`${VIEWER_BUTTON_CLASS} right-4 top-1/2 -translate-y-1/2`}
>
<ChevronRight className="h-5 w-5" />
</button>
</>
)}
<button
type="button"
aria-label="Close"
onClick={close}
className={`${VIEWER_BUTTON_CLASS} right-4 top-4`}
>
<X className="h-5 w-5" />
</button>
</dialog>
</>
);
}
/** A registry media entry as a gallery item, or the raw entry when it is not a safe URL. */
function mediaEntry(name: string, url: string, index: number): MediaItem | string {
const source = pluginOverviewUrl(url);
if (!source) return url;
const kind = pluginMediaKind(source);
return {
url: source,
kind,
label: `${name} ${kind === "video" ? "video" : "screenshot"} ${index + 1}`,
};
}
/** Fills the viewer's room at the video's aspect ratio, scaling small sources up. */
function ViewerVideo({ url, label }: { url: string; label: string }) {
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
const readAspectRatio = useCallback((event: SyntheticEvent<HTMLVideoElement>) => {
const { videoWidth, videoHeight } = event.currentTarget;
setAspectRatio(videoWidth / videoHeight);
}, []);
const style = useMemo<CSSProperties | undefined>(
() =>
aspectRatio === null
? undefined
: { aspectRatio, width: `min(${VIEW_WIDTH}, ${VIEW_HEIGHT} * ${aspectRatio})` },
[aspectRatio],
);
return (
<video
src={url}
aria-label={label}
controls
autoPlay
playsInline
onLoadedMetadata={readAspectRatio}
style={style}
className={VIEW_CLASS}
/>
);
}
function MediaTile({
item,
className,
onOpen,
}: {
item: MediaItem;
className: string;
onOpen: (item: MediaItem) => void;
}) {
const { url, kind, label } = item;
const handleClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
// Modified clicks keep the link's own behavior, such as opening the file in a new tab.
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
onOpen(item);
},
[onOpen, item],
);
return (
<a
href={url}
{...pluginOverviewPolicy.link}
aria-label={kind === "video" ? label : undefined}
onClick={handleClick}
className={className}
>
{kind === "image" ? (
<img
src={url}
alt={label}
loading="lazy"
className="h-full w-full object-cover object-top"
/>
) : (
<>
<video
src={url}
preload="metadata"
muted
playsInline
className="h-full w-full object-cover object-top"
/>
<span
aria-hidden
className="absolute inset-0 flex items-center justify-center bg-black/10"
>
<span className="rounded-full bg-black/60 p-3 text-white">
<Play className="h-5 w-5 fill-current" />
</span>
</span>
</>
)}
</a>
);
}
@@ -0,0 +1,138 @@
import registry from "../../e2e/registry.fixture.json";
import { PluginTile } from "./plugin-tile";
import { PluginCard } from "./plugin-card";
import type { Plugin } from "./registry";
import React, { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { parseFragment, type DefaultTreeAdapterMap } from "parse5";
import { describe, expect, it } from "vitest";
import corpus from "../../../protocol/tests/fixtures/plugin-overview.json";
import { PluginOverview } from "./overview";
type Element = DefaultTreeAdapterMap["element"];
function elements(html: string): Element[] {
const result: Element[] = [];
function visit(node: DefaultTreeAdapterMap["node"]) {
if ("tagName" in node) result.push(node);
if ("childNodes" in node) node.childNodes.forEach(visit);
}
visit(parseFragment(html));
return result;
}
function render(markdown: string) {
return renderToStaticMarkup(<PluginOverview>{markdown}</PluginOverview>);
}
function attribute(element: Element, name: string) {
return element.attrs.find((attr) => attr.name === name)?.value;
}
const allowedTags = new Set([
"div",
"p",
"br",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"ul",
"ol",
"li",
"strong",
"em",
"del",
"pre",
"code",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
]);
describe("plugin overview security corpus", () => {
it.each(corpus)("$name", ({ markdown, links, images }) => {
const nodes = elements(render(markdown));
expect(
nodes.filter((node) => !allowedTags.has(node.tagName)).map((node) => node.tagName),
).toEqual([]);
expect(
nodes.flatMap((node) => node.attrs).filter((attr) => /^on|^style$|^srcdoc$/i.test(attr.name)),
).toEqual([]);
const anchors = nodes.filter((node) => node.tagName === "a");
expect(anchors.map((node) => attribute(node, "href"))).toEqual(links);
for (const anchor of anchors) {
expect(attribute(anchor, "rel")).toBe("noopener noreferrer nofollow");
expect(attribute(anchor, "target")).toBe("_blank");
}
expect(
nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")),
).toEqual(images);
});
it.each(corpus)("preserves $name inside code spans and fences", ({ markdown }) => {
for (const code of [
`\`${markdown.replaceAll("\n", " ")}\``,
`\`\`\`html\n${markdown}\n\`\`\``,
]) {
const nodes = elements(render(code));
expect(
nodes.filter((node) => ["a", "img", "script", "iframe", "svg"].includes(node.tagName)),
).toEqual([]);
const text = nodes
.filter((node) => node.tagName === "code")
.flatMap((node) => node.childNodes)
.map((child) => ("value" in child ? child.value : ""))
.join("");
expect(text.trim()).toBe(
code.startsWith("```html") ? markdown : markdown.replaceAll("\n", " "),
);
}
});
it("keeps long untrusted input inert", () => {
const nodes = elements(
render("a".repeat(100_000) + "\n\n" + "[click](javascript:alert(1)) ".repeat(1_000)),
);
expect(nodes.filter((node) => ["a", "img", "script"].includes(node.tagName))).toEqual([]);
});
});
describe("registry images", () => {
it.each([
"http://example.com/x",
"data:image/png;base64,AAAA",
"javascript:alert(1)",
"vbscript:msgbox(1)",
"./icon.png",
"//example.com/x",
])("does not load %s in cards or icons", (url) => {
const plugin = { ...registry.plugins[0], icon: url, media: [url] } as Plugin;
for (const component of [
createElement(PluginTile, { plugin, size: "sm" }),
createElement(PluginCard, { plugin }),
]) {
expect(
elements(renderToStaticMarkup(component)).filter((node) => node.tagName === "img"),
).toEqual([]);
}
});
});
it("uses the first HTTPS screenshot when an earlier screenshot is rejected", () => {
const source = "https://example.com/screenshot.png";
const plugin = {
...registry.plugins[0],
icon: undefined,
media: ["data:image/png;base64,AAAA", source],
} as Plugin;
const nodes = elements(renderToStaticMarkup(createElement(PluginCard, { plugin })));
expect(
nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")),
).toEqual([source]);
});
+56
View File
@@ -0,0 +1,56 @@
import type { ReactNode } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
/** A rejected destination leaves its label as text, without even an empty anchor. */
export function PluginContentLink({
href,
children,
className,
}: {
href?: string;
children: ReactNode;
className?: string;
}) {
const destination = pluginOverviewUrl(href);
return destination ? (
<a href={destination} {...pluginOverviewPolicy.link} className={className}>
{children}
</a>
) : (
children
);
}
const allowedElements = [...pluginOverviewPolicy.elements];
const remarkPlugins = [remarkGfm];
const components: Components = {
a: ({ href, children }) => <PluginContentLink href={href}>{children}</PluginContentLink>,
img: ({ src, alt }) => {
const source = pluginOverviewUrl(typeof src === "string" ? src : undefined);
return source ? <img src={source} alt={alt ?? ""} loading="lazy" /> : alt;
},
// GFM alignment otherwise becomes an inline style in react-markdown.
th: ({ children }) => <th>{children}</th>,
td: ({ children }) => <td>{children}</td>,
};
/** Untrusted overviews do not inherit documentation directives, anchors, or HTML highlighters. */
export function PluginOverview({ children }: { children: string }) {
return (
<div className="docs-prose">
<ReactMarkdown
skipHtml={!pluginOverviewPolicy.rawHtml}
allowedElements={allowedElements}
unwrapDisallowed
remarkPlugins={remarkPlugins}
urlTransform={pluginOverviewUrl}
components={components}
>
{children}
</ReactMarkdown>
</div>
);
}
+45 -49
View File
@@ -1,14 +1,17 @@
import { Download } from "lucide-react";
import type { ReactNode } from "react";
import { AuthorAvatar } from "./author-link";
import { InstallCount } from "./install-count";
import { pluginCardScreenshot } from "./thumbnails";
import { pluginHref } from "./links";
import { PluginTile } from "./plugin-tile";
import { formatInstalls, getCategory, type Plugin } from "./registry";
import { firstMediaImage, getAuthor, getCategory, type Plugin } from "./registry";
export const PLUGIN_GRID_CLASS =
"grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4";
/** First screenshot, or the plugin tile on a quiet backdrop when there is none. */
/** First allowed image, or the plugin tile on a quiet backdrop when there is none. */
function PluginShot({ plugin }: { plugin: Plugin }) {
const url = plugin.screenshots[0];
const url = firstMediaImage(plugin);
return (
<div
aria-hidden
@@ -16,9 +19,10 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
>
{url ? (
<img
src={url}
{...pluginCardScreenshot(plugin.id, url)}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover object-left-top"
/>
) : (
@@ -30,57 +34,49 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
);
}
/** What's new card: screenshot, name, and when it was added. */
export function NewPluginCard({ plugin, added }: { plugin: Plugin; added: string }) {
/**
* What's new and Featured card, in three rows under the screenshot: tile and name, description,
* then author and `children`, such as when the plugin was added or its install count.
*/
export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
const author = getAuthor(plugin);
return (
<a href={pluginHref(plugin.id)} className="group block w-[70%] flex-shrink-0 sm:w-auto">
<PluginShot plugin={plugin} />
<div className="mt-3 flex items-center gap-2.5">
<PluginTile plugin={plugin} size="sm" />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{plugin.name}</p>
<p className="truncate text-xs text-extra-muted-foreground">Added {added}</p>
</div>
<div className="mt-3 flex items-center gap-2">
<PluginTile plugin={plugin} size="xs" />
<p className="min-w-0 truncate text-sm font-medium text-white">{plugin.name}</p>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
<p className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-extra-muted-foreground">
<AuthorAvatar author={author} size="xs" />
<span className="truncate">{author.name}</span>
{children !== undefined && (
<>
<span aria-hidden>·</span>
<span className="flex-shrink-0">{children}</span>
</>
)}
</p>
</a>
);
}
/** Grid card: screenshot, name, one muted stat, and a two-line description. */
export function PluginCard({
plugin,
installs,
added,
}: {
plugin: Plugin;
/** Shown with a download icon; otherwise `added` is shown. */
installs?: number;
added?: string;
}) {
/** Grid card: screenshot; tile, name, and `children` such as an install count; description. */
export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
return (
<a href={pluginHref(plugin.id)} className="group block">
<PluginShot plugin={plugin} />
<div className="mt-3 flex items-start gap-2.5">
<PluginTile plugin={plugin} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate text-sm font-medium text-white">{plugin.name}</p>
<span className="inline-flex flex-shrink-0 items-center gap-1 text-xs tabular-nums text-extra-muted-foreground">
{installs === undefined ? (
added
) : (
<>
<Download className="h-3 w-3" />
{formatInstalls(installs)}
</>
)}
</span>
</div>
<p className="mt-0.5 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
</div>
<div className="mt-3 flex items-center gap-2">
<PluginTile plugin={plugin} size="xs" />
<p className="min-w-0 flex-1 truncate text-sm font-medium text-white">{plugin.name}</p>
<span className="flex-shrink-0 text-xs text-extra-muted-foreground">{children}</span>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
</a>
);
}
@@ -98,11 +94,9 @@ export function PluginRankRow({
return (
<a
href={pluginHref(plugin.id)}
className="flex items-center gap-3 rounded-lg px-2 py-2.5 transition-colors hover:bg-white/[0.04]"
className="flex items-center gap-3 rounded-lg px-4 py-2.5 transition-colors hover:bg-white/[0.04]"
>
<span className="w-4 text-right text-sm tabular-nums text-extra-muted-foreground">
{rank}
</span>
<span className="text-sm tabular-nums text-extra-muted-foreground">{rank}</span>
<PluginTile plugin={plugin} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">{plugin.name}</p>
@@ -110,7 +104,9 @@ export function PluginRankRow({
{getCategory(plugin.categories[0])?.label}
</p>
</div>
<span className="text-xs tabular-nums text-muted-foreground">{formatInstalls(installs)}</span>
<span className="text-xs text-muted-foreground">
<InstallCount count={installs} />
</span>
</a>
);
}
+48 -18
View File
@@ -1,37 +1,67 @@
import { useNavigate } from "@tanstack/react-router";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { Search, X } from "lucide-react";
import { type ChangeEvent, type FormEvent, useCallback, useRef, useState } from "react";
import { type BrowseQuery, browseHref, DEFAULT_WINDOW } from "./links";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { type BrowseQuery, browseHref, DEFAULT_WINDOW, parseSearchTerm } from "./links";
const ICON_CLASS = "h-3.5 w-3.5 text-extra-muted-foreground";
/**
* Search box for the plugin pages. Typing replaces the URL with the browse page for the term,
* keeping `scope`'s category and sort. Without JavaScript the form submits the same URL.
* Search box for the plugin pages. Submitting opens the browse page for the term, keeping
* `scope`'s category and sort. With `live`, typing filters in place instead: the first character
* of a query pushes one history entry and later edits replace it, so Back leaves the search.
* Without JavaScript the form submits the same URL.
*/
export function PluginSearch({ scope, className }: { scope: BrowseQuery; className?: string }) {
export function PluginSearch({
scope,
live = false,
className,
}: {
scope: BrowseQuery;
live?: boolean;
className?: string;
}) {
const navigate = useNavigate();
const router = useRouter();
const input = useRef<HTMLInputElement>(null);
const [term, setTerm] = useState(scope.q ?? "");
const search = useCallback(
(next: string) => {
setTerm(next);
void navigate({
href: browseHref({ ...scope, q: next.trim() ? next : undefined }),
replace: true,
});
// Back and Forward between results of the same page keep this box mounted; show their term.
useEffect(
() =>
router.history.subscribe(({ location, action }) => {
if (action.type === "PUSH" || action.type === "REPLACE") return;
setTerm(parseSearchTerm(new URLSearchParams(location.search).get("q")) ?? "");
}),
[router],
);
const show = useCallback(
(next: string, { replace }: { replace: boolean }) => {
void navigate({ href: browseHref({ ...scope, q: next.trim() ? next : undefined }), replace });
},
[navigate, scope],
);
const edit = useCallback(
(next: string) => {
setTerm(next);
if (live) show(next, { replace: Boolean(term.trim()) });
},
[live, show, term],
);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => search(event.target.value),
[search],
(event: ChangeEvent<HTMLInputElement>) => edit(event.target.value),
[edit],
);
const handleClear = useCallback(() => {
search("");
edit("");
input.current?.focus();
}, [search]);
const handleSubmit = useCallback((event: FormEvent) => event.preventDefault(), []);
}, [edit]);
const handleSubmit = useCallback(
(event: FormEvent) => {
event.preventDefault();
// Live results already show the term.
if (!live) show(term, { replace: false });
},
[live, show, term],
);
return (
<form
role="search"
+3 -1
View File
@@ -1,6 +1,8 @@
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import type { Plugin } from "./registry";
const SIZE_CLASS = {
xs: "h-6 w-6 rounded-md text-xs",
sm: "h-9 w-9 rounded-lg text-sm",
lg: "h-14 w-14 rounded-xl text-xl",
} as const;
@@ -12,7 +14,7 @@ export function PluginTile({ plugin, size }: { plugin: Plugin; size: keyof typeo
aria-hidden
className={`flex flex-shrink-0 items-center justify-center border border-white/10 bg-white/[0.06] font-medium text-white/80 ${SIZE_CLASS[size]}`}
>
{plugin.icon ? (
{pluginOverviewUrl(plugin.icon) ? (
<img src={plugin.icon} alt="" className="h-full w-full rounded-[inherit] object-cover" />
) : (
plugin.name.charAt(0).toUpperCase()
+12 -2
View File
@@ -4,8 +4,9 @@ import {
PublishedPluginDetailSchema,
} from "@getpaseo/protocol/plugin-registry";
import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache";
import { handlePluginThumbnailRequest } from "./thumbnails";
import { CATEGORIES } from "./categories";
import { type InstallCounts, readInstallCounts, recordInstall } from "./installs";
import { type InstallCounts, readInstallCounts, recordClientInstall } from "./installs";
export interface RegistryEnvironment {
PLUGINS_REGISTRY_URL?: string;
WEBSITE_CACHE?: KVNamespace;
@@ -63,6 +64,10 @@ export async function handlePluginRegistryRequest(
const url = new URL(request.url);
if (request.method !== "GET") return null;
const base = env.PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins";
if (url.pathname.startsWith("/plugins/thumb/")) {
const index = await loadRegistryIndex(base, context);
return handlePluginThumbnailRequest(request, index.plugins);
}
if (url.hostname === "plugins.paseo.sh" && url.pathname === "/index.json")
return Response.json(await loadRegistryIndex(base, context));
if (url.pathname === "/sitemap-plugins.xml") {
@@ -111,7 +116,12 @@ export async function handlePluginRegistryRequest(
url.searchParams.get("intent") === "install"
) {
const cache = context.cache;
if (cache) context.waitUntil(recordInstall(cache, id, new Date()).catch(() => undefined));
if (cache)
context.waitUntil(
recordClientInstall({ cache, id, ip: request.headers.get("CF-Connecting-IP") }).catch(
() => undefined,
),
);
}
return Response.json(plugin);
}
+178 -7
View File
@@ -1,6 +1,9 @@
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import { describe, expect, it } from "vitest";
import {
addedAgo,
featuredPlugins,
formatInstalls,
installCommand,
mostInstalled,
@@ -10,7 +13,7 @@ import {
searchPlugins,
} from "./registry";
import { readInstallCounts, recordInstall } from "./installs";
import { loadRegistryIndex, loadRegistryPlugin } from "./published";
import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published";
const plugin: Plugin = {
id: "acme/example",
name: "Example",
@@ -25,7 +28,7 @@ const plugin: Plugin = {
resolved: "https://registry.npmjs.org/example.tgz",
integrity: "sha512-YWJj",
},
screenshots: [],
media: [],
submittedAt: "2026-10-03",
reviewedAt: "2026-10-03",
updatedAt: "2026-10-03",
@@ -39,6 +42,7 @@ describe("plugin registry", () => {
registry: { name: "Internal", url: "https://example.test" },
categories: [],
plugins: [plugin],
featured: [plugin.id],
generatedAt: "2026-10-03",
};
const detail = { ...plugin, readme: "# Example" };
@@ -59,8 +63,46 @@ describe("plugin registry", () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("orders by submission date and by installs in a window, keeping index order for ties", () => {
const older = { ...plugin, id: "acme/older", submittedAt: "2026-09-01" };
it("features listed plugins once each, in the registry's order, dropping IDs that are not listed", () => {
const listed = [
{ ...plugin, id: "acme/first" },
{ ...plugin, id: "acme/second" },
{ ...plugin, id: "acme/third" },
];
const featured = ["acme/third", "acme/not-published", "acme/first", "acme/third"];
expect(featuredPlugins(listed, featured).map((p) => p.id)).toEqual([
"acme/third",
"acme/first",
]);
expect(featuredPlugins(listed, ["acme/not-published"])).toEqual([]);
});
it("lists what's new by first-listing date, not submission or update dates", () => {
const listedFirst = {
...plugin,
id: "acme/listed-first",
submittedAt: "2026-09-20",
reviewedAt: "2026-09-21",
updatedAt: "2026-10-02",
publishedAt: "2026-09-02",
};
const listedLater = {
...plugin,
id: "acme/listed-later",
submittedAt: "2026-09-01",
reviewedAt: "2026-09-01",
updatedAt: "2026-09-01",
publishedAt: "2026-09-30",
};
expect(newestFirst([listedFirst, listedLater]).map((p) => p.id)).toEqual([
"acme/listed-later",
"acme/listed-first",
]);
const now = "2026-10-03T12:00:00.000Z";
expect(addedAgo(listedLater, now)).toBe("3d ago");
expect(addedAgo(listedFirst, now)).toBe("4w ago");
});
it("orders by listing date and by installs in a window, keeping index order for ties", () => {
const older = { ...plugin, id: "acme/older", publishedAt: "2026-09-01" };
const twin = { ...plugin, id: "acme/twin" };
expect(newestFirst([older, plugin, twin]).map((p) => p.id)).toEqual([
"acme/example",
@@ -94,6 +136,82 @@ describe("plugin registry", () => {
Object.keys(JSON.parse((await cache.get("plugin-installs-daily:acme/example")) ?? "{}")),
).toEqual(["2026-09-10", "2026-09-30", "2026-10-04"]);
});
it("counts repeated install reports from the same IP once per hour", async () => {
let now = Date.now();
const cache = memoryKv(() => now);
const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
await resolvePlugin(cache, headers);
now += 3_599_000;
await resolvePlugin(cache, headers);
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
[plugin.id]: { week: 1, month: 1, all: 1 },
});
now += 1_000;
await resolvePlugin(cache, headers);
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
[plugin.id]: { week: 2, month: 2, all: 2 },
});
});
it("counts different IPs and plugins independently, including query install intent", async () => {
const cache = memoryKv();
const first = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
await resolvePlugin(cache, first);
await resolvePlugin(cache, { "CF-Connecting-IP": "2001:db8::1" }, { query: "?intent=install" });
await resolvePlugin(cache, first, { id: "acme/other" });
await resolvePlugin(cache, first, { query: "?intent=install" });
await resolvePlugin(cache, first, { id: "acme/other", staticDetail: true });
expect(await readInstallCounts(cache, [plugin.id, "acme/other"], new Date())).toEqual({
[plugin.id]: { week: 2, month: 2, all: 2 },
"acme/other": { week: 1, month: 1, all: 1 },
});
const hash = createHash("sha256").update(first["CF-Connecting-IP"]).digest("hex");
expect(await cache.get(`plugin-installs-client:${plugin.id}:${hash}`)).toBe("1");
expect(JSON.stringify([...cache.values])).not.toContain("192.0.2.1");
expect(JSON.stringify([...cache.values])).not.toContain("2001:db8::1");
});
it("does not count without a Cloudflare IP or install intent", async () => {
const cache = memoryKv();
await resolvePlugin(cache, { "X-Paseo-Install": "1", "X-Forwarded-For": "192.0.2.1" });
await resolvePlugin(cache, {}, { query: "?intent=install" });
await resolvePlugin(cache, { "X-Paseo-Install": "1", "CF-Connecting-IP": "" });
await resolvePlugin(cache, { "CF-Connecting-IP": "192.0.2.1" });
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
[plugin.id]: { week: 0, month: 0, all: 0 },
});
expect([...cache.values.keys()].filter((key) => key.startsWith("plugin-installs"))).toEqual([]);
});
it("preserves historical totals and the installs endpoint response", async () => {
const cache = memoryKv();
await cache.put(`plugin-installs:${plugin.id}`, "40");
const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
await resolvePlugin(cache, headers);
await resolvePlugin(cache, headers);
const base = "https://registry.example.test";
await cache.put(
`plugins:index:v1:${base}`,
JSON.stringify({
fetchedAt: Date.now(),
value: {
schemaVersion: 1,
registry: { name: "Internal", url: base },
categories: [],
plugins: [plugin],
featured: [],
generatedAt: "2026-10-03",
},
}),
);
const response = await handlePluginRegistryRequest(
new Request("https://paseo.sh/api/plugins/installs"),
{ PLUGINS_REGISTRY_URL: base },
{ cache, waitUntil: () => undefined },
);
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({ [plugin.id]: 41 });
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
[plugin.id]: { week: 1, month: 1, all: 41 },
});
});
it("searches name, description, ID, and author, ignoring case", () => {
const other = {
...plugin,
@@ -110,6 +228,32 @@ describe("plugin registry", () => {
expect(searchPlugins(plugins, " ")).toEqual(plugins);
expect(searchPlugins(plugins, "nothing")).toEqual([]);
});
it("ranks name, then author, then description matches, keeping the given order on ties", () => {
const named = (id: string, name: string, description: string, github = "acme"): Plugin => ({
...plugin,
id,
name,
description,
author: { github },
});
// Given in install order: description-only matches first, the exact name last.
const plugins = [
named("acme/editor", "Remote Editor", "Composer pill to open a workspace"),
named("acme/kit", "PromptKit", "Rewrite prompts"),
named("omp/tools", "Tools", "Helpers", "omp"),
named("acme/history", "History", "Inspect raw composer records"),
named("acme/omp", "OMP", "Paseo integration for OMP"),
];
expect(searchPlugins(plugins, "omp").map((p) => p.id)).toEqual([
"acme/omp",
"acme/kit",
"omp/tools",
"acme/editor",
"acme/history",
]);
expect(searchPlugins(plugins, "prompt").map((p) => p.id)).toEqual(["acme/kit"]);
expect(searchPlugins(plugins, "tools omp").map((p) => p.id)).toEqual(["omp/tools"]);
});
it("strips the README title and quoted description that the page already shows", () => {
expect(readmeBody("# Example\n\n> An example\n> plugin\n\n## Usage\n")).toBe("## Usage\n");
@@ -125,16 +269,43 @@ describe("plugin registry", () => {
});
/** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */
function memoryKv(): KVNamespace {
function memoryKv(now = Date.now): KVNamespace & { values: Map<string, string> } {
const values = new Map<string, string>();
const expiresAt = new Map<string, number>();
const kv = {
values,
get: async (key: string, options?: { type?: string }) => {
if (now() >= (expiresAt.get(key) ?? Infinity)) values.delete(key);
const value = values.get(key) ?? null;
return options?.type === "json" && value !== null ? JSON.parse(value) : value;
},
put: async (key: string, value: string) => {
put: async (key: string, value: string, options?: { expirationTtl?: number }) => {
values.set(key, value);
expiresAt.set(key, options?.expirationTtl ? now() + options.expirationTtl * 1000 : Infinity);
},
};
return kv as unknown as KVNamespace;
return kv as unknown as KVNamespace & { values: Map<string, string> };
}
async function resolvePlugin(
cache: KVNamespace,
headers: HeadersInit,
{ id = plugin.id, query = "", staticDetail = false } = {},
): Promise<void> {
const base = "https://registry.example.test";
const detail = { ...plugin, id, readme: "# Example" };
await cache.put(
`plugins:detail:v1:${base}:${id}`,
JSON.stringify({ fetchedAt: Date.now(), value: detail }),
);
const pending: Promise<unknown>[] = [];
const path = staticDetail ? `/plugins/${id}.json` : `/api/plugins/resolve/${id}`;
const response = await handlePluginRegistryRequest(
new Request(`https://paseo.sh${path}${query}`, { headers }),
{ PLUGINS_REGISTRY_URL: base },
{ cache, waitUntil: (promise) => pending.push(promise) },
);
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual(detail);
await Promise.all(pending);
}
+49 -15
View File
@@ -1,5 +1,7 @@
import type { z } from "zod";
import type { PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry";
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry";
import { scoreTextFields } from "@getpaseo/protocol/search/text-match";
import { CATEGORIES, type Category, type CategorySlug } from "./categories";
import type { InstallCounts, InstallWindow } from "./installs";
export { CATEGORIES, type Category, type CategorySlug };
@@ -15,6 +17,10 @@ const DAY_MS = 24 * 60 * 60 * 1000;
export function getCategory(slug: string): Category | null {
return CATEGORIES.find((category) => category.slug === slug) ?? null;
}
/** The plugin's first HTTPS image, for cards and link previews; videos never stand in for one. */
export function firstMediaImage(plugin: Plugin): string | undefined {
return plugin.media.find((url) => pluginOverviewUrl(url) && pluginMediaKind(url) === "image");
}
export function getPluginsInCategory(plugins: Plugin[], slug: string): Plugin[] {
return plugins.filter((plugin) => plugin.categories.includes(slug));
}
@@ -61,20 +67,48 @@ export function formatInstalls(count: number): string {
const k = count / 1000;
return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`;
}
/** Plugins whose name, description, ID, or author contains the term, case-insensitively. */
export function searchPlugins(plugins: Plugin[], term: string): Plugin[] {
const needle = term.trim().toLowerCase();
if (!needle) return plugins;
return plugins.filter((plugin) =>
[plugin.name, plugin.description, plugin.id, getAuthor(plugin).name]
.join(" ")
.toLowerCase()
.includes(needle),
);
/** Fields a search term can hit, best first: a name match outranks an author match, which outranks a description match. */
function searchFieldGroups(plugin: Plugin): string[][] {
const author = getAuthor(plugin);
const name = [plugin.name, plugin.id.split("/")[1]];
const owner = [author.name, author.github, plugin.id];
return [name, owner, [plugin.description], [...name, ...owner, plugin.description]];
}
/** Newest submissions first; equal dates keep index order. */
interface SearchRank {
field: number;
tier: number;
}
function searchRank(plugin: Plugin, term: string): SearchRank | null {
const groups = searchFieldGroups(plugin);
for (let field = 0; field < groups.length; field += 1) {
const score = scoreTextFields(term, groups[field], { subsequence: false });
if (score) return { field, tier: score.tier };
}
return null;
}
/**
* Plugins whose name, author, ID, or description contains every word of the term, ignoring case.
* Name matches come first, then author, then description; within each, exact words beat partial
* ones, and equal matches keep the given order.
*/
export function searchPlugins(plugins: Plugin[], term: string): Plugin[] {
if (!term.trim()) return plugins;
return plugins
.flatMap((plugin) => {
const rank = searchRank(plugin, term);
return rank ? [{ plugin, rank }] : [];
})
.sort((a, b) => a.rank.field - b.rank.field || a.rank.tier - b.rank.tier)
.map(({ plugin }) => plugin);
}
/** The featured IDs that name a listed plugin, in the registry's order, each once. */
export function featuredPlugins(plugins: Plugin[], featured: string[]): Plugin[] {
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
return [...new Set(featured)].flatMap((id) => byId.get(id) ?? []);
}
/** Most recently listed first; equal dates keep index order. */
export function newestFirst(plugins: Plugin[]): Plugin[] {
return [...plugins].sort((a, b) => b.submittedAt.localeCompare(a.submittedAt));
return [...plugins].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
}
/** Most installs in the window first; equal counts keep index order. */
export function mostInstalled(
@@ -85,9 +119,9 @@ export function mostInstalled(
const count = (plugin: Plugin) => installs[plugin.id]?.[window] ?? 0;
return [...plugins].sort((a, b) => count(b) - count(a));
}
/** How long ago the registry accepted the plugin, relative to the loader's clock. */
/** How long ago the plugin was first listed, relative to the loader's clock. */
export function addedAgo(plugin: Plugin, now: string): string {
const days = Math.floor((Date.parse(now) - Date.parse(plugin.submittedAt)) / DAY_MS);
const days = Math.floor((Date.parse(now) - Date.parse(plugin.publishedAt)) / DAY_MS);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 14) return `${days}d ago`;
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
/** A block of a plugins page: its header, then its content one fixed gap below. */
export function PluginSection({
id,
labelledBy,
className = "",
children,
}: {
id?: string;
labelledBy?: string;
className?: string;
children: ReactNode;
}) {
return (
<section id={id} aria-labelledby={labelledBy} className={`flex flex-col gap-8 ${className}`}>
{children}
</section>
);
}
/** A section's title with its trailing controls on one baseline. */
export function PluginSectionHeader({ children }: { children: ReactNode }) {
return (
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">{children}</div>
);
}
export function PluginSectionTitle({ id, children }: { id: string; children: ReactNode }) {
return (
<h2 id={id} className="text-lg font-medium">
{children}
</h2>
);
}
@@ -0,0 +1,253 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { handlePluginThumbnailRequest, pluginCardScreenshot } from "./thumbnails";
import { NewPluginCard, PluginCard } from "./plugin-card";
import type { Plugin } from "./registry";
const source = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/preview.png";
const video = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/demo.mp4";
const plugin: Plugin = {
id: "acme/example",
name: "Example",
description: "An example",
categories: ["themes"],
author: { github: "acme" },
repository: { url: "https://github.com/acme/example" },
artifact: {
kind: "npm",
package: "paseo-example",
version: "1.2.3",
resolved: "https://registry.npmjs.org/example.tgz",
integrity: "sha512-YWJj",
},
media: [source],
submittedAt: "2026-10-03",
reviewedAt: "2026-10-03",
updatedAt: "2026-10-03",
publishedAt: "2026-10-03",
installs: 42,
};
describe("plugin card thumbnails", () => {
it("uses the first image in the plugin's media, skipping a leading video", () => {
const html = renderToStaticMarkup(
createElement(PluginCard, { plugin: { ...plugin, media: [video, source] } }),
);
expect(html).toContain(`/plugins/thumb/592/${encodeURIComponent(source)}?`);
expect(html).not.toContain(encodeURIComponent(video));
});
it("shows the plugin tile when the media has no image", () => {
const html = renderToStaticMarkup(
createElement(PluginCard, { plugin: { ...plugin, media: [video] } }),
);
expect(html).not.toContain("<img");
expect(html).not.toContain("<video");
expect(html).toContain(">E</div>");
});
it("keeps a screenshot on an unsupported host visible at its original URL", () => {
const screenshot = "https://example.com/preview.png";
const html = renderToStaticMarkup(
createElement(PluginCard, { plugin: { ...plugin, media: [screenshot] } }),
);
expect(html).toContain(`src="${screenshot}"`);
expect(html).not.toContain("/plugins/thumb/");
});
it.each([
["PluginCard", () => createElement(PluginCard, { plugin }, "today")],
["NewPluginCard", () => createElement(NewPluginCard, { plugin }, "today")],
])("renders a thumbnail instead of downloading the original screenshot (%s)", (_name, card) => {
const html = renderToStaticMarkup(card());
expect(html).toContain(
`src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`,
);
expect(html).toContain(
`srcSet="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample 1x, /plugins/thumb/1184/${encodeURIComponent(source)}?plugin=acme%2Fexample 2x"`,
);
expect(html).toContain('loading="lazy"');
expect(html).toContain('decoding="async"');
});
});
function thumbnailRequest(
sourceUrl = source,
width = 592,
id = plugin.id,
accept = "image/avif,image/webp",
) {
return new Request(
`https://paseo.sh/plugins/thumb/${width}/${encodeURIComponent(sourceUrl)}?plugin=${encodeURIComponent(id)}`,
{ headers: { accept } },
);
}
function imageResponse(body: string, headers: Record<string, string> = {}) {
return new Response(body, { headers: { "content-type": "image/png", ...headers } });
}
// A typed fetch dependency, returning real Responses without patching the global fetch.
function imageFetch(...responses: (Response | Error)[]) {
const calls: { input: Parameters<typeof fetch>[0]; init: Parameters<typeof fetch>[1] }[] = [];
const fetchImage: typeof fetch = async (input, init) => {
calls.push({ input, init });
const response = responses.shift();
if (!response) throw new Error("Unexpected fetch");
if (response instanceof Error) throw response;
return response;
};
return { calls, fetchImage };
}
describe("thumbnail route", () => {
it("sandboxes an SVG fallback when opened as a document on the website origin", async () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.domain)</script></svg>';
const upstream = imageFetch(
new Response("unsupported", { status: 415 }),
imageResponse(svg, { "content-type": "image/svg+xml" }),
);
const response = await handlePluginThumbnailRequest(
thumbnailRequest(),
[plugin],
upstream.fetchImage,
);
expect(await response.text()).toBe(svg);
expect(response.headers.get("content-security-policy")).toBe("sandbox");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it.each([
[592, source, "image/avif,image/webp", "avif"],
[592, source, "image/avif;q=0,image/webp", "webp"],
[592, source, "image/avif;q=0,image/webp;q=0", undefined],
[
1184,
"https://raw.githubusercontent.com/acme/example/abc123/preview.png",
"image/webp",
"webp",
],
[592, source, "image/png,image/*;q=0.8", undefined],
] as const)(
"transforms a registered screenshot at %s from %s for %s",
async (width, screenshot, accept, format) => {
const upstream = imageFetch(imageResponse("thumbnail"));
const response = await handlePluginThumbnailRequest(
thumbnailRequest(screenshot, width, plugin.id, accept),
[{ ...plugin, media: [screenshot] }],
upstream.fetchImage,
);
expect(upstream.calls).toEqual([
{
input: screenshot,
init: {
cf: { image: { width, fit: "scale-down", format, quality: 80 } },
},
},
]);
expect(response.status).toBe(200);
expect(await response.text()).toBe("thumbnail");
expect(response.headers.get("content-type")).toBe("image/png");
expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
expect(response.headers.get("vary")).toBe("Accept");
},
);
it.each([
["Cloudflare error", () => new Response("error 9401", { status: 403 })],
[
"non-image success",
() => new Response("<html>unavailable</html>", { headers: { "content-type": "text/html" } }),
],
["Cf-Resized error", () => imageResponse("error", { "cf-resized": "err=9401" })],
["network exception", () => new Error("Transform unavailable")],
] as const)("proxies the original with immutable caching on %s", async (_, failure) => {
const upstream = imageFetch(failure(), imageResponse("original"));
const response = await handlePluginThumbnailRequest(
thumbnailRequest(),
[plugin],
upstream.fetchImage,
);
expect(upstream.calls.map((call) => call.input)).toEqual([source, source]);
expect(upstream.calls[1].init).toBeUndefined();
expect(response.status).toBe(200);
expect(await response.text()).toBe("original");
expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
});
it.each([
["arbitrary width", thumbnailRequest(source, 593), [plugin], 400],
["unlisted source on allowed host", thumbnailRequest(source + "?different"), [plugin], 404],
["wrong plugin ID", thumbnailRequest(source, 592, "acme/other"), [plugin], 404],
[
"missing plugin ID",
new Request(`https://paseo.sh/plugins/thumb/592/${encodeURIComponent(source)}`),
[plugin],
404,
],
["removed plugin", thumbnailRequest(), [], 404],
["listed video", thumbnailRequest(video), [{ ...plugin, media: [video, source] }], 404],
["malformed encoding", new Request("https://paseo.sh/plugins/thumb/592/%ZZ"), [plugin], 400],
["malformed URL", thumbnailRequest("not a URL"), [plugin], 400],
...[
"https://example.com/preview.png",
"https://cdn.jsdelivr.net.evil.test/preview.png",
"http://cdn.jsdelivr.net/preview.png",
"https://user:pass@cdn.jsdelivr.net/preview.png",
"https://cdn.jsdelivr.net:8080/preview.png",
].map(
(url) =>
[
"disallowed registered source",
thumbnailRequest(url),
[{ ...plugin, media: [url] }],
400,
] satisfies [string, Request, Plugin[], number],
),
] satisfies [string, Request, Plugin[], number][])(
"rejects %s without fetching an image",
async (_, request, plugins, status) => {
const upstream = imageFetch();
const response = await handlePluginThumbnailRequest(
request,
[...plugins],
upstream.fetchImage,
);
expect(response.status).toBe(status);
expect(upstream.calls).toEqual([]);
expect(response.headers.get("cache-control")).toBeNull();
},
);
it.each([
() => new Response("missing", { status: 404 }),
() => new Response("<html>error</html>", { headers: { "content-type": "text/html" } }),
() => new Error("Origin unavailable"),
])("does not cache a failed original for a year", async (failure) => {
const upstream = imageFetch(new Response("transform error", { status: 502 }), failure());
const response = await handlePluginThumbnailRequest(
thumbnailRequest(),
[plugin],
upstream.fetchImage,
);
expect(response.status).toBe(502);
expect(response.headers.get("cache-control")).toBe("no-store");
});
it("round-trips screenshot URL punctuation through the card URL", async () => {
const screenshot = source + "?name=a&other=b#preview";
const upstream = imageFetch(imageResponse("thumbnail"));
const request = new Request(
"https://paseo.sh" + pluginCardScreenshot(plugin.id, screenshot).src,
);
const response = await handlePluginThumbnailRequest(
request,
[{ ...plugin, media: [screenshot] }],
upstream.fetchImage,
);
expect(response.status).toBe(200);
expect(upstream.calls[0].input).toBe(screenshot);
});
});
+119
View File
@@ -0,0 +1,119 @@
import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry";
import type { Plugin } from "./registry";
// The single-column grid reaches 591px just below sm (640px minus 48px padding).
const CARD_WIDTH = 592;
const WIDTHS = new Set([CARD_WIDTH, CARD_WIDTH * 2]);
const SOURCE_HOSTS = new Set(["cdn.jsdelivr.net", "raw.githubusercontent.com"]);
export function pluginCardScreenshot(id: string, source: string): { src: string; srcSet?: string } {
if (!parseSource(source)) return { src: source };
return {
src: pluginThumbnailUrl(id, source, 1),
srcSet: `${pluginThumbnailUrl(id, source, 1)} 1x, ${pluginThumbnailUrl(id, source, 2)} 2x`,
};
}
function pluginThumbnailUrl(id: string, source: string, scale: 1 | 2): string {
return `/plugins/thumb/${CARD_WIDTH * scale}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`;
}
/** Only images listed in the plugin's media are eligible for transformation or original fallback. */
export async function handlePluginThumbnailRequest(
request: Request,
plugins: Plugin[],
fetchImage: typeof fetch = fetch,
): Promise<Response> {
const url = new URL(request.url);
const match = /^\/plugins\/thumb\/(\d+)\/([^/]+)$/.exec(url.pathname);
if (!match || !WIDTHS.has(Number(match[1])))
return new Response("Invalid thumbnail width or source", { status: 400 });
let decoded: string;
try {
decoded = decodeURIComponent(match[2]);
} catch {
return new Response("Invalid thumbnail source", { status: 400 });
}
const source = parseSource(decoded);
if (!source) return new Response("Invalid thumbnail source", { status: 400 });
const plugin = plugins.find((entry) => entry.id === url.searchParams.get("plugin"));
if (!plugin?.media.includes(source) || pluginMediaKind(source) !== "image")
return new Response("Screenshot not found", { status: 404 });
const format = negotiatedFormat(request.headers.get("accept") ?? "");
let response: Response | undefined;
try {
response = await fetchImage(source, {
cf: { image: { width: Number(match[1]), fit: "scale-down", format, quality: 80 } },
});
} catch {
// An unavailable transformation must not prevent fetching the original.
}
if (!response || !isImage(response) || /err=/i.test(response.headers.get("cf-resized") ?? "")) {
await response?.body?.cancel();
try {
response = await fetchImage(source);
} catch {
return unavailableImage();
}
}
if (!isImage(response)) {
await response.body?.cancel();
return unavailableImage();
}
return new Response(response.body, {
headers: {
"content-type": response.headers.get("content-type")!,
"cache-control": "public, max-age=31536000, immutable",
vary: "Accept",
// Originals can be SVGs. Keep direct navigation from executing them on our origin.
"content-security-policy": "sandbox",
"x-content-type-options": "nosniff",
},
});
}
function isImage(response: Response): boolean {
return response.ok && /^image\//i.test(response.headers.get("content-type") ?? "");
}
function unavailableImage(): Response {
return new Response("Screenshot unavailable", {
status: 502,
headers: { "cache-control": "no-store" },
});
}
function parseSource(source: string): string | null {
try {
const url = new URL(source);
if (
url.protocol !== "https:" ||
!SOURCE_HOSTS.has(url.hostname) ||
url.username ||
url.password ||
url.port
)
return null;
return source;
} catch {
return null;
}
}
function negotiatedFormat(accept: string): "avif" | "webp" | undefined {
// The Worker API requires explicit negotiation; only the URL API accepts format=auto.
const accepted = new Map(
accept
.toLowerCase()
.split(",")
.map((range) => {
const [type, ...parameters] = range.split(";").map((part) => part.trim());
const quality = parameters.find((parameter) => parameter.startsWith("q="));
return [type, quality === undefined ? 1 : Number(quality.slice(2))] as const;
}),
);
return (["avif", "webp"] as const).find((format) => (accepted.get(`image/${format}`) ?? 0) > 0);
}
+9 -10
View File
@@ -1,3 +1,4 @@
import { PluginContentLink } from "~/plugins/overview";
import { createFileRoute, notFound } from "@tanstack/react-router";
import { ExternalLink } from "lucide-react";
import { useMemo } from "react";
@@ -15,6 +16,7 @@ import {
} from "~/plugins";
import { AuthorAvatar } from "~/plugins/author-link";
import { PluginsNotFound } from "~/plugins/not-found";
import { InstallCount } from "~/plugins/install-count";
import { PluginCard } from "~/plugins/plugin-card";
import "~/styles.css";
@@ -64,21 +66,16 @@ function AuthorPage() {
</p>
<div className="flex items-center gap-4 pt-1">
{authorNpmUrl(author) && (
<a
href={authorNpmUrl(author)}
target="_blank"
rel="noopener noreferrer"
className={LINK_CLASS}
>
<PluginContentLink href={authorNpmUrl(author)} className={LINK_CLASS}>
npm
<ExternalLink className="h-3 w-3" />
</a>
</PluginContentLink>
)}
{github && (
<a href={github} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
<PluginContentLink href={github} className={LINK_CLASS}>
GitHub
<ExternalLink className="h-3 w-3" />
</a>
</PluginContentLink>
)}
</div>
</div>
@@ -86,7 +83,9 @@ function AuthorPage() {
<div className="mt-12 grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3">
{plugins.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin} installs={installs[plugin.id]?.all ?? 0} />
<PluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.all ?? 0} />
</PluginCard>
))}
</div>
</SiteShell>
@@ -1,11 +1,11 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { ExternalLink } from "lucide-react";
import { CodeBlock } from "~/components/code-block";
import { DocsMarkdown } from "~/components/docs-markdown";
import { PluginOverview, PluginContentLink } from "~/plugins/overview";
import { SiteShell } from "~/components/site-shell";
import { pageMeta } from "~/meta";
import {
formatInstalls,
firstMediaImage,
getAuthor,
getCategory,
getRegistry,
@@ -15,8 +15,10 @@ import {
pluginVersion,
readmeBody,
} from "~/plugins";
import { AuthorLink } from "~/plugins/author-link";
import { AuthorAvatar, AuthorLink } from "~/plugins/author-link";
import { InstallCount } from "~/plugins/install-count";
import { categoryHref } from "~/plugins/links";
import { MediaGallery } from "~/plugins/media-gallery";
import { PluginsNotFound } from "~/plugins/not-found";
import "~/styles.css";
@@ -34,7 +36,7 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
loaderData?.plugin ? `${loaderData.plugin.name} – Paseo plugin` : "Plugin not found – Paseo",
loaderData?.plugin?.description ?? "Plugin not found.",
`/plugins/${params.owner}/${params.slug}`,
loaderData?.plugin.screenshots[0],
loaderData?.plugin && firstMediaImage(loaderData.plugin),
),
component: PluginPage,
notFoundComponent: () => (
@@ -46,6 +48,8 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
const META_LINK_CLASS =
"inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
const AUTHOR_LINK_CLASS =
"inline-flex items-center gap-1.5 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
function PluginPage() {
const { plugin, installs } = Route.useLoaderData();
@@ -55,67 +59,42 @@ function PluginPage() {
return (
<SiteShell width="default">
<div className="max-w-3xl">
<a href="/plugins" className="text-sm text-muted-foreground hover:text-foreground">
← Plugins
</a>
<h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1>
<p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p>
<div className="mt-6">
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']">
<AuthorLink author={author} className={META_LINK_CLASS}>
{author.name}
</AuthorLink>
{category && (
<a href={categoryHref(category.slug)} className={META_LINK_CLASS}>
{category.label}
</a>
)}
<span className="tabular-nums">{formatInstalls(installs)} installs</span>
<span className="font-mono">{pluginVersion(plugin)}</span>
<a
href={plugin.repository.url}
target="_blank"
rel="noopener noreferrer"
className={META_LINK_CLASS}
>
Source
<ExternalLink className="h-3 w-3" />
<a href="/plugins" className="text-sm text-muted-foreground hover:text-foreground">
← Plugins
</a>
<h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1>
<p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p>
<div className="mt-6 w-fit max-w-full">
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']">
<AuthorLink author={author} className={AUTHOR_LINK_CLASS}>
<AuthorAvatar author={author} size="xs" />
{author.name}
</AuthorLink>
{category && (
<a href={categoryHref(category.slug)} className={META_LINK_CLASS}>
{category.label}
</a>
{npm && (
<a href={npm} target="_blank" rel="noopener noreferrer" className={META_LINK_CLASS}>
npm
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
{plugin.screenshots.length > 0 && (
<div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0">
{plugin.screenshots.map((url, index) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
className="aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] md:w-[calc(50%-0.375rem)]"
>
<img
src={url}
alt={`${plugin.name} screenshot ${index + 1}`}
loading="lazy"
className="h-full w-full object-cover object-top"
/>
</a>
))}
</div>
)}
<InstallCount count={installs} />
<span className="font-mono">{pluginVersion(plugin)}</span>
<PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}>
Source
<ExternalLink className="h-3 w-3" />
</PluginContentLink>
{npm && (
<PluginContentLink href={npm} className={META_LINK_CLASS}>
npm
<ExternalLink className="h-3 w-3" />
</PluginContentLink>
)}
</div>
<div className="mt-10 border-t border-white/10 pt-10">
<DocsMarkdown>{readmeBody(plugin.readme)}</DocsMarkdown>
</div>
<MediaGallery name={plugin.name} media={plugin.media} />
<div className="mt-10 border-t border-white/10 pt-10">
<PluginOverview>{readmeBody(plugin.readme)}</PluginOverview>
</div>
</SiteShell>
);
+107 -84
View File
@@ -19,14 +19,17 @@ import {
addedAgo,
CATEGORIES,
type CategorySlug,
featuredPlugins,
getCategory,
getPluginsInCategory,
getRegistry,
type InstallWindow,
mostInstalled,
newestFirst,
pluginOwner,
} from "~/plugins";
import { ContributeSection } from "~/plugins/contribute-links";
import { PluginsHero } from "~/plugins/hero";
import {
type BrowseQuery,
browseHref,
@@ -36,10 +39,10 @@ import {
parseSearchTerm,
parseSort,
parseWindow,
SUBMIT_URL,
} from "~/plugins/links";
import { InstallCount } from "~/plugins/install-count";
import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card";
import { PluginSearch } from "~/plugins/plugin-search";
import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section";
import { WindowSwitch } from "~/plugins/window-switch";
import "~/styles.css";
@@ -58,7 +61,9 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = {
utils: Wrench,
};
const SUBMIT_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground";
// What's new and Featured cards: a swipeable row on phones, a grid from `sm` up.
const CARD_ROW_CLASS =
"-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4";
const SEE_ALL_CLASS =
"inline-flex items-center gap-0.5 text-sm text-muted-foreground transition-colors hover:text-foreground";
@@ -91,8 +96,9 @@ export const Route = createFileRoute("/plugins/")({
});
function PluginsPage() {
const { plugins, installs, now } = Route.useLoaderData();
const { plugins, featured: featuredIds, installs, now } = Route.useLoaderData();
const window = Route.useSearch().window ?? DEFAULT_WINDOW;
const featured = featuredPlugins(plugins, featuredIds);
const newest = newestFirst(plugins).slice(0, NEW_COUNT);
const top = mostInstalled(plugins, installs, window).slice(0, TOP_COUNT);
const windowHrefs = useMemo(
@@ -104,95 +110,112 @@ function PluginsPage() {
[],
);
const searchScope = useMemo<BrowseQuery>(() => ({ sort: "installs", window }), [window]);
const authorCount = new Set(plugins.map(pluginOwner)).size;
return (
<SiteShell width="default">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-3xl font-medium tracking-tight">
Plugins
<span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground">
{plugins.length}
</span>
</h1>
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
<a href={SUBMIT_URL} className={SUBMIT_CLASS}>
Submit a plugin
</a>
<PluginSearch scope={searchScope} className="w-full sm:w-56" />
</div>
</div>
<PluginsHero
pluginCount={plugins.length}
authorCount={authorCount}
searchScope={searchScope}
/>
<section aria-labelledby="whats-new" className="mt-10">
<div className="mb-4 flex items-baseline justify-between gap-4">
<h2 id="whats-new" className="text-lg font-medium">
What’s new
</h2>
<a href={browseHref({ sort: "new", window: DEFAULT_WINDOW })} className={SEE_ALL_CLASS}>
See all
<ChevronRight className="h-3.5 w-3.5" />
</a>
</div>
<div className="-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4">
{newest.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
))}
</div>
</section>
<section aria-labelledby="categories" className="mt-14">
<h2 id="categories" className="mb-4 text-lg font-medium">
Categories
</h2>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{CATEGORIES.map((category) => {
const Icon = CATEGORY_ICONS[category.slug];
return (
<div className="mt-16 flex flex-col gap-14">
{featured.length > 0 && (
<PluginSection labelledBy="featured">
<PluginSectionHeader>
<div>
<PluginSectionTitle id="featured">Featured</PluginSectionTitle>
<p className="text-sm text-muted-foreground">A selection of hand picked plugins</p>
</div>
<a
key={category.slug}
href={categoryHref(category.slug)}
className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-3.5 py-3.5 transition-colors hover:border-white/20 hover:bg-white/[0.05] sm:px-4"
href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })}
className={SEE_ALL_CLASS}
>
<Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 text-sm leading-tight text-white">
{category.label}
</span>
<span className="text-xs tabular-nums text-extra-muted-foreground">
{getPluginsInCategory(plugins, category.slug).length}
</span>
See all
<ChevronRight className="h-3.5 w-3.5" />
</a>
);
})}
</div>
</section>
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{featured.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.all ?? 0} />
</NewPluginCard>
))}
</div>
</PluginSection>
)}
<section
id="most-installed"
aria-labelledby="most-installed-title"
className="mt-14 scroll-mt-8"
>
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
<h2 id="most-installed-title" className="text-lg font-medium">
<a
href={browseHref({ sort: "installs", window })}
className="group inline-flex items-center gap-1"
>
Most installed
<ChevronRight className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
<PluginSection labelledBy="whats-new">
<PluginSectionHeader>
<PluginSectionTitle id="whats-new">What’s new</PluginSectionTitle>
<a href={browseHref({ sort: "new", window: DEFAULT_WINDOW })} className={SEE_ALL_CLASS}>
See all
<ChevronRight className="h-3.5 w-3.5" />
</a>
</h2>
<WindowSwitch current={window} hrefs={windowHrefs} />
</div>
<div className="-mx-2 grid gap-x-8 md:grid-cols-2">
{top.map((plugin, index) => (
<PluginRankRow
key={plugin.id}
plugin={plugin}
rank={index + 1}
installs={installs[plugin.id]?.[window] ?? 0}
/>
))}
</div>
</section>
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{newest.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin}>
{addedAgo(plugin, now)}
</NewPluginCard>
))}
</div>
</PluginSection>
<PluginSection labelledBy="categories">
<PluginSectionTitle id="categories">Categories</PluginSectionTitle>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{CATEGORIES.map((category) => {
const Icon = CATEGORY_ICONS[category.slug];
return (
<a
key={category.slug}
href={categoryHref(category.slug)}
className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-3.5 py-3.5 transition-colors hover:border-white/20 hover:bg-white/[0.05] sm:px-4"
>
<Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 text-sm leading-tight text-white">
{category.label}
</span>
<span className="text-xs tabular-nums text-extra-muted-foreground">
{getPluginsInCategory(plugins, category.slug).length}
</span>
</a>
);
})}
</div>
</PluginSection>
<PluginSection
id="most-installed"
labelledBy="most-installed-title"
className="scroll-mt-8"
>
<PluginSectionHeader>
<PluginSectionTitle id="most-installed-title">
<a
href={browseHref({ sort: "installs", window })}
className="group inline-flex items-center gap-1"
>
Most installed
<ChevronRight className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</a>
</PluginSectionTitle>
<WindowSwitch current={window} hrefs={windowHrefs} />
</PluginSectionHeader>
<div className="-mx-4 grid gap-x-8 md:grid-cols-2">
{top.map((plugin, index) => (
<PluginRankRow
key={plugin.id}
plugin={plugin}
rank={index + 1}
installs={installs[plugin.id]?.[window] ?? 0}
/>
))}
</div>
</PluginSection>
</div>
<ContributeSection />
</SiteShell>
+14 -4
View File
@@ -21,6 +21,16 @@ A plugin is a TypeScript project installed into one Paseo daemon. It can add
[connect a coding agent as a provider](/docs/plugins/providers). Client
contributions run on every Paseo client connected to that daemon, including mobile.
Browse [published plugins](https://paseo.sh/plugins), then install one on your daemon:
```bash
paseo plugin add owner/slug
```
Or paste `owner/slug` into **Settings → Plugins → Plugin source** and select **Install plugin**.
Paseo installs the registry's reviewed artifact. Use `git:owner/repository` or a full Git URL
when you want to install directly from a Git repository.
This guide scaffolds a plugin, runs it, and adds a workspace panel to it.
## Create a plugin
@@ -246,13 +256,13 @@ 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:
Plugins published in a Git repository install by explicit Git shorthand or URL:
```bash
paseo plugin add owner/repository
paseo plugin add git: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
paseo plugin add git:owner/monorepo:plugins/workspace
paseo plugin add git:owner/repository --ref main
```
Append `:relative/path` when the plugin lives below the repository root. `--ref` selects the initial
+89 -1
View File
@@ -11,9 +11,24 @@ category: Plugins
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:
- [Plugin registry](#plugin-registry): share a reviewed artifact users install by `owner/slug`.
- [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.
## Plugin registry
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
Users install the reviewed artifact with:
```bash
paseo plugin add owner/slug
```
Publish your artifact through npm or Git, then follow the
[registry submission guide](https://github.com/getpaseo/plugins) to submit it for review.
Registry installations and updates use the approved revision and plugin path.
Direct Git installs use explicit `git:owner/repository` shorthand or a full Git URL.
## Publish on npm
The scaffold prepares the package files and development dependencies. You choose the package name
@@ -56,6 +71,7 @@ The scaffold includes this `files` list in `package.json`:
{
"files": [
"paseo-plugin.json",
"OVERVIEW.md",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
@@ -136,12 +152,84 @@ In the app, enter only the source identifier.
:::
## Icons and screenshots
Declare your display name, icon, screenshots, and demo videos in `paseo-plugin.json`:
```json
{
"id": "review-tools",
"name": "Review tools",
"description": "Reviews changes before merge",
"icon": "assets/icon.png",
"media": ["assets/screenshot.png", "https://example.com/review-demo.mp4"],
"requirements": { "paseo": ">=0.11.0" }
}
```
All three fields are optional. `name` gives the registry and website a display name without
changing the installation ID. When omitted, the registry falls back to a humanized ID.
Registry and website display requires the registry builder to read these fields from the pinned
manifest. That update is a separate follow-up; until it lands, these fields are exposed only
through the daemon's installed-plugin list.
`icon` must point to a PNG inside the plugin package. `media` accepts paths to images and videos
inside the package or HTTPS URLs, in display order. Paths are relative to the manifest; use
forward slashes and keep them inside the package. See the [manifest reference](/docs/plugins/reference#project-files)
for validation rules.
Commit local assets alongside the manifest at the pinned revision. For npm, add their directory
(for example, `assets/`) to `package.json`'s `files` list and verify it with `npm pack --dry-run`.
The scaffold documents these fields in its manifest's `$comment`; add your display name and
assets before publishing.
Manifests using these fields fail to install on daemons before 0.11.0. Set
`requirements.paseo` to `>=0.11.0` or a narrower supported range.
## Your listing page
Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo.
It helps someone decide whether to install your plugin; the install command is already at
the top of that page. A README assumes a GitHub audience and carries installation
instructions, technical details, and badges. Long, AI-generated READMEs make people read
past that material to understand what a plugin does.
`OVERVIEW.md` is required to list your plugin in the registry. Commit it beside
`paseo-plugin.json` in the source repository at the pinned commit. The registry resolves it
relative to the manifest, under `pluginPath` for monorepos. Include it in the published npm
package too; the scaffold's `files` list includes it. Replace the scaffold's guidance comment
with useful facts before publishing.
The repository overview takes precedence over a registry import stopgap. Online validation
fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records
that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump
requires a repository overview and removes the stopgap in the same PR. A bump without the
repository overview fails validation. README files do not supply the overview.
Author overviews and registry stopgaps follow the same content contract, in this order.
Choose headings only when they help; length follows complexity. A theme needs one paragraph.
1. Explain what the plugin is and does in plain terms first.
2. Explain how it works only when that is not obvious.
3. Explain setup when needed: settings, accounts, tokens, providers, external tools, or other
plugins. Include applicable daemon version and operating system requirements. Setup
guidance is allowed; installation instructions are not.
4. Explain capabilities and settings worth understanding, what each option does, what the
plugin reads or sends and where, permissions, and known limits.
Use sentence case and plain factual language, with no em dashes. Omit installation commands,
badges, changelog, contributing or license sections, marketing, and unsupported claims.
Avoid implementation filler such as empty cleanup functions, catalogs of theme-token fields,
or lists of absent features. Keep only what helps someone choose the plugin.
If your plugin is an unchanged imported record, you can propose an author-written overview
to replace the registry stopgap. Do not add import credit to your own `OVERVIEW.md`.
## 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
paseo plugin add git:acme/paseo-review
```
For another Git host:
+42 -22
View File
@@ -52,12 +52,24 @@ The required root manifest is `paseo-plugin.json`:
}
```
| 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. |
| Field | Required | Behavior |
| -------------- | -------- | ----------------------------------------------------------------------------------------- |
| `id` | Yes | Default installation ID. |
| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. |
| `icon` | No | Relative path to a PNG inside the plugin package. |
| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. |
| `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. |
Paths are relative to `paseo-plugin.json`, use forward slashes, and cannot contain `..`
segments. See [icons and screenshots](/docs/plugins/publishing#icons-and-screenshots) for an example.
Paseo validates the references without opening local assets or fetching URLs.
Unknown top-level fields are ignored. Known fields still validate, and unknown keys inside
`requirements` are rejected so a misspelled constraint cannot silently skip a compatibility check.
Manifests using `name`, `icon`, or `media` require Paseo 0.11.0 or later; older daemons reject
these fields during installation.
### Requirements
@@ -2258,16 +2270,20 @@ failures stay inside the plugin error boundary.
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.
The CLI resolves relative directory paths from your current working directory and expands `~`
to your local home directory, preserving any `:plugin/path` suffix. With `--host`, use an absolute
path that exists on the daemon host; plugin files are read there and are never uploaded by the CLI.
Paths entered in Settings resolve relative to the daemon's working directory, and `~` expands to
the daemon's home directory.
| Source | Accepted form | Example |
| -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- |
| Plugin registry | `owner/slug` or `host/owner/slug` | `acme/review` |
| 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` |
| GitHub repository | `git:owner/repository` or `github: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` |
| Plugin below a source root | Append `:relative/plugin/path` to a directory, Git, or npm 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.
@@ -2283,24 +2299,27 @@ 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 `:`.
1. A bare `owner/slug` is a registry id; local directory sources are `.` or `..`, or start with `./`, `../`, `.\`, `..\`, `/`, `~`, a Windows drive (`C:\` or `C:/`), or a UNC (`\\server\share`) prefix.
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.
4. Resolve bare `owner/slug` through the default plugin registry and `host/owner/slug` through
that registry host. Resolve Git URLs and SCP sources as Git. `github:` requires
`owner/repository` shorthand; `git:` accepts it as well as URLs and SCP sources.
5. Resolve a remaining npm package name with its optional selector through the host's registry.
Reject anything else.
Plugin registry installs are off by default. When the daemon enables them with
`pluginRegistryEnabled: true` or `PASEO_PLUGIN_REGISTRY_ENABLED=1`, bare `owner/slug` and
`host/owner/slug` resolve through the plugin registry instead of GitHub, the registry record owns
the revision and plugin path, and GitHub shorthand requires `github:`.
The default registry is `https://plugins.paseo.sh`. Browse [published plugins](https://paseo.sh/plugins)
and install with `paseo plugin add owner/slug`. Registry records own the revision and plugin path.
Use `git:owner/repository` for explicit GitHub shorthand or a full Git URL.
Set `PASEO_PLUGIN_REGISTRY` to use a self-hosted default URL, including a path prefix.
Private registry credentials use `pluginRegistries: { "host": { "authorization": "Bearer token" } }`
in daemon config. Restart the daemon after changing these startup settings. Credentials go only
to that registry, never to artifact hosts or redirects. Installed plugins retain their recorded
source and registry URL when defaults change.
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
@@ -2347,9 +2366,10 @@ deletes its managed files; removing a directory plugin keeps your source directo
paseo plugin init /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin --id another-runtime-id
paseo plugin add owner/repository
paseo plugin add owner/slug
paseo plugin add git:owner/repository
paseo plugin add https://git.example.com/owner/repository.git --ref main
paseo plugin add owner/monorepo:plugins/review
paseo plugin add git:owner/monorepo:plugins/review
paseo plugin ls [id]
paseo plugin update <id>
paseo plugin update --all --check
+42
View File
@@ -64,6 +64,7 @@ The generated project contains:
```text
my-plugin/
paseo-plugin.json
OVERVIEW.md
package.json
tsconfig.json
index.client.tsx
@@ -638,6 +639,47 @@ Do not edit a local config when the target is a remote daemon. Perform the edit
When the same screen or sidebar item exists on several connected hosts, Paseo shows it once. The screen header has a host picker; a sidebar item uses the host of the screen on display, else the host last picked in one of the plugin's screens. The selected host owns the bundle, SDK calls, RPCs, and query cache. An offline selected host does not fall through to another host. Attachment sources stay scoped to the composer's host.
## Publishing a listing
Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo.
It helps someone decide whether to install your plugin; the install command is already at
the top of that page. A README assumes a GitHub audience and carries installation
instructions, technical details, and badges. Long, AI-generated READMEs make people read
past that material to understand what a plugin does.
`OVERVIEW.md` is required to list your plugin in the registry. Commit it beside
`paseo-plugin.json` in the source repository at the pinned commit. The registry resolves it
relative to the manifest, under `pluginPath` for monorepos. Include it in the published npm
package too; the scaffold's `files` list includes it. Replace the scaffold's guidance comment
with useful facts before publishing.
The repository overview takes precedence over a registry import stopgap. Online validation
fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records
that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump
requires a repository overview and removes the stopgap in the same PR. A bump without the
repository overview fails validation. README files do not supply the overview.
Author overviews and registry stopgaps follow the same content contract, in this order.
Choose headings only when they help; length follows complexity. A theme needs one paragraph.
1. Explain what the plugin is and does in plain terms first.
2. Explain how it works only when that is not obvious.
3. Explain setup when needed: settings, accounts, tokens, providers, external tools, or other
plugins. Include applicable daemon version and operating system requirements. Setup
guidance is allowed; installation instructions are not.
4. Explain capabilities and settings worth understanding, what each option does, what the
plugin reads or sends and where, permissions, and known limits.
Use sentence case and plain factual language, with no em dashes. Omit installation commands,
badges, changelog, contributing or license sections, marketing, and unsupported claims.
Avoid implementation filler such as empty cleanup functions, catalogs of theme-token fields,
or lists of absent features. Keep only what helps someone choose the plugin.
If your plugin is an unchanged imported record, you can propose an author-written overview
to replace the registry stopgap. Do not add import credit to your own `OVERVIEW.md`.
See [Your listing page](https://paseo.sh/docs/plugins/publishing.md#your-listing-page).
## Typecheck and manage
When editing a plugin, typecheck its source before install or reload: