#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process'
import { createHash, randomBytes } from 'node:crypto'
import { existsSync } from 'node:fs'
import {
chmod,
mkdir,
mkdtemp,
readFile,
rename,
rm,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..')
const EXTRA_DIR = path.join(REPO_ROOT, 'extra')
const LOCK_PATH = path.join(SCRIPT_DIR, 'engine.lock.json')
const DEFAULT_REPO = 'motrixapp/aria2'
const ENGINE = 'aria2'
const GITHUB = 'https://github.com'
export const EXIT_OK = 0
export const EXIT_FAILURE = 1
export const EXIT_USAGE = 2
export const EXIT_UNSUPPORTED_HOST = 3
const DEFAULT_TIMEOUT_MS = 30_000
const DEFAULT_RETRIES = 3
const DEFAULT_BACKOFF_MS = 500
function resolveTimeoutMs() {
const raw = Number(process.env.MOTRIX_ENGINE_FETCH_TIMEOUT_MS)
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS
}
const DESKTOP_TARGETS = [
{ key: 'darwin-arm64', os: 'darwin', releaseArch: 'arm64', ext: 'tar.gz' },
{ key: 'darwin-x64', os: 'darwin', releaseArch: 'x64', ext: 'tar.gz' },
{ key: 'win32-arm64', os: 'win32', releaseArch: 'arm64', ext: 'zip' },
{ key: 'win32-x64', os: 'win32', releaseArch: 'x64', ext: 'zip' },
{ key: 'win32-ia32', os: 'win32', releaseArch: 'ia32', ext: 'zip' },
{ key: 'linux-x64', os: 'linux', releaseArch: 'x64', ext: 'tar.gz' },
{ key: 'linux-arm64', os: 'linux', releaseArch: 'arm64', ext: 'tar.gz' },
{ key: 'linux-arm', os: 'linux', releaseArch: 'armv7l', ext: 'tar.gz' },
]
export function binaryName(platform) {
return platform === 'win32' ? 'aria2c.exe' : 'aria2c'
}
export function bundledPath(extraDir, platform, arch) {
return path.join(extraDir, platform, arch, binaryName(platform))
}
export function resolveHostKey(platform, arch) {
return `${platform}-${arch}`
}
export function sha256Hex(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
export function verifyDigest(bytes, expected) {
return sha256Hex(bytes) === String(expected).toLowerCase()
}
export function parseSha256Sums(text) {
const map = new Map()
for (const line of text.split('\n')) {
const match = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/)
if (match) map.set(match[2], match[1].toLowerCase())
}
return map
}
export function parseArgs(argv) {
const args = {
writeLock: false,
tag: undefined,
all: false,
platform: undefined,
arch: undefined,
force: false,
}
for (let i = 0; i < argv.length; i++) {
const token = argv[i]
let flag = token
let inlineValue
const eq = token.startsWith('--') ? token.indexOf('=') : -1
if (eq !== -1) {
flag = token.slice(0, eq)
inlineValue = token.slice(eq + 1)
}
const readValue = () => {
if (inlineValue !== undefined) return inlineValue
const next = argv[i + 1]
if (next === undefined || next.startsWith('--')) {
throw new Error(`flag ${flag} requires a value`)
}
i += 1
return next
}
switch (flag) {
case '--write-lock':
args.writeLock = true
break
case '--all':
args.all = true
break
case '--force':
args.force = true
break
case '--tag':
args.tag = readValue()
break
case '--platform':
args.platform = readValue()
break
case '--arch':
args.arch = readValue()
break
default:
throw new Error(`unknown flag: ${token}`)
}
}
return args
}
export function selectKeys(lock, args, host) {
const available = Object.keys(lock.assets)
if (args.all) return available
const requireKey = (key) => {
if (!lock.assets[key]) {
throw new Error(`no prebuilt aria2 binary for ${key} in the lockfile`)
}
return [key]
}
if (args.platform && args.arch) {
return requireKey(`${args.platform}-${args.arch}`)
}
if (args.platform) {
const prefix = `${args.platform}-`
const keys = available.filter((key) => key.startsWith(prefix))
if (keys.length === 0) {
throw new Error(`no aria2 assets for platform ${args.platform}`)
}
return keys
}
if (args.arch) {
return requireKey(`${host.platform}-${args.arch}`)
}
return requireKey(resolveHostKey(host.platform, host.arch))
}
export async function fetchWithRetry(url, io = {}) {
const fetchFn = io.fetch ?? fetch
const timeoutMs = io.timeoutMs ?? resolveTimeoutMs()
const retries = io.retries ?? DEFAULT_RETRIES
const backoffMs = io.backoffMs ?? DEFAULT_BACKOFF_MS
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)))
let lastError
for (let attempt = 1; attempt <= retries; attempt += 1) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetchFn(url, {
redirect: 'follow',
signal: controller.signal,
})
if (res.ok) return Buffer.from(await res.arrayBuffer())
lastError = new Error(`HTTP ${res.status} ${res.statusText}`)
if (res.status < 500 && res.status !== 429) break
} catch (err) {
lastError = err
} finally {
clearTimeout(timer)
}
if (attempt < retries) await sleep(attempt * backoffMs)
}
throw new Error(
`GET ${url} failed after ${retries} attempt(s): ` +
`${lastError?.message ?? lastError}`
)
}
function downloadBytes(url) {
return fetchWithRetry(url)
}
let unzipAvailable
function hasUnzip() {
if (unzipAvailable === undefined) {
unzipAvailable = !spawnSync('unzip', ['-v'], { stdio: 'ignore' }).error
}
return unzipAvailable
}
export function resolveExtractCommand(archivePath, destDir, opts = {}) {
if (archivePath.toLowerCase().endsWith('.zip')) {
if (opts.unzipAvailable) {
return { command: 'unzip', args: ['-o', archivePath, '-d', destDir] }
}
return { command: 'tar', args: ['-xf', archivePath, '-C', destDir] }
}
return { command: 'tar', args: ['-xzf', archivePath, '-C', destDir] }
}
export function extractArchive(archivePath, destDir, io = {}) {
const spawnFn = io.spawn ?? spawn
const unzip = io.unzipAvailable ?? hasUnzip()
const { command, args } = resolveExtractCommand(archivePath, destDir, {
unzipAvailable: unzip,
})
return new Promise((resolve, reject) => {
const child = spawnFn(command, args, {
stdio: ['ignore', 'ignore', 'pipe'],
})
let stderr = ''
child.stderr.on('data', (chunk) => {
stderr += chunk
})
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) resolve()
else reject(new Error(`${command} exited ${code}: ${stderr.trim()}`))
})
})
}
async function readLockFile() {
if (!existsSync(LOCK_PATH)) return null
return JSON.parse(await readFile(LOCK_PATH, 'utf8'))
}
async function writeLockFileAtomic(lock) {
const json = `${JSON.stringify(lock, null, 2)}\n`
const staging = `${LOCK_PATH}.tmp-${randomBytes(6).toString('hex')}`
await writeFile(staging, json)
await rename(staging, LOCK_PATH)
}
export const defaultDeps = {
download: downloadBytes,
extract: extractArchive,
readFile: (p) => readFile(p),
writeFile: (p, data) => writeFile(p, data),
rename: (from, to) => rename(from, to),
chmod: (p, mode) => chmod(p, mode),
mkdir: (p) => mkdir(p, { recursive: true }),
mkdtemp: (prefix) => mkdtemp(path.join(tmpdir(), prefix)),
rm: (p) => rm(p, { recursive: true, force: true }),
fileExists: (p) => existsSync(p),
randomId: () => randomBytes(6).toString('hex'),
readLock: readLockFile,
writeLockFile: writeLockFileAtomic,
host: () => ({ platform: process.platform, arch: process.arch }),
extraDir: () => EXTRA_DIR,
log: (...parts) => console.log(...parts),
logError: (...parts) => console.error(...parts),
}
export async function installAsset(ctx, deps = defaultDeps) {
const { key, asset, repo, tag, extraDir, force } = ctx
const dash = key.indexOf('-')
const platform = key.slice(0, dash)
const arch = key.slice(dash + 1)
const target = bundledPath(extraDir, platform, arch)
const expectedBinary = String(asset.binarySha256).toLowerCase()
if (!force && deps.fileExists(target)) {
const current = sha256Hex(await deps.readFile(target))
if (current === expectedBinary) {
deps.log(`[fetch-engine] ${key}: ${asset.bin} up to date — skipping`)
return 'skipped'
}
}
const url = `${GITHUB}/${repo}/releases/download/${tag}/${asset.file}`
deps.log(`[fetch-engine] ${key}: downloading ${asset.file}`)
const archiveBytes = await deps.download(url)
if (!verifyDigest(archiveBytes, asset.archiveSha256)) {
throw new Error(
'archive digest mismatch\n' +
` file: ${asset.file}\n` +
` url: ${url}\n` +
` expected: ${String(asset.archiveSha256).toLowerCase()}\n` +
` actual: ${sha256Hex(archiveBytes)}`
)
}
const workDir = await deps.mkdtemp('aria2-fetch-')
let staging
try {
const archivePath = path.join(workDir, asset.file)
await deps.writeFile(archivePath, archiveBytes)
await deps.extract(archivePath, workDir)
const extractedBin = path.join(workDir, asset.bin)
if (!deps.fileExists(extractedBin)) {
throw new Error(`archive ${asset.file} did not contain ${asset.bin}`)
}
const binBytes = await deps.readFile(extractedBin)
const binDigest = sha256Hex(binBytes)
if (binDigest !== expectedBinary) {
throw new Error(
'binary digest mismatch\n' +
` binary: ${asset.bin} (from ${asset.file})\n` +
` expected: ${expectedBinary}\n` +
` actual: ${binDigest}`
)
}
const targetDir = path.dirname(target)
await deps.mkdir(targetDir)
staging = path.join(targetDir, `.${asset.bin}.tmp-${deps.randomId()}`)
await deps.writeFile(staging, binBytes)
if (platform !== 'win32') await deps.chmod(staging, 0o755)
await deps.rename(staging, target)
deps.log(`[fetch-engine] ${key}: installed ${asset.bin} -> ${target}`)
return 'installed'
} finally {
await deps.rm(workDir)
if (staging) await deps.rm(staging)
}
}
export async function runWriteLock(args, existing, deps = defaultDeps) {
const repo = existing?.repo ?? DEFAULT_REPO
const tag = args.tag ?? existing?.tag
if (!tag) {
deps.logError(
'[fetch-engine] --write-lock needs --tag when no lockfile exists'
)
return EXIT_USAGE
}
const version = tag.replace(/^v/, '')
const sumsUrl = `${GITHUB}/${repo}/releases/download/${tag}/SHA256SUMS`
deps.log(`[fetch-engine] write-lock: fetching SHA256SUMS for ${tag}`)
const sums = parseSha256Sums((await deps.download(sumsUrl)).toString('utf8'))
const assets = {}
for (const target of DESKTOP_TARGETS) {
const bin = binaryName(target.os)
const file = `aria2c-${version}-${target.os}-${target.releaseArch}.${target.ext}`
const archiveSha256 = sums.get(file)
if (!archiveSha256) {
deps.logError(`[fetch-engine] SHA256SUMS is missing ${file}`)
return EXIT_FAILURE
}
const url = `${GITHUB}/${repo}/releases/download/${tag}/${file}`
deps.log(`[fetch-engine] write-lock: ${target.key} <- ${file}`)
const archiveBytes = await deps.download(url)
if (!verifyDigest(archiveBytes, archiveSha256)) {
deps.logError(
`[fetch-engine] archive digest mismatch for ${file} — refusing lock`
)
return EXIT_FAILURE
}
const workDir = await deps.mkdtemp('aria2-lock-')
try {
const archivePath = path.join(workDir, file)
await deps.writeFile(archivePath, archiveBytes)
await deps.extract(archivePath, workDir)
const extractedBin = path.join(workDir, bin)
if (!deps.fileExists(extractedBin)) {
deps.logError(`[fetch-engine] ${file} did not contain ${bin}`)
return EXIT_FAILURE
}
const binarySha256 = sha256Hex(await deps.readFile(extractedBin))
assets[target.key] = { file, bin, archiveSha256, binarySha256 }
} finally {
await deps.rm(workDir)
}
}
for (const target of DESKTOP_TARGETS) {
const record = assets[target.key]
if (!record) {
deps.logError(`[fetch-engine] lock is missing desktop key ${target.key}`)
return EXIT_FAILURE
}
if (!record.file.includes(`-${version}-`)) {
deps.logError(
`[fetch-engine] ${target.key} file ${record.file} does not embed ` +
`version ${version}`
)
return EXIT_FAILURE
}
}
await deps.writeLockFile({
engine: existing?.engine ?? ENGINE,
repo,
tag,
version,
assets,
})
deps.log(
`[fetch-engine] wrote ${LOCK_PATH} for ${tag} ` +
`(${Object.keys(assets).length} assets)`
)
return EXIT_OK
}
export async function run(argv, deps = defaultDeps) {
let args
try {
args = parseArgs(argv)
} catch (err) {
deps.logError(`[fetch-engine] ${err.message}`)
return EXIT_USAGE
}
if (args.writeLock) {
return runWriteLock(args, await deps.readLock(), deps)
}
const lock = await deps.readLock()
if (!lock) {
deps.logError(
'[fetch-engine] no lockfile — run `node scripts/fetch-engine.mjs ' +
'--write-lock --tag <tag>` first'
)
return EXIT_FAILURE
}
const host = deps.host()
const hostMode = !args.all && !args.platform && !args.arch
if (hostMode && !lock.assets[resolveHostKey(host.platform, host.arch)]) {
const hostKey = resolveHostKey(host.platform, host.arch)
if (DESKTOP_TARGETS.some((target) => target.key === hostKey)) {
deps.logError(
`[fetch-engine] supported desktop target ${hostKey} is missing from the lockfile; regenerate it from a complete aria2 release`
)
return EXIT_FAILURE
}
deps.log(
`[fetch-engine] no prebuilt aria2 binary for this host (${hostKey}) — ` +
'skipping (this platform is not packaged)'
)
return EXIT_UNSUPPORTED_HOST
}
let keys
try {
keys = selectKeys(lock, args, host)
} catch (err) {
deps.logError(`[fetch-engine] ${err.message}`)
return EXIT_USAGE
}
const extraDir = deps.extraDir()
let failed = false
for (const key of keys) {
try {
await installAsset(
{
key,
asset: lock.assets[key],
repo: lock.repo,
tag: lock.tag,
extraDir,
force: args.force,
},
deps
)
} catch (err) {
deps.logError(`[fetch-engine] ${key}: ${err.message}`)
failed = true
}
}
if (failed && hostMode) {
deps.logError(
'[fetch-engine] could not fetch the aria2 binary for this host.\n' +
' If you are offline or on a restricted network, set ' +
'MOTRIX_SKIP_ENGINE_FETCH=1 to skip this step during install, then run ' +
'`pnpm fetch:engine` later once connectivity is restored.'
)
}
return failed ? EXIT_FAILURE : EXIT_OK
}
const invokedAsMain =
process.argv[1] != null &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (invokedAsMain) {
run(process.argv.slice(2))
.then((code) => process.exit(code))
.catch((err) => {
console.error(`[fetch-engine] fatal: ${err?.stack ?? err}`)
process.exit(EXIT_FAILURE)
})
}