diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index ecc77efc65..51f9f904b8 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -181,6 +181,7 @@ All changes included in 1.9: - ([#4426](https://github.com/quarto-dev/quarto-cli/issues/4426)): New `quarto install verapdf` command installs [veraPDF](https://verapdf.org/) for PDF/A and PDF/UA validation. When verapdf is available, PDFs created with the `pdf-standard` option are automatically validated for compliance. Also supports `quarto uninstall verapdf`, `quarto update verapdf`, and `quarto tools`. - ([#11877](https://github.com/quarto-dev/quarto-cli/issues/11877), [#10961](https://github.com/quarto-dev/quarto-cli/issues/10961), [#6821](https://github.com/quarto-dev/quarto-cli/issues/6821), [#13704](https://github.com/quarto-dev/quarto-cli/issues/13704)): New `quarto install chrome-headless-shell` command downloads [Chrome Headless Shell](https://developer.chrome.com/blog/chrome-headless-shell) from Google's Chrome for Testing API. This is the recommended headless browser for diagram rendering (Mermaid, Graphviz) to non-HTML formats. Smaller and lighter than full Chrome, with fewer system dependencies. +- ([#12124](https://github.com/quarto-dev/quarto-cli/issues/12124)): Support `quarto install tinytex` on ARM64 Linux and adopt new TinyTeX naming scheme with `.tar.xz` compression. ### `preview` diff --git a/src/core/zip.ts b/src/core/zip.ts index b9bc8170c9..8ed23ddde5 100644 --- a/src/core/zip.ts +++ b/src/core/zip.ts @@ -12,7 +12,21 @@ import { safeWindowsExec } from "./windows.ts"; export function unzip(file: string, dir?: string) { if (!dir) dir = dirname(file); - if (file.endsWith("zip")) { + if (isWindows && file.endsWith(".exe")) { + // Self-extracting 7z archive (e.g., TinyTeX-windows.exe) + return safeWindowsExec( + file, + ["-y"], + (cmd: string[]) => { + return execProcess({ + cmd: cmd[0], + args: cmd.slice(1), + cwd: dir, + stdout: "piped", + }); + }, + ); + } else if (file.endsWith("zip")) { // It's a zip file if (isWindows) { const args = [ @@ -51,7 +65,7 @@ export function unzip(file: string, dir?: string) { // Otherwise fall back to "tar" in PATH } return execProcess( - { cmd: tarCmd, args: ["xfz", file], cwd: dir, stdout: "piped" }, + { cmd: tarCmd, args: ["xf", file], cwd: dir, stdout: "piped" }, ); } } diff --git a/src/tools/impl/tinytex.ts b/src/tools/impl/tinytex.ts index ec63840102..cb07917406 100644 --- a/src/tools/impl/tinytex.ts +++ b/src/tools/impl/tinytex.ts @@ -33,6 +33,7 @@ import { suggestUserBinPaths } from "../../core/path.ts"; import { ensureDirSync, walkSync } from "../../deno_ral/fs.ts"; import { + arch, isLinux, isMac, isWindows, @@ -65,15 +66,6 @@ export const tinyTexInstallable: InstallableTool = { }, os: ["darwin"], message: "The directory /usr/local/bin is not writable.", - }, { - check: () => { - // Can't be a linux non-x86 platform - const needsSource = needsSourceInstall(); - return Promise.resolve(!needsSource); - }, - os: ["linux"], - message: - "This platform doesn't support installation at this time. Please install manually instead. See https://yihui.org/tinytex/#installation.", }], installed, installDir, @@ -161,17 +153,17 @@ async function preparePackage( const version = pkgInfo.version; // target package information - const pkgName = tinyTexPkgName(kPackageMaximal, version); - const filePath = join(context.workingDir, pkgName); - - // Download the package - const url = tinyTexUrl(pkgName, pkgInfo); - if (url) { - // Download the package - await context.download(`TinyTex ${version}`, url, filePath); + const candidates = tinyTexPkgName(kPackageMaximal, version); + const result = tinyTexUrl(candidates, pkgInfo); + if (result) { + const filePath = join(context.workingDir, result.name); + await context.download(`TinyTex ${version}`, result.url, filePath); return { filePath, version }; } else { - context.error("Couldn't determine what URL to use to download"); + context.error( + `Couldn't determine what URL to use to download TinyTeX. ` + + `Tried: ${candidates.join(", ")}`, + ); return Promise.reject(); } } @@ -502,22 +494,55 @@ async function textLiveRepo() { return autoUrl; } -function tinyTexPkgName(base?: string, ver?: string) { - const ext = isWindows ? "zip" : isLinux ? "tar.gz" : "tgz"; +export function tinyTexPkgName( + base?: string, + ver?: string, + options?: { os?: string; arch?: string }, +): string[] { + const effectiveOs = options?.os ?? + (isWindows ? "windows" : isLinux ? "linux" : "darwin"); + const effectiveArch = options?.arch ?? arch; base = base || "TinyTeX"; - if (ver) { - return `${base}-${ver}.${ext}`; + + if (!ver) { + const ext = effectiveOs === "windows" + ? "zip" + : effectiveOs === "linux" + ? "tar.gz" + : "tgz"; + return [`${base}.${ext}`]; + } + + const candidates: string[] = []; + + if (effectiveOs === "windows") { + candidates.push(`${base}-windows-${ver}.exe`); + candidates.push(`${base}-${ver}.zip`); + } else if (effectiveOs === "linux") { + if (effectiveArch === "aarch64") { + candidates.push(`${base}-linux-arm64-${ver}.tar.xz`); + candidates.push(`${base}-arm64-${ver}.tar.gz`); + } else { + candidates.push(`${base}-linux-x86_64-${ver}.tar.xz`); + candidates.push(`${base}-${ver}.tar.gz`); + } } else { - return `${base}.${ext}`; + candidates.push(`${base}-darwin-${ver}.tar.xz`); + candidates.push(`${base}-${ver}.tgz`); } + + return candidates; } -function tinyTexUrl(pkg: string, remotePkgInfo: RemotePackageInfo) { - const asset = remotePkgInfo.assets.find((asset) => { - return asset.name === pkg; - }); - return asset?.url; +function tinyTexUrl(candidates: string[], remotePkgInfo: RemotePackageInfo) { + for (const pkg of candidates) { + const asset = remotePkgInfo.assets.find((asset) => asset.name === pkg); + if (asset) { + return { url: asset.url, name: pkg }; + } + } + return undefined; } async function remotePackageInfo(): Promise { @@ -537,14 +562,6 @@ async function isWritable(path: string) { return status.state === "granted"; } -function needsSourceInstall() { - if (isLinux && Deno.build.arch !== "x86_64") { - return true; - } else { - return false; - } -} - async function isTinyTex() { const root = await texLiveRoot(); if (root) { diff --git a/tests/unit/tools/tinytex.test.ts b/tests/unit/tools/tinytex.test.ts new file mode 100644 index 0000000000..5522c1bc7d --- /dev/null +++ b/tests/unit/tools/tinytex.test.ts @@ -0,0 +1,160 @@ +/* + * tinytex.test.ts + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../../test.ts"; +import { assert, assertEquals } from "testing/asserts"; +import { tinyTexPkgName } from "../../../src/tools/impl/tinytex.ts"; +import { getLatestRelease } from "../../../src/tools/github.ts"; +import { GitHubRelease } from "../../../src/tools/types.ts"; + +// ---- Pure logic tests for tinyTexPkgName ---- + +unitTest("tinyTexPkgName - Linux aarch64 with version", async () => { + assertEquals( + tinyTexPkgName("TinyTeX", "v2026.04", { os: "linux", arch: "aarch64" }), + [ + "TinyTeX-linux-arm64-v2026.04.tar.xz", + "TinyTeX-arm64-v2026.04.tar.gz", + ], + ); +}); + +unitTest("tinyTexPkgName - Linux x86_64 with version", async () => { + assertEquals( + tinyTexPkgName("TinyTeX", "v2026.04", { os: "linux", arch: "x86_64" }), + [ + "TinyTeX-linux-x86_64-v2026.04.tar.xz", + "TinyTeX-v2026.04.tar.gz", + ], + ); +}); + +unitTest("tinyTexPkgName - macOS with version", async () => { + assertEquals( + tinyTexPkgName("TinyTeX", "v2026.04", { os: "darwin", arch: "aarch64" }), + [ + "TinyTeX-darwin-v2026.04.tar.xz", + "TinyTeX-v2026.04.tgz", + ], + ); +}); + +unitTest("tinyTexPkgName - Windows with version", async () => { + assertEquals( + tinyTexPkgName("TinyTeX", "v2026.04", { os: "windows", arch: "x86_64" }), + [ + "TinyTeX-windows-v2026.04.exe", + "TinyTeX-v2026.04.zip", + ], + ); +}); + +unitTest("tinyTexPkgName - versionless Linux aarch64", async () => { + assertEquals( + tinyTexPkgName("TinyTeX", undefined, { os: "linux", arch: "aarch64" }), + ["TinyTeX.tar.gz"], + ); +}); + +unitTest("tinyTexPkgName - TinyTeX-1 ARM64 Linux", async () => { + assertEquals( + tinyTexPkgName("TinyTeX-1", "v2026.04", { + os: "linux", + arch: "aarch64", + }), + [ + "TinyTeX-1-linux-arm64-v2026.04.tar.xz", + "TinyTeX-1-arm64-v2026.04.tar.gz", + ], + ); +}); + +unitTest("tinyTexPkgName - default base", async () => { + assertEquals( + tinyTexPkgName(undefined, "v2026.04", { os: "linux", arch: "x86_64" }), + [ + "TinyTeX-linux-x86_64-v2026.04.tar.xz", + "TinyTeX-v2026.04.tar.gz", + ], + ); +}); + +// ---- Asset-existence tests (network, verify against latest release) ---- + +const kTinyTexRepo = "rstudio/tinytex-releases"; + +let cachedRelease: GitHubRelease | undefined; +async function getRelease() { + if (!cachedRelease) { + cachedRelease = await getLatestRelease(kTinyTexRepo); + } + return cachedRelease; +} + +function assertAssetExists( + candidates: string[], + assetNames: string[], + label: string, +) { + const found = candidates.some((c) => assetNames.includes(c)); + assert( + found, + `No matching asset for ${label}. Candidates: ${candidates.join(", ")}. ` + + `Available TinyTeX assets: ${assetNames.filter((a) => a.startsWith("TinyTeX")).join(", ")}`, + ); +} + +unitTest( + "tinyTexPkgName - Linux x86_64 candidates match latest release", + async () => { + const release = await getRelease(); + const assetNames = release.assets.map((a) => a.name); + const candidates = tinyTexPkgName("TinyTeX", release.tag_name, { + os: "linux", + arch: "x86_64", + }); + assertAssetExists(candidates, assetNames, "Linux x86_64"); + }, +); + +unitTest( + "tinyTexPkgName - Linux aarch64 candidates match latest release", + async () => { + const release = await getRelease(); + const assetNames = release.assets.map((a) => a.name); + const candidates = tinyTexPkgName("TinyTeX", release.tag_name, { + os: "linux", + arch: "aarch64", + }); + assertAssetExists(candidates, assetNames, "Linux aarch64"); + }, +); + +unitTest( + "tinyTexPkgName - macOS candidates match latest release", + async () => { + const release = await getRelease(); + const assetNames = release.assets.map((a) => a.name); + const candidates = tinyTexPkgName("TinyTeX", release.tag_name, { + os: "darwin", + arch: "aarch64", + }); + assertAssetExists(candidates, assetNames, "macOS"); + }, +); + +unitTest( + "tinyTexPkgName - Windows candidates match latest release", + async () => { + const release = await getRelease(); + const assetNames = release.assets.map((a) => a.name); + const candidates = tinyTexPkgName("TinyTeX", release.tag_name, { + os: "windows", + arch: "x86_64", + }); + assertAssetExists(candidates, assetNames, "Windows"); + }, +);