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