import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import semver from "semver"; import { log } from "./cli.ts"; import { spawn } from "./subprocess.ts"; export type SupportedPackageManager = "npm" | "pnpm" | "yarn" | "bun"; const SUPPORTED_NAMES: readonly SupportedPackageManager[] = ["npm", "pnpm", "yarn", "bun"]; // corepack ships pnpm + yarn shims out of the box. it does not ship bun or // npm (npm comes with node). callers fall back to the legacy npm-install-g // path for managers outside this set. const COREPACK_MANAGED: readonly SupportedPackageManager[] = ["pnpm", "yarn"]; export interface PackageManagerSpec { name: SupportedPackageManager; /** * either a concrete semver (e.g. "11.1.1") or a range (e.g. "^11.0.0"). * `concrete` distinguishes — corepack only accepts concrete versions. */ version: string; concrete: boolean; /** which package.json field this came from */ source: "devEngines" | "packageManager"; } interface PackageJson { packageManager?: string; devEngines?: { packageManager?: { name?: string; version?: string; onFail?: string; }; }; } function isSupported(name: string): name is SupportedPackageManager { return (SUPPORTED_NAMES as readonly string[]).includes(name); } function parsePackageManagerField(value: string): PackageManagerSpec | null { // npm spec form is "name@version[+integrity]" — corepack adds the integrity // suffix; we strip it because it's not a semver. const withoutHash = value.split("+")[0]; const at = withoutHash.lastIndexOf("@"); if (at <= 0) return null; const name = withoutHash.slice(0, at); const version = withoutHash.slice(at + 1); if (!isSupported(name)) { log.warning(`» unknown packageManager in package.json: ${value}`); return null; } return { name, version, concrete: semver.valid(version) !== null, source: "packageManager", }; } function parseDevEnginesField( field: NonNullable["packageManager"]> ): PackageManagerSpec | null { if (!field.name || !field.version) return null; if (!isSupported(field.name)) { log.warning(`» unknown devEngines.packageManager.name in package.json: ${field.name}`); return null; } const version = field.version.trim(); return { name: field.name, version, concrete: semver.valid(version) !== null, source: "devEngines", }; } /** * resolve the project's intended package manager from package.json. precedence * matches pnpm 11+: `devEngines.packageManager` wins over `packageManager`. * when both are present, a concrete `packageManager` that satisfies a * `devEngines` range is preferred (we can pin it via corepack); otherwise * we warn on disagreement and stick with `devEngines`. */ export async function resolvePackageManagerSpec(cwd: string): Promise { const pkgPath = join(cwd, "package.json"); if (!existsSync(pkgPath)) return null; let pkg: PackageJson; try { pkg = JSON.parse(await readFile(pkgPath, "utf-8")) as PackageJson; } catch (err) { log.warning( `» failed to parse package.json for package manager resolution: ${err instanceof Error ? err.message : String(err)}` ); return null; } const devSpec = pkg.devEngines?.packageManager ? parseDevEnginesField(pkg.devEngines.packageManager) : null; const pmSpec = pkg.packageManager?.trim() ? parsePackageManagerField(pkg.packageManager.trim()) : null; if (!devSpec) return pmSpec; if (!pmSpec) return devSpec; if (devSpec.name !== pmSpec.name) { log.warning( `» devEngines.packageManager (${devSpec.name}) disagrees with packageManager (${pmSpec.name}); using devEngines per pnpm 11 precedence` ); return devSpec; } // same manager — try to land on a concrete version we can pin via corepack. if (devSpec.concrete) { if (pmSpec.concrete && devSpec.version !== pmSpec.version) { log.warning( `» devEngines.packageManager (${devSpec.version}) disagrees with packageManager (${pmSpec.version}); using devEngines per pnpm 11 precedence` ); } return devSpec; } if (pmSpec.concrete && semver.satisfies(pmSpec.version, devSpec.version)) { return pmSpec; } if (pmSpec.concrete) { log.warning( `» packageManager (${pmSpec.version}) does not satisfy devEngines range (${devSpec.version}); using devEngines` ); } return devSpec; } interface CorepackResult { exitCode: number; stderr: string; } async function runCorepack(args: string[]): Promise { const result = await spawn({ cmd: "corepack", args, env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "", COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", }, onStdout: (chunk) => process.stdout.write(chunk), onStderr: (chunk) => process.stderr.write(chunk), }); return { exitCode: result.exitCode, stderr: result.stderr }; } async function currentVersion(name: SupportedPackageManager): Promise { const result = await spawn({ cmd: name, args: ["--version"], env: { PATH: process.env.PATH || "" }, }); if (result.exitCode !== 0) return null; return result.stdout.trim(); } /** * ensure the requested package manager is on PATH at the declared version, * provisioning via corepack when applicable. returns true if PATH now * resolves to that version, false if we couldn't pin it (in which case * the caller should treat PATH as untrusted and may fall back to its * legacy install path). * * never throws: network failure, missing corepack, range-only versions — * all degrade to "log warning, return false". the existing PATH binary * still works; we just don't get our version guarantee. */ export async function ensurePackageManager(spec: PackageManagerSpec): Promise { if (spec.name === "npm") return true; if (!(COREPACK_MANAGED as readonly string[]).includes(spec.name)) { return false; } if (!spec.concrete) { log.warning( `» ${spec.name} ${spec.source} version is a range (${spec.version}); corepack requires a concrete pin. leaving PATH unchanged.` ); return false; } const existing = await currentVersion(spec.name); if (existing === spec.version) { log.info(`» ${spec.name}@${spec.version} already active`); return true; } log.info(`» corepack prepare ${spec.name}@${spec.version} --activate`); const enable = await runCorepack(["enable"]); if (enable.exitCode !== 0) { log.warning( `» corepack enable failed (exit ${enable.exitCode}); leaving ${spec.name} from PATH. stderr: ${enable.stderr.trim() || "(empty)"}` ); return false; } const prepare = await runCorepack(["prepare", `${spec.name}@${spec.version}`, "--activate"]); if (prepare.exitCode !== 0) { log.warning( `» corepack prepare ${spec.name}@${spec.version} failed (exit ${prepare.exitCode}); leaving ${spec.name} from PATH. stderr: ${prepare.stderr.trim() || "(empty)"}` ); return false; } const after = await currentVersion(spec.name); if (after !== spec.version) { log.warning( `» corepack activated ${spec.name}@${spec.version} but PATH still resolves to ${after ?? "(missing)"}; continuing anyway` ); } return true; }