Ctrl K
ComunidadGuías…

utils/packageManager.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { existsSync } from "node:fs";
import { mkdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { isValid, satisfies } from "verkit";
import { log } from "./cli.ts";
import { resolveNpm } from "./install.ts";
import { buildSandboxedCommand, prependSandboxedPath } from "./sandbox.ts";
import { filterEnvForUntrustedCode } from "./secrets.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: isValid(version),
    source: "packageManager",
  };
}

function parseDevEnginesField(
  field: NonNullable<NonNullable<PackageJson["devEngines"]>["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: isValid(version),
    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<PackageManagerSpec | null> {
  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 && 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<CorepackResult> {
  const sandboxed = buildSandboxedCommand({
    cmd: "corepack",
    args,
    env: { ...filterEnvForUntrustedCode(), COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
    shimWritable: args[0] === "enable",
  });
  const result = await spawn({
    ...sandboxed,
    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<string | null> {
  const result = await spawn(
    buildSandboxedCommand({ cmd: name, args: ["--version"], env: filterEnvForUntrustedCode() })
  );
  if (result.exitCode !== 0) return null;
  return result.stdout.trim();
}

/** the per-run directory the corepack shim is installed into. deliberately
 * NOT the node bin dir: that's npm's `-g` target, and a corepack shim sitting
 * there makes a customer setup script's `npm i -g pnpm` abort with EEXIST
 * (npm refuses to clobber a binary it doesn't own). lives under the run
 * tmpdir so it's cleaned up with everything else. */
export function packageManagerBinDir(tmpdir: string): string {
  return join(tmpdir, "pm-bin");
}

export interface EnsurePackageManagerParams {
  spec: PackageManagerSpec;
  /** directory to install the corepack shim into (see `packageManagerBinDir`).
   * prepended to PATH so the pinned binary resolves by name. */
  binDir: string;
}

/**
 * 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).
 *
 * the corepack shim is installed into `params.binDir` (prepended to PATH),
 * not the node bin dir, so a later `npm i -g pnpm` in a setup hook can't
 * collide with it. our dir wins the PATH lookup, so the pinned version is
 * also what resolves even if that `npm i -g` succeeds into the node bin dir.
 *
 * 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(params: EnsurePackageManagerParams): Promise<boolean> {
  const spec = params.spec;
  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 (shim dir: ${params.binDir})`
  );

  await mkdir(params.binDir, { recursive: true });
  const enable = await runCorepack(["enable", "--install-directory", params.binDir, spec.name]);
  if (enable.exitCode !== 0) {
    log.warning(
      `» corepack enable failed (exit ${enable.exitCode}); leaving ${spec.name} from PATH. stderr: ${enable.stderr.trim() || "(empty)"}`
    );
    return false;
  }

  // shim dir first so `pnpm` resolves to the pinned binary and shadows any
  // `pnpm` a later `npm i -g pnpm` drops into the node bin dir.
  prependSandboxedPath(params.binDir);

  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;
}

/** `SupportedPackageManager` plus deno, which corepack never manages. */
export type ProvisionablePackageManager = SupportedPackageManager | "deno";

async function isCommandAvailable(command: string): Promise<boolean> {
  const result = await spawn(
    buildSandboxedCommand({ cmd: "which", args: [command], env: filterEnvForUntrustedCode() })
  );
  return result.exitCode === 0;
}

/**
 * Install a manager corepack doesn't ship a shim for. pnpm and yarn go through
 * `ensurePackageManager`; bun and deno fall through to here.
 */
async function installFallback(
  name: ProvisionablePackageManager,
  installSpec: string
): Promise<string | null> {
  if (name === "npm") return null;
  log.info(`» installing ${installSpec} via npm install -g (corepack does not manage ${name})`);
  const npm = resolveNpm();
  const args =
    name === "deno"
      ? ["-c", "curl -fsSL https://deno.land/install.sh | sh"]
      : [...npm.args, "install", "-g", installSpec];
  const cmd = name === "deno" ? "sh" : npm.command;
  const result = await spawn({
    ...buildSandboxedCommand({ cmd, args, env: filterEnvForUntrustedCode() }),
    onStderr: (chunk) => process.stderr.write(chunk),
  });
  if (result.exitCode !== 0) return result.stderr || `failed to install ${name}`;
  if (name === "deno") {
    prependSandboxedPath(join(process.env.HOME || "", ".deno", "bin"));
  }
  log.info(`» installed ${name}`);
  return null;
}

/**
 * Put the project's package manager on PATH, by whichever route provisions it —
 * corepack for pnpm/yarn, `npm i -g` for bun, the install script for deno.
 * Returns an error string on failure, else null.
 *
 * Callers must gate this on shell being enabled; provisioning executes code.
 *
 * Idempotent by construction (`isCommandAvailable` short-circuits and
 * `ensurePackageManager` caches on a `--version` match), which is what lets
 * `main.ts` call it BEFORE the `setup` lifecycle hook and the prep phase call it
 * again later for the cost of one `which`. That ordering is the point: a `setup`
 * hook running `bun install` used to die with `bun: command not found` 0.8s
 * before Pullfrog installed bun itself, because the pre-hook site only ever
 * asked corepack — which ignores bun and deno entirely. See #1121.
 */
export async function provisionPackageManager(params: {
  name: ProvisionablePackageManager;
  declared: PackageManagerSpec | null;
  binDir: string;
}): Promise<string | null> {
  if (await isCommandAvailable(params.name)) {
    // already on PATH, but possibly the wrong version.
    if (params.declared) {
      await ensurePackageManager({ spec: params.declared, binDir: params.binDir });
    }
    return null;
  }
  if (params.declared) {
    const activated = await ensurePackageManager({ spec: params.declared, binDir: params.binDir });
    if (activated) return null;
  }
  const spec = params.declared ? `${params.declared.name}@${params.declared.version}` : params.name;
  return installFallback(params.name, spec);
}