mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 05:32:58 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65cc85cad2 |
+2
-10
@@ -123,13 +123,6 @@ 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
|
||||
@@ -159,9 +152,8 @@ 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. 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.
|
||||
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged
|
||||
through `installPluginSource`; only the daemon resolves host paths and acquires sources.
|
||||
|
||||
`ManagedPluginSources` owns acquisition and offline source description. Config stores the active
|
||||
directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-zL3+gbScK8eMkjQUtKDX4UJTXt4BMeqMSyeWB017MA4=
|
||||
sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE=
|
||||
|
||||
Generated
-1
@@ -40174,7 +40174,6 @@
|
||||
"@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,4 +1,3 @@
|
||||
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";
|
||||
@@ -656,27 +655,3 @@ 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 { en } from "@/i18n/resources/en";
|
||||
import { getStateLabel } from "@/git/pull-request-panel/data";
|
||||
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(en.workspace.git.pr.states[state], {
|
||||
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,13 +9,12 @@ import {
|
||||
isPipelineActiveStatus,
|
||||
mapPipelineStatus,
|
||||
} from "@/git/forges/gitlab";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors";
|
||||
import {
|
||||
deriveAvatarColor,
|
||||
formatAge,
|
||||
getActivityVerbKey,
|
||||
getStateLabelKey,
|
||||
getActivityVerb,
|
||||
getStateLabel,
|
||||
mapPrPaneData,
|
||||
} from "./data";
|
||||
|
||||
@@ -780,34 +779,37 @@ describe("formatAge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const english = i18n.getFixedT("en");
|
||||
const french = i18n.getFixedT("fr");
|
||||
|
||||
describe("getStateLabelKey", () => {
|
||||
describe("getStateLabel", () => {
|
||||
it.each([
|
||||
["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);
|
||||
["open", "Open"],
|
||||
["draft", "Draft"],
|
||||
["merged", "Merged"],
|
||||
["closed", "Closed"],
|
||||
] as const)("maps %s → %s", (state, expected) => {
|
||||
expect(getStateLabel(state)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActivityVerbKey", () => {
|
||||
it.each([
|
||||
[{ kind: "comment" }, "Commented", "A commenté"],
|
||||
[{ kind: "review", reviewState: "approved" }, "Approved", "A approuvé"],
|
||||
[
|
||||
{ kind: "review", reviewState: "changes_requested" },
|
||||
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(
|
||||
"Requested changes",
|
||||
"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);
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -318,18 +318,16 @@ function parsePullRequestNumber(url: string): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
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 getStateLabel(state: PrState): string {
|
||||
if (state === "draft") return "Draft";
|
||||
if (state === "merged") return "Merged";
|
||||
if (state === "closed") return "Closed";
|
||||
return "Open";
|
||||
}
|
||||
|
||||
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";
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
canAddPullRequestActivityToChat,
|
||||
} from "./context-attachment";
|
||||
import { ChecksSection, getCheckIdentity } from "./checks-section";
|
||||
import { getActivityVerbKey, getStateLabelKey } from "./data";
|
||||
import { getActivityVerb, getStateLabel } 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">
|
||||
{t(getStateLabelKey(data.state))}
|
||||
{getStateLabel(data.state)}
|
||||
</Text>
|
||||
{nativeHeaderMeta}
|
||||
{repoIdentity ? (
|
||||
@@ -760,8 +760,7 @@ function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: nu
|
||||
}
|
||||
|
||||
function ActivityVerb({ activity }: { activity: PrPaneActivity }) {
|
||||
const { t } = useTranslation();
|
||||
const verb = t(getActivityVerbKey(activity)).toLowerCase();
|
||||
const verb = getActivityVerb(activity).toLowerCase();
|
||||
if (activity.kind === "review" && activity.reviewState === "approved") {
|
||||
return (
|
||||
<View style={styles.verbGroup}>
|
||||
@@ -1066,12 +1065,8 @@ function ThreadBlock({
|
||||
? formatPullRequestThreadPath(thread.location)
|
||||
: t("workspace.git.pr.thread.discussion")}
|
||||
</Text>
|
||||
{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}
|
||||
{thread.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
|
||||
{thread.location?.isOutdated ? <StatusBadge label="Outdated" /> : null}
|
||||
<View style={styles.headerTrailing}>
|
||||
{collapsed ? (
|
||||
<View style={styles.threadCount}>
|
||||
|
||||
@@ -76,7 +76,6 @@ 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;
|
||||
@@ -88,16 +87,6 @@ const untranslatedPullRequestPanelLabels = [
|
||||
"Adding...",
|
||||
"Comment actions",
|
||||
"Thread actions",
|
||||
"Commented",
|
||||
"Approved",
|
||||
"Requested changes",
|
||||
"Reviewed",
|
||||
"Draft",
|
||||
"Merged",
|
||||
"Closed",
|
||||
"Open",
|
||||
"Resolved",
|
||||
"Outdated",
|
||||
] as const;
|
||||
|
||||
function findUntranslatedPullRequestPanelLabels(): string[] {
|
||||
@@ -255,14 +244,6 @@ 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,8 +1070,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "سلسلة المناقشة",
|
||||
resolved: "تم الحل",
|
||||
outdated: "قديم",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "غير قادر على تحميل حالة طلب السحب",
|
||||
|
||||
@@ -1078,8 +1078,6 @@ export const en = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "Discussion thread",
|
||||
resolved: "Resolved",
|
||||
outdated: "Outdated",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Unable to load pull request status",
|
||||
|
||||
@@ -1093,18 +1093,16 @@ export const es: TranslationResources = {
|
||||
open: "Abierto",
|
||||
},
|
||||
activity: {
|
||||
commented: "Comentó",
|
||||
approved: "Aprobó",
|
||||
requestedChanges: "Solicitó cambios",
|
||||
reviewed: "Revisó",
|
||||
commented: "Comentado",
|
||||
approved: "Aprobado",
|
||||
requestedChanges: "Cambios solicitados",
|
||||
reviewed: "Revisado",
|
||||
},
|
||||
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,8 +1094,6 @@ 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,8 +1083,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "ディスカッションスレッド",
|
||||
resolved: "解決済み",
|
||||
outdated: "古い",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "プルリクエストのステータスを読み込めません",
|
||||
|
||||
@@ -1077,8 +1077,6 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "토론 스레드",
|
||||
resolved: "해결됨",
|
||||
outdated: "오래됨",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "풀 리퀘스트 상태를 불러올 수 없습니다",
|
||||
|
||||
@@ -1084,17 +1084,15 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
activity: {
|
||||
commented: "Comentou",
|
||||
approved: "Aprovou",
|
||||
approved: "Aprovado",
|
||||
requestedChanges: "Solicitou alterações",
|
||||
reviewed: "Revisou",
|
||||
reviewed: "Revisado",
|
||||
},
|
||||
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,8 +1087,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "Ветка обсуждения",
|
||||
resolved: "Решено",
|
||||
outdated: "Устарело",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Не удалось загрузить статус PR",
|
||||
|
||||
@@ -1046,24 +1046,22 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
states: {
|
||||
draft: "草稿",
|
||||
merged: "已合并",
|
||||
draft: "Draft",
|
||||
merged: "已 merge",
|
||||
closed: "已关闭",
|
||||
open: "开放",
|
||||
open: "Open",
|
||||
},
|
||||
activity: {
|
||||
commented: "已评论",
|
||||
approved: "已批准",
|
||||
requestedChanges: "请求修改",
|
||||
reviewed: "已审查",
|
||||
reviewed: "已 review",
|
||||
},
|
||||
time: {
|
||||
justNow: "刚刚",
|
||||
},
|
||||
thread: {
|
||||
discussion: "讨论主题",
|
||||
resolved: "已解决",
|
||||
outdated: "已过时",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "无法加载 Pull Request 状态",
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
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";
|
||||
@@ -128,18 +125,7 @@ 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 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 sourceReference = formatPluginSourceReference(source, options.path);
|
||||
const data = await withPluginSourceClient(options.daemonTarget, (client) =>
|
||||
client.installPluginSource({
|
||||
source: sourceReference,
|
||||
|
||||
@@ -8,13 +8,11 @@ import {
|
||||
} from "../../../../scripts/test-support/npm-registry.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { 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";
|
||||
|
||||
@@ -47,81 +45,6 @@ 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");
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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,21 +39,13 @@ export const PublishedPluginSchema = z.object({
|
||||
repository: z.object({ url: z.string().url(), commit: z.string().optional() }),
|
||||
artifact: PluginRegistryArtifactSchema,
|
||||
icon: z.string().url().optional(),
|
||||
/** Images and videos in display order; see `pluginMediaKind`. */
|
||||
media: z.array(z.string().url()),
|
||||
screenshots: 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),
|
||||
@@ -62,8 +54,6 @@ 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,30 +309,18 @@ 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" };
|
||||
}
|
||||
if (await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId)) {
|
||||
archivedAtToRestore = record.archivedAt;
|
||||
}
|
||||
await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
await ensureAgentLoaded(params.agentId, {
|
||||
agentManager: params.agentManager,
|
||||
agentStorage: params.agentStorage,
|
||||
logger: params.logger,
|
||||
});
|
||||
|
||||
if (params.sessionMode) {
|
||||
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
|
||||
|
||||
@@ -76,8 +76,6 @@ import {
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptContentBlock,
|
||||
type AgentPromptInput,
|
||||
type AgentResumePurpose,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
@@ -150,18 +148,6 @@ 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);
|
||||
}
|
||||
@@ -511,7 +497,6 @@ interface ACPAgentSessionOptions {
|
||||
capabilities: AgentCapabilityFlags;
|
||||
extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
handle?: AgentPersistenceHandle;
|
||||
resumePurpose?: AgentResumePurpose;
|
||||
agentId?: string;
|
||||
launchEnv?: Record<string, string>;
|
||||
waitForInitialCommands?: boolean;
|
||||
@@ -1021,7 +1006,6 @@ 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`);
|
||||
@@ -1063,7 +1047,6 @@ export class ACPAgentClient implements AgentClient {
|
||||
providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers,
|
||||
},
|
||||
handle,
|
||||
resumePurpose: options?.purpose,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
extensionCommandsParser: this.extensionCommandsParser,
|
||||
@@ -1717,7 +1700,6 @@ 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;
|
||||
@@ -1771,7 +1753,6 @@ 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;
|
||||
@@ -2789,16 +2770,6 @@ 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,
|
||||
@@ -2812,7 +2783,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: await this.resolveProcessCwd(),
|
||||
cwd: this.config.cwd,
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [this.launchEnv],
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
// Reproduces #6199 on the logged-in Claude Code path. Claude Opus 5.5 writes the
|
||||
// text it means for the user between tool calls as a "progress update": a
|
||||
// thinking block that Claude Code marks with `narration_block_indexes`.
|
||||
const PROMPT =
|
||||
"There are three plan files in this directory: plan-a.txt, plan-b.txt and plan-c.txt. Read each one, tell me how they compare on cost, time and risk, and then ask me which plan to pick using your AskUserQuestion tool. Keep me posted as you go.";
|
||||
|
||||
const handles: Array<{ cwd: string; session: AgentSession }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const handle of handles.splice(0)) {
|
||||
await handle.session.close().catch(() => undefined);
|
||||
rmSync(handle.cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function writePlans(cwd: string): void {
|
||||
writeFileSync(
|
||||
path.join(cwd, "plan-a.txt"),
|
||||
"Plan A\nCost: 3 credits\nTime: 2 days\nRisk: needs a manual review step\n",
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(cwd, "plan-b.txt"),
|
||||
"Plan B\nCost: 5 credits\nTime: 1 day\nRisk: none known\n",
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(cwd, "plan-c.txt"),
|
||||
"Plan C\nCost: 2 credits\nTime: 4 days\nRisk: depends on an external vendor\n",
|
||||
);
|
||||
}
|
||||
|
||||
test("the comparison written before AskUserQuestion reaches the timeline as assistant text", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "claude-progress-updates-"));
|
||||
writePlans(cwd);
|
||||
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
model: "claude-opus-5-5",
|
||||
thinkingOptionId: "xhigh",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
handles.push({ cwd, session });
|
||||
|
||||
const items: AgentTimelineItem[] = [];
|
||||
await new Promise<void>((resolve) => {
|
||||
session.subscribe((event: AgentStreamEvent) => {
|
||||
if (event.type === "timeline") items.push(event.item);
|
||||
if (
|
||||
event.type === "permission_requested" ||
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed"
|
||||
) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
void session.startTurn(PROMPT);
|
||||
});
|
||||
|
||||
const lastReadIndex = items.findLastIndex(
|
||||
(item) => item.type === "tool_call" && item.name !== "AskUserQuestion",
|
||||
);
|
||||
const askIndex = items.findIndex(
|
||||
(item) => item.type === "tool_call" && item.name === "AskUserQuestion",
|
||||
);
|
||||
expect(askIndex).toBeGreaterThan(lastReadIndex);
|
||||
const between = items.slice(lastReadIndex + 1, askIndex);
|
||||
const assistantText = between
|
||||
.flatMap((item) => (item.type === "assistant_message" ? [item.text] : []))
|
||||
.join("");
|
||||
expect(assistantText).toMatch(/plan/i);
|
||||
}, 280_000);
|
||||
@@ -712,56 +712,4 @@ 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
@@ -1,56 +0,0 @@
|
||||
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,7 +44,6 @@ 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,
|
||||
@@ -2085,7 +2084,6 @@ 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;
|
||||
@@ -5186,7 +5184,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) || this.mapSubagentHandbackBlock(block, items)) {
|
||||
if (!isClaudeContentChunk(block)) {
|
||||
continue;
|
||||
}
|
||||
this.mapBlockToTimeline(block, {
|
||||
@@ -5274,24 +5272,6 @@ 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,64 +35,4 @@ 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,10 +6,6 @@ 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";
|
||||
|
||||
@@ -95,7 +91,7 @@ export class ClaudeSidechainTracker {
|
||||
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
|
||||
const actionCandidates = this.extractSubAgentActionCandidates(message);
|
||||
const childTimelineItems = [
|
||||
...this.extractSubAgentTimelineItems(message, state),
|
||||
...this.extractSubAgentTimelineItems(message),
|
||||
...this.extractSubAgentToolResults(message, state),
|
||||
];
|
||||
let actionUpdated = false;
|
||||
@@ -237,10 +233,7 @@ export class ClaudeSidechainTracker {
|
||||
this.activeSidechains.clear();
|
||||
}
|
||||
|
||||
private extractSubAgentTimelineItems(
|
||||
message: SDKMessage,
|
||||
state: SubAgentActivityState,
|
||||
): AgentTimelineItem[] {
|
||||
private extractSubAgentTimelineItems(message: SDKMessage): AgentTimelineItem[] {
|
||||
if (message.type !== "assistant" || !Array.isArray(message.message?.content)) {
|
||||
return [];
|
||||
}
|
||||
@@ -260,18 +253,6 @@ 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;
|
||||
@@ -410,12 +391,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* 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
@@ -1,154 +0,0 @@
|
||||
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,12 +205,19 @@ 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 }));
|
||||
await writeFile(
|
||||
path.join(home, "config.json"),
|
||||
JSON.stringify({ pluginRegistries, pluginRegistryEnabled: false }),
|
||||
);
|
||||
const config = loadConfig(home, {
|
||||
env: { PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal" },
|
||||
env: {
|
||||
PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal",
|
||||
PASEO_PLUGIN_REGISTRY_ENABLED: "false",
|
||||
},
|
||||
});
|
||||
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,29 +831,6 @@ 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
@@ -1,115 +0,0 @@
|
||||
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,6 +308,8 @@ 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: ".",
|
||||
},
|
||||
media: [],
|
||||
screenshots: [],
|
||||
submittedAt: "2026-10-03",
|
||||
reviewedAt: "2026-10-03",
|
||||
updatedAt: "2026-10-03",
|
||||
|
||||
@@ -433,7 +433,7 @@ describe("registry plugin sources", () => {
|
||||
commit,
|
||||
pluginPath: "packages/example",
|
||||
},
|
||||
media: [],
|
||||
screenshots: [],
|
||||
submittedAt: "2026-10-03",
|
||||
reviewedAt: "2026-10-03",
|
||||
updatedAt: "2026-10-03",
|
||||
@@ -516,10 +516,20 @@ 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,17 +1,9 @@
|
||||
import {
|
||||
PluginRegistryArtifactSchema,
|
||||
PluginRegistryIdSchema,
|
||||
PublishedPluginDetailSchema,
|
||||
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;
|
||||
@@ -57,7 +49,7 @@ export async function resolveRegistryPlugin(
|
||||
}
|
||||
if (!response.ok)
|
||||
throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`);
|
||||
const plugin = RegistryInstallDocumentSchema.parse(await response.json());
|
||||
const plugin = PublishedPluginDetailSchema.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 =
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"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,6 +1,4 @@
|
||||
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 { expect, test, type Page } from "playwright/test";
|
||||
import { CATEGORIES } from "../src/plugins/categories";
|
||||
|
||||
async function openPlugins(page: Page) {
|
||||
@@ -36,7 +34,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: "Behavior", exact: true }),
|
||||
page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Copy to clipboard" }).click();
|
||||
await expect
|
||||
@@ -80,7 +78,7 @@ test("filters by search and clears to all plugins", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await openPlugins(page);
|
||||
|
||||
await submitSearch(page, "graphite");
|
||||
await searchPlugins(page, "graphite");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/);
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }),
|
||||
@@ -98,7 +96,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 submitSearch(page, "graphite");
|
||||
await searchPlugins(page, "graphite");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/);
|
||||
await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
@@ -122,52 +120,13 @@ test("clears the search with the clear button", async ({ page }) => {
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
test("replaces history while typing a search", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.goto("/plugins/all");
|
||||
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 searchPlugins(page, "graphite");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/);
|
||||
await page.goBack();
|
||||
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");
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
});
|
||||
|
||||
test("keeps old category links working", async ({ page }) => {
|
||||
@@ -208,7 +167,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: "Behavior", exact: true }),
|
||||
page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }),
|
||||
).toBeVisible();
|
||||
await expectPageMetadata(
|
||||
page,
|
||||
@@ -301,19 +260,6 @@ 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(
|
||||
@@ -360,161 +306,6 @@ 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,
|
||||
}) => {
|
||||
@@ -529,124 +320,3 @@ 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,15 +1,6 @@
|
||||
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");
|
||||
@@ -17,9 +8,7 @@ createServer((request, response) => {
|
||||
response.end(JSON.stringify(registry));
|
||||
return;
|
||||
}
|
||||
const plugin = [securityPlugin, ...registry.plugins].find(
|
||||
(entry) => request.url === `/plugins/${entry.id}.json`,
|
||||
);
|
||||
const plugin = 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,12 +5,6 @@
|
||||
"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",
|
||||
@@ -80,12 +74,12 @@
|
||||
"name": "alhassanaraouf",
|
||||
"github": "alhassanaraouf"
|
||||
},
|
||||
"media": [
|
||||
"screenshots": [
|
||||
"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-24T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-24T17:12:05.467Z",
|
||||
"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."
|
||||
@@ -112,13 +106,13 @@
|
||||
"name": "Tom Lundy",
|
||||
"github": "dorasto"
|
||||
},
|
||||
"media": [
|
||||
"screenshots": [
|
||||
"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-22T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-22T10:39:28.935Z",
|
||||
"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"
|
||||
@@ -145,13 +139,13 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"media": [
|
||||
"screenshots": [
|
||||
"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-09-19T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-01T17:46:20.500Z",
|
||||
"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"
|
||||
@@ -178,13 +172,13 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"media": [
|
||||
"screenshots": [
|
||||
"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-19T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-27T19:03:31.943Z",
|
||||
"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"
|
||||
@@ -211,12 +205,12 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.mp4"
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/launchd-jobs/docs/screenshot.png"
|
||||
],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-19T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-22T17:56:41.061Z",
|
||||
"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"
|
||||
@@ -243,12 +237,12 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"media": [
|
||||
"screenshots": [
|
||||
"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-19T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-22T18:03:14.979Z",
|
||||
"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"
|
||||
@@ -275,10 +269,10 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"media": [],
|
||||
"screenshots": [],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-19T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-01T22:25:58.192Z",
|
||||
"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"
|
||||
@@ -304,13 +298,13 @@
|
||||
"name": "hungcuong9125",
|
||||
"github": "hungcuong9125"
|
||||
},
|
||||
"media": [
|
||||
"screenshots": [
|
||||
"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-22T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-30T02:57:56.473Z",
|
||||
"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."
|
||||
@@ -337,13 +331,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"media": [
|
||||
"screenshots": [
|
||||
"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-09-17T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:35.639Z",
|
||||
"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."
|
||||
@@ -370,13 +364,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"media": [
|
||||
"screenshots": [
|
||||
"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-09-17T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:37:29.149Z",
|
||||
"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."
|
||||
@@ -403,14 +397,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"media": [
|
||||
"screenshots": [
|
||||
"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-09-17T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:37:18.185Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo."
|
||||
@@ -437,13 +430,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"media": [
|
||||
"screenshots": [
|
||||
"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-09-18T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:06.720Z",
|
||||
"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"
|
||||
@@ -470,13 +463,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"media": [
|
||||
"screenshots": [
|
||||
"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-09-17T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:36.267Z",
|
||||
"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."
|
||||
@@ -503,10 +496,10 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"media": [],
|
||||
"screenshots": [],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:37:04.327Z",
|
||||
"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."
|
||||
@@ -533,13 +526,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"media": [
|
||||
"screenshots": [
|
||||
"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-09-18T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:38:48.289Z",
|
||||
"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."
|
||||
@@ -566,10 +559,10 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"media": [],
|
||||
"screenshots": [],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:15.719Z",
|
||||
"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."
|
||||
@@ -595,12 +588,12 @@
|
||||
"name": "Tom Gringauz",
|
||||
"github": "tomgrin10"
|
||||
},
|
||||
"media": [
|
||||
"screenshots": [
|
||||
"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-22T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-27T09:59:16.553Z",
|
||||
"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."
|
||||
@@ -626,10 +619,10 @@
|
||||
"name": "Tom Gringauz",
|
||||
"github": "tomgrin10"
|
||||
},
|
||||
"media": [],
|
||||
"screenshots": [],
|
||||
"submittedAt": "2026-09-22T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-22T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-23T10:36:50.112Z",
|
||||
"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,7 +38,6 @@
|
||||
"@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-20">
|
||||
<div className="mb-12">
|
||||
<SiteHeader />
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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,
|
||||
@@ -56,7 +55,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} live className="mb-4 lg:mb-6" />
|
||||
<PluginSearch scope={query} className="mb-4 lg:mb-6" />
|
||||
<CategoryNav plugins={matches} query={query} />
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -73,38 +72,36 @@ export function BrowsePage({
|
||||
</a>
|
||||
)}
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SiteShell>
|
||||
@@ -189,7 +186,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
|
||||
@@ -210,6 +207,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 text-center sm:px-8"
|
||||
className="mt-20 rounded-xl border border-white/10 bg-white/[0.03] px-6 py-8 sm:px-8"
|
||||
>
|
||||
<h2 id="build-your-own" className="text-lg font-medium">
|
||||
Build your own plugin
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">
|
||||
<p className="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 justify-center gap-3">
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={DOCS_URL} className={PRIMARY_BUTTON_CLASS}>
|
||||
Read the docs
|
||||
</a>
|
||||
|
||||
@@ -11,19 +11,14 @@ import {
|
||||
function registryBase(): string {
|
||||
return (env as RegistryEnvironment).PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins";
|
||||
}
|
||||
/** Listed plugins in index order, featured plugin IDs, install counts per window, and the server's clock. */
|
||||
/** Listed plugins in index order, their 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,
|
||||
featured: index.featured,
|
||||
installs,
|
||||
now: new Date().toISOString(),
|
||||
};
|
||||
return { plugins: index.plugins, installs, now: new Date().toISOString() };
|
||||
});
|
||||
export const getRegistryPlugin = createServerFn({ method: "GET" })
|
||||
.validator((id: string) => id)
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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]);
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
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,15 +1,14 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { pluginCardScreenshot } from "./thumbnails";
|
||||
import { pluginHref } from "./links";
|
||||
import { PluginTile } from "./plugin-tile";
|
||||
import { firstMediaImage, formatInstalls, getCategory, type Plugin } from "./registry";
|
||||
import { 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 allowed image, or the plugin tile on a quiet backdrop when there is none. */
|
||||
/** First screenshot, or the plugin tile on a quiet backdrop when there is none. */
|
||||
function PluginShot({ plugin }: { plugin: Plugin }) {
|
||||
const url = firstMediaImage(plugin);
|
||||
const url = plugin.screenshots[0];
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -17,10 +16,9 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
|
||||
>
|
||||
{url ? (
|
||||
<img
|
||||
{...pluginCardScreenshot(plugin.id, url)}
|
||||
src={url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover object-left-top"
|
||||
/>
|
||||
) : (
|
||||
@@ -100,9 +98,11 @@ export function PluginRankRow({
|
||||
return (
|
||||
<a
|
||||
href={pluginHref(plugin.id)}
|
||||
className="flex items-center gap-3 rounded-lg px-4 py-2.5 transition-colors hover:bg-white/[0.04]"
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-2.5 transition-colors hover:bg-white/[0.04]"
|
||||
>
|
||||
<span className="text-sm tabular-nums text-extra-muted-foreground">{rank}</span>
|
||||
<span className="w-4 text-right 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,67 +1,37 @@
|
||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { type BrowseQuery, browseHref, DEFAULT_WINDOW, parseSearchTerm } from "./links";
|
||||
import { type ChangeEvent, type FormEvent, useCallback, useRef, useState } from "react";
|
||||
import { type BrowseQuery, browseHref, DEFAULT_WINDOW } from "./links";
|
||||
|
||||
const ICON_CLASS = "h-3.5 w-3.5 text-extra-muted-foreground";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function PluginSearch({
|
||||
scope,
|
||||
live = false,
|
||||
className,
|
||||
}: {
|
||||
scope: BrowseQuery;
|
||||
live?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
export function PluginSearch({ scope, className }: { scope: BrowseQuery; className?: string }) {
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [term, setTerm] = useState(scope.q ?? "");
|
||||
// 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 });
|
||||
const search = useCallback(
|
||||
(next: string) => {
|
||||
setTerm(next);
|
||||
void navigate({
|
||||
href: browseHref({ ...scope, q: next.trim() ? next : undefined }),
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[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>) => edit(event.target.value),
|
||||
[edit],
|
||||
(event: ChangeEvent<HTMLInputElement>) => search(event.target.value),
|
||||
[search],
|
||||
);
|
||||
const handleClear = useCallback(() => {
|
||||
edit("");
|
||||
search("");
|
||||
input.current?.focus();
|
||||
}, [edit]);
|
||||
const handleSubmit = useCallback(
|
||||
(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
// Live results already show the term.
|
||||
if (!live) show(term, { replace: false });
|
||||
},
|
||||
[live, show, term],
|
||||
);
|
||||
}, [search]);
|
||||
const handleSubmit = useCallback((event: FormEvent) => event.preventDefault(), []);
|
||||
return (
|
||||
<form
|
||||
role="search"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||
import type { Plugin } from "./registry";
|
||||
|
||||
const SIZE_CLASS = {
|
||||
@@ -13,7 +12,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]}`}
|
||||
>
|
||||
{pluginOverviewUrl(plugin.icon) ? (
|
||||
{plugin.icon ? (
|
||||
<img src={plugin.icon} alt="" className="h-full w-full rounded-[inherit] object-cover" />
|
||||
) : (
|
||||
plugin.name.charAt(0).toUpperCase()
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 {
|
||||
@@ -64,10 +63,6 @@ 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,8 +2,6 @@ import { createHash } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addedAgo,
|
||||
featuredPlugins,
|
||||
formatInstalls,
|
||||
installCommand,
|
||||
mostInstalled,
|
||||
@@ -28,7 +26,7 @@ const plugin: Plugin = {
|
||||
resolved: "https://registry.npmjs.org/example.tgz",
|
||||
integrity: "sha512-YWJj",
|
||||
},
|
||||
media: [],
|
||||
screenshots: [],
|
||||
submittedAt: "2026-10-03",
|
||||
reviewedAt: "2026-10-03",
|
||||
updatedAt: "2026-10-03",
|
||||
@@ -42,7 +40,6 @@ 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" };
|
||||
@@ -63,46 +60,8 @@ describe("plugin registry", () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
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" };
|
||||
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" };
|
||||
const twin = { ...plugin, id: "acme/twin" };
|
||||
expect(newestFirst([older, plugin, twin]).map((p) => p.id)).toEqual([
|
||||
"acme/example",
|
||||
@@ -196,7 +155,6 @@ describe("plugin registry", () => {
|
||||
registry: { name: "Internal", url: base },
|
||||
categories: [],
|
||||
plugins: [plugin],
|
||||
featured: [],
|
||||
generatedAt: "2026-10-03",
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { z } from "zod";
|
||||
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||
import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry";
|
||||
import 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 };
|
||||
@@ -16,10 +15,6 @@ 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));
|
||||
}
|
||||
@@ -77,14 +72,9 @@ export function searchPlugins(plugins: Plugin[], term: string): Plugin[] {
|
||||
.includes(needle),
|
||||
);
|
||||
}
|
||||
/** 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. */
|
||||
/** Newest submissions first; equal dates keep index order. */
|
||||
export function newestFirst(plugins: Plugin[]): Plugin[] {
|
||||
return [...plugins].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
|
||||
return [...plugins].sort((a, b) => b.submittedAt.localeCompare(a.submittedAt));
|
||||
}
|
||||
/** Most installs in the window first; equal counts keep index order. */
|
||||
export function mostInstalled(
|
||||
@@ -95,9 +85,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 plugin was first listed, relative to the loader's clock. */
|
||||
/** How long ago the registry accepted the plugin, relative to the loader's clock. */
|
||||
export function addedAgo(plugin: Plugin, now: string): string {
|
||||
const days = Math.floor((Date.parse(now) - Date.parse(plugin.publishedAt)) / DAY_MS);
|
||||
const days = Math.floor((Date.parse(now) - Date.parse(plugin.submittedAt)) / DAY_MS);
|
||||
if (days <= 0) return "today";
|
||||
if (days === 1) return "yesterday";
|
||||
if (days < 14) return `${days}d ago`;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
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,4 +1,3 @@
|
||||
import { PluginContentLink } from "~/plugins/overview";
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
@@ -65,16 +64,21 @@ function AuthorPage() {
|
||||
</p>
|
||||
<div className="flex items-center gap-4 pt-1">
|
||||
{authorNpmUrl(author) && (
|
||||
<PluginContentLink href={authorNpmUrl(author)} className={LINK_CLASS}>
|
||||
<a
|
||||
href={authorNpmUrl(author)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
npm
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</PluginContentLink>
|
||||
</a>
|
||||
)}
|
||||
{github && (
|
||||
<PluginContentLink href={github} className={LINK_CLASS}>
|
||||
<a href={github} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||
GitHub
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</PluginContentLink>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { CodeBlock } from "~/components/code-block";
|
||||
import { PluginOverview, PluginContentLink } from "~/plugins/overview";
|
||||
import { DocsMarkdown } from "~/components/docs-markdown";
|
||||
import { SiteShell } from "~/components/site-shell";
|
||||
import { pageMeta } from "~/meta";
|
||||
import {
|
||||
firstMediaImage,
|
||||
formatInstalls,
|
||||
getAuthor,
|
||||
getCategory,
|
||||
@@ -18,7 +17,6 @@ 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";
|
||||
|
||||
@@ -36,7 +34,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 && firstMediaImage(loaderData.plugin),
|
||||
loaderData?.plugin.screenshots[0],
|
||||
),
|
||||
component: PluginPage,
|
||||
notFoundComponent: () => (
|
||||
@@ -77,22 +75,46 @@ function PluginPage() {
|
||||
)}
|
||||
<span className="tabular-nums">{formatInstalls(installs)} installs</span>
|
||||
<span className="font-mono">{pluginVersion(plugin)}</span>
|
||||
<PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}>
|
||||
<a
|
||||
href={plugin.repository.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={META_LINK_CLASS}
|
||||
>
|
||||
Source
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</PluginContentLink>
|
||||
</a>
|
||||
{npm && (
|
||||
<PluginContentLink href={npm} className={META_LINK_CLASS}>
|
||||
<a href={npm} target="_blank" rel="noopener noreferrer" className={META_LINK_CLASS}>
|
||||
npm
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</PluginContentLink>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MediaGallery name={plugin.name} media={plugin.media} />
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="mt-10 border-t border-white/10 pt-10">
|
||||
<PluginOverview>{readmeBody(plugin.readme)}</PluginOverview>
|
||||
<DocsMarkdown>{readmeBody(plugin.readme)}</DocsMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</SiteShell>
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
addedAgo,
|
||||
CATEGORIES,
|
||||
type CategorySlug,
|
||||
featuredPlugins,
|
||||
getCategory,
|
||||
getPluginsInCategory,
|
||||
getRegistry,
|
||||
@@ -41,7 +40,6 @@ 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";
|
||||
|
||||
@@ -61,9 +59,6 @@ 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";
|
||||
|
||||
@@ -96,9 +91,8 @@ export const Route = createFileRoute("/plugins/")({
|
||||
});
|
||||
|
||||
function PluginsPage() {
|
||||
const { plugins, featured: featuredIds, installs, now } = Route.useLoaderData();
|
||||
const { plugins, 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(
|
||||
@@ -128,100 +122,77 @@ function PluginsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })}
|
||||
className={SEE_ALL_CLASS}
|
||||
>
|
||||
See all plugins
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</PluginSectionHeader>
|
||||
<div className={CARD_ROW_CLASS}>
|
||||
{featured.map((plugin) => (
|
||||
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
|
||||
))}
|
||||
</div>
|
||||
</PluginSection>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<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" />
|
||||
<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 (
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<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" />
|
||||
</a>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<ContributeSection />
|
||||
</SiteShell>
|
||||
|
||||
@@ -2258,11 +2258,8 @@ 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.
|
||||
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.
|
||||
Absolute host paths are recommended because relative paths resolve against the daemon's working
|
||||
directory. The daemon expands `~` to its home directory.
|
||||
|
||||
| Source | Accepted form | Example |
|
||||
| -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- |
|
||||
|
||||
Reference in New Issue
Block a user