mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 05:32:58 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
+25
-15
@@ -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
|
||||
|
||||
|
||||
Generated
+2
-1
@@ -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",
|
||||
|
||||
@@ -218,7 +218,7 @@ export function createPluginCommand(): Command {
|
||||
.description("Trust and install a plugin from a directory, Git repository, or npm package")
|
||||
.argument(
|
||||
"<source>",
|
||||
"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 <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
|
||||
.option("--ref <ref>", "Git branch, tag, or commit")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof PluginRegistryIdentitySchema>;
|
||||
export type PluginRegistries = z.infer<typeof PluginRegistriesSchema>;
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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<string, PluginSource>;
|
||||
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"),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing registry address");
|
||||
const 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<void>((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<string | string[] | undefined> = [];
|
||||
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<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing registry address");
|
||||
const options = { 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<void>((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);
|
||||
|
||||
@@ -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<typeof ManagedPluginRecordSchema>;
|
||||
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<string, ManagedPluginRecord>;
|
||||
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<ManagedPluginCandidate> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--verify", ref], { cwd });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
function assertRegistryArtifactIdentity(
|
||||
identity: Exclude<PluginInstallation["identity"], { kind: "directory" }>,
|
||||
artifact: Awaited<ReturnType<typeof resolveRegistryPlugin>>["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";
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
if (authorization) headers.Authorization = authorization;
|
||||
if (install) headers["X-Paseo-Install"] = "1";
|
||||
const response = await fetch(`${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`, {
|
||||
headers,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
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 };
|
||||
}
|
||||
@@ -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("<loc>https://paseo.sh/plugins/omercnet</loc>");
|
||||
expect(await plugins.text()).toContain(
|
||||
"<loc>https://paseo.sh/plugins/omercnet/fresh-worktrees</loc>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://paseo.sh/sitemap-index.xml
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap><loc>https://paseo.sh/sitemap.xml</loc></sitemap>
|
||||
<sitemap><loc>https://paseo.sh/sitemap-plugins.xml</loc></sitemap>
|
||||
</sitemapindex>
|
||||
@@ -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 (
|
||||
<nav aria-label="Breadcrumb" className="not-prose mb-6">
|
||||
<ol className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
{items.map((item, index) => {
|
||||
const last = index === items.length - 1;
|
||||
return (
|
||||
<li key={`${item.href ?? ""}#${item.label}`} className="flex items-center gap-2">
|
||||
{index > 0 && <ChevronRight size={14} className="text-border" />}
|
||||
{item.href && !last ? (
|
||||
<Link to={item.href} className="hover:text-foreground transition-colors">
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={last ? "text-foreground" : undefined}>{item.label}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<nav aria-label="Breadcrumb" className="not-prose mb-6">
|
||||
<ol className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<Link to="/docs" className="hover:text-foreground transition-colors">
|
||||
Docs
|
||||
</Link>
|
||||
</li>
|
||||
{groups.map((group) => (
|
||||
<li key={group.label} className="flex items-center gap-2">
|
||||
<ChevronRight size={14} className="text-border" />
|
||||
<span>{group.label}</span>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex items-center gap-2">
|
||||
<ChevronRight size={14} className="text-border" />
|
||||
<span className="text-foreground">{doc.frontmatter.nav}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
const items = useMemo<BreadcrumbItem[]>(
|
||||
() => [
|
||||
{ label: "Docs", href: "/docs" },
|
||||
...getDocBreadcrumbGroups(doc, tree).map((group) => ({ label: group.label })),
|
||||
{ label: doc.frontmatter.nav },
|
||||
],
|
||||
[doc, tree],
|
||||
);
|
||||
return <Breadcrumbs items={items} />;
|
||||
}
|
||||
|
||||
@@ -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<ExtensibleCardLink> = [
|
||||
{ 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<ExtensibleCardLink> = [
|
||||
|
||||
@@ -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
|
||||
</a>
|
||||
{PLUGINS_LINKED && (
|
||||
<a
|
||||
href="/plugins"
|
||||
className="block text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Plugins
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="/changelog"
|
||||
className="block text-muted-foreground hover:text-foreground transition-colors"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PLUGINS_LINKED } from "~/plugins/launch";
|
||||
import "~/styles.css";
|
||||
import { DiscordIcon, GitHubIcon } from "~/components/brand-icons";
|
||||
import { useStars } from "~/routes/__root";
|
||||
@@ -23,6 +24,14 @@ export function SiteHeader() {
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
{PLUGINS_LINKED && (
|
||||
<a
|
||||
href="/plugins"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Plugins
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="/changelog"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const SITE_ORIGIN = "https://paseo.sh";
|
||||
|
||||
export function pageMeta(title: string, description: string, path: string) {
|
||||
export function pageMeta(title: string, description: string, path: string, image?: string) {
|
||||
const url = `${SITE_ORIGIN}${path}`;
|
||||
return {
|
||||
meta: [
|
||||
@@ -9,6 +9,12 @@ export function pageMeta(title: string, description: string, path: string) {
|
||||
{ property: "og:title", content: title },
|
||||
{ property: "og:description", content: description },
|
||||
{ property: "og:url", content: url },
|
||||
...(image
|
||||
? [
|
||||
{ property: "og:image", content: image },
|
||||
{ name: "twitter:image", content: image },
|
||||
]
|
||||
: []),
|
||||
{ name: "twitter:title", content: title },
|
||||
{ name: "twitter:description", content: description },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { type Author, authorAvatarUrl } from "./registry";
|
||||
|
||||
const SIZE_CLASS = {
|
||||
sm: "h-5 w-5 text-[10px]",
|
||||
lg: "h-14 w-14 text-xl",
|
||||
} as const;
|
||||
|
||||
export function AuthorAvatar({ author, size }: { author: Author; size: keyof typeof SIZE_CLASS }) {
|
||||
const url = authorAvatarUrl(author);
|
||||
const className = `flex flex-shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/10 bg-white/[0.06] font-medium text-white/60 ${SIZE_CLASS[size]}`;
|
||||
if (url) {
|
||||
return (
|
||||
<span className={className}>
|
||||
<img src={url} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span aria-hidden className={className}>
|
||||
{author.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Link to="/plugins/$owner" params={params} className={className}>
|
||||
{children ?? (
|
||||
<>
|
||||
<AuthorAvatar author={author} size="sm" />
|
||||
{author.name}
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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 (
|
||||
<Link to="/plugins" search={search} replace={replace} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./registry";
|
||||
export { getRegistry, getRegistryPlugin } from "./loader";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Keep navigation on paseo.cafe until the coordinated directory announcement.
|
||||
export const PLUGINS_LINKED = false;
|
||||
@@ -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()));
|
||||
@@ -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 (
|
||||
<SiteShell width="default">
|
||||
<Breadcrumbs items={NOT_FOUND_CRUMBS} />
|
||||
<h1 className="text-3xl font-medium tracking-tight mb-4">{title}</h1>
|
||||
<p className="text-lg text-white/70 leading-relaxed max-w-2xl">
|
||||
{children}{" "}
|
||||
<Link to="/plugins" className="underline hover:text-white">
|
||||
Browse all plugins
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Link
|
||||
to="/plugins/$owner/$slug"
|
||||
params={params}
|
||||
className="flex flex-col gap-3 rounded-xl border border-white/10 bg-white/[0.03] p-4 hover:border-white/20 hover:bg-white/[0.05] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PluginTile plugin={plugin} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white">{plugin.name}</p>
|
||||
{author && <p className="truncate text-xs text-extra-muted-foreground">{author.name}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<p className="line-clamp-2 flex-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{plugin.description}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-white/40">
|
||||
{category ? (
|
||||
<span className="truncate rounded-full border border-white/10 px-2 py-1">
|
||||
{category.label}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{plugin.installs !== undefined && (
|
||||
<span className="inline-flex flex-shrink-0 items-center gap-1 tabular-nums">
|
||||
<Download className="h-3 w-3" />
|
||||
{formatInstalls(plugin.installs)} installs
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
|
||||
export function PluginsTrustNote() {
|
||||
return (
|
||||
<p className="inline-flex items-start gap-2 text-xs text-extra-muted-foreground">
|
||||
<ShieldAlert className="mt-0.5 h-3 w-3 flex-shrink-0" />
|
||||
Plugins run unsandboxed on your machine. Read the source before you install.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
aria-hidden
|
||||
className={`flex flex-shrink-0 items-center justify-center border border-white/10 bg-white/[0.06] font-medium text-white/80 ${SIZE_CLASS[size]}`}
|
||||
>
|
||||
{plugin.icon ? (
|
||||
<img src={plugin.icon} alt="" className="h-full w-full rounded-[inherit] object-cover" />
|
||||
) : (
|
||||
plugin.name.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<unknown> {
|
||||
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<typeof PluginRegistryIndexSchema.parse> =>
|
||||
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<typeof PublishedPluginDetailSchema.parse> | 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<Response | null> {
|
||||
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<string>(["/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) => `<url><loc>https://paseo.sh${path}</loc></url>`);
|
||||
return new Response(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join("")}</urlset>`,
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -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<void>((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<void>((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");
|
||||
});
|
||||
});
|
||||
@@ -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<typeof PublishedPluginSchema>;
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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: () => (
|
||||
<PluginsNotFound title="Author not found">
|
||||
Nobody with that GitHub owner has a plugin listed.
|
||||
</PluginsNotFound>
|
||||
),
|
||||
});
|
||||
|
||||
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<BreadcrumbItem[]>(
|
||||
() => [{ label: "Plugins", href: "/plugins" }, { label: author.name }],
|
||||
[author],
|
||||
);
|
||||
|
||||
const plugins = sortPlugins(getPluginsByAuthor(allPlugins, author.username), "popular");
|
||||
const github = authorGitHubUrl(author);
|
||||
|
||||
return (
|
||||
<SiteShell width="default">
|
||||
<Breadcrumbs items={crumbs} />
|
||||
<div className="flex items-start gap-4">
|
||||
<AuthorAvatar author={author} size="lg" />
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-medium tracking-tight">{author.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{plugins.length} {plugins.length === 1 ? "plugin" : "plugins"}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 pt-1">
|
||||
{authorNpmUrl(author) && (
|
||||
<a
|
||||
href={authorNpmUrl(author)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
npm
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
{github && (
|
||||
<a href={github} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||
GitHub
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mt-12 ${PLUGIN_GRID_CLASS}`}>
|
||||
{plugins.map((plugin) => (
|
||||
<PluginCard key={plugin.id} plugin={plugin} />
|
||||
))}
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -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: () => (
|
||||
<PluginsNotFound title="Plugin not found">
|
||||
There is no plugin listed at this address.
|
||||
</PluginsNotFound>
|
||||
),
|
||||
});
|
||||
|
||||
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<BreadcrumbItem[]>(() => {
|
||||
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 (
|
||||
<SiteShell width="default">
|
||||
<Breadcrumbs items={crumbs} />
|
||||
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<PluginTile plugin={plugin} size="lg" />
|
||||
<div className="min-w-0 space-y-2">
|
||||
<h1 className="text-3xl font-medium tracking-tight">{plugin.name}</h1>
|
||||
<p className="text-lg text-white/70 leading-relaxed max-w-2xl">{plugin.description}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 pt-1">
|
||||
{author && <AuthorLink author={author} />}
|
||||
{count !== undefined && (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground tabular-nums">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{formatInstalls(count)}
|
||||
<span className="text-extra-muted-foreground">installs</span>
|
||||
</span>
|
||||
)}
|
||||
{plugin.categories.map((slug) => {
|
||||
const category = getCategory(slug);
|
||||
if (!category) return null;
|
||||
return (
|
||||
<CategoryLink key={slug} category={category.slug} className={CATEGORY_PILL_CLASS}>
|
||||
{category.label}
|
||||
</CategoryLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full space-y-2 lg:w-[30rem] lg:flex-shrink-0">
|
||||
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pluginVersion(plugin)} · Updated {plugin.updatedAt.slice(0, 10)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">For older daemons</p>
|
||||
<CodeBlock size="sm">{pinnedInstallCommand(plugin)}</CodeBlock>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<PluginsTrustNote />
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href={plugin.repository.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
Source
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
{npmUrl(plugin) && (
|
||||
<a
|
||||
href={npmUrl(plugin)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
npm
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plugin.screenshots.length > 0 && (
|
||||
<div className="-mx-6 mt-12 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0">
|
||||
{plugin.screenshots.map((url, index) => (
|
||||
<a
|
||||
key={url}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] lg:w-[calc(50%-0.375rem)]"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`${plugin.name} screenshot ${index + 1}`}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-12 max-w-3xl">
|
||||
<DocsMarkdown>{readme}</DocsMarkdown>
|
||||
</div>
|
||||
|
||||
<RelatedPlugins plugin={plugin} plugins={plugins} />
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-20 space-y-16">
|
||||
{category && inCategory.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-6 flex items-end justify-between gap-4">
|
||||
<h2 className="text-xl font-medium">More in {category.label}</h2>
|
||||
<CategoryLink category={category.slug} className={VIEW_ALL_CLASS}>
|
||||
View all
|
||||
</CategoryLink>
|
||||
</div>
|
||||
<div className={PLUGIN_GRID_CLASS}>
|
||||
{inCategory.map((candidate) => (
|
||||
<PluginCard key={candidate.id} plugin={candidate} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{author && byAuthor.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-6 flex items-end justify-between gap-4">
|
||||
<h2 className="text-xl font-medium">More from {author.name}</h2>
|
||||
<AuthorLink author={author} className={VIEW_ALL_CLASS}>
|
||||
View all
|
||||
</AuthorLink>
|
||||
</div>
|
||||
<div className={PLUGIN_GRID_CLASS}>
|
||||
{byAuthor.map((candidate) => (
|
||||
<PluginCard key={candidate.id} plugin={candidate} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>): 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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<SiteShell width="default">
|
||||
<h1 className="text-3xl font-medium tracking-tight mb-4">Plugins</h1>
|
||||
<p className="text-lg text-white/70 leading-relaxed max-w-2xl">
|
||||
Themes, providers, panels, and automations built by the Paseo community. Install any of them
|
||||
with one command.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<PluginsTrustNote />
|
||||
<a
|
||||
href="/docs/plugins"
|
||||
className="text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
|
||||
>
|
||||
Build your own
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<label className="relative flex-1 md:max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-extra-muted-foreground" />
|
||||
<input
|
||||
type="search"
|
||||
value={search.q ?? ""}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Search plugins"
|
||||
aria-label="Search plugins"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/[0.03] py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-extra-muted-foreground focus:border-white/20 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<div role="group" aria-label="Sort" className="flex items-center gap-2">
|
||||
<SortButton value="popular" current={sort} onSelect={handleSort}>
|
||||
Popular
|
||||
</SortButton>
|
||||
<SortButton value="new" current={sort} onSelect={handleSort}>
|
||||
New
|
||||
</SortButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="-mx-6 mt-4 flex gap-2 overflow-x-auto px-6 pb-1 md:mx-0 md:flex-wrap md:px-0">
|
||||
<CategoryLink
|
||||
category={null}
|
||||
q={search.q}
|
||||
sort={sort}
|
||||
replace
|
||||
className={search.category ? PILL_INACTIVE : PILL_ACTIVE}
|
||||
>
|
||||
All
|
||||
</CategoryLink>
|
||||
{CATEGORIES.map((category) => {
|
||||
if (getPluginsInCategory(plugins, category.slug).length === 0) return null;
|
||||
return (
|
||||
<CategoryLink
|
||||
key={category.slug}
|
||||
category={category.slug}
|
||||
q={search.q}
|
||||
sort={sort}
|
||||
replace
|
||||
className={search.category === category.slug ? PILL_ACTIVE : PILL_INACTIVE}
|
||||
>
|
||||
{category.label}
|
||||
</CategoryLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filtering ? (
|
||||
<FilteredPlugins search={search} sort={sort} plugins={plugins} />
|
||||
) : (
|
||||
<CategorySections sort={sort} plugins={plugins} />
|
||||
)}
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
aria-pressed={active}
|
||||
className={active ? PILL_ACTIVE : PILL_INACTIVE}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CategorySections({ sort, plugins }: { sort: PluginSort; plugins: Plugin[] }) {
|
||||
return (
|
||||
<div className="mt-12 space-y-16">
|
||||
{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 (
|
||||
<section key={category.slug} aria-labelledby={`category-${category.slug}`}>
|
||||
<div className="mb-6 flex items-end justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 id={`category-${category.slug}`} className="text-xl font-medium">
|
||||
{category.label}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{category.description}</p>
|
||||
</div>
|
||||
{all.length > shown.length && (
|
||||
<CategoryLink category={category.slug} sort={sort} className={VIEW_ALL_CLASS}>
|
||||
View all ({all.length})
|
||||
</CategoryLink>
|
||||
)}
|
||||
</div>
|
||||
<div className={PLUGIN_GRID_CLASS}>
|
||||
{shown.map((plugin) => (
|
||||
<PluginCard key={plugin.id} plugin={plugin} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="mt-12" aria-live="polite">
|
||||
<div className="mb-6 space-y-1">
|
||||
<h2 className="text-xl font-medium">
|
||||
{category ? category.label : `Results for “${search.q}”`}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{category && !search.q ? category.description : null}
|
||||
{search.q && category ? `Matching “${search.q}”.` : null}
|
||||
{!category && search.q ? `${results.length} of ${total} plugins.` : null}
|
||||
</p>
|
||||
</div>
|
||||
{results.length === 0 ? (
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center">
|
||||
<p className="text-sm text-muted-foreground">No plugins match.</p>
|
||||
<CategoryLink
|
||||
category={null}
|
||||
className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
|
||||
>
|
||||
Clear filters
|
||||
</CategoryLink>
|
||||
</div>
|
||||
) : (
|
||||
<div className={PLUGIN_GRID_CLASS}>
|
||||
{results.map((plugin) => (
|
||||
<PluginCard key={plugin.id} plugin={plugin} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:<URL or SCP source>`; the prefix is optional for URLs and SCP sources | `git:https://git.example.com/acme/review.git` |
|
||||
| npm package | `npm:<name>[@<version, tag, or range>]`; `npm:` is optional | `npm:@acme/paseo-review@^1.2.0` |
|
||||
| Plugin below a source root | Append `:relative/plugin/path` to any source | `github:acme/monorepo:plugins/review` |
|
||||
| Plugin below a source root | Append `:relative/plugin/path` to 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 <id>
|
||||
paseo plugin update --all --check
|
||||
|
||||
Reference in New Issue
Block a user