diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d3c132e0c..3a4c3ab914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/docs/plugins.md b/docs/plugins.md index 7cabeeda84..e9d5a18a9e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 ``` diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index 7e32f61350..05a2dd765e 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -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"); diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts index b707abdae8..e3eff916e7 100644 --- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts +++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts @@ -244,13 +244,16 @@ const FOOTER_GEOMETRY_TOLERANCE = 0.01; * Settings together at the end. */ export async function expectFooterIconRow(page: Page): Promise { - 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); diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 35230d4aaa..a1464907ce 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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…", diff --git a/packages/app/src/i18n/resources/plugin-settings.ts b/packages/app/src/i18n/resources/plugin-settings.ts index b3f0621df7..8146ad9747 100644 --- a/packages/app/src/i18n/resources/plugin-settings.ts +++ b/packages/app/src/i18n/resources/plugin-settings.ts @@ -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: "正在安装…", diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts index cb42c0b0a5..fd7e845ecd 100644 --- a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -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"); diff --git a/packages/cli/src/commands/plugin/index.test.ts b/packages/cli/src/commands/plugin/index.test.ts index bad3bc0436..8ea6de9dfb 100644 --- a/packages/cli/src/commands/plugin/index.test.ts +++ b/packages/cli/src/commands/plugin/index.test.ts @@ -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(); }); diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index 993c690f68..3a126752c9 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -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( "", - "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 ", "Runtime plugin ID (defaults to paseo-plugin.json id)") .option("--ref ", "Git branch, tag, or commit") diff --git a/packages/protocol/src/plugin-source-reference.test.ts b/packages/protocol/src/plugin-source-reference.test.ts index b8ce6e7346..83f41ce3df 100644 --- a/packages/protocol/src/plugin-source-reference.test.ts +++ b/packages/protocol/src/plugin-source-reference.test.ts @@ -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 }); }); }); diff --git a/packages/protocol/src/plugin-source-reference.ts b/packages/protocol/src/plugin-source-reference.ts index 229dc1df88..15171531f0 100644 --- a/packages/protocol/src/plugin-source-reference.ts +++ b/packages/protocol/src/plugin-source-reference.ts @@ -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 { const separator = reference.lastIndexOf(":"); if (separator === -1) return { source: reference, pluginPath: undefined }; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e63ce89fb3..3f49f41a19 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -414,7 +414,6 @@ export interface PaseoDaemonConfig { plugins?: Record; 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, }), diff --git a/packages/server/src/server/config-environment.ts b/packages/server/src/server/config-environment.ts index 2442ee5dc5..24134035de 100644 --- a/packages/server/src/server/config-environment.ts +++ b/packages/server/src/server/config-environment.ts @@ -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", diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index 02a315c9a5..fa49722304 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -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 }); } diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 61481ce1f9..7b2d06560e 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -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"), diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index f924a88ebc..24ded0dec2 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -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(), diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 544b609df5..464ad446f0 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -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 = []; + 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((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((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-")); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 7f74c847df..5567e23dd5 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -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 }); diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index 40878ae74e..ab9d6c50fb 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -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((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((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 = []; + const server = createServer((request, response) => { + requests.push(request.url); + response.writeHead(404).end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + 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((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((resolve, reject) => @@ -444,7 +536,7 @@ it("installs and updates only the npm artifacts pinned by the plugin registry", await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("Missing registry address"); - const options = { 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( diff --git a/packages/server/src/server/plugins/managed-source.ts b/packages/server/src/server/plugins/managed-source.ts index 8ee3b2b6f6..5373b7453a 100644 --- a/packages/server/src/server/plugins/managed-source.ts +++ b/packages/server/src/server/plugins/managed-source.ts @@ -147,11 +147,13 @@ export class ManagedPluginSources { input: InstallInput, target?: PluginUpdateTarget, ): Promise { - 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)) { diff --git a/packages/server/src/server/plugins/managed-source/registry.ts b/packages/server/src/server/plugins/managed-source/registry.ts index 179bdc5da7..b57aac6c0c 100644 --- a/packages/server/src/server/plugins/managed-source/registry.ts +++ b/packages/server/src/server/plugins/managed-source/registry.ts @@ -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 = {}; 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()); diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index 033235b7f0..50d9be9826 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -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 diff --git a/public-docs/plugins/publishing.md b/public-docs/plugins/publishing.md index a021bda644..a32185a845 100644 --- a/public-docs/plugins/publishing.md +++ b/public-docs/plugins/publishing.md @@ -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: diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index da146de78d..d4d67fae9b 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -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 ` and `paseo plugin install ` 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:`; the prefix is optional for URLs and SCP sources | `git:https://git.example.com/acme/review.git` | | npm package | `npm:[@]`; `npm:` is optional | `npm:@acme/paseo-review@^1.2.0` | -| Plugin below a source root | Append `:relative/plugin/path` to any source | `github:acme/monorepo:plugins/review` | +| Plugin below a source root | Append `:relative/plugin/path` to 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 ` 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 paseo plugin update --all --check