mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65cc85cad2 | ||
|
|
7fd469ae19 | ||
|
|
56d5680742 | ||
|
|
06bde1e57a | ||
|
|
396a2bc0b2 |
@@ -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")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
@@ -9,6 +9,7 @@ import { scaffoldPluginDirectory } from "./scaffold.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
const execFileAsync = promisify(execFile);
|
||||
const execAsync = promisify(exec);
|
||||
const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc");
|
||||
|
||||
async function typecheckPlugin(directory: string): Promise<void> {
|
||||
@@ -28,6 +29,47 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("plugin scaffold", () => {
|
||||
it("creates an author overview beside the manifest", async () => {
|
||||
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
|
||||
directories.push(parent);
|
||||
const directory = path.join(parent, "hello-plugin");
|
||||
await scaffoldPluginDirectory(directory);
|
||||
|
||||
const overview = await readFile(path.join(directory, "OVERVIEW.md"), "utf8");
|
||||
const comments = overview.match(/<!--[\s\S]*?-->/g) ?? [];
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(overview.replace(/<!--[\s\S]*?-->/g, "").trim()).toBe("");
|
||||
expect(overview).toContain("required to list");
|
||||
expect(overview).toContain("README");
|
||||
expect(overview).toContain("choosing a plugin in Paseo");
|
||||
expect(overview).toContain("Length follows complexity");
|
||||
expect(overview).toContain("1. Describe what your plugin is and does");
|
||||
expect(overview).toContain("2. Explain how it works only when it is not obvious");
|
||||
expect(overview).toContain("3. Explain setup when needed");
|
||||
expect(overview).toContain("4. Explain capabilities and settings");
|
||||
expect(overview).toContain("permissions");
|
||||
expect(overview).toContain("known limits");
|
||||
expect(overview).not.toContain("This plugin entry was imported");
|
||||
const packageJson = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
|
||||
expect(packageJson.files).toContain("OVERVIEW.md");
|
||||
expect(await readdir(directory)).toContain("paseo-plugin.json");
|
||||
});
|
||||
|
||||
it("includes the author overview in the packed npm artifact", async () => {
|
||||
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
|
||||
directories.push(parent);
|
||||
const directory = path.join(parent, "packed-plugin");
|
||||
await scaffoldPluginDirectory(directory);
|
||||
|
||||
const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", {
|
||||
cwd: directory,
|
||||
});
|
||||
const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[];
|
||||
expect(artifact.files.map((file) => file.path)).toEqual(
|
||||
expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]),
|
||||
);
|
||||
});
|
||||
|
||||
it(
|
||||
"creates a standalone split-runtime project that typechecks",
|
||||
{ timeout: 15_000 },
|
||||
@@ -60,6 +102,7 @@ describe("plugin scaffold", () => {
|
||||
version: "0.0.0",
|
||||
files: [
|
||||
"paseo-plugin.json",
|
||||
"OVERVIEW.md",
|
||||
"index.client.ts",
|
||||
"index.client.tsx",
|
||||
"index.server.ts",
|
||||
|
||||
@@ -3,6 +3,34 @@ import path from "node:path";
|
||||
import { PluginIdSchema } from "@getpaseo/protocol/messages";
|
||||
import { resolveCliVersion } from "../../version.js";
|
||||
|
||||
const OVERVIEW = `<!--
|
||||
OVERVIEW.md is required to list your plugin in the registry. Keep it beside
|
||||
paseo-plugin.json in your repository at the pinned commit and in your npm package.
|
||||
Replace this comment with useful facts before publishing.
|
||||
|
||||
This overview is for people choosing a plugin in Paseo, on the plugin page under
|
||||
its install command. README assumes a GitHub audience and includes technical
|
||||
details, installation instructions, and badges. Long, AI-generated READMEs leave
|
||||
people reading past that material to understand what the plugin does.
|
||||
|
||||
Write these parts in order, including only what helps someone decide to install:
|
||||
|
||||
1. Describe what your plugin is and does in plain terms first.
|
||||
2. Explain how it works only when it is not obvious.
|
||||
3. Explain setup when needed: settings, accounts, tokens, providers, tools, or
|
||||
other plugins. Include applicable daemon version and operating system
|
||||
requirements. Setup guidance is allowed; installation instructions are not.
|
||||
4. Explain capabilities and settings worth understanding, what each option does,
|
||||
what the plugin reads or sends and where, permissions, and known limits.
|
||||
|
||||
Length follows complexity; a theme needs one paragraph. Choose headings only
|
||||
when they help. Use sentence case and plain factual language, with no em dashes.
|
||||
Omit installation commands, badges, changelog, contributing or license sections,
|
||||
marketing, and unsupported claims. Avoid implementation filler such as empty
|
||||
cleanup functions, theme-token field lists, or lists of absent features.
|
||||
-->
|
||||
`;
|
||||
|
||||
const TSCONFIG = {
|
||||
compilerOptions: {
|
||||
target: "ES2020",
|
||||
@@ -155,6 +183,7 @@ export async function scaffoldPluginDirectory(
|
||||
version: "0.0.0",
|
||||
files: [
|
||||
"paseo-plugin.json",
|
||||
"OVERVIEW.md",
|
||||
"index.client.ts",
|
||||
"index.client.tsx",
|
||||
"index.server.ts",
|
||||
@@ -179,6 +208,7 @@ export async function scaffoldPluginDirectory(
|
||||
"paseo-plugin.json",
|
||||
`${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`,
|
||||
],
|
||||
["OVERVIEW.md", OVERVIEW],
|
||||
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
||||
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
|
||||
["index.client.tsx", CLIENT_ENTRY],
|
||||
|
||||
@@ -144,6 +144,19 @@ const title = "Hello";
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an Astro expression open through nested template strings", () => {
|
||||
const code = "<p>{`a ${`}`} b`}</p>";
|
||||
|
||||
const tokens = highlightCode(code, "Nested.astro").flat();
|
||||
|
||||
expect(tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ text: "`}`", style: "string" },
|
||||
{ text: " b`", style: "string" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights Vue SFCs across script, template, and style", () => {
|
||||
const code = [
|
||||
'<script lang="ts">',
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { parser as cssParser } from "@lezer/css";
|
||||
import { parser as htmlParser } from "@lezer/html";
|
||||
import { parser as jsParser } from "@lezer/javascript";
|
||||
import { findClosingBrace } from "../js-scanner.js";
|
||||
|
||||
interface Range {
|
||||
from: number;
|
||||
@@ -23,88 +24,11 @@ interface Range {
|
||||
const jsxParser = jsParser.configure({ dialect: "ts jsx" });
|
||||
const typescriptParser = jsParser.configure({ dialect: "ts" });
|
||||
|
||||
function isSpace(code: number): boolean {
|
||||
return code === 32 || code === 9 || code === 10 || code === 13;
|
||||
}
|
||||
|
||||
function isRegexStart(text: string, position: number): boolean {
|
||||
let previous = position - 1;
|
||||
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
|
||||
if (previous < 0) return true;
|
||||
|
||||
const character = text[previous];
|
||||
if (/[)\]}<"'`\d]/.test(character)) return false;
|
||||
const code = text.charCodeAt(previous);
|
||||
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
|
||||
if (!/[A-Za-z_$]/.test(character)) return true;
|
||||
|
||||
let start = previous;
|
||||
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
|
||||
const keyword = text.slice(start + 1, previous + 1);
|
||||
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
|
||||
keyword,
|
||||
);
|
||||
}
|
||||
|
||||
function skipQuotedText(text: string, opening: number): number {
|
||||
const quote = text.charCodeAt(opening);
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 92) position++;
|
||||
else if (code === quote) return position;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
function skipLineComment(text: string, opening: number): number {
|
||||
const newline = text.indexOf("\n", opening + 2);
|
||||
return newline >= 0 ? newline : text.length - 1;
|
||||
}
|
||||
|
||||
function skipBlockComment(text: string, opening: number): number {
|
||||
const closing = text.indexOf("*/", opening + 2);
|
||||
return closing >= 0 ? closing + 1 : text.length - 1;
|
||||
}
|
||||
|
||||
function skipRegex(text: string, opening: number): number {
|
||||
let isInCharacterClass = false;
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 10 || code === 13) return position;
|
||||
if (code === 92) position++;
|
||||
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
|
||||
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
|
||||
else if (!isInCharacterClass && code === 47) return position;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
function findClosingBrace(text: string, opening: number): number {
|
||||
let depth = 0;
|
||||
for (let position = opening; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 47 && text.charCodeAt(position + 1) === 47) {
|
||||
position = skipLineComment(text, position);
|
||||
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
|
||||
position = skipBlockComment(text, position);
|
||||
} else if (code === 47 && isRegexStart(text, position)) {
|
||||
position = skipRegex(text, position);
|
||||
} else if (code === 34 || code === 39 || code === 96) {
|
||||
position = skipQuotedText(text, position);
|
||||
} else if (code === 123) {
|
||||
depth++;
|
||||
} else if (code === 125 && --depth === 0) {
|
||||
return position;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findExpressions(text: string): Range[] {
|
||||
const ranges: Range[] = [];
|
||||
for (let position = 0; position < text.length; position++) {
|
||||
if (text.charCodeAt(position) !== 123) continue;
|
||||
const closing = findClosingBrace(text, position);
|
||||
const closing = findClosingBrace(text, position + 1);
|
||||
if (closing < 0) break;
|
||||
ranges.push({ from: position, to: closing });
|
||||
position = closing;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Scans JavaScript embedded in a template (Astro `{ }` expressions, Vue `{{ }}`
|
||||
// interpolations) for the brace that closes it, without parsing the code.
|
||||
|
||||
function isSpace(code: number): boolean {
|
||||
return code === 32 || code === 9 || code === 10 || code === 13;
|
||||
}
|
||||
|
||||
function isRegexStart(text: string, position: number): boolean {
|
||||
let previous = position - 1;
|
||||
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
|
||||
if (previous < 0) return true;
|
||||
|
||||
const character = text[previous];
|
||||
if (/[)\]}<"'`\d]/.test(character)) return false;
|
||||
const code = text.charCodeAt(previous);
|
||||
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
|
||||
if (!/[A-Za-z_$]/.test(character)) return true;
|
||||
|
||||
let start = previous;
|
||||
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
|
||||
const keyword = text.slice(start + 1, previous + 1);
|
||||
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
|
||||
keyword,
|
||||
);
|
||||
}
|
||||
|
||||
// Template strings nested deeper than this are treated as running to the end.
|
||||
const MAX_TEMPLATE_NESTING = 32;
|
||||
|
||||
function skipQuotedText(text: string, opening: number, nesting = 0): number {
|
||||
const quote = text.charCodeAt(opening);
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 92) position++;
|
||||
else if (code === quote) return position;
|
||||
else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) {
|
||||
position = skipTemplateSubstitution(text, position + 2, nesting);
|
||||
}
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
function skipTemplateSubstitution(text: string, start: number, nesting: number): number {
|
||||
if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1;
|
||||
const closing = findClosingBrace(text, start, () => true, nesting + 1);
|
||||
return closing >= 0 ? closing : text.length - 1;
|
||||
}
|
||||
|
||||
function skipLineComment(text: string, opening: number): number {
|
||||
const newline = text.indexOf("\n", opening + 2);
|
||||
return newline >= 0 ? newline : text.length - 1;
|
||||
}
|
||||
|
||||
function skipBlockComment(text: string, opening: number): number {
|
||||
const closing = text.indexOf("*/", opening + 2);
|
||||
return closing >= 0 ? closing + 1 : text.length - 1;
|
||||
}
|
||||
|
||||
function skipRegex(text: string, opening: number): number {
|
||||
let isInCharacterClass = false;
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 10 || code === 13) return position;
|
||||
if (code === 92) position++;
|
||||
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
|
||||
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
|
||||
else if (!isInCharacterClass && code === 47) return position;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
// Finds the first `}` outside nested braces, strings, comments, and regexes
|
||||
// for which `isEnd` holds.
|
||||
export function findClosingBrace(
|
||||
text: string,
|
||||
start: number,
|
||||
isEnd: (position: number) => boolean = () => true,
|
||||
nesting = 0,
|
||||
): number {
|
||||
let depth = 0;
|
||||
for (let position = start; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 47 && text.charCodeAt(position + 1) === 47) {
|
||||
position = skipLineComment(text, position);
|
||||
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
|
||||
position = skipBlockComment(text, position);
|
||||
} else if (code === 47 && isRegexStart(text, position)) {
|
||||
position = skipRegex(text, position);
|
||||
} else if (code === 34 || code === 39 || code === 96) {
|
||||
position = skipQuotedText(text, position, nesting);
|
||||
} else if (code === 123) {
|
||||
depth++;
|
||||
} else if (code === 125 && depth === 0) {
|
||||
if (isEnd(position)) return position;
|
||||
} else if (code === 125) {
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { parser as cssParser } from "@lezer/css";
|
||||
import { parser as htmlParser } from "@lezer/html";
|
||||
import { parser as jsParser } from "@lezer/javascript";
|
||||
import { findClosingBrace } from "../js-scanner.js";
|
||||
|
||||
interface Range {
|
||||
from: number;
|
||||
@@ -17,104 +18,6 @@ interface Range {
|
||||
const typescriptParser = jsParser.configure({ dialect: "ts" });
|
||||
const expressionParser = jsParser.configure({ top: "SingleExpression" });
|
||||
|
||||
function isSpace(code: number): boolean {
|
||||
return code === 32 || code === 9 || code === 10 || code === 13;
|
||||
}
|
||||
|
||||
function isRegexStart(text: string, position: number): boolean {
|
||||
let previous = position - 1;
|
||||
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
|
||||
if (previous < 0) return true;
|
||||
|
||||
const character = text[previous];
|
||||
if (/[)\]}<"'`\d]/.test(character)) return false;
|
||||
const code = text.charCodeAt(previous);
|
||||
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
|
||||
if (!/[A-Za-z_$]/.test(character)) return true;
|
||||
|
||||
let start = previous;
|
||||
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
|
||||
const keyword = text.slice(start + 1, previous + 1);
|
||||
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
|
||||
keyword,
|
||||
);
|
||||
}
|
||||
|
||||
// Template strings nested deeper than this are treated as running to the end.
|
||||
const MAX_TEMPLATE_NESTING = 32;
|
||||
|
||||
function skipQuotedText(text: string, opening: number, nesting = 0): number {
|
||||
const quote = text.charCodeAt(opening);
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 92) position++;
|
||||
else if (code === quote) return position;
|
||||
else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) {
|
||||
position = skipTemplateSubstitution(text, position + 2, nesting);
|
||||
}
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
function skipTemplateSubstitution(text: string, start: number, nesting: number): number {
|
||||
if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1;
|
||||
const closing = findClosingBrace(text, start, () => true, nesting + 1);
|
||||
return closing >= 0 ? closing : text.length - 1;
|
||||
}
|
||||
|
||||
function skipLineComment(text: string, opening: number): number {
|
||||
const newline = text.indexOf("\n", opening + 2);
|
||||
return newline >= 0 ? newline : text.length - 1;
|
||||
}
|
||||
|
||||
function skipBlockComment(text: string, opening: number): number {
|
||||
const closing = text.indexOf("*/", opening + 2);
|
||||
return closing >= 0 ? closing + 1 : text.length - 1;
|
||||
}
|
||||
|
||||
function skipRegex(text: string, opening: number): number {
|
||||
let isInCharacterClass = false;
|
||||
for (let position = opening + 1; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 10 || code === 13) return position;
|
||||
if (code === 92) position++;
|
||||
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
|
||||
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
|
||||
else if (!isInCharacterClass && code === 47) return position;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
// Finds the first `}` outside nested braces, strings, comments, and regexes
|
||||
// for which `isEnd` holds.
|
||||
function findClosingBrace(
|
||||
text: string,
|
||||
start: number,
|
||||
isEnd: (position: number) => boolean,
|
||||
nesting = 0,
|
||||
): number {
|
||||
let depth = 0;
|
||||
for (let position = start; position < text.length; position++) {
|
||||
const code = text.charCodeAt(position);
|
||||
if (code === 47 && text.charCodeAt(position + 1) === 47) {
|
||||
position = skipLineComment(text, position);
|
||||
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
|
||||
position = skipBlockComment(text, position);
|
||||
} else if (code === 47 && isRegexStart(text, position)) {
|
||||
position = skipRegex(text, position);
|
||||
} else if (code === 34 || code === 39 || code === 96) {
|
||||
position = skipQuotedText(text, position, nesting);
|
||||
} else if (code === 123) {
|
||||
depth++;
|
||||
} else if (code === 125 && depth === 0) {
|
||||
if (isEnd(position)) return position;
|
||||
} else if (code === 125) {
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findInterpolationEnd(text: string, start: number): number {
|
||||
return findClosingBrace(text, start, (position) => text.charCodeAt(position + 1) === 125);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
// Reproduces #6199 on the logged-in Claude Code path. Claude Opus 5.5 writes the
|
||||
// text it means for the user between tool calls as a "progress update": a
|
||||
// thinking block that Claude Code marks with `narration_block_indexes`.
|
||||
const PROMPT =
|
||||
"There are three plan files in this directory: plan-a.txt, plan-b.txt and plan-c.txt. Read each one, tell me how they compare on cost, time and risk, and then ask me which plan to pick using your AskUserQuestion tool. Keep me posted as you go.";
|
||||
|
||||
const handles: Array<{ cwd: string; session: AgentSession }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const handle of handles.splice(0)) {
|
||||
await handle.session.close().catch(() => undefined);
|
||||
rmSync(handle.cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function writePlans(cwd: string): void {
|
||||
writeFileSync(
|
||||
path.join(cwd, "plan-a.txt"),
|
||||
"Plan A\nCost: 3 credits\nTime: 2 days\nRisk: needs a manual review step\n",
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(cwd, "plan-b.txt"),
|
||||
"Plan B\nCost: 5 credits\nTime: 1 day\nRisk: none known\n",
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(cwd, "plan-c.txt"),
|
||||
"Plan C\nCost: 2 credits\nTime: 4 days\nRisk: depends on an external vendor\n",
|
||||
);
|
||||
}
|
||||
|
||||
test("the comparison written before AskUserQuestion reaches the timeline as assistant text", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "claude-progress-updates-"));
|
||||
writePlans(cwd);
|
||||
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
model: "claude-opus-5-5",
|
||||
thinkingOptionId: "xhigh",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
handles.push({ cwd, session });
|
||||
|
||||
const items: AgentTimelineItem[] = [];
|
||||
await new Promise<void>((resolve) => {
|
||||
session.subscribe((event: AgentStreamEvent) => {
|
||||
if (event.type === "timeline") items.push(event.item);
|
||||
if (
|
||||
event.type === "permission_requested" ||
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed"
|
||||
) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
void session.startTurn(PROMPT);
|
||||
});
|
||||
|
||||
const lastReadIndex = items.findLastIndex(
|
||||
(item) => item.type === "tool_call" && item.name !== "AskUserQuestion",
|
||||
);
|
||||
const askIndex = items.findIndex(
|
||||
(item) => item.type === "tool_call" && item.name === "AskUserQuestion",
|
||||
);
|
||||
expect(askIndex).toBeGreaterThan(lastReadIndex);
|
||||
const between = items.slice(lastReadIndex + 1, askIndex);
|
||||
const assistantText = between
|
||||
.flatMap((item) => (item.type === "assistant_message" ? [item.text] : []))
|
||||
.join("");
|
||||
expect(assistantText).toMatch(/plan/i);
|
||||
}, 280_000);
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Paseo website
|
||||
|
||||
## Plugin install counts
|
||||
|
||||
Clients report an install by resolving a plugin with `X-Paseo-Install: 1` or
|
||||
`?intent=install`, using `/api/plugins/resolve/<owner>/<slug>` or
|
||||
`/plugins/<owner>/<slug>.json`. Ordinary resolves do not count. These numbers
|
||||
measure install intent, not successful artifact installation or npm downloads.
|
||||
|
||||
The worker requires Cloudflare's `CF-Connecting-IP` header to count a report.
|
||||
It stores the plugin ID plus a SHA-256 hash of the IP in a `WEBSITE_CACHE` KV key
|
||||
with a one hour TTL, never the raw IP. An existing key skips the count; repeats
|
||||
do not extend the hour. Missing or empty IP headers do not count.
|
||||
|
||||
Accepted reports increment the existing all-time total and UTC daily buckets in
|
||||
`WEBSITE_CACHE`. Daily buckets retain 60 days and supply the website's seven-day
|
||||
and thirty-day rankings. `/api/plugins/installs` still returns plugin IDs mapped
|
||||
to all-time totals. The [registry build](https://github.com/getpaseo/plugins/blob/main/scripts/build.ts)
|
||||
reads that endpoint into the published plugin details and index; the website
|
||||
reads windowed counts from KV through its five-minute cache. Existing counts
|
||||
remain intact. The KV guard is advisory: concurrent requests and KV propagation
|
||||
are not serialized.
|
||||
@@ -12,6 +12,26 @@ const WINDOW_DAYS = { week: 7, month: 30 } as const;
|
||||
const totalKey = (id: string) => `plugin-installs:${id}`;
|
||||
const dailyKey = (id: string) => `plugin-installs-daily:${id}`;
|
||||
|
||||
export async function recordClientInstall({
|
||||
cache,
|
||||
id,
|
||||
ip,
|
||||
}: {
|
||||
cache: KVNamespace;
|
||||
id: string;
|
||||
ip: string | null;
|
||||
}): Promise<void> {
|
||||
if (!ip) return;
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ip));
|
||||
const hash = Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const key = `plugin-installs-client:${id}:${hash}`;
|
||||
if ((await cache.get(key)) !== null) return;
|
||||
await cache.put(key, "1", { expirationTtl: 3600 });
|
||||
await recordInstall(cache, id, new Date());
|
||||
}
|
||||
|
||||
export async function recordInstall(cache: KVNamespace, id: string, now: Date): Promise<void> {
|
||||
const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]);
|
||||
await Promise.all([
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "@getpaseo/protocol/plugin-registry";
|
||||
import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache";
|
||||
import { CATEGORIES } from "./categories";
|
||||
import { type InstallCounts, readInstallCounts, recordInstall } from "./installs";
|
||||
import { type InstallCounts, readInstallCounts, recordClientInstall } from "./installs";
|
||||
export interface RegistryEnvironment {
|
||||
PLUGINS_REGISTRY_URL?: string;
|
||||
WEBSITE_CACHE?: KVNamespace;
|
||||
@@ -111,7 +111,12 @@ export async function handlePluginRegistryRequest(
|
||||
url.searchParams.get("intent") === "install"
|
||||
) {
|
||||
const cache = context.cache;
|
||||
if (cache) context.waitUntil(recordInstall(cache, id, new Date()).catch(() => undefined));
|
||||
if (cache)
|
||||
context.waitUntil(
|
||||
recordClientInstall({ cache, id, ip: request.headers.get("CF-Connecting-IP") }).catch(
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Response.json(plugin);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
searchPlugins,
|
||||
} from "./registry";
|
||||
import { readInstallCounts, recordInstall } from "./installs";
|
||||
import { loadRegistryIndex, loadRegistryPlugin } from "./published";
|
||||
import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published";
|
||||
const plugin: Plugin = {
|
||||
id: "acme/example",
|
||||
name: "Example",
|
||||
@@ -94,6 +95,81 @@ describe("plugin registry", () => {
|
||||
Object.keys(JSON.parse((await cache.get("plugin-installs-daily:acme/example")) ?? "{}")),
|
||||
).toEqual(["2026-09-10", "2026-09-30", "2026-10-04"]);
|
||||
});
|
||||
it("counts repeated install reports from the same IP once per hour", async () => {
|
||||
let now = Date.now();
|
||||
const cache = memoryKv(() => now);
|
||||
const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
|
||||
await resolvePlugin(cache, headers);
|
||||
now += 3_599_000;
|
||||
await resolvePlugin(cache, headers);
|
||||
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
|
||||
[plugin.id]: { week: 1, month: 1, all: 1 },
|
||||
});
|
||||
now += 1_000;
|
||||
await resolvePlugin(cache, headers);
|
||||
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
|
||||
[plugin.id]: { week: 2, month: 2, all: 2 },
|
||||
});
|
||||
});
|
||||
it("counts different IPs and plugins independently, including query install intent", async () => {
|
||||
const cache = memoryKv();
|
||||
const first = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
|
||||
await resolvePlugin(cache, first);
|
||||
await resolvePlugin(cache, { "CF-Connecting-IP": "2001:db8::1" }, { query: "?intent=install" });
|
||||
await resolvePlugin(cache, first, { id: "acme/other" });
|
||||
await resolvePlugin(cache, first, { query: "?intent=install" });
|
||||
await resolvePlugin(cache, first, { id: "acme/other", staticDetail: true });
|
||||
expect(await readInstallCounts(cache, [plugin.id, "acme/other"], new Date())).toEqual({
|
||||
[plugin.id]: { week: 2, month: 2, all: 2 },
|
||||
"acme/other": { week: 1, month: 1, all: 1 },
|
||||
});
|
||||
const hash = createHash("sha256").update(first["CF-Connecting-IP"]).digest("hex");
|
||||
expect(await cache.get(`plugin-installs-client:${plugin.id}:${hash}`)).toBe("1");
|
||||
expect(JSON.stringify([...cache.values])).not.toContain("192.0.2.1");
|
||||
expect(JSON.stringify([...cache.values])).not.toContain("2001:db8::1");
|
||||
});
|
||||
it("does not count without a Cloudflare IP or install intent", async () => {
|
||||
const cache = memoryKv();
|
||||
await resolvePlugin(cache, { "X-Paseo-Install": "1", "X-Forwarded-For": "192.0.2.1" });
|
||||
await resolvePlugin(cache, {}, { query: "?intent=install" });
|
||||
await resolvePlugin(cache, { "X-Paseo-Install": "1", "CF-Connecting-IP": "" });
|
||||
await resolvePlugin(cache, { "CF-Connecting-IP": "192.0.2.1" });
|
||||
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
|
||||
[plugin.id]: { week: 0, month: 0, all: 0 },
|
||||
});
|
||||
expect([...cache.values.keys()].filter((key) => key.startsWith("plugin-installs"))).toEqual([]);
|
||||
});
|
||||
it("preserves historical totals and the installs endpoint response", async () => {
|
||||
const cache = memoryKv();
|
||||
await cache.put(`plugin-installs:${plugin.id}`, "40");
|
||||
const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
|
||||
await resolvePlugin(cache, headers);
|
||||
await resolvePlugin(cache, headers);
|
||||
const base = "https://registry.example.test";
|
||||
await cache.put(
|
||||
`plugins:index:v1:${base}`,
|
||||
JSON.stringify({
|
||||
fetchedAt: Date.now(),
|
||||
value: {
|
||||
schemaVersion: 1,
|
||||
registry: { name: "Internal", url: base },
|
||||
categories: [],
|
||||
plugins: [plugin],
|
||||
generatedAt: "2026-10-03",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const response = await handlePluginRegistryRequest(
|
||||
new Request("https://paseo.sh/api/plugins/installs"),
|
||||
{ PLUGINS_REGISTRY_URL: base },
|
||||
{ cache, waitUntil: () => undefined },
|
||||
);
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({ [plugin.id]: 41 });
|
||||
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
|
||||
[plugin.id]: { week: 1, month: 1, all: 41 },
|
||||
});
|
||||
});
|
||||
it("searches name, description, ID, and author, ignoring case", () => {
|
||||
const other = {
|
||||
...plugin,
|
||||
@@ -125,16 +201,43 @@ describe("plugin registry", () => {
|
||||
});
|
||||
|
||||
/** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */
|
||||
function memoryKv(): KVNamespace {
|
||||
function memoryKv(now = Date.now): KVNamespace & { values: Map<string, string> } {
|
||||
const values = new Map<string, string>();
|
||||
const expiresAt = new Map<string, number>();
|
||||
const kv = {
|
||||
values,
|
||||
get: async (key: string, options?: { type?: string }) => {
|
||||
if (now() >= (expiresAt.get(key) ?? Infinity)) values.delete(key);
|
||||
const value = values.get(key) ?? null;
|
||||
return options?.type === "json" && value !== null ? JSON.parse(value) : value;
|
||||
},
|
||||
put: async (key: string, value: string) => {
|
||||
put: async (key: string, value: string, options?: { expirationTtl?: number }) => {
|
||||
values.set(key, value);
|
||||
expiresAt.set(key, options?.expirationTtl ? now() + options.expirationTtl * 1000 : Infinity);
|
||||
},
|
||||
};
|
||||
return kv as unknown as KVNamespace;
|
||||
return kv as unknown as KVNamespace & { values: Map<string, string> };
|
||||
}
|
||||
|
||||
async function resolvePlugin(
|
||||
cache: KVNamespace,
|
||||
headers: HeadersInit,
|
||||
{ id = plugin.id, query = "", staticDetail = false } = {},
|
||||
): Promise<void> {
|
||||
const base = "https://registry.example.test";
|
||||
const detail = { ...plugin, id, readme: "# Example" };
|
||||
await cache.put(
|
||||
`plugins:detail:v1:${base}:${id}`,
|
||||
JSON.stringify({ fetchedAt: Date.now(), value: detail }),
|
||||
);
|
||||
const pending: Promise<unknown>[] = [];
|
||||
const path = staticDetail ? `/plugins/${id}.json` : `/api/plugins/resolve/${id}`;
|
||||
const response = await handlePluginRegistryRequest(
|
||||
new Request(`https://paseo.sh${path}${query}`, { headers }),
|
||||
{ PLUGINS_REGISTRY_URL: base },
|
||||
{ cache, waitUntil: (promise) => pending.push(promise) },
|
||||
);
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual(detail);
|
||||
await Promise.all(pending);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -56,6 +71,7 @@ The scaffold includes this `files` list in `package.json`:
|
||||
{
|
||||
"files": [
|
||||
"paseo-plugin.json",
|
||||
"OVERVIEW.md",
|
||||
"index.client.ts",
|
||||
"index.client.tsx",
|
||||
"index.server.ts",
|
||||
@@ -136,12 +152,52 @@ In the app, enter only the source identifier.
|
||||
|
||||
:::
|
||||
|
||||
## Your listing page
|
||||
|
||||
Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo.
|
||||
It helps someone decide whether to install your plugin; the install command is already at
|
||||
the top of that page. A README assumes a GitHub audience and carries installation
|
||||
instructions, technical details, and badges. Long, AI-generated READMEs make people read
|
||||
past that material to understand what a plugin does.
|
||||
|
||||
`OVERVIEW.md` is required to list your plugin in the registry. Commit it beside
|
||||
`paseo-plugin.json` in the source repository at the pinned commit. The registry resolves it
|
||||
relative to the manifest, under `pluginPath` for monorepos. Include it in the published npm
|
||||
package too; the scaffold's `files` list includes it. Replace the scaffold's guidance comment
|
||||
with useful facts before publishing.
|
||||
|
||||
The repository overview takes precedence over a registry import stopgap. Online validation
|
||||
fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records
|
||||
that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump
|
||||
requires a repository overview and removes the stopgap in the same PR. A bump without the
|
||||
repository overview fails validation. Neither README files nor a `paseo-listing.json`
|
||||
`readme` override supplies the overview.
|
||||
|
||||
Author overviews and registry stopgaps follow the same content contract, in this order.
|
||||
Choose headings only when they help; length follows complexity. A theme needs one paragraph.
|
||||
|
||||
1. Explain what the plugin is and does in plain terms first.
|
||||
2. Explain how it works only when that is not obvious.
|
||||
3. Explain setup when needed: settings, accounts, tokens, providers, external tools, or other
|
||||
plugins. Include applicable daemon version and operating system requirements. Setup
|
||||
guidance is allowed; installation instructions are not.
|
||||
4. Explain capabilities and settings worth understanding, what each option does, what the
|
||||
plugin reads or sends and where, permissions, and known limits.
|
||||
|
||||
Use sentence case and plain factual language, with no em dashes. Omit installation commands,
|
||||
badges, changelog, contributing or license sections, marketing, and unsupported claims.
|
||||
Avoid implementation filler such as empty cleanup functions, catalogs of theme-token fields,
|
||||
or lists of absent features. Keep only what helps someone choose the plugin.
|
||||
|
||||
If your plugin is an unchanged imported record, you can propose an author-written overview
|
||||
to replace the registry stopgap. Do not add import credit to your own `OVERVIEW.md`.
|
||||
|
||||
## Share through GitHub or Git
|
||||
|
||||
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
|
||||
|
||||
@@ -64,6 +64,7 @@ The generated project contains:
|
||||
```text
|
||||
my-plugin/
|
||||
paseo-plugin.json
|
||||
OVERVIEW.md
|
||||
package.json
|
||||
tsconfig.json
|
||||
index.client.tsx
|
||||
@@ -638,6 +639,48 @@ Do not edit a local config when the target is a remote daemon. Perform the edit
|
||||
|
||||
When the same screen or sidebar item exists on several connected hosts, Paseo shows it once. The screen header has a host picker; a sidebar item uses the host of the screen on display, else the host last picked in one of the plugin's screens. The selected host owns the bundle, SDK calls, RPCs, and query cache. An offline selected host does not fall through to another host. Attachment sources stay scoped to the composer's host.
|
||||
|
||||
## Publishing a listing
|
||||
|
||||
Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo.
|
||||
It helps someone decide whether to install your plugin; the install command is already at
|
||||
the top of that page. A README assumes a GitHub audience and carries installation
|
||||
instructions, technical details, and badges. Long, AI-generated READMEs make people read
|
||||
past that material to understand what a plugin does.
|
||||
|
||||
`OVERVIEW.md` is required to list your plugin in the registry. Commit it beside
|
||||
`paseo-plugin.json` in the source repository at the pinned commit. The registry resolves it
|
||||
relative to the manifest, under `pluginPath` for monorepos. Include it in the published npm
|
||||
package too; the scaffold's `files` list includes it. Replace the scaffold's guidance comment
|
||||
with useful facts before publishing.
|
||||
|
||||
The repository overview takes precedence over a registry import stopgap. Online validation
|
||||
fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records
|
||||
that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump
|
||||
requires a repository overview and removes the stopgap in the same PR. A bump without the
|
||||
repository overview fails validation. Neither README files nor a `paseo-listing.json`
|
||||
`readme` override supplies the overview.
|
||||
|
||||
Author overviews and registry stopgaps follow the same content contract, in this order.
|
||||
Choose headings only when they help; length follows complexity. A theme needs one paragraph.
|
||||
|
||||
1. Explain what the plugin is and does in plain terms first.
|
||||
2. Explain how it works only when that is not obvious.
|
||||
3. Explain setup when needed: settings, accounts, tokens, providers, external tools, or other
|
||||
plugins. Include applicable daemon version and operating system requirements. Setup
|
||||
guidance is allowed; installation instructions are not.
|
||||
4. Explain capabilities and settings worth understanding, what each option does, what the
|
||||
plugin reads or sends and where, permissions, and known limits.
|
||||
|
||||
Use sentence case and plain factual language, with no em dashes. Omit installation commands,
|
||||
badges, changelog, contributing or license sections, marketing, and unsupported claims.
|
||||
Avoid implementation filler such as empty cleanup functions, catalogs of theme-token fields,
|
||||
or lists of absent features. Keep only what helps someone choose the plugin.
|
||||
|
||||
If your plugin is an unchanged imported record, you can propose an author-written overview
|
||||
to replace the registry stopgap. Do not add import credit to your own `OVERVIEW.md`.
|
||||
|
||||
See [Your listing page](https://paseo.sh/docs/plugins/publishing.md#your-listing-page).
|
||||
|
||||
## Typecheck and manage
|
||||
|
||||
When editing a plugin, typecheck its source before install or reload:
|
||||
|
||||
Reference in New Issue
Block a user