@uniweb/unipress 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,228 @@
1
+ // Resolve a usable Typst binary path — either a caller-supplied override,
2
+ // a previously cached download, or a fresh fetch from typst/typst's
3
+ // GitHub releases.
4
+ //
5
+ // Contract: `resolveTypstBinary()` returns an absolute path to an
6
+ // executable `typst` (`typst.exe` on Windows). It does NOT spawn `typst
7
+ // compile` — that's the sink's job. It DOES spawn `<path> --version`
8
+ // as a sanity check: if the binary won't run, we'd rather fail here with
9
+ // a clear error than later with a spawn-from-sink error.
10
+ //
11
+ // Cache layout (per plan §12.3):
12
+ // $UNIPRESS_CACHE_DIR/typst/<version>/typst
13
+ // or the platform-default cache dir when $UNIPRESS_CACHE_DIR is unset.
14
+
15
+ import { createHash } from 'node:crypto'
16
+ import { spawn } from 'node:child_process'
17
+ import { mkdir, rm, rename, readdir, chmod, stat } from 'node:fs/promises'
18
+ import { createWriteStream, existsSync } from 'node:fs'
19
+ import { join, dirname, resolve, isAbsolute } from 'node:path'
20
+ import { homedir, tmpdir } from 'node:os'
21
+ import { Readable } from 'node:stream'
22
+ import { pipeline } from 'node:stream/promises'
23
+ import {
24
+ TYPST_VERSION,
25
+ detectPlatform,
26
+ buildReleaseUrl,
27
+ getChecksum
28
+ } from './versions.js'
29
+ import { TypstBinaryError } from '../errors.js'
30
+
31
+ const EXE = process.platform === 'win32' ? 'typst.exe' : 'typst'
32
+
33
+ // Platform-default cache directory.
34
+ // UNIPRESS_CACHE_DIR .......... absolute path, wins over everything.
35
+ // XDG_CACHE_HOME/unipress ..... Linux + anyone who set it.
36
+ // ~/Library/Caches/unipress ... macOS default.
37
+ // %LOCALAPPDATA%/unipress ..... Windows default.
38
+ // ~/.cache/unipress ........... fallback.
39
+ export function getCacheDir() {
40
+ const override = process.env.UNIPRESS_CACHE_DIR
41
+ if (override) {
42
+ if (!isAbsolute(override)) {
43
+ throw new TypstBinaryError(
44
+ `UNIPRESS_CACHE_DIR must be an absolute path (got '${override}')`
45
+ )
46
+ }
47
+ return override
48
+ }
49
+ const xdg = process.env.XDG_CACHE_HOME
50
+ if (xdg) return join(xdg, 'unipress')
51
+ if (process.platform === 'darwin') {
52
+ return join(homedir(), 'Library', 'Caches', 'unipress')
53
+ }
54
+ if (process.platform === 'win32') {
55
+ const local = process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local')
56
+ return join(local, 'unipress', 'Cache')
57
+ }
58
+ return join(homedir(), '.cache', 'unipress')
59
+ }
60
+
61
+ export function getBinaryPath(version = TYPST_VERSION) {
62
+ return join(getCacheDir(), 'typst', version, EXE)
63
+ }
64
+
65
+ // Main entry point. Returns an absolute path to a working typst binary.
66
+ export async function resolveTypstBinary({ overridePath = null, version = TYPST_VERSION, onProgress = () => {} } = {}) {
67
+ if (overridePath) {
68
+ const absolute = resolve(overridePath)
69
+ if (!existsSync(absolute)) {
70
+ throw new TypstBinaryError(
71
+ `--typst-binary points at a file that does not exist: ${absolute}`
72
+ )
73
+ }
74
+ await sanityCheck(absolute, { label: 'override' })
75
+ onProgress(`using typst binary (override): ${absolute}`)
76
+ return absolute
77
+ }
78
+
79
+ const cached = getBinaryPath(version)
80
+ if (existsSync(cached)) {
81
+ await sanityCheck(cached, { label: 'cache' })
82
+ onProgress(`using cached typst ${version}: ${cached}`)
83
+ return cached
84
+ }
85
+
86
+ return downloadAndInstall({ version, onProgress })
87
+ }
88
+
89
+ async function downloadAndInstall({ version, onProgress }) {
90
+ const platform = detectPlatform()
91
+ if (!platform.triple) {
92
+ throw new TypstBinaryError(
93
+ `no prebuilt typst binary available for ${platform.key}\n` +
94
+ `hint: install typst yourself and pass --typst-binary <path>, or\n` +
95
+ ` set the UNIPRESS_CACHE_DIR so unipress can find an existing binary`
96
+ )
97
+ }
98
+
99
+ const checksum = getChecksum(platform.triple)
100
+ if (!checksum) {
101
+ throw new TypstBinaryError(
102
+ `no pinned SHA-256 for triple '${platform.triple}' at version ${version}\n` +
103
+ `hint: src/typst/versions.js is out of sync — see the bump procedure comment`
104
+ )
105
+ }
106
+
107
+ const url = buildReleaseUrl({ version, triple: platform.triple, ext: platform.ext })
108
+ const destDir = join(getCacheDir(), 'typst', version)
109
+ const finalPath = join(destDir, EXE)
110
+
111
+ onProgress(`downloading typst ${version} for ${platform.triple}...`)
112
+ await mkdir(destDir, { recursive: true })
113
+
114
+ const archivePath = join(destDir, `typst-${platform.triple}.${platform.ext}`)
115
+ await fetchTo(url, archivePath)
116
+
117
+ onProgress('verifying checksum...')
118
+ const actual = await sha256OfFile(archivePath)
119
+ if (actual !== checksum) {
120
+ await rm(archivePath, { force: true })
121
+ throw new TypstBinaryError(
122
+ `sha256 mismatch for typst ${version} (${platform.triple})\n` +
123
+ ` expected: ${checksum}\n` +
124
+ ` actual: ${actual}\n` +
125
+ `hint: the download may be corrupt or the pinned checksum is stale — retry, or report this`
126
+ )
127
+ }
128
+
129
+ onProgress('extracting...')
130
+ await extractArchive(archivePath, destDir)
131
+ await rm(archivePath, { force: true })
132
+
133
+ // Archive extracts into a typst-<triple>/ subdirectory containing the
134
+ // binary. Move the binary up one level and remove the leftover dir.
135
+ await liftBinary(destDir, platform.triple, finalPath)
136
+
137
+ if (process.platform !== 'win32') {
138
+ await chmod(finalPath, 0o755)
139
+ }
140
+
141
+ await sanityCheck(finalPath, { label: 'downloaded' })
142
+ onProgress(`installed typst ${version} at ${finalPath}`)
143
+ return finalPath
144
+ }
145
+
146
+ async function fetchTo(url, destPath) {
147
+ let response
148
+ try {
149
+ response = await fetch(url, { redirect: 'follow' })
150
+ } catch (err) {
151
+ throw new TypstBinaryError(
152
+ `failed to fetch ${url}\n` +
153
+ `cause: ${err.message}\n` +
154
+ `hint: check network access, or pre-populate the cache and retry`
155
+ )
156
+ }
157
+ if (!response.ok || !response.body) {
158
+ throw new TypstBinaryError(
159
+ `fetch failed: ${url}\n` +
160
+ `HTTP ${response.status} ${response.statusText}`
161
+ )
162
+ }
163
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(destPath))
164
+ }
165
+
166
+ async function sha256OfFile(path) {
167
+ const { createReadStream } = await import('node:fs')
168
+ const hash = createHash('sha256')
169
+ await pipeline(createReadStream(path), hash)
170
+ return hash.digest('hex')
171
+ }
172
+
173
+ async function extractArchive(archivePath, destDir) {
174
+ // `tar -xf` handles both tar.xz (via libarchive/GNU tar+xz) and .zip
175
+ // on macOS, Linux, and modern Windows (ships bsdtar since 2017).
176
+ await run('tar', ['-xf', archivePath, '-C', destDir], {
177
+ onError: (err) => new TypstBinaryError(
178
+ `failed to extract ${archivePath}\n` +
179
+ `cause: ${err.message}\n` +
180
+ `hint: is \`tar\` on PATH? (macOS/Linux ship it; Windows 10+ does too)`
181
+ )
182
+ })
183
+ }
184
+
185
+ async function liftBinary(destDir, triple, finalPath) {
186
+ const entries = await readdir(destDir, { withFileTypes: true })
187
+ const sub = entries.find(e => e.isDirectory() && e.name.startsWith('typst-'))
188
+ if (!sub) {
189
+ throw new TypstBinaryError(
190
+ `extracted archive has no typst-<triple>/ directory under ${destDir}`
191
+ )
192
+ }
193
+ const extractedBinary = join(destDir, sub.name, EXE)
194
+ if (!existsSync(extractedBinary)) {
195
+ throw new TypstBinaryError(
196
+ `expected ${extractedBinary} after extraction, but it is missing`
197
+ )
198
+ }
199
+ await rename(extractedBinary, finalPath)
200
+ await rm(join(destDir, sub.name), { recursive: true, force: true })
201
+ }
202
+
203
+ async function sanityCheck(binaryPath, { label }) {
204
+ try {
205
+ await run(binaryPath, ['--version'])
206
+ } catch (err) {
207
+ throw new TypstBinaryError(
208
+ `typst ${label} binary at ${binaryPath} failed \`--version\`\n` +
209
+ `cause: ${err.message}`
210
+ )
211
+ }
212
+ }
213
+
214
+ function run(cmd, args, { onError } = {}) {
215
+ return new Promise((resolveProm, reject) => {
216
+ const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] })
217
+ let stderr = ''
218
+ proc.stderr.on('data', chunk => { stderr += chunk.toString() })
219
+ proc.on('error', err => reject(onError ? onError(err) : err))
220
+ proc.on('close', code => {
221
+ if (code === 0) resolveProm()
222
+ else {
223
+ const err = new Error(`\`${cmd}\` exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`)
224
+ reject(onError ? onError(err) : err)
225
+ }
226
+ })
227
+ })
228
+ }
@@ -0,0 +1,65 @@
1
+ // Pinned Typst version + per-platform download metadata.
2
+ //
3
+ // Typst does NOT publish .sha256 sidecar files on its GitHub Releases
4
+ // (checked across 0.11-0.14). The plan originally assumed otherwise.
5
+ // Instead, GitHub's own release-assets API surfaces asset.digest for
6
+ // each uploaded artifact; we snapshot those SHA-256 digests here so the
7
+ // binary manager can verify downloads without a second fetch.
8
+ //
9
+ // --- Bumping to a new Typst version ---
10
+ // 1. Pick the new version number; update TYPST_VERSION below.
11
+ // 2. Fetch the release asset digests from GitHub:
12
+ // curl -sSL https://api.github.com/repos/typst/typst/releases/tags/v<VERSION> \
13
+ // | python3 -c "import json,sys; d=json.load(sys.stdin); \
14
+ // [print(a['name'], a['digest']) for a in d['assets']]"
15
+ // 3. Copy the per-platform digests into CHECKSUMS below. The asset names
16
+ // in GitHub's feed match the `{triple}.{ext}` suffixes here.
17
+ // 4. Smoke-test on at least one platform: delete the local cache
18
+ // ($UNIPRESS_CACHE_DIR/typst/<VERSION>) and run a compile that
19
+ // exercises the binary manager.
20
+ //
21
+ // Verified 2026-04-24 against
22
+ // https://api.github.com/repos/typst/typst/releases/tags/v0.14.2
23
+
24
+ export const TYPST_VERSION = '0.14.2'
25
+
26
+ // Maps (process.platform, process.arch) → { triple, ext }.
27
+ // Only the platforms we distribute unipress for. riscv64 / armv7 / etc.
28
+ // are technically downloadable from typst/typst but we don't test them,
29
+ // so we surface a clear "unsupported platform" error rather than trying
30
+ // to fetch something we can't smoke-test.
31
+ export const PLATFORM_TRIPLES = {
32
+ 'darwin-arm64': { triple: 'aarch64-apple-darwin', ext: 'tar.xz' },
33
+ 'darwin-x64': { triple: 'x86_64-apple-darwin', ext: 'tar.xz' },
34
+ 'linux-x64': { triple: 'x86_64-unknown-linux-musl', ext: 'tar.xz' },
35
+ 'linux-arm64': { triple: 'aarch64-unknown-linux-musl', ext: 'tar.xz' },
36
+ 'win32-x64': { triple: 'x86_64-pc-windows-msvc', ext: 'zip' },
37
+ 'win32-arm64': { triple: 'aarch64-pc-windows-msvc', ext: 'zip' }
38
+ }
39
+
40
+ // SHA-256 digests of each release archive. Keyed by triple so the
41
+ // binary manager can look up the expected hash after it's picked a
42
+ // triple for the host.
43
+ export const CHECKSUMS = {
44
+ 'aarch64-apple-darwin': '470aa49a2298d20b65c119a10e4ff8808550453e0cb4d85625b89caf0cedf048',
45
+ 'x86_64-apple-darwin': '4e91d8e1e33ab164f949c5762e01ee3faa585c8615a2a6bd5e3677fa8506b249',
46
+ 'x86_64-unknown-linux-musl': 'a6044cbad2a954deb921167e257e120ac0a16b20339ec01121194ff9d394996d',
47
+ 'aarch64-unknown-linux-musl': '491b101aa40a3a7ea82a3f8a6232cabb4e6a7e233810082e5ac812d43fdcd47a',
48
+ 'x86_64-pc-windows-msvc': '51353994ac83218c3497052e89b2c432c53b9d4439cdc1b361e2ea4798ebfc13',
49
+ 'aarch64-pc-windows-msvc': '1c4aaa0de000ab1787dda354c34f4fa1fe3c2525d3d038e692a3d7daa333d551'
50
+ }
51
+
52
+ export function detectPlatform() {
53
+ const key = `${process.platform}-${process.arch}`
54
+ const entry = PLATFORM_TRIPLES[key]
55
+ if (!entry) return { key, triple: null, ext: null }
56
+ return { key, ...entry }
57
+ }
58
+
59
+ export function buildReleaseUrl({ version = TYPST_VERSION, triple, ext } = {}) {
60
+ return `https://github.com/typst/typst/releases/download/v${version}/typst-${triple}.${ext}`
61
+ }
62
+
63
+ export function getChecksum(triple) {
64
+ return CHECKSUMS[triple] ?? null
65
+ }