From b5e9747e2a59b694a3e4d827202d686c2e4612c9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 3 Oct 2026 16:30:13 +0100 Subject: [PATCH] Add the reviewed plugin registry and the unlinked plugin directory pages (#5986) * feat(plugins): install reviewed artifacts from open registries * fix(website): build protocol before deployment checks * fix(website): make plugin pages discoverable to search engines Resolve missing registry entries in route loaders to return HTTP 404. Serve a registry-backed sitemap so newly listed plugins and authors are discoverable without rebuilding the website. * feat(website): keep plugin directory unlinked for coordinated launch Preserve direct routes and SEO while retaining the partner directory link. Cover nested Git artifacts through registry installation and pinned website commands. * fix(website): link plugin cards by registry owner and stub the clipboard in e2e Plugin and author URLs come from the owner segment of the plugin ID, not the author's GitHub login, so a listed plugin always resolves. The Playwright spec records clipboard writes instead of granting a permission WebKit does not have. --- .github/workflows/deploy-website.yml | 6 +- docs/plugins.md | 40 ++- package-lock.json | 3 +- packages/cli/src/commands/plugin/index.ts | 2 +- packages/protocol/src/messages.ts | 15 +- packages/protocol/src/plugin-registry.ts | 75 +++++ .../src/plugin-source-reference.test.ts | 18 ++ .../protocol/src/plugin-source-reference.ts | 3 + packages/server/src/server/bootstrap.ts | 8 +- .../server/src/server/config-environment.ts | 1 + packages/server/src/server/config.test.ts | 15 + packages/server/src/server/config.ts | 2 + .../server/src/server/persisted-config.ts | 2 + .../plugins/managed-source.posix.test.ts | 177 +++++++++++- .../src/server/plugins/managed-source.ts | 87 +++++- .../server/plugins/managed-source/registry.ts | 54 ++++ packages/website/e2e/plugins.spec.ts | 216 +++++++++++++++ packages/website/package.json | 1 + packages/website/public/robots.txt | 4 + packages/website/public/sitemap-index.xml | 5 + .../website/src/components/breadcrumbs.tsx | 32 +++ .../src/components/docs-breadcrumbs.tsx | 34 +-- .../website/src/components/landing-page.tsx | 17 +- .../website/src/components/site-footer.tsx | 9 + .../website/src/components/site-header.tsx | 9 + packages/website/src/meta.ts | 8 +- packages/website/src/plugins/author-link.tsx | 51 ++++ packages/website/src/plugins/categories.ts | 48 ++++ .../website/src/plugins/category-link.tsx | 36 +++ packages/website/src/plugins/index.ts | 2 + packages/website/src/plugins/launch.ts | 2 + packages/website/src/plugins/loader.ts | 14 + packages/website/src/plugins/not-found.tsx | 21 ++ packages/website/src/plugins/plugin-card.tsx | 49 ++++ .../src/plugins/plugin-page-header.tsx | 10 + packages/website/src/plugins/plugin-tile.tsx | 22 ++ packages/website/src/plugins/published.ts | 106 +++++++ packages/website/src/plugins/registry.test.ts | 84 ++++++ packages/website/src/plugins/registry.ts | 94 +++++++ packages/website/src/routeTree.gen.ts | 69 ++++- .../website/src/routes/plugins/$owner.tsx | 94 +++++++ .../src/routes/plugins/$owner_.$slug.tsx | 235 ++++++++++++++++ packages/website/src/routes/plugins/index.tsx | 261 ++++++++++++++++++ packages/website/src/server-entry.ts | 8 + packages/website/src/styles.css | 6 + packages/website/vite.config.ts | 6 + packages/website/wrangler.toml | 1 + public-docs/plugins/reference.md | 14 +- 48 files changed, 1998 insertions(+), 78 deletions(-) create mode 100644 packages/protocol/src/plugin-registry.ts create mode 100644 packages/server/src/server/plugins/managed-source/registry.ts create mode 100644 packages/website/e2e/plugins.spec.ts create mode 100644 packages/website/public/robots.txt create mode 100644 packages/website/public/sitemap-index.xml create mode 100644 packages/website/src/components/breadcrumbs.tsx create mode 100644 packages/website/src/plugins/author-link.tsx create mode 100644 packages/website/src/plugins/categories.ts create mode 100644 packages/website/src/plugins/category-link.tsx create mode 100644 packages/website/src/plugins/index.ts create mode 100644 packages/website/src/plugins/launch.ts create mode 100644 packages/website/src/plugins/loader.ts create mode 100644 packages/website/src/plugins/not-found.tsx create mode 100644 packages/website/src/plugins/plugin-card.tsx create mode 100644 packages/website/src/plugins/plugin-page-header.tsx create mode 100644 packages/website/src/plugins/plugin-tile.tsx create mode 100644 packages/website/src/plugins/published.ts create mode 100644 packages/website/src/plugins/registry.test.ts create mode 100644 packages/website/src/plugins/registry.ts create mode 100644 packages/website/src/routes/plugins/$owner.tsx create mode 100644 packages/website/src/routes/plugins/$owner_.$slug.tsx create mode 100644 packages/website/src/routes/plugins/index.tsx diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index cd20556182..f99dabaea9 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -7,6 +7,7 @@ on: - "CHANGELOG.md" - "public-docs/**" - "packages/website/**" + - "packages/protocol/**" - "package.json" - "package-lock.json" - "patches/**" @@ -29,7 +30,10 @@ jobs: cache: "npm" - name: Install dependencies - run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --include-workspace-root + run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --workspace=@getpaseo/protocol --include-workspace-root + + - name: Build protocol + run: npm run build:protocol - name: Typecheck run: npm run typecheck --workspace=@getpaseo/website diff --git a/docs/plugins.md b/docs/plugins.md index ab12fe7dd2..e0d94de357 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -115,27 +115,37 @@ the bundled integration; an entry with `extends` shadows it with a custom provid [provider contributions](#contribute-a-provider) for the contract and [Muse Code](../public-docs/muse-code.md) for setup, per-agent options, and version limitations. +## Install from a registry + +`paseo plugin install owner/slug` installs the registry's reviewed artifact. 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. + +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 +`pluginRegistries: { "host": { "authorization": "Bearer token" } }`. +Restart your daemon after changing these startup settings. Credentials go only to the registry, +never artifact hosts or redirects. Git/npm use their own host authentication. + +The [open registry protocol](https://github.com/getpaseo/plugins/blob/main/PROTOCOL.md) +owns static hosting, record shapes, pins, and advisory install counts. + ## Install a Git source -GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing -directory always wins over shorthand resolution. +GitHub shorthand requires `github:`. Other hosts use a Git URL. An existing directory +still wins over source resolution. ```bash -paseo plugin add owner/repository -paseo plugin add https://gitlab.com/group/repository.git -paseo plugin add https://git.example.com/owner/repository.git -paseo plugin add owner/monorepo:plugins/review -paseo plugin add owner/repository --ref main -paseo plugin ls -paseo plugin update review -paseo plugin update --all +paseo plugin install github:owner/repository +paseo plugin install https://gitlab.com/group/repository.git +paseo plugin install github:owner/monorepo:plugins/review +paseo plugin install github:owner/repository --ref main ``` -Append `:relative/path` to the source when the plugin lives below the repository root. - -`--ref` chooses the initial branch, tag, or commit once. Ordinary updates resolve the remote's -current default HEAD and ask for approval. `ls` reports the installed commit without contacting the remote. -Removing a Git source deletes Paseo's managed checkout. +Append `:relative/path` for a plugin below the repository root. `--ref` chooses the initial +branch, tag, or commit once. Updates of explicit Git sources resolve the remote's default HEAD +and ask for approval. `ls` reports the installed commit without contacting the remote. ## Managed source ownership diff --git a/package-lock.json b/package-lock.json index 6e8fb5385f..01d3bd7f4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40165,7 +40165,8 @@ "remark-directive": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^4.0.2", - "wrangler": "^4.75.0" + "wrangler": "^4.75.0", + "@getpaseo/protocol": "*" }, "devDependencies": { "@tailwindcss/vite": "^4.1.8", diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index ae3541eeb6..4250ba8620 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -218,7 +218,7 @@ export function createPluginCommand(): Command { .description("Trust and install a plugin from a directory, Git repository, or npm package") .argument( "", - "Host directory, Git or npm source, optionally followed by :plugin/path", + "Registry owner/slug or host/owner/slug, host directory, github:owner/repo, git: or npm: source", ) .option("--id ", "Runtime plugin ID (defaults to paseo-plugin.json id)") .option("--ref ", "Git branch, tag, or commit") diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index a4f883604e..47610620d7 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,3 +1,4 @@ +import { PluginRegistryIdentitySchema } from "./plugin-registry.js"; import { AgentProfileSchema, AgentSkillSelectionSchema } from "./agent-profile.js"; export { AgentProfileSchema, @@ -1451,8 +1452,18 @@ export const PluginSourceInstallRequestSchema = z.object({ export const PluginSourceIdentitySchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("directory"), path: z.string() }), - z.object({ kind: z.literal("git"), remote: z.string(), pluginPath: z.string() }), - z.object({ kind: z.literal("npm"), packageName: z.string(), pluginPath: z.string() }), + z.object({ + kind: z.literal("git"), + remote: z.string(), + pluginPath: z.string(), + registry: PluginRegistryIdentitySchema.optional(), + }), + z.object({ + kind: z.literal("npm"), + packageName: z.string(), + pluginPath: z.string(), + registry: PluginRegistryIdentitySchema.optional(), + }), ]); export const PluginInstallationSchema = z.object({ identity: PluginSourceIdentitySchema, diff --git a/packages/protocol/src/plugin-registry.ts b/packages/protocol/src/plugin-registry.ts new file mode 100644 index 0000000000..36c5354c88 --- /dev/null +++ b/packages/protocol/src/plugin-registry.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +export const PluginRegistryIdSchema = z + .string() + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/); +export const PluginRegistryIdentitySchema = z.object({ + url: z.string().url(), + id: PluginRegistryIdSchema, +}); +export const PluginRegistriesSchema = z.record( + z.string(), + z.object({ authorization: z.string().min(1) }), +); +export const PluginRegistryArtifactSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("npm"), + package: z.string().regex(/^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/), + version: z.string().regex(/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/), + resolved: z.string().url(), + integrity: z.string().regex(/^sha512-[A-Za-z0-9+/]+=*$/), + }), + z.object({ + kind: z.literal("git"), + remote: z.string().min(1), + commit: z.string().regex(/^[0-9a-f]{40,64}$/), + pluginPath: z + .string() + .regex(/^[^\\:]+$/) + .optional(), + }), +]); +export const PublishedPluginSchema = z.object({ + id: PluginRegistryIdSchema, + name: z.string(), + description: z.string(), + license: z.string().optional(), + categories: z.array(z.string()), + author: z.object({ github: z.string(), name: z.string().optional(), npm: z.string().optional() }), + repository: z.object({ url: z.string().url(), commit: z.string().optional() }), + artifact: PluginRegistryArtifactSchema, + icon: z.string().url().optional(), + screenshots: z.array(z.string().url()), + submittedAt: z.string(), + reviewedAt: z.string(), + updatedAt: z.string(), + publishedAt: z.string(), + installs: z.number().int().nonnegative().optional(), +}); +export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() }); +export const PluginRegistryIndexSchema = z.object({ + schemaVersion: z.literal(1), + registry: z.object({ name: z.string(), url: z.string().url() }), + categories: z.array( + z.object({ slug: z.string(), label: z.string(), description: z.string().optional() }), + ), + plugins: z.array(PublishedPluginSchema), + generatedAt: z.string(), +}); +export type PluginRegistryIdentity = z.infer; +export type PluginRegistries = z.infer; + +export function parsePluginRegistryReference( + source: string, + defaultUrl = "https://plugins.paseo.sh", +): PluginRegistryIdentity | null { + const segments = source.split("/"); + if (segments.length === 2 && PluginRegistryIdSchema.safeParse(source).success) + return { url: defaultUrl.replace(/\/+$/, ""), id: source }; + const [host, ...rest] = segments; + if (segments.length !== 3 || !host || !/[.:]/.test(host) || /[@?#\\]/.test(host)) return null; + const id = rest.join("/"); + if (!PluginRegistryIdSchema.safeParse(id).success) return null; + const url = new URL(`https://${host}`); + return { url: url.origin, id }; +} diff --git a/packages/protocol/src/plugin-source-reference.test.ts b/packages/protocol/src/plugin-source-reference.test.ts index 8d12f0b0c5..b8ce6e7346 100644 --- a/packages/protocol/src/plugin-source-reference.test.ts +++ b/packages/protocol/src/plugin-source-reference.test.ts @@ -1,3 +1,4 @@ +import { parsePluginRegistryReference } from "./plugin-registry.js"; import { describe, expect, it } from "vitest"; import { parsePluginSourceReference } from "./plugin-source-reference.js"; @@ -13,6 +14,7 @@ describe("plugin source references", () => { undefined, ], ["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], ["owner/repository:plugins/review", "owner/repository", "plugins/review"], [ @@ -43,3 +45,19 @@ describe("plugin source references", () => { expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath }); }); }); + +it("addresses default and private registries without treating explicit sources as registry IDs", () => { + expect(parsePluginRegistryReference("omercnet/dracula")).toEqual({ + url: "https://plugins.paseo.sh", + id: "omercnet/dracula", + }); + expect(parsePluginRegistryReference("acme/plugin", "https://internal.example/registry/")).toEqual( + { url: "https://internal.example/registry", id: "acme/plugin" }, + ); + expect(parsePluginRegistryReference("internal.example:8443/acme/plugin")).toEqual({ + url: "https://internal.example:8443", + id: "acme/plugin", + }); + expect(parsePluginRegistryReference("github:acme/plugin")).toBeNull(); + expect(parsePluginRegistryReference("npm:@acme/plugin")).toBeNull(); +}); diff --git a/packages/protocol/src/plugin-source-reference.ts b/packages/protocol/src/plugin-source-reference.ts index d944ae13b7..229dc1df88 100644 --- a/packages/protocol/src/plugin-source-reference.ts +++ b/packages/protocol/src/plugin-source-reference.ts @@ -1,3 +1,4 @@ +import { parsePluginRegistryReference } from "./plugin-registry.js"; import type { PluginInstallation } from "./messages.js"; export interface PluginSourceReference { source: string; @@ -5,6 +6,7 @@ export interface PluginSourceReference { } 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:" }); @@ -56,6 +58,7 @@ function isPortableRelativePluginPath(pluginPath: string): boolean { export function formatPluginIdentity(identity: PluginInstallation["identity"]): string { if (identity.kind === "directory") return identity.path; + if (identity.registry) return `${new URL(identity.registry.url).host}/${identity.registry.id}`; const source = identity.kind === "npm" ? `npm:${identity.packageName}` : `git:${identity.remote}`; return identity.pluginPath === "." ? source : `${source}:${identity.pluginPath}`; } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index ebf6e0bc81..3c2428c2dc 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,3 +1,4 @@ +import type { PluginRegistries } from "@getpaseo/protocol/plugin-registry"; import { describeHookWorkspace } from "./plugins/lifecycle/index.js"; import express from "express"; import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http"; @@ -411,6 +412,8 @@ export interface PaseoDaemonConfig { skillSelection?: AgentSkillSelection; pluginsEnabled?: boolean; plugins?: Record; + pluginRegistries?: PluginRegistries; + pluginRegistryUrl?: string; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -616,7 +619,10 @@ export async function createPaseoDaemon( const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore); const browserToolsBroker = new BrowserToolsBroker({}); const pluginRuntime = new PluginService(logger, daemonConfigStore, daemonVersion, { - managedSources: new ManagedPluginSources(config.paseoHome), + managedSources: new ManagedPluginSources(config.paseoHome, { + registries: config.pluginRegistries, + defaultUrl: config.pluginRegistryUrl, + }), builtinPlugins: resolveBuiltinPluginLoader(dependencies), settingsDirectory: path.join(config.paseoHome, "plugin-settings"), }); diff --git a/packages/server/src/server/config-environment.ts b/packages/server/src/server/config-environment.ts index 92bf9183ee..24134035de 100644 --- a/packages/server/src/server/config-environment.ts +++ b/packages/server/src/server/config-environment.ts @@ -29,6 +29,7 @@ export const DAEMON_SETTING_ENV_KEYS = [ "PASEO_LOG_ROTATE_COUNT", "PASEO_LOG_ROTATE_SIZE", "PASEO_PASSWORD", + "PASEO_PLUGIN_REGISTRY", "PASEO_RELAY_ENABLED", "PASEO_RELAY_ENDPOINT", "PASEO_RELAY_PUBLIC_ENDPOINT", diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index 80045a4454..a71b5621e8 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -200,3 +200,18 @@ describe("server config", () => { ).toBe(path.join(packageRoot, "app-dist")); }); }); + +test("loads private plugin registry settings through the configuration boundary", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "paseo-registry-config-")); + try { + const pluginRegistries = { "plugins.example.test": { authorization: "Bearer fixture" } }; + await writeFile(path.join(home, "config.json"), JSON.stringify({ pluginRegistries })); + const config = loadConfig(home, { + env: { PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal" }, + }); + expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal"); + expect(config.pluginRegistries).toEqual(pluginRegistries); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index a683ec69fb..7b2d06560e 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -626,6 +626,8 @@ export function resolveConfigFromPersisted( skillSelection: persisted.agents?.skills?.selection, pluginsEnabled: persisted.pluginsEnabled ?? false, plugins: persisted.plugins, + pluginRegistries: persisted.pluginRegistries, + pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 40c4150907..79e3d97218 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -1,3 +1,4 @@ +import { PluginRegistriesSchema } from "@getpaseo/protocol/plugin-registry"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; @@ -306,6 +307,7 @@ export const PersistedConfigSchema = z .optional(), providers: ProvidersSchema.optional(), + pluginRegistries: PluginRegistriesSchema.optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index 9d9bad15f3..7723721eb4 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -48,7 +48,7 @@ describe("managed Git plugin sources", () => { await expect(failure).rejects.not.toThrow(/oauth2|super-secret/); }); - it("normalizes explicit GitHub identifiers and shorthand to the same Git source", async () => { + it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => { const repository = await createRepository(); const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-github-home-")); roots.push(home); @@ -61,11 +61,7 @@ describe("managed Git plugin sources", () => { Object.assign(process.env, overlay); try { const sources = new ManagedPluginSources(home); - for (const source of [ - "github:fixture/repository", - "fixture/repository", - "git:fixture/repository", - ]) { + for (const source of ["github:fixture/repository", "git:fixture/repository"]) { const candidate = await sources.prepareInstall({ source }); expect(candidate.record).toMatchObject({ kind: "git", @@ -275,3 +271,172 @@ describe("managed Git plugin sources", () => { } }, 30_000); }); + +describe("registry plugin sources", () => { + it("installs a bare id from a private registry and polls its pin without install intent", async () => { + const repository = await createRepository(); + await mkdir(path.join(repository, "packages/example"), { recursive: true }); + await rename( + path.join(repository, "paseo-plugin.json"), + path.join(repository, "packages/example/paseo-plugin.json"), + ); + await rename( + path.join(repository, "index.server.ts"), + path.join(repository, "packages/example/index.server.ts"), + ); + await commitAll(repository, "move plugin into monorepo"); + const revision = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }); + const commit = revision.stdout.trim(); + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-home-")); + roots.push(home); + const requests: Array<{ + path: string | undefined; + intent: string | string[] | undefined; + authorization: string | undefined; + }> = []; + const server = createServer((req, res) => { + requests.push({ + path: req.url, + intent: req.headers["x-paseo-install"], + authorization: req.headers.authorization, + }); + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + id: "acme/example", + name: "Example", + description: "Test", + categories: [], + author: { github: "acme" }, + repository: { url: "https://github.com/acme/example" }, + artifact: { + kind: "git", + remote: pathToFileURL(repository).href, + commit, + pluginPath: "packages/example", + }, + screenshots: [], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + readme: "# Test", + }), + ); + }); + await new Promise((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 host = `127.0.0.1:${address.port}`; + const url = `http://${host}/internal`; + try { + const sources = new ManagedPluginSources(home, { + defaultUrl: url, + registries: { [host]: { authorization: "Bearer test" } }, + }); + let candidate = await sources.prepareInstall({ source: "acme/example" }); + candidate = await sources.place("managed-example", candidate); + expect( + JSON.parse(await readFile(path.join(candidate.directory, "paseo-plugin.json"), "utf8")).id, + ).toBe("managed-example"); + await sources.verifyCandidate("managed-example", candidate); + sources.commit("managed-example", candidate.record); + const preview = await sources.preview("managed-example", candidate.directory); + expect(preview.outcome).toBe("current"); + expect(preview.current?.identity).toEqual({ + kind: "git", + remote: pathToFileURL(repository).href, + pluginPath: "packages/example", + registry: { url, id: "acme/example" }, + }); + expect(requests).toEqual([ + { path: "/internal/plugins/acme/example.json", intent: "1", authorization: "Bearer test" }, + { + path: "/internal/plugins/acme/example.json", + intent: undefined, + authorization: "Bearer test", + }, + ]); + } finally { + await new Promise((resolve, reject) => + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }), + ); + } + }); +}); + +it("installs and updates only the npm artifacts pinned by the plugin registry", async () => { + const { startNpmRegistry, npmPluginPackages } = + await import("../../../../../scripts/test-support/npm-registry.mjs"); + const { resolveNpm } = await import("./managed-source/npm.js"); + const npm = await startNpmRegistry(npmPluginPackages()); + const previous = process.env.npm_config_userconfig; + process.env.npm_config_userconfig = npm.userconfig; + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-npm-")); + roots.push(home); + const intents: Array = []; + let pin = await resolveNpm("paseo-fixture-plugin", "1.0.0", home); + const server = createServer((request, response) => { + intents.push(request.headers["x-paseo-install"]); + response.end( + 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((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 = { defaultUrl: `http://127.0.0.1:${address.port}` }; + try { + const sources = new ManagedPluginSources(home, options); + const candidate = await sources.place( + "example", + await sources.prepareInstall({ source: "acme/example" }), + ); + await sources.verifyCandidate("example", candidate); + sources.commit("example", candidate.record); + expect((await sources.describe("example", candidate.directory)).currentRevision).toBe("1.0.0"); + expect((await sources.preview("example", candidate.directory)).outcome).toBe("current"); + pin = await resolveNpm("paseo-fixture-plugin", "1.1.0", home); + const restarted = new ManagedPluginSources(home, options); + const preview = await restarted.preview("example", candidate.directory); + expect(preview.target).toEqual({ kind: "npm", ...pin }); + expect(preview.outcome).toBe("update"); + const updated = await restarted.place( + "example", + await restarted.prepareUpdate(preview.proposal!, candidate.directory), + ); + await restarted.verifyCandidate("example", updated); + expect(updated.record.registry).toEqual({ url: options.defaultUrl, id: "acme/example" }); + expect(intents).toEqual(["1", undefined, undefined, undefined]); + pin = { ...pin, integrity: "sha512-YWJj" }; + await expect(sources.prepareInstall({ source: "acme/example" })).rejects.toThrow( + "changed since review", + ); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await npm.close(); + if (previous === undefined) delete process.env.npm_config_userconfig; + else process.env.npm_config_userconfig = previous; + } +}, 30_000); diff --git a/packages/server/src/server/plugins/managed-source.ts b/packages/server/src/server/plugins/managed-source.ts index a2ab4a90f8..366d8d53b0 100644 --- a/packages/server/src/server/plugins/managed-source.ts +++ b/packages/server/src/server/plugins/managed-source.ts @@ -1,3 +1,8 @@ +import { + PluginRegistryIdentitySchema, + parsePluginRegistryReference, +} from "@getpaseo/protocol/plugin-registry"; +import { resolveRegistryPlugin, type RegistryOptions } from "./managed-source/registry.js"; import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { mkdir, realpath, rename, rm } from "node:fs/promises"; @@ -23,8 +28,12 @@ const GIT_TIMEOUT_MS = 120_000; const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" } as const; // COMPAT(plugin-source-record): v0.8.0; accept and strip pre-correction selector/revision/root copies until daemon floor supports artifact-derived metadata. const ManagedPluginRecordSchema = z.union([ - z.object({ kind: z.literal("git").default("git"), remote: z.string().min(1) }), - z.object({ kind: z.literal("npm") }), + z.object({ + kind: z.literal("git").default("git"), + remote: z.string().min(1), + registry: PluginRegistryIdentitySchema.optional(), + }), + z.object({ kind: z.literal("npm"), registry: PluginRegistryIdentitySchema.optional() }), ]); export type ManagedPluginRecord = z.infer; export interface ManagedPluginCandidate { @@ -33,6 +42,7 @@ export interface ManagedPluginCandidate { directory: string; record: ManagedPluginRecord; versionRoot: string; + target?: PluginUpdateTarget; } interface InstallInput { source: string; @@ -49,7 +59,10 @@ export class ManagedPluginSources { private readonly root: string; private readonly metadataPath: string; private readonly records: Record; - constructor(paseoHome: string) { + constructor( + paseoHome: string, + private readonly registryOptions: RegistryOptions = {}, + ) { this.root = path.resolve(paseoHome, "plugins"); this.metadataPath = path.join(this.root, "sources.json"); this.records = existsSync(this.metadataPath) @@ -85,6 +98,7 @@ export class ManagedPluginSources { sourceRoot: path.join(versionRoot, "checkout"), identity: { kind: "git", + ...(record.registry ? { registry: record.registry } : {}), remote: redactRemoteCredentials(record.remote), pluginPath: parts.slice(2).join("/") || ".", }, @@ -100,6 +114,7 @@ export class ManagedPluginSources { sourceRoot: path.join(versionRoot, "node_modules", packageName), identity: { kind: "npm", + ...(record.registry ? { registry: record.registry } : {}), packageName, pluginPath: parts.slice(2 + packageParts).join("/") || ".", }, @@ -132,6 +147,14 @@ export class ManagedPluginSources { input: InstallInput, target?: PluginUpdateTarget, ): Promise { + const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl); + if (registry) { + if (input.ref || 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); + return { ...candidate, record: { ...candidate.record, registry } }; + } const pluginPath = normalizePluginPath(input.pluginPath); const versionRoot = await this.createStagingRoot(); try { @@ -163,7 +186,7 @@ export class ManagedPluginSources { assertPluginPath(sourceRoot, directory); await assertRealContainment(sourceRoot, directory); const { id: defaultId, build } = await readPluginManifest(directory); - return { build, defaultId, directory, record, versionRoot }; + return { build, defaultId, directory, record, versionRoot, ...(target ? { target } : {}) }; } catch (error) { await rm(versionRoot, { recursive: true, force: true }); throw error; @@ -202,7 +225,18 @@ export class ManagedPluginSources { const location = this.locate(pluginId, configuredPath); let target: PluginUpdateTarget; let outcome: PluginUpdatePreview["outcome"]; - if (current.identity.kind === "npm") { + if (current.identity.registry) { + if (selection) throw new Error("Registry updates use only the reviewed registry pin"); + const resolved = await resolveRegistryPlugin( + current.identity.registry, + this.registryOptions, + false, + ); + assertRegistryArtifactIdentity(current.identity, resolved.artifact); + target = resolved.target; + const revision = target.kind === "npm" ? target.version : target.commit; + outcome = revision === current.currentRevision ? "current" : "update"; + } else if (current.identity.kind === "npm") { target = { kind: "npm", ...(await resolveNpm( @@ -211,10 +245,7 @@ export class ManagedPluginSources { this.root, )), }; - const comparison = compareVersions(target.version, current.currentRevision); - if (comparison === 0) outcome = "current"; - else if (comparison < 0 && !selection) outcome = "installed-newer"; - else outcome = "update"; + outcome = npmUpdateOutcome(target.version, current.currentRevision, selection); } else { const record = this.records[pluginId]; if (record?.kind !== "git") throw new Error("Git source is no longer configured"); @@ -271,6 +302,14 @@ export class ManagedPluginSources { await this.assertCurrent(proposal, configuredPath); const identity = this.locate(proposal.id, configuredPath).identity; const record = this.records[proposal.id]; + 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)) + throw new Error("Registry pin changed since review; check again"); + const candidate = await this.prepareInstall(resolved.input, proposal.target); + return { ...candidate, record: { ...candidate.record, registry: identity.registry } }; + } if (identity.kind === "git" && record?.kind === "git" && proposal.target.kind === "git") return this.prepareInstall( { @@ -296,6 +335,7 @@ export class ManagedPluginSources { candidate: ManagedPluginCandidate, target?: PluginUpdateTarget, ): Promise { + target ??= candidate.target; const location = this.locate(pluginId, candidate.directory, candidate.record); const revision = await this.revision(location, candidate.directory); if (target?.kind === "git" && revision !== target.commit) @@ -395,6 +435,7 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s return []; } function normalizeGitSource(source: string): string { + const explicit = 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)) { @@ -405,7 +446,7 @@ function normalizeGitSource(source: string): string { source = source.slice(4); } const github = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(source); - if (github) { + if (github && explicit) { const repository = github[2].replace(/\.git$/, ""); return `https://github.com/${github[1]}/${repository}.git`; } @@ -578,3 +619,29 @@ async function revParse(cwd: string, ref: string): Promise { const { stdout } = await runGitCommand(["rev-parse", "--verify", ref], { cwd }); return stdout.trim(); } + +function assertRegistryArtifactIdentity( + identity: Exclude, + artifact: Awaited>["artifact"], +): void { + const matches = + identity.kind === "npm" && artifact.kind === "npm" + ? identity.packageName === artifact.package && identity.pluginPath === "." + : identity.kind === "git" && + artifact.kind === "git" && + identity.remote === redactRemoteCredentials(artifact.remote) && + identity.pluginPath === (artifact.pluginPath ?? "."); + if (!matches) + throw new Error("Registry artifact source changed; reinstall to approve the new source"); +} + +function npmUpdateOutcome( + version: string, + current: string, + selection: PluginUpdateSelection | undefined, +): PluginUpdatePreview["outcome"] { + const comparison = compareVersions(version, current); + if (comparison === 0) return "current"; + if (comparison < 0 && !selection) return "installed-newer"; + return "update"; +} diff --git a/packages/server/src/server/plugins/managed-source/registry.ts b/packages/server/src/server/plugins/managed-source/registry.ts new file mode 100644 index 0000000000..d57d0aea79 --- /dev/null +++ b/packages/server/src/server/plugins/managed-source/registry.ts @@ -0,0 +1,54 @@ +import { + PublishedPluginDetailSchema, + type PluginRegistries, + type PluginRegistryIdentity, +} from "@getpaseo/protocol/plugin-registry"; +import type { PluginUpdateTarget } from "@getpaseo/protocol/messages"; + +export interface RegistryOptions { + defaultUrl?: string; + registries?: PluginRegistries; +} +export async function resolveRegistryPlugin( + identity: PluginRegistryIdentity, + options: RegistryOptions, + install: boolean, +) { + const base = new URL(identity.url); + if (!["http:", "https:"].includes(base.protocol) || base.username || base.password) + throw new Error("Registry base must be an HTTP(S) URL without credentials"); + const authorization = options.registries?.[base.host]?.authorization; + if ( + authorization && + base.protocol !== "https:" && + !["localhost", "127.0.0.1", "[::1]"].includes(base.hostname) + ) + throw new Error("Registry credentials require HTTPS"); + const headers: Record = {}; + 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), + }); + if (!response.ok) + throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`); + const plugin = PublishedPluginDetailSchema.parse(await response.json()); + if (plugin.id !== identity.id) throw new Error("Registry returned a different plugin ID"); + const artifact = plugin.artifact; + const target: PluginUpdateTarget = + artifact.kind === "npm" + ? { + kind: "npm", + version: artifact.version, + resolved: artifact.resolved, + integrity: artifact.integrity, + } + : { kind: "git", commit: artifact.commit }; + const input = + artifact.kind === "npm" + ? { source: `npm:${artifact.package}@${artifact.version}` } + : { source: `git:${artifact.remote}`, ref: artifact.commit, pluginPath: artifact.pluginPath }; + return { input, target, artifact }; +} diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts new file mode 100644 index 0000000000..bd8f70f004 --- /dev/null +++ b/packages/website/e2e/plugins.spec.ts @@ -0,0 +1,216 @@ +import { expect, test, type Page } from "playwright/test"; + +async function openPlugins(page: Page) { + // Wait for hydration so typing reaches React rather than the server-rendered input. + await page.goto("/plugins", { waitUntil: "networkidle" }); + await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible(); +} + +test("browses from the directory into a category, a plugin, and its author", async ({ + page, + context, +}) => { + // WebKit has no clipboard permission to grant; record what the page writes instead. + await context.addInitScript(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: (text: string) => { + (window as unknown as { __copied?: string }).__copied = text; + return Promise.resolve(); + }, + }, + }); + }); + await openPlugins(page); + + const themes = page.getByRole("region", { name: "Themes" }); + await expect(themes.getByRole("link", { name: /Dracula/ })).toBeVisible(); + + await browseCategory(page, "Git & code review"); + await expect(page).toHaveURL(/category=/); + await expect(page.getByRole("heading", { level: 2 })).toHaveCount(1); + await expect(page.getByRole("region", { name: "Themes" })).toHaveCount(0); + + await openPlugin(page, /Fresh Worktrees/); + await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/); + await expect( + page.getByRole("heading", { level: 1, name: "Fresh Worktrees", exact: true }), + ).toBeVisible(); + 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 }), + ).toBeVisible(); + await copyInstallCommand(page); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied)) + .toBe("paseo plugin install omercnet/fresh-worktrees"); + + const breadcrumbs = page.getByRole("navigation", { name: "Breadcrumb" }); + await expect(breadcrumbs).toContainText("Plugins"); + await expect(breadcrumbs).toContainText("Git & code review"); + await expect(breadcrumbs).toContainText("Fresh Worktrees"); + + await openAuthor(page, "Omer Cohen"); + await expect(page).toHaveURL(/\/plugins\/omercnet$/); + await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible(); + await expect(page.getByRole("link", { name: /Agent Monitor/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Defer/ })).toHaveCount(0); +}); + +test("filters by search and clears to the full directory", async ({ page }) => { + await openPlugins(page); + + await searchPlugins(page, "graphite"); + await expect(page).toHaveURL(/q=graphite/); + await expect(page.getByRole("heading", { level: 2, name: /Results for/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Graphite/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + + await searchPlugins(page, "zzzz-nothing"); + await expect(page.getByText("No plugins match.")).toBeVisible(); + await page.getByRole("link", { name: "Clear filters" }).click(); + await expect(page).toHaveURL(/\/plugins\/?$/); + await expect(page.getByRole("region", { name: "Themes" })).toBeVisible(); +}); + +test("explains a plugin that is not listed", async ({ page }) => { + await page.goto("/plugins/acme/does-not-exist"); + await expect(page.getByRole("heading", { level: 1, name: "Plugin not found" })).toBeVisible(); + await page.getByRole("link", { name: "Browse all plugins" }).click(); + await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible(); +}); + +async function copyInstallCommand(page: Page) { + await page.getByRole("button", { name: "Copy to clipboard" }).first().click(); +} + +async function browseCategory(page: Page, category: string) { + await page + .getByRole("region", { name: category }) + .getByRole("link", { name: /^View all/ }) + .click(); +} +async function openPlugin(page: Page, name: RegExp) { + await page.getByRole("link", { name }).first().click(); +} +async function openAuthor(page: Page, name: string) { + await page.getByRole("link", { name }).first().click(); +} +async function searchPlugins(page: Page, query: string) { + await page.getByRole("searchbox", { name: "Search plugins" }).fill(query); +} + +test.describe("search engine visits without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + test("reads directory, author, and complete plugin HTML with page metadata", async ({ page }) => { + await openPlugins(page); + await expect(page.getByRole("link", { name: /Fresh Worktrees/ }).first()).toBeVisible(); + await expectPageMetadata(page, "Plugins – Extend Paseo with community plugins", "/plugins"); + + await page.goto("/plugins/omercnet"); + await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible(); + await expect(page.getByRole("link", { name: /Fresh Worktrees/ }).first()).toBeVisible(); + await expectPageMetadata(page, "Omer Cohen – Paseo plugins", "/plugins/omercnet"); + + const response = await page.goto("/plugins/omercnet/fresh-worktrees"); + expect(response?.status()).toBe(200); + expect(response?.headers()["cache-control"]).toBe("private, no-store"); + 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 }), + ).toBeVisible(); + await expectPageMetadata( + page, + "Fresh Worktrees – Paseo plugin", + "/plugins/omercnet/fresh-worktrees", + ); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + "content", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png", + ); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + "content", + "Fast-forwards clean local base branches before Paseo creates branch-off worktrees", + ); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); + + await page.goto("/plugins/tomgrin10/graphite"); + await expectPageMetadata(page, "Graphite – Paseo plugin", "/plugins/tomgrin10/graphite"); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + "content", + "https://paseo.sh/og-image.png", + ); + }); + + test("returns real 404 pages for unknown plugins and authors", async ({ page }) => { + const plugin = await page.goto("/plugins/acme/does-not-exist"); + expect(plugin?.status()).toBe(404); + await expect(page.getByRole("heading", { name: "Plugin not found" })).toBeVisible(); + await expect(page).toHaveTitle("Plugin not found – Paseo"); + const author = await page.goto("/plugins/does-not-exist"); + expect(author?.status()).toBe(404); + await expect(page.getByRole("heading", { name: "Author not found" })).toBeVisible(); + await expect(page).toHaveTitle("Author not found – Paseo"); + }); + + test("discovers plugin and author URLs through robots and the sitemap index", async ({ + request, + }) => { + const robots = await request.get("/robots.txt"); + expect(await robots.text()).toContain("Sitemap: https://paseo.sh/sitemap-index.xml"); + const index = await request.get("/sitemap-index.xml"); + expect(await index.text()).toContain("https://paseo.sh/sitemap-plugins.xml"); + const plugins = await request.get("/sitemap-plugins.xml"); + expect(plugins.status()).toBe(200); + expect(plugins.headers()["content-type"]).toContain("application/xml"); + expect(await plugins.text()).toContain("https://paseo.sh/plugins/omercnet"); + expect(await plugins.text()).toContain( + "https://paseo.sh/plugins/omercnet/fresh-worktrees", + ); + }); +}); + +async function expectPageMetadata(page: Page, title: string, path: string) { + await expect(page).toHaveTitle(title); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + `https://paseo.sh${path}`, + ); + await expect(page.locator('meta[property="og:title"]')).toHaveAttribute("content", title); + await expect(page.locator('meta[property="og:url"]')).toHaveAttribute( + "content", + `https://paseo.sh${path}`, + ); + await expect(page.locator('meta[property="og:type"]')).toHaveAttribute("content", "website"); + await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute( + "content", + "summary_large_image", + ); + const description = await page.locator('meta[name="description"]').getAttribute("content"); + expect(description).toBeTruthy(); + await expect(page.locator('meta[property="og:description"]')).toHaveAttribute( + "content", + description!, + ); +} + +test("keeps the directory unlinked until the coordinated announcement", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByRole("banner").getByRole("link", { name: "Plugins", exact: true }), + ).toHaveCount(0); + await expect( + page.getByRole("contentinfo").getByRole("link", { name: "Plugins", exact: true }), + ).toHaveCount(0); + await expect(page.getByRole("link", { name: "Community plugins" })).toHaveAttribute( + "href", + "https://paseo.cafe", + ); + await expect(page.locator('a[href="/plugins"]')).toHaveCount(0); + const response = await page.goto("/plugins"); + expect(response?.status()).toBe(200); + await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible(); +}); diff --git a/packages/website/package.json b/packages/website/package.json index 5f64191da9..a2159f2062 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -15,6 +15,7 @@ "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", "@fontsource-variable/geist": "^5.3.0", + "@getpaseo/protocol": "*", "@tanstack/react-router": "^1.170.16", "@tanstack/react-start": "^1.168.26", "framer-motion": "^12.35.2", diff --git a/packages/website/public/robots.txt b/packages/website/public/robots.txt new file mode 100644 index 0000000000..75001ce745 --- /dev/null +++ b/packages/website/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://paseo.sh/sitemap-index.xml diff --git a/packages/website/public/sitemap-index.xml b/packages/website/public/sitemap-index.xml new file mode 100644 index 0000000000..ae15d8df3b --- /dev/null +++ b/packages/website/public/sitemap-index.xml @@ -0,0 +1,5 @@ + + + https://paseo.sh/sitemap.xml + https://paseo.sh/sitemap-plugins.xml + diff --git a/packages/website/src/components/breadcrumbs.tsx b/packages/website/src/components/breadcrumbs.tsx new file mode 100644 index 0000000000..240f34047b --- /dev/null +++ b/packages/website/src/components/breadcrumbs.tsx @@ -0,0 +1,32 @@ +import { Link } from "@tanstack/react-router"; +import { ChevronRight } from "lucide-react"; + +export interface BreadcrumbItem { + label: string; + /** Internal path. The last item is the current page and has no href. */ + href?: string; +} + +export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) { + return ( + + ); +} diff --git a/packages/website/src/components/docs-breadcrumbs.tsx b/packages/website/src/components/docs-breadcrumbs.tsx index 7b82e7b4fb..0da4f4472a 100644 --- a/packages/website/src/components/docs-breadcrumbs.tsx +++ b/packages/website/src/components/docs-breadcrumbs.tsx @@ -1,5 +1,5 @@ -import { Link } from "@tanstack/react-router"; -import { ChevronRight } from "lucide-react"; +import { useMemo } from "react"; +import { type BreadcrumbItem, Breadcrumbs } from "~/components/breadcrumbs"; import { type Doc, type DocsNavNode, getDocBreadcrumbGroups } from "~/docs"; interface DocsBreadcrumbsProps { @@ -8,27 +8,13 @@ interface DocsBreadcrumbsProps { } export function DocsBreadcrumbs({ doc, tree }: DocsBreadcrumbsProps) { - const groups = getDocBreadcrumbGroups(doc, tree); - - return ( - + const items = useMemo( + () => [ + { label: "Docs", href: "/docs" }, + ...getDocBreadcrumbGroups(doc, tree).map((group) => ({ label: group.label })), + { label: doc.frontmatter.nav }, + ], + [doc, tree], ); + return ; } diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index ce68595980..a1fcc18ef3 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -1,3 +1,4 @@ +import { PLUGINS_LINKED } from "~/plugins/launch"; import * as React from "react"; import { ArrowRight, @@ -822,13 +823,15 @@ interface ExtensibleCardLink { const PLUGIN_CARD_LINKS: ReadonlyArray = [ { href: "/docs/plugins", label: "Plugin documentation", icon: BookOpen }, - { - href: "https://paseo.cafe", - label: "Community plugins", - icon: Coffee, - external: true, - accent: true, - }, + PLUGINS_LINKED + ? { href: "/plugins", label: "Community plugins", icon: Blocks, accent: true } + : { + href: "https://paseo.cafe", + label: "Community plugins", + icon: Coffee, + external: true, + accent: true, + }, ]; const FORK_CARD_LINKS: ReadonlyArray = [ diff --git a/packages/website/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx index ba092410da..42ee404279 100644 --- a/packages/website/src/components/site-footer.tsx +++ b/packages/website/src/components/site-footer.tsx @@ -1,3 +1,4 @@ +import { PLUGINS_LINKED } from "~/plugins/launch"; import { getAlternativePages } from "~/data/alternative-pages"; import { appStoreUrl, playStoreUrl, webAppUrl } from "~/downloads"; @@ -27,6 +28,14 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { > Docs + {PLUGINS_LINKED && ( + + Plugins + + )} Docs + {PLUGINS_LINKED && ( + + Plugins + + )} + + + ); + } + return ( + + {author.name.charAt(0).toUpperCase()} + + ); +} + +const DEFAULT_CLASS = + "inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"; + +/** Link to the author's plugin list. Renders avatar and name unless children are given. */ +export function AuthorLink({ + author, + className = DEFAULT_CLASS, + children, +}: { + author: Author; + className?: string; + children?: ReactNode; +}) { + const params = useMemo(() => ({ owner: author.username }), [author.username]); + return ( + + {children ?? ( + <> + + {author.name} + + )} + + ); +} diff --git a/packages/website/src/plugins/categories.ts b/packages/website/src/plugins/categories.ts new file mode 100644 index 0000000000..498bea280e --- /dev/null +++ b/packages/website/src/plugins/categories.ts @@ -0,0 +1,48 @@ +export const CATEGORIES = [ + { slug: "themes", label: "Themes", description: "Change how Paseo looks." }, + { + slug: "agents-and-providers", + label: "Agents & providers", + description: "Run more coding agents through Paseo.", + }, + { + slug: "monitoring", + label: "Monitoring & orchestration", + description: "See every agent at once and steer work across workspaces.", + }, + { + slug: "timeline-and-composer", + label: "Timeline & composer", + description: "Change what a conversation shows and how you write to agents.", + }, + { + slug: "workspace-panels", + label: "Workspace panels", + description: "Tools that open as tabs next to your agents.", + }, + { + slug: "git-and-code-review", + label: "Git & code review", + description: "Branches, worktrees, and pull requests.", + }, + { + slug: "automation", + label: "Automation & scheduling", + description: "Run things later, on a schedule, or when an agent goes idle.", + }, + { slug: "notifications", label: "Notifications", description: "Know when an agent needs you." }, + { + slug: "usage-and-pricing", + label: "Usage & pricing", + description: "What models cost and how much you have used.", + }, + { + slug: "integrations", + label: "Integrations", + description: "Issues, tasks, and tools from other services inside Paseo.", + }, + { slug: "utilities", label: "Utilities", description: "Small helpers for everyday work." }, +] as const; + +export type CategorySlug = (typeof CATEGORIES)[number]["slug"]; +export type Category = (typeof CATEGORIES)[number]; diff --git a/packages/website/src/plugins/category-link.tsx b/packages/website/src/plugins/category-link.tsx new file mode 100644 index 0000000000..d51dbebd25 --- /dev/null +++ b/packages/website/src/plugins/category-link.tsx @@ -0,0 +1,36 @@ +import { Link } from "@tanstack/react-router"; +import { type ReactNode, useMemo } from "react"; +import type { CategorySlug, PluginSort } from "./registry"; + +interface CategoryLinkProps { + /** `null` links to the unfiltered list. */ + category: CategorySlug | null; + q?: string; + sort?: PluginSort; + replace?: boolean; + className?: string; + children: ReactNode; +} + +/** Link into the plugin list filtered by category, keeping any search and sort. */ +export function CategoryLink({ + category, + q, + sort, + replace, + className, + children, +}: CategoryLinkProps) { + const search = useMemo(() => { + const next: { category?: CategorySlug; q?: string; sort?: PluginSort } = {}; + if (category) next.category = category; + if (q) next.q = q; + if (sort === "new") next.sort = sort; + return next; + }, [category, q, sort]); + return ( + + {children} + + ); +} diff --git a/packages/website/src/plugins/index.ts b/packages/website/src/plugins/index.ts new file mode 100644 index 0000000000..d0c892ed8c --- /dev/null +++ b/packages/website/src/plugins/index.ts @@ -0,0 +1,2 @@ +export * from "./registry"; +export { getRegistry, getRegistryPlugin } from "./loader"; diff --git a/packages/website/src/plugins/launch.ts b/packages/website/src/plugins/launch.ts new file mode 100644 index 0000000000..bc784696de --- /dev/null +++ b/packages/website/src/plugins/launch.ts @@ -0,0 +1,2 @@ +// Keep navigation on paseo.cafe until the coordinated directory announcement. +export const PLUGINS_LINKED = false; diff --git a/packages/website/src/plugins/loader.ts b/packages/website/src/plugins/loader.ts new file mode 100644 index 0000000000..617cd5e9ed --- /dev/null +++ b/packages/website/src/plugins/loader.ts @@ -0,0 +1,14 @@ +import { createServerFn } from "@tanstack/react-start"; +import { env } from "cloudflare:workers"; +import { getWebsiteCacheContext } from "~/cloudflare-cache"; +import { loadRegistryIndex, loadRegistryPlugin, type RegistryEnvironment } from "./published"; + +function registryBase(): string { + return (env as RegistryEnvironment).PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins"; +} +export const getRegistry = createServerFn({ method: "GET" }).handler(() => + loadRegistryIndex(registryBase(), getWebsiteCacheContext()), +); +export const getRegistryPlugin = createServerFn({ method: "GET" }) + .validator((id: string) => id) + .handler(({ data }) => loadRegistryPlugin(registryBase(), data, getWebsiteCacheContext())); diff --git a/packages/website/src/plugins/not-found.tsx b/packages/website/src/plugins/not-found.tsx new file mode 100644 index 0000000000..f07a363b12 --- /dev/null +++ b/packages/website/src/plugins/not-found.tsx @@ -0,0 +1,21 @@ +import { Link } from "@tanstack/react-router"; +import { Breadcrumbs } from "~/components/breadcrumbs"; +import { SiteShell } from "~/components/site-shell"; + +const NOT_FOUND_CRUMBS = [{ label: "Plugins", href: "/plugins" }, { label: "Not found" }]; + +export function PluginsNotFound({ title, children }: { title: string; children: string }) { + return ( + + +

{title}

+

+ {children}{" "} + + Browse all plugins + + . +

+
+ ); +} diff --git a/packages/website/src/plugins/plugin-card.tsx b/packages/website/src/plugins/plugin-card.tsx new file mode 100644 index 0000000000..6c3502f78c --- /dev/null +++ b/packages/website/src/plugins/plugin-card.tsx @@ -0,0 +1,49 @@ +import { Link } from "@tanstack/react-router"; +import { Download } from "lucide-react"; +import { useMemo } from "react"; +import { PluginTile } from "./plugin-tile"; +import { formatInstalls, getAuthor, getCategory, type Plugin } from "./registry"; + +export const PLUGIN_GRID_CLASS = "grid gap-3 sm:grid-cols-2 lg:grid-cols-3"; + +export function PluginCard({ plugin }: { plugin: Plugin }) { + const author = getAuthor(plugin); + const category = getCategory(plugin.categories[0]); + const params = useMemo(() => { + const [owner, slug] = plugin.id.split("/"); + return { owner, slug }; + }, [plugin.id]); + return ( + +
+ +
+

{plugin.name}

+ {author &&

{author.name}

} +
+
+

+ {plugin.description} +

+
+ {category ? ( + + {category.label} + + ) : ( + + )} + {plugin.installs !== undefined && ( + + + {formatInstalls(plugin.installs)} installs + + )} +
+ + ); +} diff --git a/packages/website/src/plugins/plugin-page-header.tsx b/packages/website/src/plugins/plugin-page-header.tsx new file mode 100644 index 0000000000..9547d2fdda --- /dev/null +++ b/packages/website/src/plugins/plugin-page-header.tsx @@ -0,0 +1,10 @@ +import { ShieldAlert } from "lucide-react"; + +export function PluginsTrustNote() { + return ( +

+ + Plugins run unsandboxed on your machine. Read the source before you install. +

+ ); +} diff --git a/packages/website/src/plugins/plugin-tile.tsx b/packages/website/src/plugins/plugin-tile.tsx new file mode 100644 index 0000000000..01234ac8b8 --- /dev/null +++ b/packages/website/src/plugins/plugin-tile.tsx @@ -0,0 +1,22 @@ +import type { Plugin } from "./registry"; + +const SIZE_CLASS = { + sm: "h-9 w-9 rounded-lg text-sm", + lg: "h-14 w-14 rounded-xl text-xl", +} as const; + +/** PNG icon with a letter tile when no icon is published. */ +export function PluginTile({ plugin, size }: { plugin: Plugin; size: keyof typeof SIZE_CLASS }) { + return ( +
+ {plugin.icon ? ( + + ) : ( + plugin.name.charAt(0).toUpperCase() + )} +
+ ); +} diff --git a/packages/website/src/plugins/published.ts b/packages/website/src/plugins/published.ts new file mode 100644 index 0000000000..9d8697aabb --- /dev/null +++ b/packages/website/src/plugins/published.ts @@ -0,0 +1,106 @@ +import { + PluginRegistryIdSchema, + PluginRegistryIndexSchema, + PublishedPluginDetailSchema, +} from "@getpaseo/protocol/plugin-registry"; +import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache"; +export interface RegistryEnvironment { + PLUGINS_REGISTRY_URL?: string; + WEBSITE_CACHE?: KVNamespace; +} +async function documentAt(base: string, path: string): Promise { + const response = await fetch(`${base.replace(/\/+$/, "")}/${path}`); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`Plugin registry returned ${response.status}`); + return response.json(); +} +export function loadRegistryIndex(base: string, context: WebsiteCacheContext) { + return getBlockingColdCache({ + context, + key: `plugins:index:v1:${base}`, + isValue: (value): value is ReturnType => + PluginRegistryIndexSchema.safeParse(value).success, + fetchFresh: async () => PluginRegistryIndexSchema.parse(await documentAt(base, "index.json")), + }); +} +export async function loadRegistryPlugin(base: string, id: string, context: WebsiteCacheContext) { + if (!PluginRegistryIdSchema.safeParse(id).success) return null; + return getBlockingColdCache({ + context, + key: `plugins:detail:v1:${base}:${id}`, + isValue: (value): value is ReturnType | null => + value === null || PublishedPluginDetailSchema.safeParse(value).success, + fetchFresh: async () => { + const raw = await documentAt(base, `plugins/${id}.json`); + if (raw === null) return null; + const plugin = PublishedPluginDetailSchema.parse(raw); + if (plugin.id !== id) throw new Error("Plugin registry returned a different ID"); + return plugin; + }, + }); +} +export async function handlePluginRegistryRequest( + request: Request, + env: RegistryEnvironment, + context: WebsiteCacheContext, +): Promise { + 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.hostname === "plugins.paseo.sh" && url.pathname === "/index.json") + return Response.json(await loadRegistryIndex(base, context)); + if (url.pathname === "/sitemap-plugins.xml") { + const index = await loadRegistryIndex(base, context); + // IDs are validated owner/slug strings, so these paths contain no XML metacharacters. + const paths = new Set(["/plugins"]); + for (const plugin of index.plugins) { + paths.add(`/plugins/${plugin.id.split("/")[0]}`); + paths.add(`/plugins/${plugin.id}`); + } + const urls = [...paths].sort().map((path) => `https://paseo.sh${path}`); + return new Response( + `${urls.join("")}`, + { + headers: { + "content-type": "application/xml; charset=utf-8", + "cache-control": "public, max-age=300", + }, + }, + ); + } + if (url.pathname === "/api/plugins/installs") { + const index = await loadRegistryIndex(base, context); + const entries = await Promise.all( + index.plugins.map( + async (plugin) => + [ + plugin.id, + Number((await context.cache?.get(`plugin-installs:${plugin.id}`)) ?? 0), + ] as const, + ), + ); + return Response.json(Object.fromEntries(entries)); + } + const match = + /^\/api\/plugins\/resolve\/([^/]+\/[^/]+)$/.exec(url.pathname) ?? + /^\/plugins\/([^/]+\/[^/]+)\.json$/.exec(url.pathname); + if (!match) return null; + const id = match[1]; + const plugin = await loadRegistryPlugin(base, id, context); + if (!plugin) return Response.json({ error: "Plugin not found" }, { status: 404 }); + if ( + request.headers.get("X-Paseo-Install") === "1" || + url.searchParams.get("intent") === "install" + ) { + const cache = context.cache; + if (cache) + context.waitUntil( + (async () => { + const key = `plugin-installs:${id}`; + const count = Number((await cache.get(key)) ?? 0); + await cache.put(key, String(count + 1)); + })().catch(() => undefined), + ); + } + return Response.json(plugin); +} diff --git a/packages/website/src/plugins/registry.test.ts b/packages/website/src/plugins/registry.test.ts new file mode 100644 index 0000000000..20f5cfda6e --- /dev/null +++ b/packages/website/src/plugins/registry.test.ts @@ -0,0 +1,84 @@ +import { createServer } from "node:http"; +import { describe, expect, it } from "vitest"; +import { + formatInstalls, + installCommand, + pinnedInstallCommand, + queryPlugins, + type Plugin, +} from "./registry"; +import { loadRegistryIndex, loadRegistryPlugin } from "./published"; +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", + }, + screenshots: [], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + installs: 42, +}; +describe("plugin registry", () => { + it("loads a directory and a detail from a static HTTP registry", async () => { + const index = { + schemaVersion: 1, + registry: { name: "Internal", url: "https://example.test" }, + categories: [], + plugins: [plugin], + generatedAt: "2026-10-03", + }; + const detail = { ...plugin, readme: "# Example" }; + const server = createServer((request, response) => { + const value = request.url === "/index.json" ? index : detail; + response.end(JSON.stringify(value)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing address"); + const base = `http://127.0.0.1:${address.port}`; + const context = { cache: null, waitUntil: () => undefined }; + try { + expect(await loadRegistryIndex(base, context)).toEqual(index); + expect(await loadRegistryPlugin(base, plugin.id, context)).toEqual(detail); + await expect(loadRegistryPlugin(base, "acme/wrong", context)).rejects.toThrow("different ID"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it("filters namespaced IDs and ranks by installs", () => { + const popular = { ...plugin, id: "acme/popular", installs: 100 }; + expect(queryPlugins([plugin, popular], { sort: "popular" }).map((p) => p.id)).toEqual([ + "acme/popular", + "acme/example", + ]); + expect(queryPlugins([plugin, popular], { q: "acme/example", sort: "new" })).toEqual([plugin]); + expect(queryPlugins([plugin], { category: "utilities", sort: "popular" })).toEqual([]); + }); + it("offers a registry command and an exact explicit command", () => { + expect(installCommand(plugin)).toBe("paseo plugin install acme/example"); + expect(pinnedInstallCommand(plugin)).toBe("paseo plugin install npm:paseo-example@1.2.3"); + expect( + pinnedInstallCommand({ + ...plugin, + artifact: { + kind: "git", + remote: "https://github.com/acme/plugins.git", + commit: "a".repeat(40), + pluginPath: "packages/example", + }, + }), + ).toBe(`paseo plugin install github:acme/plugins:packages/example --ref ${"a".repeat(40)}`); + expect(formatInstalls(1250)).toBe("1.3k"); + }); +}); diff --git a/packages/website/src/plugins/registry.ts b/packages/website/src/plugins/registry.ts new file mode 100644 index 0000000000..33f822cd3f --- /dev/null +++ b/packages/website/src/plugins/registry.ts @@ -0,0 +1,94 @@ +import type { z } from "zod"; +import type { PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry"; +import { CATEGORIES, type Category, type CategorySlug } from "./categories"; +export { CATEGORIES, type Category, type CategorySlug }; +export type Plugin = z.infer; +export interface Author { + username: string; + name: string; + github: string; + npm?: string; +} +export type PluginSort = "popular" | "new"; +export function getCategory(slug: string): Category | null { + return CATEGORIES.find((category) => category.slug === slug) ?? null; +} +export function getPluginsInCategory(plugins: Plugin[], slug: string): Plugin[] { + return plugins.filter((plugin) => plugin.categories.includes(slug)); +} +/** The namespace segment of the plugin ID. It is the route param for the plugin and its author. */ +export function pluginOwner(plugin: Plugin): string { + return plugin.id.split("/")[0]; +} +export function getAuthor(plugin: Plugin): Author { + const { author } = plugin; + return { + username: pluginOwner(plugin), + github: author.github, + name: author.name ?? author.github, + npm: author.npm, + }; +} +export function getPluginsByAuthor(plugins: Plugin[], owner: string): Plugin[] { + return plugins.filter((plugin) => pluginOwner(plugin) === owner); +} +export function installCommand(plugin: Plugin): string { + return `paseo plugin install ${plugin.id}`; +} +export function pinnedInstallCommand(plugin: Plugin): string { + const artifact = plugin.artifact; + if (artifact.kind === "npm") + return `paseo plugin install npm:${artifact.package}@${artifact.version}`; + const source = artifact.remote + .replace(/^https:\/\/github.com\//, "github:") + .replace(/\.git$/, ""); + const location = artifact.pluginPath ? `${source}:${artifact.pluginPath}` : source; + return `paseo plugin install ${location} --ref ${artifact.commit}`; +} +export function pluginVersion(plugin: Plugin): string { + return plugin.artifact.kind === "npm" + ? plugin.artifact.version + : plugin.artifact.commit.slice(0, 12); +} +export function npmUrl(plugin: Plugin): string | undefined { + return plugin.artifact.kind === "npm" + ? `https://www.npmjs.com/package/${plugin.artifact.package}` + : undefined; +} +export function authorNpmUrl(author: Author): string | undefined { + return author.npm ? `https://www.npmjs.com/~${author.npm}` : undefined; +} +export function authorGitHubUrl(author: Author): string { + return `https://github.com/${author.github}`; +} +export function authorAvatarUrl(author: Author): string { + return `https://github.com/${author.github}.png?size=80`; +} +export function formatInstalls(count: number): string { + if (count < 1000) return String(count); + const k = count / 1000; + return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`; +} +export interface PluginQuery { + category?: CategorySlug; + q?: string; + sort: PluginSort; +} +export function queryPlugins(plugins: Plugin[], query: PluginQuery): Plugin[] { + const needle = query.q?.trim().toLowerCase() ?? ""; + const matches = plugins.filter( + (plugin) => + (!query.category || plugin.categories.includes(query.category)) && + (!needle || + `${plugin.name} ${plugin.description} ${plugin.id}`.toLowerCase().includes(needle)), + ); + return sortPlugins(matches, query.sort); +} +export function sortPlugins(list: Plugin[], sort: PluginSort): Plugin[] { + return [...list].sort( + (a, b) => + (sort === "new" + ? b.updatedAt.localeCompare(a.updatedAt) + : (b.installs ?? 0) - (a.installs ?? 0)) || a.name.localeCompare(b.name), + ); +} diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts index 9b2a842b0d..a9b668c187 100644 --- a/packages/website/src/routeTree.gen.ts +++ b/packages/website/src/routeTree.gen.ts @@ -60,8 +60,10 @@ import { Route as AmpRouteImport } from "./routes/amp"; import { Route as AgoragenticRouteImport } from "./routes/agoragentic"; import { Route as AgentsRouteImport } from "./routes/agents"; import { Route as IndexRouteImport } from "./routes/index"; +import { Route as PluginsIndexRouteImport } from "./routes/plugins/index"; import { Route as DocsIndexRouteImport } from "./routes/docs/index"; import { Route as BlogIndexRouteImport } from "./routes/blog/index"; +import { Route as PluginsOwnerRouteImport } from "./routes/plugins/$owner"; import { Route as DownloadThanksRouteImport } from "./routes/download_.thanks"; import { Route as DocsSplatRouteImport } from "./routes/docs/$"; import { Route as BlogSplatRouteImport } from "./routes/blog/$"; @@ -73,6 +75,7 @@ import { Route as AlternativesHappyCoderRouteImport } from "./routes/alternative import { Route as AlternativesConductorRouteImport } from "./routes/alternatives/conductor"; import { Route as AlternativesCodexAppRouteImport } from "./routes/alternatives/codex-app"; import { Route as AlternativesClaudeDesktopRouteImport } from "./routes/alternatives/claude-desktop"; +import { Route as PluginsOwnerSlugRouteImport } from "./routes/plugins/$owner_.$slug"; const VtcodeRoute = VtcodeRouteImport.update({ id: "/vtcode", @@ -329,6 +332,11 @@ const IndexRoute = IndexRouteImport.update({ path: "/", getParentRoute: () => rootRouteImport, } as any); +const PluginsIndexRoute = PluginsIndexRouteImport.update({ + id: "/plugins/", + path: "/plugins/", + getParentRoute: () => rootRouteImport, +} as any); const DocsIndexRoute = DocsIndexRouteImport.update({ id: "/", path: "/", @@ -339,6 +347,11 @@ const BlogIndexRoute = BlogIndexRouteImport.update({ path: "/", getParentRoute: () => BlogRoute, } as any); +const PluginsOwnerRoute = PluginsOwnerRouteImport.update({ + id: "/plugins/$owner", + path: "/plugins/$owner", + getParentRoute: () => rootRouteImport, +} as any); const DownloadThanksRoute = DownloadThanksRouteImport.update({ id: "/download_/thanks", path: "/download/thanks", @@ -396,6 +409,11 @@ const AlternativesClaudeDesktopRoute = path: "/alternatives/claude-desktop", getParentRoute: () => rootRouteImport, } as any); +const PluginsOwnerSlugRoute = PluginsOwnerSlugRouteImport.update({ + id: "/plugins/$owner_/$slug", + path: "/plugins/$owner/$slug", + getParentRoute: () => rootRouteImport, +} as any); export interface FileRoutesByFullPath { "/": typeof IndexRoute; @@ -460,8 +478,11 @@ export interface FileRoutesByFullPath { "/blog/$": typeof BlogSplatRoute; "/docs/$": typeof DocsSplatRoute; "/download/thanks": typeof DownloadThanksRoute; + "/plugins/$owner": typeof PluginsOwnerRoute; "/blog/": typeof BlogIndexRoute; "/docs/": typeof DocsIndexRoute; + "/plugins/": typeof PluginsIndexRoute; + "/plugins/$owner/$slug": typeof PluginsOwnerSlugRoute; } export interface FileRoutesByTo { "/": typeof IndexRoute; @@ -524,8 +545,11 @@ export interface FileRoutesByTo { "/blog/$": typeof BlogSplatRoute; "/docs/$": typeof DocsSplatRoute; "/download/thanks": typeof DownloadThanksRoute; + "/plugins/$owner": typeof PluginsOwnerRoute; "/blog": typeof BlogIndexRoute; "/docs": typeof DocsIndexRoute; + "/plugins": typeof PluginsIndexRoute; + "/plugins/$owner/$slug": typeof PluginsOwnerSlugRoute; } export interface FileRoutesById { __root__: typeof rootRouteImport; @@ -591,8 +615,11 @@ export interface FileRoutesById { "/blog/$": typeof BlogSplatRoute; "/docs/$": typeof DocsSplatRoute; "/download_/thanks": typeof DownloadThanksRoute; + "/plugins/$owner": typeof PluginsOwnerRoute; "/blog/": typeof BlogIndexRoute; "/docs/": typeof DocsIndexRoute; + "/plugins/": typeof PluginsIndexRoute; + "/plugins/$owner_/$slug": typeof PluginsOwnerSlugRoute; } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath; @@ -659,8 +686,11 @@ export interface FileRouteTypes { | "/blog/$" | "/docs/$" | "/download/thanks" + | "/plugins/$owner" | "/blog/" - | "/docs/"; + | "/docs/" + | "/plugins/" + | "/plugins/$owner/$slug"; fileRoutesByTo: FileRoutesByTo; to: | "/" @@ -723,8 +753,11 @@ export interface FileRouteTypes { | "/blog/$" | "/docs/$" | "/download/thanks" + | "/plugins/$owner" | "/blog" - | "/docs"; + | "/docs" + | "/plugins" + | "/plugins/$owner/$slug"; id: | "__root__" | "/" @@ -789,8 +822,11 @@ export interface FileRouteTypes { | "/blog/$" | "/docs/$" | "/download_/thanks" + | "/plugins/$owner" | "/blog/" - | "/docs/"; + | "/docs/" + | "/plugins/" + | "/plugins/$owner_/$slug"; fileRoutesById: FileRoutesById; } export interface RootRouteChildren { @@ -854,6 +890,9 @@ export interface RootRouteChildren { AlternativesOrcaRoute: typeof AlternativesOrcaRoute; AlternativesSupersetRoute: typeof AlternativesSupersetRoute; DownloadThanksRoute: typeof DownloadThanksRoute; + PluginsOwnerRoute: typeof PluginsOwnerRoute; + PluginsIndexRoute: typeof PluginsIndexRoute; + PluginsOwnerSlugRoute: typeof PluginsOwnerSlugRoute; } declare module "@tanstack/react-router" { @@ -1215,6 +1254,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof IndexRouteImport; parentRoute: typeof rootRouteImport; }; + "/plugins/": { + id: "/plugins/"; + path: "/plugins"; + fullPath: "/plugins/"; + preLoaderRoute: typeof PluginsIndexRouteImport; + parentRoute: typeof rootRouteImport; + }; "/docs/": { id: "/docs/"; path: "/"; @@ -1229,6 +1275,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof BlogIndexRouteImport; parentRoute: typeof BlogRoute; }; + "/plugins/$owner": { + id: "/plugins/$owner"; + path: "/plugins/$owner"; + fullPath: "/plugins/$owner"; + preLoaderRoute: typeof PluginsOwnerRouteImport; + parentRoute: typeof rootRouteImport; + }; "/download_/thanks": { id: "/download_/thanks"; path: "/download/thanks"; @@ -1306,6 +1359,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof AlternativesClaudeDesktopRouteImport; parentRoute: typeof rootRouteImport; }; + "/plugins/$owner_/$slug": { + id: "/plugins/$owner_/$slug"; + path: "/plugins/$owner/$slug"; + fullPath: "/plugins/$owner/$slug"; + preLoaderRoute: typeof PluginsOwnerSlugRouteImport; + parentRoute: typeof rootRouteImport; + }; } } @@ -1394,6 +1454,9 @@ const rootRouteChildren: RootRouteChildren = { AlternativesOrcaRoute: AlternativesOrcaRoute, AlternativesSupersetRoute: AlternativesSupersetRoute, DownloadThanksRoute: DownloadThanksRoute, + PluginsOwnerRoute: PluginsOwnerRoute, + PluginsIndexRoute: PluginsIndexRoute, + PluginsOwnerSlugRoute: PluginsOwnerSlugRoute, }; export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/packages/website/src/routes/plugins/$owner.tsx b/packages/website/src/routes/plugins/$owner.tsx new file mode 100644 index 0000000000..562d1d91cc --- /dev/null +++ b/packages/website/src/routes/plugins/$owner.tsx @@ -0,0 +1,94 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { ExternalLink } from "lucide-react"; +import { useMemo } from "react"; +import { type BreadcrumbItem, Breadcrumbs } from "~/components/breadcrumbs"; +import { SiteShell } from "~/components/site-shell"; +import { pageMeta } from "~/meta"; +import { + authorGitHubUrl, + authorNpmUrl, + getAuthor, + getRegistry, + getPluginsByAuthor, + pluginOwner, + sortPlugins, +} from "~/plugins"; +import { AuthorAvatar } from "~/plugins/author-link"; +import { PluginsNotFound } from "~/plugins/not-found"; +import { PLUGIN_GRID_CLASS, PluginCard } from "~/plugins/plugin-card"; +import "~/styles.css"; + +export const Route = createFileRoute("/plugins/$owner")({ + loader: async ({ params }) => { + const registry = await getRegistry(); + const first = registry.plugins.find((plugin) => pluginOwner(plugin) === params.owner); + if (!first) throw notFound(); + return { plugins: registry.plugins, author: getAuthor(first) }; + }, + head: ({ params, loaderData }) => + pageMeta( + loaderData ? `${loaderData.author.name} – Paseo plugins` : "Author not found – Paseo", + loaderData ? `Paseo plugins published by ${loaderData.author.name}.` : "Author not found.", + `/plugins/${params.owner}`, + ), + component: AuthorPage, + notFoundComponent: () => ( + + Nobody with that GitHub owner has a plugin listed. + + ), +}); + +const LINK_CLASS = + "inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; + +function AuthorPage() { + const { plugins: allPlugins, author } = Route.useLoaderData(); + const crumbs = useMemo( + () => [{ label: "Plugins", href: "/plugins" }, { label: author.name }], + [author], + ); + + const plugins = sortPlugins(getPluginsByAuthor(allPlugins, author.username), "popular"); + const github = authorGitHubUrl(author); + + return ( + + +
+ +
+ {plugins.map((plugin) => ( + + ))} +
+ + ); +} diff --git a/packages/website/src/routes/plugins/$owner_.$slug.tsx b/packages/website/src/routes/plugins/$owner_.$slug.tsx new file mode 100644 index 0000000000..ec14392db2 --- /dev/null +++ b/packages/website/src/routes/plugins/$owner_.$slug.tsx @@ -0,0 +1,235 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { Download, ExternalLink } from "lucide-react"; +import { useMemo } from "react"; +import { type BreadcrumbItem, Breadcrumbs } from "~/components/breadcrumbs"; +import { CodeBlock } from "~/components/code-block"; +import { DocsMarkdown } from "~/components/docs-markdown"; +import { SiteShell } from "~/components/site-shell"; +import { pageMeta } from "~/meta"; +import { + formatInstalls, + getAuthor, + getCategory, + getRegistry, + getRegistryPlugin, + pinnedInstallCommand, + pluginVersion, + getPluginsByAuthor, + getPluginsInCategory, + installCommand, + npmUrl, + type Plugin, + sortPlugins, +} from "~/plugins"; +import { AuthorLink } from "~/plugins/author-link"; +import { CategoryLink } from "~/plugins/category-link"; +import { PluginsNotFound } from "~/plugins/not-found"; +import { PLUGIN_GRID_CLASS, PluginCard } from "~/plugins/plugin-card"; +import { PluginsTrustNote } from "~/plugins/plugin-page-header"; +import { PluginTile } from "~/plugins/plugin-tile"; +import "~/styles.css"; + +const RELATED_COUNT = 3; + +export const Route = createFileRoute("/plugins/$owner_/$slug")({ + loader: async ({ params }) => { + const [registry, plugin] = await Promise.all([ + getRegistry(), + getRegistryPlugin({ data: `${params.owner}/${params.slug}` }), + ]); + if (!plugin) throw notFound(); + return { plugins: registry.plugins, plugin }; + }, + head: ({ params, loaderData }) => + pageMeta( + 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], + ), + component: PluginPage, + notFoundComponent: () => ( + + There is no plugin listed at this address. + + ), +}); + +const LINK_CLASS = + "inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; +const VIEW_ALL_CLASS = + "flex-shrink-0 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; +const CATEGORY_PILL_CLASS = + "rounded-full border border-white/10 px-2 py-1 text-xs text-white/40 transition-colors hover:border-white/20 hover:text-white/70"; + +function PluginPage() { + const { plugins, plugin } = Route.useLoaderData(); + const primaryCategory = getCategory(plugin.categories[0]); + + const crumbs = useMemo(() => { + return [ + { label: "Plugins", href: "/plugins" }, + ...(primaryCategory + ? [{ label: primaryCategory.label, href: `/plugins?category=${primaryCategory.slug}` }] + : []), + { label: plugin.name }, + ]; + }, [plugin, primaryCategory]); + + const author = getAuthor(plugin); + const count = plugin.installs; + const readme = plugin.readme; + + return ( + + + +
+
+ +
+

{plugin.name}

+

{plugin.description}

+
+ {author && } + {count !== undefined && ( + + + {formatInstalls(count)} + installs + + )} + {plugin.categories.map((slug) => { + const category = getCategory(slug); + if (!category) return null; + return ( + + {category.label} + + ); + })} +
+
+
+
+ {installCommand(plugin)} +

+ {pluginVersion(plugin)} · Updated {plugin.updatedAt.slice(0, 10)} +

+

For older daemons

+ {pinnedInstallCommand(plugin)} +
+ +
+ + Source + + + {npmUrl(plugin) && ( + + npm + + + )} +
+
+
+
+ + {plugin.screenshots.length > 0 && ( +
+ {plugin.screenshots.map((url, index) => ( + + {`${plugin.name} + + ))} +
+ )} + +
+ {readme} +
+ + +
+ ); +} + +function RelatedPlugins({ plugin, plugins }: { plugin: Plugin; plugins: Plugin[] }) { + const category = getCategory(plugin.categories[0]); + const author = getAuthor(plugin); + const inCategory = category + ? sortPlugins( + getPluginsInCategory(plugins, category.slug).filter( + (candidate) => candidate.id !== plugin.id, + ), + "popular", + ).slice(0, RELATED_COUNT) + : []; + const byAuthor = author + ? sortPlugins( + getPluginsByAuthor(plugins, author.username).filter( + (candidate) => candidate.id !== plugin.id && !inCategory.includes(candidate), + ), + "popular", + ).slice(0, RELATED_COUNT) + : []; + + if (inCategory.length === 0 && byAuthor.length === 0) return null; + + return ( +
+ {category && inCategory.length > 0 && ( +
+
+

More in {category.label}

+ + View all + +
+
+ {inCategory.map((candidate) => ( + + ))} +
+
+ )} + {author && byAuthor.length > 0 && ( +
+
+

More from {author.name}

+ + View all + +
+
+ {byAuthor.map((candidate) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx new file mode 100644 index 0000000000..0c624c2749 --- /dev/null +++ b/packages/website/src/routes/plugins/index.tsx @@ -0,0 +1,261 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Search } from "lucide-react"; +import { type ChangeEvent, useCallback } from "react"; +import { SiteShell } from "~/components/site-shell"; +import { pageMeta } from "~/meta"; +import { + CATEGORIES, + type CategorySlug, + getCategory, + getRegistry, + getPluginsInCategory, + type Plugin, + type PluginSort, + queryPlugins, + sortPlugins, +} from "~/plugins"; +import { CategoryLink } from "~/plugins/category-link"; +import { PLUGIN_GRID_CLASS, PluginCard } from "~/plugins/plugin-card"; +import { PluginsTrustNote } from "~/plugins/plugin-page-header"; +import "~/styles.css"; + +interface PluginsSearch { + category?: CategorySlug; + q?: string; + sort?: PluginSort; +} + +const SECTION_PREVIEW_COUNT = 3; + +export const Route = createFileRoute("/plugins/")({ + validateSearch: (search: Record): PluginsSearch => { + const result: PluginsSearch = {}; + const category = typeof search.category === "string" ? getCategory(search.category) : null; + if (category) result.category = category.slug; + if (typeof search.q === "string" && search.q.trim()) result.q = search.q; + if (search.sort === "new") result.sort = "new"; + return result; + }, + head: () => + pageMeta( + "Plugins – Extend Paseo with community plugins", + "Themes, providers, panels, and automations built by the Paseo community. Install any of them with one command.", + "/plugins", + ), + loader: () => getRegistry(), + component: PluginsPage, +}); + +const PILL_BASE = + "inline-flex flex-shrink-0 items-center rounded-full border px-3 py-1.5 text-xs transition-colors"; +const PILL_ACTIVE = `${PILL_BASE} border-white/20 bg-white/[0.07] text-white`; +const PILL_INACTIVE = `${PILL_BASE} border-white/10 bg-white/[0.025] text-muted-foreground hover:border-white/15 hover:bg-white/[0.04] hover:text-foreground`; +const VIEW_ALL_CLASS = + "flex-shrink-0 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; + +function PluginsPage() { + const search = Route.useSearch(); + const { plugins } = Route.useLoaderData(); + const navigate = useNavigate({ from: "/plugins/" }); + const sort: PluginSort = search.sort ?? "popular"; + const filtering = Boolean(search.category || search.q); + + const handleSearchChange = useCallback( + (event: ChangeEvent) => { + const q = event.target.value; + void navigate({ + search: (prev) => (q ? { ...prev, q } : { ...prev, q: undefined }), + replace: true, + }); + }, + [navigate], + ); + const handleSort = useCallback( + (next: PluginSort) => { + void navigate({ + search: (prev) => ({ ...prev, sort: next === "new" ? next : undefined }), + replace: true, + }); + }, + [navigate], + ); + + return ( + +

Plugins

+

+ Themes, providers, panels, and automations built by the Paseo community. Install any of them + with one command. +

+ + +
+ +
+ + Popular + + + New + +
+
+ +
+ + All + + {CATEGORIES.map((category) => { + if (getPluginsInCategory(plugins, category.slug).length === 0) return null; + return ( + + {category.label} + + ); + })} +
+ + {filtering ? ( + + ) : ( + + )} +
+ ); +} + +function SortButton({ + value, + current, + onSelect, + children, +}: { + value: PluginSort; + current: PluginSort; + onSelect: (sort: PluginSort) => void; + children: string; +}) { + const handleClick = useCallback(() => onSelect(value), [onSelect, value]); + const active = value === current; + return ( + + ); +} + +function CategorySections({ sort, plugins }: { sort: PluginSort; plugins: Plugin[] }) { + return ( +
+ {CATEGORIES.map((category) => { + const all = sortPlugins(getPluginsInCategory(plugins, category.slug), sort); + if (all.length === 0) return null; + const shown = all.slice(0, SECTION_PREVIEW_COUNT); + return ( +
+
+
+

+ {category.label} +

+

{category.description}

+
+ {all.length > shown.length && ( + + View all ({all.length}) + + )} +
+
+ {shown.map((plugin) => ( + + ))} +
+
+ ); + })} +
+ ); +} + +function FilteredPlugins({ + search, + sort, + plugins, +}: { + search: PluginsSearch; + sort: PluginSort; + plugins: Plugin[]; +}) { + const category = search.category ? getCategory(search.category) : null; + const results = queryPlugins(plugins, { category: search.category, q: search.q, sort }); + const total = plugins.length; + + return ( +
+
+

+ {category ? category.label : `Results for “${search.q}”`} +

+

+ {category && !search.q ? category.description : null} + {search.q && category ? `Matching “${search.q}”.` : null} + {!category && search.q ? `${results.length} of ${total} plugins.` : null} +

+
+ {results.length === 0 ? ( +
+

No plugins match.

+ + Clear filters + +
+ ) : ( +
+ {results.map((plugin) => ( + + ))} +
+ )} +
+ ); +} diff --git a/packages/website/src/server-entry.ts b/packages/website/src/server-entry.ts index 657abf9cfe..775e8348ea 100644 --- a/packages/website/src/server-entry.ts +++ b/packages/website/src/server-entry.ts @@ -1,3 +1,4 @@ +import { handlePluginRegistryRequest } from "~/plugins/published"; import type { Register } from "@tanstack/react-router"; import type { RequestHandler } from "@tanstack/react-start/server"; import { getAndroidVersionCode } from "~/android-version"; @@ -8,6 +9,7 @@ import { buildLlmsTxt } from "~/llms"; interface WebsiteEnv { WEBSITE_CACHE?: KVNamespace; + PLUGINS_REGISTRY_URL?: string; } function markdownResponse(body: string): Response { @@ -79,6 +81,12 @@ export default { async fetch(request: Request, env: WebsiteEnv, context: ExecutionContext): Promise { const url = new URL(request.url); + const pluginResponse = await handlePluginRegistryRequest(request, env, { + cache: env.WEBSITE_CACHE ?? null, + waitUntil: (promise) => context.waitUntil(promise), + }); + if (pluginResponse) return pluginResponse; + const environment = import.meta.env.DEV ? "development" : "production"; const canonicalRedirect = getCanonicalRedirect(url, environment); if (canonicalRedirect) { diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css index bfbe26a82a..946176fb43 100644 --- a/packages/website/src/styles.css +++ b/packages/website/src/styles.css @@ -406,6 +406,12 @@ font-weight: 400; } +.docs-prose img { + max-width: 100%; + border-radius: 0.5rem; + border: 1px solid var(--color-border); +} + .docs-prose p { margin-top: 1rem; margin-bottom: 1rem; diff --git a/packages/website/vite.config.ts b/packages/website/vite.config.ts index f9260acf49..46dbc554de 100644 --- a/packages/website/vite.config.ts +++ b/packages/website/vite.config.ts @@ -80,6 +80,11 @@ function discoverBlogRoutes(): string[] { return ["/blog", ...slugs.map((slug) => `/blog/${slug}`)]; } +// Author and detail URLs are served by /sitemap-plugins.xml as the registry changes. +function discoverPluginRoutes(): string[] { + return ["/plugins"]; +} + const sitemapPages = [ "/", "/agents", @@ -90,6 +95,7 @@ const sitemapPages = [ "/terms", ...discoverAgentRoutes(), ...discoverAlternativeRoutes(), + ...discoverPluginRoutes(), ...discoverDocsRoutes(), ...discoverBlogRoutes(), ].map((routePath) => ({ diff --git a/packages/website/wrangler.toml b/packages/website/wrangler.toml index 089a22089e..48db01ed78 100644 --- a/packages/website/wrangler.toml +++ b/packages/website/wrangler.toml @@ -7,6 +7,7 @@ main = "./src/server-entry.ts" routes = [ { pattern = "paseo.sh", custom_domain = true }, { pattern = "www.paseo.sh", custom_domain = true }, + { pattern = "plugins.paseo.sh", custom_domain = true }, ] [observability] diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 85e04bbdc3..fc473d99d0 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2208,11 +2208,12 @@ directory. The app does not expand `~`; your shell may expand it before the CLI | Source | Accepted form | Example | | -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- | +| Registry plugin | `owner/slug` or `host/owner/slug` | `omercnet/dracula` | | 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 | `github:owner/repository` | `github:acme/paseo-review` | | Git repository | `git:`; the prefix is optional for URLs and SCP sources | `git:https://git.example.com/acme/review.git` | | npm package | `npm:[@]`; `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 an explicit npm/Git 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. @@ -2237,8 +2238,9 @@ Paseo resolves an identifier in this order: 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. +5. Resolve bare `owner/slug` and `host/owner/slug` through the plugin registry. Registry records + own the artifact revision and plugin path. GitHub shorthand requires `github:`; `git:` also + accepts shorthand, URLs, and SCP sources. 6. Resolve a remaining npm package name with its optional selector through the host's registry. Reject anything else. @@ -2287,9 +2289,9 @@ 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 github:owner/repository paseo plugin add https://git.example.com/owner/repository.git --ref main -paseo plugin add owner/monorepo:plugins/review +paseo plugin add github:owner/monorepo:plugins/review paseo plugin ls [id] paseo plugin update paseo plugin update --all --check