diff --git a/package.json b/package.json index 28e27bc9..b212f6c5 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "postinstall": "node scripts/link-plugin-dev-sdk.mjs", "preflight:workspace-links": "node cli/node_modules/tsx/dist/cli.mjs scripts/ensure-workspace-package-links.ts", "dev": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch", "dev:watch": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch", @@ -42,7 +43,7 @@ "smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh", "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", "smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs", - "test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs", + "test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/link-plugin-dev-sdk.test.js", "test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts", "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed", "test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts", diff --git a/packages/plugins/plugin-workspace-diff/package.json b/packages/plugins/plugin-workspace-diff/package.json index eec44700..e4f5c69b 100644 --- a/packages/plugins/plugin-workspace-diff/package.json +++ b/packages/plugins/plugin-workspace-diff/package.json @@ -42,7 +42,6 @@ "diff" ], "scripts": { - "postinstall": "node ../../../scripts/link-plugin-dev-sdk.mjs", "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "tsc && node ./scripts/build-ui.mjs", "clean": "rm -rf dist", diff --git a/packages/plugins/sandbox-providers/cloudflare/package.json b/packages/plugins/sandbox-providers/cloudflare/package.json index ecb37288..d959d6ff 100644 --- a/packages/plugins/sandbox-providers/cloudflare/package.json +++ b/packages/plugins/sandbox-providers/cloudflare/package.json @@ -42,7 +42,6 @@ "cloudflare" ], "scripts": { - "postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs", "prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "rm -rf dist && tsc", "clean": "rm -rf dist", diff --git a/packages/plugins/sandbox-providers/daytona/package.json b/packages/plugins/sandbox-providers/daytona/package.json index cae60c2c..bea6e657 100644 --- a/packages/plugins/sandbox-providers/daytona/package.json +++ b/packages/plugins/sandbox-providers/daytona/package.json @@ -41,7 +41,6 @@ "daytona" ], "scripts": { - "postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs", "prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "rm -rf dist && tsc", "clean": "rm -rf dist", diff --git a/packages/plugins/sandbox-providers/e2b/package.json b/packages/plugins/sandbox-providers/e2b/package.json index 864c0a0b..0e180ae7 100644 --- a/packages/plugins/sandbox-providers/e2b/package.json +++ b/packages/plugins/sandbox-providers/e2b/package.json @@ -41,7 +41,6 @@ "e2b" ], "scripts": { - "postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs", "prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "rm -rf dist && tsc", "clean": "rm -rf dist", diff --git a/packages/plugins/sandbox-providers/exe-dev/package.json b/packages/plugins/sandbox-providers/exe-dev/package.json index 94d7f5ff..c54ef859 100644 --- a/packages/plugins/sandbox-providers/exe-dev/package.json +++ b/packages/plugins/sandbox-providers/exe-dev/package.json @@ -41,7 +41,6 @@ "exe.dev" ], "scripts": { - "postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs", "prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "rm -rf dist && tsc", "clean": "rm -rf dist", diff --git a/packages/plugins/sandbox-providers/kubernetes/package.json b/packages/plugins/sandbox-providers/kubernetes/package.json index 4f6fdef6..74b2f1f5 100644 --- a/packages/plugins/sandbox-providers/kubernetes/package.json +++ b/packages/plugins/sandbox-providers/kubernetes/package.json @@ -39,7 +39,6 @@ "kubernetes" ], "scripts": { - "postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs", "prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "rm -rf dist && tsc", "clean": "rm -rf dist", diff --git a/packages/plugins/sandbox-providers/modal/package.json b/packages/plugins/sandbox-providers/modal/package.json index 86ce73ae..952900a5 100644 --- a/packages/plugins/sandbox-providers/modal/package.json +++ b/packages/plugins/sandbox-providers/modal/package.json @@ -41,7 +41,6 @@ "modal" ], "scripts": { - "postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs", "prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "rm -rf dist && tsc", "clean": "rm -rf dist", diff --git a/packages/plugins/sandbox-providers/novita/package.json b/packages/plugins/sandbox-providers/novita/package.json index 34fa4f39..1bb6ad9d 100644 --- a/packages/plugins/sandbox-providers/novita/package.json +++ b/packages/plugins/sandbox-providers/novita/package.json @@ -42,7 +42,6 @@ "novita-ai" ], "scripts": { - "postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs", "prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "rm -rf dist && tsc", "clean": "rm -rf dist", diff --git a/scripts/build-standalone-public-packages.mjs b/scripts/build-standalone-public-packages.mjs index 3a199219..7d004bb7 100644 --- a/scripts/build-standalone-public-packages.mjs +++ b/scripts/build-standalone-public-packages.mjs @@ -6,6 +6,8 @@ import path, { dirname } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; +import { linkSdkInto } from "./link-plugin-dev-sdk.mjs"; + const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, ".."); const workspacePath = path.join(repoRoot, "pnpm-workspace.yaml"); @@ -133,6 +135,11 @@ function main() { run("pnpm", installArgs, pkgDir); + // The fresh install above wipes node_modules and no longer fires a + // per-plugin postinstall (removed for supply-chain safety), so link the + // in-repo @paperclipai/plugin-sdk that the build's tsc resolves against. + linkSdkInto(pkgDir); + if (pkgJson.scripts?.build) { run("pnpm", ["run", "build"], pkgDir); } else { diff --git a/scripts/link-plugin-dev-sdk.mjs b/scripts/link-plugin-dev-sdk.mjs index e4b4f425..512b67f8 100644 --- a/scripts/link-plugin-dev-sdk.mjs +++ b/scripts/link-plugin-dev-sdk.mjs @@ -1,41 +1,98 @@ #!/usr/bin/env node -import { existsSync, mkdirSync, lstatSync, readlinkSync, rmSync, symlinkSync } from "node:fs"; +// 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 packageDir = process.cwd(); const sdkDir = join(repoRoot, "packages", "plugins", "sdk"); -const scopeDir = join(packageDir, "node_modules", "@paperclipai"); -const linkTarget = join(scopeDir, "plugin-sdk"); -if (!existsSync(join(packageDir, "package.json"))) { - throw new Error(`No package.json found in plugin directory: ${packageDir}`); +// 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"), + ]; } -mkdirSync(scopeDir, { recursive: true }); - -try { - const stat = lstatSync(linkTarget); - if (stat.isSymbolicLink()) { - const existingTarget = readlinkSync(linkTarget); - const relativeSdkDir = relative(scopeDir, sdkDir); - if (existingTarget === relativeSdkDir) { - console.log(` ✓ Local @paperclipai/plugin-sdk already linked for ${packageDir}`); - process.exit(0); +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; } - rmSync(linkTarget, { force: true }); - } else { - console.log(" i Keeping existing installed @paperclipai/plugin-sdk directory in place"); - process.exit(0); } -} catch { - // target does not exist yet + return { linked, skipped }; } -const relativeSdkDir = relative(scopeDir, sdkDir); -symlinkSync(relativeSdkDir, linkTarget, "dir"); +// 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})`); +} -console.log(` ✓ Linked local @paperclipai/plugin-sdk for ${packageDir}`); +// 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; +} diff --git a/scripts/link-plugin-dev-sdk.test.js b/scripts/link-plugin-dev-sdk.test.js new file mode 100644 index 00000000..178a16ac --- /dev/null +++ b/scripts/link-plugin-dev-sdk.test.js @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; + +import { linkSdkInto, readPluginsUnder } from "./link-plugin-dev-sdk.mjs"; + +let workDir; + +before(() => { + workDir = mkdtempSync(join(tmpdir(), "link-plugin-dev-sdk-")); +}); + +after(() => { + rmSync(workDir, { force: true, recursive: true }); +}); + +function makePackage(dir) { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "package.json"), "{}\n"); + return dir; +} + +test("readPluginsUnder returns [] for a missing directory", () => { + assert.deepEqual(readPluginsUnder(join(workDir, "does-not-exist")), []); +}); + +test("readPluginsUnder finds first-level package directories", () => { + const parent = join(workDir, "first-level"); + const a = makePackage(join(parent, "a")); + const b = makePackage(join(parent, "b")); + + assert.deepEqual(readPluginsUnder(parent).sort(), [a, b].sort()); +}); + +test("readPluginsUnder recurses into directories that are not themselves packages", () => { + // Mirrors the recursive pnpm-workspace exclusion glob: a provider nested + // deeper than one level must still be discovered. + const parent = join(workDir, "nested"); + const nested = makePackage(join(parent, "vendor", "my-plugin")); + + assert.deepEqual(readPluginsUnder(parent), [nested]); +}); + +test("readPluginsUnder stops descending once a package.json is found and skips node_modules", () => { + const parent = join(workDir, "boundaries"); + const pkg = makePackage(join(parent, "plugin")); + // A nested package inside an already-matched package must not be returned. + makePackage(join(pkg, "sub-package")); + // node_modules must be ignored entirely. + makePackage(join(parent, "node_modules", "some-dep")); + + assert.deepEqual(readPluginsUnder(parent), [pkg]); +}); + +test("linkSdkInto creates the plugin-sdk symlink and is idempotent", () => { + const pkg = makePackage(join(workDir, "link-target")); + + assert.equal(linkSdkInto(pkg), true); + + const link = join(pkg, "node_modules", "@paperclipai", "plugin-sdk"); + assert.ok(lstatSync(link).isSymbolicLink()); + + // Second call is a no-op because the link already points at the in-repo SDK. + assert.equal(linkSdkInto(pkg), false); +}); + +test("linkSdkInto leaves a real (non-symlink) install in place", () => { + const pkg = makePackage(join(workDir, "real-install")); + const scopeDir = join(pkg, "node_modules", "@paperclipai"); + mkdirSync(scopeDir, { recursive: true }); + // Simulate a published-tarball install: a real directory, not a symlink. + makePackage(join(scopeDir, "plugin-sdk")); + + assert.equal(linkSdkInto(pkg), false); + assert.ok(!lstatSync(join(scopeDir, "plugin-sdk")).isSymbolicLink()); +}); + +test("linkSdkInto replaces a symlink that points somewhere else", () => { + const pkg = makePackage(join(workDir, "stale-link")); + const scopeDir = join(pkg, "node_modules", "@paperclipai"); + mkdirSync(scopeDir, { recursive: true }); + symlinkSync("../somewhere-else", join(scopeDir, "plugin-sdk"), "dir"); + + assert.equal(linkSdkInto(pkg), true); + assert.notEqual(readlinkSync(join(scopeDir, "plugin-sdk")), "../somewhere-else"); + assert.ok(existsSync(scopeDir)); +});