From 783492cf96e83e8e30873c30c46d299a7a20e3ba Mon Sep 17 00:00:00 2001 From: Pedro Sanders Date: Tue, 3 Nov 2026 22:05:59 -0400 Subject: [PATCH] fix(orchestrate): stage the agent's dependencies where npx hoists them Under npx, perfkit's dependencies sit beside it in npx's node_modules, not inside it, so the agent upload failed with 'dependency commander is not installed'. Dependencies are now found the way Node resolves them: the nearest node_modules in the package's directory or any parent. --- package-lock.json | 4 +- package.json | 2 +- src/orchestrate/stageAgentPackage.ts | 50 ++++++--- .../stageAgentPackage.unit.test.ts | 104 ++++++++++++++++++ 4 files changed, 139 insertions(+), 21 deletions(-) create mode 100644 test/orchestrate/stageAgentPackage.unit.test.ts diff --git a/package-lock.json b/package-lock.json index 5f62385..0b879eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "perfkit", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "perfkit", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { "commander": "^14.0.3", diff --git a/package.json b/package.json index 2b00969..e11ba78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "perfkit", - "version": "0.1.1", + "version": "0.1.2", "description": "Benchmark harness for Fonoster projects: DigitalOcean runs, one report.json schema, one branded report page", "license": "MIT", "author": "Pedro Sanders ", diff --git a/src/orchestrate/stageAgentPackage.ts b/src/orchestrate/stageAgentPackage.ts index 68aa9d1..01619eb 100644 --- a/src/orchestrate/stageAgentPackage.ts +++ b/src/orchestrate/stageAgentPackage.ts @@ -2,7 +2,25 @@ * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. */ import { access, cp, readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; + +/** + * Where `name` is installed for code in `from`, the way Node resolves it: the + * nearest `node_modules/` in `from` or any parent directory. Under npx + * the dependencies sit beside the package, not inside it. + */ +async function findPackageDir( + name: string, + from: string +): Promise { + const candidate = join(from, "node_modules", name); + const found = await access(join(candidate, "package.json")) + .then(() => true) + .catch(() => false); + if (found) return candidate; + const parent = dirname(from); + return parent === from ? null : findPackageDir(name, parent); +} /** * Copies the CLI's own package into `dest`: `dist/`, `package.json` and the @@ -26,21 +44,23 @@ export async function stageAgentPackage( ); }); + // name -> the directory it is installed in const closure = async ( names: string[], - seen: Set - ): Promise> => { - const fresh = names.filter((name) => !seen.has(name)); + seen: Map + ): Promise> => { + const fresh = [...new Set(names)].filter((name) => !seen.has(name)); if (fresh.length === 0) return seen; - fresh.forEach((name) => seen.add(name)); const next = await Promise.all( fresh.map(async (name) => { - const file = join(packageRoot, "node_modules", name, "package.json"); - const text = await readFile(file, "utf8").catch(() => { + const dir = await findPackageDir(name, packageRoot); + if (!dir) { throw new Error( - `dependency ${name} is not installed in ${packageRoot}` + `dependency ${name} is not installed in ${packageRoot} or any parent node_modules` ); - }); + } + seen.set(name, dir); + const text = await readFile(join(dir, "package.json"), "utf8"); return Object.keys( (JSON.parse(text) as { dependencies?: Record }) .dependencies ?? {} @@ -49,19 +69,13 @@ export async function stageAgentPackage( ); return closure(next.flat(), seen); }; - const deps = await closure(Object.keys(pkg.dependencies ?? {}), new Set()); + const deps = await closure(Object.keys(pkg.dependencies ?? {}), new Map()); await cp(join(packageRoot, "dist"), join(dest, "dist"), { recursive: true }); await cp(join(packageRoot, "package.json"), join(dest, "package.json")); await Promise.all( - [...deps].map((name) => - cp( - join(packageRoot, "node_modules", name), - join(dest, "node_modules", name), - { - recursive: true - } - ) + [...deps].map(([name, dir]) => + cp(dir, join(dest, "node_modules", name), { recursive: true }) ) ); return pkg.version; diff --git a/test/orchestrate/stageAgentPackage.unit.test.ts b/test/orchestrate/stageAgentPackage.unit.test.ts new file mode 100644 index 0000000..1c009db --- /dev/null +++ b/test/orchestrate/stageAgentPackage.unit.test.ts @@ -0,0 +1,104 @@ +/** + * Copyright (C) 2026 by Fonoster Inc (https://fonoster.com). MIT License. + */ +import { expect } from "chai"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stageAgentPackage } from "../../src/orchestrate/stageAgentPackage.js"; + +const pkgJson = (name: string, deps: Record = {}) => + JSON.stringify({ name, version: "1.0.0", dependencies: deps }); + +async function writePackage( + dir: string, + name: string, + deps?: Record +): Promise { + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "package.json"), pkgJson(name, deps)); + await writeFile(join(dir, "index.js"), `module.exports = "${name}";`); +} + +describe("stageAgentPackage", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "perfkit-stage-")); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + async function writePerfkit(dir: string): Promise { + await mkdir(join(dir, "dist"), { recursive: true }); + await writeFile(join(dir, "dist", "cli.js"), "// cli"); + await writeFile( + join(dir, "package.json"), + JSON.stringify({ + name: "perfkit", + version: "0.1.2", + dependencies: { commander: "^14", yaml: "^2" } + }) + ); + } + + it("should stage dependencies installed inside the package", async () => { + const pkg = join(root, "perfkit"); + await writePerfkit(pkg); + await writePackage(join(pkg, "node_modules", "commander"), "commander"); + await writePackage(join(pkg, "node_modules", "yaml"), "yaml"); + const dest = join(root, "out"); + + const version = await stageAgentPackage(pkg, dest); + + expect(version).to.equal("0.1.2"); + expect( + await readFile(join(dest, "node_modules", "yaml", "index.js"), "utf8") + ).to.contain("yaml"); + }); + + it("should stage dependencies hoisted beside the package, as npx installs them", async () => { + // npx: /node_modules/perfkit with commander, yaml and yaml's own + // dependency hoisted to /node_modules + const cache = join(root, "_npx", "abc"); + const pkg = join(cache, "node_modules", "perfkit"); + await writePerfkit(pkg); + await writePackage(join(cache, "node_modules", "commander"), "commander"); + await writePackage(join(cache, "node_modules", "yaml"), "yaml", { + "yaml-dep": "^1" + }); + await writePackage(join(cache, "node_modules", "yaml-dep"), "yaml-dep"); + const dest = join(root, "out"); + + await stageAgentPackage(pkg, dest); + + expect( + await readFile( + join(dest, "node_modules", "commander", "index.js"), + "utf8" + ) + ).to.contain("commander"); + expect( + await readFile(join(dest, "node_modules", "yaml-dep", "index.js"), "utf8") + ).to.contain("yaml-dep"); + expect(await readFile(join(dest, "dist", "cli.js"), "utf8")).to.contain( + "cli" + ); + }); + + it("should name a dependency that is installed nowhere", async () => { + const pkg = join(root, "perfkit"); + await writePerfkit(pkg); + await writePackage(join(pkg, "node_modules", "commander"), "commander"); + + const error = await stageAgentPackage(pkg, join(root, "out")).catch( + (e: Error) => e + ); + + expect((error as Error).message).to.contain( + "dependency yaml is not installed" + ); + }); +});