Ctrl K
ComunidadGuías…

install-latex-engine.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node

/**
 * install-latex-engine.mjs — One-command local LaTeX engine for the latex CV path (#5132)
 *
 * Usage:
 *   node install-latex-engine.mjs            # install tectonic into the repo root (no-op if tectonic is present)
 *   node install-latex-engine.mjs --check    # report the engine generate-latex.mjs would use; never downloads
 *   node install-latex-engine.mjs --force    # re-download even if tectonic is already available
 *   node install-latex-engine.mjs --json     # machine-readable result
 *
 * `latex` / `latex-tex` modes compile through generate-latex.mjs, which needs
 * tectonic or pdflatex. pdflatex means a full TeX distribution (1-5 GB, admin
 * installers, different per OS); tectonic is one binary that fetches only the
 * packages a document uses, is already the engine resolveLatexEngine() prefers,
 * and is the only one that builds the CJK template (#3553). So this installs
 * tectonic, next to the scripts, where resolveLatexEngine() looks first.
 *
 * Opt-in by design: nothing calls this on `npm install`. generate-latex.mjs
 * reports `installHint` when no engine is found, and the latex modes ask the
 * user before running it.
 *
 * Supply chain: the version is pinned and so is the SHA-256 of every asset
 * (the digests GitHub publishes on the release). A download that does not
 * match is discarded before anything is extracted. Bumping the version means
 * updating TECTONIC_VERSION and every digest together. The download itself is
 * bounded: it is aborted after DOWNLOAD_TIMEOUT_MS, and reading stops as soon
 * as it passes MAX_DOWNLOAD_BYTES, so a stalled or oversized response never
 * hangs the agent or fills the disk before the digest check can reject it.
 */

import { createHash } from 'crypto';
import { execFileSync } from 'child_process';
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, copyFileSync, chmodSync, rmSync, readdirSync, renameSync } from 'fs';
import { tmpdir } from 'os';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { isMainModule } from './lib/is-main-module.mjs';
import { validateFlags } from './lib/cli-flags.mjs';
import { resolveLatexEngine, localTectonicPath } from './generate-latex.mjs';

const ROOT = dirname(fileURLToPath(import.meta.url));

export const TECTONIC_VERSION = '0.17.0';

const RELEASE_BASE =
  `https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%40${TECTONIC_VERSION}`;

// `${process.platform}-${process.arch}` → release asset + pinned SHA-256.
// musl builds on Linux: statically linked, so they run on glibc and Alpine alike.
export const TECTONIC_ASSETS = {
  'win32-x64': {
    file: `tectonic-${TECTONIC_VERSION}-x86_64-pc-windows-msvc.zip`,
    sha256: 'f61ce51f0b0ade1015b7de7ef368541c5424e9756ecbd0d7af97d6d48030845f',
  },
  'darwin-x64': {
    file: `tectonic-${TECTONIC_VERSION}-x86_64-apple-darwin.tar.gz`,
    sha256: '7c90ef5b6ddb1eb1937e4337add5237b79338e4b9676459fa91187d24d6cdf80',
  },
  'darwin-arm64': {
    file: `tectonic-${TECTONIC_VERSION}-aarch64-apple-darwin.tar.gz`,
    sha256: 'a3f1cac7c5678f01661a92212f58480ae3b0634115d880dbc59e2953ded45667',
  },
  'linux-x64': {
    file: `tectonic-${TECTONIC_VERSION}-x86_64-unknown-linux-musl.tar.gz`,
    sha256: '8533d07f9ccbd7a65824b9e0459041bca34af1eb33daba48f59215593753a3b7',
  },
  'linux-arm64': {
    file: `tectonic-${TECTONIC_VERSION}-aarch64-unknown-linux-musl.tar.gz`,
    sha256: 'b10954a95404f3ab2328d2fa59a5ebab8e657f893fab096f98be8db7c0c979b8',
  },
};

// The largest pinned asset is ~22 MB; a response far past that is not the release.
export const MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024;
export const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;

const MANUAL_INSTALL_URL = 'https://tectonic-typesetting.github.io/book/latest/installation/';

/**
 * @param {string} platform - process.platform
 * @param {string} arch - process.arch
 * @returns {{file: string, sha256: string, url: string}|null} null when no prebuilt binary is pinned
 */
export function tectonicAsset(platform, arch) {
  const asset = TECTONIC_ASSETS[`${platform}-${arch}`];
  return asset ? { ...asset, url: `${RELEASE_BASE}/${asset.file}` } : null;
}

