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
@@ -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());