install-latex-engine.mjs
Rama main · réplica de lectura del repositorio original.
#!/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();
}