/**
 * @param {Buffer|Uint8Array} buf
 * @param {string} expected - hex SHA-256
 * @returns {boolean}
 */
export function verifySha256(buf, expected) {
  if (typeof expected !== 'string' || !/^[0-9a-f]{64}$/i.test(expected)) return false;
  return createHash('sha256').update(buf).digest('hex') === expected.toLowerCase();
}

// Windows' own bsdtar reads .zip; a GNU tar earlier on PATH (Git for Windows,
// MSYS) does not, so name the system one explicitly when it exists.
function tarBinary(platform) {
  if (platform === 'win32' && process.env.SystemRoot) {
    const systemTar = join(process.env.SystemRoot, 'System32', 'tar.exe');
    if (existsSync(systemTar)) return systemTar;
  }
  return 'tar';
}

/**
 * Fetch `url` into a Buffer, giving up after `timeoutMs` (covering the body as
 * well as the headers) and as soon as more than `maxBytes` have arrived.
 * Throws an Error whose message says which limit was hit.
 */
async function boundedDownload(fetchImpl, url, { timeoutMs, maxBytes }) {
  const controller = new AbortController();
  let timer;
  const timedOut = new Promise((_, reject) => {
    timer = setTimeout(() => {
      const err = new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`);
      controller.abort(err);
      reject(err);
    }, timeoutMs);
  });
  timedOut.catch(() => {});
  // Raced as well as passed as `signal`, so an implementation that ignores the
  // signal still cannot outlive the timeout.
  const bounded = (p) => Promise.race([p, timedOut]);
  const tooLarge = () => new Error(`response exceeds the ${Math.round(maxBytes / 1024 / 1024)} MB limit`);

  let reader;
  try {
    const res = await bounded(fetchImpl(url, { signal: controller.signal }));
    if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
    const declared = Number(res.headers?.get?.('content-length'));
    if (declared > maxBytes) throw tooLarge();
    if (!res.body?.getReader) throw new Error('response has no readable body');

    reader = res.body.getReader();
    const chunks = [];
    let total = 0;
    for (;;) {
      const { done, value } = await bounded(reader.read());
      if (done) break;
      total += value.byteLength;
      if (total > maxBytes) throw tooLarge();
      chunks.push(value);
    }
    reader = null;
    return Buffer.concat(chunks, total);
  } catch (err) {
    controller.abort(err);
    throw err;
  } finally {
    clearTimeout(timer);
    if (reader) reader.cancel().catch(() => {});
  }
}

function binaryWorks(bin) {
  try {
    return execFileSync(bin, ['--version'], { stdio: 'pipe', encoding: 'utf-8' }).trim();
  } catch {
    return null;
  }
}

/**
 * Download, verify and place the pinned tectonic binary.
 *
 * @param {object} [opts]
 * @param {string} [opts.root] - directory the binary is installed into (the repo root)
 * @param {string} [opts.platform]
 * @param {string} [opts.arch]
 * @param {boolean} [opts.force] - reinstall even if tectonic is already available
 * @param {number} [opts.timeoutMs] - abort the download after this long
 * @param {number} [opts.maxBytes] - stop reading the download past this size
 * @param {typeof fetch} [opts.fetchImpl] - injectable for tests
 * @param {() => string|null} [opts.detectEngine] - injectable for tests
 * @param {(asset: object) => void} [opts.onDownload] - called once, right before the download starts
 * @param {typeof tectonicAsset} [opts.resolveAsset] - injectable for tests
 * @param {(bin: string) => string|null} [opts.probe] - runs the staged binary, returns its version or null; injectable for tests
 * @returns {Promise<{status: string, engine?: string, path?: string, version?: string, error?: string}>}
 *   status: 'installed' | 'already-available' | 'unsupported-platform' | 'checksum-mismatch' | 'failed'
 */
export async function installTectonic({
  root = ROOT,
  platform = process.platform,
  arch = process.arch,
  force = false,
  timeoutMs = DOWNLOAD_TIMEOUT_MS,
  maxBytes = MAX_DOWNLOAD_BYTES,
  fetchImpl = globalThis.fetch,
  detectEngine = resolveLatexEngine,
  onDownload = () => {},
  resolveAsset = tectonicAsset,
  probe = binaryWorks,
} = {}) {
  // Only tectonic counts: a pdflatex-only host still needs it for the CJK
  // template, which is where generate-latex.mjs sends those users.
  if (!force) {
    const engine = detectEngine();
    if (engine === 'tectonic') return { status: 'already-available', engine };
  }

  const asset = resolveAsset(platform, arch);
  if (!asset) {
    return {
      status: 'unsupported-platform',
      error: `No pinned tectonic build for ${platform}-${arch}. Install tectonic or pdflatex manually: ${MANUAL_INSTALL_URL}`,
    };
  }

  let buf;
  try {
    onDownload(asset);
    buf = await boundedDownload(fetchImpl, asset.url, { timeoutMs, maxBytes });
  } catch (err) {
    return { status: 'failed', error: `Download failed: ${err.message}` };
  }

  if (!verifySha256(buf, asset.sha256)) {
    return {
      status: 'checksum-mismatch',
      error: `SHA-256 of ${asset.file} does not match the pinned digest; nothing was installed.`,
    };
  }

  const target = localTectonicPath(root, platform);
  const work = mkdtempSync(join(tmpdir(), 'career-ops-tectonic-'));
  let staged = null;
  let version;
  try {
    const archive = join(work, asset.file);
    writeFileSync(archive, buf);
    const extractDir = join(work, 'x');
    mkdirSync(extractDir);
    const tarArgs = asset.file.endsWith('.zip') ? ['-xf', archive, '-C', extractDir] : ['-xzf', archive, '-C', extractDir];
    execFileSync(tarBinary(platform), tarArgs, { stdio: 'pipe' });

    const binName = platform === 'win32' ? 'tectonic.exe' : 'tectonic';
    if (!readdirSync(extractDir).includes(binName)) {
      return { status: 'failed', error: `${asset.file} did not contain ${binName}` };
    }
    // Stage beside the target and probe it there, so a --force reinstall that
    // is interrupted or produces a binary that won't run never replaces a
    // working one. Keep .exe last on Windows so the staged file still runs.
    staged = target.replace(/(\.exe)?$/, '.part$1');
    copyFileSync(join(extractDir, binName), staged);
    if (platform !== 'win32') chmodSync(staged, 0o755);
    version = probe(staged);
    if (!version) {
      return { status: 'failed', error: `Downloaded binary did not run; ${target} was left untouched.` };
    }
    renameSync(staged, target);
    staged = null;
  } catch (err) {
    return { status: 'failed', error: `Install failed: ${err.message}` };
  } finally {
    if (staged) rmSync(staged, { force: true });
    rmSync(work, { recursive: true, force: true });
  }

  return { status: 'installed', engine: 'tectonic', path: target, version };
}

// ── CLI flags + help ──────────────────────────────────────

const KNOWN_FLAGS = ['--check', '--force', '--json', '--help', '-h'];

const USAGE = `Usage:
  node install-latex-engine.mjs            # install tectonic ${TECTONIC_VERSION} into the repo root (no-op if tectonic is present)
  node install-latex-engine.mjs --check    # report the engine generate-latex.mjs would use; never downloads
  node install-latex-engine.mjs --force    # re-download even if tectonic is already available
  node install-latex-engine.mjs --json     # machine-readable result
  node install-latex-engine.mjs --help|-h  # print this usage block and exit

Downloads one pinned, SHA-256-verified binary from the official tectonic GitHub
release (~10-22 MB). Nothing about you is sent anywhere.`;

async function main() {
  const args = process.argv.slice(2);
  validateFlags(args, KNOWN_FLAGS, USAGE);
  const json = args.includes('--json');

  let result;
  if (args.includes('--check')) {
    const engine = resolveLatexEngine();
    const local = localTectonicPath();
    result = { status: engine ? 'available' : 'missing', engine, localBinary: existsSync(local) ? local : null };
    if (!engine) result.installHint = 'node install-latex-engine.mjs';
  } else {
    result = await installTectonic({
      force: args.includes('--force'),
      onDownload: (asset) => { if (!json) console.error(`Downloading ${asset.file}…`); },
    });
  }

  if (json) {
    console.log(JSON.stringify(result, null, 2));
  } else if (result.error) {
    console.error(result.error);
  } else if (result.status === 'installed') {
    console.log(`Installed ${result.version} → ${result.path}`);
  } else if (result.status === 'already-available') {
    console.log('tectonic is already available; nothing to do. Use --force to reinstall.');
  } else if (result.status === 'available') {
    console.log(`LaTeX engine: ${result.engine}${result.localBinary ? ` (${result.localBinary})` : ''}`);
  } else {
    console.log('No LaTeX engine found. Run: node install-latex-engine.mjs');
  }

  const ok = ['installed', 'already-available', 'available'].includes(result.status);
  process.exit(ok ? 0 : 1);
}

if (isMainModule(import.meta.url)) {
  main();
}