Enable plugin registry installs by default (#6224)

* feat(plugins): enable registry installs by default

* fix(plugins): explain explicit Git source for missing registry IDs

* fix(plugins): tailor missing registry guidance to installation

* test(app): sample footer geometry in one browser frame

* fix(plugins): make registry source resolution deterministic

Directory existence must not change the meaning of a registry identifier shared by CLI, RPC, and UI installs. Classify explicit host paths in the source parser and explain recovery from registry transport and revision-selection failures.

* fix(plugins): recognize dot and Windows directory sources

* test(cli): wait for readable daemon ownership before cancellation

The cancellation test raced PID-file creation against its first write and parsed empty JSON in CI. Use the public daemon-instance reader to wait for and capture the supervisor PID before asserting cancellation cleanup.

* test(app): expect missing directory install guidance
This commit is contained in:
Mohamed Boudra
2026-10-06 16:52:02 +01:00
committed by GitHub
parent 56d5680742
commit 7fd469ae19
24 changed files with 385 additions and 118 deletions
+2
View File
@@ -27,6 +27,8 @@
### Changed
- Enabled the plugin registry by default: `paseo plugin add owner/slug` installs a reviewed artifact even when a local directory matches; use explicit local paths (including `.`, `..`, and Windows drive or UNC paths), and use `github:owner/repository` for direct GitHub installs. Registry connection, missing-ID, and `--ref` errors include recovery steps.
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975))
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
+7 -10
View File
@@ -117,12 +117,9 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
## Install from a registry
Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
artifact, and GitHub shorthand requires `github:`. Registry installs
`paseo plugin add owner/slug` installs the registry's reviewed artifact by default.
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
selection is unavailable for registry installs; install an explicit source to select your own.
@@ -138,13 +135,13 @@ owns static hosting, record shapes, pins, and advisory install counts.
## Install a Git source
GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git
URL. An existing directory still wins over source resolution.
GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git
URL.
```bash
paseo plugin install owner/repository
paseo plugin install git:owner/repository
paseo plugin install https://gitlab.com/group/repository.git
paseo plugin install owner/monorepo:plugins/review
paseo plugin install git:owner/monorepo:plugins/review
paseo plugin install github:owner/repository --ref main
```
@@ -557,7 +557,7 @@ async function retryGitInstallAndInspectRows(
await installPlugin(page, missingSource);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
"Plugin source is neither an existing directory nor a Git URL",
`Plugin directory does not exist: ${missingSource}`,
);
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
await capturePluginInstallForm(page, testInfo, "compact-error");
@@ -244,13 +244,16 @@ const FOOTER_GEOMETRY_TOLERANCE = 0.01;
* Settings together at the end.
*/
export async function expectFooterIconRow(page: Page): Promise<void> {
const boxes = (
await Promise.all(
FOOTER_ICON_TEST_IDS.map((testID) =>
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
),
)
).map((box) => box!);
// Read one browser-frame snapshot: the compact drawer can translate between separate RPCs.
const boxes = await page
.locator(FOOTER_ICON_TEST_IDS.map((testID) => `[data-testid="${testID}"]:visible`).join(","))
.evaluateAll((elements) =>
elements.map((element) => {
const { x, y, width, height } = element.getBoundingClientRect();
return { testID: element.getAttribute("data-testid"), x, y, width, height };
}),
);
expect(boxes.map((box) => box.testID)).toEqual(FOOTER_ICON_TEST_IDS);
const [first] = boxes;
for (const box of boxes) {
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
+1 -1
View File
@@ -2160,7 +2160,7 @@ export const en = {
globalTitle: "Enable plugins",
globalHint: "Global switch for every configured plugin",
sourceLabel: "Plugin source",
sourcePlaceholder: "Directory, Git URL, or npm package",
sourcePlaceholder: "owner/slug, directory, Git URL, or npm package",
docs: "Docs",
install: "Install plugin",
installing: "Installing…",
@@ -27,7 +27,7 @@ export const pluginSettings = {
globalTitle: "تفعيل الإضافات",
globalHint: "مفتاح عام لكل الإضافات المضبوطة",
sourceLabel: "مصدر الإضافة",
sourcePlaceholder: "مجلد أو رابط Git أو حزمة npm",
sourcePlaceholder: "owner/slug, مجلد أو رابط Git أو حزمة npm",
docs: "الوثائق",
install: "تثبيت الإضافة",
installing: "جارٍ التثبيت…",
@@ -82,7 +82,7 @@ export const pluginSettings = {
globalTitle: "Activar plugins",
globalHint: "Interruptor global para todos los plugins configurados",
sourceLabel: "Fuente del plugin",
sourcePlaceholder: "Directorio, URL de Git o paquete npm",
sourcePlaceholder: "owner/slug, Directorio, URL de Git o paquete npm",
docs: "Documentación",
install: "Instalar plugin",
installing: "Instalando…",
@@ -137,7 +137,7 @@ export const pluginSettings = {
globalTitle: "Activer les plugins",
globalHint: "Interrupteur global pour tous les plugins configurés",
sourceLabel: "Source du plugin",
sourcePlaceholder: "Dossier, URL Git ou paquet npm",
sourcePlaceholder: "owner/slug, Dossier, URL Git ou paquet npm",
docs: "Documentation",
install: "Installer le plugin",
installing: "Installation…",
@@ -192,7 +192,7 @@ export const pluginSettings = {
globalTitle: "プラグインを有効化",
globalHint: "設定済みプラグインすべての共通スイッチ",
sourceLabel: "プラグインソース",
sourcePlaceholder: "ディレクトリ、Git URL、npm パッケージ",
sourcePlaceholder: "owner/slug, ディレクトリ、Git URL、npm パッケージ",
docs: "ドキュメント",
install: "プラグインをインストール",
installing: "インストール中…",
@@ -246,7 +246,7 @@ export const pluginSettings = {
globalTitle: "플러그인 활성화",
globalHint: "설정된 모든 플러그인의 전역 스위치",
sourceLabel: "플러그인 소스",
sourcePlaceholder: "디렉터리, Git URL 또는 npm 패키지",
sourcePlaceholder: "owner/slug, 디렉터리, Git URL 또는 npm 패키지",
docs: "문서",
install: "플러그인 설치",
installing: "설치 중…",
@@ -301,7 +301,7 @@ export const pluginSettings = {
globalTitle: "Ativar plugins",
globalHint: "Controle global para todos os plugins configurados",
sourceLabel: "Fonte do plugin",
sourcePlaceholder: "Diretório, URL Git ou pacote npm",
sourcePlaceholder: "owner/slug, Diretório, URL Git ou pacote npm",
docs: "Documentação",
install: "Instalar plugin",
installing: "Instalando…",
@@ -356,7 +356,7 @@ export const pluginSettings = {
globalTitle: "Включить плагины",
globalHint: "Общий переключатель для всех настроенных плагинов",
sourceLabel: "Источник плагина",
sourcePlaceholder: "Каталог, URL Git или пакет npm",
sourcePlaceholder: "owner/slug, Каталог, URL Git или пакет npm",
docs: "Документация",
install: "Установить плагин",
installing: "Установка…",
@@ -410,7 +410,7 @@ export const pluginSettings = {
globalTitle: "启用插件",
globalHint: "所有已配置插件的全局开关",
sourceLabel: "插件来源",
sourcePlaceholder: "目录、Git URL 或 npm 包",
sourcePlaceholder: "owner/slug, 目录、Git URL 或 npm 包",
docs: "文档",
install: "安装插件",
installing: "正在安装…",
@@ -358,14 +358,21 @@ test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])(
stdio: "ignore",
});
const exited = new Promise((resolve) => child!.once("exit", resolve));
// The lock file exists before its contents identify the supervisor.
let supervisorPid: number | undefined;
await expect
.poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 })
.toBe(true);
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
.poll(
async () => {
supervisorPid = (await readDaemonInstance(home))?.pid;
return supervisorPid;
},
{ timeout: 10_000 },
)
.toBeTypeOf("number");
await f.ok(["status", "--home", home]);
child.kill("SIGINT");
await exited;
expect(() => process.kill(lock.pid, 0)).toThrow();
expect(() => process.kill(supervisorPid!, 0)).toThrow();
expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped");
} finally {
child?.kill("SIGTERM");
@@ -255,12 +255,12 @@ describe("plugin management commands", () => {
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const command = createPluginCommand();
await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], {
await command.parseAsync(["install", "git:owner/monorepo", "--path", "plugins/review"], {
from: "user",
});
expect(installPluginSource).toHaveBeenCalledWith({
source: "owner/monorepo:plugins/review",
source: "git:owner/monorepo:plugins/review",
});
stderr.mockRestore();
});
+4 -2
View File
@@ -215,10 +215,12 @@ export function createPluginCommand(): Command {
plugin
.command("install")
.alias("add")
.description("Trust and install a plugin from a directory, Git repository, or npm package")
.description(
"Trust and install a plugin from a registry, directory, Git repository, or npm package",
)
.argument(
"<source>",
"Host directory, GitHub owner/repo, git: or npm: source, optionally followed by :plugin/path",
"Registry owner/slug or host/owner/slug, host directory, explicit git:owner/repo or Git URL, or npm: source",
)
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
.option("--ref <ref>", "Git branch, tag, or commit")
@@ -3,6 +3,35 @@ import { describe, expect, it } from "vitest";
import { parsePluginSourceReference } from "./plugin-source-reference.js";
describe("plugin source references", () => {
it.each([
".",
"..",
"./owner/slug",
"../owner/slug",
"/owner/slug",
"~/owner/slug",
"~",
"C:\\",
"C:/",
"D:\\plugins\\repository",
"D:/plugins/repository",
"\\\\server\\share",
"\\\\server\\share\\plugins\\review",
".\\plugins\\review",
"..\\plugins\\review",
])("recognizes the explicit directory %s by shape", (source) => {
expect(parsePluginSourceReference(source)).toEqual({
kind: "directory",
source,
pluginPath: undefined,
});
expect(parsePluginSourceReference(`${source}:plugins/review`)).toEqual({
kind: "directory",
source,
pluginPath: "plugins/review",
});
});
it.each([
["npm:review@1.2.0:nested", "npm:review@1.2.0", "nested"],
["npm:review@^1.2.0", "npm:review@^1.2.0", undefined],
@@ -16,6 +45,7 @@ describe("plugin source references", () => {
["git:file:///repo:plugins/main", "git:file:///repo", "plugins/main"],
["plugins.example.com:8443/owner/slug", "plugins.example.com:8443/owner/slug", undefined],
["owner/repository", "owner/repository", undefined],
["plugins/foo", "plugins/foo", undefined],
["owner/repository:plugins/review", "owner/repository", "plugins/review"],
[
"https://example.test:8443/owner/repository.git",
@@ -39,10 +69,8 @@ describe("plugin source references", () => {
"file:///D:/plugins/repository",
"plugins/review",
],
["D:\\plugins\\repository", "D:\\plugins\\repository", undefined],
["D:\\plugins\\repository:plugins/review", "D:\\plugins\\repository", "plugins/review"],
])("parses %s", (reference, source, pluginPath) => {
expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath });
expect(parsePluginSourceReference(reference)).toEqual({ kind: "managed", source, pluginPath });
});
});
@@ -1,19 +1,27 @@
import { parsePluginRegistryReference } from "./plugin-registry.js";
import type { PluginInstallation } from "./messages.js";
export interface PluginSourceReference {
kind: "directory" | "managed";
source: string;
pluginPath: string | undefined;
}
export function parsePluginSourceReference(reference: string): PluginSourceReference {
if (parsePluginRegistryReference(reference)) return { source: reference, pluginPath: undefined };
const prefix = /^(npm:|github:|git:(?!\/\/))/.exec(reference)?.[0];
const source = prefix ? reference.slice(prefix.length) : reference;
const parsed = splitPluginPath(source, { scp: prefix !== "npm:" });
return { ...parsed, source: `${prefix ?? ""}${parsed.source}` };
if (!prefix && /^(\.\.?(?:[/\\]|$)|\/|~|[A-Za-z]:[/\\]|\\\\)/.test(parsed.source)) {
return { kind: "directory", ...parsed };
}
if (parsePluginRegistryReference(reference))
return { kind: "managed", source: reference, pluginPath: undefined };
return { kind: "managed", ...parsed, source: `${prefix ?? ""}${parsed.source}` };
}
function splitPluginPath(reference: string, options: { scp: boolean }): PluginSourceReference {
function splitPluginPath(
reference: string,
options: { scp: boolean },
): Pick<PluginSourceReference, "source" | "pluginPath"> {
const separator = reference.lastIndexOf(":");
if (separator === -1) return { source: reference, pluginPath: undefined };
-2
View File
@@ -414,7 +414,6 @@ export interface PaseoDaemonConfig {
plugins?: Record<string, PluginSource>;
pluginRegistries?: PluginRegistries;
pluginRegistryUrl?: string;
pluginRegistryEnabled?: boolean;
staticDir: string;
mcpDebug: boolean;
isDev?: boolean;
@@ -625,7 +624,6 @@ export async function createPaseoDaemon(
usageSession: (id) => agentManager.usageSession(id),
},
managedSources: new ManagedPluginSources(config.paseoHome, {
enabled: config.pluginRegistryEnabled ?? false,
registries: config.pluginRegistries,
defaultUrl: config.pluginRegistryUrl,
}),
@@ -30,7 +30,6 @@ export const DAEMON_SETTING_ENV_KEYS = [
"PASEO_LOG_ROTATE_SIZE",
"PASEO_PASSWORD",
"PASEO_PLUGIN_REGISTRY",
"PASEO_PLUGIN_REGISTRY_ENABLED",
"PASEO_RELAY_ENABLED",
"PASEO_RELAY_ENDPOINT",
"PASEO_RELAY_PUBLIC_ENDPOINT",
+9 -11
View File
@@ -205,21 +205,19 @@ test("loads private plugin registry settings through the configuration boundary"
const home = await mkdtemp(path.join(os.tmpdir(), "paseo-registry-config-"));
try {
const pluginRegistries = { "plugins.example.test": { authorization: "Bearer fixture" } };
await writeFile(path.join(home, "config.json"), JSON.stringify({ pluginRegistries }));
await writeFile(
path.join(home, "config.json"),
JSON.stringify({ pluginRegistries, pluginRegistryEnabled: false }),
);
const config = loadConfig(home, {
env: { PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal" },
env: {
PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal",
PASEO_PLUGIN_REGISTRY_ENABLED: "false",
},
});
expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal");
expect(config.pluginRegistries).toEqual(pluginRegistries);
expect(config.pluginRegistryEnabled).toBe(false);
await writeFile(
path.join(home, "config.json"),
JSON.stringify({ pluginRegistries, pluginRegistryEnabled: true }),
);
expect(loadConfig(home, { env: {} }).pluginRegistryEnabled).toBe(true);
expect(
loadConfig(home, { env: { PASEO_PLUGIN_REGISTRY_ENABLED: "false" } }).pluginRegistryEnabled,
).toBe(false);
expect(config).not.toHaveProperty("pluginRegistryEnabled");
} finally {
await rm(home, { recursive: true, force: true });
}
-4
View File
@@ -628,10 +628,6 @@ export function resolveConfigFromPersisted(
plugins: persisted.plugins,
pluginRegistries: persisted.pluginRegistries,
pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY,
pluginRegistryEnabled:
parseBooleanEnv(env.PASEO_PLUGIN_REGISTRY_ENABLED) ??
persisted.pluginRegistryEnabled ??
false,
mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"),
@@ -308,6 +308,7 @@ export const PersistedConfigSchema = z
providers: ProvidersSchema.optional(),
pluginRegistries: PluginRegistriesSchema.optional(),
// COMPAT(plugin-registry-gate): added in v0.11.0; accept the ignored startup key until 2027-04-06.
pluginRegistryEnabled: z.boolean().optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
@@ -10,6 +10,7 @@ import {
stat,
writeFile,
} from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -451,7 +452,102 @@ describe("PluginService", () => {
await service.stopAllPlugins();
});
it("prefers an existing directory and installs its selected plugin subdirectory", async () => {
it("resolves a bare registry id even when a matching directory exists in the daemon cwd", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
const owner = path.join(process.cwd(), `registry-shadow-${randomUUID()}`);
await mkdir(owner);
roots.push(home, owner);
const source = `${path.basename(owner)}/example`;
const repository = await createPlugin("registry-example", "export default () => () => {};\n");
await mkdir(path.join(owner, "example"));
await cp(repository, path.join(owner, "example"), { recursive: true });
await runGitCommand(["init", "-b", "main"], { cwd: repository });
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(
[
"-c",
"user.name=Paseo Tests",
"-c",
"user.email=paseo@example.test",
"commit",
"-m",
"fixture",
],
{ cwd: repository },
);
const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository });
const requests: Array<string | undefined> = [];
const server = createServer((request, response) => {
requests.push(request.url);
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify({
id: source,
name: "Example",
description: "Test",
categories: [],
author: { github: path.basename(owner) },
repository: { url: "https://github.com/fixture/example" },
artifact: {
kind: "git",
remote: pathToFileURL(repository).href,
commit: stdout.trim(),
pluginPath: ".",
},
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 url = `http://127.0.0.1:${address.port}`;
const store = createStore(home);
store.patch({ pluginsEnabled: false });
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0", {
managedSources: new ManagedPluginSources(home, { defaultUrl: url }),
}),
);
try {
await service.start();
await expect(service.installSource({ source })).resolves.toMatchObject({
id: "registry-example",
status: "disabled",
installation: {
identity: { kind: "git", registry: { url, id: source } },
currentRevision: stdout.trim(),
},
});
expect(requests).toEqual([`/plugins/${source}.json`]);
await expect(
service.installSource({ source: `./${source}`, id: "local-example" }),
).resolves.toMatchObject({
installation: { identity: { kind: "directory", path: path.join(owner, "example") } },
});
expect(requests).toEqual([`/plugins/${source}.json`]);
} finally {
await service.stopAllPlugins();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}, 30_000);
it("names a missing explicit directory instead of trying a managed source", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const directory = path.join(home, "missing");
const service = createService(home);
await expect(service.installSource({ source: directory })).rejects.toThrow(
`Plugin directory does not exist: ${directory}`,
);
});
it("installs an explicit directory and its selected plugin subdirectory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "owner-repository-"));
+6 -10
View File
@@ -21,6 +21,7 @@ import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js";
import { readPluginManifest } from "./manifest.js";
import { expandTilde } from "../../utils/path.js";
import { runPluginBuild } from "./preparation.js";
import { PluginRuntime } from "./runtime.js";
import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js";
@@ -286,16 +287,11 @@ export class PluginService {
if (input.id && this.builtinPluginIds.has(input.id)) {
throw new Error(`Plugin ID "${input.id}" is reserved for a built-in plugin`);
}
const directDirectory = path.resolve(input.source);
const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source);
const directInfo = await stat(directDirectory).catch(() => null);
const reference = directInfo?.isDirectory()
? { source: input.source, pluginPath: undefined }
: parsePluginSourceReference(input.source);
const directory = path.resolve(reference.source);
let info = directInfo;
if (!info?.isDirectory() && !explicit) info = await stat(directory).catch(() => null);
if (info?.isDirectory()) {
const reference = parsePluginSourceReference(input.source);
if (reference.kind === "directory") {
const directory = path.resolve(expandTilde(reference.source));
const info = await stat(directory).catch(() => null);
if (!info?.isDirectory()) throw new Error(`Plugin directory does not exist: ${directory}`);
if (input.ref) throw new Error("Plugin --ref is only valid for Git sources");
const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath);
return this.installDirectory({ path: pluginDirectory, id: input.id });
@@ -280,7 +280,60 @@ describe("managed Git plugin sources", () => {
});
describe("registry plugin sources", () => {
it("treats owner/repository as GitHub shorthand without contacting the registry while registry installs are off", async () => {
it("explains how to choose a revision when --ref is used with a registry id", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-ref-home-"));
roots.push(home);
const sources = new ManagedPluginSources(home);
await expect(
sources.prepareInstall({ source: "fixture/example", ref: "main" }),
).rejects.toThrow(
"Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.",
);
});
it("names the registry URL and recovery steps when the registry fetch fails", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-offline-home-"));
roots.push(home);
const server = createServer((request) => request.socket.destroy());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
const url = `http://127.0.0.1:${address.port}`;
try {
const sources = new ManagedPluginSources(home, { defaultUrl: url });
await expect(sources.prepareInstall({ source: "fixture/example" })).rejects.toThrow(
`Could not reach plugin registry ${url}/plugins/fixture/example.json. Plugin fixture/example was not installed. Retry or use an explicit npm: or github: source.`,
);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("explains explicit local and GitHub sources when a registry ID is missing", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-missing-home-"));
roots.push(home);
const requests: Array<string | undefined> = [];
const server = createServer((request, response) => {
requests.push(request.url);
response.writeHead(404).end();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
try {
const sources = new ManagedPluginSources(home, {
defaultUrl: `http://127.0.0.1:${address.port}`,
});
await expect(sources.prepareInstall({ source: "fixture/missing" })).rejects.toThrow(
"If you intended a local directory, use ./fixture/missing. If you intended a GitHub source, use git:fixture/missing, or a full Git URL for another Git host.",
);
expect(requests).toEqual(["/plugins/fixture/missing.json"]);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("keeps explicit Git sources and persisted Git installs independent of the registry", async () => {
const repository = await createRepository();
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-off-home-"));
roots.push(home);
@@ -298,12 +351,34 @@ describe("registry plugin sources", () => {
const sources = new ManagedPluginSources(home, {
defaultUrl: `http://127.0.0.1:${address.port}`,
});
const candidate = await sources.prepareInstall({ source: "fixture/repository" });
expect(candidate.record).toEqual({
kind: "git",
remote: "https://github.com/fixture/repository.git",
});
await sources.discard(candidate);
for (const source of [
"git:fixture/repository",
"https://github.com/fixture/repository.git",
]) {
const candidate = await sources.place(
"managed-example",
await sources.prepareInstall({ source }),
);
expect(candidate.record).toEqual({
kind: "git",
remote: "https://github.com/fixture/repository.git",
});
sources.commit("managed-example", candidate.record);
const restarted = new ManagedPluginSources(home, {
defaultUrl: `http://127.0.0.1:${address.port}/changed`,
});
expect((await restarted.preview("managed-example", candidate.directory)).outcome).toBe(
"current",
);
expect(
(await restarted.describe("managed-example", candidate.directory)).identity,
).toEqual({
kind: "git",
remote: "https://github.com/fixture/repository.git",
pluginPath: ".",
});
await sources.discard(candidate);
}
});
expect(requests).toEqual([]);
} finally {
@@ -332,12 +407,17 @@ describe("registry plugin sources", () => {
intent: string | string[] | undefined;
authorization: string | undefined;
}> = [];
let missing = false;
const server = createServer((req, res) => {
requests.push({
path: req.url,
intent: req.headers["x-paseo-install"],
authorization: req.headers.authorization,
});
if (missing) {
res.writeHead(404).end();
return;
}
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
@@ -369,7 +449,6 @@ describe("registry plugin sources", () => {
const url = `http://${host}/internal`;
try {
const sources = new ManagedPluginSources(home, {
enabled: true,
defaultUrl: url,
registries: { [host]: { authorization: "Bearer test" } },
});
@@ -380,7 +459,11 @@ describe("registry plugin sources", () => {
).toBe("managed-example");
await sources.verifyCandidate("managed-example", candidate);
sources.commit("managed-example", candidate.record);
const preview = await sources.preview("managed-example", candidate.directory);
const restarted = new ManagedPluginSources(home, {
defaultUrl: "http://127.0.0.1:1/changed",
registries: { [host]: { authorization: "Bearer test" } },
});
const preview = await restarted.preview("managed-example", candidate.directory);
expect(preview.outcome).toBe("current");
expect(preview.current?.identity).toEqual({
kind: "git",
@@ -388,6 +471,10 @@ describe("registry plugin sources", () => {
pluginPath: "packages/example",
registry: { url, id: "acme/example" },
});
missing = true;
await expect(restarted.preview("managed-example", candidate.directory)).rejects.toThrow(
"Check that the installed plugin is still published in this registry before updating",
);
expect(requests).toEqual([
{ path: "/internal/plugins/acme/example.json", intent: "1", authorization: "Bearer test" },
{
@@ -395,6 +482,11 @@ describe("registry plugin sources", () => {
intent: undefined,
authorization: "Bearer test",
},
{
path: "/internal/plugins/acme/example.json",
intent: undefined,
authorization: "Bearer test",
},
]);
} finally {
await new Promise<void>((resolve, reject) =>
@@ -444,7 +536,7 @@ it("installs and updates only the npm artifacts pinned by the plugin registry",
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
const options = { enabled: true, defaultUrl: `http://127.0.0.1:${address.port}` };
const options = { defaultUrl: `http://127.0.0.1:${address.port}` };
try {
const sources = new ManagedPluginSources(home, options);
const candidate = await sources.place(
@@ -147,11 +147,13 @@ export class ManagedPluginSources {
input: InstallInput,
target?: PluginUpdateTarget,
): Promise<ManagedPluginCandidate> {
const registry = this.registryOptions.enabled
? parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl)
: null;
const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl);
if (registry) {
if (input.ref || input.pluginPath)
if (input.ref)
throw new Error(
"Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.",
);
if (input.pluginPath)
throw new Error("Registry sources use their reviewed artifact path and revision");
const resolved = await resolveRegistryPlugin(registry, this.registryOptions, true);
const candidate = await this.prepareInstall(resolved.input, resolved.target);
@@ -175,7 +177,7 @@ export class ManagedPluginSources {
sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName);
record = { kind: "npm" };
} else {
const remote = normalizeGitSource(input.source, !this.registryOptions.enabled);
const remote = normalizeGitSource(input.source);
sourceRoot = path.join(versionRoot, "checkout");
await clone(remote, sourceRoot);
const commit = await resolveRequestedRef(sourceRoot, input.ref);
@@ -227,7 +229,7 @@ export class ManagedPluginSources {
const location = this.locate(pluginId, configuredPath);
let target: PluginUpdateTarget;
let outcome: PluginUpdatePreview["outcome"];
if (this.registryOptions.enabled && current.identity.registry) {
if (current.identity.registry) {
if (selection) throw new Error("Registry updates use only the reviewed registry pin");
const resolved = await resolveRegistryPlugin(
current.identity.registry,
@@ -304,7 +306,7 @@ export class ManagedPluginSources {
await this.assertCurrent(proposal, configuredPath);
const identity = this.locate(proposal.id, configuredPath).identity;
const record = this.records[proposal.id];
if (this.registryOptions.enabled && identity.kind !== "directory" && identity.registry) {
if (identity.kind !== "directory" && identity.registry) {
const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false);
assertRegistryArtifactIdentity(identity, resolved.artifact);
if (!isDeepStrictEqual(resolved.target, proposal.target))
@@ -436,10 +438,9 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s
}
return [];
}
/** Bare owner/repo is GitHub shorthand unless registry references own that syntax. */
function normalizeGitSource(source: string, bareGitHubShorthand: boolean): string {
const expandsShorthand =
bareGitHubShorthand || source.startsWith("github:") || source.startsWith("git:");
/** Only explicit Git prefixes expand GitHub shorthand. */
function normalizeGitSource(source: string): string {
const expandsShorthand = source.startsWith("github:") || source.startsWith("git:");
if (source.startsWith("github:")) {
const shorthand = source.slice(7);
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) {
@@ -6,8 +6,6 @@ import {
import type { PluginUpdateTarget } from "@getpaseo/protocol/messages";
export interface RegistryOptions {
/** Off: bare owner/repo is GitHub shorthand and no registry is contacted. */
enabled?: boolean;
defaultUrl?: string;
registries?: PluginRegistries;
}
@@ -29,11 +27,26 @@ export async function resolveRegistryPlugin(
const headers: Record<string, string> = {};
if (authorization) headers.Authorization = authorization;
if (install) headers["X-Paseo-Install"] = "1";
const response = await fetch(`${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`, {
headers,
redirect: "error",
signal: AbortSignal.timeout(30000),
});
const url = `${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`;
let response: Response;
try {
response = await fetch(url, {
headers,
redirect: "error",
signal: AbortSignal.timeout(30000),
});
} catch (cause) {
const guidance = install
? `Plugin ${identity.id} was not installed. Retry or use an explicit npm: or github: source.`
: `Could not check updates for ${identity.id}. Retry later.`;
throw new Error(`Could not reach plugin registry ${url}. ${guidance}`, { cause });
}
if (response.status === 404) {
const guidance = install
? `If you intended a local directory, use ./${identity.id}. If you intended a GitHub source, use git:${identity.id}, or a full Git URL for another Git host.`
: "Check that the installed plugin is still published in this registry before updating.";
throw new Error(`Plugin ${identity.id} was not found in registry ${base.host}. ${guidance}`);
}
if (!response.ok)
throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`);
const plugin = PublishedPluginDetailSchema.parse(await response.json());
+14 -4
View File
@@ -21,6 +21,16 @@ A plugin is a TypeScript project installed into one Paseo daemon. It can add
[connect a coding agent as a provider](/docs/plugins/providers). Client
contributions run on every Paseo client connected to that daemon, including mobile.
Browse [published plugins](https://paseo.sh/plugins), then install one on your daemon:
```bash
paseo plugin add owner/slug
```
Or paste `owner/slug` into **Settings → Plugins → Plugin source** and select **Install plugin**.
Paseo installs the registry's reviewed artifact. Use `git:owner/repository` or a full Git URL
when you want to install directly from a Git repository.
This guide scaffolds a plugin, runs it, and adds a workspace panel to it.
## Create a plugin
@@ -246,13 +256,13 @@ reload use the installed files.
See [plugin sources](/docs/plugins/reference#plugin-sources) for identifier syntax. To share your own plugin, follow
[Publish a plugin](/docs/plugins/publishing).
Plugins published in a Git repository install by shorthand or URL:
Plugins published in a Git repository install by explicit Git shorthand or URL:
```bash
paseo plugin add owner/repository
paseo plugin add git:owner/repository
paseo plugin add https://gitlab.com/group/repository.git
paseo plugin add owner/monorepo:plugins/workspace
paseo plugin add owner/repository --ref main
paseo plugin add git:owner/monorepo:plugins/workspace
paseo plugin add git:owner/repository --ref main
```
Append `:relative/path` when the plugin lives below the repository root. `--ref` selects the initial
+16 -1
View File
@@ -11,9 +11,24 @@ category: Plugins
Publish your plugin so other people can install and use it in Paseo. Start with a working
[plugin project](/docs/plugins), then choose where to share it:
- [Plugin registry](#plugin-registry): share a reviewed artifact users install by `owner/slug`.
- [npm](#publish-on-npm): publish a package on the public npm registry.
- [GitHub or Git](#share-through-github-or-git): let users install from a repository.
## Plugin registry
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
Users install the reviewed artifact with:
```bash
paseo plugin add owner/slug
```
Publish your artifact through npm or Git, then follow the
[registry submission guide](https://github.com/getpaseo/plugins) to submit it for review.
Registry installations and updates use the approved revision and plugin path.
Direct Git installs use explicit `git:owner/repository` shorthand or a full Git URL.
## Publish on npm
The scaffold prepares the package files and development dependencies. You choose the package name
@@ -182,7 +197,7 @@ to replace the registry stopgap. Do not add import credit to your own `OVERVIEW.
Push the plugin project to a repository. Users can install it with:
```bash
paseo plugin install github:acme/paseo-review
paseo plugin add git:acme/paseo-review
```
For another Git host:
+20 -15
View File
@@ -2259,15 +2259,16 @@ failures stay inside the plugin error boundary.
Paste one of these source identifiers into **Settings → Plugins**, or pass it to
`paseo plugin install`. `paseo plugin add <source>` and `paseo plugin install <source>` are aliases.
Absolute host paths are recommended because relative paths resolve against the daemon's working
directory. The app does not expand `~`; your shell may expand it before the CLI runs.
directory. The daemon expands `~` to its home directory.
| Source | Accepted form | Example |
| -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- |
| Plugin registry | `owner/slug` or `host/owner/slug` | `acme/review` |
| Host directory | Absolute or relative path on the daemon host | `/srv/paseo/plugins/review` |
| GitHub repository | `github:owner/repository` or `owner/repository` | `github:acme/paseo-review` |
| GitHub repository | `git:owner/repository` or `github:owner/repository` | `github:acme/paseo-review` |
| Git repository | `git:<URL or SCP source>`; the prefix is optional for URLs and SCP sources | `git:https://git.example.com/acme/review.git` |
| npm package | `npm:<name>[@<version, tag, or range>]`; `npm:` is optional | `npm:@acme/paseo-review@^1.2.0` |
| Plugin below a source root | Append `:relative/plugin/path` to any source | `github:acme/monorepo:plugins/review` |
| Plugin below a source root | Append `:relative/plugin/path` to a directory, Git, or npm source | `github:acme/monorepo:plugins/review` |
Git URLs use `https://`, `http://`, `ssh://`, `git://`, or `file://`. SCP sources use
`user@host:path`. `file://` selects Git acquisition, not directory installation.
@@ -2283,24 +2284,27 @@ The package registry validates the selected version, tag, or range.
Paseo resolves an identifier in this order:
1. An existing directory matching the complete identifier on the daemon host wins, including a
literal directory containing `:`.
1. A bare `owner/slug` is a registry id; local directory sources are `.` or `..`, or start with `./`, `../`, `.\`, `..\`, `/`, `~`, a Windows drive (`C:\` or `C:/`), or a UNC (`\\server\share`) prefix.
2. Otherwise, recognize `npm:`, `github:`, or `git:` before interpreting a subdirectory suffix.
`git://` is a Git URL scheme. An explicit prefix selects acquisition of that kind.
3. Recognize a final `:relative/plugin/path` only when its suffix contains no empty, `.` or `..`
segments. A lone `.` selects the source root. Both `/` and `\` separate suffix segments; use `/`
across hosts. URL ports and the separator in an SCP source stay part of the source. A suffix
that does not satisfy these rules stays part of the identifier.
4. Without an explicit prefix, an existing directory matching the remaining source wins.
5. Resolve Git URLs and SCP sources as Git; expand exact `owner/repository` shorthand to GitHub
HTTPS. `github:` requires that shorthand; `git:` accepts it as well as URLs and SCP sources.
6. Resolve a remaining npm package name with its optional selector through the host's registry.
4. Resolve bare `owner/slug` through the default plugin registry and `host/owner/slug` through
that registry host. Resolve Git URLs and SCP sources as Git. `github:` requires
`owner/repository` shorthand; `git:` accepts it as well as URLs and SCP sources.
5. Resolve a remaining npm package name with its optional selector through the host's registry.
Reject anything else.
Plugin registry installs are off by default. When the daemon enables them with
`pluginRegistryEnabled: true` or `PASEO_PLUGIN_REGISTRY_ENABLED=1`, bare `owner/slug` and
`host/owner/slug` resolve through the plugin registry instead of GitHub, the registry record owns
the revision and plugin path, and GitHub shorthand requires `github:`.
The default registry is `https://plugins.paseo.sh`. Browse [published plugins](https://paseo.sh/plugins)
and install with `paseo plugin add owner/slug`. Registry records own the revision and plugin path.
Use `git:owner/repository` for explicit GitHub shorthand or a full Git URL.
Set `PASEO_PLUGIN_REGISTRY` to use a self-hosted default URL, including a path prefix.
Private registry credentials use `pluginRegistries: { "host": { "authorization": "Bearer token" } }`
in daemon config. Restart the daemon after changing these startup settings. Credentials go only
to that registry, never to artifact hosts or redirects. Installed plugins retain their recorded
source and registry URL when defaults change.
Directory lookup happens on the daemon host. The app uses the `paseo-plugin.json` ID; the CLI
accepts `--id <runtime-id>` to override it. An existing installation ID is rejected without changing
@@ -2347,9 +2351,10 @@ deletes its managed files; removing a directory plugin keeps your source directo
paseo plugin init /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin --id another-runtime-id
paseo plugin add owner/repository
paseo plugin add owner/slug
paseo plugin add git:owner/repository
paseo plugin add https://git.example.com/owner/repository.git --ref main
paseo plugin add owner/monorepo:plugins/review
paseo plugin add git:owner/monorepo:plugins/review
paseo plugin ls [id]
paseo plugin update <id>
paseo plugin update --all --check