diff --git a/lib/shared/get-package-manager.ts b/lib/shared/get-package-manager.ts index da9e84184..8b8462d93 100644 --- a/lib/shared/get-package-manager.ts +++ b/lib/shared/get-package-manager.ts @@ -1,6 +1,23 @@ import fs from "fs" import kleur from "kleur" -import { execSync } from "node:child_process" +import { spawnSync } from "node:child_process" + +export interface PackageManager { + name: "npm" | "yarn" | "pnpm" | "bun" + uninstall: (opts: { name: string; cwd: string }) => void + install: (opts: { name: string; cwd: string }) => void + update: (opts: { name: string; cwd: string }) => void + init: (opts: { cwd: string }) => void + installDeps: (opts: { + deps: string[] + cwd: string + dev?: boolean + }) => void + getInitCommand: () => string + getInstallDepsCommand: (deps: string[], dev?: boolean) => string + installAll: (opts: { cwd: string }) => void + getInstallAllCommand: () => string +} function detectPackageManager(): "npm" | "yarn" | "pnpm" | "bun" { const userAgent = process.env.npm_config_user_agent || "" @@ -28,21 +45,28 @@ function detectPackageManager(): "npm" | "yarn" | "pnpm" | "bun" { return "npm" // Default to npm } -export interface PackageManager { - name: "npm" | "yarn" | "pnpm" | "bun" - uninstall: (opts: { name: string; cwd: string }) => void - install: (opts: { name: string; cwd: string }) => void - update: (opts: { name: string; cwd: string }) => void - init: (opts: { cwd: string }) => void - installDeps: (opts: { - deps: string[] - cwd: string - dev?: boolean - }) => void - getInitCommand: () => string - getInstallDepsCommand: (deps: string[], dev?: boolean) => string - installAll: (opts: { cwd: string }) => void - getInstallAllCommand: () => string +function validatePackageNames(names: string[]) { + for (const n of names) { + const specIsSafe = /^[a-z0-9@.\-_/:]+$/i.test(n) && !n.includes("..") + if (!specIsSafe) { + throw new Error(`Refusing to process invalid package spec: ${n}`) + } + } +} + +function handleSpawnOutput(output: ReturnType) { + if (output.stdout) process.stdout.write(output.stdout) + if (output.stderr) process.stderr.write(output.stderr) + if (output.error) throw output.error + if (output.status !== 0) { + const err = new Error(`Command failed with exit code ${output.status}`) + Object.assign(err, { + status: output.status, + stdout: output.stdout, + stderr: output.stderr, + }) + throw err + } } export function getPackageManager(): PackageManager { @@ -50,78 +74,95 @@ export function getPackageManager(): PackageManager { return { name: pm, uninstall: ({ name, cwd }) => { - let uninstallCommand: string - if (pm === "yarn") { - uninstallCommand = `yarn remove ${name}` - } else if (pm === "pnpm") { - uninstallCommand = `pnpm remove ${name}` - } else if (pm === "bun") { - uninstallCommand = `bun remove ${name}` - } else { - uninstallCommand = `npm uninstall ${name}` - } - execSync(uninstallCommand, { stdio: "pipe", cwd }) + const names = name.split(/\s+/).filter(Boolean) + if (names.length === 0) return + validatePackageNames(names) + + let args: string[] + if (pm === "yarn") args = ["remove", ...names] + else if (pm === "pnpm") args = ["remove", ...names] + else if (pm === "bun") args = ["remove", ...names] + else args = ["uninstall", ...names] + + const output = spawnSync(pm, args, { stdio: "pipe", cwd }) + handleSpawnOutput(output) }, install: ({ name, cwd }) => { - let installCommand: string - if (pm === "yarn") { - installCommand = `yarn add ${name}` - } else if (pm === "pnpm") { - installCommand = `pnpm add ${name}` - } else if (pm === "bun") { - installCommand = `bun add ${name}` - } else { - installCommand = `npm install ${name}` - } - console.log(kleur.gray(`> ${installCommand}`)) - const output = execSync(installCommand, { + const names = name.split(/\s+/).filter(Boolean) + if (names.length === 0) return + validatePackageNames(names) + + let args: string[] + if (pm === "yarn") args = ["add", ...names] + else if (pm === "pnpm") args = ["add", ...names] + else if (pm === "bun") args = ["add", ...names] + else args = ["install", ...names] + + console.log(kleur.gray(`> ${pm} ${args.join(" ")}`)) + const output = spawnSync(pm, args, { stdio: ["inherit", "pipe", "pipe"], cwd, }) - if (output) { - process.stdout.write(output) - } + handleSpawnOutput(output) }, update: ({ name, cwd }) => { - let updateCommand: string - if (pm === "yarn") { - updateCommand = `yarn upgrade ${name}` - } else if (pm === "pnpm") { - updateCommand = `pnpm update ${name}` - } else if (pm === "bun") { - updateCommand = `bun update ${name}` - } else { - updateCommand = `npm update ${name}` - } - console.log(kleur.gray(`> ${updateCommand}`)) - const output = execSync(updateCommand, { + const names = name.split(/\s+/).filter(Boolean) + if (names.length === 0) return + validatePackageNames(names) + + let args: string[] + if (pm === "yarn") args = ["upgrade", ...names] + else if (pm === "pnpm") args = ["update", ...names] + else if (pm === "bun") args = ["update", ...names] + else args = ["update", ...names] + + console.log(kleur.gray(`> ${pm} ${args.join(" ")}`)) + const output = spawnSync(pm, args, { stdio: ["inherit", "pipe", "pipe"], cwd, }) - if (output) { - process.stdout.write(output) - } + handleSpawnOutput(output) }, init: ({ cwd }) => { - const initCommand = getInitCommand() - execSync(initCommand, { stdio: "inherit", cwd }) + let args: string[] + if (pm === "yarn") args = ["init", "-y"] + else if (pm === "pnpm") args = ["init"] + else if (pm === "bun") args = ["init", "-y"] + else args = ["init", "-y"] + + const output = spawnSync(pm, args, { stdio: "inherit", cwd }) + handleSpawnOutput(output) }, installDeps: ({ deps, cwd, dev }) => { - const installCommand = getInstallDepsCommand(deps, dev) - execSync(installCommand, { stdio: "inherit", cwd }) + if (deps.length === 0) return + validatePackageNames(deps) + + let args: string[] + if (pm === "bun") args = ["add", dev ? "-d" : "", ...deps].filter(Boolean) + else if (pm === "yarn") + args = ["add", dev ? "-D" : "", ...deps].filter(Boolean) + else if (pm === "pnpm") + args = ["add", dev ? "-D" : "", ...deps].filter(Boolean) + else args = ["install", dev ? "-D" : "", ...deps].filter(Boolean) + + const output = spawnSync(pm, args, { stdio: "inherit", cwd }) + handleSpawnOutput(output) }, getInitCommand, getInstallDepsCommand, installAll: ({ cwd }) => { - const installCommand = getInstallAllCommand() - console.log(kleur.gray(`> ${installCommand}`)) - const output = execSync(installCommand, { + let args: string[] + if (pm === "yarn") args = ["install"] + else if (pm === "pnpm") args = ["install"] + else if (pm === "bun") args = ["install"] + else args = ["install"] + + console.log(kleur.gray(`> ${pm} install`)) + const output = spawnSync(pm, args, { stdio: ["inherit", "pipe", "pipe"], cwd, }) - if (output) { - process.stdout.write(output) - } + handleSpawnOutput(output) }, getInstallAllCommand, } diff --git a/tests/shared/get-package-manager.test.ts b/tests/shared/get-package-manager.test.ts new file mode 100644 index 000000000..9eee798bd --- /dev/null +++ b/tests/shared/get-package-manager.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test" +import { getPackageManager } from "../../lib/shared/get-package-manager" + +test("get-package-manager command injection prevention", () => { + const pm = getPackageManager() + + expect(() => pm.install({ name: "x; ls", cwd: "." })).toThrow() + expect(() => pm.install({ name: "pkg|curl", cwd: "." })).toThrow() + expect(() => pm.install({ name: "pkg>file", cwd: "." })).toThrow() + expect(() => pm.install({ name: "../pkg", cwd: "." })).toThrow() + expect(() => pm.install({ name: "$(whoami)", cwd: "." })).toThrow() +})