diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts index 13220644..32b7620d 100644 --- a/cli/src/__tests__/company-import-export-e2e.test.ts +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -1,5 +1,5 @@ import { execFile, spawn } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import net from "node:net"; import os from "node:os"; import path from "node:path"; @@ -104,20 +104,50 @@ function writeTestConfig(configPath: string, tempRoot: string, port: number, con writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); } -function createServerEnv(configPath: string, port: number, connectionString: string) { +interface TestPaperclipEnv { + configPath: string; + paperclipHome: string; + instanceId: string; + shellHome?: string; +} + +function createBasePaperclipEnv(options: TestPaperclipEnv) { const env = { ...process.env }; for (const key of Object.keys(env)) { if (key.startsWith("PAPERCLIP_")) { delete env[key]; } } + + env.PAPERCLIP_CONFIG = options.configPath; + env.PAPERCLIP_HOME = options.paperclipHome; + env.PAPERCLIP_INSTANCE_ID = options.instanceId; + env.PAPERCLIP_CONTEXT = path.join(options.paperclipHome, "context.json"); + env.PAPERCLIP_AUTH_STORE = path.join(options.paperclipHome, "auth.json"); + if (options.shellHome) { + env.HOME = options.shellHome; + } + + return env; +} + +function createServerEnv( + configPath: string, + port: number, + connectionString: string, + options: Omit, +) { + const env = createBasePaperclipEnv({ + configPath, + ...options, + }); + delete env.DATABASE_URL; delete env.PORT; delete env.HOST; delete env.SERVE_UI; delete env.HEARTBEAT_SCHEDULER_ENABLED; - env.PAPERCLIP_CONFIG = configPath; env.DATABASE_URL = connectionString; env.HOST = "127.0.0.1"; env.PORT = String(port); @@ -130,13 +160,8 @@ function createServerEnv(configPath: string, port: number, connectionString: str return env; } -function createCliEnv() { - const env = { ...process.env }; - for (const key of Object.keys(env)) { - if (key.startsWith("PAPERCLIP_")) { - delete env[key]; - } - } +function createCliEnv(options: TestPaperclipEnv) { + const env = createBasePaperclipEnv(options); delete env.DATABASE_URL; delete env.PORT; delete env.HOST; @@ -183,14 +208,25 @@ async function api(baseUrl: string, pathname: string, init?: RequestInit): Pr return text ? JSON.parse(text) as T : (null as T); } -async function runCliJson(args: string[], opts: { apiBase: string; configPath: string }) { +async function runCliJson( + args: string[], + opts: TestPaperclipEnv & { apiBase?: string; includeConfigArg?: boolean }, +) { const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + const cliArgs = ["--silent", "paperclipai", ...args]; + if (opts.apiBase) { + cliArgs.push("--api-base", opts.apiBase); + } + if (opts.includeConfigArg !== false) { + cliArgs.push("--config", opts.configPath); + } + cliArgs.push("--json"); const result = await execFileAsync( "pnpm", - ["--silent", "paperclipai", ...args, "--api-base", opts.apiBase, "--config", opts.configPath, "--json"], + cliArgs, { cwd: repoRoot, - env: createCliEnv(), + env: createCliEnv(opts), maxBuffer: 10 * 1024 * 1024, }, ); @@ -235,6 +271,9 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => { let configPath = ""; let exportDir = ""; let apiBase = ""; + let paperclipHome = ""; + let cliShellHome = ""; + let paperclipInstanceId = ""; let serverProcess: ServerProcess | null = null; let tempDb: Awaited> | null = null; @@ -242,6 +281,11 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => { tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-")); configPath = path.join(tempRoot, "config", "config.json"); exportDir = path.join(tempRoot, "exported-company"); + paperclipHome = path.join(tempRoot, "paperclip-home"); + cliShellHome = path.join(tempRoot, "shell-home"); + paperclipInstanceId = "company-cli-e2e"; + mkdirSync(paperclipHome, { recursive: true }); + mkdirSync(cliShellHome, { recursive: true }); tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-"); @@ -256,7 +300,11 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => { ["paperclipai", "run", "--config", configPath], { cwd: repoRoot, - env: createServerEnv(configPath, port, tempDb.connectionString), + env: createServerEnv(configPath, port, tempDb.connectionString, { + paperclipHome, + instanceId: paperclipInstanceId, + shellHome: cliShellHome, + }), stdio: ["ignore", "pipe", "pipe"], }, ); @@ -282,6 +330,31 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => { it("exports a company package and imports it into new and existing companies", async () => { expect(serverProcess).not.toBeNull(); + const cliContext = await runCliJson<{ + contextPath: string; + profileName: string; + profile: { apiBase?: string }; + }>( + ["context", "set", "--profile", "isolation-check", "--api-base", "https://example.test"], + { + configPath, + paperclipHome, + instanceId: paperclipInstanceId, + shellHome: cliShellHome, + includeConfigArg: false, + }, + ); + + const expectedContextPath = path.join(paperclipHome, "context.json"); + const leakedContextPath = path.join(cliShellHome, ".paperclip", "context.json"); + expect(cliContext.contextPath).toBe(expectedContextPath); + expect(cliContext.profileName).toBe("isolation-check"); + expect(cliContext.profile.apiBase).toBe("https://example.test"); + expect(existsSync(expectedContextPath)).toBe(true); + expect(existsSync(leakedContextPath)).toBe(false); + rmSync(expectedContextPath, { force: true }); + expect(existsSync(expectedContextPath)).toBe(false); + const sourceCompany = await api<{ id: string; name: string; issuePrefix: string }>(apiBase, "/api/companies", { method: "POST", headers: { "content-type": "application/json" }, @@ -355,7 +428,13 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => { "--include", "company,agents,projects,issues", ], - { apiBase, configPath }, + { + apiBase, + configPath, + paperclipHome, + instanceId: paperclipInstanceId, + shellHome: cliShellHome, + }, ); expect(exportResult.ok).toBe(true); @@ -379,7 +458,13 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => { "company,agents,projects,issues", "--yes", ], - { apiBase, configPath }, + { + apiBase, + configPath, + paperclipHome, + instanceId: paperclipInstanceId, + shellHome: cliShellHome, + }, ); expect(importedNew.company.action).toBe("created"); @@ -427,7 +512,13 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => { "rename", "--dry-run", ], - { apiBase, configPath }, + { + apiBase, + configPath, + paperclipHome, + instanceId: paperclipInstanceId, + shellHome: cliShellHome, + }, ); expect(previewExisting.errors).toEqual([]); @@ -454,7 +545,13 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => { "rename", "--yes", ], - { apiBase, configPath }, + { + apiBase, + configPath, + paperclipHome, + instanceId: paperclipInstanceId, + shellHome: cliShellHome, + }, ); expect(importedExisting.company.action).toBe("unchanged"); @@ -501,7 +598,13 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => { "company,agents,projects,issues", "--yes", ], - { apiBase, configPath }, + { + apiBase, + configPath, + paperclipHome, + instanceId: paperclipInstanceId, + shellHome: cliShellHome, + }, ); expect(importedFromZip.company.action).toBe("created"); diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/package.json b/packages/plugins/examples/plugin-authoring-smoke-example/package.json index 61b27ab9..7c726583 100644 --- a/packages/plugins/examples/plugin-authoring-smoke-example/package.json +++ b/packages/plugins/examples/plugin-authoring-smoke-example/package.json @@ -5,13 +5,13 @@ "private": true, "description": "A Paperclip plugin", "scripts": { - "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs", + "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "node ./esbuild.config.mjs", "build:rollup": "rollup -c", "dev": "node ./esbuild.config.mjs --watch", "dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177", "test": "vitest run --config ./vitest.config.ts", - "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" }, "paperclipPlugin": { "manifest": "./dist/manifest.js", diff --git a/packages/plugins/examples/plugin-file-browser-example/package.json b/packages/plugins/examples/plugin-file-browser-example/package.json index 86c720d4..f0115bb4 100644 --- a/packages/plugins/examples/plugin-file-browser-example/package.json +++ b/packages/plugins/examples/plugin-file-browser-example/package.json @@ -13,10 +13,10 @@ "ui": "./dist/ui/" }, "scripts": { - "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs", + "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "tsc && node ./scripts/build-ui.mjs", "clean": "rm -rf dist", - "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" }, "dependencies": { "@codemirror/lang-javascript": "^6.2.2", diff --git a/packages/plugins/examples/plugin-hello-world-example/package.json b/packages/plugins/examples/plugin-hello-world-example/package.json index 5d055caa..fa0948f1 100644 --- a/packages/plugins/examples/plugin-hello-world-example/package.json +++ b/packages/plugins/examples/plugin-hello-world-example/package.json @@ -13,10 +13,10 @@ "ui": "./dist/ui/" }, "scripts": { - "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs", + "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "tsc", "clean": "rm -rf dist", - "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" }, "dependencies": { "@paperclipai/plugin-sdk": "workspace:*" diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/package.json b/packages/plugins/examples/plugin-kitchen-sink-example/package.json index 467ff039..8cf5e957 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/package.json +++ b/packages/plugins/examples/plugin-kitchen-sink-example/package.json @@ -13,10 +13,10 @@ "ui": "./dist/ui/" }, "scripts": { - "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs", + "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "tsc && node ./scripts/build-ui.mjs", "clean": "rm -rf dist", - "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" }, "dependencies": { "@paperclipai/plugin-sdk": "workspace:*", diff --git a/packages/plugins/examples/plugin-orchestration-smoke-example/package.json b/packages/plugins/examples/plugin-orchestration-smoke-example/package.json index fb9985ca..1feb6de4 100644 --- a/packages/plugins/examples/plugin-orchestration-smoke-example/package.json +++ b/packages/plugins/examples/plugin-orchestration-smoke-example/package.json @@ -5,13 +5,13 @@ "private": true, "description": "First-party smoke plugin for orchestration-grade Paperclip plugin APIs", "scripts": { - "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs", + "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "node ./esbuild.config.mjs", "build:rollup": "rollup -c", "dev": "node ./esbuild.config.mjs --watch", "dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177", "test": "vitest run --config ./vitest.config.ts", - "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" }, "paperclipPlugin": { "manifest": "./dist/manifest.js", diff --git a/packages/plugins/paperclip-plugin-fake-sandbox/package.json b/packages/plugins/paperclip-plugin-fake-sandbox/package.json index 76205258..5797a7c1 100644 --- a/packages/plugins/paperclip-plugin-fake-sandbox/package.json +++ b/packages/plugins/paperclip-plugin-fake-sandbox/package.json @@ -12,10 +12,10 @@ "worker": "./dist/worker.js" }, "scripts": { - "prebuild": "node ../../../scripts/ensure-plugin-build-deps.mjs", + "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "tsc", "clean": "rm -rf dist", - "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit", "test": "vitest run --config vitest.config.ts" }, "dependencies": { diff --git a/packages/plugins/sandbox-providers/e2b/package.json b/packages/plugins/sandbox-providers/e2b/package.json index 1ebd224b..434cac0b 100644 --- a/packages/plugins/sandbox-providers/e2b/package.json +++ b/packages/plugins/sandbox-providers/e2b/package.json @@ -42,10 +42,10 @@ ], "scripts": { "postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs", - "prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk build", + "prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "rm -rf dist && tsc", "clean": "rm -rf dist", - "typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk build && tsc --noEmit", + "typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit", "test": "vitest run --config vitest.config.ts", "prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../../scripts/generate-plugin-package-json.mjs", "postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi" diff --git a/packages/plugins/sdk/package.json b/packages/plugins/sdk/package.json index 5564f28f..837314d2 100644 --- a/packages/plugins/sdk/package.json +++ b/packages/plugins/sdk/package.json @@ -103,6 +103,7 @@ "scripts": { "build": "pnpm --filter @paperclipai/shared build && tsc", "clean": "rm -rf dist", + "ensure-build-deps": "node ../../../scripts/ensure-plugin-build-deps.mjs", "typecheck": "pnpm --filter @paperclipai/shared build && tsc --noEmit", "dev:server": "tsx src/dev-cli.ts" }, diff --git a/scripts/ensure-plugin-build-deps.mjs b/scripts/ensure-plugin-build-deps.mjs index f8470da1..1ca6b01f 100644 --- a/scripts/ensure-plugin-build-deps.mjs +++ b/scripts/ensure-plugin-build-deps.mjs @@ -8,6 +8,9 @@ import { fileURLToPath } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(scriptDir, ".."); const tscCliPath = path.join(rootDir, "node_modules", "typescript", "bin", "tsc"); +const lockDir = path.join(rootDir, "node_modules", ".cache", "paperclip-plugin-build-deps.lock"); +const lockTimeoutMs = 60_000; +const lockPollMs = 100; const buildTargets = [ { @@ -26,21 +29,77 @@ if (!fs.existsSync(tscCliPath)) { throw new Error(`TypeScript CLI not found at ${tscCliPath}`); } -for (const target of buildTargets) { - if (fs.existsSync(target.output)) { - continue; +function allOutputsExist() { + return buildTargets.every((target) => fs.existsSync(target.output)); +} + +function sleep(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function waitForLockRelease() { + const startedAt = Date.now(); + while (Date.now() - startedAt < lockTimeoutMs) { + if (!fs.existsSync(lockDir)) { + return; + } + if (allOutputsExist()) { + return; + } + sleep(lockPollMs); } - const result = spawnSync(process.execPath, [tscCliPath, "-p", target.tsconfig], { - cwd: rootDir, - stdio: "inherit", - }); + throw new Error(`Timed out waiting for plugin build dependency lock at ${lockDir}`); +} - if (result.error) { - throw result.error; +if (allOutputsExist()) { + process.exit(0); +} + +fs.mkdirSync(path.dirname(lockDir), { recursive: true }); + +let holdsLock = false; +let exitCode = 0; +try { + try { + fs.mkdirSync(lockDir); + holdsLock = true; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") { + waitForLockRelease(); + if (!allOutputsExist()) { + throw new Error("Plugin build dependency lock released before all outputs were created"); + } + process.exit(0); + } + throw error; } - if (result.status !== 0) { - process.exit(result.status ?? 1); + for (const target of buildTargets) { + if (fs.existsSync(target.output)) { + continue; + } + + const result = spawnSync(process.execPath, [tscCliPath, "-p", target.tsconfig], { + cwd: rootDir, + stdio: "inherit", + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + exitCode = result.status ?? 1; + break; + } + } +} finally { + if (holdsLock) { + fs.rmSync(lockDir, { recursive: true, force: true }); } } + +if (exitCode !== 0) { + process.exit(exitCode); +} diff --git a/server/package.json b/server/package.json index dac65fa7..ab81280e 100644 --- a/server/package.json +++ b/server/package.json @@ -40,7 +40,7 @@ "postpack": "rm -rf ui-dist", "clean": "rm -rf dist", "start": "node dist/index.js", - "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0",