mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfddde086a | ||
|
|
f2e2d58ba7 | ||
|
|
2d85fe4e04 | ||
|
|
1463396ebc | ||
|
|
d4ea77b910 | ||
|
|
f715221de8 | ||
|
|
aedba5ec27 | ||
|
|
bdc58fb07c | ||
|
|
3f1a04304d | ||
|
|
50cf244e86 | ||
|
|
aaeefdc00b | ||
|
|
c5deaa0516 | ||
|
|
72c180aaf7 | ||
|
|
66b2567be3 | ||
|
|
cb2b569bbd | ||
|
|
2ecf3ba783 | ||
|
|
4515061f88 | ||
|
|
3c478e5967 | ||
|
|
05637a7f05 | ||
|
|
13d21f60f1 | ||
|
|
fc3b2c4eb7 | ||
|
|
a9fef4879a | ||
|
|
a292b402cf | ||
|
|
64c9f263e8 | ||
|
|
48059aeae3 | ||
|
|
7fd469ae19 | ||
|
|
56d5680742 | ||
|
|
06bde1e57a | ||
|
|
396a2bc0b2 |
@@ -27,6 +27,8 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Enabled the plugin registry by default: `paseo plugin add owner/slug` installs a reviewed artifact even when a local directory matches; use explicit local paths (including `.`, `..`, and Windows drive or UNC paths), and use `github:owner/repository` for direct GitHub installs. Registry connection, missing-ID, and `--ref` errors include recovery steps.
|
||||
|
||||
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975))
|
||||
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
|
||||
+17
-12
@@ -117,15 +117,19 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
|
||||
|
||||
## Install from a registry
|
||||
|
||||
Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
|
||||
`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
|
||||
`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
|
||||
|
||||
With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
|
||||
artifact, and GitHub shorthand requires `github:`. Registry installs
|
||||
`paseo plugin add owner/slug` installs the registry's reviewed artifact by default.
|
||||
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
|
||||
Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
|
||||
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
|
||||
selection is unavailable for registry installs; install an explicit source to select your own.
|
||||
|
||||
Registry overviews are untrusted author content, even before installation. Render them with
|
||||
`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative
|
||||
URLs, and isolated external links. The website adapter and corpus tests live in
|
||||
`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is
|
||||
`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share
|
||||
this policy. Installed-plugin descriptions in the app remain plain text.
|
||||
|
||||
Use `host/owner/slug` for a registry at `https://host`, or set
|
||||
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
|
||||
Private registry credentials live in daemon config under
|
||||
@@ -138,13 +142,13 @@ owns static hosting, record shapes, pins, and advisory install counts.
|
||||
|
||||
## Install a Git source
|
||||
|
||||
GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git
|
||||
URL. An existing directory still wins over source resolution.
|
||||
GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git
|
||||
URL.
|
||||
|
||||
```bash
|
||||
paseo plugin install owner/repository
|
||||
paseo plugin install git:owner/repository
|
||||
paseo plugin install https://gitlab.com/group/repository.git
|
||||
paseo plugin install owner/monorepo:plugins/review
|
||||
paseo plugin install git:owner/monorepo:plugins/review
|
||||
paseo plugin install github:owner/repository --ref main
|
||||
```
|
||||
|
||||
@@ -155,8 +159,9 @@ and ask for approval. `ls` reports the installed commit without contacting the r
|
||||
## Managed source ownership
|
||||
|
||||
The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier
|
||||
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged
|
||||
through `installPluginSource`; only the daemon resolves host paths and acquires sources.
|
||||
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. The CLI resolves directory sources before
|
||||
calling `installPluginSource`; see the public source reference for CLI and app path semantics.
|
||||
Only the daemon reads plugin files and acquires sources.
|
||||
|
||||
`ManagedPluginSources` owns acquisition and offline source description. Config stores the active
|
||||
directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE=
|
||||
sha256-zL3+gbScK8eMkjQUtKDX4UJTXt4BMeqMSyeWB017MA4=
|
||||
|
||||
Generated
+1
@@ -40174,6 +40174,7 @@
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"parse5": "^7.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
@@ -557,7 +558,7 @@ async function retryGitInstallAndInspectRows(
|
||||
|
||||
await installPlugin(page, missingSource);
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
|
||||
"Plugin source is neither an existing directory nor a Git URL",
|
||||
`Plugin directory does not exist: ${missingSource}`,
|
||||
);
|
||||
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
|
||||
await capturePluginInstallForm(page, testInfo, "compact-error");
|
||||
@@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) {
|
||||
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
|
||||
const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n");
|
||||
await installPluginWithUntrustedDescription(pluginEnvironment, description);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
const text = page.getByText(description, { exact: true });
|
||||
await expect(text).toBeVisible();
|
||||
// This field must remain a text node, with no Markdown links, images, or HTML children.
|
||||
expect(await text.evaluate((element) => element.childElementCount)).toBe(0);
|
||||
});
|
||||
|
||||
async function installPluginWithUntrustedDescription(
|
||||
environment: PluginEnvironment,
|
||||
description: string,
|
||||
) {
|
||||
const directory = await createDirectoryPlugin(
|
||||
environment.directory,
|
||||
"plain-text-plugin",
|
||||
description,
|
||||
"Plain text plugin",
|
||||
);
|
||||
await environment.client.installPluginSource({ source: directory });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getStateLabel } from "@/git/pull-request-panel/data";
|
||||
import { en } from "@/i18n/resources/en";
|
||||
import { openPullRequestPanel } from "./workspace-tabs";
|
||||
|
||||
export async function openPrPane(page: Page): Promise<void> {
|
||||
@@ -14,7 +14,7 @@ export async function expectPrPaneState(
|
||||
page: Page,
|
||||
state: "open" | "merged" | "closed" | "draft",
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
|
||||
await expect(page.getByTestId("pr-pane-state")).toHaveText(en.workspace.git.pr.states[state], {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -244,13 +244,16 @@ const FOOTER_GEOMETRY_TOLERANCE = 0.01;
|
||||
* Settings together at the end.
|
||||
*/
|
||||
export async function expectFooterIconRow(page: Page): Promise<void> {
|
||||
const boxes = (
|
||||
await Promise.all(
|
||||
FOOTER_ICON_TEST_IDS.map((testID) =>
|
||||
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
|
||||
),
|
||||
)
|
||||
).map((box) => box!);
|
||||
// Read one browser-frame snapshot: the compact drawer can translate between separate RPCs.
|
||||
const boxes = await page
|
||||
.locator(FOOTER_ICON_TEST_IDS.map((testID) => `[data-testid="${testID}"]:visible`).join(","))
|
||||
.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const { x, y, width, height } = element.getBoundingClientRect();
|
||||
return { testID: element.getAttribute("data-testid"), x, y, width, height };
|
||||
}),
|
||||
);
|
||||
expect(boxes.map((box) => box.testID)).toEqual(FOOTER_ICON_TEST_IDS);
|
||||
const [first] = boxes;
|
||||
for (const box of boxes) {
|
||||
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
isPipelineActiveStatus,
|
||||
mapPipelineStatus,
|
||||
} from "@/git/forges/gitlab";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors";
|
||||
import {
|
||||
deriveAvatarColor,
|
||||
formatAge,
|
||||
getActivityVerb,
|
||||
getStateLabel,
|
||||
getActivityVerbKey,
|
||||
getStateLabelKey,
|
||||
mapPrPaneData,
|
||||
} from "./data";
|
||||
|
||||
@@ -779,37 +780,34 @@ describe("formatAge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStateLabel", () => {
|
||||
const english = i18n.getFixedT("en");
|
||||
const french = i18n.getFixedT("fr");
|
||||
|
||||
describe("getStateLabelKey", () => {
|
||||
it.each([
|
||||
["open", "Open"],
|
||||
["draft", "Draft"],
|
||||
["merged", "Merged"],
|
||||
["closed", "Closed"],
|
||||
] as const)("maps %s → %s", (state, expected) => {
|
||||
expect(getStateLabel(state)).toBe(expected);
|
||||
["open", "Open", "Ouverte"],
|
||||
["draft", "Draft", "Brouillon"],
|
||||
["merged", "Merged", "Fusionnée"],
|
||||
["closed", "Closed", "Fermée"],
|
||||
] as const)("labels %s as %s, and %s in French", (state, englishLabel, frenchLabel) => {
|
||||
expect(english(getStateLabelKey(state))).toBe(englishLabel);
|
||||
expect(french(getStateLabelKey(state))).toBe(frenchLabel);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActivityVerb", () => {
|
||||
it("returns Commented for comment kind", () => {
|
||||
expect(getActivityVerb({ kind: "comment" })).toBe("Commented");
|
||||
});
|
||||
|
||||
it("returns Approved for approved review", () => {
|
||||
expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved");
|
||||
});
|
||||
|
||||
it("returns Requested changes for changes_requested review", () => {
|
||||
expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe(
|
||||
describe("getActivityVerbKey", () => {
|
||||
it.each([
|
||||
[{ kind: "comment" }, "Commented", "A commenté"],
|
||||
[{ kind: "review", reviewState: "approved" }, "Approved", "A approuvé"],
|
||||
[
|
||||
{ kind: "review", reviewState: "changes_requested" },
|
||||
"Requested changes",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns Reviewed for a commented review with body (generic case)", () => {
|
||||
expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed");
|
||||
});
|
||||
|
||||
it("returns Reviewed when reviewState is undefined", () => {
|
||||
expect(getActivityVerb({ kind: "review" })).toBe("Reviewed");
|
||||
"A demandé des modifications",
|
||||
],
|
||||
[{ kind: "review", reviewState: "commented" }, "Reviewed", "A relu"],
|
||||
[{ kind: "review" }, "Reviewed", "A relu"],
|
||||
] as const)("labels %o as %s, and %s in French", (item, englishVerb, frenchVerb) => {
|
||||
expect(english(getActivityVerbKey(item))).toBe(englishVerb);
|
||||
expect(french(getActivityVerbKey(item))).toBe(frenchVerb);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -318,16 +318,18 @@ function parsePullRequestNumber(url: string): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function getStateLabel(state: PrState): string {
|
||||
if (state === "draft") return "Draft";
|
||||
if (state === "merged") return "Merged";
|
||||
if (state === "closed") return "Closed";
|
||||
return "Open";
|
||||
export function getStateLabelKey(state: PrState) {
|
||||
if (state === "draft") return "workspace.git.pr.states.draft";
|
||||
if (state === "merged") return "workspace.git.pr.states.merged";
|
||||
if (state === "closed") return "workspace.git.pr.states.closed";
|
||||
return "workspace.git.pr.states.open";
|
||||
}
|
||||
|
||||
export function getActivityVerb(item: Pick<PrPaneActivity, "kind" | "reviewState">): string {
|
||||
if (item.kind === "comment") return "Commented";
|
||||
if (item.reviewState === "approved") return "Approved";
|
||||
if (item.reviewState === "changes_requested") return "Requested changes";
|
||||
return "Reviewed";
|
||||
export function getActivityVerbKey(item: Pick<PrPaneActivity, "kind" | "reviewState">) {
|
||||
if (item.kind === "comment") return "workspace.git.pr.activity.commented";
|
||||
if (item.reviewState === "approved") return "workspace.git.pr.activity.approved";
|
||||
if (item.reviewState === "changes_requested") {
|
||||
return "workspace.git.pr.activity.requestedChanges";
|
||||
}
|
||||
return "workspace.git.pr.activity.reviewed";
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
canAddPullRequestActivityToChat,
|
||||
} from "./context-attachment";
|
||||
import { ChecksSection, getCheckIdentity } from "./checks-section";
|
||||
import { getActivityVerb, getStateLabel } from "./data";
|
||||
import { getActivityVerbKey, getStateLabelKey } from "./data";
|
||||
import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
|
||||
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
|
||||
import { CheckPresentationIcon } from "@/git/check-presentation.view";
|
||||
@@ -538,7 +538,7 @@ export function PullRequestPane({
|
||||
<View style={styles.metaLine}>
|
||||
<StateIcon size={14} uniProps={statePresentation.iconColor} />
|
||||
<Text style={stateLabelStyle(data.state)} testID="pr-pane-state">
|
||||
{getStateLabel(data.state)}
|
||||
{t(getStateLabelKey(data.state))}
|
||||
</Text>
|
||||
{nativeHeaderMeta}
|
||||
{repoIdentity ? (
|
||||
@@ -760,7 +760,8 @@ function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: nu
|
||||
}
|
||||
|
||||
function ActivityVerb({ activity }: { activity: PrPaneActivity }) {
|
||||
const verb = getActivityVerb(activity).toLowerCase();
|
||||
const { t } = useTranslation();
|
||||
const verb = t(getActivityVerbKey(activity)).toLowerCase();
|
||||
if (activity.kind === "review" && activity.reviewState === "approved") {
|
||||
return (
|
||||
<View style={styles.verbGroup}>
|
||||
@@ -1065,8 +1066,12 @@ function ThreadBlock({
|
||||
? formatPullRequestThreadPath(thread.location)
|
||||
: t("workspace.git.pr.thread.discussion")}
|
||||
</Text>
|
||||
{thread.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
|
||||
{thread.location?.isOutdated ? <StatusBadge label="Outdated" /> : null}
|
||||
{thread.isResolved ? (
|
||||
<StatusBadge label={t("workspace.git.pr.thread.resolved")} variant="success" />
|
||||
) : null}
|
||||
{thread.location?.isOutdated ? (
|
||||
<StatusBadge label={t("workspace.git.pr.thread.outdated")} />
|
||||
) : null}
|
||||
<View style={styles.headerTrailing}>
|
||||
{collapsed ? (
|
||||
<View style={styles.threadCount}>
|
||||
|
||||
@@ -76,6 +76,7 @@ const untranslatedLocalFallbacks = [
|
||||
|
||||
const pullRequestPanelSources = [
|
||||
"git/pull-request-panel/pane.tsx",
|
||||
"git/pull-request-panel/data.ts",
|
||||
"git/pull-request-panel/checks-section.tsx",
|
||||
"components/sidebar/sidebar-status-list.tsx",
|
||||
] as const;
|
||||
@@ -87,6 +88,16 @@ const untranslatedPullRequestPanelLabels = [
|
||||
"Adding...",
|
||||
"Comment actions",
|
||||
"Thread actions",
|
||||
"Commented",
|
||||
"Approved",
|
||||
"Requested changes",
|
||||
"Reviewed",
|
||||
"Draft",
|
||||
"Merged",
|
||||
"Closed",
|
||||
"Open",
|
||||
"Resolved",
|
||||
"Outdated",
|
||||
] as const;
|
||||
|
||||
function findUntranslatedPullRequestPanelLabels(): string[] {
|
||||
@@ -244,6 +255,14 @@ describe("translation resources", () => {
|
||||
expect(pr.actions.addingToChat).not.toBe(englishPr.actions.addingToChat);
|
||||
expect(pr.accessibility.commentActions).not.toBe(englishPr.accessibility.commentActions);
|
||||
expect(pr.accessibility.threadActions).not.toBe(englishPr.accessibility.threadActions);
|
||||
for (const verb of Object.keys(englishPr.activity) as (keyof typeof englishPr.activity)[]) {
|
||||
expect(pr.activity[verb]).not.toBe(englishPr.activity[verb]);
|
||||
}
|
||||
for (const state of Object.keys(englishPr.states) as (keyof typeof englishPr.states)[]) {
|
||||
expect(pr.states[state]).not.toBe(englishPr.states[state]);
|
||||
}
|
||||
expect(pr.thread.resolved).not.toBe(englishPr.thread.resolved);
|
||||
expect(pr.thread.outdated).not.toBe(englishPr.thread.outdated);
|
||||
expect(resource.sidebar.statusGroupAccessibility).not.toBe(
|
||||
en.sidebar.statusGroupAccessibility,
|
||||
);
|
||||
|
||||
@@ -1070,6 +1070,8 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "سلسلة المناقشة",
|
||||
resolved: "تم الحل",
|
||||
outdated: "قديم",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "غير قادر على تحميل حالة طلب السحب",
|
||||
|
||||
@@ -1078,6 +1078,8 @@ export const en = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "Discussion thread",
|
||||
resolved: "Resolved",
|
||||
outdated: "Outdated",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Unable to load pull request status",
|
||||
@@ -2160,7 +2162,7 @@ export const en = {
|
||||
globalTitle: "Enable plugins",
|
||||
globalHint: "Global switch for every configured plugin",
|
||||
sourceLabel: "Plugin source",
|
||||
sourcePlaceholder: "Directory, Git URL, or npm package",
|
||||
sourcePlaceholder: "owner/slug, directory, Git URL, or npm package",
|
||||
docs: "Docs",
|
||||
install: "Install plugin",
|
||||
installing: "Installing…",
|
||||
|
||||
@@ -1093,16 +1093,18 @@ export const es: TranslationResources = {
|
||||
open: "Abierto",
|
||||
},
|
||||
activity: {
|
||||
commented: "Comentado",
|
||||
approved: "Aprobado",
|
||||
requestedChanges: "Cambios solicitados",
|
||||
reviewed: "Revisado",
|
||||
commented: "Comentó",
|
||||
approved: "Aprobó",
|
||||
requestedChanges: "Solicitó cambios",
|
||||
reviewed: "Revisó",
|
||||
},
|
||||
time: {
|
||||
justNow: "En este momento",
|
||||
},
|
||||
thread: {
|
||||
discussion: "Hilo de discusión",
|
||||
resolved: "Resuelto",
|
||||
outdated: "Desactualizado",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción",
|
||||
|
||||
@@ -1094,6 +1094,8 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "Fil de discussion",
|
||||
resolved: "Résolu",
|
||||
outdated: "Obsolète",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Impossible de charger le statut de la pull request",
|
||||
|
||||
@@ -1083,6 +1083,8 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "ディスカッションスレッド",
|
||||
resolved: "解決済み",
|
||||
outdated: "古い",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "プルリクエストのステータスを読み込めません",
|
||||
|
||||
@@ -1077,6 +1077,8 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "토론 스레드",
|
||||
resolved: "해결됨",
|
||||
outdated: "오래됨",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "풀 리퀘스트 상태를 불러올 수 없습니다",
|
||||
|
||||
@@ -27,7 +27,7 @@ export const pluginSettings = {
|
||||
globalTitle: "تفعيل الإضافات",
|
||||
globalHint: "مفتاح عام لكل الإضافات المضبوطة",
|
||||
sourceLabel: "مصدر الإضافة",
|
||||
sourcePlaceholder: "مجلد أو رابط Git أو حزمة npm",
|
||||
sourcePlaceholder: "owner/slug, مجلد أو رابط Git أو حزمة npm",
|
||||
docs: "الوثائق",
|
||||
install: "تثبيت الإضافة",
|
||||
installing: "جارٍ التثبيت…",
|
||||
@@ -82,7 +82,7 @@ export const pluginSettings = {
|
||||
globalTitle: "Activar plugins",
|
||||
globalHint: "Interruptor global para todos los plugins configurados",
|
||||
sourceLabel: "Fuente del plugin",
|
||||
sourcePlaceholder: "Directorio, URL de Git o paquete npm",
|
||||
sourcePlaceholder: "owner/slug, Directorio, URL de Git o paquete npm",
|
||||
docs: "Documentación",
|
||||
install: "Instalar plugin",
|
||||
installing: "Instalando…",
|
||||
@@ -137,7 +137,7 @@ export const pluginSettings = {
|
||||
globalTitle: "Activer les plugins",
|
||||
globalHint: "Interrupteur global pour tous les plugins configurés",
|
||||
sourceLabel: "Source du plugin",
|
||||
sourcePlaceholder: "Dossier, URL Git ou paquet npm",
|
||||
sourcePlaceholder: "owner/slug, Dossier, URL Git ou paquet npm",
|
||||
docs: "Documentation",
|
||||
install: "Installer le plugin",
|
||||
installing: "Installation…",
|
||||
@@ -192,7 +192,7 @@ export const pluginSettings = {
|
||||
globalTitle: "プラグインを有効化",
|
||||
globalHint: "設定済みプラグインすべての共通スイッチ",
|
||||
sourceLabel: "プラグインソース",
|
||||
sourcePlaceholder: "ディレクトリ、Git URL、npm パッケージ",
|
||||
sourcePlaceholder: "owner/slug, ディレクトリ、Git URL、npm パッケージ",
|
||||
docs: "ドキュメント",
|
||||
install: "プラグインをインストール",
|
||||
installing: "インストール中…",
|
||||
@@ -246,7 +246,7 @@ export const pluginSettings = {
|
||||
globalTitle: "플러그인 활성화",
|
||||
globalHint: "설정된 모든 플러그인의 전역 스위치",
|
||||
sourceLabel: "플러그인 소스",
|
||||
sourcePlaceholder: "디렉터리, Git URL 또는 npm 패키지",
|
||||
sourcePlaceholder: "owner/slug, 디렉터리, Git URL 또는 npm 패키지",
|
||||
docs: "문서",
|
||||
install: "플러그인 설치",
|
||||
installing: "설치 중…",
|
||||
@@ -301,7 +301,7 @@ export const pluginSettings = {
|
||||
globalTitle: "Ativar plugins",
|
||||
globalHint: "Controle global para todos os plugins configurados",
|
||||
sourceLabel: "Fonte do plugin",
|
||||
sourcePlaceholder: "Diretório, URL Git ou pacote npm",
|
||||
sourcePlaceholder: "owner/slug, Diretório, URL Git ou pacote npm",
|
||||
docs: "Documentação",
|
||||
install: "Instalar plugin",
|
||||
installing: "Instalando…",
|
||||
@@ -356,7 +356,7 @@ export const pluginSettings = {
|
||||
globalTitle: "Включить плагины",
|
||||
globalHint: "Общий переключатель для всех настроенных плагинов",
|
||||
sourceLabel: "Источник плагина",
|
||||
sourcePlaceholder: "Каталог, URL Git или пакет npm",
|
||||
sourcePlaceholder: "owner/slug, Каталог, URL Git или пакет npm",
|
||||
docs: "Документация",
|
||||
install: "Установить плагин",
|
||||
installing: "Установка…",
|
||||
@@ -410,7 +410,7 @@ export const pluginSettings = {
|
||||
globalTitle: "启用插件",
|
||||
globalHint: "所有已配置插件的全局开关",
|
||||
sourceLabel: "插件来源",
|
||||
sourcePlaceholder: "目录、Git URL 或 npm 包",
|
||||
sourcePlaceholder: "owner/slug, 目录、Git URL 或 npm 包",
|
||||
docs: "文档",
|
||||
install: "安装插件",
|
||||
installing: "正在安装…",
|
||||
|
||||
@@ -1084,15 +1084,17 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
activity: {
|
||||
commented: "Comentou",
|
||||
approved: "Aprovado",
|
||||
approved: "Aprovou",
|
||||
requestedChanges: "Solicitou alterações",
|
||||
reviewed: "Revisado",
|
||||
reviewed: "Revisou",
|
||||
},
|
||||
time: {
|
||||
justNow: "agora mesmo",
|
||||
},
|
||||
thread: {
|
||||
discussion: "Tópico de discussão",
|
||||
resolved: "Resolvido",
|
||||
outdated: "Desatualizado",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Não foi possível carregar o status da pull request",
|
||||
|
||||
@@ -1087,6 +1087,8 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "Ветка обсуждения",
|
||||
resolved: "Решено",
|
||||
outdated: "Устарело",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Не удалось загрузить статус PR",
|
||||
|
||||
@@ -1046,22 +1046,24 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
states: {
|
||||
draft: "Draft",
|
||||
merged: "已 merge",
|
||||
draft: "草稿",
|
||||
merged: "已合并",
|
||||
closed: "已关闭",
|
||||
open: "Open",
|
||||
open: "开放",
|
||||
},
|
||||
activity: {
|
||||
commented: "已评论",
|
||||
approved: "已批准",
|
||||
requestedChanges: "请求修改",
|
||||
reviewed: "已 review",
|
||||
reviewed: "已审查",
|
||||
},
|
||||
time: {
|
||||
justNow: "刚刚",
|
||||
},
|
||||
thread: {
|
||||
discussion: "讨论主题",
|
||||
resolved: "已解决",
|
||||
outdated: "已过时",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "无法加载 Pull Request 状态",
|
||||
|
||||
@@ -358,14 +358,21 @@ test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])(
|
||||
stdio: "ignore",
|
||||
});
|
||||
const exited = new Promise((resolve) => child!.once("exit", resolve));
|
||||
// The lock file exists before its contents identify the supervisor.
|
||||
let supervisorPid: number | undefined;
|
||||
await expect
|
||||
.poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 })
|
||||
.toBe(true);
|
||||
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
|
||||
.poll(
|
||||
async () => {
|
||||
supervisorPid = (await readDaemonInstance(home))?.pid;
|
||||
return supervisorPid;
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBeTypeOf("number");
|
||||
await f.ok(["status", "--home", home]);
|
||||
child.kill("SIGINT");
|
||||
await exited;
|
||||
expect(() => process.kill(lock.pid, 0)).toThrow();
|
||||
expect(() => process.kill(supervisorPid!, 0)).toThrow();
|
||||
expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped");
|
||||
} finally {
|
||||
child?.kill("SIGTERM");
|
||||
|
||||
@@ -58,7 +58,12 @@ async function probeDaemonStatus(
|
||||
let live: Record<string, unknown> = {};
|
||||
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
|
||||
try {
|
||||
client = await connectToDaemon({ target, instance: instance ?? undefined, timeout: 1_500 });
|
||||
// An explicit host may sit behind SSH or a relay, so it gets the usual connect budget.
|
||||
client = await connectToDaemon({
|
||||
target,
|
||||
instance: instance ?? undefined,
|
||||
timeout: target.kind === "endpoint" ? undefined : 1_500,
|
||||
});
|
||||
} catch (error) {
|
||||
const failure = buildDaemonConnectionCommandError({ target, error });
|
||||
if (target.kind === "endpoint") throw failure;
|
||||
|
||||
@@ -255,12 +255,12 @@ describe("plugin management commands", () => {
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
const command = createPluginCommand();
|
||||
|
||||
await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], {
|
||||
await command.parseAsync(["install", "git:owner/monorepo", "--path", "plugins/review"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(installPluginSource).toHaveBeenCalledWith({
|
||||
source: "owner/monorepo:plugins/review",
|
||||
source: "git:owner/monorepo:plugins/review",
|
||||
});
|
||||
stderr.mockRestore();
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import path from "node:path";
|
||||
import { expandUserPath } from "../../classify.js";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { reviewPluginUpdates, type UpdateOutcome } from "./update.js";
|
||||
import { Command } from "commander";
|
||||
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
formatPluginSourceReference,
|
||||
parsePluginSourceReference,
|
||||
formatPluginIdentity,
|
||||
} from "@getpaseo/protocol/plugin-source-reference";
|
||||
import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
@@ -125,7 +128,18 @@ export async function runPluginInstallCommand(
|
||||
process.stderr.write(
|
||||
"Trusting plugin code: server code and preparation commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n",
|
||||
);
|
||||
const sourceReference = formatPluginSourceReference(source, options.path);
|
||||
const reference = parsePluginSourceReference(source);
|
||||
let resolvedSource = source;
|
||||
if (reference.kind === "directory") {
|
||||
const directory = expandUserPath(reference.source);
|
||||
// An absolute path can target a daemon on a different operating system.
|
||||
const absoluteDirectory =
|
||||
path.posix.isAbsolute(directory) || path.win32.isAbsolute(directory)
|
||||
? directory
|
||||
: path.resolve(directory);
|
||||
resolvedSource = formatPluginSourceReference(absoluteDirectory, reference.pluginPath);
|
||||
}
|
||||
const sourceReference = formatPluginSourceReference(resolvedSource, options.path);
|
||||
const data = await withPluginSourceClient(options.daemonTarget, (client) =>
|
||||
client.installPluginSource({
|
||||
source: sourceReference,
|
||||
@@ -215,10 +229,12 @@ export function createPluginCommand(): Command {
|
||||
plugin
|
||||
.command("install")
|
||||
.alias("add")
|
||||
.description("Trust and install a plugin from a directory, Git repository, or npm package")
|
||||
.description(
|
||||
"Trust and install a plugin from a registry, directory, Git repository, or npm package",
|
||||
)
|
||||
.argument(
|
||||
"<source>",
|
||||
"Host directory, GitHub owner/repo, git: or npm: source, optionally followed by :plugin/path",
|
||||
"Registry owner/slug or host/owner/slug, host directory, explicit git:owner/repo or Git URL, or npm: source",
|
||||
)
|
||||
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
|
||||
.option("--ref <ref>", "Git branch, tag, or commit")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
@@ -9,6 +9,7 @@ import { scaffoldPluginDirectory } from "./scaffold.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
const execFileAsync = promisify(execFile);
|
||||
const execAsync = promisify(exec);
|
||||
const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc");
|
||||
|
||||
async function typecheckPlugin(directory: string): Promise<void> {
|
||||
@@ -28,6 +29,47 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("plugin scaffold", () => {
|
||||
it("creates an author overview beside the manifest", async () => {
|
||||
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
|
||||
directories.push(parent);
|
||||
const directory = path.join(parent, "hello-plugin");
|
||||
await scaffoldPluginDirectory(directory);
|
||||
|
||||
const overview = await readFile(path.join(directory, "OVERVIEW.md"), "utf8");
|
||||
const comments = overview.match(/<!--[\s\S]*?-->/g) ?? [];
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(overview.replace(/<!--[\s\S]*?-->/g, "").trim()).toBe("");
|
||||
expect(overview).toContain("required to list");
|
||||
expect(overview).toContain("README");
|
||||
expect(overview).toContain("choosing a plugin in Paseo");
|
||||
expect(overview).toContain("Length follows complexity");
|
||||
expect(overview).toContain("1. Describe what your plugin is and does");
|
||||
expect(overview).toContain("2. Explain how it works only when it is not obvious");
|
||||
expect(overview).toContain("3. Explain setup when needed");
|
||||
expect(overview).toContain("4. Explain capabilities and settings");
|
||||
expect(overview).toContain("permissions");
|
||||
expect(overview).toContain("known limits");
|
||||
expect(overview).not.toContain("This plugin entry was imported");
|
||||
const packageJson = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
|
||||
expect(packageJson.files).toContain("OVERVIEW.md");
|
||||
expect(await readdir(directory)).toContain("paseo-plugin.json");
|
||||
});
|
||||
|
||||
it("includes the author overview in the packed npm artifact", async () => {
|
||||
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
|
||||
directories.push(parent);
|
||||
const directory = path.join(parent, "packed-plugin");
|
||||
await scaffoldPluginDirectory(directory);
|
||||
|
||||
const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", {
|
||||
cwd: directory,
|
||||
});
|
||||
const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[];
|
||||
expect(artifact.files.map((file) => file.path)).toEqual(
|
||||
expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]),
|
||||
);
|
||||
});
|
||||
|
||||
it(
|
||||
"creates a standalone split-runtime project that typechecks",
|
||||
{ timeout: 15_000 },
|
||||
@@ -51,6 +93,8 @@ describe("plugin scaffold", () => {
|
||||
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual(
|
||||
{
|
||||
id: "hello-plugin",
|
||||
$comment: expect.stringContaining('"icon": "assets/icon.png"'),
|
||||
media: [],
|
||||
requirements: { paseo: `>=${cliPackageJson.version}` },
|
||||
},
|
||||
);
|
||||
@@ -60,6 +104,7 @@ describe("plugin scaffold", () => {
|
||||
version: "0.0.0",
|
||||
files: [
|
||||
"paseo-plugin.json",
|
||||
"OVERVIEW.md",
|
||||
"index.client.ts",
|
||||
"index.client.tsx",
|
||||
"index.server.ts",
|
||||
|
||||
@@ -3,6 +3,34 @@ import path from "node:path";
|
||||
import { PluginIdSchema } from "@getpaseo/protocol/messages";
|
||||
import { resolveCliVersion } from "../../version.js";
|
||||
|
||||
const OVERVIEW = `<!--
|
||||
OVERVIEW.md is required to list your plugin in the registry. Keep it beside
|
||||
paseo-plugin.json in your repository at the pinned commit and in your npm package.
|
||||
Replace this comment with useful facts before publishing.
|
||||
|
||||
This overview is for people choosing a plugin in Paseo, on the plugin page under
|
||||
its install command. README assumes a GitHub audience and includes technical
|
||||
details, installation instructions, and badges. Long, AI-generated READMEs leave
|
||||
people reading past that material to understand what the plugin does.
|
||||
|
||||
Write these parts in order, including only what helps someone decide to install:
|
||||
|
||||
1. Describe what your plugin is and does in plain terms first.
|
||||
2. Explain how it works only when it is not obvious.
|
||||
3. Explain setup when needed: settings, accounts, tokens, providers, tools, or
|
||||
other plugins. Include applicable daemon version and operating system
|
||||
requirements. Setup guidance is allowed; installation instructions are not.
|
||||
4. Explain capabilities and settings worth understanding, what each option does,
|
||||
what the plugin reads or sends and where, permissions, and known limits.
|
||||
|
||||
Length follows complexity; a theme needs one paragraph. Choose headings only
|
||||
when they help. Use sentence case and plain factual language, with no em dashes.
|
||||
Omit installation commands, badges, changelog, contributing or license sections,
|
||||
marketing, and unsupported claims. Avoid implementation filler such as empty
|
||||
cleanup functions, theme-token field lists, or lists of absent features.
|
||||
-->
|
||||
`;
|
||||
|
||||
const TSCONFIG = {
|
||||
compilerOptions: {
|
||||
target: "ES2020",
|
||||
@@ -155,6 +183,7 @@ export async function scaffoldPluginDirectory(
|
||||
version: "0.0.0",
|
||||
files: [
|
||||
"paseo-plugin.json",
|
||||
"OVERVIEW.md",
|
||||
"index.client.ts",
|
||||
"index.client.tsx",
|
||||
"index.server.ts",
|
||||
@@ -177,8 +206,19 @@ export async function scaffoldPluginDirectory(
|
||||
const files = new Map<string, string>([
|
||||
[
|
||||
"paseo-plugin.json",
|
||||
`${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id,
|
||||
$comment:
|
||||
'Add "name": "My plugin" to set a display name. Add "icon": "assets/icon.png" for a PNG inside the package. Add image or video paths (relative to this manifest) or HTTPS URLs to media. Include local assets in package.json files.',
|
||||
media: [],
|
||||
requirements: { paseo: `>=${version}` },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
],
|
||||
["OVERVIEW.md", OVERVIEW],
|
||||
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
||||
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
|
||||
["index.client.tsx", CLIENT_ENTRY],
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
import { startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Daemon Status over SSH ===\n");
|
||||
|
||||
// The stub stands in for OpenSSH; Windows cannot run it as `ssh` from PATH.
|
||||
if (process.platform === "win32") {
|
||||
console.log("Skipped on Windows\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const SSH_SETUP_MS = 2500;
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-status-ssh-"));
|
||||
let daemon: Awaited<ReturnType<typeof startTestDaemon>> | undefined;
|
||||
|
||||
try {
|
||||
daemon = await startTestDaemon();
|
||||
// Opens the -W stream only after the delay a real SSH handshake takes.
|
||||
await writeFile(
|
||||
join(root, "ssh"),
|
||||
`#!${process.execPath}
|
||||
const net = require("node:net");
|
||||
const args = process.argv.slice(2);
|
||||
const [host, port] = args[args.indexOf("-W") + 1].split(":");
|
||||
setTimeout(() => {
|
||||
const socket = net.connect(Number(port), host);
|
||||
process.stdin.pipe(socket);
|
||||
socket.pipe(process.stdout);
|
||||
socket.on("close", () => process.exit(0));
|
||||
}, ${SSH_SETUP_MS});
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
|
||||
console.log("Test 1: status reaches a daemon whose SSH stream takes 2.5s to open");
|
||||
const result = await runLocalPaseo(
|
||||
["daemon", "status", "--host", `ssh://build-box?daemonPort=${daemon.port}`, "--json"],
|
||||
{ PATH: `${root}${delimiter}${process.env.PATH ?? ""}` },
|
||||
);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, result.stderr);
|
||||
const status = JSON.parse(result.stdout);
|
||||
assert.strictEqual(status.connectedDaemon, "reachable", JSON.stringify(status));
|
||||
assert.strictEqual(typeof status.serverId, "string");
|
||||
console.log("✓ status waits for the SSH stream like other commands\n");
|
||||
} finally {
|
||||
await daemon?.stop();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("=== Daemon Status over SSH Tests Passed ===");
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
} from "../../../../scripts/test-support/npm-registry.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { once } from "node:events";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { connectToDaemon } from "../../src/utils/client.ts";
|
||||
import { createE2ETestContext } from "../helpers/test-daemon.ts";
|
||||
|
||||
@@ -45,6 +47,81 @@ async function main(): Promise<void> {
|
||||
);
|
||||
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
|
||||
|
||||
// Observe real CLI requests while forwarding them to the isolated daemon.
|
||||
const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
const sources: string[] = [];
|
||||
proxy.on("connection", (socket, request) => {
|
||||
const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, {
|
||||
headers: { authorization: request.headers.authorization ?? "" },
|
||||
});
|
||||
const ready = once(upstream, "open");
|
||||
socket.on("message", async (data, isBinary) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (
|
||||
message.type === "session" &&
|
||||
message.message.type === "plugin.source.install.request"
|
||||
) {
|
||||
sources.push(message.message.source);
|
||||
}
|
||||
await ready;
|
||||
upstream.send(data, { binary: isBinary });
|
||||
});
|
||||
upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary }));
|
||||
socket.on("close", () => upstream.close());
|
||||
upstream.on("close", () => socket.close());
|
||||
});
|
||||
await once(proxy, "listening");
|
||||
const address = proxy.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
const sibling = path.join(context.workDir, "x");
|
||||
const caller = path.join(context.workDir, "caller");
|
||||
await mkdir(caller);
|
||||
await mkdir(path.join(sibling, "sub"), { recursive: true });
|
||||
for (const target of [caller, path.join(sibling, "sub"), context.paseoHome]) {
|
||||
await writeFile(
|
||||
path.join(target, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "relative-cli",
|
||||
requirements: { paseo: `>=${resolveCliVersion()}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(target, "index.server.ts"), pluginSource);
|
||||
}
|
||||
assert.notEqual(caller, process.cwd()); // The daemon inherits the test runner's cwd.
|
||||
try {
|
||||
for (const [source, expectedSource, expectedDirectory, extraArgs] of [
|
||||
[".", caller, caller, []],
|
||||
["../x:sub", `${sibling}:sub`, path.join(sibling, "sub"), []],
|
||||
["../x", `${sibling}:sub`, path.join(sibling, "sub"), ["--path", "sub"]],
|
||||
["~", context.paseoHome, context.paseoHome, []],
|
||||
["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []],
|
||||
] as const) {
|
||||
sources.length = 0;
|
||||
const result = await context.paseo(
|
||||
["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"],
|
||||
{ cwd: caller },
|
||||
);
|
||||
assert.deepEqual(sources, [expectedSource]);
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
assert.equal(JSON.parse(result.stdout).path, expectedDirectory);
|
||||
const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]);
|
||||
assert.equal(removed.exitCode, 0, removed.stderr);
|
||||
}
|
||||
// An absolute path belongs to the daemon's platform, which can differ from the CLI's.
|
||||
const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`;
|
||||
sources.length = 0;
|
||||
const missing = await context.paseo(
|
||||
["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"],
|
||||
{ cwd: caller },
|
||||
);
|
||||
assert.deepEqual(sources, [windowsSource]);
|
||||
assert.equal(missing.exitCode, 1);
|
||||
assert.match(missing.stderr, /Plugin directory does not exist/);
|
||||
} finally {
|
||||
for (const socket of proxy.clients) socket.close();
|
||||
await promisify(proxy.close.bind(proxy))();
|
||||
}
|
||||
|
||||
const install = await context.paseo(["plugin", "install", directory, "--json"]);
|
||||
assert.equal(install.exitCode, 0, install.stderr);
|
||||
assert.equal(JSON.parse(install.stdout).id, "cli-e2e");
|
||||
|
||||
@@ -144,6 +144,19 @@ const title = "Hello";
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an Astro expression open through nested template strings", () => {
|
||||
const code = "<p>{`a ${`}`} b`}</p>";
|
||||
|
||||
const tokens = highlightCode(code, "Nested.astro").flat();
|
||||
|
||||
expect(tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ text: "`}`", style: "string" },
|
||||
{ text: " b`", style: "string" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights Vue SFCs across script, template, and style", () => {
|
||||
const code = [
|
||||
'<script lang="ts">',
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { parser as cssParser } from "@lezer/css";
|
||||
import { parser as htmlParser } from "@lezer/html";
|
||||
import { parser as jsParser } from "@lezer/javascript";
|
||||
import { findClosingBrace } from "../js-scanner.js";
|
||||
|
||||
interface Range {
|
||||
from: number;
|
||||
@@ -23,88 +24,11 @@ interface Range {
|
||||
const jsxParser = jsParser.configure({ dialect: "ts jsx" });
|
||||
const typescriptParser = jsParser.configure({ dialect: "ts" });
|
||||
|
||||
function isSpace(code: number): boolean {
|
||||
return code === 32 || code === 9 || code === 10 || code === 13;
|
||||
}
|
||||
|
||||
function isRegexStart(text: string, position: number): boolean {
|
||||
let previous = position - 1;
|
||||
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
|
||||
if (previous < 0) return true;
|
||||
|
||||
const character = text[previous];
|
||||
if (/[)\]}<"'`\d]/.test(character)) return false;
|
||||
const code = text.charCodeAt(previous);
|
||||
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
|
||||
if (!/[A-Za-z_$]/.test(character)) return true;
|
||||
|
||||
let start = previous;
|
||||
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
|
||||
const keyword = text.slice(start + 1, previous + 1);
|
||||
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
|
||||
keyword,
|
||||
);
|
||||
}
|
||||
|
||||
function skipQuotedText(text: string, opening: number): number {
|
||||
const quote = text.charCodeAt(opening);
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 92) position++;
|
||||
else if (code === quote) return position;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
function skipLineComment(text: string, opening: number): number {
|
||||
const newline = text.indexOf("\n", opening + 2);
|
||||
return newline >= 0 ? newline : text.length - 1;
|
||||
}
|
||||
|
||||
function skipBlockComment(text: string, opening: number): number {
|
||||
const closing = text.indexOf("*/", opening + 2);
|
||||
return closing >= 0 ? closing + 1 : text.length - 1;
|
||||
}
|
||||
|
||||
function skipRegex(text: string, opening: number): number {
|
||||
let isInCharacterClass = false;
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 10 || code === 13) return position;
|
||||
if (code === 92) position++;
|
||||
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
|
||||
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
|
||||
else if (!isInCharacterClass && code === 47) return position;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
function findClosingBrace(text: string, opening: number): number {
|
||||
let depth = 0;
|
||||
for (let position = opening; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 47 && text.charCodeAt(position + 1) === 47) {
|
||||
position = skipLineComment(text, position);
|
||||
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
|
||||
position = skipBlockComment(text, position);
|
||||
} else if (code === 47 && isRegexStart(text, position)) {
|
||||
position = skipRegex(text, position);
|
||||
} else if (code === 34 || code === 39 || code === 96) {
|
||||
position = skipQuotedText(text, position);
|
||||
} else if (code === 123) {
|
||||
depth++;
|
||||
} else if (code === 125 && --depth === 0) {
|
||||
return position;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findExpressions(text: string): Range[] {
|
||||
const ranges: Range[] = [];
|
||||
for (let position = 0; position < text.length; position++) {
|
||||
if (text.charCodeAt(position) !== 123) continue;
|
||||
const closing = findClosingBrace(text, position);
|
||||
const closing = findClosingBrace(text, position + 1);
|
||||
if (closing < 0) break;
|
||||
ranges.push({ from: position, to: closing });
|
||||
position = closing;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Scans JavaScript embedded in a template (Astro `{ }` expressions, Vue `{{ }}`
|
||||
// interpolations) for the brace that closes it, without parsing the code.
|
||||
|
||||
function isSpace(code: number): boolean {
|
||||
return code === 32 || code === 9 || code === 10 || code === 13;
|
||||
}
|
||||
|
||||
function isRegexStart(text: string, position: number): boolean {
|
||||
let previous = position - 1;
|
||||
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
|
||||
if (previous < 0) return true;
|
||||
|
||||
const character = text[previous];
|
||||
if (/[)\]}<"'`\d]/.test(character)) return false;
|
||||
const code = text.charCodeAt(previous);
|
||||
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
|
||||
if (!/[A-Za-z_$]/.test(character)) return true;
|
||||
|
||||
let start = previous;
|
||||
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
|
||||
const keyword = text.slice(start + 1, previous + 1);
|
||||
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
|
||||
keyword,
|
||||
);
|
||||
}
|
||||
|
||||
// Template strings nested deeper than this are treated as running to the end.
|
||||
const MAX_TEMPLATE_NESTING = 32;
|
||||
|
||||
function skipQuotedText(text: string, opening: number, nesting = 0): number {
|
||||
const quote = text.charCodeAt(opening);
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 92) position++;
|
||||
else if (code === quote) return position;
|
||||
else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) {
|
||||
position = skipTemplateSubstitution(text, position + 2, nesting);
|
||||
}
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
function skipTemplateSubstitution(text: string, start: number, nesting: number): number {
|
||||
if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1;
|
||||
const closing = findClosingBrace(text, start, () => true, nesting + 1);
|
||||
return closing >= 0 ? closing : text.length - 1;
|
||||
}
|
||||
|
||||
function skipLineComment(text: string, opening: number): number {
|
||||
const newline = text.indexOf("\n", opening + 2);
|
||||
return newline >= 0 ? newline : text.length - 1;
|
||||
}
|
||||
|
||||
function skipBlockComment(text: string, opening: number): number {
|
||||
const closing = text.indexOf("*/", opening + 2);
|
||||
return closing >= 0 ? closing + 1 : text.length - 1;
|
||||
}
|
||||
|
||||
function skipRegex(text: string, opening: number): number {
|
||||
let isInCharacterClass = false;
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 10 || code === 13) return position;
|
||||
if (code === 92) position++;
|
||||
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
|
||||
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
|
||||
else if (!isInCharacterClass && code === 47) return position;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
// Finds the first `}` outside nested braces, strings, comments, and regexes
|
||||
// for which `isEnd` holds.
|
||||
export function findClosingBrace(
|
||||
text: string,
|
||||
start: number,
|
||||
isEnd: (position: number) => boolean = () => true,
|
||||
nesting = 0,
|
||||
): number {
|
||||
let depth = 0;
|
||||
for (let position = start; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 47 && text.charCodeAt(position + 1) === 47) {
|
||||
position = skipLineComment(text, position);
|
||||
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
|
||||
position = skipBlockComment(text, position);
|
||||
} else if (code === 47 && isRegexStart(text, position)) {
|
||||
position = skipRegex(text, position);
|
||||
} else if (code === 34 || code === 39 || code === 96) {
|
||||
position = skipQuotedText(text, position, nesting);
|
||||
} else if (code === 123) {
|
||||
depth++;
|
||||
} else if (code === 125 && depth === 0) {
|
||||
if (isEnd(position)) return position;
|
||||
} else if (code === 125) {
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { parser as cssParser } from "@lezer/css";
|
||||
import { parser as htmlParser } from "@lezer/html";
|
||||
import { parser as jsParser } from "@lezer/javascript";
|
||||
import { findClosingBrace } from "../js-scanner.js";
|
||||
|
||||
interface Range {
|
||||
from: number;
|
||||
@@ -17,104 +18,6 @@ interface Range {
|
||||
const typescriptParser = jsParser.configure({ dialect: "ts" });
|
||||
const expressionParser = jsParser.configure({ top: "SingleExpression" });
|
||||
|
||||
function isSpace(code: number): boolean {
|
||||
return code === 32 || code === 9 || code === 10 || code === 13;
|
||||
}
|
||||
|
||||
function isRegexStart(text: string, position: number): boolean {
|
||||
let previous = position - 1;
|
||||
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
|
||||
if (previous < 0) return true;
|
||||
|
||||
const character = text[previous];
|
||||
if (/[)\]}<"'`\d]/.test(character)) return false;
|
||||
const code = text.charCodeAt(previous);
|
||||
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
|
||||
if (!/[A-Za-z_$]/.test(character)) return true;
|
||||
|
||||
let start = previous;
|
||||
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
|
||||
const keyword = text.slice(start + 1, previous + 1);
|
||||
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
|
||||
keyword,
|
||||
);
|
||||
}
|
||||
|
||||
// Template strings nested deeper than this are treated as running to the end.
|
||||
const MAX_TEMPLATE_NESTING = 32;
|
||||
|
||||
function skipQuotedText(text: string, opening: number, nesting = 0): number {
|
||||
const quote = text.charCodeAt(opening);
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 92) position++;
|
||||
else if (code === quote) return position;
|
||||
else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) {
|
||||
position = skipTemplateSubstitution(text, position + 2, nesting);
|
||||
}
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
function skipTemplateSubstitution(text: string, start: number, nesting: number): number {
|
||||
if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1;
|
||||
const closing = findClosingBrace(text, start, () => true, nesting + 1);
|
||||
return closing >= 0 ? closing : text.length - 1;
|
||||
}
|
||||
|
||||
function skipLineComment(text: string, opening: number): number {
|
||||
const newline = text.indexOf("\n", opening + 2);
|
||||
return newline >= 0 ? newline : text.length - 1;
|
||||
}
|
||||
|
||||
function skipBlockComment(text: string, opening: number): number {
|
||||
const closing = text.indexOf("*/", opening + 2);
|
||||
return closing >= 0 ? closing + 1 : text.length - 1;
|
||||
}
|
||||
|
||||
function skipRegex(text: string, opening: number): number {
|
||||
let isInCharacterClass = false;
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 10 || code === 13) return position;
|
||||
if (code === 92) position++;
|
||||
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
|
||||
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
|
||||
else if (!isInCharacterClass && code === 47) return position;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
// Finds the first `}` outside nested braces, strings, comments, and regexes
|
||||
// for which `isEnd` holds.
|
||||
function findClosingBrace(
|
||||
text: string,
|
||||
start: number,
|
||||
isEnd: (position: number) => boolean,
|
||||
nesting = 0,
|
||||
): number {
|
||||
let depth = 0;
|
||||
for (let position = start; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 47 && text.charCodeAt(position + 1) === 47) {
|
||||
position = skipLineComment(text, position);
|
||||
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
|
||||
position = skipBlockComment(text, position);
|
||||
} else if (code === 47 && isRegexStart(text, position)) {
|
||||
position = skipRegex(text, position);
|
||||
} else if (code === 34 || code === 39 || code === 96) {
|
||||
position = skipQuotedText(text, position, nesting);
|
||||
} else if (code === 123) {
|
||||
depth++;
|
||||
} else if (code === 125 && depth === 0) {
|
||||
if (isEnd(position)) return position;
|
||||
} else if (code === 125) {
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findInterpolationEnd(text: string, start: number): number {
|
||||
return findClosingBrace(text, start, (position) => text.charCodeAt(position + 1) === 125);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,27 @@ describe("plugin protocol compatibility", () => {
|
||||
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
|
||||
},
|
||||
);
|
||||
it("preserves manifest metadata in plugin list responses", () => {
|
||||
const message = {
|
||||
type: "plugin.list.response",
|
||||
payload: {
|
||||
requestId: "metadata",
|
||||
plugins: [
|
||||
{
|
||||
id: "review",
|
||||
name: "Review tools",
|
||||
icon: "assets/icon.png",
|
||||
media: ["screenshot.png", "https://example.com/demo.mp4"],
|
||||
path: "/plugins/review",
|
||||
enabled: false,
|
||||
status: "disabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
|
||||
});
|
||||
|
||||
it("parses plugin timeline append messages and advertises the capability", () => {
|
||||
expect(
|
||||
SessionInboundMessageSchema.parse({
|
||||
|
||||
@@ -6632,6 +6632,9 @@ export const PluginNpmInstallationSchema = z.object({
|
||||
|
||||
export const PluginListItemSchema = z.object({
|
||||
id: PluginIdSchema,
|
||||
name: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
media: z.array(z.string()).optional(),
|
||||
description: z.string().optional(),
|
||||
path: z.string(),
|
||||
enabled: z.boolean(),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pluginOverviewUrl } from "./plugin-overview.js";
|
||||
|
||||
describe("plugin overview destinations", () => {
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"./docs",
|
||||
"../docs",
|
||||
"/login",
|
||||
"//example.com",
|
||||
"#section",
|
||||
"?login",
|
||||
"http://example.com",
|
||||
"mailto:hello@example.com",
|
||||
"ftp://example.com",
|
||||
"javascript:alert(1)",
|
||||
"data:image/png;base64,AAAA",
|
||||
"vbscript:msgbox(1)",
|
||||
"javascript:alert(1)",
|
||||
"javascript%3Aalert(1)",
|
||||
"javascript%253Aalert(1)",
|
||||
"java\tscript:alert(1)",
|
||||
" https://example.com",
|
||||
"https://example.com\n",
|
||||
"https:example.com",
|
||||
"https:///example.com",
|
||||
"https://paseo.sh@evil.example",
|
||||
"https://example.com\\@evil.example",
|
||||
"https://",
|
||||
])("leaves %s as text", (url) => {
|
||||
expect(pluginOverviewUrl(url)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://example.com/path?q=a&b=c#section",
|
||||
"HTTPS://example.com/path",
|
||||
"https://example.com/%3Cscript%3E",
|
||||
])("allows %s without decoding it again", (url) => {
|
||||
expect(pluginOverviewUrl(url)).toBe(url);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/** Presentation contract for author-controlled registry content, independent of the UI runtime. */
|
||||
export const pluginOverviewPolicy = {
|
||||
rawHtml: false,
|
||||
elements: [
|
||||
"p",
|
||||
"br",
|
||||
"hr",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"blockquote",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"strong",
|
||||
"em",
|
||||
"del",
|
||||
"pre",
|
||||
"code",
|
||||
"a",
|
||||
"img",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
],
|
||||
link: { rel: "noopener noreferrer nofollow", target: "_blank" },
|
||||
} as const;
|
||||
|
||||
/** Validate decoded Markdown destinations; never resolve against the reader's page or repository. */
|
||||
export function pluginOverviewUrl(value: string | undefined): string | undefined {
|
||||
// URL parsers discard controls and normalize backslashes; reject those ambiguous spellings.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (!value || !/^https:\/\/[^/?#]/i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value))
|
||||
return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || !url.hostname || url.username || url.password)
|
||||
return undefined;
|
||||
return value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pluginMediaKind } from "./plugin-registry.js";
|
||||
|
||||
describe("plugin media kind", () => {
|
||||
it.each([
|
||||
["https://cdn.example.com/a/preview.png", "image"],
|
||||
["https://cdn.example.com/a/preview.JPG", "image"],
|
||||
["https://cdn.example.com/a/preview.jpeg", "image"],
|
||||
["https://cdn.example.com/a/preview.webp", "image"],
|
||||
["https://cdn.example.com/a/preview.gif", "image"],
|
||||
["https://cdn.example.com/a/demo.mp4", "video"],
|
||||
["https://cdn.example.com/a/demo.WebM", "video"],
|
||||
["https://cdn.example.com/a/demo.mp4?raw=true#t=2", "video"],
|
||||
["https://cdn.example.com/mp4/preview.png?format=mp4", "image"],
|
||||
] as const)("reads %s as %s from its path extension", (url, kind) => {
|
||||
expect(pluginMediaKind(url)).toBe(kind);
|
||||
});
|
||||
});
|
||||
@@ -39,13 +39,21 @@ export const PublishedPluginSchema = z.object({
|
||||
repository: z.object({ url: z.string().url(), commit: z.string().optional() }),
|
||||
artifact: PluginRegistryArtifactSchema,
|
||||
icon: z.string().url().optional(),
|
||||
screenshots: z.array(z.string().url()),
|
||||
/** Images and videos in display order; see `pluginMediaKind`. */
|
||||
media: z.array(z.string().url()),
|
||||
submittedAt: z.string(),
|
||||
reviewedAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
publishedAt: z.string(),
|
||||
installs: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
const VIDEO_EXTENSIONS = new Set(["mp4", "webm"]);
|
||||
|
||||
/** Whether a registry media entry is a video or an image, from its path extension. */
|
||||
export function pluginMediaKind(url: string): "image" | "video" {
|
||||
const extension = new URL(url).pathname.split(".").pop()?.toLowerCase() ?? "";
|
||||
return VIDEO_EXTENSIONS.has(extension) ? "video" : "image";
|
||||
}
|
||||
export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() });
|
||||
export const PluginRegistryIndexSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
@@ -54,6 +62,8 @@ export const PluginRegistryIndexSchema = z.object({
|
||||
z.object({ slug: z.string(), label: z.string(), description: z.string().optional() }),
|
||||
),
|
||||
plugins: z.array(PublishedPluginSchema),
|
||||
/** Plugin IDs the maintainer features, in display order. An ID may name a plugin that is not listed. */
|
||||
featured: z.array(z.string()),
|
||||
generatedAt: z.string(),
|
||||
});
|
||||
export type PluginRegistryIdentity = z.infer<typeof PluginRegistryIdentitySchema>;
|
||||
|
||||
@@ -3,6 +3,35 @@ import { describe, expect, it } from "vitest";
|
||||
import { parsePluginSourceReference } from "./plugin-source-reference.js";
|
||||
|
||||
describe("plugin source references", () => {
|
||||
it.each([
|
||||
".",
|
||||
"..",
|
||||
"./owner/slug",
|
||||
"../owner/slug",
|
||||
"/owner/slug",
|
||||
"~/owner/slug",
|
||||
"~",
|
||||
"C:\\",
|
||||
"C:/",
|
||||
"D:\\plugins\\repository",
|
||||
"D:/plugins/repository",
|
||||
"\\\\server\\share",
|
||||
"\\\\server\\share\\plugins\\review",
|
||||
".\\plugins\\review",
|
||||
"..\\plugins\\review",
|
||||
])("recognizes the explicit directory %s by shape", (source) => {
|
||||
expect(parsePluginSourceReference(source)).toEqual({
|
||||
kind: "directory",
|
||||
source,
|
||||
pluginPath: undefined,
|
||||
});
|
||||
expect(parsePluginSourceReference(`${source}:plugins/review`)).toEqual({
|
||||
kind: "directory",
|
||||
source,
|
||||
pluginPath: "plugins/review",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["npm:review@1.2.0:nested", "npm:review@1.2.0", "nested"],
|
||||
["npm:review@^1.2.0", "npm:review@^1.2.0", undefined],
|
||||
@@ -16,6 +45,7 @@ describe("plugin source references", () => {
|
||||
["git:file:///repo:plugins/main", "git:file:///repo", "plugins/main"],
|
||||
["plugins.example.com:8443/owner/slug", "plugins.example.com:8443/owner/slug", undefined],
|
||||
["owner/repository", "owner/repository", undefined],
|
||||
["plugins/foo", "plugins/foo", undefined],
|
||||
["owner/repository:plugins/review", "owner/repository", "plugins/review"],
|
||||
[
|
||||
"https://example.test:8443/owner/repository.git",
|
||||
@@ -39,10 +69,8 @@ describe("plugin source references", () => {
|
||||
"file:///D:/plugins/repository",
|
||||
"plugins/review",
|
||||
],
|
||||
["D:\\plugins\\repository", "D:\\plugins\\repository", undefined],
|
||||
["D:\\plugins\\repository:plugins/review", "D:\\plugins\\repository", "plugins/review"],
|
||||
])("parses %s", (reference, source, pluginPath) => {
|
||||
expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath });
|
||||
expect(parsePluginSourceReference(reference)).toEqual({ kind: "managed", source, pluginPath });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { parsePluginRegistryReference } from "./plugin-registry.js";
|
||||
import type { PluginInstallation } from "./messages.js";
|
||||
export interface PluginSourceReference {
|
||||
kind: "directory" | "managed";
|
||||
source: string;
|
||||
pluginPath: string | undefined;
|
||||
}
|
||||
|
||||
export function parsePluginSourceReference(reference: string): PluginSourceReference {
|
||||
if (parsePluginRegistryReference(reference)) return { source: reference, pluginPath: undefined };
|
||||
const prefix = /^(npm:|github:|git:(?!\/\/))/.exec(reference)?.[0];
|
||||
const source = prefix ? reference.slice(prefix.length) : reference;
|
||||
const parsed = splitPluginPath(source, { scp: prefix !== "npm:" });
|
||||
return { ...parsed, source: `${prefix ?? ""}${parsed.source}` };
|
||||
if (!prefix && /^(\.\.?(?:[/\\]|$)|\/|~|[A-Za-z]:[/\\]|\\\\)/.test(parsed.source)) {
|
||||
return { kind: "directory", ...parsed };
|
||||
}
|
||||
if (parsePluginRegistryReference(reference))
|
||||
return { kind: "managed", source: reference, pluginPath: undefined };
|
||||
return { kind: "managed", ...parsed, source: `${prefix ?? ""}${parsed.source}` };
|
||||
}
|
||||
|
||||
function splitPluginPath(reference: string, options: { scp: boolean }): PluginSourceReference {
|
||||
function splitPluginPath(
|
||||
reference: string,
|
||||
options: { scp: boolean },
|
||||
): Pick<PluginSourceReference, "source" | "pluginPath"> {
|
||||
const separator = reference.lastIndexOf(":");
|
||||
if (separator === -1) return { source: reference, pluginPath: undefined };
|
||||
|
||||
|
||||
+1202
File diff suppressed because it is too large
Load Diff
@@ -309,18 +309,30 @@ export async function sendPromptToAgent(
|
||||
const unarchive = params.unarchive ?? true;
|
||||
|
||||
const record = await params.agentStorage.get(params.agentId);
|
||||
let archivedAtToRestore: string | null = null;
|
||||
if (record?.archivedAt) {
|
||||
if (!unarchive) {
|
||||
return { disposition: "turn_started" };
|
||||
}
|
||||
await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId);
|
||||
if (await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId)) {
|
||||
archivedAtToRestore = record.archivedAt;
|
||||
}
|
||||
}
|
||||
|
||||
await ensureAgentLoaded(params.agentId, {
|
||||
agentManager: params.agentManager,
|
||||
agentStorage: params.agentStorage,
|
||||
logger: params.logger,
|
||||
});
|
||||
try {
|
||||
await ensureAgentLoaded(params.agentId, {
|
||||
agentManager: params.agentManager,
|
||||
agentStorage: params.agentStorage,
|
||||
logger: params.logger,
|
||||
});
|
||||
} catch (error) {
|
||||
// A send that could not load the agent leaves it where it was: still archived.
|
||||
// Concurrent sends share this load, so none of them holds a live session.
|
||||
if (archivedAtToRestore) {
|
||||
await params.agentManager.archiveSnapshot(params.agentId, archivedAtToRestore);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (params.sessionMode) {
|
||||
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
|
||||
|
||||
@@ -76,6 +76,8 @@ import {
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptContentBlock,
|
||||
type AgentPromptInput,
|
||||
type AgentResumePurpose,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
@@ -148,6 +150,18 @@ function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promis
|
||||
});
|
||||
}
|
||||
|
||||
async function isDirectory(target: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.stat(target)).isDirectory();
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -497,6 +511,7 @@ interface ACPAgentSessionOptions {
|
||||
capabilities: AgentCapabilityFlags;
|
||||
extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
handle?: AgentPersistenceHandle;
|
||||
resumePurpose?: AgentResumePurpose;
|
||||
agentId?: string;
|
||||
launchEnv?: Record<string, string>;
|
||||
waitForInitialCommands?: boolean;
|
||||
@@ -1006,6 +1021,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentResumeSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
if (handle.provider !== this.provider) {
|
||||
throw new Error(`Cannot resume ${handle.provider} handle with ${this.provider} provider`);
|
||||
@@ -1047,6 +1063,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers,
|
||||
},
|
||||
handle,
|
||||
resumePurpose: options?.purpose,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
extensionCommandsParser: this.extensionCommandsParser,
|
||||
@@ -1700,6 +1717,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private readonly terminalEntries = new Map<string, TerminalEntry>();
|
||||
private readonly persistedHistory: AgentTimelineItem[] = [];
|
||||
private readonly initialHandle?: AgentPersistenceHandle;
|
||||
private readonly resumePurpose: AgentResumePurpose;
|
||||
|
||||
private readonly config: AgentSessionConfig;
|
||||
private child: ChildProcessWithoutNullStreams | null = null;
|
||||
@@ -1753,6 +1771,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.agentId = options.agentId;
|
||||
this.launchEnv = options.launchEnv;
|
||||
this.initialHandle = options.handle;
|
||||
this.resumePurpose = options.resumePurpose ?? "interactive";
|
||||
this.config = { ...config, provider: options.provider };
|
||||
this.currentMode = config.modeId ?? null;
|
||||
this.currentModel = config.model ?? null;
|
||||
@@ -2770,6 +2789,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Loading an archived agent's history runs nothing in its working directory, which
|
||||
// may have been removed with its worktree. session/load still names the original
|
||||
// directory; only the process starts from the home directory.
|
||||
private async resolveProcessCwd(): Promise<string> {
|
||||
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
|
||||
return this.config.cwd;
|
||||
}
|
||||
return homedir();
|
||||
}
|
||||
|
||||
private async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
const prefix = await resolveProviderLaunch({
|
||||
commandConfig: this.runtimeSettings?.command,
|
||||
@@ -2783,7 +2812,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
const command = prefix.command;
|
||||
const args = [...prefix.args, ...this.defaultCommand.slice(1)];
|
||||
const child = spawnProcess(command, args, {
|
||||
cwd: this.config.cwd,
|
||||
cwd: await this.resolveProcessCwd(),
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [this.launchEnv],
|
||||
|
||||
@@ -712,4 +712,56 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
||||
expect(timeline.length).toBeGreaterThan(0);
|
||||
expect(timeline[0]).toMatchObject({ id: TOOL_USE_ID });
|
||||
});
|
||||
|
||||
test("replays a SubagentHandback report as the subagent's final message", async () => {
|
||||
const handbackId = "toolu_handback";
|
||||
const report = "## Verdict\n\n- **Coherent**";
|
||||
writeSession({
|
||||
parentLines: [taskToolUse(), taskToolResult()],
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
isSidechain: true,
|
||||
agentId: AGENT_ID,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: handbackId,
|
||||
name: "SubagentHandback",
|
||||
input: { message: report },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
isSidechain: true,
|
||||
agentId: AGENT_ID,
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: handbackId,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '{"success":true,"message":"Report delivered to your caller."}',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const items = (await replayDescriptors()).flatMap((event) =>
|
||||
event.event.type === "timeline" ? [event.event.item] : [],
|
||||
);
|
||||
expect(items).toEqual([{ type: "assistant_message", text: report }]);
|
||||
});
|
||||
});
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
const ROOT_PROMPT = `Use Claude Code's native Agent tool exactly once, never Paseo tools, with this complete task:
|
||||
|
||||
Do not use any tools. Reply with exactly this markdown report and nothing else:
|
||||
## HANDBACK_HEADING
|
||||
- **HANDBACK_BOLD** item
|
||||
|
||||
Then reply exactly ROOT_DONE.`;
|
||||
|
||||
function subagentItems(events: AgentStreamEvent[]): AgentTimelineItem[] {
|
||||
return events.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event.item] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function expectHandbackAsReport(items: AgentTimelineItem[]): void {
|
||||
expect(items.filter((item) => item.type === "tool_call")).toEqual([]);
|
||||
expect(
|
||||
items.flatMap((item) => (item.type === "assistant_message" ? [item.text] : [])).join(""),
|
||||
).toContain("## HANDBACK_HEADING\n- **HANDBACK_BOLD** item");
|
||||
}
|
||||
|
||||
test("an auto-mode Claude subagent's handback report shows as its final message", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-subagent-handback-"));
|
||||
const client = new ClaudeAgentClient({ logger: pino({ level: "warn" }) });
|
||||
const session = await client.createSession({ provider: "claude", cwd, modeId: "auto" });
|
||||
try {
|
||||
const events = await collectSessionTurnEvents(session, ROOT_PROMPT);
|
||||
expectHandbackAsReport(subagentItems(events));
|
||||
|
||||
const persistence = session.describePersistence();
|
||||
expect(persistence).not.toBeNull();
|
||||
await session.close();
|
||||
const restored = await client.resumeSession(persistence!, { cwd });
|
||||
try {
|
||||
const replayed: AgentStreamEvent[] = [];
|
||||
for await (const event of restored.streamHistory()) replayed.push(event);
|
||||
expectHandbackAsReport(subagentItems(replayed));
|
||||
} finally {
|
||||
await restored.close();
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 240_000);
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "./model-manifest.js";
|
||||
import { parsePartialJsonObject } from "./partial-json.js";
|
||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||
import { readClaudeSubagentHandback } from "./subagent-handback.js";
|
||||
import { ClaudeTaskState } from "./task-state.js";
|
||||
import {
|
||||
ClaudeTaskProtocolSource,
|
||||
@@ -2084,6 +2085,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private toolUseCache = new Map<string, ToolUseCacheEntry>();
|
||||
private toolUseIndexToId = new Map<number, string>();
|
||||
private toolUseInputBuffers = new Map<string, string>();
|
||||
private subagentHandbackIds = new Set<string>();
|
||||
private pendingPermissions = new Map<string, PendingPermission>();
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private autonomousTurn: AutonomousTurnState | null = null;
|
||||
@@ -5184,7 +5186,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// User SDK entries can arrive as multiple text blocks, but Paseo treats them as one message.
|
||||
const userTextParts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!isClaudeContentChunk(block)) {
|
||||
if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) {
|
||||
continue;
|
||||
}
|
||||
this.mapBlockToTimeline(block, {
|
||||
@@ -5272,6 +5274,24 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
// A subagent's handback call is its final message, and the call's result is only an
|
||||
// acknowledgement, so neither becomes a tool call.
|
||||
private mapSubagentHandbackBlock(block: ClaudeContentChunk, items: AgentTimelineItem[]): boolean {
|
||||
if (typeof block.tool_use_id === "string" && this.subagentHandbackIds.has(block.tool_use_id)) {
|
||||
this.subagentHandbackIds.delete(block.tool_use_id);
|
||||
return true;
|
||||
}
|
||||
const handback = readClaudeSubagentHandback(block);
|
||||
if (!handback) {
|
||||
return false;
|
||||
}
|
||||
this.subagentHandbackIds.add(handback.callId);
|
||||
if (handback.report) {
|
||||
items.push({ type: "assistant_message", text: handback.report });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private handleToolUseStart(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
|
||||
const entry = this.upsertToolUseEntry(block);
|
||||
if (!entry) {
|
||||
|
||||
@@ -35,4 +35,64 @@ describe("ClaudeSidechainTracker", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a SubagentHandback report as the subagent's final message", () => {
|
||||
const tracker = new ClaudeSidechainTracker({ getToolInput: () => null });
|
||||
const report = " indented code\n\n## Verdict\n\n- **Coherent**";
|
||||
const frames = [
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: "task-1",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "handback-1",
|
||||
name: "SubagentHandback",
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "task-1",
|
||||
message: {
|
||||
id: "msg-1",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "handback-1",
|
||||
name: "SubagentHandback",
|
||||
input: { message: report },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
parent_tool_use_id: "task-1",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "handback-1",
|
||||
tool_name: "SubagentHandback",
|
||||
content: [{ type: "text", text: '{"success":true}' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const childItems = frames
|
||||
.flatMap((frame) => tracker.handleMessage(frame as unknown as SDKMessage, "task-1"))
|
||||
.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "timeline"
|
||||
? [event.event.item]
|
||||
: [],
|
||||
);
|
||||
|
||||
expect(childItems).toEqual([{ type: "assistant_message", text: report, messageId: "msg-1" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
mapClaudeRunningToolCall,
|
||||
} from "./tool-call-mapper.js";
|
||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||
import {
|
||||
isClaudeSubagentHandbackToolName,
|
||||
readClaudeSubagentHandback,
|
||||
} from "./subagent-handback.js";
|
||||
|
||||
import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
|
||||
@@ -91,7 +95,7 @@ export class ClaudeSidechainTracker {
|
||||
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
|
||||
const actionCandidates = this.extractSubAgentActionCandidates(message);
|
||||
const childTimelineItems = [
|
||||
...this.extractSubAgentTimelineItems(message),
|
||||
...this.extractSubAgentTimelineItems(message, state),
|
||||
...this.extractSubAgentToolResults(message, state),
|
||||
];
|
||||
let actionUpdated = false;
|
||||
@@ -233,7 +237,10 @@ export class ClaudeSidechainTracker {
|
||||
this.activeSidechains.clear();
|
||||
}
|
||||
|
||||
private extractSubAgentTimelineItems(message: SDKMessage): AgentTimelineItem[] {
|
||||
private extractSubAgentTimelineItems(
|
||||
message: SDKMessage,
|
||||
state: SubAgentActivityState,
|
||||
): AgentTimelineItem[] {
|
||||
if (message.type !== "assistant" || !Array.isArray(message.message?.content)) {
|
||||
return [];
|
||||
}
|
||||
@@ -253,6 +260,18 @@ export class ClaudeSidechainTracker {
|
||||
} else if (block.type === "thinking") {
|
||||
const text = readTrimmedString(block.thinking);
|
||||
if (text) items.push({ type: "reasoning", text });
|
||||
} else {
|
||||
const handback = readClaudeSubagentHandback(block);
|
||||
if (!handback) continue;
|
||||
// The handback's result is only an acknowledgement, never a tool card.
|
||||
state.completedActionKeys.add(handback.callId);
|
||||
if (handback.report) {
|
||||
items.push({
|
||||
type: "assistant_message",
|
||||
text: handback.report,
|
||||
...(messageId ? { messageId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
@@ -391,6 +410,12 @@ export class ClaudeSidechainTracker {
|
||||
}
|
||||
|
||||
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
||||
return this.extractToolActionCandidates(message).filter(
|
||||
(action) => !isClaudeSubagentHandbackToolName(action.toolName),
|
||||
);
|
||||
}
|
||||
|
||||
private extractToolActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
||||
if (message.type === "assistant") {
|
||||
return this.extractAssistantMessageActions(message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Since Claude Code 2.1.271, a subagent in auto mode delivers its report to its caller through a
|
||||
* `SubagentHandback` tool call instead of its final text. The report is the subagent's final
|
||||
* message, so Paseo shows it as one and hides the call and its acknowledgement.
|
||||
*/
|
||||
const SUBAGENT_HANDBACK_TOOL_NAME = "SubagentHandback";
|
||||
|
||||
export interface ClaudeSubagentHandback {
|
||||
callId: string;
|
||||
/** Null while the call is still streaming its input. */
|
||||
report: string | null;
|
||||
}
|
||||
|
||||
export function isClaudeSubagentHandbackToolName(name: unknown): boolean {
|
||||
return name === SUBAGENT_HANDBACK_TOOL_NAME;
|
||||
}
|
||||
|
||||
export function readClaudeSubagentHandback(block: {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}): ClaudeSubagentHandback | null {
|
||||
if (block.type !== "tool_use" || !isClaudeSubagentHandbackToolName(block.name)) return null;
|
||||
if (typeof block.id !== "string" || block.id.length === 0) return null;
|
||||
const input = block.input as { message?: unknown } | null | undefined;
|
||||
const report = typeof input?.message === "string" ? input.message : "";
|
||||
return { callId: block.id, report: report.trim().length > 0 ? report : null };
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
|
||||
describe("GenericACPAgentClient archived history", () => {
|
||||
test("replays an archived session whose working directory was removed", async () => {
|
||||
await withFakeACPAgent(async ({ command, removedCwd, loadRequestsPath }) => {
|
||||
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
|
||||
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
|
||||
undefined,
|
||||
undefined,
|
||||
{ purpose: "history" },
|
||||
);
|
||||
try {
|
||||
expect(await collectTimelineText(session.streamHistory())).toEqual([
|
||||
"user_message:What is in the README?",
|
||||
"assistant_message:It describes the project.",
|
||||
]);
|
||||
const loadRequests = JSON.parse(await readFile(loadRequestsPath, "utf8"));
|
||||
expect(loadRequests).toEqual([{ sessionId: "archived-session", cwd: removedCwd }]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("replays an archived session whose working directory path is now a file", async () => {
|
||||
await withFakeACPAgent(async ({ command, removedCwd }) => {
|
||||
await writeFile(removedCwd, "not a directory", "utf8");
|
||||
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
|
||||
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
|
||||
undefined,
|
||||
undefined,
|
||||
{ purpose: "history" },
|
||||
);
|
||||
try {
|
||||
expect(await collectTimelineText(session.streamHistory())).toEqual([
|
||||
"user_message:What is in the README?",
|
||||
"assistant_message:It describes the project.",
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("still requires the working directory to resume a session that will run", async () => {
|
||||
await withFakeACPAgent(async ({ command, removedCwd }) => {
|
||||
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
|
||||
|
||||
await expect(
|
||||
client.resumeSession(
|
||||
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
|
||||
undefined,
|
||||
undefined,
|
||||
{ purpose: "interactive" },
|
||||
),
|
||||
).rejects.toThrow("ENOENT");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function collectTimelineText(events: AsyncGenerator<AgentStreamEvent>): Promise<string[]> {
|
||||
const text: string[] = [];
|
||||
for await (const event of events) {
|
||||
if (event.type !== "timeline") {
|
||||
continue;
|
||||
}
|
||||
if (event.item.type === "user_message" || event.item.type === "assistant_message") {
|
||||
text.push(`${event.item.type}:${event.item.text}`);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async function withFakeACPAgent(
|
||||
run: (input: {
|
||||
command: [string, ...string[]];
|
||||
removedCwd: string;
|
||||
loadRequestsPath: string;
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-archived-history-"));
|
||||
try {
|
||||
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
|
||||
const loadRequestsPath = path.join(testDir, "load-requests.json");
|
||||
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
|
||||
await run({
|
||||
command: [process.execPath, scriptPath, loadRequestsPath],
|
||||
removedCwd: path.join(testDir, "removed-worktree"),
|
||||
loadRequestsPath,
|
||||
});
|
||||
} finally {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const fakeACPAgentScript = `
|
||||
const fs = require("node:fs");
|
||||
const readline = require("node:readline");
|
||||
|
||||
const loadRequestsPath = process.argv[2];
|
||||
const loadRequests = [];
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
|
||||
function write(message) {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
|
||||
}
|
||||
|
||||
function replay(sessionId, sessionUpdate, text) {
|
||||
write({
|
||||
method: "session/update",
|
||||
params: { sessionId, update: { sessionUpdate, content: { type: "text", text } } },
|
||||
});
|
||||
}
|
||||
|
||||
rl.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") {
|
||||
write({
|
||||
id: message.id,
|
||||
result: {
|
||||
protocolVersion: message.params?.protocolVersion ?? 1,
|
||||
agentCapabilities: { loadSession: true, sessionCapabilities: { close: {} } },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === "session/load") {
|
||||
const { sessionId, cwd } = message.params;
|
||||
loadRequests.push({ sessionId, cwd });
|
||||
fs.writeFileSync(loadRequestsPath, JSON.stringify(loadRequests));
|
||||
replay(sessionId, "user_message_chunk", "What is in the README?");
|
||||
replay(sessionId, "agent_message_chunk", "It describes the project.");
|
||||
write({ id: message.id, result: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.id !== undefined) {
|
||||
write({ id: message.id, result: {} });
|
||||
}
|
||||
});
|
||||
`;
|
||||
@@ -414,7 +414,6 @@ export interface PaseoDaemonConfig {
|
||||
plugins?: Record<string, PluginSource>;
|
||||
pluginRegistries?: PluginRegistries;
|
||||
pluginRegistryUrl?: string;
|
||||
pluginRegistryEnabled?: boolean;
|
||||
staticDir: string;
|
||||
mcpDebug: boolean;
|
||||
isDev?: boolean;
|
||||
@@ -625,7 +624,6 @@ export async function createPaseoDaemon(
|
||||
usageSession: (id) => agentManager.usageSession(id),
|
||||
},
|
||||
managedSources: new ManagedPluginSources(config.paseoHome, {
|
||||
enabled: config.pluginRegistryEnabled ?? false,
|
||||
registries: config.pluginRegistries,
|
||||
defaultUrl: config.pluginRegistryUrl,
|
||||
}),
|
||||
|
||||
@@ -30,7 +30,6 @@ export const DAEMON_SETTING_ENV_KEYS = [
|
||||
"PASEO_LOG_ROTATE_SIZE",
|
||||
"PASEO_PASSWORD",
|
||||
"PASEO_PLUGIN_REGISTRY",
|
||||
"PASEO_PLUGIN_REGISTRY_ENABLED",
|
||||
"PASEO_RELAY_ENABLED",
|
||||
"PASEO_RELAY_ENDPOINT",
|
||||
"PASEO_RELAY_PUBLIC_ENDPOINT",
|
||||
|
||||
@@ -211,15 +211,6 @@ test("loads private plugin registry settings through the configuration boundary"
|
||||
});
|
||||
expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal");
|
||||
expect(config.pluginRegistries).toEqual(pluginRegistries);
|
||||
expect(config.pluginRegistryEnabled).toBe(false);
|
||||
await writeFile(
|
||||
path.join(home, "config.json"),
|
||||
JSON.stringify({ pluginRegistries, pluginRegistryEnabled: true }),
|
||||
);
|
||||
expect(loadConfig(home, { env: {} }).pluginRegistryEnabled).toBe(true);
|
||||
expect(
|
||||
loadConfig(home, { env: { PASEO_PLUGIN_REGISTRY_ENABLED: "false" } }).pluginRegistryEnabled,
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -628,10 +628,6 @@ export function resolveConfigFromPersisted(
|
||||
plugins: persisted.plugins,
|
||||
pluginRegistries: persisted.pluginRegistries,
|
||||
pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY,
|
||||
pluginRegistryEnabled:
|
||||
parseBooleanEnv(env.PASEO_PLUGIN_REGISTRY_ENABLED) ??
|
||||
persisted.pluginRegistryEnabled ??
|
||||
false,
|
||||
mcpDebug: env.MCP_DEBUG === "1",
|
||||
isDev: resolvePaseoNodeEnv(env) === "development",
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
|
||||
@@ -831,6 +831,29 @@ test("send_agent_message auto-unarchives archived agents", async () => {
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
test("send_agent_message leaves an archived agent archived when its directory is gone", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const created = await ctx.client.createAgent({
|
||||
config: {
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
const archived = await ctx.client.archiveAgent(created.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
|
||||
await expect(ctx.client.sendMessage(created.id, "hello")).rejects.toThrow(
|
||||
"Working directory does not exist",
|
||||
);
|
||||
|
||||
const afterSend = await ctx.client.fetchAgent({ agentId: created.id });
|
||||
expect(afterSend?.agent.archivedAt).toBe(archived.archivedAt);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("refresh_agent auto-unarchives archived agents", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import { mkdtempSync, readdirSync, realpathSync, rmSync } from "node:fs";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import {
|
||||
canRunRealProvider,
|
||||
getRealProviderRuntimeSettings,
|
||||
} from "./real-provider-test-config.js";
|
||||
|
||||
const PROVIDER = "paseo-catalog-drift";
|
||||
const KEPT_MODEL = "kept-model";
|
||||
const RETIRED_MODEL = "retired-model";
|
||||
|
||||
function model(id: string, name: string) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
family: "gemini-flash",
|
||||
attachment: true,
|
||||
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: 1_048_576, output: 65_535 },
|
||||
cost: { input: 0.1, output: 0.4 },
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
doc: "http://127.0.0.1:9",
|
||||
models: Object.fromEntries(modelIds.map((id) => [id, model(id, id)])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Stands in for models.dev, the catalog OpenCode refreshes on its own schedule.
|
||||
async function startCatalogSource() {
|
||||
let body = JSON.stringify(catalog([KEPT_MODEL, RETIRED_MODEL]));
|
||||
let fetches = 0;
|
||||
const server: Server = createServer((request, response) => {
|
||||
if (request.url !== "/api.json") {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
fetches += 1;
|
||||
response.writeHead(200, { "content-type": "application/json" }).end(body);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
fetches: () => fetches,
|
||||
retire: (id: string) => {
|
||||
body = JSON.stringify(
|
||||
catalog([KEPT_MODEL, RETIRED_MODEL].filter((entry) => entry !== id))
|
||||
);
|
||||
},
|
||||
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
describe("daemon E2E (real opencode) - model catalog drift", () => {
|
||||
let canRun = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = await canRunRealProvider("opencode");
|
||||
});
|
||||
|
||||
beforeEach((context) => {
|
||||
if (!canRun) context.skip();
|
||||
});
|
||||
|
||||
test("models listed for OpenCode follow OpenCode's catalog after it changes", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const source = await startCatalogSource();
|
||||
const settings = getRealProviderRuntimeSettings("opencode");
|
||||
const cacheHome = settings.env!.XDG_CACHE_HOME!;
|
||||
const provider = new OpenCodeAgentClient(logger, {
|
||||
...settings,
|
||||
env: {
|
||||
...settings.env,
|
||||
OPENCODE_MODELS_URL: source.url,
|
||||
PASEO_CATALOG_DRIFT_KEY: "test",
|
||||
},
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { opencode: provider },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
});
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-catalog-"))
|
||||
);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
|
||||
const before = await client.listProviderModels("opencode");
|
||||
expect(before.models.map((entry) => entry.id)).toContain(
|
||||
`${PROVIDER}/${RETIRED_MODEL}`
|
||||
);
|
||||
|
||||
// The catalog drops a model and OpenCode's cached copy is renewed, as when
|
||||
// any other OpenCode process refreshes it while Paseo sits idle. The next
|
||||
// OpenCode server Paseo starts loads that catalog.
|
||||
source.retire(RETIRED_MODEL);
|
||||
const cacheDir = path.join(cacheHome, "opencode");
|
||||
for (const file of readdirSync(cacheDir).filter((name) =>
|
||||
name.startsWith("models-")
|
||||
))
|
||||
rmSync(path.join(cacheDir, file), { force: true });
|
||||
|
||||
const fetchesBefore = source.fetches();
|
||||
const agent = await client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "catalog drift",
|
||||
model: `${PROVIDER}/${KEPT_MODEL}`,
|
||||
modeId: "build",
|
||||
});
|
||||
expect(source.fetches()).toBeGreaterThan(fetchesBefore);
|
||||
|
||||
let switchError: unknown = null;
|
||||
try {
|
||||
await client.setAgentModel(agent.id, `${PROVIDER}/${RETIRED_MODEL}`);
|
||||
} catch (error) {
|
||||
switchError = error;
|
||||
}
|
||||
|
||||
const after = await client.listProviderModels("opencode");
|
||||
const listed = after.models.map((entry) => entry.id);
|
||||
// Every model offered in the picker must be one OpenCode accepts.
|
||||
expect({
|
||||
listed: listed.includes(`${PROVIDER}/${RETIRED_MODEL}`),
|
||||
switchError,
|
||||
}).toEqual({
|
||||
listed: false,
|
||||
switchError: expect.anything(),
|
||||
});
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close().catch(() => undefined);
|
||||
await source.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -308,7 +308,6 @@ export const PersistedConfigSchema = z
|
||||
|
||||
providers: ProvidersSchema.optional(),
|
||||
pluginRegistries: PluginRegistriesSchema.optional(),
|
||||
pluginRegistryEnabled: z.boolean().optional(),
|
||||
pluginsEnabled: z.boolean().optional(),
|
||||
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
||||
worktrees: WorktreesConfigSchema.optional(),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -418,7 +419,7 @@ describe("PluginService", () => {
|
||||
await service.stopAllPlugins();
|
||||
}, 20_000);
|
||||
|
||||
it("lists manifest descriptions for running and disabled plugins without hiding malformed entries", async () => {
|
||||
it("lists manifest metadata for running and disabled plugins without hiding malformed entries", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const running = await createPlugin("running", "export default () => () => {};");
|
||||
@@ -426,7 +427,13 @@ describe("PluginService", () => {
|
||||
const malformed = await createPlugin("malformed", "export default () => () => {};");
|
||||
await writeFile(
|
||||
path.join(running, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "running", description: "Runs checks" }),
|
||||
JSON.stringify({
|
||||
id: "running",
|
||||
description: "Runs checks",
|
||||
name: "Checks",
|
||||
icon: "icon.png",
|
||||
media: ["screenshot.png", "https://example.com/demo.mp4"],
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(disabled, "paseo-plugin.json"),
|
||||
@@ -442,16 +449,123 @@ describe("PluginService", () => {
|
||||
await service.start();
|
||||
|
||||
expect(
|
||||
(await service.listPlugins()).map(({ id, description }) => ({ id, description })),
|
||||
(await service.listPlugins()).map(({ id, description, name, icon, media }) => ({
|
||||
id,
|
||||
description,
|
||||
name,
|
||||
icon,
|
||||
media,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "disabled", description: "Waits until enabled" },
|
||||
{ id: "malformed", description: undefined },
|
||||
{ id: "running", description: "Runs checks" },
|
||||
{
|
||||
id: "running",
|
||||
description: "Runs checks",
|
||||
name: "Checks",
|
||||
icon: "icon.png",
|
||||
media: ["screenshot.png", "https://example.com/demo.mp4"],
|
||||
},
|
||||
]);
|
||||
await service.stopAllPlugins();
|
||||
});
|
||||
|
||||
it("prefers an existing directory and installs its selected plugin subdirectory", async () => {
|
||||
it("resolves a bare registry id even when a matching directory exists in the daemon cwd", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
const owner = path.join(process.cwd(), `registry-shadow-${randomUUID()}`);
|
||||
await mkdir(owner);
|
||||
roots.push(home, owner);
|
||||
const source = `${path.basename(owner)}/example`;
|
||||
const repository = await createPlugin("registry-example", "export default () => () => {};\n");
|
||||
await mkdir(path.join(owner, "example"));
|
||||
await cp(repository, path.join(owner, "example"), { recursive: true });
|
||||
await runGitCommand(["init", "-b", "main"], { cwd: repository });
|
||||
await runGitCommand(["add", "-A"], { cwd: repository });
|
||||
await runGitCommand(
|
||||
[
|
||||
"-c",
|
||||
"user.name=Paseo Tests",
|
||||
"-c",
|
||||
"user.email=paseo@example.test",
|
||||
"commit",
|
||||
"-m",
|
||||
"fixture",
|
||||
],
|
||||
{ cwd: repository },
|
||||
);
|
||||
const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository });
|
||||
const requests: Array<string | undefined> = [];
|
||||
const server = createServer((request, response) => {
|
||||
requests.push(request.url);
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
id: source,
|
||||
name: "Example",
|
||||
description: "Test",
|
||||
categories: [],
|
||||
author: { github: path.basename(owner) },
|
||||
repository: { url: "https://github.com/fixture/example" },
|
||||
artifact: {
|
||||
kind: "git",
|
||||
remote: pathToFileURL(repository).href,
|
||||
commit: stdout.trim(),
|
||||
pluginPath: ".",
|
||||
},
|
||||
media: [],
|
||||
submittedAt: "2026-10-03",
|
||||
reviewedAt: "2026-10-03",
|
||||
updatedAt: "2026-10-03",
|
||||
publishedAt: "2026-10-03",
|
||||
readme: "# Test",
|
||||
}),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing registry address");
|
||||
const url = `http://127.0.0.1:${address.port}`;
|
||||
const store = createStore(home);
|
||||
store.patch({ pluginsEnabled: false });
|
||||
const service = bindTestSessionHost(
|
||||
new PluginService(pino({ level: "silent" }), store, "0.4.0", {
|
||||
managedSources: new ManagedPluginSources(home, { defaultUrl: url }),
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await service.start();
|
||||
await expect(service.installSource({ source })).resolves.toMatchObject({
|
||||
id: "registry-example",
|
||||
status: "disabled",
|
||||
installation: {
|
||||
identity: { kind: "git", registry: { url, id: source } },
|
||||
currentRevision: stdout.trim(),
|
||||
},
|
||||
});
|
||||
expect(requests).toEqual([`/plugins/${source}.json`]);
|
||||
await expect(
|
||||
service.installSource({ source: `./${source}`, id: "local-example" }),
|
||||
).resolves.toMatchObject({
|
||||
installation: { identity: { kind: "directory", path: path.join(owner, "example") } },
|
||||
});
|
||||
expect(requests).toEqual([`/plugins/${source}.json`]);
|
||||
} finally {
|
||||
await service.stopAllPlugins();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("names a missing explicit directory instead of trying a managed source", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const directory = path.join(home, "missing");
|
||||
const service = createService(home);
|
||||
await expect(service.installSource({ source: directory })).rejects.toThrow(
|
||||
`Plugin directory does not exist: ${directory}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("installs an explicit directory and its selected plugin subdirectory", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const repository = await mkdtemp(path.join(tmpdir(), "owner-repository-"));
|
||||
|
||||
@@ -21,6 +21,7 @@ import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
|
||||
import type { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js";
|
||||
import { readPluginManifest } from "./manifest.js";
|
||||
import { expandTilde } from "../../utils/path.js";
|
||||
import { runPluginBuild } from "./preparation.js";
|
||||
import { PluginRuntime } from "./runtime.js";
|
||||
import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js";
|
||||
@@ -215,7 +216,12 @@ export class PluginService {
|
||||
}),
|
||||
};
|
||||
const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null);
|
||||
if (manifest?.description) item.description = manifest.description;
|
||||
if (manifest) {
|
||||
item.name = manifest.name;
|
||||
item.description = manifest.description;
|
||||
item.icon = manifest.icon;
|
||||
item.media = manifest.media;
|
||||
}
|
||||
item.installation = await this.managedSources
|
||||
?.describe(id, source.path)
|
||||
.catch(() => undefined);
|
||||
@@ -286,16 +292,11 @@ export class PluginService {
|
||||
if (input.id && this.builtinPluginIds.has(input.id)) {
|
||||
throw new Error(`Plugin ID "${input.id}" is reserved for a built-in plugin`);
|
||||
}
|
||||
const directDirectory = path.resolve(input.source);
|
||||
const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source);
|
||||
const directInfo = await stat(directDirectory).catch(() => null);
|
||||
const reference = directInfo?.isDirectory()
|
||||
? { source: input.source, pluginPath: undefined }
|
||||
: parsePluginSourceReference(input.source);
|
||||
const directory = path.resolve(reference.source);
|
||||
let info = directInfo;
|
||||
if (!info?.isDirectory() && !explicit) info = await stat(directory).catch(() => null);
|
||||
if (info?.isDirectory()) {
|
||||
const reference = parsePluginSourceReference(input.source);
|
||||
if (reference.kind === "directory") {
|
||||
const directory = path.resolve(expandTilde(reference.source));
|
||||
const info = await stat(directory).catch(() => null);
|
||||
if (!info?.isDirectory()) throw new Error(`Plugin directory does not exist: ${directory}`);
|
||||
if (input.ref) throw new Error("Plugin --ref is only valid for Git sources");
|
||||
const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath);
|
||||
return this.installDirectory({ path: pluginDirectory, id: input.id });
|
||||
|
||||
@@ -280,7 +280,60 @@ describe("managed Git plugin sources", () => {
|
||||
});
|
||||
|
||||
describe("registry plugin sources", () => {
|
||||
it("treats owner/repository as GitHub shorthand without contacting the registry while registry installs are off", async () => {
|
||||
it("explains how to choose a revision when --ref is used with a registry id", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-ref-home-"));
|
||||
roots.push(home);
|
||||
const sources = new ManagedPluginSources(home);
|
||||
await expect(
|
||||
sources.prepareInstall({ source: "fixture/example", ref: "main" }),
|
||||
).rejects.toThrow(
|
||||
"Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the registry URL and recovery steps when the registry fetch fails", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-offline-home-"));
|
||||
roots.push(home);
|
||||
const server = createServer((request) => request.socket.destroy());
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing registry address");
|
||||
const url = `http://127.0.0.1:${address.port}`;
|
||||
try {
|
||||
const sources = new ManagedPluginSources(home, { defaultUrl: url });
|
||||
await expect(sources.prepareInstall({ source: "fixture/example" })).rejects.toThrow(
|
||||
`Could not reach plugin registry ${url}/plugins/fixture/example.json. Plugin fixture/example was not installed. Retry or use an explicit npm: or github: source.`,
|
||||
);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("explains explicit local and GitHub sources when a registry ID is missing", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-missing-home-"));
|
||||
roots.push(home);
|
||||
const requests: Array<string | undefined> = [];
|
||||
const server = createServer((request, response) => {
|
||||
requests.push(request.url);
|
||||
response.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing registry address");
|
||||
try {
|
||||
const sources = new ManagedPluginSources(home, {
|
||||
defaultUrl: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
await expect(sources.prepareInstall({ source: "fixture/missing" })).rejects.toThrow(
|
||||
"If you intended a local directory, use ./fixture/missing. If you intended a GitHub source, use git:fixture/missing, or a full Git URL for another Git host.",
|
||||
);
|
||||
expect(requests).toEqual(["/plugins/fixture/missing.json"]);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps explicit Git sources and persisted Git installs independent of the registry", async () => {
|
||||
const repository = await createRepository();
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-off-home-"));
|
||||
roots.push(home);
|
||||
@@ -298,12 +351,34 @@ describe("registry plugin sources", () => {
|
||||
const sources = new ManagedPluginSources(home, {
|
||||
defaultUrl: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
const candidate = await sources.prepareInstall({ source: "fixture/repository" });
|
||||
expect(candidate.record).toEqual({
|
||||
kind: "git",
|
||||
remote: "https://github.com/fixture/repository.git",
|
||||
});
|
||||
await sources.discard(candidate);
|
||||
for (const source of [
|
||||
"git:fixture/repository",
|
||||
"https://github.com/fixture/repository.git",
|
||||
]) {
|
||||
const candidate = await sources.place(
|
||||
"managed-example",
|
||||
await sources.prepareInstall({ source }),
|
||||
);
|
||||
expect(candidate.record).toEqual({
|
||||
kind: "git",
|
||||
remote: "https://github.com/fixture/repository.git",
|
||||
});
|
||||
sources.commit("managed-example", candidate.record);
|
||||
const restarted = new ManagedPluginSources(home, {
|
||||
defaultUrl: `http://127.0.0.1:${address.port}/changed`,
|
||||
});
|
||||
expect((await restarted.preview("managed-example", candidate.directory)).outcome).toBe(
|
||||
"current",
|
||||
);
|
||||
expect(
|
||||
(await restarted.describe("managed-example", candidate.directory)).identity,
|
||||
).toEqual({
|
||||
kind: "git",
|
||||
remote: "https://github.com/fixture/repository.git",
|
||||
pluginPath: ".",
|
||||
});
|
||||
await sources.discard(candidate);
|
||||
}
|
||||
});
|
||||
expect(requests).toEqual([]);
|
||||
} finally {
|
||||
@@ -332,12 +407,17 @@ describe("registry plugin sources", () => {
|
||||
intent: string | string[] | undefined;
|
||||
authorization: string | undefined;
|
||||
}> = [];
|
||||
let missing = false;
|
||||
const server = createServer((req, res) => {
|
||||
requests.push({
|
||||
path: req.url,
|
||||
intent: req.headers["x-paseo-install"],
|
||||
authorization: req.headers.authorization,
|
||||
});
|
||||
if (missing) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
@@ -353,7 +433,7 @@ describe("registry plugin sources", () => {
|
||||
commit,
|
||||
pluginPath: "packages/example",
|
||||
},
|
||||
screenshots: [],
|
||||
media: [],
|
||||
submittedAt: "2026-10-03",
|
||||
reviewedAt: "2026-10-03",
|
||||
updatedAt: "2026-10-03",
|
||||
@@ -369,7 +449,6 @@ describe("registry plugin sources", () => {
|
||||
const url = `http://${host}/internal`;
|
||||
try {
|
||||
const sources = new ManagedPluginSources(home, {
|
||||
enabled: true,
|
||||
defaultUrl: url,
|
||||
registries: { [host]: { authorization: "Bearer test" } },
|
||||
});
|
||||
@@ -380,7 +459,11 @@ describe("registry plugin sources", () => {
|
||||
).toBe("managed-example");
|
||||
await sources.verifyCandidate("managed-example", candidate);
|
||||
sources.commit("managed-example", candidate.record);
|
||||
const preview = await sources.preview("managed-example", candidate.directory);
|
||||
const restarted = new ManagedPluginSources(home, {
|
||||
defaultUrl: "http://127.0.0.1:1/changed",
|
||||
registries: { [host]: { authorization: "Bearer test" } },
|
||||
});
|
||||
const preview = await restarted.preview("managed-example", candidate.directory);
|
||||
expect(preview.outcome).toBe("current");
|
||||
expect(preview.current?.identity).toEqual({
|
||||
kind: "git",
|
||||
@@ -388,6 +471,10 @@ describe("registry plugin sources", () => {
|
||||
pluginPath: "packages/example",
|
||||
registry: { url, id: "acme/example" },
|
||||
});
|
||||
missing = true;
|
||||
await expect(restarted.preview("managed-example", candidate.directory)).rejects.toThrow(
|
||||
"Check that the installed plugin is still published in this registry before updating",
|
||||
);
|
||||
expect(requests).toEqual([
|
||||
{ path: "/internal/plugins/acme/example.json", intent: "1", authorization: "Bearer test" },
|
||||
{
|
||||
@@ -395,6 +482,11 @@ describe("registry plugin sources", () => {
|
||||
intent: undefined,
|
||||
authorization: "Bearer test",
|
||||
},
|
||||
{
|
||||
path: "/internal/plugins/acme/example.json",
|
||||
intent: undefined,
|
||||
authorization: "Bearer test",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
@@ -424,27 +516,17 @@ it("installs and updates only the npm artifacts pinned by the plugin registry",
|
||||
const server = createServer((request, response) => {
|
||||
intents.push(request.headers["x-paseo-install"]);
|
||||
response.end(
|
||||
// Install reads only the ID and artifact, so the document omits the directory's display fields.
|
||||
JSON.stringify({
|
||||
id: "acme/example",
|
||||
name: "Example",
|
||||
description: "Test",
|
||||
categories: [],
|
||||
author: { github: "acme" },
|
||||
repository: { url: "https://github.com/acme/example" },
|
||||
artifact: { kind: "npm", package: "paseo-fixture-plugin", ...pin },
|
||||
screenshots: [],
|
||||
submittedAt: "2026-10-03",
|
||||
reviewedAt: "2026-10-03",
|
||||
updatedAt: "2026-10-03",
|
||||
publishedAt: "2026-10-03",
|
||||
readme: "# Example",
|
||||
}),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing registry address");
|
||||
const options = { enabled: true, defaultUrl: `http://127.0.0.1:${address.port}` };
|
||||
const options = { defaultUrl: `http://127.0.0.1:${address.port}` };
|
||||
try {
|
||||
const sources = new ManagedPluginSources(home, options);
|
||||
const candidate = await sources.place(
|
||||
|
||||
@@ -147,11 +147,13 @@ export class ManagedPluginSources {
|
||||
input: InstallInput,
|
||||
target?: PluginUpdateTarget,
|
||||
): Promise<ManagedPluginCandidate> {
|
||||
const registry = this.registryOptions.enabled
|
||||
? parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl)
|
||||
: null;
|
||||
const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl);
|
||||
if (registry) {
|
||||
if (input.ref || input.pluginPath)
|
||||
if (input.ref)
|
||||
throw new Error(
|
||||
"Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.",
|
||||
);
|
||||
if (input.pluginPath)
|
||||
throw new Error("Registry sources use their reviewed artifact path and revision");
|
||||
const resolved = await resolveRegistryPlugin(registry, this.registryOptions, true);
|
||||
const candidate = await this.prepareInstall(resolved.input, resolved.target);
|
||||
@@ -175,7 +177,7 @@ export class ManagedPluginSources {
|
||||
sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName);
|
||||
record = { kind: "npm" };
|
||||
} else {
|
||||
const remote = normalizeGitSource(input.source, !this.registryOptions.enabled);
|
||||
const remote = normalizeGitSource(input.source);
|
||||
sourceRoot = path.join(versionRoot, "checkout");
|
||||
await clone(remote, sourceRoot);
|
||||
const commit = await resolveRequestedRef(sourceRoot, input.ref);
|
||||
@@ -227,7 +229,7 @@ export class ManagedPluginSources {
|
||||
const location = this.locate(pluginId, configuredPath);
|
||||
let target: PluginUpdateTarget;
|
||||
let outcome: PluginUpdatePreview["outcome"];
|
||||
if (this.registryOptions.enabled && current.identity.registry) {
|
||||
if (current.identity.registry) {
|
||||
if (selection) throw new Error("Registry updates use only the reviewed registry pin");
|
||||
const resolved = await resolveRegistryPlugin(
|
||||
current.identity.registry,
|
||||
@@ -304,7 +306,7 @@ export class ManagedPluginSources {
|
||||
await this.assertCurrent(proposal, configuredPath);
|
||||
const identity = this.locate(proposal.id, configuredPath).identity;
|
||||
const record = this.records[proposal.id];
|
||||
if (this.registryOptions.enabled && identity.kind !== "directory" && identity.registry) {
|
||||
if (identity.kind !== "directory" && identity.registry) {
|
||||
const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false);
|
||||
assertRegistryArtifactIdentity(identity, resolved.artifact);
|
||||
if (!isDeepStrictEqual(resolved.target, proposal.target))
|
||||
@@ -436,10 +438,9 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s
|
||||
}
|
||||
return [];
|
||||
}
|
||||
/** Bare owner/repo is GitHub shorthand unless registry references own that syntax. */
|
||||
function normalizeGitSource(source: string, bareGitHubShorthand: boolean): string {
|
||||
const expandsShorthand =
|
||||
bareGitHubShorthand || source.startsWith("github:") || source.startsWith("git:");
|
||||
/** Only explicit Git prefixes expand GitHub shorthand. */
|
||||
function normalizeGitSource(source: string): string {
|
||||
const expandsShorthand = source.startsWith("github:") || source.startsWith("git:");
|
||||
if (source.startsWith("github:")) {
|
||||
const shorthand = source.slice(7);
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import {
|
||||
PublishedPluginDetailSchema,
|
||||
PluginRegistryArtifactSchema,
|
||||
PluginRegistryIdSchema,
|
||||
type PluginRegistries,
|
||||
type PluginRegistryIdentity,
|
||||
} from "@getpaseo/protocol/plugin-registry";
|
||||
import type { PluginUpdateTarget } from "@getpaseo/protocol/messages";
|
||||
import { z } from "zod";
|
||||
|
||||
// Install reads only these fields; the rest of a published plugin is the directory's display data.
|
||||
const RegistryInstallDocumentSchema = z.object({
|
||||
id: PluginRegistryIdSchema,
|
||||
artifact: PluginRegistryArtifactSchema,
|
||||
});
|
||||
|
||||
export interface RegistryOptions {
|
||||
/** Off: bare owner/repo is GitHub shorthand and no registry is contacted. */
|
||||
enabled?: boolean;
|
||||
defaultUrl?: string;
|
||||
registries?: PluginRegistries;
|
||||
}
|
||||
@@ -29,14 +35,29 @@ export async function resolveRegistryPlugin(
|
||||
const headers: Record<string, string> = {};
|
||||
if (authorization) headers.Authorization = authorization;
|
||||
if (install) headers["X-Paseo-Install"] = "1";
|
||||
const response = await fetch(`${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`, {
|
||||
headers,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
const url = `${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
} catch (cause) {
|
||||
const guidance = install
|
||||
? `Plugin ${identity.id} was not installed. Retry or use an explicit npm: or github: source.`
|
||||
: `Could not check updates for ${identity.id}. Retry later.`;
|
||||
throw new Error(`Could not reach plugin registry ${url}. ${guidance}`, { cause });
|
||||
}
|
||||
if (response.status === 404) {
|
||||
const guidance = install
|
||||
? `If you intended a local directory, use ./${identity.id}. If you intended a GitHub source, use git:${identity.id}, or a full Git URL for another Git host.`
|
||||
: "Check that the installed plugin is still published in this registry before updating.";
|
||||
throw new Error(`Plugin ${identity.id} was not found in registry ${base.host}. ${guidance}`);
|
||||
}
|
||||
if (!response.ok)
|
||||
throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`);
|
||||
const plugin = PublishedPluginDetailSchema.parse(await response.json());
|
||||
const plugin = RegistryInstallDocumentSchema.parse(await response.json());
|
||||
if (plugin.id !== identity.id) throw new Error("Registry returned a different plugin ID");
|
||||
const artifact = plugin.artifact;
|
||||
const target: PluginUpdateTarget =
|
||||
|
||||
@@ -24,6 +24,69 @@ describe("plugin manifest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reads display metadata and ignores future top-level fields", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
const manifest = {
|
||||
id: "review",
|
||||
name: "Review tools",
|
||||
icon: "assets/icon.png",
|
||||
media: ["assets/screenshot.webp", "demo.mp4", "https://example.com/media?id=1"],
|
||||
};
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ ...manifest, futureField: { enabled: true } }),
|
||||
);
|
||||
await expect(readPluginManifest(directory)).resolves.toEqual(manifest);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: " " },
|
||||
{ name: 42 },
|
||||
{ icon: "" },
|
||||
{ icon: 42 },
|
||||
{ icon: "icon.svg" },
|
||||
{ icon: "/icon.png" },
|
||||
{ icon: "../icon.png" },
|
||||
{ icon: "assets/../../icon.png" },
|
||||
{ icon: "https://example.com/icon.png" },
|
||||
{ icon: "C:/icon.png" },
|
||||
{ icon: "assets\\icon.png" },
|
||||
{ icon: "icon.png?query" },
|
||||
{ media: "screenshot.png" },
|
||||
{ media: [42] },
|
||||
{ media: [""] },
|
||||
{ media: ["../demo.mp4"] },
|
||||
{ media: ["/demo.mp4"] },
|
||||
{ media: ["http://example.com/demo.mp4"] },
|
||||
{ media: ["https://"] },
|
||||
{ media: ["//example.com/demo.mp4"] },
|
||||
{ media: ["file:///demo.mp4"] },
|
||||
{ media: ["data:image/png;base64,abc"] },
|
||||
{ media: ["."] },
|
||||
{ media: ["assets/"] },
|
||||
{ id: "INVALID" },
|
||||
{ description: 42 },
|
||||
{ requirements: { paseoo: ">=0.11.0" } },
|
||||
])("rejects invalid known fields: %j", async (fields) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "example", futureField: true, ...fields }),
|
||||
);
|
||||
await expect(readPluginManifest(directory)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("accepts omitted metadata and empty media", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
for (const manifest of [{ id: "example" }, { id: "example", media: [] }]) {
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify(manifest));
|
||||
await expect(readPluginManifest(directory)).resolves.toEqual(manifest);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads and validates requirements before any plugin code runs", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
|
||||
@@ -8,14 +8,33 @@ const MANIFEST_FILENAME = "paseo-plugin.json";
|
||||
const PluginBuildCommandSchema = z
|
||||
.array(z.string().refine((argument) => argument.trim().length > 0))
|
||||
.min(1);
|
||||
const PluginManifestSchema = z
|
||||
.object({
|
||||
id: PluginIdSchema,
|
||||
description: z.string().trim().min(1).optional(),
|
||||
requirements: PluginRequirementsSchema.strict().optional(),
|
||||
build: z.array(PluginBuildCommandSchema).min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
// Manifest asset paths use forward slashes and stay inside the plugin package.
|
||||
const PluginAssetPathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) =>
|
||||
value === value.trim() &&
|
||||
!value.startsWith("/") &&
|
||||
!/[\\:?#\p{Cc}]/u.test(value) &&
|
||||
!value.split("/").includes("..") &&
|
||||
!value.endsWith("/") &&
|
||||
value.split("/").at(-1) !== ".",
|
||||
"Expected a relative file path inside the plugin package",
|
||||
);
|
||||
const PluginManifestSchema = z.object({
|
||||
id: PluginIdSchema,
|
||||
name: z.string().trim().min(1).optional(),
|
||||
description: z.string().trim().min(1).optional(),
|
||||
icon: PluginAssetPathSchema.refine(
|
||||
(value) => /\.png$/i.test(value),
|
||||
"Expected a relative path to a PNG",
|
||||
).optional(),
|
||||
media: z.array(z.union([PluginAssetPathSchema, z.url({ protocol: /^https$/ })])).optional(),
|
||||
// A misspelled requirement must not silently disable compatibility checks.
|
||||
requirements: PluginRequirementsSchema.strict().optional(),
|
||||
build: z.array(PluginBuildCommandSchema).min(1).optional(),
|
||||
});
|
||||
|
||||
export type PluginManifest = z.infer<typeof PluginManifestSchema>;
|
||||
|
||||
|
||||
@@ -510,9 +510,7 @@ it("starts the default shell through the worker and accepts quoted commands", as
|
||||
|
||||
session.send({ type: "input", data: `${command}\r` });
|
||||
|
||||
await waitForCondition(() => existsSync(markerPath), 10000);
|
||||
|
||||
expect(readFileSync(markerPath, "utf8")).toBe("shell-ok");
|
||||
await expect.poll(() => readFileSync(markerPath, "utf8"), { timeout: 10000 }).toBe("shell-ok");
|
||||
});
|
||||
|
||||
it("lists subdirectory terminals when querying the workspace root", async () => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Paseo website
|
||||
|
||||
## Plugin install counts
|
||||
|
||||
Clients report an install by resolving a plugin with `X-Paseo-Install: 1` or
|
||||
`?intent=install`, using `/api/plugins/resolve/<owner>/<slug>` or
|
||||
`/plugins/<owner>/<slug>.json`. Ordinary resolves do not count. These numbers
|
||||
measure install intent, not successful artifact installation or npm downloads.
|
||||
|
||||
The worker requires Cloudflare's `CF-Connecting-IP` header to count a report.
|
||||
It stores the plugin ID plus a SHA-256 hash of the IP in a `WEBSITE_CACHE` KV key
|
||||
with a one hour TTL, never the raw IP. An existing key skips the count; repeats
|
||||
do not extend the hour. Missing or empty IP headers do not count.
|
||||
|
||||
Accepted reports increment the existing all-time total and UTC daily buckets in
|
||||
`WEBSITE_CACHE`. Daily buckets retain 60 days and supply the website's seven-day
|
||||
and thirty-day rankings. `/api/plugins/installs` still returns plugin IDs mapped
|
||||
to all-time totals. The [registry build](https://github.com/getpaseo/plugins/blob/main/scripts/build.ts)
|
||||
reads that endpoint into the published plugin details and index; the website
|
||||
reads windowed counts from KV through its five-minute cache. Existing counts
|
||||
remain intact. The KV guard is advisory: concurrent requests and KV propagation
|
||||
are not serialized.
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "security/overview",
|
||||
"name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
|
||||
"description": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
|
||||
"artifact": {
|
||||
"kind": "npm",
|
||||
"package": "@alhassanaraouf/paseo-base2tone-theme",
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz",
|
||||
"integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg=="
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "javascript:alert(1)"
|
||||
},
|
||||
"categories": ["themes"],
|
||||
"author": {
|
||||
"github": "security",
|
||||
"name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">"
|
||||
},
|
||||
"media": [
|
||||
"data:image/svg+xml,<svg onload=\"globalThis.__overviewExecuted=1\"/>",
|
||||
"http://example.com/image.png"
|
||||
],
|
||||
"submittedAt": "2026-09-24T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-24T17:12:05.467Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "## Overview security\n\n</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
|
||||
"icon": "data:image/png;base64,AAAA"
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { expect, test, type Page } from "playwright/test";
|
||||
import securityPlugin from "./overview.fixture.json" with { type: "json" };
|
||||
import { expect, test, type Page, type Locator } from "playwright/test";
|
||||
import registry from "./registry.fixture.json" with { type: "json" };
|
||||
import { CATEGORIES } from "../src/plugins/categories";
|
||||
|
||||
async function openPlugins(page: Page) {
|
||||
@@ -6,6 +8,13 @@ async function openPlugins(page: Page) {
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
|
||||
}
|
||||
|
||||
function pluginCards(page: Page, section: string): Locator {
|
||||
return page
|
||||
.getByRole("region", { name: section })
|
||||
.getByRole("link")
|
||||
.filter({ hasNotText: /^See all$/ });
|
||||
}
|
||||
|
||||
test("browses from the directory into a category, a plugin, and its author", async ({
|
||||
page,
|
||||
context,
|
||||
@@ -34,7 +43,7 @@ test("browses from the directory into a category, a plugin, and its author", asy
|
||||
await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1);
|
||||
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1);
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }),
|
||||
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Copy to clipboard" }).click();
|
||||
await expect
|
||||
@@ -78,7 +87,7 @@ test("filters by search and clears to all plugins", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await openPlugins(page);
|
||||
|
||||
await searchPlugins(page, "graphite");
|
||||
await submitSearch(page, "graphite");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/);
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }),
|
||||
@@ -96,7 +105,7 @@ test("filters by search and clears to all plugins", async ({ page }) => {
|
||||
|
||||
test("keeps the directory's ranking window when searching", async ({ page }) => {
|
||||
await page.goto("/plugins?window=month");
|
||||
await searchPlugins(page, "graphite");
|
||||
await submitSearch(page, "graphite");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/);
|
||||
await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
@@ -120,13 +129,52 @@ test("clears the search with the clear button", async ({ page }) => {
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("replaces history while typing a search", async ({ page }) => {
|
||||
test("searches from the directory on submit, and Back returns to the directory", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await openPlugins(page);
|
||||
const entries = await historyLength(page);
|
||||
|
||||
await typeSearch(page, "gra");
|
||||
await expect(page).toHaveURL(/\/plugins$/);
|
||||
expect(await historyLength(page)).toBe(entries);
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible();
|
||||
expect(await historyLength(page)).toBe(entries + 1);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/plugins$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("filters browse results as you type in one history entry", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.goto("/plugins/all");
|
||||
await searchPlugins(page, "graphite");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/);
|
||||
const entries = await historyLength(page);
|
||||
|
||||
await typeSearch(page, "gra");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible();
|
||||
await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0);
|
||||
expect(await historyLength(page)).toBe(entries + 1);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page).toHaveURL(/\/plugins\/all$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
|
||||
await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("");
|
||||
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible();
|
||||
await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra");
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible();
|
||||
await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra");
|
||||
});
|
||||
|
||||
test("keeps old category links working", async ({ page }) => {
|
||||
@@ -167,7 +215,7 @@ test.describe("search engine visits without JavaScript", () => {
|
||||
expect(response?.headers()["x-robots-tag"]).toBeUndefined();
|
||||
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }),
|
||||
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
|
||||
).toBeVisible();
|
||||
await expectPageMetadata(
|
||||
page,
|
||||
@@ -260,6 +308,19 @@ async function searchPlugins(page: Page, term: string) {
|
||||
await page.getByRole("searchbox", { name: "Search plugins" }).fill(term);
|
||||
}
|
||||
|
||||
async function submitSearch(page: Page, term: string) {
|
||||
await searchPlugins(page, term);
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
async function typeSearch(page: Page, term: string) {
|
||||
await page.getByRole("searchbox", { name: "Search plugins" }).pressSequentially(term);
|
||||
}
|
||||
|
||||
async function historyLength(page: Page): Promise<number> {
|
||||
return page.evaluate(() => window.history.length);
|
||||
}
|
||||
|
||||
async function expectPageMetadata(page: Page, title: string, path: string) {
|
||||
await expect(page).toHaveTitle(title);
|
||||
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
|
||||
@@ -306,6 +367,157 @@ test("keeps the directory unlinked until the coordinated announcement", async ({
|
||||
test.describe("registry fixture layout", () => {
|
||||
test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture");
|
||||
|
||||
test("uses density-aware card thumbnails and keeps detail screenshots original", async ({
|
||||
page,
|
||||
}) => {
|
||||
const plugin = registry.plugins.find((entry) => entry.id === "alhassanaraouf/base2tone")!;
|
||||
const source = plugin.media[0];
|
||||
await openPlugins(page);
|
||||
await expectThumbnailCard(
|
||||
page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Base2Tone/ }),
|
||||
source,
|
||||
plugin.id,
|
||||
);
|
||||
await page.goto("/plugins/all");
|
||||
const card = page.getByRole("main").getByRole("link", { name: /Base2Tone/ });
|
||||
await expectThumbnailCard(card, source, plugin.id);
|
||||
await card.click();
|
||||
await expect(
|
||||
page.getByRole("img", { name: "Base2Tone screenshot 1", exact: true }),
|
||||
).toHaveAttribute("src", source);
|
||||
});
|
||||
|
||||
test("shows images and videos as gallery tiles in registry order and opens each in the viewer", async ({
|
||||
page,
|
||||
}) => {
|
||||
const plugin = registry.plugins.find((entry) => entry.id === "omercnet/dracula")!;
|
||||
const [firstImage, video] = plugin.media;
|
||||
await page.goto(`/plugins/${plugin.id}`);
|
||||
const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ });
|
||||
await expect(tiles).toHaveCount(3);
|
||||
const layout = await tiles.evaluateAll(galleryTiles);
|
||||
expect(layout.map((tile) => tile.href)).toEqual(plugin.media);
|
||||
expect(new Set(layout.map((tile) => tile.size)).size).toBe(1);
|
||||
const preview = tiles.nth(1).locator("video");
|
||||
await expect(preview).toHaveAttribute("preload", "metadata");
|
||||
await expect(preview).not.toHaveAttribute("controls");
|
||||
await expect(preview).not.toHaveAttribute("autoplay");
|
||||
expect(await preview.evaluate((element: HTMLVideoElement) => element.muted)).toBe(true);
|
||||
|
||||
const viewer = page.getByRole("dialog");
|
||||
await tiles.nth(1).click();
|
||||
const playing = viewer.getByLabel("Dracula video 2");
|
||||
await expect(playing).toHaveAttribute("src", video);
|
||||
await expect(playing).toHaveAttribute("controls", "");
|
||||
await expect.poll(() => playing.evaluate(isPaused)).toBe(false);
|
||||
// The fixture video is 320x240, so it has to scale up to fill the viewer.
|
||||
await expect.poll(() => playing.evaluate(viewerFill)).toBeCloseTo(1, 2);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(viewer).toBeHidden();
|
||||
await expect(page.locator("dialog video")).toHaveCount(0);
|
||||
|
||||
await tiles.first().click();
|
||||
await expect(viewer.getByRole("img", { name: "Dracula screenshot 1" })).toHaveAttribute(
|
||||
"src",
|
||||
firstImage,
|
||||
);
|
||||
await viewer.getByRole("button", { name: "Close" }).click();
|
||||
await expect(viewer).toBeHidden();
|
||||
expect(page.context().pages()).toHaveLength(1);
|
||||
|
||||
const newTab = page.context().waitForEvent("page");
|
||||
await tiles.first().click({ modifiers: ["ControlOrMeta"] });
|
||||
await expect(await newTab).toHaveURL(firstImage);
|
||||
await expect(viewer).toBeHidden();
|
||||
});
|
||||
|
||||
test("peeks the next gallery tile at the strip's edge, and fills the row with two", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Wide screens show two tiles and part of the third; phones show one and part of the second.
|
||||
for (const [width, expected] of [
|
||||
[1280, [1, 1, "peeks"]],
|
||||
[375, [1, "peeks", 0]],
|
||||
] as const) {
|
||||
await page.setViewportSize({ width, height: 720 });
|
||||
await page.goto("/plugins/omercnet/dracula");
|
||||
const three = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ });
|
||||
const shares = await three.evaluateAll(visibleShares);
|
||||
expect(shares.map((share) => (share > 0.1 && share < 0.5 ? "peeks" : share))).toEqual(
|
||||
expected,
|
||||
);
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/plugins/gpambrozio/herald");
|
||||
const two = page.getByRole("link", { name: /^Herald screenshot \d$/ });
|
||||
expect(await two.evaluateAll(visibleShares)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
test("steps through the viewer with arrow keys and buttons, wrapping at the ends", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/plugins/omercnet/dracula");
|
||||
const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ });
|
||||
const viewer = page.getByRole("dialog");
|
||||
const shows = (name: string) =>
|
||||
expect(
|
||||
viewer.getByRole("img", { name, exact: true }).or(viewer.getByLabel(name, { exact: true })),
|
||||
).toBeVisible();
|
||||
|
||||
await tiles.nth(1).click();
|
||||
await expect.poll(() => viewer.getByLabel("Dracula video 2").evaluate(isPaused)).toBe(false);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await shows("Dracula screenshot 3");
|
||||
await expect(page.locator("dialog video")).toHaveCount(0);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await shows("Dracula screenshot 1");
|
||||
await viewer.getByRole("img").click();
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await shows("Dracula screenshot 3");
|
||||
|
||||
await viewer.getByRole("button", { name: "Previous" }).click();
|
||||
await shows("Dracula video 2");
|
||||
await viewer.getByRole("button", { name: "Next" }).click();
|
||||
await shows("Dracula screenshot 3");
|
||||
await viewer.getByRole("button", { name: "Next" }).click();
|
||||
await shows("Dracula screenshot 1");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(viewer).toBeHidden();
|
||||
});
|
||||
|
||||
test("keeps a video that fails to load visible in the viewer", async ({ page }) => {
|
||||
const plugin = registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!;
|
||||
await page.route(plugin.media[0], (route) => route.abort());
|
||||
await page.goto(`/plugins/${plugin.id}`);
|
||||
await page.getByRole("link", { name: "launchd Jobs video 1" }).click();
|
||||
const video = page.getByRole("dialog").getByLabel("launchd Jobs video 1");
|
||||
await expect(video).toBeVisible();
|
||||
await expect(video).toHaveCSS("opacity", "1");
|
||||
await expect(page.getByRole("button", { name: "Next" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shows the plugin tile on cards when its media has no image", async ({ page }) => {
|
||||
await page.goto("/plugins/all");
|
||||
const card = page.getByRole("main").getByRole("link", { name: /launchd Jobs/ });
|
||||
await expect(card.locator("img, video")).toHaveCount(0);
|
||||
await card.click();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"),
|
||||
).toHaveAttribute(
|
||||
"src",
|
||||
registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!.media[0],
|
||||
);
|
||||
});
|
||||
|
||||
test("features listed plugins above What's new in the registry's order", async ({ page }) => {
|
||||
await openPlugins(page);
|
||||
await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured");
|
||||
const featured = page.getByRole("region", { name: "Featured" });
|
||||
await expect(featured).toContainText("A selection of hand picked plugins");
|
||||
await expect(pluginCards(page, "Featured")).toHaveText([/Dracula/, /Herald/, /launchd Jobs/]);
|
||||
});
|
||||
|
||||
test("lists the nine categories in order with counts, and the newest plugins first", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -315,8 +527,132 @@ test.describe("registry fixture layout", () => {
|
||||
CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)),
|
||||
);
|
||||
await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0");
|
||||
await expect(
|
||||
page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Added/ }),
|
||||
).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]);
|
||||
await expect(pluginCards(page, "What’s new")).toHaveText([
|
||||
/Base2Tone/,
|
||||
/Sayr/,
|
||||
/PromptKit/,
|
||||
/Defer/,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function galleryTiles(links: Element[]) {
|
||||
return links.map((link) => ({
|
||||
href: link.getAttribute("href"),
|
||||
size: `${link.clientWidth}x${link.clientHeight}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** How much of each tile's width the gallery strip shows, rounded to hundredths. */
|
||||
function visibleShares(links: Element[]) {
|
||||
const strip = links[0].parentElement!.getBoundingClientRect();
|
||||
return links.map((link) => {
|
||||
const tile = link.getBoundingClientRect();
|
||||
const shown = Math.min(tile.right, strip.right) - Math.max(tile.left, strip.left);
|
||||
return Math.round((Math.max(shown, 0) / tile.width) * 100) / 100;
|
||||
});
|
||||
}
|
||||
|
||||
/** The share of the viewer's room the media fills along its limiting axis; 1 fills it. */
|
||||
function viewerFill(media: HTMLElement) {
|
||||
const box = media.getBoundingClientRect();
|
||||
return Math.max(box.width / (window.innerWidth * 0.9), box.height / (window.innerHeight * 0.85));
|
||||
}
|
||||
|
||||
function isPaused(video: HTMLVideoElement) {
|
||||
return video.paused;
|
||||
}
|
||||
|
||||
async function expectThumbnailCard(card: Locator, source: string, id: string) {
|
||||
// Card screenshots are decorative and hidden from the accessibility tree.
|
||||
const image = card.locator("img").first();
|
||||
const path = (width: number) =>
|
||||
`/plugins/thumb/${width}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`;
|
||||
await expect(image).toHaveAttribute("src", path(592));
|
||||
await expect(image).toHaveAttribute("srcset", `${path(592)} 1x, ${path(1184)} 2x`);
|
||||
await expect(image).toHaveAttribute("loading", "lazy");
|
||||
await expect(image).toHaveAttribute("decoding", "async");
|
||||
await image.scrollIntoViewIfNeeded();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
image.evaluate((element: HTMLImageElement) => element.complete && element.naturalWidth > 0),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
const box = await image.evaluate((element) => {
|
||||
const rect = element.parentElement!.getBoundingClientRect();
|
||||
return { width: rect.width, height: rect.height };
|
||||
});
|
||||
expect(box.width).toBeGreaterThan(0);
|
||||
expect(box.width / box.height).toBeCloseTo(1.6, 2);
|
||||
}
|
||||
|
||||
test("keeps author content inert through SSR and hydration", async ({
|
||||
page,
|
||||
request,
|
||||
}, testInfo) => {
|
||||
await verifyPluginOverviewResponse(request);
|
||||
await openUntrustedPlugin(page);
|
||||
await expectPluginOverviewSafe(page);
|
||||
await page.reload();
|
||||
await expectPluginOverviewSafe(page);
|
||||
await testInfo.attach("plugin-overview", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
async function verifyPluginOverviewResponse(request: import("playwright/test").APIRequestContext) {
|
||||
const response = await request.get("/plugins/security/overview");
|
||||
expect(response.status()).toBe(200);
|
||||
const html = await response.text();
|
||||
expect(html).not.toContain("<script>globalThis.__overviewExecuted=1</script>");
|
||||
expect(html).toContain("\\x3C/script>");
|
||||
}
|
||||
|
||||
async function openUntrustedPlugin(page: Page) {
|
||||
await page.goto("/plugins/security/overview");
|
||||
await expect(page.getByRole("heading", { name: "Overview security", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectPluginOverviewSafe(page: Page) {
|
||||
expect(
|
||||
await page.evaluate(() => (globalThis as { __overviewExecuted?: number }).__overviewExecuted),
|
||||
).toBeUndefined();
|
||||
// Security assertions inspect every emitted attribute, including inaccessible injected elements.
|
||||
const violations = await page.locator(".docs-prose").evaluate((root) => {
|
||||
const bad: string[] = [];
|
||||
for (const element of root.querySelectorAll("*")) {
|
||||
if (
|
||||
["SCRIPT", "IFRAME", "SVG", "FORM", "INPUT", "META", "BASE", "STYLE"].includes(
|
||||
element.tagName,
|
||||
)
|
||||
)
|
||||
bad.push(element.tagName);
|
||||
for (const attr of element.attributes) {
|
||||
if (/^on|^style$|^srcdoc$/i.test(attr.name)) bad.push(attr.name);
|
||||
if (["href", "src"].includes(attr.name) && !attr.value.startsWith("https://"))
|
||||
bad.push(attr.value);
|
||||
}
|
||||
if (
|
||||
element.tagName === "A" &&
|
||||
(element.getAttribute("rel") !== "noopener noreferrer nofollow" ||
|
||||
element.getAttribute("target") !== "_blank")
|
||||
)
|
||||
bad.push("unsafe anchor");
|
||||
}
|
||||
return bad;
|
||||
});
|
||||
expect(violations).toEqual([]);
|
||||
await expect(page.getByRole("link", { name: "Source", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("img", { name: /screenshot/ })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText(securityPlugin.name);
|
||||
await expect(
|
||||
page.getByRole("link", { name: securityPlugin.author.name, exact: true }),
|
||||
).toHaveAttribute("href", "/plugins/security");
|
||||
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
|
||||
"content",
|
||||
securityPlugin.description,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { createServer } from "node:http";
|
||||
import registry from "./registry.fixture.json" with { type: "json" };
|
||||
|
||||
import securityPlugin from "./overview.fixture.json" with { type: "json" };
|
||||
|
||||
import overviewCorpus from "../../protocol/tests/fixtures/plugin-overview.json" with { type: "json" };
|
||||
securityPlugin.readme +=
|
||||
"\n\n" +
|
||||
overviewCorpus
|
||||
.map(({ markdown }) => markdown + "\n\n```html\n" + markdown + "\n```")
|
||||
.join("\n\n");
|
||||
|
||||
// Serve the published registry contract so SSR and browser navigation use the same data.
|
||||
createServer((request, response) => {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
@@ -8,7 +17,9 @@ createServer((request, response) => {
|
||||
response.end(JSON.stringify(registry));
|
||||
return;
|
||||
}
|
||||
const plugin = registry.plugins.find((entry) => request.url === `/plugins/${entry.id}.json`);
|
||||
const plugin = [securityPlugin, ...registry.plugins].find(
|
||||
(entry) => request.url === `/plugins/${entry.id}.json`,
|
||||
);
|
||||
response.statusCode = plugin ? 200 : 404;
|
||||
response.end(JSON.stringify(plugin ?? { error: "Plugin not found" }));
|
||||
}).listen(8188, "127.0.0.1");
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
"url": "https://plugins.paseo.sh"
|
||||
},
|
||||
"generatedAt": "2026-10-04T15:55:47.882Z",
|
||||
"featured": [
|
||||
"omercnet/dracula",
|
||||
"gpambrozio/herald",
|
||||
"acme/not-published",
|
||||
"gpambrozio/launchd-jobs"
|
||||
],
|
||||
"categories": [
|
||||
{
|
||||
"slug": "daemon-management",
|
||||
@@ -74,12 +80,12 @@
|
||||
"name": "alhassanaraouf",
|
||||
"github": "alhassanaraouf"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png"
|
||||
],
|
||||
"submittedAt": "2026-09-24T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-24T17:12:05.467Z",
|
||||
"publishedAt": "2026-09-24T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md."
|
||||
@@ -106,13 +112,13 @@
|
||||
"name": "Tom Lundy",
|
||||
"github": "dorasto"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/sayr-plguin-1.png",
|
||||
"https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png"
|
||||
],
|
||||
"submittedAt": "2026-09-22T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-22T10:39:28.935Z",
|
||||
"publishedAt": "2026-09-22T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks"
|
||||
@@ -139,13 +145,13 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/screenshot.png",
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png"
|
||||
],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-01T17:46:20.500Z",
|
||||
"publishedAt": "2026-09-19T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions"
|
||||
@@ -172,13 +178,13 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/panel.png",
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png"
|
||||
],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-27T19:03:31.943Z",
|
||||
"publishedAt": "2026-09-19T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel"
|
||||
@@ -205,12 +211,12 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/launchd-jobs/docs/screenshot.png"
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.mp4"
|
||||
],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-22T17:56:41.061Z",
|
||||
"publishedAt": "2026-09-19T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel"
|
||||
@@ -237,12 +243,12 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png"
|
||||
],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-22T18:03:14.979Z",
|
||||
"publishedAt": "2026-09-19T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter"
|
||||
@@ -269,10 +275,10 @@
|
||||
"name": "gpambrozio",
|
||||
"github": "gpambrozio"
|
||||
},
|
||||
"screenshots": [],
|
||||
"media": [],
|
||||
"submittedAt": "2026-09-19T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-01T22:25:58.192Z",
|
||||
"publishedAt": "2026-09-19T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it"
|
||||
@@ -298,13 +304,13 @@
|
||||
"name": "hungcuong9125",
|
||||
"github": "hungcuong9125"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-provider-cli.png",
|
||||
"https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png"
|
||||
],
|
||||
"submittedAt": "2026-09-22T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-30T02:57:56.473Z",
|
||||
"publishedAt": "2026-09-22T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer."
|
||||
@@ -331,13 +337,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-roster.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png"
|
||||
],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:35.639Z",
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster."
|
||||
@@ -364,13 +370,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-wide.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png"
|
||||
],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:37:29.149Z",
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace."
|
||||
@@ -397,13 +403,14 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot.png",
|
||||
"https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.webm",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png"
|
||||
],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:37:18.185Z",
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo."
|
||||
@@ -430,13 +437,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png"
|
||||
],
|
||||
"submittedAt": "2026-09-18T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:06.720Z",
|
||||
"publishedAt": "2026-09-18T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees"
|
||||
@@ -463,13 +470,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/plugin-manager.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png"
|
||||
],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:36.267Z",
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling."
|
||||
@@ -496,10 +503,10 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [],
|
||||
"media": [],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:37:04.327Z",
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces."
|
||||
@@ -526,13 +533,13 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-wide.png",
|
||||
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png"
|
||||
],
|
||||
"submittedAt": "2026-09-18T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:38:48.289Z",
|
||||
"publishedAt": "2026-09-18T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client."
|
||||
@@ -559,10 +566,10 @@
|
||||
"name": "Omer Cohen",
|
||||
"github": "omercnet"
|
||||
},
|
||||
"screenshots": [],
|
||||
"media": [],
|
||||
"submittedAt": "2026-09-17T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-10-03T09:36:15.719Z",
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host."
|
||||
@@ -588,12 +595,12 @@
|
||||
"name": "Tom Gringauz",
|
||||
"github": "tomgrin10"
|
||||
},
|
||||
"screenshots": [
|
||||
"media": [
|
||||
"https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png"
|
||||
],
|
||||
"submittedAt": "2026-09-22T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-27T09:59:16.553Z",
|
||||
"publishedAt": "2026-09-22T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset."
|
||||
@@ -619,10 +626,10 @@
|
||||
"name": "Tom Gringauz",
|
||||
"github": "tomgrin10"
|
||||
},
|
||||
"screenshots": [],
|
||||
"media": [],
|
||||
"submittedAt": "2026-09-22T00:00:00.000Z",
|
||||
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||
"publishedAt": "2026-09-23T10:36:50.112Z",
|
||||
"publishedAt": "2026-09-22T00:00:00.000Z",
|
||||
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo."
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"parse5": "^7.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
|
||||
@@ -18,7 +18,7 @@ export function SiteShell({ children, width }: SiteShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<main className={mainClasses}>
|
||||
<div className="mb-12">
|
||||
<div className="mb-20">
|
||||
<SiteHeader />
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type ReactNode, useMemo } from "react";
|
||||
import { type Author, authorAvatarUrl } from "./registry";
|
||||
|
||||
const SIZE_CLASS = {
|
||||
xs: "h-4 w-4 text-[8px]",
|
||||
sm: "h-5 w-5 text-[10px]",
|
||||
lg: "h-14 w-14 text-xl",
|
||||
} as const;
|
||||
|
||||
@@ -3,8 +3,10 @@ import { SiteShell } from "~/components/site-shell";
|
||||
import { ContributeLinks } from "./contribute-links";
|
||||
import type { InstallCounts } from "./installs";
|
||||
import { type BrowseQuery, browseHref } from "./links";
|
||||
import { InstallCount } from "./install-count";
|
||||
import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card";
|
||||
import { PluginSearch } from "./plugin-search";
|
||||
import { PluginSection } from "./section";
|
||||
import {
|
||||
addedAgo,
|
||||
CATEGORIES,
|
||||
@@ -45,17 +47,18 @@ export function BrowsePage({
|
||||
query: BrowseQuery;
|
||||
}) {
|
||||
const category = query.category ? getCategory(query.category) : null;
|
||||
const matches = query.q ? searchPlugins(plugins, query.q) : plugins;
|
||||
const scoped = category ? getPluginsInCategory(matches, category.slug) : matches;
|
||||
const results =
|
||||
query.sort === "new" ? newestFirst(scoped) : mostInstalled(scoped, installs, query.window);
|
||||
const ordered =
|
||||
query.sort === "new" ? newestFirst(plugins) : mostInstalled(plugins, installs, query.window);
|
||||
// Search ranks by relevance; the tab's order breaks ties.
|
||||
const matches = query.q ? searchPlugins(ordered, query.q) : ordered;
|
||||
const results = category ? getPluginsInCategory(matches, category.slug) : matches;
|
||||
const title = category?.label ?? "All plugins";
|
||||
const clearHref = browseHref({ ...query, q: undefined });
|
||||
return (
|
||||
<SiteShell width="wide">
|
||||
<div className="lg:flex lg:gap-12">
|
||||
<aside className="mb-8 lg:mb-0 lg:w-48 lg:flex-shrink-0">
|
||||
<PluginSearch scope={query} className="mb-4 lg:mb-6" />
|
||||
<PluginSearch scope={query} live className="mb-4 lg:mb-6" />
|
||||
<CategoryNav plugins={matches} query={query} />
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -63,7 +66,7 @@ export function BrowsePage({
|
||||
<h1 className="text-3xl font-medium tracking-tight">
|
||||
{query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title}
|
||||
<span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground">
|
||||
{scoped.length}
|
||||
{results.length}
|
||||
</span>
|
||||
</h1>
|
||||
{query.q && (
|
||||
@@ -72,36 +75,38 @@ export function BrowsePage({
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<SortTabs query={query} />
|
||||
{results.length === 0 && query.q && (
|
||||
<div className="mt-8 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center">
|
||||
<p className="text-sm text-muted-foreground">No plugins match.</p>
|
||||
<a
|
||||
href={clearHref}
|
||||
className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
|
||||
>
|
||||
Clear filters
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{results.length === 0 && !query.q && (
|
||||
<p className="mt-8 text-sm text-muted-foreground">No plugins in this category yet.</p>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<div className={`mt-8 ${PLUGIN_GRID_CLASS}`}>
|
||||
{results.map((plugin) =>
|
||||
query.sort === "new" ? (
|
||||
<PluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
|
||||
) : (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
installs={installs[plugin.id]?.[query.window] ?? 0}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<PluginSection>
|
||||
<SortTabs query={query} />
|
||||
{results.length === 0 && query.q && (
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center">
|
||||
<p className="text-sm text-muted-foreground">No plugins match.</p>
|
||||
<a
|
||||
href={clearHref}
|
||||
className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
|
||||
>
|
||||
Clear filters
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{results.length === 0 && !query.q && (
|
||||
<p className="text-sm text-muted-foreground">No plugins in this category yet.</p>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<div className={PLUGIN_GRID_CLASS}>
|
||||
{results.map((plugin) =>
|
||||
query.sort === "new" ? (
|
||||
<PluginCard key={plugin.id} plugin={plugin}>
|
||||
{addedAgo(plugin, now)}
|
||||
</PluginCard>
|
||||
) : (
|
||||
<PluginCard key={plugin.id} plugin={plugin}>
|
||||
<InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />
|
||||
</PluginCard>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PluginSection>
|
||||
</div>
|
||||
</div>
|
||||
</SiteShell>
|
||||
@@ -186,7 +191,7 @@ function SortTabs({ query }: { query: BrowseQuery }) {
|
||||
<WindowSwitch current={query.window} hrefs={windowHrefs} />
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-6 border-b border-white/10">
|
||||
<div className="flex gap-6">
|
||||
<a
|
||||
@@ -207,6 +212,6 @@ function SortTabs({ query }: { query: BrowseQuery }) {
|
||||
{windowSwitch && <div className="hidden pb-2 sm:block">{windowSwitch}</div>}
|
||||
</div>
|
||||
{windowSwitch && <div className="mt-4 sm:hidden">{windowSwitch}</div>}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,27 +21,36 @@ export function ContributeLinks() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Closing section of the directory: what a plugin can do, with Read the docs and Submit buttons. */
|
||||
/** "Submit a plugin" and "Read the docs", as a primary and a secondary button. */
|
||||
export function ContributeButtons() {
|
||||
return (
|
||||
<>
|
||||
<a href={SUBMIT_URL} className={PRIMARY_BUTTON_CLASS}>
|
||||
Submit a plugin
|
||||
</a>
|
||||
<a href={DOCS_URL} className={SECONDARY_BUTTON_CLASS}>
|
||||
Read the docs
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Closing section of the directory: what a plugin can do, with Submit and Read the docs buttons. */
|
||||
export function ContributeSection() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="build-your-own"
|
||||
className="mt-20 rounded-xl border border-white/10 bg-white/[0.03] px-6 py-8 sm:px-8"
|
||||
className="mt-20 rounded-xl border border-white/10 bg-white/[0.03] px-6 py-8 text-center sm:px-8"
|
||||
>
|
||||
<h2 id="build-your-own" className="text-lg font-medium">
|
||||
Build your own plugin
|
||||
</h2>
|
||||
<p className="mt-2 max-w-lg text-sm text-muted-foreground">
|
||||
<p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">
|
||||
Plugins add themes, workspace panels, slash commands, and agent hooks to Paseo. Write one
|
||||
with the plugin docs, then submit it to get it listed here.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={DOCS_URL} className={PRIMARY_BUTTON_CLASS}>
|
||||
Read the docs
|
||||
</a>
|
||||
<a href={SUBMIT_URL} className={SECONDARY_BUTTON_CLASS}>
|
||||
Submit a plugin
|
||||
</a>
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<ContributeButtons />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ContributeButtons } from "./contribute-links";
|
||||
import type { BrowseQuery } from "./links";
|
||||
import { PluginSearch } from "./plugin-search";
|
||||
|
||||
interface PluginsHeroProps {
|
||||
pluginCount: number;
|
||||
authorCount: number;
|
||||
/** Where the search box sends its term. */
|
||||
searchScope: BrowseQuery;
|
||||
}
|
||||
|
||||
function plural(count: number, word: string): string {
|
||||
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/** Header of the plugin directory: title, counts, and copy, then the contribute buttons and search. */
|
||||
export function PluginsHero({ pluginCount, authorCount, searchScope }: PluginsHeroProps) {
|
||||
return (
|
||||
<header>
|
||||
<h1 className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="text-3xl font-medium tracking-tight">Plugins</span>
|
||||
<span className="text-sm font-normal tabular-nums text-extra-muted-foreground">
|
||||
{plural(pluginCount, "plugin")} by {plural(authorCount, "author")}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-3 max-w-xl text-sm leading-relaxed text-muted-foreground">
|
||||
Browse community plugins that extend Paseo with new functionality.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ContributeButtons />
|
||||
</div>
|
||||
<PluginSearch scope={searchScope} className="w-full sm:w-64" />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { formatInstalls } from "./registry";
|
||||
|
||||
/** An install count as a download icon and the number; inherits the surrounding text style. */
|
||||
export function InstallCount({ count }: { count: number }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 tabular-nums">
|
||||
<Download aria-hidden className="h-3 w-3" />
|
||||
{formatInstalls(count)}
|
||||
<span className="sr-only">installs</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,26 @@ const WINDOW_DAYS = { week: 7, month: 30 } as const;
|
||||
const totalKey = (id: string) => `plugin-installs:${id}`;
|
||||
const dailyKey = (id: string) => `plugin-installs-daily:${id}`;
|
||||
|
||||
export async function recordClientInstall({
|
||||
cache,
|
||||
id,
|
||||
ip,
|
||||
}: {
|
||||
cache: KVNamespace;
|
||||
id: string;
|
||||
ip: string | null;
|
||||
}): Promise<void> {
|
||||
if (!ip) return;
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ip));
|
||||
const hash = Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const key = `plugin-installs-client:${id}:${hash}`;
|
||||
if ((await cache.get(key)) !== null) return;
|
||||
await cache.put(key, "1", { expirationTtl: 3600 });
|
||||
await recordInstall(cache, id, new Date());
|
||||
}
|
||||
|
||||
export async function recordInstall(cache: KVNamespace, id: string, now: Date): Promise<void> {
|
||||
const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]);
|
||||
await Promise.all([
|
||||
|
||||
@@ -11,14 +11,19 @@ import {
|
||||
function registryBase(): string {
|
||||
return (env as RegistryEnvironment).PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins";
|
||||
}
|
||||
/** Listed plugins in index order, their install counts per window, and the server's clock. */
|
||||
/** Listed plugins in index order, featured plugin IDs, install counts per window, and the server's clock. */
|
||||
export const getRegistry = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const context = getWebsiteCacheContext();
|
||||
const [index, installs] = await Promise.all([
|
||||
loadRegistryIndex(registryBase(), context),
|
||||
loadInstallCounts(registryBase(), context),
|
||||
]);
|
||||
return { plugins: index.plugins, installs, now: new Date().toISOString() };
|
||||
return {
|
||||
plugins: index.plugins,
|
||||
featured: index.featured,
|
||||
installs,
|
||||
now: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
export const getRegistryPlugin = createServerFn({ method: "GET" })
|
||||
.validator((id: string) => id)
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||
import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry";
|
||||
import { ChevronLeft, ChevronRight, Play, X } from "lucide-react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
type SyntheticEvent,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const TILE_CLASS =
|
||||
"relative block aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%]";
|
||||
// Two tiles fill the row; with more, a third peeks at the right edge to show the strip scrolls.
|
||||
const FILLING_TILE_CLASS = `${TILE_CLASS} md:w-[calc(50%-0.375rem)]`;
|
||||
const PEEKING_TILE_CLASS = `${TILE_CLASS} md:w-[42%]`;
|
||||
// The viewer's room: media never exceeds this box, which leaves a margin around it.
|
||||
const VIEW_WIDTH = "90vw";
|
||||
const VIEW_HEIGHT = "85vh";
|
||||
const VIEW_CLASS = "max-h-[85vh] max-w-[90vw] rounded-lg object-contain";
|
||||
const VIEWER_BUTTON_CLASS =
|
||||
"fixed rounded-full bg-black/60 p-2 text-white transition-colors hover:bg-black/80";
|
||||
const ARROW_STEPS: Partial<Record<string, number>> = { ArrowLeft: -1, ArrowRight: 1 };
|
||||
|
||||
interface MediaItem {
|
||||
url: string;
|
||||
kind: "image" | "video";
|
||||
/** Image alt text, or the video's accessible name. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Plugin media in registry order. Each tile opens the viewer; without JavaScript it links to the file. */
|
||||
export function MediaGallery({ name, media }: { name: string; media: string[] }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const entries = useMemo(
|
||||
() => media.map((url, index) => mediaEntry(name, url, index)),
|
||||
[name, media],
|
||||
);
|
||||
const items = useMemo(() => entries.filter((entry) => typeof entry !== "string"), [entries]);
|
||||
const [viewing, setViewing] = useState<number | null>(null);
|
||||
const item = viewing === null ? undefined : items[viewing];
|
||||
const open = useCallback(
|
||||
(opened: MediaItem) => {
|
||||
setViewing(items.indexOf(opened));
|
||||
dialog.current?.showModal();
|
||||
},
|
||||
[items],
|
||||
);
|
||||
const close = useCallback(() => dialog.current?.close(), []);
|
||||
// Unmounting the viewed media stops a playing video.
|
||||
const handleClose = useCallback(() => setViewing(null), []);
|
||||
const closeOnBackdrop = useCallback((event: MouseEvent<HTMLDialogElement>) => {
|
||||
if (event.target === event.currentTarget) event.currentTarget.close();
|
||||
}, []);
|
||||
const step = useCallback(
|
||||
(by: number) =>
|
||||
setViewing((index) => (index === null ? null : (index + by + items.length) % items.length)),
|
||||
[items.length],
|
||||
);
|
||||
const showPrevious = useCallback(() => step(-1), [step]);
|
||||
const showNext = useCallback(() => step(1), [step]);
|
||||
// Captured so arrow keys move between items even while a focused video would seek.
|
||||
const stepWithArrowKeys = useCallback(
|
||||
(event: KeyboardEvent<HTMLDialogElement>) => {
|
||||
const by = ARROW_STEPS[event.key];
|
||||
if (by === undefined || items.length < 2) return;
|
||||
event.preventDefault();
|
||||
step(by);
|
||||
},
|
||||
[items.length, step],
|
||||
);
|
||||
|
||||
if (media.length === 0) return null;
|
||||
const tileClass = items.length > 2 ? PEEKING_TILE_CLASS : FILLING_TILE_CLASS;
|
||||
return (
|
||||
<>
|
||||
<div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0">
|
||||
{entries.map((entry) =>
|
||||
typeof entry === "string" ? (
|
||||
<p key={entry}>{entry}</p>
|
||||
) : (
|
||||
<MediaTile key={entry.url} item={entry} className={tileClass} onOpen={open} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{/* Focusable so arrow keys still reach it after a click on non-focusable media. */}
|
||||
<dialog
|
||||
ref={dialog}
|
||||
tabIndex={-1}
|
||||
aria-label={item?.label}
|
||||
onClose={handleClose}
|
||||
onClick={closeOnBackdrop}
|
||||
onKeyDownCapture={stepWithArrowKeys}
|
||||
className="m-auto overflow-visible bg-transparent p-0 outline-none backdrop:bg-black/80"
|
||||
>
|
||||
{item?.kind === "image" && (
|
||||
<img key={viewing} src={item.url} alt={item.label} className={VIEW_CLASS} />
|
||||
)}
|
||||
{item?.kind === "video" && <ViewerVideo key={viewing} url={item.url} label={item.label} />}
|
||||
{items.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous"
|
||||
onClick={showPrevious}
|
||||
className={`${VIEWER_BUTTON_CLASS} left-4 top-1/2 -translate-y-1/2`}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next"
|
||||
onClick={showNext}
|
||||
className={`${VIEWER_BUTTON_CLASS} right-4 top-1/2 -translate-y-1/2`}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={close}
|
||||
className={`${VIEWER_BUTTON_CLASS} right-4 top-4`}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A registry media entry as a gallery item, or the raw entry when it is not a safe URL. */
|
||||
function mediaEntry(name: string, url: string, index: number): MediaItem | string {
|
||||
const source = pluginOverviewUrl(url);
|
||||
if (!source) return url;
|
||||
const kind = pluginMediaKind(source);
|
||||
return {
|
||||
url: source,
|
||||
kind,
|
||||
label: `${name} ${kind === "video" ? "video" : "screenshot"} ${index + 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fills the viewer's room at the video's aspect ratio, scaling small sources up. */
|
||||
function ViewerVideo({ url, label }: { url: string; label: string }) {
|
||||
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
|
||||
const readAspectRatio = useCallback((event: SyntheticEvent<HTMLVideoElement>) => {
|
||||
const { videoWidth, videoHeight } = event.currentTarget;
|
||||
setAspectRatio(videoWidth / videoHeight);
|
||||
}, []);
|
||||
const style = useMemo<CSSProperties | undefined>(
|
||||
() =>
|
||||
aspectRatio === null
|
||||
? undefined
|
||||
: { aspectRatio, width: `min(${VIEW_WIDTH}, ${VIEW_HEIGHT} * ${aspectRatio})` },
|
||||
[aspectRatio],
|
||||
);
|
||||
return (
|
||||
<video
|
||||
src={url}
|
||||
aria-label={label}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
onLoadedMetadata={readAspectRatio}
|
||||
style={style}
|
||||
className={VIEW_CLASS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaTile({
|
||||
item,
|
||||
className,
|
||||
onOpen,
|
||||
}: {
|
||||
item: MediaItem;
|
||||
className: string;
|
||||
onOpen: (item: MediaItem) => void;
|
||||
}) {
|
||||
const { url, kind, label } = item;
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLAnchorElement>) => {
|
||||
// Modified clicks keep the link's own behavior, such as opening the file in a new tab.
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
onOpen(item);
|
||||
},
|
||||
[onOpen, item],
|
||||
);
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
{...pluginOverviewPolicy.link}
|
||||
aria-label={kind === "video" ? label : undefined}
|
||||
onClick={handleClick}
|
||||
className={className}
|
||||
>
|
||||
{kind === "image" ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={label}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<video
|
||||
src={url}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/10"
|
||||
>
|
||||
<span className="rounded-full bg-black/60 p-3 text-white">
|
||||
<Play className="h-5 w-5 fill-current" />
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import registry from "../../e2e/registry.fixture.json";
|
||||
import { PluginTile } from "./plugin-tile";
|
||||
import { PluginCard } from "./plugin-card";
|
||||
import type { Plugin } from "./registry";
|
||||
import React, { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { parseFragment, type DefaultTreeAdapterMap } from "parse5";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import corpus from "../../../protocol/tests/fixtures/plugin-overview.json";
|
||||
import { PluginOverview } from "./overview";
|
||||
|
||||
type Element = DefaultTreeAdapterMap["element"];
|
||||
function elements(html: string): Element[] {
|
||||
const result: Element[] = [];
|
||||
function visit(node: DefaultTreeAdapterMap["node"]) {
|
||||
if ("tagName" in node) result.push(node);
|
||||
if ("childNodes" in node) node.childNodes.forEach(visit);
|
||||
}
|
||||
visit(parseFragment(html));
|
||||
return result;
|
||||
}
|
||||
function render(markdown: string) {
|
||||
return renderToStaticMarkup(<PluginOverview>{markdown}</PluginOverview>);
|
||||
}
|
||||
function attribute(element: Element, name: string) {
|
||||
return element.attrs.find((attr) => attr.name === name)?.value;
|
||||
}
|
||||
const allowedTags = new Set([
|
||||
"div",
|
||||
"p",
|
||||
"br",
|
||||
"hr",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"blockquote",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"strong",
|
||||
"em",
|
||||
"del",
|
||||
"pre",
|
||||
"code",
|
||||
"a",
|
||||
"img",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
]);
|
||||
|
||||
describe("plugin overview security corpus", () => {
|
||||
it.each(corpus)("$name", ({ markdown, links, images }) => {
|
||||
const nodes = elements(render(markdown));
|
||||
expect(
|
||||
nodes.filter((node) => !allowedTags.has(node.tagName)).map((node) => node.tagName),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
nodes.flatMap((node) => node.attrs).filter((attr) => /^on|^style$|^srcdoc$/i.test(attr.name)),
|
||||
).toEqual([]);
|
||||
const anchors = nodes.filter((node) => node.tagName === "a");
|
||||
expect(anchors.map((node) => attribute(node, "href"))).toEqual(links);
|
||||
for (const anchor of anchors) {
|
||||
expect(attribute(anchor, "rel")).toBe("noopener noreferrer nofollow");
|
||||
expect(attribute(anchor, "target")).toBe("_blank");
|
||||
}
|
||||
expect(
|
||||
nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")),
|
||||
).toEqual(images);
|
||||
});
|
||||
|
||||
it.each(corpus)("preserves $name inside code spans and fences", ({ markdown }) => {
|
||||
for (const code of [
|
||||
`\`${markdown.replaceAll("\n", " ")}\``,
|
||||
`\`\`\`html\n${markdown}\n\`\`\``,
|
||||
]) {
|
||||
const nodes = elements(render(code));
|
||||
expect(
|
||||
nodes.filter((node) => ["a", "img", "script", "iframe", "svg"].includes(node.tagName)),
|
||||
).toEqual([]);
|
||||
const text = nodes
|
||||
.filter((node) => node.tagName === "code")
|
||||
.flatMap((node) => node.childNodes)
|
||||
.map((child) => ("value" in child ? child.value : ""))
|
||||
.join("");
|
||||
expect(text.trim()).toBe(
|
||||
code.startsWith("```html") ? markdown : markdown.replaceAll("\n", " "),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps long untrusted input inert", () => {
|
||||
const nodes = elements(
|
||||
render("a".repeat(100_000) + "\n\n" + "[click](javascript:alert(1)) ".repeat(1_000)),
|
||||
);
|
||||
expect(nodes.filter((node) => ["a", "img", "script"].includes(node.tagName))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry images", () => {
|
||||
it.each([
|
||||
"http://example.com/x",
|
||||
"data:image/png;base64,AAAA",
|
||||
"javascript:alert(1)",
|
||||
"vbscript:msgbox(1)",
|
||||
"./icon.png",
|
||||
"//example.com/x",
|
||||
])("does not load %s in cards or icons", (url) => {
|
||||
const plugin = { ...registry.plugins[0], icon: url, media: [url] } as Plugin;
|
||||
for (const component of [
|
||||
createElement(PluginTile, { plugin, size: "sm" }),
|
||||
createElement(PluginCard, { plugin }),
|
||||
]) {
|
||||
expect(
|
||||
elements(renderToStaticMarkup(component)).filter((node) => node.tagName === "img"),
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the first HTTPS screenshot when an earlier screenshot is rejected", () => {
|
||||
const source = "https://example.com/screenshot.png";
|
||||
const plugin = {
|
||||
...registry.plugins[0],
|
||||
icon: undefined,
|
||||
media: ["data:image/png;base64,AAAA", source],
|
||||
} as Plugin;
|
||||
const nodes = elements(renderToStaticMarkup(createElement(PluginCard, { plugin })));
|
||||
expect(
|
||||
nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")),
|
||||
).toEqual([source]);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ReactNode } from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||
|
||||
/** A rejected destination leaves its label as text, without even an empty anchor. */
|
||||
export function PluginContentLink({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
href?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const destination = pluginOverviewUrl(href);
|
||||
return destination ? (
|
||||
<a href={destination} {...pluginOverviewPolicy.link} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
}
|
||||
|
||||
const allowedElements = [...pluginOverviewPolicy.elements];
|
||||
const remarkPlugins = [remarkGfm];
|
||||
|
||||
const components: Components = {
|
||||
a: ({ href, children }) => <PluginContentLink href={href}>{children}</PluginContentLink>,
|
||||
img: ({ src, alt }) => {
|
||||
const source = pluginOverviewUrl(typeof src === "string" ? src : undefined);
|
||||
return source ? <img src={source} alt={alt ?? ""} loading="lazy" /> : alt;
|
||||
},
|
||||
// GFM alignment otherwise becomes an inline style in react-markdown.
|
||||
th: ({ children }) => <th>{children}</th>,
|
||||
td: ({ children }) => <td>{children}</td>,
|
||||
};
|
||||
|
||||
/** Untrusted overviews do not inherit documentation directives, anchors, or HTML highlighters. */
|
||||
export function PluginOverview({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="docs-prose">
|
||||
<ReactMarkdown
|
||||
skipHtml={!pluginOverviewPolicy.rawHtml}
|
||||
allowedElements={allowedElements}
|
||||
unwrapDisallowed
|
||||
remarkPlugins={remarkPlugins}
|
||||
urlTransform={pluginOverviewUrl}
|
||||
components={components}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Download } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthorAvatar } from "./author-link";
|
||||
import { InstallCount } from "./install-count";
|
||||
import { pluginCardScreenshot } from "./thumbnails";
|
||||
import { pluginHref } from "./links";
|
||||
import { PluginTile } from "./plugin-tile";
|
||||
import { formatInstalls, getCategory, type Plugin } from "./registry";
|
||||
import { firstMediaImage, getAuthor, getCategory, type Plugin } from "./registry";
|
||||
|
||||
export const PLUGIN_GRID_CLASS =
|
||||
"grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4";
|
||||
|
||||
/** First screenshot, or the plugin tile on a quiet backdrop when there is none. */
|
||||
/** First allowed image, or the plugin tile on a quiet backdrop when there is none. */
|
||||
function PluginShot({ plugin }: { plugin: Plugin }) {
|
||||
const url = plugin.screenshots[0];
|
||||
const url = firstMediaImage(plugin);
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -16,9 +19,10 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
|
||||
>
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
{...pluginCardScreenshot(plugin.id, url)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover object-left-top"
|
||||
/>
|
||||
) : (
|
||||
@@ -30,57 +34,49 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** What's new card: screenshot, name, and when it was added. */
|
||||
export function NewPluginCard({ plugin, added }: { plugin: Plugin; added: string }) {
|
||||
/**
|
||||
* What's new and Featured card, in three rows under the screenshot: tile and name, description,
|
||||
* then author and `children`, such as when the plugin was added or its install count.
|
||||
*/
|
||||
export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
|
||||
const author = getAuthor(plugin);
|
||||
return (
|
||||
<a href={pluginHref(plugin.id)} className="group block w-[70%] flex-shrink-0 sm:w-auto">
|
||||
<PluginShot plugin={plugin} />
|
||||
<div className="mt-3 flex items-center gap-2.5">
|
||||
<PluginTile plugin={plugin} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white">{plugin.name}</p>
|
||||
<p className="truncate text-xs text-extra-muted-foreground">Added {added}</p>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<PluginTile plugin={plugin} size="xs" />
|
||||
<p className="min-w-0 truncate text-sm font-medium text-white">{plugin.name}</p>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
|
||||
{plugin.description}
|
||||
</p>
|
||||
<p className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-extra-muted-foreground">
|
||||
<AuthorAvatar author={author} size="xs" />
|
||||
<span className="truncate">{author.name}</span>
|
||||
{children !== undefined && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="flex-shrink-0">{children}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** Grid card: screenshot, name, one muted stat, and a two-line description. */
|
||||
export function PluginCard({
|
||||
plugin,
|
||||
installs,
|
||||
added,
|
||||
}: {
|
||||
plugin: Plugin;
|
||||
/** Shown with a download icon; otherwise `added` is shown. */
|
||||
installs?: number;
|
||||
added?: string;
|
||||
}) {
|
||||
/** Grid card: screenshot; tile, name, and `children` such as an install count; description. */
|
||||
export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
|
||||
return (
|
||||
<a href={pluginHref(plugin.id)} className="group block">
|
||||
<PluginShot plugin={plugin} />
|
||||
<div className="mt-3 flex items-start gap-2.5">
|
||||
<PluginTile plugin={plugin} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="truncate text-sm font-medium text-white">{plugin.name}</p>
|
||||
<span className="inline-flex flex-shrink-0 items-center gap-1 text-xs tabular-nums text-extra-muted-foreground">
|
||||
{installs === undefined ? (
|
||||
added
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3 w-3" />
|
||||
{formatInstalls(installs)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
|
||||
{plugin.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<PluginTile plugin={plugin} size="xs" />
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-medium text-white">{plugin.name}</p>
|
||||
<span className="flex-shrink-0 text-xs text-extra-muted-foreground">{children}</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
|
||||
{plugin.description}
|
||||
</p>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -98,11 +94,9 @@ export function PluginRankRow({
|
||||
return (
|
||||
<a
|
||||
href={pluginHref(plugin.id)}
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-2.5 transition-colors hover:bg-white/[0.04]"
|
||||
className="flex items-center gap-3 rounded-lg px-4 py-2.5 transition-colors hover:bg-white/[0.04]"
|
||||
>
|
||||
<span className="w-4 text-right text-sm tabular-nums text-extra-muted-foreground">
|
||||
{rank}
|
||||
</span>
|
||||
<span className="text-sm tabular-nums text-extra-muted-foreground">{rank}</span>
|
||||
<PluginTile plugin={plugin} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-white">{plugin.name}</p>
|
||||
@@ -110,7 +104,9 @@ export function PluginRankRow({
|
||||
{getCategory(plugin.categories[0])?.label}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{formatInstalls(installs)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<InstallCount count={installs} />
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,67 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { type ChangeEvent, type FormEvent, useCallback, useRef, useState } from "react";
|
||||
import { type BrowseQuery, browseHref, DEFAULT_WINDOW } from "./links";
|
||||
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { type BrowseQuery, browseHref, DEFAULT_WINDOW, parseSearchTerm } from "./links";
|
||||
|
||||
const ICON_CLASS = "h-3.5 w-3.5 text-extra-muted-foreground";
|
||||
|
||||
/**
|
||||
* Search box for the plugin pages. Typing replaces the URL with the browse page for the term,
|
||||
* keeping `scope`'s category and sort. Without JavaScript the form submits the same URL.
|
||||
* Search box for the plugin pages. Submitting opens the browse page for the term, keeping
|
||||
* `scope`'s category and sort. With `live`, typing filters in place instead: the first character
|
||||
* of a query pushes one history entry and later edits replace it, so Back leaves the search.
|
||||
* Without JavaScript the form submits the same URL.
|
||||
*/
|
||||
export function PluginSearch({ scope, className }: { scope: BrowseQuery; className?: string }) {
|
||||
export function PluginSearch({
|
||||
scope,
|
||||
live = false,
|
||||
className,
|
||||
}: {
|
||||
scope: BrowseQuery;
|
||||
live?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [term, setTerm] = useState(scope.q ?? "");
|
||||
const search = useCallback(
|
||||
(next: string) => {
|
||||
setTerm(next);
|
||||
void navigate({
|
||||
href: browseHref({ ...scope, q: next.trim() ? next : undefined }),
|
||||
replace: true,
|
||||
});
|
||||
// Back and Forward between results of the same page keep this box mounted; show their term.
|
||||
useEffect(
|
||||
() =>
|
||||
router.history.subscribe(({ location, action }) => {
|
||||
if (action.type === "PUSH" || action.type === "REPLACE") return;
|
||||
setTerm(parseSearchTerm(new URLSearchParams(location.search).get("q")) ?? "");
|
||||
}),
|
||||
[router],
|
||||
);
|
||||
const show = useCallback(
|
||||
(next: string, { replace }: { replace: boolean }) => {
|
||||
void navigate({ href: browseHref({ ...scope, q: next.trim() ? next : undefined }), replace });
|
||||
},
|
||||
[navigate, scope],
|
||||
);
|
||||
const edit = useCallback(
|
||||
(next: string) => {
|
||||
setTerm(next);
|
||||
if (live) show(next, { replace: Boolean(term.trim()) });
|
||||
},
|
||||
[live, show, term],
|
||||
);
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => search(event.target.value),
|
||||
[search],
|
||||
(event: ChangeEvent<HTMLInputElement>) => edit(event.target.value),
|
||||
[edit],
|
||||
);
|
||||
const handleClear = useCallback(() => {
|
||||
search("");
|
||||
edit("");
|
||||
input.current?.focus();
|
||||
}, [search]);
|
||||
const handleSubmit = useCallback((event: FormEvent) => event.preventDefault(), []);
|
||||
}, [edit]);
|
||||
const handleSubmit = useCallback(
|
||||
(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
// Live results already show the term.
|
||||
if (!live) show(term, { replace: false });
|
||||
},
|
||||
[live, show, term],
|
||||
);
|
||||
return (
|
||||
<form
|
||||
role="search"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||
import type { Plugin } from "./registry";
|
||||
|
||||
const SIZE_CLASS = {
|
||||
xs: "h-6 w-6 rounded-md text-xs",
|
||||
sm: "h-9 w-9 rounded-lg text-sm",
|
||||
lg: "h-14 w-14 rounded-xl text-xl",
|
||||
} as const;
|
||||
@@ -12,7 +14,7 @@ export function PluginTile({ plugin, size }: { plugin: Plugin; size: keyof typeo
|
||||
aria-hidden
|
||||
className={`flex flex-shrink-0 items-center justify-center border border-white/10 bg-white/[0.06] font-medium text-white/80 ${SIZE_CLASS[size]}`}
|
||||
>
|
||||
{plugin.icon ? (
|
||||
{pluginOverviewUrl(plugin.icon) ? (
|
||||
<img src={plugin.icon} alt="" className="h-full w-full rounded-[inherit] object-cover" />
|
||||
) : (
|
||||
plugin.name.charAt(0).toUpperCase()
|
||||
|
||||
@@ -4,8 +4,9 @@ import {
|
||||
PublishedPluginDetailSchema,
|
||||
} from "@getpaseo/protocol/plugin-registry";
|
||||
import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache";
|
||||
import { handlePluginThumbnailRequest } from "./thumbnails";
|
||||
import { CATEGORIES } from "./categories";
|
||||
import { type InstallCounts, readInstallCounts, recordInstall } from "./installs";
|
||||
import { type InstallCounts, readInstallCounts, recordClientInstall } from "./installs";
|
||||
export interface RegistryEnvironment {
|
||||
PLUGINS_REGISTRY_URL?: string;
|
||||
WEBSITE_CACHE?: KVNamespace;
|
||||
@@ -63,6 +64,10 @@ export async function handlePluginRegistryRequest(
|
||||
const url = new URL(request.url);
|
||||
if (request.method !== "GET") return null;
|
||||
const base = env.PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins";
|
||||
if (url.pathname.startsWith("/plugins/thumb/")) {
|
||||
const index = await loadRegistryIndex(base, context);
|
||||
return handlePluginThumbnailRequest(request, index.plugins);
|
||||
}
|
||||
if (url.hostname === "plugins.paseo.sh" && url.pathname === "/index.json")
|
||||
return Response.json(await loadRegistryIndex(base, context));
|
||||
if (url.pathname === "/sitemap-plugins.xml") {
|
||||
@@ -111,7 +116,12 @@ export async function handlePluginRegistryRequest(
|
||||
url.searchParams.get("intent") === "install"
|
||||
) {
|
||||
const cache = context.cache;
|
||||
if (cache) context.waitUntil(recordInstall(cache, id, new Date()).catch(() => undefined));
|
||||
if (cache)
|
||||
context.waitUntil(
|
||||
recordClientInstall({ cache, id, ip: request.headers.get("CF-Connecting-IP") }).catch(
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Response.json(plugin);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addedAgo,
|
||||
featuredPlugins,
|
||||
formatInstalls,
|
||||
installCommand,
|
||||
mostInstalled,
|
||||
@@ -10,7 +13,7 @@ import {
|
||||
searchPlugins,
|
||||
} from "./registry";
|
||||
import { readInstallCounts, recordInstall } from "./installs";
|
||||
import { loadRegistryIndex, loadRegistryPlugin } from "./published";
|
||||
import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published";
|
||||
const plugin: Plugin = {
|
||||
id: "acme/example",
|
||||
name: "Example",
|
||||
@@ -25,7 +28,7 @@ const plugin: Plugin = {
|
||||
resolved: "https://registry.npmjs.org/example.tgz",
|
||||
integrity: "sha512-YWJj",
|
||||
},
|
||||
screenshots: [],
|
||||
media: [],
|
||||
submittedAt: "2026-10-03",
|
||||
reviewedAt: "2026-10-03",
|
||||
updatedAt: "2026-10-03",
|
||||
@@ -39,6 +42,7 @@ describe("plugin registry", () => {
|
||||
registry: { name: "Internal", url: "https://example.test" },
|
||||
categories: [],
|
||||
plugins: [plugin],
|
||||
featured: [plugin.id],
|
||||
generatedAt: "2026-10-03",
|
||||
};
|
||||
const detail = { ...plugin, readme: "# Example" };
|
||||
@@ -59,8 +63,46 @@ describe("plugin registry", () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
it("orders by submission date and by installs in a window, keeping index order for ties", () => {
|
||||
const older = { ...plugin, id: "acme/older", submittedAt: "2026-09-01" };
|
||||
it("features listed plugins once each, in the registry's order, dropping IDs that are not listed", () => {
|
||||
const listed = [
|
||||
{ ...plugin, id: "acme/first" },
|
||||
{ ...plugin, id: "acme/second" },
|
||||
{ ...plugin, id: "acme/third" },
|
||||
];
|
||||
const featured = ["acme/third", "acme/not-published", "acme/first", "acme/third"];
|
||||
expect(featuredPlugins(listed, featured).map((p) => p.id)).toEqual([
|
||||
"acme/third",
|
||||
"acme/first",
|
||||
]);
|
||||
expect(featuredPlugins(listed, ["acme/not-published"])).toEqual([]);
|
||||
});
|
||||
it("lists what's new by first-listing date, not submission or update dates", () => {
|
||||
const listedFirst = {
|
||||
...plugin,
|
||||
id: "acme/listed-first",
|
||||
submittedAt: "2026-09-20",
|
||||
reviewedAt: "2026-09-21",
|
||||
updatedAt: "2026-10-02",
|
||||
publishedAt: "2026-09-02",
|
||||
};
|
||||
const listedLater = {
|
||||
...plugin,
|
||||
id: "acme/listed-later",
|
||||
submittedAt: "2026-09-01",
|
||||
reviewedAt: "2026-09-01",
|
||||
updatedAt: "2026-09-01",
|
||||
publishedAt: "2026-09-30",
|
||||
};
|
||||
expect(newestFirst([listedFirst, listedLater]).map((p) => p.id)).toEqual([
|
||||
"acme/listed-later",
|
||||
"acme/listed-first",
|
||||
]);
|
||||
const now = "2026-10-03T12:00:00.000Z";
|
||||
expect(addedAgo(listedLater, now)).toBe("3d ago");
|
||||
expect(addedAgo(listedFirst, now)).toBe("4w ago");
|
||||
});
|
||||
it("orders by listing date and by installs in a window, keeping index order for ties", () => {
|
||||
const older = { ...plugin, id: "acme/older", publishedAt: "2026-09-01" };
|
||||
const twin = { ...plugin, id: "acme/twin" };
|
||||
expect(newestFirst([older, plugin, twin]).map((p) => p.id)).toEqual([
|
||||
"acme/example",
|
||||
@@ -94,6 +136,82 @@ describe("plugin registry", () => {
|
||||
Object.keys(JSON.parse((await cache.get("plugin-installs-daily:acme/example")) ?? "{}")),
|
||||
).toEqual(["2026-09-10", "2026-09-30", "2026-10-04"]);
|
||||
});
|
||||
it("counts repeated install reports from the same IP once per hour", async () => {
|
||||
let now = Date.now();
|
||||
const cache = memoryKv(() => now);
|
||||
const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
|
||||
await resolvePlugin(cache, headers);
|
||||
now += 3_599_000;
|
||||
await resolvePlugin(cache, headers);
|
||||
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
|
||||
[plugin.id]: { week: 1, month: 1, all: 1 },
|
||||
});
|
||||
now += 1_000;
|
||||
await resolvePlugin(cache, headers);
|
||||
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
|
||||
[plugin.id]: { week: 2, month: 2, all: 2 },
|
||||
});
|
||||
});
|
||||
it("counts different IPs and plugins independently, including query install intent", async () => {
|
||||
const cache = memoryKv();
|
||||
const first = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
|
||||
await resolvePlugin(cache, first);
|
||||
await resolvePlugin(cache, { "CF-Connecting-IP": "2001:db8::1" }, { query: "?intent=install" });
|
||||
await resolvePlugin(cache, first, { id: "acme/other" });
|
||||
await resolvePlugin(cache, first, { query: "?intent=install" });
|
||||
await resolvePlugin(cache, first, { id: "acme/other", staticDetail: true });
|
||||
expect(await readInstallCounts(cache, [plugin.id, "acme/other"], new Date())).toEqual({
|
||||
[plugin.id]: { week: 2, month: 2, all: 2 },
|
||||
"acme/other": { week: 1, month: 1, all: 1 },
|
||||
});
|
||||
const hash = createHash("sha256").update(first["CF-Connecting-IP"]).digest("hex");
|
||||
expect(await cache.get(`plugin-installs-client:${plugin.id}:${hash}`)).toBe("1");
|
||||
expect(JSON.stringify([...cache.values])).not.toContain("192.0.2.1");
|
||||
expect(JSON.stringify([...cache.values])).not.toContain("2001:db8::1");
|
||||
});
|
||||
it("does not count without a Cloudflare IP or install intent", async () => {
|
||||
const cache = memoryKv();
|
||||
await resolvePlugin(cache, { "X-Paseo-Install": "1", "X-Forwarded-For": "192.0.2.1" });
|
||||
await resolvePlugin(cache, {}, { query: "?intent=install" });
|
||||
await resolvePlugin(cache, { "X-Paseo-Install": "1", "CF-Connecting-IP": "" });
|
||||
await resolvePlugin(cache, { "CF-Connecting-IP": "192.0.2.1" });
|
||||
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
|
||||
[plugin.id]: { week: 0, month: 0, all: 0 },
|
||||
});
|
||||
expect([...cache.values.keys()].filter((key) => key.startsWith("plugin-installs"))).toEqual([]);
|
||||
});
|
||||
it("preserves historical totals and the installs endpoint response", async () => {
|
||||
const cache = memoryKv();
|
||||
await cache.put(`plugin-installs:${plugin.id}`, "40");
|
||||
const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
|
||||
await resolvePlugin(cache, headers);
|
||||
await resolvePlugin(cache, headers);
|
||||
const base = "https://registry.example.test";
|
||||
await cache.put(
|
||||
`plugins:index:v1:${base}`,
|
||||
JSON.stringify({
|
||||
fetchedAt: Date.now(),
|
||||
value: {
|
||||
schemaVersion: 1,
|
||||
registry: { name: "Internal", url: base },
|
||||
categories: [],
|
||||
plugins: [plugin],
|
||||
featured: [],
|
||||
generatedAt: "2026-10-03",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const response = await handlePluginRegistryRequest(
|
||||
new Request("https://paseo.sh/api/plugins/installs"),
|
||||
{ PLUGINS_REGISTRY_URL: base },
|
||||
{ cache, waitUntil: () => undefined },
|
||||
);
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({ [plugin.id]: 41 });
|
||||
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
|
||||
[plugin.id]: { week: 1, month: 1, all: 41 },
|
||||
});
|
||||
});
|
||||
it("searches name, description, ID, and author, ignoring case", () => {
|
||||
const other = {
|
||||
...plugin,
|
||||
@@ -110,6 +228,32 @@ describe("plugin registry", () => {
|
||||
expect(searchPlugins(plugins, " ")).toEqual(plugins);
|
||||
expect(searchPlugins(plugins, "nothing")).toEqual([]);
|
||||
});
|
||||
it("ranks name, then author, then description matches, keeping the given order on ties", () => {
|
||||
const named = (id: string, name: string, description: string, github = "acme"): Plugin => ({
|
||||
...plugin,
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
author: { github },
|
||||
});
|
||||
// Given in install order: description-only matches first, the exact name last.
|
||||
const plugins = [
|
||||
named("acme/editor", "Remote Editor", "Composer pill to open a workspace"),
|
||||
named("acme/kit", "PromptKit", "Rewrite prompts"),
|
||||
named("omp/tools", "Tools", "Helpers", "omp"),
|
||||
named("acme/history", "History", "Inspect raw composer records"),
|
||||
named("acme/omp", "OMP", "Paseo integration for OMP"),
|
||||
];
|
||||
expect(searchPlugins(plugins, "omp").map((p) => p.id)).toEqual([
|
||||
"acme/omp",
|
||||
"acme/kit",
|
||||
"omp/tools",
|
||||
"acme/editor",
|
||||
"acme/history",
|
||||
]);
|
||||
expect(searchPlugins(plugins, "prompt").map((p) => p.id)).toEqual(["acme/kit"]);
|
||||
expect(searchPlugins(plugins, "tools omp").map((p) => p.id)).toEqual(["omp/tools"]);
|
||||
});
|
||||
|
||||
it("strips the README title and quoted description that the page already shows", () => {
|
||||
expect(readmeBody("# Example\n\n> An example\n> plugin\n\n## Usage\n")).toBe("## Usage\n");
|
||||
@@ -125,16 +269,43 @@ describe("plugin registry", () => {
|
||||
});
|
||||
|
||||
/** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */
|
||||
function memoryKv(): KVNamespace {
|
||||
function memoryKv(now = Date.now): KVNamespace & { values: Map<string, string> } {
|
||||
const values = new Map<string, string>();
|
||||
const expiresAt = new Map<string, number>();
|
||||
const kv = {
|
||||
values,
|
||||
get: async (key: string, options?: { type?: string }) => {
|
||||
if (now() >= (expiresAt.get(key) ?? Infinity)) values.delete(key);
|
||||
const value = values.get(key) ?? null;
|
||||
return options?.type === "json" && value !== null ? JSON.parse(value) : value;
|
||||
},
|
||||
put: async (key: string, value: string) => {
|
||||
put: async (key: string, value: string, options?: { expirationTtl?: number }) => {
|
||||
values.set(key, value);
|
||||
expiresAt.set(key, options?.expirationTtl ? now() + options.expirationTtl * 1000 : Infinity);
|
||||
},
|
||||
};
|
||||
return kv as unknown as KVNamespace;
|
||||
return kv as unknown as KVNamespace & { values: Map<string, string> };
|
||||
}
|
||||
|
||||
async function resolvePlugin(
|
||||
cache: KVNamespace,
|
||||
headers: HeadersInit,
|
||||
{ id = plugin.id, query = "", staticDetail = false } = {},
|
||||
): Promise<void> {
|
||||
const base = "https://registry.example.test";
|
||||
const detail = { ...plugin, id, readme: "# Example" };
|
||||
await cache.put(
|
||||
`plugins:detail:v1:${base}:${id}`,
|
||||
JSON.stringify({ fetchedAt: Date.now(), value: detail }),
|
||||
);
|
||||
const pending: Promise<unknown>[] = [];
|
||||
const path = staticDetail ? `/plugins/${id}.json` : `/api/plugins/resolve/${id}`;
|
||||
const response = await handlePluginRegistryRequest(
|
||||
new Request(`https://paseo.sh${path}${query}`, { headers }),
|
||||
{ PLUGINS_REGISTRY_URL: base },
|
||||
{ cache, waitUntil: (promise) => pending.push(promise) },
|
||||
);
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual(detail);
|
||||
await Promise.all(pending);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { z } from "zod";
|
||||
import type { PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry";
|
||||
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||
import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry";
|
||||
import { scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||
import { CATEGORIES, type Category, type CategorySlug } from "./categories";
|
||||
import type { InstallCounts, InstallWindow } from "./installs";
|
||||
export { CATEGORIES, type Category, type CategorySlug };
|
||||
@@ -15,6 +17,10 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
export function getCategory(slug: string): Category | null {
|
||||
return CATEGORIES.find((category) => category.slug === slug) ?? null;
|
||||
}
|
||||
/** The plugin's first HTTPS image, for cards and link previews; videos never stand in for one. */
|
||||
export function firstMediaImage(plugin: Plugin): string | undefined {
|
||||
return plugin.media.find((url) => pluginOverviewUrl(url) && pluginMediaKind(url) === "image");
|
||||
}
|
||||
export function getPluginsInCategory(plugins: Plugin[], slug: string): Plugin[] {
|
||||
return plugins.filter((plugin) => plugin.categories.includes(slug));
|
||||
}
|
||||
@@ -61,20 +67,48 @@ export function formatInstalls(count: number): string {
|
||||
const k = count / 1000;
|
||||
return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`;
|
||||
}
|
||||
/** Plugins whose name, description, ID, or author contains the term, case-insensitively. */
|
||||
export function searchPlugins(plugins: Plugin[], term: string): Plugin[] {
|
||||
const needle = term.trim().toLowerCase();
|
||||
if (!needle) return plugins;
|
||||
return plugins.filter((plugin) =>
|
||||
[plugin.name, plugin.description, plugin.id, getAuthor(plugin).name]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(needle),
|
||||
);
|
||||
/** Fields a search term can hit, best first: a name match outranks an author match, which outranks a description match. */
|
||||
function searchFieldGroups(plugin: Plugin): string[][] {
|
||||
const author = getAuthor(plugin);
|
||||
const name = [plugin.name, plugin.id.split("/")[1]];
|
||||
const owner = [author.name, author.github, plugin.id];
|
||||
return [name, owner, [plugin.description], [...name, ...owner, plugin.description]];
|
||||
}
|
||||
/** Newest submissions first; equal dates keep index order. */
|
||||
interface SearchRank {
|
||||
field: number;
|
||||
tier: number;
|
||||
}
|
||||
function searchRank(plugin: Plugin, term: string): SearchRank | null {
|
||||
const groups = searchFieldGroups(plugin);
|
||||
for (let field = 0; field < groups.length; field += 1) {
|
||||
const score = scoreTextFields(term, groups[field], { subsequence: false });
|
||||
if (score) return { field, tier: score.tier };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Plugins whose name, author, ID, or description contains every word of the term, ignoring case.
|
||||
* Name matches come first, then author, then description; within each, exact words beat partial
|
||||
* ones, and equal matches keep the given order.
|
||||
*/
|
||||
export function searchPlugins(plugins: Plugin[], term: string): Plugin[] {
|
||||
if (!term.trim()) return plugins;
|
||||
return plugins
|
||||
.flatMap((plugin) => {
|
||||
const rank = searchRank(plugin, term);
|
||||
return rank ? [{ plugin, rank }] : [];
|
||||
})
|
||||
.sort((a, b) => a.rank.field - b.rank.field || a.rank.tier - b.rank.tier)
|
||||
.map(({ plugin }) => plugin);
|
||||
}
|
||||
/** The featured IDs that name a listed plugin, in the registry's order, each once. */
|
||||
export function featuredPlugins(plugins: Plugin[], featured: string[]): Plugin[] {
|
||||
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
||||
return [...new Set(featured)].flatMap((id) => byId.get(id) ?? []);
|
||||
}
|
||||
/** Most recently listed first; equal dates keep index order. */
|
||||
export function newestFirst(plugins: Plugin[]): Plugin[] {
|
||||
return [...plugins].sort((a, b) => b.submittedAt.localeCompare(a.submittedAt));
|
||||
return [...plugins].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
|
||||
}
|
||||
/** Most installs in the window first; equal counts keep index order. */
|
||||
export function mostInstalled(
|
||||
@@ -85,9 +119,9 @@ export function mostInstalled(
|
||||
const count = (plugin: Plugin) => installs[plugin.id]?.[window] ?? 0;
|
||||
return [...plugins].sort((a, b) => count(b) - count(a));
|
||||
}
|
||||
/** How long ago the registry accepted the plugin, relative to the loader's clock. */
|
||||
/** How long ago the plugin was first listed, relative to the loader's clock. */
|
||||
export function addedAgo(plugin: Plugin, now: string): string {
|
||||
const days = Math.floor((Date.parse(now) - Date.parse(plugin.submittedAt)) / DAY_MS);
|
||||
const days = Math.floor((Date.parse(now) - Date.parse(plugin.publishedAt)) / DAY_MS);
|
||||
if (days <= 0) return "today";
|
||||
if (days === 1) return "yesterday";
|
||||
if (days < 14) return `${days}d ago`;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** A block of a plugins page: its header, then its content one fixed gap below. */
|
||||
export function PluginSection({
|
||||
id,
|
||||
labelledBy,
|
||||
className = "",
|
||||
children,
|
||||
}: {
|
||||
id?: string;
|
||||
labelledBy?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} aria-labelledby={labelledBy} className={`flex flex-col gap-8 ${className}`}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A section's title with its trailing controls on one baseline. */
|
||||
export function PluginSectionHeader({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginSectionTitle({ id, children }: { id: string; children: ReactNode }) {
|
||||
return (
|
||||
<h2 id={id} className="text-lg font-medium">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { handlePluginThumbnailRequest, pluginCardScreenshot } from "./thumbnails";
|
||||
import { NewPluginCard, PluginCard } from "./plugin-card";
|
||||
import type { Plugin } from "./registry";
|
||||
|
||||
const source = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/preview.png";
|
||||
const video = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/demo.mp4";
|
||||
const plugin: Plugin = {
|
||||
id: "acme/example",
|
||||
name: "Example",
|
||||
description: "An example",
|
||||
categories: ["themes"],
|
||||
author: { github: "acme" },
|
||||
repository: { url: "https://github.com/acme/example" },
|
||||
artifact: {
|
||||
kind: "npm",
|
||||
package: "paseo-example",
|
||||
version: "1.2.3",
|
||||
resolved: "https://registry.npmjs.org/example.tgz",
|
||||
integrity: "sha512-YWJj",
|
||||
},
|
||||
media: [source],
|
||||
submittedAt: "2026-10-03",
|
||||
reviewedAt: "2026-10-03",
|
||||
updatedAt: "2026-10-03",
|
||||
publishedAt: "2026-10-03",
|
||||
installs: 42,
|
||||
};
|
||||
|
||||
describe("plugin card thumbnails", () => {
|
||||
it("uses the first image in the plugin's media, skipping a leading video", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(PluginCard, { plugin: { ...plugin, media: [video, source] } }),
|
||||
);
|
||||
expect(html).toContain(`/plugins/thumb/592/${encodeURIComponent(source)}?`);
|
||||
expect(html).not.toContain(encodeURIComponent(video));
|
||||
});
|
||||
|
||||
it("shows the plugin tile when the media has no image", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(PluginCard, { plugin: { ...plugin, media: [video] } }),
|
||||
);
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).not.toContain("<video");
|
||||
expect(html).toContain(">E</div>");
|
||||
});
|
||||
|
||||
it("keeps a screenshot on an unsupported host visible at its original URL", () => {
|
||||
const screenshot = "https://example.com/preview.png";
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(PluginCard, { plugin: { ...plugin, media: [screenshot] } }),
|
||||
);
|
||||
expect(html).toContain(`src="${screenshot}"`);
|
||||
expect(html).not.toContain("/plugins/thumb/");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["PluginCard", () => createElement(PluginCard, { plugin }, "today")],
|
||||
["NewPluginCard", () => createElement(NewPluginCard, { plugin }, "today")],
|
||||
])("renders a thumbnail instead of downloading the original screenshot (%s)", (_name, card) => {
|
||||
const html = renderToStaticMarkup(card());
|
||||
expect(html).toContain(
|
||||
`src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`,
|
||||
);
|
||||
expect(html).toContain(
|
||||
`srcSet="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample 1x, /plugins/thumb/1184/${encodeURIComponent(source)}?plugin=acme%2Fexample 2x"`,
|
||||
);
|
||||
expect(html).toContain('loading="lazy"');
|
||||
expect(html).toContain('decoding="async"');
|
||||
});
|
||||
});
|
||||
|
||||
function thumbnailRequest(
|
||||
sourceUrl = source,
|
||||
width = 592,
|
||||
id = plugin.id,
|
||||
accept = "image/avif,image/webp",
|
||||
) {
|
||||
return new Request(
|
||||
`https://paseo.sh/plugins/thumb/${width}/${encodeURIComponent(sourceUrl)}?plugin=${encodeURIComponent(id)}`,
|
||||
{ headers: { accept } },
|
||||
);
|
||||
}
|
||||
|
||||
function imageResponse(body: string, headers: Record<string, string> = {}) {
|
||||
return new Response(body, { headers: { "content-type": "image/png", ...headers } });
|
||||
}
|
||||
|
||||
// A typed fetch dependency, returning real Responses without patching the global fetch.
|
||||
function imageFetch(...responses: (Response | Error)[]) {
|
||||
const calls: { input: Parameters<typeof fetch>[0]; init: Parameters<typeof fetch>[1] }[] = [];
|
||||
const fetchImage: typeof fetch = async (input, init) => {
|
||||
calls.push({ input, init });
|
||||
const response = responses.shift();
|
||||
if (!response) throw new Error("Unexpected fetch");
|
||||
if (response instanceof Error) throw response;
|
||||
return response;
|
||||
};
|
||||
return { calls, fetchImage };
|
||||
}
|
||||
|
||||
describe("thumbnail route", () => {
|
||||
it("sandboxes an SVG fallback when opened as a document on the website origin", async () => {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.domain)</script></svg>';
|
||||
const upstream = imageFetch(
|
||||
new Response("unsupported", { status: 415 }),
|
||||
imageResponse(svg, { "content-type": "image/svg+xml" }),
|
||||
);
|
||||
const response = await handlePluginThumbnailRequest(
|
||||
thumbnailRequest(),
|
||||
[plugin],
|
||||
upstream.fetchImage,
|
||||
);
|
||||
expect(await response.text()).toBe(svg);
|
||||
expect(response.headers.get("content-security-policy")).toBe("sandbox");
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[592, source, "image/avif,image/webp", "avif"],
|
||||
[592, source, "image/avif;q=0,image/webp", "webp"],
|
||||
[592, source, "image/avif;q=0,image/webp;q=0", undefined],
|
||||
[
|
||||
1184,
|
||||
"https://raw.githubusercontent.com/acme/example/abc123/preview.png",
|
||||
"image/webp",
|
||||
"webp",
|
||||
],
|
||||
[592, source, "image/png,image/*;q=0.8", undefined],
|
||||
] as const)(
|
||||
"transforms a registered screenshot at %s from %s for %s",
|
||||
async (width, screenshot, accept, format) => {
|
||||
const upstream = imageFetch(imageResponse("thumbnail"));
|
||||
const response = await handlePluginThumbnailRequest(
|
||||
thumbnailRequest(screenshot, width, plugin.id, accept),
|
||||
[{ ...plugin, media: [screenshot] }],
|
||||
upstream.fetchImage,
|
||||
);
|
||||
expect(upstream.calls).toEqual([
|
||||
{
|
||||
input: screenshot,
|
||||
init: {
|
||||
cf: { image: { width, fit: "scale-down", format, quality: 80 } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("thumbnail");
|
||||
expect(response.headers.get("content-type")).toBe("image/png");
|
||||
expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
|
||||
expect(response.headers.get("vary")).toBe("Accept");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["Cloudflare error", () => new Response("error 9401", { status: 403 })],
|
||||
[
|
||||
"non-image success",
|
||||
() => new Response("<html>unavailable</html>", { headers: { "content-type": "text/html" } }),
|
||||
],
|
||||
["Cf-Resized error", () => imageResponse("error", { "cf-resized": "err=9401" })],
|
||||
["network exception", () => new Error("Transform unavailable")],
|
||||
] as const)("proxies the original with immutable caching on %s", async (_, failure) => {
|
||||
const upstream = imageFetch(failure(), imageResponse("original"));
|
||||
const response = await handlePluginThumbnailRequest(
|
||||
thumbnailRequest(),
|
||||
[plugin],
|
||||
upstream.fetchImage,
|
||||
);
|
||||
expect(upstream.calls.map((call) => call.input)).toEqual([source, source]);
|
||||
expect(upstream.calls[1].init).toBeUndefined();
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("original");
|
||||
expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["arbitrary width", thumbnailRequest(source, 593), [plugin], 400],
|
||||
["unlisted source on allowed host", thumbnailRequest(source + "?different"), [plugin], 404],
|
||||
["wrong plugin ID", thumbnailRequest(source, 592, "acme/other"), [plugin], 404],
|
||||
[
|
||||
"missing plugin ID",
|
||||
new Request(`https://paseo.sh/plugins/thumb/592/${encodeURIComponent(source)}`),
|
||||
[plugin],
|
||||
404,
|
||||
],
|
||||
["removed plugin", thumbnailRequest(), [], 404],
|
||||
["listed video", thumbnailRequest(video), [{ ...plugin, media: [video, source] }], 404],
|
||||
["malformed encoding", new Request("https://paseo.sh/plugins/thumb/592/%ZZ"), [plugin], 400],
|
||||
["malformed URL", thumbnailRequest("not a URL"), [plugin], 400],
|
||||
...[
|
||||
"https://example.com/preview.png",
|
||||
"https://cdn.jsdelivr.net.evil.test/preview.png",
|
||||
"http://cdn.jsdelivr.net/preview.png",
|
||||
"https://user:pass@cdn.jsdelivr.net/preview.png",
|
||||
"https://cdn.jsdelivr.net:8080/preview.png",
|
||||
].map(
|
||||
(url) =>
|
||||
[
|
||||
"disallowed registered source",
|
||||
thumbnailRequest(url),
|
||||
[{ ...plugin, media: [url] }],
|
||||
400,
|
||||
] satisfies [string, Request, Plugin[], number],
|
||||
),
|
||||
] satisfies [string, Request, Plugin[], number][])(
|
||||
"rejects %s without fetching an image",
|
||||
async (_, request, plugins, status) => {
|
||||
const upstream = imageFetch();
|
||||
const response = await handlePluginThumbnailRequest(
|
||||
request,
|
||||
[...plugins],
|
||||
upstream.fetchImage,
|
||||
);
|
||||
expect(response.status).toBe(status);
|
||||
expect(upstream.calls).toEqual([]);
|
||||
expect(response.headers.get("cache-control")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
() => new Response("missing", { status: 404 }),
|
||||
() => new Response("<html>error</html>", { headers: { "content-type": "text/html" } }),
|
||||
() => new Error("Origin unavailable"),
|
||||
])("does not cache a failed original for a year", async (failure) => {
|
||||
const upstream = imageFetch(new Response("transform error", { status: 502 }), failure());
|
||||
const response = await handlePluginThumbnailRequest(
|
||||
thumbnailRequest(),
|
||||
[plugin],
|
||||
upstream.fetchImage,
|
||||
);
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("round-trips screenshot URL punctuation through the card URL", async () => {
|
||||
const screenshot = source + "?name=a&other=b#preview";
|
||||
const upstream = imageFetch(imageResponse("thumbnail"));
|
||||
const request = new Request(
|
||||
"https://paseo.sh" + pluginCardScreenshot(plugin.id, screenshot).src,
|
||||
);
|
||||
const response = await handlePluginThumbnailRequest(
|
||||
request,
|
||||
[{ ...plugin, media: [screenshot] }],
|
||||
upstream.fetchImage,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(upstream.calls[0].input).toBe(screenshot);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry";
|
||||
import type { Plugin } from "./registry";
|
||||
|
||||
// The single-column grid reaches 591px just below sm (640px minus 48px padding).
|
||||
const CARD_WIDTH = 592;
|
||||
const WIDTHS = new Set([CARD_WIDTH, CARD_WIDTH * 2]);
|
||||
const SOURCE_HOSTS = new Set(["cdn.jsdelivr.net", "raw.githubusercontent.com"]);
|
||||
|
||||
export function pluginCardScreenshot(id: string, source: string): { src: string; srcSet?: string } {
|
||||
if (!parseSource(source)) return { src: source };
|
||||
return {
|
||||
src: pluginThumbnailUrl(id, source, 1),
|
||||
srcSet: `${pluginThumbnailUrl(id, source, 1)} 1x, ${pluginThumbnailUrl(id, source, 2)} 2x`,
|
||||
};
|
||||
}
|
||||
|
||||
function pluginThumbnailUrl(id: string, source: string, scale: 1 | 2): string {
|
||||
return `/plugins/thumb/${CARD_WIDTH * scale}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
/** Only images listed in the plugin's media are eligible for transformation or original fallback. */
|
||||
export async function handlePluginThumbnailRequest(
|
||||
request: Request,
|
||||
plugins: Plugin[],
|
||||
fetchImage: typeof fetch = fetch,
|
||||
): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const match = /^\/plugins\/thumb\/(\d+)\/([^/]+)$/.exec(url.pathname);
|
||||
if (!match || !WIDTHS.has(Number(match[1])))
|
||||
return new Response("Invalid thumbnail width or source", { status: 400 });
|
||||
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(match[2]);
|
||||
} catch {
|
||||
return new Response("Invalid thumbnail source", { status: 400 });
|
||||
}
|
||||
const source = parseSource(decoded);
|
||||
if (!source) return new Response("Invalid thumbnail source", { status: 400 });
|
||||
|
||||
const plugin = plugins.find((entry) => entry.id === url.searchParams.get("plugin"));
|
||||
if (!plugin?.media.includes(source) || pluginMediaKind(source) !== "image")
|
||||
return new Response("Screenshot not found", { status: 404 });
|
||||
|
||||
const format = negotiatedFormat(request.headers.get("accept") ?? "");
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
response = await fetchImage(source, {
|
||||
cf: { image: { width: Number(match[1]), fit: "scale-down", format, quality: 80 } },
|
||||
});
|
||||
} catch {
|
||||
// An unavailable transformation must not prevent fetching the original.
|
||||
}
|
||||
if (!response || !isImage(response) || /err=/i.test(response.headers.get("cf-resized") ?? "")) {
|
||||
await response?.body?.cancel();
|
||||
try {
|
||||
response = await fetchImage(source);
|
||||
} catch {
|
||||
return unavailableImage();
|
||||
}
|
||||
}
|
||||
if (!isImage(response)) {
|
||||
await response.body?.cancel();
|
||||
return unavailableImage();
|
||||
}
|
||||
return new Response(response.body, {
|
||||
headers: {
|
||||
"content-type": response.headers.get("content-type")!,
|
||||
"cache-control": "public, max-age=31536000, immutable",
|
||||
vary: "Accept",
|
||||
// Originals can be SVGs. Keep direct navigation from executing them on our origin.
|
||||
"content-security-policy": "sandbox",
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isImage(response: Response): boolean {
|
||||
return response.ok && /^image\//i.test(response.headers.get("content-type") ?? "");
|
||||
}
|
||||
|
||||
function unavailableImage(): Response {
|
||||
return new Response("Screenshot unavailable", {
|
||||
status: 502,
|
||||
headers: { "cache-control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
function parseSource(source: string): string | null {
|
||||
try {
|
||||
const url = new URL(source);
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
!SOURCE_HOSTS.has(url.hostname) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.port
|
||||
)
|
||||
return null;
|
||||
return source;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function negotiatedFormat(accept: string): "avif" | "webp" | undefined {
|
||||
// The Worker API requires explicit negotiation; only the URL API accepts format=auto.
|
||||
const accepted = new Map(
|
||||
accept
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.map((range) => {
|
||||
const [type, ...parameters] = range.split(";").map((part) => part.trim());
|
||||
const quality = parameters.find((parameter) => parameter.startsWith("q="));
|
||||
return [type, quality === undefined ? 1 : Number(quality.slice(2))] as const;
|
||||
}),
|
||||
);
|
||||
return (["avif", "webp"] as const).find((format) => (accepted.get(`image/${format}`) ?? 0) > 0);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PluginContentLink } from "~/plugins/overview";
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from "~/plugins";
|
||||
import { AuthorAvatar } from "~/plugins/author-link";
|
||||
import { PluginsNotFound } from "~/plugins/not-found";
|
||||
import { InstallCount } from "~/plugins/install-count";
|
||||
import { PluginCard } from "~/plugins/plugin-card";
|
||||
import "~/styles.css";
|
||||
|
||||
@@ -64,21 +66,16 @@ function AuthorPage() {
|
||||
</p>
|
||||
<div className="flex items-center gap-4 pt-1">
|
||||
{authorNpmUrl(author) && (
|
||||
<a
|
||||
href={authorNpmUrl(author)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
<PluginContentLink href={authorNpmUrl(author)} className={LINK_CLASS}>
|
||||
npm
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</PluginContentLink>
|
||||
)}
|
||||
{github && (
|
||||
<a href={github} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||
<PluginContentLink href={github} className={LINK_CLASS}>
|
||||
GitHub
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</PluginContentLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,7 +83,9 @@ function AuthorPage() {
|
||||
|
||||
<div className="mt-12 grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{plugins.map((plugin) => (
|
||||
<PluginCard key={plugin.id} plugin={plugin} installs={installs[plugin.id]?.all ?? 0} />
|
||||
<PluginCard key={plugin.id} plugin={plugin}>
|
||||
<InstallCount count={installs[plugin.id]?.all ?? 0} />
|
||||
</PluginCard>
|
||||
))}
|
||||
</div>
|
||||
</SiteShell>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { CodeBlock } from "~/components/code-block";
|
||||
import { DocsMarkdown } from "~/components/docs-markdown";
|
||||
import { PluginOverview, PluginContentLink } from "~/plugins/overview";
|
||||
import { SiteShell } from "~/components/site-shell";
|
||||
import { pageMeta } from "~/meta";
|
||||
import {
|
||||
formatInstalls,
|
||||
firstMediaImage,
|
||||
getAuthor,
|
||||
getCategory,
|
||||
getRegistry,
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
pluginVersion,
|
||||
readmeBody,
|
||||
} from "~/plugins";
|
||||
import { AuthorLink } from "~/plugins/author-link";
|
||||
import { AuthorAvatar, AuthorLink } from "~/plugins/author-link";
|
||||
import { InstallCount } from "~/plugins/install-count";
|
||||
import { categoryHref } from "~/plugins/links";
|
||||
import { MediaGallery } from "~/plugins/media-gallery";
|
||||
import { PluginsNotFound } from "~/plugins/not-found";
|
||||
import "~/styles.css";
|
||||
|
||||
@@ -34,7 +36,7 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
|
||||
loaderData?.plugin ? `${loaderData.plugin.name} – Paseo plugin` : "Plugin not found – Paseo",
|
||||
loaderData?.plugin?.description ?? "Plugin not found.",
|
||||
`/plugins/${params.owner}/${params.slug}`,
|
||||
loaderData?.plugin.screenshots[0],
|
||||
loaderData?.plugin && firstMediaImage(loaderData.plugin),
|
||||
),
|
||||
component: PluginPage,
|
||||
notFoundComponent: () => (
|
||||
@@ -46,6 +48,8 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
|
||||
|
||||
const META_LINK_CLASS =
|
||||
"inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
|
||||
const AUTHOR_LINK_CLASS =
|
||||
"inline-flex items-center gap-1.5 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
|
||||
|
||||
function PluginPage() {
|
||||
const { plugin, installs } = Route.useLoaderData();
|
||||
@@ -55,67 +59,42 @@ function PluginPage() {
|
||||
|
||||
return (
|
||||
<SiteShell width="default">
|
||||
<div className="max-w-3xl">
|
||||
<a href="/plugins" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
← Plugins
|
||||
</a>
|
||||
<h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1>
|
||||
<p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p>
|
||||
<div className="mt-6">
|
||||
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']">
|
||||
<AuthorLink author={author} className={META_LINK_CLASS}>
|
||||
{author.name}
|
||||
</AuthorLink>
|
||||
{category && (
|
||||
<a href={categoryHref(category.slug)} className={META_LINK_CLASS}>
|
||||
{category.label}
|
||||
</a>
|
||||
)}
|
||||
<span className="tabular-nums">{formatInstalls(installs)} installs</span>
|
||||
<span className="font-mono">{pluginVersion(plugin)}</span>
|
||||
<a
|
||||
href={plugin.repository.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={META_LINK_CLASS}
|
||||
>
|
||||
Source
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<a href="/plugins" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
← Plugins
|
||||
</a>
|
||||
<h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1>
|
||||
<p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p>
|
||||
<div className="mt-6 w-fit max-w-full">
|
||||
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']">
|
||||
<AuthorLink author={author} className={AUTHOR_LINK_CLASS}>
|
||||
<AuthorAvatar author={author} size="xs" />
|
||||
{author.name}
|
||||
</AuthorLink>
|
||||
{category && (
|
||||
<a href={categoryHref(category.slug)} className={META_LINK_CLASS}>
|
||||
{category.label}
|
||||
</a>
|
||||
{npm && (
|
||||
<a href={npm} target="_blank" rel="noopener noreferrer" className={META_LINK_CLASS}>
|
||||
npm
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plugin.screenshots.length > 0 && (
|
||||
<div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0">
|
||||
{plugin.screenshots.map((url, index) => (
|
||||
<a
|
||||
key={url}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] md:w-[calc(50%-0.375rem)]"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`${plugin.name} screenshot ${index + 1}`}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<InstallCount count={installs} />
|
||||
<span className="font-mono">{pluginVersion(plugin)}</span>
|
||||
<PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}>
|
||||
Source
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</PluginContentLink>
|
||||
{npm && (
|
||||
<PluginContentLink href={npm} className={META_LINK_CLASS}>
|
||||
npm
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</PluginContentLink>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 border-t border-white/10 pt-10">
|
||||
<DocsMarkdown>{readmeBody(plugin.readme)}</DocsMarkdown>
|
||||
</div>
|
||||
<MediaGallery name={plugin.name} media={plugin.media} />
|
||||
|
||||
<div className="mt-10 border-t border-white/10 pt-10">
|
||||
<PluginOverview>{readmeBody(plugin.readme)}</PluginOverview>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
|
||||
@@ -19,14 +19,17 @@ import {
|
||||
addedAgo,
|
||||
CATEGORIES,
|
||||
type CategorySlug,
|
||||
featuredPlugins,
|
||||
getCategory,
|
||||
getPluginsInCategory,
|
||||
getRegistry,
|
||||
type InstallWindow,
|
||||
mostInstalled,
|
||||
newestFirst,
|
||||
pluginOwner,
|
||||
} from "~/plugins";
|
||||
import { ContributeSection } from "~/plugins/contribute-links";
|
||||
import { PluginsHero } from "~/plugins/hero";
|
||||
import {
|
||||
type BrowseQuery,
|
||||
browseHref,
|
||||
@@ -36,10 +39,10 @@ import {
|
||||
parseSearchTerm,
|
||||
parseSort,
|
||||
parseWindow,
|
||||
SUBMIT_URL,
|
||||
} from "~/plugins/links";
|
||||
import { InstallCount } from "~/plugins/install-count";
|
||||
import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card";
|
||||
import { PluginSearch } from "~/plugins/plugin-search";
|
||||
import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section";
|
||||
import { WindowSwitch } from "~/plugins/window-switch";
|
||||
import "~/styles.css";
|
||||
|
||||
@@ -58,7 +61,9 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = {
|
||||
utils: Wrench,
|
||||
};
|
||||
|
||||
const SUBMIT_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground";
|
||||
// What's new and Featured cards: a swipeable row on phones, a grid from `sm` up.
|
||||
const CARD_ROW_CLASS =
|
||||
"-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4";
|
||||
const SEE_ALL_CLASS =
|
||||
"inline-flex items-center gap-0.5 text-sm text-muted-foreground transition-colors hover:text-foreground";
|
||||
|
||||
@@ -91,8 +96,9 @@ export const Route = createFileRoute("/plugins/")({
|
||||
});
|
||||
|
||||
function PluginsPage() {
|
||||
const { plugins, installs, now } = Route.useLoaderData();
|
||||
const { plugins, featured: featuredIds, installs, now } = Route.useLoaderData();
|
||||
const window = Route.useSearch().window ?? DEFAULT_WINDOW;
|
||||
const featured = featuredPlugins(plugins, featuredIds);
|
||||
const newest = newestFirst(plugins).slice(0, NEW_COUNT);
|
||||
const top = mostInstalled(plugins, installs, window).slice(0, TOP_COUNT);
|
||||
const windowHrefs = useMemo(
|
||||
@@ -104,95 +110,112 @@ function PluginsPage() {
|
||||
[],
|
||||
);
|
||||
const searchScope = useMemo<BrowseQuery>(() => ({ sort: "installs", window }), [window]);
|
||||
const authorCount = new Set(plugins.map(pluginOwner)).size;
|
||||
|
||||
return (
|
||||
<SiteShell width="default">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-3xl font-medium tracking-tight">
|
||||
Plugins
|
||||
<span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground">
|
||||
{plugins.length}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<a href={SUBMIT_URL} className={SUBMIT_CLASS}>
|
||||
Submit a plugin
|
||||
</a>
|
||||
<PluginSearch scope={searchScope} className="w-full sm:w-56" />
|
||||
</div>
|
||||
</div>
|
||||
<PluginsHero
|
||||
pluginCount={plugins.length}
|
||||
authorCount={authorCount}
|
||||
searchScope={searchScope}
|
||||
/>
|
||||
|
||||
<section aria-labelledby="whats-new" className="mt-10">
|
||||
<div className="mb-4 flex items-baseline justify-between gap-4">
|
||||
<h2 id="whats-new" className="text-lg font-medium">
|
||||
What’s new
|
||||
</h2>
|
||||
<a href={browseHref({ sort: "new", window: DEFAULT_WINDOW })} className={SEE_ALL_CLASS}>
|
||||
See all
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4">
|
||||
{newest.map((plugin) => (
|
||||
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="categories" className="mt-14">
|
||||
<h2 id="categories" className="mb-4 text-lg font-medium">
|
||||
Categories
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{CATEGORIES.map((category) => {
|
||||
const Icon = CATEGORY_ICONS[category.slug];
|
||||
return (
|
||||
<div className="mt-16 flex flex-col gap-14">
|
||||
{featured.length > 0 && (
|
||||
<PluginSection labelledBy="featured">
|
||||
<PluginSectionHeader>
|
||||
<div>
|
||||
<PluginSectionTitle id="featured">Featured</PluginSectionTitle>
|
||||
<p className="text-sm text-muted-foreground">A selection of hand picked plugins</p>
|
||||
</div>
|
||||
<a
|
||||
key={category.slug}
|
||||
href={categoryHref(category.slug)}
|
||||
className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-3.5 py-3.5 transition-colors hover:border-white/20 hover:bg-white/[0.05] sm:px-4"
|
||||
href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })}
|
||||
className={SEE_ALL_CLASS}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 text-sm leading-tight text-white">
|
||||
{category.label}
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-extra-muted-foreground">
|
||||
{getPluginsInCategory(plugins, category.slug).length}
|
||||
</span>
|
||||
See all
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</PluginSectionHeader>
|
||||
<div className={CARD_ROW_CLASS}>
|
||||
{featured.map((plugin) => (
|
||||
<NewPluginCard key={plugin.id} plugin={plugin}>
|
||||
<InstallCount count={installs[plugin.id]?.all ?? 0} />
|
||||
</NewPluginCard>
|
||||
))}
|
||||
</div>
|
||||
</PluginSection>
|
||||
)}
|
||||
|
||||
<section
|
||||
id="most-installed"
|
||||
aria-labelledby="most-installed-title"
|
||||
className="mt-14 scroll-mt-8"
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
|
||||
<h2 id="most-installed-title" className="text-lg font-medium">
|
||||
<a
|
||||
href={browseHref({ sort: "installs", window })}
|
||||
className="group inline-flex items-center gap-1"
|
||||
>
|
||||
Most installed
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
<PluginSection labelledBy="whats-new">
|
||||
<PluginSectionHeader>
|
||||
<PluginSectionTitle id="whats-new">What’s new</PluginSectionTitle>
|
||||
<a href={browseHref({ sort: "new", window: DEFAULT_WINDOW })} className={SEE_ALL_CLASS}>
|
||||
See all
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</h2>
|
||||
<WindowSwitch current={window} hrefs={windowHrefs} />
|
||||
</div>
|
||||
<div className="-mx-2 grid gap-x-8 md:grid-cols-2">
|
||||
{top.map((plugin, index) => (
|
||||
<PluginRankRow
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
rank={index + 1}
|
||||
installs={installs[plugin.id]?.[window] ?? 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</PluginSectionHeader>
|
||||
<div className={CARD_ROW_CLASS}>
|
||||
{newest.map((plugin) => (
|
||||
<NewPluginCard key={plugin.id} plugin={plugin}>
|
||||
{addedAgo(plugin, now)}
|
||||
</NewPluginCard>
|
||||
))}
|
||||
</div>
|
||||
</PluginSection>
|
||||
|
||||
<PluginSection labelledBy="categories">
|
||||
<PluginSectionTitle id="categories">Categories</PluginSectionTitle>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{CATEGORIES.map((category) => {
|
||||
const Icon = CATEGORY_ICONS[category.slug];
|
||||
return (
|
||||
<a
|
||||
key={category.slug}
|
||||
href={categoryHref(category.slug)}
|
||||
className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-3.5 py-3.5 transition-colors hover:border-white/20 hover:bg-white/[0.05] sm:px-4"
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 text-sm leading-tight text-white">
|
||||
{category.label}
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-extra-muted-foreground">
|
||||
{getPluginsInCategory(plugins, category.slug).length}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PluginSection>
|
||||
|
||||
<PluginSection
|
||||
id="most-installed"
|
||||
labelledBy="most-installed-title"
|
||||
className="scroll-mt-8"
|
||||
>
|
||||
<PluginSectionHeader>
|
||||
<PluginSectionTitle id="most-installed-title">
|
||||
<a
|
||||
href={browseHref({ sort: "installs", window })}
|
||||
className="group inline-flex items-center gap-1"
|
||||
>
|
||||
Most installed
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
</a>
|
||||
</PluginSectionTitle>
|
||||
<WindowSwitch current={window} hrefs={windowHrefs} />
|
||||
</PluginSectionHeader>
|
||||
<div className="-mx-4 grid gap-x-8 md:grid-cols-2">
|
||||
{top.map((plugin, index) => (
|
||||
<PluginRankRow
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
rank={index + 1}
|
||||
installs={installs[plugin.id]?.[window] ?? 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PluginSection>
|
||||
</div>
|
||||
|
||||
<ContributeSection />
|
||||
</SiteShell>
|
||||
|
||||
@@ -21,6 +21,16 @@ A plugin is a TypeScript project installed into one Paseo daemon. It can add
|
||||
[connect a coding agent as a provider](/docs/plugins/providers). Client
|
||||
contributions run on every Paseo client connected to that daemon, including mobile.
|
||||
|
||||
Browse [published plugins](https://paseo.sh/plugins), then install one on your daemon:
|
||||
|
||||
```bash
|
||||
paseo plugin add owner/slug
|
||||
```
|
||||
|
||||
Or paste `owner/slug` into **Settings → Plugins → Plugin source** and select **Install plugin**.
|
||||
Paseo installs the registry's reviewed artifact. Use `git:owner/repository` or a full Git URL
|
||||
when you want to install directly from a Git repository.
|
||||
|
||||
This guide scaffolds a plugin, runs it, and adds a workspace panel to it.
|
||||
|
||||
## Create a plugin
|
||||
@@ -246,13 +256,13 @@ reload use the installed files.
|
||||
See [plugin sources](/docs/plugins/reference#plugin-sources) for identifier syntax. To share your own plugin, follow
|
||||
[Publish a plugin](/docs/plugins/publishing).
|
||||
|
||||
Plugins published in a Git repository install by shorthand or URL:
|
||||
Plugins published in a Git repository install by explicit Git shorthand or URL:
|
||||
|
||||
```bash
|
||||
paseo plugin add owner/repository
|
||||
paseo plugin add git:owner/repository
|
||||
paseo plugin add https://gitlab.com/group/repository.git
|
||||
paseo plugin add owner/monorepo:plugins/workspace
|
||||
paseo plugin add owner/repository --ref main
|
||||
paseo plugin add git:owner/monorepo:plugins/workspace
|
||||
paseo plugin add git:owner/repository --ref main
|
||||
```
|
||||
|
||||
Append `:relative/path` when the plugin lives below the repository root. `--ref` selects the initial
|
||||
|
||||
@@ -11,9 +11,24 @@ category: Plugins
|
||||
Publish your plugin so other people can install and use it in Paseo. Start with a working
|
||||
[plugin project](/docs/plugins), then choose where to share it:
|
||||
|
||||
- [Plugin registry](#plugin-registry): share a reviewed artifact users install by `owner/slug`.
|
||||
- [npm](#publish-on-npm): publish a package on the public npm registry.
|
||||
- [GitHub or Git](#share-through-github-or-git): let users install from a repository.
|
||||
|
||||
## Plugin registry
|
||||
|
||||
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
|
||||
Users install the reviewed artifact with:
|
||||
|
||||
```bash
|
||||
paseo plugin add owner/slug
|
||||
```
|
||||
|
||||
Publish your artifact through npm or Git, then follow the
|
||||
[registry submission guide](https://github.com/getpaseo/plugins) to submit it for review.
|
||||
Registry installations and updates use the approved revision and plugin path.
|
||||
Direct Git installs use explicit `git:owner/repository` shorthand or a full Git URL.
|
||||
|
||||
## Publish on npm
|
||||
|
||||
The scaffold prepares the package files and development dependencies. You choose the package name
|
||||
@@ -56,6 +71,7 @@ The scaffold includes this `files` list in `package.json`:
|
||||
{
|
||||
"files": [
|
||||
"paseo-plugin.json",
|
||||
"OVERVIEW.md",
|
||||
"index.client.ts",
|
||||
"index.client.tsx",
|
||||
"index.server.ts",
|
||||
@@ -136,12 +152,84 @@ In the app, enter only the source identifier.
|
||||
|
||||
:::
|
||||
|
||||
## Icons and screenshots
|
||||
|
||||
Declare your display name, icon, screenshots, and demo videos in `paseo-plugin.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "review-tools",
|
||||
"name": "Review tools",
|
||||
"description": "Reviews changes before merge",
|
||||
"icon": "assets/icon.png",
|
||||
"media": ["assets/screenshot.png", "https://example.com/review-demo.mp4"],
|
||||
"requirements": { "paseo": ">=0.11.0" }
|
||||
}
|
||||
```
|
||||
|
||||
All three fields are optional. `name` gives the registry and website a display name without
|
||||
changing the installation ID. When omitted, the registry falls back to a humanized ID.
|
||||
Registry and website display requires the registry builder to read these fields from the pinned
|
||||
manifest. That update is a separate follow-up; until it lands, these fields are exposed only
|
||||
through the daemon's installed-plugin list.
|
||||
`icon` must point to a PNG inside the plugin package. `media` accepts paths to images and videos
|
||||
inside the package or HTTPS URLs, in display order. Paths are relative to the manifest; use
|
||||
forward slashes and keep them inside the package. See the [manifest reference](/docs/plugins/reference#project-files)
|
||||
for validation rules.
|
||||
|
||||
Commit local assets alongside the manifest at the pinned revision. For npm, add their directory
|
||||
(for example, `assets/`) to `package.json`'s `files` list and verify it with `npm pack --dry-run`.
|
||||
The scaffold documents these fields in its manifest's `$comment`; add your display name and
|
||||
assets before publishing.
|
||||
|
||||
Manifests using these fields fail to install on daemons before 0.11.0. Set
|
||||
`requirements.paseo` to `>=0.11.0` or a narrower supported range.
|
||||
|
||||
## Your listing page
|
||||
|
||||
Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo.
|
||||
It helps someone decide whether to install your plugin; the install command is already at
|
||||
the top of that page. A README assumes a GitHub audience and carries installation
|
||||
instructions, technical details, and badges. Long, AI-generated READMEs make people read
|
||||
past that material to understand what a plugin does.
|
||||
|
||||
`OVERVIEW.md` is required to list your plugin in the registry. Commit it beside
|
||||
`paseo-plugin.json` in the source repository at the pinned commit. The registry resolves it
|
||||
relative to the manifest, under `pluginPath` for monorepos. Include it in the published npm
|
||||
package too; the scaffold's `files` list includes it. Replace the scaffold's guidance comment
|
||||
with useful facts before publishing.
|
||||
|
||||
The repository overview takes precedence over a registry import stopgap. Online validation
|
||||
fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records
|
||||
that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump
|
||||
requires a repository overview and removes the stopgap in the same PR. A bump without the
|
||||
repository overview fails validation. README files do not supply the overview.
|
||||
|
||||
Author overviews and registry stopgaps follow the same content contract, in this order.
|
||||
Choose headings only when they help; length follows complexity. A theme needs one paragraph.
|
||||
|
||||
1. Explain what the plugin is and does in plain terms first.
|
||||
2. Explain how it works only when that is not obvious.
|
||||
3. Explain setup when needed: settings, accounts, tokens, providers, external tools, or other
|
||||
plugins. Include applicable daemon version and operating system requirements. Setup
|
||||
guidance is allowed; installation instructions are not.
|
||||
4. Explain capabilities and settings worth understanding, what each option does, what the
|
||||
plugin reads or sends and where, permissions, and known limits.
|
||||
|
||||
Use sentence case and plain factual language, with no em dashes. Omit installation commands,
|
||||
badges, changelog, contributing or license sections, marketing, and unsupported claims.
|
||||
Avoid implementation filler such as empty cleanup functions, catalogs of theme-token fields,
|
||||
or lists of absent features. Keep only what helps someone choose the plugin.
|
||||
|
||||
If your plugin is an unchanged imported record, you can propose an author-written overview
|
||||
to replace the registry stopgap. Do not add import credit to your own `OVERVIEW.md`.
|
||||
|
||||
## Share through GitHub or Git
|
||||
|
||||
Push the plugin project to a repository. Users can install it with:
|
||||
|
||||
```bash
|
||||
paseo plugin install github:acme/paseo-review
|
||||
paseo plugin add git:acme/paseo-review
|
||||
```
|
||||
|
||||
For another Git host:
|
||||
|
||||
@@ -52,12 +52,24 @@ The required root manifest is `paseo-plugin.json`:
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Behavior |
|
||||
| -------------- | -------- | ---------------------------------------------------------------------- |
|
||||
| `id` | Yes | Default installation ID. |
|
||||
| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. |
|
||||
| `requirements` | No | Supported Paseo versions, described below. |
|
||||
| `build` | No | Preparation commands, described in the CLI reference. |
|
||||
| Field | Required | Behavior |
|
||||
| -------------- | -------- | ----------------------------------------------------------------------------------------- |
|
||||
| `id` | Yes | Default installation ID. |
|
||||
| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. |
|
||||
| `icon` | No | Relative path to a PNG inside the plugin package. |
|
||||
| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. |
|
||||
| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. |
|
||||
| `requirements` | No | Supported Paseo versions, described below. |
|
||||
| `build` | No | Preparation commands, described in the CLI reference. |
|
||||
|
||||
Paths are relative to `paseo-plugin.json`, use forward slashes, and cannot contain `..`
|
||||
segments. See [icons and screenshots](/docs/plugins/publishing#icons-and-screenshots) for an example.
|
||||
Paseo validates the references without opening local assets or fetching URLs.
|
||||
|
||||
Unknown top-level fields are ignored. Known fields still validate, and unknown keys inside
|
||||
`requirements` are rejected so a misspelled constraint cannot silently skip a compatibility check.
|
||||
Manifests using `name`, `icon`, or `media` require Paseo 0.11.0 or later; older daemons reject
|
||||
these fields during installation.
|
||||
|
||||
### Requirements
|
||||
|
||||
@@ -2258,16 +2270,20 @@ failures stay inside the plugin error boundary.
|
||||
|
||||
Paste one of these source identifiers into **Settings → Plugins**, or pass it to
|
||||
`paseo plugin install`. `paseo plugin add <source>` and `paseo plugin install <source>` are aliases.
|
||||
Absolute host paths are recommended because relative paths resolve against the daemon's working
|
||||
directory. The app does not expand `~`; your shell may expand it before the CLI runs.
|
||||
The CLI resolves relative directory paths from your current working directory and expands `~`
|
||||
to your local home directory, preserving any `:plugin/path` suffix. With `--host`, use an absolute
|
||||
path that exists on the daemon host; plugin files are read there and are never uploaded by the CLI.
|
||||
Paths entered in Settings resolve relative to the daemon's working directory, and `~` expands to
|
||||
the daemon's home directory.
|
||||
|
||||
| Source | Accepted form | Example |
|
||||
| -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| Plugin registry | `owner/slug` or `host/owner/slug` | `acme/review` |
|
||||
| Host directory | Absolute or relative path on the daemon host | `/srv/paseo/plugins/review` |
|
||||
| GitHub repository | `github:owner/repository` or `owner/repository` | `github:acme/paseo-review` |
|
||||
| GitHub repository | `git:owner/repository` or `github:owner/repository` | `github:acme/paseo-review` |
|
||||
| Git repository | `git:<URL or SCP source>`; the prefix is optional for URLs and SCP sources | `git:https://git.example.com/acme/review.git` |
|
||||
| npm package | `npm:<name>[@<version, tag, or range>]`; `npm:` is optional | `npm:@acme/paseo-review@^1.2.0` |
|
||||
| Plugin below a source root | Append `:relative/plugin/path` to any source | `github:acme/monorepo:plugins/review` |
|
||||
| Plugin below a source root | Append `:relative/plugin/path` to a directory, Git, or npm source | `github:acme/monorepo:plugins/review` |
|
||||
|
||||
Git URLs use `https://`, `http://`, `ssh://`, `git://`, or `file://`. SCP sources use
|
||||
`user@host:path`. `file://` selects Git acquisition, not directory installation.
|
||||
@@ -2283,24 +2299,27 @@ The package registry validates the selected version, tag, or range.
|
||||
|
||||
Paseo resolves an identifier in this order:
|
||||
|
||||
1. An existing directory matching the complete identifier on the daemon host wins, including a
|
||||
literal directory containing `:`.
|
||||
1. A bare `owner/slug` is a registry id; local directory sources are `.` or `..`, or start with `./`, `../`, `.\`, `..\`, `/`, `~`, a Windows drive (`C:\` or `C:/`), or a UNC (`\\server\share`) prefix.
|
||||
2. Otherwise, recognize `npm:`, `github:`, or `git:` before interpreting a subdirectory suffix.
|
||||
`git://` is a Git URL scheme. An explicit prefix selects acquisition of that kind.
|
||||
3. Recognize a final `:relative/plugin/path` only when its suffix contains no empty, `.` or `..`
|
||||
segments. A lone `.` selects the source root. Both `/` and `\` separate suffix segments; use `/`
|
||||
across hosts. URL ports and the separator in an SCP source stay part of the source. A suffix
|
||||
that does not satisfy these rules stays part of the identifier.
|
||||
4. Without an explicit prefix, an existing directory matching the remaining source wins.
|
||||
5. Resolve Git URLs and SCP sources as Git; expand exact `owner/repository` shorthand to GitHub
|
||||
HTTPS. `github:` requires that shorthand; `git:` accepts it as well as URLs and SCP sources.
|
||||
6. Resolve a remaining npm package name with its optional selector through the host's registry.
|
||||
4. Resolve bare `owner/slug` through the default plugin registry and `host/owner/slug` through
|
||||
that registry host. Resolve Git URLs and SCP sources as Git. `github:` requires
|
||||
`owner/repository` shorthand; `git:` accepts it as well as URLs and SCP sources.
|
||||
5. Resolve a remaining npm package name with its optional selector through the host's registry.
|
||||
Reject anything else.
|
||||
|
||||
Plugin registry installs are off by default. When the daemon enables them with
|
||||
`pluginRegistryEnabled: true` or `PASEO_PLUGIN_REGISTRY_ENABLED=1`, bare `owner/slug` and
|
||||
`host/owner/slug` resolve through the plugin registry instead of GitHub, the registry record owns
|
||||
the revision and plugin path, and GitHub shorthand requires `github:`.
|
||||
The default registry is `https://plugins.paseo.sh`. Browse [published plugins](https://paseo.sh/plugins)
|
||||
and install with `paseo plugin add owner/slug`. Registry records own the revision and plugin path.
|
||||
Use `git:owner/repository` for explicit GitHub shorthand or a full Git URL.
|
||||
Set `PASEO_PLUGIN_REGISTRY` to use a self-hosted default URL, including a path prefix.
|
||||
Private registry credentials use `pluginRegistries: { "host": { "authorization": "Bearer token" } }`
|
||||
in daemon config. Restart the daemon after changing these startup settings. Credentials go only
|
||||
to that registry, never to artifact hosts or redirects. Installed plugins retain their recorded
|
||||
source and registry URL when defaults change.
|
||||
|
||||
Directory lookup happens on the daemon host. The app uses the `paseo-plugin.json` ID; the CLI
|
||||
accepts `--id <runtime-id>` to override it. An existing installation ID is rejected without changing
|
||||
@@ -2347,9 +2366,10 @@ deletes its managed files; removing a directory plugin keeps your source directo
|
||||
paseo plugin init /absolute/path/to/plugin
|
||||
paseo plugin install /absolute/path/to/plugin
|
||||
paseo plugin install /absolute/path/to/plugin --id another-runtime-id
|
||||
paseo plugin add owner/repository
|
||||
paseo plugin add owner/slug
|
||||
paseo plugin add git:owner/repository
|
||||
paseo plugin add https://git.example.com/owner/repository.git --ref main
|
||||
paseo plugin add owner/monorepo:plugins/review
|
||||
paseo plugin add git:owner/monorepo:plugins/review
|
||||
paseo plugin ls [id]
|
||||
paseo plugin update <id>
|
||||
paseo plugin update --all --check
|
||||
|
||||
@@ -64,6 +64,7 @@ The generated project contains:
|
||||
```text
|
||||
my-plugin/
|
||||
paseo-plugin.json
|
||||
OVERVIEW.md
|
||||
package.json
|
||||
tsconfig.json
|
||||
index.client.tsx
|
||||
@@ -638,6 +639,47 @@ Do not edit a local config when the target is a remote daemon. Perform the edit
|
||||
|
||||
When the same screen or sidebar item exists on several connected hosts, Paseo shows it once. The screen header has a host picker; a sidebar item uses the host of the screen on display, else the host last picked in one of the plugin's screens. The selected host owns the bundle, SDK calls, RPCs, and query cache. An offline selected host does not fall through to another host. Attachment sources stay scoped to the composer's host.
|
||||
|
||||
## Publishing a listing
|
||||
|
||||
Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo.
|
||||
It helps someone decide whether to install your plugin; the install command is already at
|
||||
the top of that page. A README assumes a GitHub audience and carries installation
|
||||
instructions, technical details, and badges. Long, AI-generated READMEs make people read
|
||||
past that material to understand what a plugin does.
|
||||
|
||||
`OVERVIEW.md` is required to list your plugin in the registry. Commit it beside
|
||||
`paseo-plugin.json` in the source repository at the pinned commit. The registry resolves it
|
||||
relative to the manifest, under `pluginPath` for monorepos. Include it in the published npm
|
||||
package too; the scaffold's `files` list includes it. Replace the scaffold's guidance comment
|
||||
with useful facts before publishing.
|
||||
|
||||
The repository overview takes precedence over a registry import stopgap. Online validation
|
||||
fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records
|
||||
that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump
|
||||
requires a repository overview and removes the stopgap in the same PR. A bump without the
|
||||
repository overview fails validation. README files do not supply the overview.
|
||||
|
||||
Author overviews and registry stopgaps follow the same content contract, in this order.
|
||||
Choose headings only when they help; length follows complexity. A theme needs one paragraph.
|
||||
|
||||
1. Explain what the plugin is and does in plain terms first.
|
||||
2. Explain how it works only when that is not obvious.
|
||||
3. Explain setup when needed: settings, accounts, tokens, providers, external tools, or other
|
||||
plugins. Include applicable daemon version and operating system requirements. Setup
|
||||
guidance is allowed; installation instructions are not.
|
||||
4. Explain capabilities and settings worth understanding, what each option does, what the
|
||||
plugin reads or sends and where, permissions, and known limits.
|
||||
|
||||
Use sentence case and plain factual language, with no em dashes. Omit installation commands,
|
||||
badges, changelog, contributing or license sections, marketing, and unsupported claims.
|
||||
Avoid implementation filler such as empty cleanup functions, catalogs of theme-token fields,
|
||||
or lists of absent features. Keep only what helps someone choose the plugin.
|
||||
|
||||
If your plugin is an unchanged imported record, you can propose an author-written overview
|
||||
to replace the registry stopgap. Do not add import credit to your own `OVERVIEW.md`.
|
||||
|
||||
See [Your listing page](https://paseo.sh/docs/plugins/publishing.md#your-listing-page).
|
||||
|
||||
## Typecheck and manage
|
||||
|
||||
When editing a plugin, typecheck its source before install or reload:
|
||||
|
||||
Reference in New Issue
Block a user