feat(plugins): load workspace packages in dev

This commit is contained in:
Federico Jaramillo Martinez
2026-05-21 09:41:46 +02:00
parent 3cce6d20d1
commit 698a89948b
7 changed files with 140 additions and 12 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@jmfederico/pi-web": patch
"@jmfederico/pi-web-actions": patch
---
Load and watch first-party workspace plugin packages from the single Pi Web development command without requiring local symlinks.
+3 -5
View File
@@ -139,16 +139,14 @@ A separate plugin package should:
- use a local symlink into `~/.pi-web/plugins/<plugin-id>` while developing;
- document any private Pi Web APIs it dogfoods until those APIs become stable plugin runtime helpers.
Typical local development loop:
Typical local development loop from this repository:
```bash
npm --workspace @jmfederico/pi-web-actions run dev
mkdir -p ~/.pi-web/plugins
ln -s /path/to/pi-web/plugins/actions ~/.pi-web/plugins/actions
npm run dev
curl http://127.0.0.1:8504/pi-web-plugins/manifest.json
```
The main Pi Web `dev:web` script watches bundled plugins in `pi-web-plugins/`. Separate workspace packages should run their own package-level watcher when needed.
The main Pi Web `dev` command watches bundled plugins in `pi-web-plugins/`, builds/watches separate plugin packages in `plugins/*`, and discovers those source-checkout plugin packages without symlinking them into `~/.pi-web/plugins`.
## Discovery and packaging
+2 -1
View File
@@ -27,10 +27,11 @@
"scripts": {
"dev": "bash -c 'trap \"kill 0\" EXIT; npm run dev:sessiond & npm run dev:web & npm run dev:client & wait'",
"dev:sessiond": "tsx watch src/server/sessiond.ts",
"dev:web": "bash -c 'set -e; npm run build:plugins; trap \"kill 0\" EXIT; npm run dev:plugins & tsx watch src/server/index.ts & wait'",
"dev:web": "bash -c 'set -e; npm run build:plugins; npm run build:plugin-packages; trap \"kill 0\" EXIT; npm run dev:plugins & npm run dev:plugin-packages & tsx watch src/server/index.ts & wait'",
"dev:server": "npm run dev:web",
"dev:client": "vite --host 0.0.0.0",
"dev:plugins": "node scripts/build-plugins.mjs --watch",
"dev:plugin-packages": "node scripts/dev-plugin-packages.mjs",
"build": "tsc -p tsconfig.build.json && npm run build:plugins && npm run build:plugin-packages && vite build",
"build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs",
"build:plugin-packages": "bash -c 'set -e; shopt -s nullglob; for package in plugins/*/package.json; do dir=${package%/package.json}; (cd \"$dir\" && npm run build --if-present); done'",
+2 -4
View File
@@ -47,12 +47,10 @@ After editing `.pi-web/actions.json`, click **Refresh** in the Actions tab or re
## Development in this monorepo
This package is developed as a separate npm package, not as a bundled Pi Web plugin. For local development:
This package is developed as a separate npm package, not as a bundled Pi Web plugin. From the Pi Web repository, the single root dev command builds, watches, and auto-loads this package without symlinking it into `~/.pi-web/plugins`:
```bash
npm --workspace @jmfederico/pi-web-actions run dev
mkdir -p ~/.pi-web/plugins
ln -s /srv/dev/pi-web/plugins/actions ~/.pi-web/plugins/actions
npm run dev
```
Then reload Pi Web and check discovery:
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { relative, resolve } from "node:path";
const cwd = process.cwd();
const pluginsRoot = resolve(cwd, "plugins");
const devPackages = await findPluginPackagesWithDevScripts(pluginsRoot);
const children = new Set();
let stopping = false;
process.on("SIGINT", () => { stopAndExit(130); });
process.on("SIGTERM", () => { stopAndExit(143); });
if (devPackages.length === 0) {
console.log("[plugin-packages] no plugin package dev scripts found");
await stayAlive();
}
for (const packageInfo of devPackages) startPackageDev(packageInfo);
console.log(`[plugin-packages] watching ${String(devPackages.length)} plugin package${devPackages.length === 1 ? "" : "s"}`);
await stayAlive();
async function findPluginPackagesWithDevScripts(root) {
if (!existsSync(root)) return [];
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
const packages = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dir = resolve(root, entry.name);
const packageInfo = await readPluginPackageInfo(dir);
if (packageInfo !== undefined) packages.push(packageInfo);
}
return packages.sort((left, right) => left.name.localeCompare(right.name));
}
async function readPluginPackageInfo(dir) {
const packagePath = resolve(dir, "package.json");
const content = await readFile(packagePath, "utf8").catch(() => undefined);
if (content === undefined) return undefined;
const parsed = JSON.parse(content);
if (!isRecord(parsed)) return undefined;
const scripts = parsed["scripts"];
if (!isRecord(scripts) || typeof scripts["dev"] !== "string") return undefined;
const rawName = parsed["name"];
return { dir, name: typeof rawName === "string" && rawName !== "" ? rawName : relative(cwd, dir) };
}
function startPackageDev(packageInfo) {
const child = spawn("npm", ["run", "dev"], {
cwd: packageInfo.dir,
stdio: ["ignore", "pipe", "pipe"],
});
children.add(child);
pipeWithPrefix(child.stdout, process.stdout, `[${packageInfo.name}]`);
pipeWithPrefix(child.stderr, process.stderr, `[${packageInfo.name}]`);
child.on("error", (error) => {
children.delete(child);
if (stopping) return;
console.error(`[plugin-packages] failed to start ${packageInfo.name} dev: ${error instanceof Error ? error.message : String(error)}`);
stopAndExit(1);
});
child.on("exit", (code, signal) => {
children.delete(child);
if (stopping) return;
const reason = signal === null ? `code ${String(code ?? 0)}` : `signal ${signal}`;
console.error(`[plugin-packages] ${packageInfo.name} dev exited with ${reason}`);
stopAndExit(code === null || code === 0 ? 1 : code);
});
}
function pipeWithPrefix(stream, output, prefix) {
let pending = "";
stream.setEncoding("utf8");
stream.on("data", (chunk) => {
pending += chunk;
const lines = pending.split(/\r?\n/u);
pending = lines.pop() ?? "";
for (const line of lines) output.write(`${prefix} ${line}\n`);
});
stream.on("end", () => {
if (pending !== "") output.write(`${prefix} ${pending}\n`);
});
}
function stopAndExit(code) {
if (stopping) return;
stopping = true;
for (const child of children) child.kill("SIGTERM");
setTimeout(() => { process.exit(code); }, 100);
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function stayAlive() {
await new Promise(() => undefined);
}
+17
View File
@@ -54,6 +54,23 @@ describe("PiWebPluginService", () => {
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u);
});
it("discovers source checkout plugin packages without symlinks", async () => {
await mkdir(join(tempDir, "src", "server"), { recursive: true });
await writeFile(join(tempDir, "src", "server", "index.ts"), "export {};\n");
await writePlugin(join(tempDir, "plugins", "source-dev"), {
packageJson: { piWeb: { plugins: [{ id: "source-dev", module: "dist/pi-web-plugin.js" }] } },
files: { "dist/pi-web-plugin.js": "export default { apiVersion: 1, name: 'Source Dev', activate: () => ({ contributions: {} }) };" },
});
const service = new PiWebPluginService({ cwd: tempDir, packageProvider: false });
const manifest = await service.manifest();
expect(manifest.plugins).toEqual(expect.arrayContaining([
expect.objectContaining({ id: "source-dev", source: "dev", scope: "local" }),
]));
await expect(service.readAsset("source-dev", "dist/pi-web-plugin.js")).resolves.toBeDefined();
});
it("discovers local plugins through symlinks for development", async () => {
const pluginDir = join(tempDir, "dev-plugin");
await writePlugin(pluginDir, {
+9 -2
View File
@@ -84,7 +84,7 @@ export class PiWebPluginService {
constructor(options: PiWebPluginServiceOptions = {}) {
const cwd = options.cwd ?? process.cwd();
const agentDir = options.agentDir ?? getAgentDir();
this.roots = options.roots ?? defaultPluginRoots();
this.roots = options.roots ?? defaultPluginRoots(cwd);
this.packageProvider = options.packageProvider === false ? undefined : options.packageProvider ?? new DefaultPiPackageProvider(cwd, agentDir);
}
@@ -148,11 +148,12 @@ export class PiWebPluginService {
}
}
function defaultPluginRoots(): LocalPluginRoot[] {
function defaultPluginRoots(cwd: string): LocalPluginRoot[] {
const moduleDir = dirname(fileURLToPath(import.meta.url));
const packageRoot = join(moduleDir, "..", "..");
return [
{ path: bundledPluginRoot(packageRoot), source: "bundled", scope: "bundled" },
...sourceCheckoutPluginRoots(cwd),
{ path: join(piWebDataDir(), "plugins"), source: "local", scope: "local" },
];
}
@@ -161,6 +162,12 @@ function bundledPluginRoot(packageRoot: string): string {
return join(packageRoot, "dist", "pi-web-plugins");
}
function sourceCheckoutPluginRoots(cwd: string): LocalPluginRoot[] {
const pluginsRoot = join(cwd, "plugins");
if (!existsSync(join(cwd, "src", "server", "index.ts")) || !existsSync(pluginsRoot)) return [];
return [{ path: pluginsRoot, source: "dev", scope: "local" }];
}
async function discoverLocalRoot(root: LocalPluginRoot): Promise<PluginRecord[]> {
if (!existsSync(root.path)) return [];
const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []);