From 698a89948bbda46af10271749dfcd79dfd35d90f Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 21 May 2026 09:41:46 +0200 Subject: [PATCH] feat(plugins): load workspace packages in dev --- .changeset/dev-load-plugin-workspaces.md | 6 ++ docs/plugins.md | 8 +- package.json | 3 +- plugins/actions/README.md | 6 +- scripts/dev-plugin-packages.mjs | 101 +++++++++++++++++++++++ src/server/piWebPluginService.test.ts | 17 ++++ src/server/piWebPluginService.ts | 11 ++- 7 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 .changeset/dev-load-plugin-workspaces.md create mode 100644 scripts/dev-plugin-packages.mjs diff --git a/.changeset/dev-load-plugin-workspaces.md b/.changeset/dev-load-plugin-workspaces.md new file mode 100644 index 0000000..d0ee369 --- /dev/null +++ b/.changeset/dev-load-plugin-workspaces.md @@ -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. diff --git a/docs/plugins.md b/docs/plugins.md index b6b225f..7bd7523 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -139,16 +139,14 @@ A separate plugin package should: - use a local symlink into `~/.pi-web/plugins/` 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 diff --git a/package.json b/package.json index fc53c6c..6b1480b 100644 --- a/package.json +++ b/package.json @@ -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'", diff --git a/plugins/actions/README.md b/plugins/actions/README.md index fd19719..c74a30a 100644 --- a/plugins/actions/README.md +++ b/plugins/actions/README.md @@ -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: diff --git a/scripts/dev-plugin-packages.mjs b/scripts/dev-plugin-packages.mjs new file mode 100644 index 0000000..7aa3ae9 --- /dev/null +++ b/scripts/dev-plugin-packages.mjs @@ -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); +} diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index ac3fe14..42d7d9e 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -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, { diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 8c2c228..e38b065 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -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 { if (!existsSync(root.path)) return []; const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []);