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;
|
- 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.
|
- 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
|
```bash
|
||||||
npm --workspace @jmfederico/pi-web-actions run dev
|
npm run dev
|
||||||
mkdir -p ~/.pi-web/plugins
|
|
||||||
ln -s /path/to/pi-web/plugins/actions ~/.pi-web/plugins/actions
|
|
||||||
curl http://127.0.0.1:8504/pi-web-plugins/manifest.json
|
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
|
## Discovery and packaging
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -27,10 +27,11 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bash -c 'trap \"kill 0\" EXIT; npm run dev:sessiond & npm run dev:web & npm run dev:client & wait'",
|
"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: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:server": "npm run dev:web",
|
||||||
"dev:client": "vite --host 0.0.0.0",
|
"dev:client": "vite --host 0.0.0.0",
|
||||||
"dev:plugins": "node scripts/build-plugins.mjs --watch",
|
"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": "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: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'",
|
"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
|
## 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
|
```bash
|
||||||
npm --workspace @jmfederico/pi-web-actions run dev
|
npm run dev
|
||||||
mkdir -p ~/.pi-web/plugins
|
|
||||||
ln -s /srv/dev/pi-web/plugins/actions ~/.pi-web/plugins/actions
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then reload Pi Web and check discovery:
|
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);
|
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 () => {
|
it("discovers local plugins through symlinks for development", async () => {
|
||||||
const pluginDir = join(tempDir, "dev-plugin");
|
const pluginDir = join(tempDir, "dev-plugin");
|
||||||
await writePlugin(pluginDir, {
|
await writePlugin(pluginDir, {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export class PiWebPluginService {
|
|||||||
constructor(options: PiWebPluginServiceOptions = {}) {
|
constructor(options: PiWebPluginServiceOptions = {}) {
|
||||||
const cwd = options.cwd ?? process.cwd();
|
const cwd = options.cwd ?? process.cwd();
|
||||||
const agentDir = options.agentDir ?? getAgentDir();
|
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);
|
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 moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||||
const packageRoot = join(moduleDir, "..", "..");
|
const packageRoot = join(moduleDir, "..", "..");
|
||||||
return [
|
return [
|
||||||
{ path: bundledPluginRoot(packageRoot), source: "bundled", scope: "bundled" },
|
{ path: bundledPluginRoot(packageRoot), source: "bundled", scope: "bundled" },
|
||||||
|
...sourceCheckoutPluginRoots(cwd),
|
||||||
{ path: join(piWebDataDir(), "plugins"), source: "local", scope: "local" },
|
{ path: join(piWebDataDir(), "plugins"), source: "local", scope: "local" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -161,6 +162,12 @@ function bundledPluginRoot(packageRoot: string): string {
|
|||||||
return join(packageRoot, "dist", "pi-web-plugins");
|
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[]> {
|
async function discoverLocalRoot(root: LocalPluginRoot): Promise<PluginRecord[]> {
|
||||||
if (!existsSync(root.path)) return [];
|
if (!existsSync(root.path)) return [];
|
||||||
const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []);
|
const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []);
|
||||||
|
|||||||
Reference in New Issue
Block a user