Ctrl K
ComunidadGuías…

prep/installNodeDependencies.ts

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

Archivo original ↗Preguntar a KiBot
import { existsSync } from "node:fs";
import { join } from "node:path";
import { detect } from "package-manager-detector";
import { resolveCommand } from "package-manager-detector/commands";
import { log } from "../utils/cli.ts";
import { provisionPackageManager, resolvePackageManagerSpec } from "../utils/packageManager.ts";
import { buildSandboxedCommand } from "../utils/sandbox.ts";
import { filterEnvForUntrustedCode } from "../utils/secrets.ts";
import { spawn } from "../utils/subprocess.ts";
import type { NodePackageManager, NodePrepResult, PrepDefinition, PrepOptions } from "./types.ts";

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

export const installNodeDependencies: PrepDefinition = {
  name: "installNodeDependencies",

  shouldRun: () => {
    const packageJsonPath = join(process.cwd(), "package.json");
    return existsSync(packageJsonPath);
  },

  run: async (options: PrepOptions): Promise<NodePrepResult> => {
    // prefer the project's declared spec (devEngines.packageManager wins over
    // packageManager). fall back to lockfile detection when nothing is declared.
    // restrict detect() to the lockfile strategy: `detected` here doubles as
    // the lockfile-presence gate below, and the default strategy set also
    // returns positives off `packageManager`/`devEngines` fields (which would
    // mask the very case we're trying to detect — declared manager but no
    // lockfile committed).
    const declared = await resolvePackageManagerSpec(process.cwd());
    const detected = await detect({ cwd: process.cwd(), strategies: ["lockfile"] });

    const packageManager: NodePackageManager =
      declared?.name ?? (detected?.name as NodePackageManager) ?? "npm";
    const agent = detected?.agent ?? packageManager;

    if (declared) {
      log.info(
        `» using ${packageManager}@${declared.version} from package.json (${declared.source})`
      );
    } else if (detected) {
      log.info(`» detected package manager: ${packageManager} (${agent})`);
    } else {
      log.info(`» no package manager declared, defaulting to npm`);
    }

    // provisioning: corepack for pnpm/yarn, legacy npm-install-g for bun/deno.
    // when shell is disabled we can't run installers (they execute code), so
    // we require the binary to already be on PATH.
    if (options.ignoreScripts) {
      if (!(await isCommandAvailable(packageManager))) {
        return {
          language: "node",
          packageManager,
          dependenciesInstalled: false,
          issues: [
            `${packageManager} is not available and cannot be installed when shell is disabled (would execute code)`,
          ],
        };
      }
    } else {
      // idempotent, and main.ts already ran this before the setup hook — so on
      // the common path this costs one `which`. see #1121.
      const installError = await provisionPackageManager({
        name: packageManager,
        declared,
        binDir: options.binDir,
      });
      if (installError) {
        return {
          language: "node",
          packageManager,
          dependenciesInstalled: false,
          issues: [installError],
        };
      }
    }

    // frozen-lockfile install only. eager prep is non-mutating by contract:
    // we run it before the agent starts and any artifact it leaves in the
    // tree (e.g. a generated `package-lock.json`) trips the dirty-tree
    // post-run gate and produces a spurious PR. `frozen` commands
    // (`npm ci`, `pnpm install --frozen-lockfile`, etc.) were assumed to
    // fail cleanly without a lockfile — that assumption is false for
    // pnpm 11.1.1 against a no-deps `package.json` (it silently writes an
    // empty `pnpm-lock.yaml` despite the flag). gate on `detect()` having
    // found a lockfile; it walks up the tree (so monorepo subpackages
    // resolve to the workspace-root lockfile) and recognizes every
    // manager's accepted lockfile variants (`bun.lockb` + `bun.lock`,
    // `npm-shrinkwrap.json` + `package-lock.json`, etc.). when none is
    // present, the project either has no installable dependencies or
    // opts into install via a `setup` lifecycle hook
    // (`action/utils/lifecycle.ts`); either way, eager prep should skip.
    if (!detected) {
      log.info(
        `» skipping ${packageManager} install: no lockfile found (would otherwise risk lockfile drift)`
      );
      return { language: "node", packageManager, dependenciesInstalled: false, issues: [] };
    }

    const resolved = resolveCommand(agent, "frozen", []);
    if (!resolved) {
      return {
        language: "node",
        packageManager,
        dependenciesInstalled: false,
        issues: [`no frozen-install command available 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}`);
    // the installer executes arbitrary repo postinstall scripts, so run it inside
    // the PID+mount-namespace sandbox: those scripts land in a fresh PID namespace
    // (cannot read the action process's /proc/<pid>/environ) and see only the
    // already-filtered env. no namespace available → run directly (there is no
    // agent shell to plant malicious scripts in that case either).
    const result = await spawn(
      buildSandboxedCommand({
        cmd: resolved.command,
        args: resolved.args,
        env: filterEnvForUntrustedCode(),
      })
    );

    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: [],
    };
  },
};