mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
919700c6a2 | ||
|
|
3f1a04304d | ||
|
|
50cf244e86 | ||
|
|
aaeefdc00b | ||
|
|
c5deaa0516 | ||
|
|
72c180aaf7 | ||
|
|
66b2567be3 | ||
|
|
cb2b569bbd | ||
|
|
2ecf3ba783 | ||
|
|
4515061f88 | ||
|
|
3c478e5967 | ||
|
|
05637a7f05 | ||
|
|
13d21f60f1 | ||
|
|
fc3b2c4eb7 | ||
|
|
a9fef4879a | ||
|
|
a292b402cf | ||
|
|
64c9f263e8 | ||
|
|
48059aeae3 |
+10
-2
@@ -123,6 +123,13 @@ 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
|
||||
@@ -152,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
@@ -1 +1 @@
|
||||
sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE=
|
||||
sha256-zL3+gbScK8eMkjQUtKDX4UJTXt4BMeqMSyeWB017MA4=
|
||||
|
||||
Generated
+1
@@ -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";
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -1070,6 +1070,8 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "سلسلة المناقشة",
|
||||
resolved: "تم الحل",
|
||||
outdated: "قديم",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "غير قادر على تحميل حالة طلب السحب",
|
||||
|
||||
@@ -1078,6 +1078,8 @@ export const en = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "Discussion thread",
|
||||
resolved: "Resolved",
|
||||
outdated: "Outdated",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Unable to load pull request status",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1083,6 +1083,8 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "ディスカッションスレッド",
|
||||
resolved: "解決済み",
|
||||
outdated: "古い",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "プルリクエストのステータスを読み込めません",
|
||||
|
||||
@@ -1077,6 +1077,8 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "토론 스레드",
|
||||
resolved: "해결됨",
|
||||
outdated: "오래됨",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "풀 리퀘스트 상태를 불러올 수 없습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1087,6 +1087,8 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "Ветка обсуждения",
|
||||
resolved: "Решено",
|
||||
outdated: "Устарело",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Не удалось загрузить статус PR",
|
||||
|
||||
@@ -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 状态",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
+1202
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 }]);
|
||||
});
|
||||
});
|
||||
|
||||
+56
@@ -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 };
|
||||
}
|
||||
+154
@@ -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: {} });
|
||||
}
|
||||
});
|
||||
`;
|
||||
@@ -205,19 +205,12 @@ test("loads private plugin registry settings through the configuration boundary"
|
||||
const home = await mkdtemp(path.join(os.tmpdir(), "paseo-registry-config-"));
|
||||
try {
|
||||
const pluginRegistries = { "plugins.example.test": { authorization: "Bearer fixture" } };
|
||||
await writeFile(
|
||||
path.join(home, "config.json"),
|
||||
JSON.stringify({ pluginRegistries, pluginRegistryEnabled: false }),
|
||||
);
|
||||
await writeFile(path.join(home, "config.json"), JSON.stringify({ pluginRegistries }));
|
||||
const config = loadConfig(home, {
|
||||
env: {
|
||||
PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal",
|
||||
PASEO_PLUGIN_REGISTRY_ENABLED: "false",
|
||||
},
|
||||
env: { PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal" },
|
||||
});
|
||||
expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal");
|
||||
expect(config.pluginRegistries).toEqual(pluginRegistries);
|
||||
expect(config).not.toHaveProperty("pluginRegistryEnabled");
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { OpenCodeV2AgentClient } from "../agent/providers/opencode/v2/agent.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
|
||||
// Reproduction for #6006. Needs an OpenCode 2.x binary in OPENCODE_V2_BIN.
|
||||
const OPENCODE_V2_BIN = process.env.OPENCODE_V2_BIN;
|
||||
|
||||
const PROVIDER = "paseo-catalog-drift";
|
||||
const KEPT_MODEL = "kept-model";
|
||||
const RETIRED_MODEL = "retired-model";
|
||||
|
||||
function catalog(modelIds: string[]) {
|
||||
return {
|
||||
[PROVIDER]: {
|
||||
id: PROVIDER,
|
||||
env: ["PASEO_CATALOG_DRIFT_KEY"],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
api: "http://127.0.0.1:9/v1",
|
||||
name: "Catalog drift",
|
||||
models: Object.fromEntries(
|
||||
modelIds.map((id) => [
|
||||
id,
|
||||
{
|
||||
id,
|
||||
name: id,
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
tool_call: true,
|
||||
temperature: true,
|
||||
release_date: "2025-06-17",
|
||||
last_updated: "2025-06-17",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
open_weights: false,
|
||||
limit: { context: 128_000, output: 8_000 },
|
||||
cost: { input: 0, output: 0 },
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.runIf(OPENCODE_V2_BIN)(
|
||||
"a model OpenCode dropped from its catalog stays in the picker and fails to apply",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "opencode-v2-catalog-drift-")));
|
||||
const modelsPath = path.join(root, "models.json");
|
||||
const cwd = path.join(root, "work");
|
||||
mkdirSync(cwd);
|
||||
writeFileSync(modelsPath, JSON.stringify(catalog([KEPT_MODEL, RETIRED_MODEL])));
|
||||
const provider = new OpenCodeV2AgentClient({
|
||||
logger,
|
||||
settings: {
|
||||
command: { mode: "replace", argv: [OPENCODE_V2_BIN!] },
|
||||
env: {
|
||||
OPENCODE_MODELS_PATH: modelsPath,
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "1",
|
||||
OPENCODE_DISABLE_AUTO_UPDATE: "1",
|
||||
PASEO_CATALOG_DRIFT_KEY: "test",
|
||||
XDG_CONFIG_HOME: path.join(root, "config"),
|
||||
XDG_DATA_HOME: path.join(root, "data"),
|
||||
XDG_CACHE_HOME: path.join(root, "cache"),
|
||||
},
|
||||
},
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({ agentClients: { opencode: provider }, logger });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
|
||||
const before = await client.listProviderModels("opencode", { cwd });
|
||||
expect(before.models?.map((entry) => entry.id)).toContain(`${PROVIDER}/${RETIRED_MODEL}`);
|
||||
|
||||
// OpenCode's catalog drops the model while Paseo sits idle.
|
||||
writeFileSync(modelsPath, JSON.stringify(catalog([KEPT_MODEL])));
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "catalog drift",
|
||||
model: `${PROVIDER}/${KEPT_MODEL}`,
|
||||
modeId: "build",
|
||||
});
|
||||
|
||||
const listed = await client.listProviderModels("opencode", { cwd });
|
||||
const offered = listed.models?.map((entry) => entry.id) ?? [];
|
||||
let switchError: string | null = null;
|
||||
if (offered.includes(`${PROVIDER}/${RETIRED_MODEL}`)) {
|
||||
try {
|
||||
await client.setAgentModel(agent.id, `${PROVIDER}/${RETIRED_MODEL}`);
|
||||
} catch (error) {
|
||||
switchError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
// Every model the picker offers must be one OpenCode accepts.
|
||||
expect({ offered, switchError }).toEqual({
|
||||
offered: [`${PROVIDER}/${KEPT_MODEL}`],
|
||||
switchError: null,
|
||||
});
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close().catch(() => undefined);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
@@ -308,8 +308,6 @@ export const PersistedConfigSchema = z
|
||||
|
||||
providers: ProvidersSchema.optional(),
|
||||
pluginRegistries: PluginRegistriesSchema.optional(),
|
||||
// COMPAT(plugin-registry-gate): added in v0.11.0; accept the ignored startup key until 2027-04-06.
|
||||
pluginRegistryEnabled: z.boolean().optional(),
|
||||
pluginsEnabled: z.boolean().optional(),
|
||||
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
||||
worktrees: WorktreesConfigSchema.optional(),
|
||||
|
||||
@@ -494,7 +494,7 @@ describe("PluginService", () => {
|
||||
commit: stdout.trim(),
|
||||
pluginPath: ".",
|
||||
},
|
||||
screenshots: [],
|
||||
media: [],
|
||||
submittedAt: "2026-10-03",
|
||||
reviewedAt: "2026-10-03",
|
||||
updatedAt: "2026-10-03",
|
||||
|
||||
@@ -433,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",
|
||||
@@ -516,20 +516,10 @@ 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
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 {
|
||||
defaultUrl?: string;
|
||||
@@ -49,7 +57,7 @@ export async function resolveRegistryPlugin(
|
||||
}
|
||||
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 =
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -34,7 +36,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 +80,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 +98,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 +122,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 +208,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 +301,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 +360,161 @@ 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(featured.getByRole("link", { name: /Added/ })).toHaveText([
|
||||
/Dracula/,
|
||||
/Herald/,
|
||||
/launchd Jobs/,
|
||||
]);
|
||||
});
|
||||
|
||||
test("lists the nine categories in order with counts, and the newest plugins first", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -320,3 +529,124 @@ test.describe("registry fixture layout", () => {
|
||||
).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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { InstallCounts } from "./installs";
|
||||
import { type BrowseQuery, browseHref } from "./links";
|
||||
import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card";
|
||||
import { PluginSearch } from "./plugin-search";
|
||||
import { PluginSection } from "./section";
|
||||
import {
|
||||
addedAgo,
|
||||
CATEGORIES,
|
||||
@@ -55,7 +56,7 @@ export function BrowsePage({
|
||||
<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">
|
||||
@@ -72,36 +73,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} added={addedAgo(plugin, now)} />
|
||||
) : (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
installs={installs[plugin.id]?.[query.window] ?? 0}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PluginSection>
|
||||
</div>
|
||||
</div>
|
||||
</SiteShell>
|
||||
@@ -186,7 +189,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 +210,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,16 +26,16 @@ 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">
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<a href={DOCS_URL} className={PRIMARY_BUTTON_CLASS}>
|
||||
Read the docs
|
||||
</a>
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { pluginCardScreenshot } from "./thumbnails";
|
||||
import { pluginHref } from "./links";
|
||||
import { PluginTile } from "./plugin-tile";
|
||||
import { formatInstalls, getCategory, type Plugin } from "./registry";
|
||||
import { firstMediaImage, formatInstalls, 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 +17,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"
|
||||
/>
|
||||
) : (
|
||||
@@ -98,11 +100,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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||
import type { Plugin } from "./registry";
|
||||
|
||||
const SIZE_CLASS = {
|
||||
@@ -12,7 +13,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()
|
||||
|
||||
@@ -4,6 +4,7 @@ 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, recordClientInstall } from "./installs";
|
||||
export interface RegistryEnvironment {
|
||||
@@ -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") {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addedAgo,
|
||||
featuredPlugins,
|
||||
formatInstalls,
|
||||
installCommand,
|
||||
mostInstalled,
|
||||
@@ -26,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",
|
||||
@@ -40,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" };
|
||||
@@ -60,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",
|
||||
@@ -155,6 +196,7 @@ describe("plugin registry", () => {
|
||||
registry: { name: "Internal", url: base },
|
||||
categories: [],
|
||||
plugins: [plugin],
|
||||
featured: [],
|
||||
generatedAt: "2026-10-03",
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { CATEGORIES, type Category, type CategorySlug } from "./categories";
|
||||
import type { InstallCounts, InstallWindow } from "./installs";
|
||||
export { CATEGORIES, type Category, type CategorySlug };
|
||||
@@ -15,6 +16,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));
|
||||
}
|
||||
@@ -72,9 +77,14 @@ export function searchPlugins(plugins: Plugin[], term: string): Plugin[] {
|
||||
.includes(needle),
|
||||
);
|
||||
}
|
||||
/** Newest submissions first; equal dates keep index order. */
|
||||
/** 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 +95,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`;
|
||||
|
||||
@@ -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, NewPluginCard])(
|
||||
"renders a thumbnail instead of downloading the original screenshot (%s)",
|
||||
(Card) => {
|
||||
const html = renderToStaticMarkup(createElement(Card, { plugin, added: "today" }));
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -64,21 +65,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>
|
||||
|
||||
@@ -1,10 +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 {
|
||||
firstMediaImage,
|
||||
formatInstalls,
|
||||
getAuthor,
|
||||
getCategory,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "~/plugins";
|
||||
import { AuthorLink } from "~/plugins/author-link";
|
||||
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: () => (
|
||||
@@ -75,46 +77,22 @@ function PluginPage() {
|
||||
)}
|
||||
<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}
|
||||
>
|
||||
<PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}>
|
||||
Source
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</PluginContentLink>
|
||||
{npm && (
|
||||
<a href={npm} target="_blank" rel="noopener noreferrer" className={META_LINK_CLASS}>
|
||||
<PluginContentLink href={npm} className={META_LINK_CLASS}>
|
||||
npm
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</PluginContentLink>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
<MediaGallery name={plugin.name} media={plugin.media} />
|
||||
|
||||
<div className="mt-10 border-t border-white/10 pt-10">
|
||||
<DocsMarkdown>{readmeBody(plugin.readme)}</DocsMarkdown>
|
||||
<PluginOverview>{readmeBody(plugin.readme)}</PluginOverview>
|
||||
</div>
|
||||
</div>
|
||||
</SiteShell>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
addedAgo,
|
||||
CATEGORIES,
|
||||
type CategorySlug,
|
||||
featuredPlugins,
|
||||
getCategory,
|
||||
getPluginsInCategory,
|
||||
getRegistry,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
} from "~/plugins/links";
|
||||
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";
|
||||
|
||||
@@ -59,6 +61,9 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = {
|
||||
};
|
||||
|
||||
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(
|
||||
@@ -122,77 +128,100 @@ function PluginsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-10 flex flex-col gap-14">
|
||||
{featured.length > 0 && (
|
||||
<PluginSection labelledBy="featured">
|
||||
<PluginSectionHeader>
|
||||
<div>
|
||||
<PluginSectionTitle id="featured">Featured</PluginSectionTitle>
|
||||
<p className="mt-1 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 plugins
|
||||
<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} added={addedAgo(plugin, now)} />
|
||||
))}
|
||||
</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} added={addedAgo(plugin, now)} />
|
||||
))}
|
||||
</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>
|
||||
|
||||
@@ -2258,8 +2258,11 @@ 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 daemon expands `~` to its home directory.
|
||||
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 |
|
||||
| -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- |
|
||||
|
||||
Reference in New Issue
Block a user