import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { isKeyOf } from "@ark/util"; import { detect } from "package-manager-detector"; import { resolveCommand } from "package-manager-detector/commands"; import { log } from "../utils/cli.ts"; import { spawn } from "../utils/subprocess.ts"; import type { NodePackageManager, NodePrepResult, PrepDefinition, PrepOptions } from "./types.ts"; // install command templates for each package manager (version placeholder: {version}) const nodePackageManagers: Record = { npm: ["echo", "npm is already installed"], pnpm: ["npm", "install", "-g", "{version}"], yarn: ["npm", "install", "-g", "{version}"], bun: ["npm", "install", "-g", "{version}"], deno: ["sh", "-c", "curl -fsSL https://deno.land/install.sh | sh"], }; async function isCommandAvailable(command: string): Promise { const result = await spawn({ cmd: "which", args: [command], env: { PATH: process.env.PATH || "" }, }); return result.exitCode === 0; } interface PackageManagerSpec { name: NodePackageManager; installSpec: string; // e.g., "pnpm@8.15.0" (without hash suffix) } function getPackageManagerFromPackageJson(): PackageManagerSpec | null { const packageJsonPath = join(process.cwd(), "package.json"); try { const content = readFileSync(packageJsonPath, "utf-8"); const pkg = JSON.parse(content) as { packageManager?: string }; if (!pkg.packageManager) return null; // format: "pnpm@8.15.0" or "pnpm@8.15.0+sha512.abc123..." // strip the hash suffix (+sha256.xxx) as npm install doesn't understand it const withoutHash = pkg.packageManager.split("+")[0]; const name = withoutHash.split("@")[0]; if (isKeyOf(name, nodePackageManagers)) { return { name, installSpec: withoutHash }; } log.warning(`unknown packageManager in package.json: ${pkg.packageManager}`); return null; } catch { return null; } } async function installPackageManager( name: NodePackageManager, installSpec: string ): Promise { if (name === "npm") return null; // npm is always available log.info(`» installing ${installSpec}...`); const [cmd, ...templateArgs] = nodePackageManagers[name]; const args = templateArgs.map((arg) => (arg === "{version}" ? installSpec : arg)); const result = await spawn({ cmd, args, env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" }, onStderr: (chunk) => process.stderr.write(chunk), }); if (result.exitCode !== 0) { return result.stderr || `failed to install ${name}`; } // deno installs to $HOME/.deno/bin - add to PATH for subsequent commands if (name === "deno") { const denoPath = join(process.env.HOME || "", ".deno", "bin"); process.env.PATH = `${denoPath}:${process.env.PATH}`; } log.info(`» installed ${name}`); return null; } export const installNodeDependencies: PrepDefinition = { name: "installNodeDependencies", shouldRun: () => { const packageJsonPath = join(process.cwd(), "package.json"); return existsSync(packageJsonPath); }, run: async (options: PrepOptions): Promise => { // check packageManager field in package.json first (takes priority) const fromPackageJson = getPackageManagerFromPackageJson(); // detect from lockfile as fallback const detected = await detect({ cwd: process.cwd() }); // prefer package.json field, fall back to lockfile detection, default to npm const packageManager = fromPackageJson?.name || (detected?.name as NodePackageManager) || "npm"; const installSpec = fromPackageJson?.installSpec || packageManager; const agent = detected?.agent || packageManager; if (fromPackageJson) { log.info(`» using packageManager from package.json: ${fromPackageJson.installSpec}`); } else if (detected) { log.info(`» detected package manager: ${packageManager} (${agent})`); } else { log.info(`» no package manager detected, defaulting to npm`); } // check if package manager is available, install if needed if (!(await isCommandAvailable(packageManager))) { // SECURITY: when shell is disabled, don't install package managers. // installPackageManager runs `npm install -g` or `curl | sh` (for deno), // both of which execute code. the package manager must already be available. if (options.ignoreScripts) { return { language: "node", packageManager, dependenciesInstalled: false, issues: [ `${packageManager} is not available and cannot be installed when shell is disabled (would execute code)`, ], }; } log.info(`» ${packageManager} not found, attempting to install...`); const installError = await installPackageManager(packageManager, installSpec); if (installError) { return { language: "node", packageManager, dependenciesInstalled: false, issues: [installError], }; } } // get the frozen install command (or fallback to regular install) const resolved = resolveCommand(agent, "frozen", []) || resolveCommand(agent, "install", []); if (!resolved) { return { language: "node", packageManager, dependenciesInstalled: false, issues: [`no install command found for ${agent}`], }; } // SECURITY: when shell is disabled, suppress lifecycle scripts to prevent // agents from injecting arbitrary code execution via package.json scripts if (options.ignoreScripts) { resolved.args.push("--ignore-scripts"); log.info("» --ignore-scripts enabled (shell disabled)"); } const fullCommand = `${resolved.command} ${resolved.args.join(" ")}`; log.info(`» running: ${fullCommand}`); const result = await spawn({ cmd: resolved.command, args: resolved.args, env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" }, }); const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); if (output) { log.startGroup(`${fullCommand} output`); log.info(output); log.endGroup(); } if (result.exitCode !== 0) { const errorMessage = output || `exited with code ${result.exitCode}`; return { language: "node", packageManager, dependenciesInstalled: false, issues: [`\`${fullCommand}\` failed:\n${errorMessage}`], }; } return { language: "node", packageManager, dependenciesInstalled: true, issues: [], }; }, };