diff --git a/scripts/ensure-workspace-package-links.ts b/scripts/ensure-workspace-package-links.ts new file mode 100644 index 00000000..d2494545 --- /dev/null +++ b/scripts/ensure-workspace-package-links.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env -S node --import tsx +import { spawn } from "node:child_process"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import path from "node:path"; +import { repoRoot } from "./dev-service-profile.ts"; + +type WorkspaceLinkMismatch = { + packageName: string; + expectedPath: string; + actualPath: string | null; +}; + +function readJsonFile(filePath: string): Record { + return JSON.parse(readFileSync(filePath, "utf8")) as Record; +} + +function resolveWorkspacePackagePath(packageName: string): string | null { + if (packageName === "@paperclipai/adapter-utils") { + return path.join(repoRoot, "packages", "adapter-utils"); + } + if (packageName === "@paperclipai/db") { + return path.join(repoRoot, "packages", "db"); + } + if (packageName === "@paperclipai/shared") { + return path.join(repoRoot, "packages", "shared"); + } + if (packageName === "@paperclipai/plugin-sdk") { + return path.join(repoRoot, "packages", "plugins", "sdk"); + } + if (packageName.startsWith("@paperclipai/adapter-")) { + return path.join(repoRoot, "packages", "adapters", packageName.slice("@paperclipai/adapter-".length)); + } + return null; +} + +function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] { + const serverPackageJson = readJsonFile(path.join(repoRoot, "server", "package.json")); + const dependencies = { + ...(serverPackageJson.dependencies as Record | undefined), + ...(serverPackageJson.devDependencies as Record | undefined), + }; + const mismatches: WorkspaceLinkMismatch[] = []; + + for (const [packageName, version] of Object.entries(dependencies)) { + if (typeof version !== "string" || !version.startsWith("workspace:")) continue; + + const expectedPath = resolveWorkspacePackagePath(packageName); + if (!expectedPath) continue; + + const linkPath = path.join(repoRoot, "server", "node_modules", ...packageName.split("/")); + const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null; + if (actualPath === path.resolve(expectedPath)) continue; + + mismatches.push({ + packageName, + expectedPath: path.resolve(expectedPath), + actualPath, + }); + } + + return mismatches; +} + +function runCommand(command: string, args: string[], cwd: string) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: process.env, + stdio: "inherit", + }); + + child.on("error", reject); + child.on("exit", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + `${command} ${args.join(" ")} failed with ${signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`}`, + ), + ); + }); + }); +} + +async function ensureServerWorkspaceLinksCurrent() { + const mismatches = findServerWorkspaceLinkMismatches(); + if (mismatches.length === 0) return; + + console.log("[paperclip] detected stale workspace package links for server; relinking dependencies..."); + for (const mismatch of mismatches) { + console.log( + `[paperclip] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}`, + ); + } + + const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + await runCommand( + pnpmBin, + ["install", "--force", "--config.confirmModulesPurge=false"], + repoRoot, + ); + + const remainingMismatches = findServerWorkspaceLinkMismatches(); + if (remainingMismatches.length === 0) return; + + throw new Error( + `Workspace relink did not repair all server package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`, + ); +} + +await ensureServerWorkspaceLinksCurrent(); diff --git a/server/package.json b/server/package.json index 0f7efa44..b2d17ad3 100644 --- a/server/package.json +++ b/server/package.json @@ -32,15 +32,16 @@ "skills" ], "scripts": { - "dev": "tsx src/index.ts", - "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts", + "preflight:workspace-links": "tsx ../scripts/ensure-workspace-package-links.ts", + "dev": "pnpm run preflight:workspace-links && tsx src/index.ts", + "dev:watch": "pnpm run preflight:workspace-links && cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts", "prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh", - "build": "tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/", + "build": "pnpm run preflight:workspace-links && tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/", "prepack": "pnpm run prepare:ui-dist", "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 run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", diff --git a/server/src/adapters/utils.ts b/server/src/adapters/utils.ts index 3c85852b..d9201bb6 100644 --- a/server/src/adapters/utils.ts +++ b/server/src/adapters/utils.ts @@ -1,34 +1,77 @@ // Re-export everything from the shared adapter-utils/server-utils package. // This file is kept as a convenience shim so existing in-tree // imports (process/, http/, heartbeat.ts) don't need rewriting. +import type { ChildProcess } from "node:child_process"; import { logger } from "../middleware/logger.js"; -export { - type RunProcessResult, - runningProcesses, - MAX_CAPTURE_BYTES, - MAX_EXCERPT_BYTES, - parseObject, - asString, - asNumber, - asBoolean, - asStringArray, - parseJson, - appendWithCap, - resolvePathValue, - renderTemplate, - redactEnvForLogs, - buildInvocationEnvForLogs, - buildPaperclipEnv, - defaultPathForPlatform, - ensurePathInEnv, - ensureAbsoluteDirectory, - ensureCommandResolvable, - resolveCommandForLogs, -} from "@paperclipai/adapter-utils/server-utils"; +import * as serverUtils from "@paperclipai/adapter-utils/server-utils"; +export type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils"; + +type BuildInvocationEnvForLogsOptions = { + runtimeEnv?: NodeJS.ProcessEnv | Record; + includeRuntimeKeys?: string[]; + resolvedCommand?: string | null; + resolvedCommandEnvKey?: string; +}; + +export const runningProcesses: Map = + serverUtils.runningProcesses; +export const MAX_CAPTURE_BYTES = serverUtils.MAX_CAPTURE_BYTES; +export const MAX_EXCERPT_BYTES = serverUtils.MAX_EXCERPT_BYTES; +export const parseObject = serverUtils.parseObject; +export const asString = serverUtils.asString; +export const asNumber = serverUtils.asNumber; +export const asBoolean = serverUtils.asBoolean; +export const asStringArray = serverUtils.asStringArray; +export const parseJson = serverUtils.parseJson; +export const appendWithCap = serverUtils.appendWithCap; +export const resolvePathValue = serverUtils.resolvePathValue; +export const renderTemplate = serverUtils.renderTemplate; +export const redactEnvForLogs = serverUtils.redactEnvForLogs; +export const buildPaperclipEnv = serverUtils.buildPaperclipEnv; +export const defaultPathForPlatform = serverUtils.defaultPathForPlatform; +export const ensurePathInEnv = serverUtils.ensurePathInEnv; +export const ensureAbsoluteDirectory = serverUtils.ensureAbsoluteDirectory; +export const ensureCommandResolvable = serverUtils.ensureCommandResolvable; +export const resolveCommandForLogs = serverUtils.resolveCommandForLogs; + +export function buildInvocationEnvForLogs( + env: Record, + options: BuildInvocationEnvForLogsOptions = {}, +): Record { + const maybeBuildInvocationEnvForLogs = ( + serverUtils as typeof serverUtils & { + buildInvocationEnvForLogs?: ( + env: Record, + options?: BuildInvocationEnvForLogsOptions, + ) => Record; + } + ).buildInvocationEnvForLogs; + + if (typeof maybeBuildInvocationEnvForLogs === "function") { + return maybeBuildInvocationEnvForLogs(env, options); + } + + const merged: Record = { ...env }; + const runtimeEnv = options.runtimeEnv ?? {}; + + for (const key of options.includeRuntimeKeys ?? []) { + if (key in merged) continue; + const value = runtimeEnv[key]; + if (typeof value !== "string" || value.length === 0) continue; + merged[key] = value; + } + + const resolvedCommand = options.resolvedCommand?.trim(); + if (resolvedCommand) { + merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = resolvedCommand; + } + + return redactEnvForLogs(merged); +} // Re-export runChildProcess with the server's pino logger wired in. -import { runChildProcess as _runChildProcess } from "@paperclipai/adapter-utils/server-utils"; import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils"; +const _runChildProcess = serverUtils.runChildProcess; export async function runChildProcess( runId: string,