mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user