diff --git a/build/npm-invocation.cjs b/build/npm-invocation.cjs new file mode 100644 index 00000000..c1f1968f --- /dev/null +++ b/build/npm-invocation.cjs @@ -0,0 +1,33 @@ +const { existsSync } = require("node:fs"); +const { join } = require("node:path"); + +function npmInvocation(projectDir, environment = process.env) { + const npmCli = environment.npm_execpath; + if (npmCli) { + return { + command: process.execPath, + prefixArgs: [npmCli], + }; + } + + const bundledNpmCli = join( + projectDir, + "node_modules", + "npm", + "bin", + "npm-cli.js", + ); + if (existsSync(bundledNpmCli)) { + return { + command: process.execPath, + prefixArgs: [bundledNpmCli], + }; + } + + return { + command: process.platform === "win32" ? "npm.cmd" : "npm", + prefixArgs: [], + }; +} + +module.exports = { npmInvocation }; diff --git a/build/opencode-config.cjs b/build/opencode-config.cjs index 9fb07614..cb700eb6 100644 --- a/build/opencode-config.cjs +++ b/build/opencode-config.cjs @@ -8,21 +8,11 @@ const { } = require("node:fs"); const { join } = require("node:path"); const { spawnSync } = require("node:child_process"); +const { npmInvocation } = require("./npm-invocation.cjs"); const opencodeVersion = "1.18.29"; const opencodePluginPackage = "@opencode-ai/plugin"; -function npmInvocation() { - const npmCli = process.env.npm_execpath; - if (npmCli) { - return { command: process.execPath, prefixArgs: [npmCli] }; - } - if (process.platform === "win32") { - throw new Error("npm_execpath is required to prepare OpenCode config"); - } - return { command: "npm", prefixArgs: [] }; -} - function prepareBundledOpenCodeConfig(projectDir) { const targetDirectory = join( projectDir, @@ -68,7 +58,7 @@ function prepareBundledOpenCodeConfig(projectDir) { "utf8", ); writeFileSync(join(stagingDirectory, ".gitignore"), "node_modules\n"); - const npm = npmInvocation(); + const npm = npmInvocation(projectDir); const environment = { ...process.env }; delete environment.NODE_TLS_REJECT_UNAUTHORIZED; const result = spawnSync( diff --git a/build/runtime-hooks.cjs b/build/runtime-hooks.cjs index 9996488d..b19158c9 100644 --- a/build/runtime-hooks.cjs +++ b/build/runtime-hooks.cjs @@ -17,6 +17,7 @@ const { opencodeVersion, prepareBundledOpenCodeConfig, } = require("./opencode-config.cjs"); +const { npmInvocation } = require("./npm-invocation.cjs"); const architectureNames = { 1: "x64", @@ -48,23 +49,6 @@ async function lockedIntegrity(projectDir, packageName) { return entry.integrity; } -function npmInvocation() { - const npmCli = process.env.npm_execpath; - if (npmCli) { - return { - command: process.execPath, - prefixArgs: [npmCli], - }; - } - if (process.platform === "win32") { - throw new Error("npm_execpath is required to prepare bundled runtimes"); - } - return { - command: "npm", - prefixArgs: [], - }; -} - async function downloadPackage(projectDir, packageName, integrity) { const cacheDirectory = join(projectDir, ".runtime-resources", "cache"); await mkdir(cacheDirectory, { recursive: true }); @@ -80,7 +64,7 @@ async function downloadPackage(projectDir, packageName, integrity) { await rm(archivePath, { force: true }); } - const npm = npmInvocation(); + const npm = npmInvocation(projectDir); const result = spawnSync( npm.command, [ diff --git a/tests/npm-invocation.test.ts b/tests/npm-invocation.test.ts new file mode 100644 index 00000000..5f324192 --- /dev/null +++ b/tests/npm-invocation.test.ts @@ -0,0 +1,60 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +interface NpmInvocationModule { + npmInvocation( + projectDir: string, + environment?: NodeJS.ProcessEnv + ): { + command: string + prefixArgs: string[] + } +} + +const require = createRequire(import.meta.url) +const { npmInvocation } = require( + '../build/npm-invocation.cjs' +) as NpmInvocationModule + +let directory: string + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'goodbuddy-npm-invocation-')) +}) + +afterEach(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +describe('npm invocation', () => { + it('uses npm_execpath when the package manager provides it', () => { + expect( + npmInvocation(directory, { npm_execpath: 'provided-npm-cli.js' }) + ).toEqual({ + command: process.execPath, + prefixArgs: ['provided-npm-cli.js'] + }) + }) + + it('uses the project npm CLI when npm_execpath is absent', () => { + const npmCli = join( + directory, + 'node_modules', + 'npm', + 'bin', + 'npm-cli.js' + ) + mkdirSync(join(directory, 'node_modules', 'npm', 'bin'), { + recursive: true + }) + writeFileSync(npmCli, '') + + expect(npmInvocation(directory, {})).toEqual({ + command: process.execPath, + prefixArgs: [npmCli] + }) + }) +})