paperclip/scripts/link-plugin-dev-sdk.mjs

99 lines
3.8 KiB
JavaScript

#!/usr/bin/env node
// Dev-only: link the in-repo @paperclipai/plugin-sdk into plugin packages that
// are excluded from the pnpm workspace (see pnpm-workspace.yaml). Workspace
// members get their SDK link from pnpm automatically; excluded packages do not.
//
// Invoked from the root postinstall. Intentionally NOT referenced from any
// plugin's own package.json so the published tarballs cannot carry a lifecycle
// script that escapes their package directory at install time.
import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
const sdkDir = join(repoRoot, "packages", "plugins", "sdk");
// Plugin packages excluded from the workspace that still need @paperclipai/plugin-sdk
// linked in for local dev. Keep in sync with pnpm-workspace.yaml exclusions.
function excludedPluginDirs() {
return [
...readPluginsUnder(join(repoRoot, "packages", "plugins", "sandbox-providers")),
join(repoRoot, "packages", "plugins", "examples", "plugin-orchestration-smoke-example"),
];
}
export function linkExcludedPlugins() {
let linked = 0;
let skipped = 0;
for (const packageDir of excludedPluginDirs()) {
if (!existsSync(join(packageDir, "package.json"))) continue;
if (linkSdkInto(packageDir)) {
linked += 1;
} else {
skipped += 1;
}
}
return { linked, skipped };
}
// Run as a CLI (root postinstall) when invoked directly, but stay importable so
// repo-internal scripts (e.g. the standalone package builder) can relink a
// single package after a fresh install.
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const { linked, skipped } = linkExcludedPlugins();
console.log(` ✓ Linked @paperclipai/plugin-sdk into ${linked} excluded plugin(s) (skipped ${skipped})`);
}
// Recursively collect package directories (those containing a package.json)
// under parentDir. The matching pnpm-workspace.yaml exclusion uses a recursive
// glob (e.g. "!packages/plugins/sandbox-providers/**"), so a provider nested
// deeper than one level must still be discovered here.
export function readPluginsUnder(parentDir) {
if (!existsSync(parentDir)) return [];
const found = [];
for (const entry of readdirSync(parentDir, { withFileTypes: true })) {
// Skip symlinked directories so discovery can't be steered outside the
// intended plugin subtree, and skip node_modules.
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name === "node_modules") continue;
const childDir = join(parentDir, entry.name);
if (existsSync(join(childDir, "package.json"))) {
found.push(childDir);
} else {
found.push(...readPluginsUnder(childDir));
}
}
return found;
}
export function linkSdkInto(packageDir) {
const scopeDir = join(packageDir, "node_modules", "@paperclipai");
const linkTarget = join(scopeDir, "plugin-sdk");
const relativeSdkDir = relative(scopeDir, sdkDir);
mkdirSync(scopeDir, { recursive: true });
try {
const stat = lstatSync(linkTarget);
if (stat.isSymbolicLink()) {
if (readlinkSync(linkTarget) === relativeSdkDir) {
// Already linked to the in-repo SDK; nothing to do.
return false;
}
rmSync(linkTarget, { force: true });
} else {
// A real install has already populated @paperclipai/plugin-sdk (e.g. the
// plugin host did `npm install` of the published tarball). Leave it.
return false;
}
} catch (error) {
// A missing target is expected (nothing linked yet); surface anything else.
if (error?.code !== "ENOENT") throw error;
}
symlinkSync(relativeSdkDir, linkTarget, "dir");
return true;
}