Archived
feat(plugins): load workspace packages in dev
This commit is contained in:
@@ -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
@@ -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
@@ -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'",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(() => []);
|
||||
|
||||
Reference in New Issue
Block a user