blob: 3bd777a36e4135ba047f1d7420273c8330e2a6f3 [file]
#!/usr/bin/env node
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, '..');
const manifestPath = join(repoRoot, 'apps', 'desktop', 'bundled-tools.json');
const toolsDir = join(repoRoot, 'apps', 'desktop', 'resources', 'tools');
const DEFAULT_FETCH_TIMEOUT_MS = 300_000;
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
const officeCli = manifest.officecli;
const FETCH_TIMEOUT_MS = readPositiveIntEnv(
'MAKA_OFFICECLI_FETCH_TIMEOUT_MS',
DEFAULT_FETCH_TIMEOUT_MS,
);
export function officeCliTargetFor(platform, arch) {
return officeCli.assets[`${platform}-${arch}`] ? { platform, arch } : null;
}
export function assetForTarget(platform, arch) {
const asset = officeCli.assets[`${platform}-${arch}`];
if (!asset) throw new Error(`Unsupported OfficeCLI target: ${platform}-${arch}`);
return asset;
}
export function binaryNameForPlatform(platform) {
return platform === 'win32' ? 'officecli.exe' : 'officecli';
}
export function officeCliDownloadUrl(version, asset) {
return `https://github.com/${officeCli.repo}/releases/download/${version}/${asset}`;
}
export function officeCliSha256SumsUrl(version) {
return `https://github.com/${officeCli.repo}/releases/download/${version}/SHA256SUMS`;
}
export function parseSha256Sums(text) {
const entries = new Map();
for (const line of text.split(/\r?\n/)) {
const match = line.trim().match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
if (match) entries.set(match[2].trim(), match[1].toLowerCase());
}
return entries;
}
export function sha256(data) {
return createHash('sha256').update(Buffer.from(data)).digest('hex');
}
export function assertOfficeCliChecksums({ asset, actual, upstream, pinned }) {
if (!/^[a-f0-9]{64}$/.test(pinned ?? '')) {
throw new Error(`Missing repository-pinned checksum for ${asset}`);
}
if (upstream !== pinned) {
throw new Error(
`Upstream checksum for ${asset} does not match repository-pinned checksum: expected ${pinned}, got ${upstream}`,
);
}
if (actual !== pinned) {
throw new Error(`Checksum mismatch for ${asset}: expected ${pinned}, got ${actual}`);
}
}
export function officeCliVersionMatches(stdout, expectedVersion) {
const normalized = expectedVersion.replace(/^v/, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`\\b${normalized}\\b`).test(stdout);
}
function readPositiveIntEnv(name, fallback) {
const raw = process.env[name];
if (!raw) return fallback;
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer timeout in milliseconds`);
}
return parsed;
}
function isTimeoutError(error) {
return Boolean(
error &&
typeof error === 'object' &&
(error.name === 'AbortError' || error.name === 'TimeoutError'),
);
}
function officeCliTimeoutError(url) {
return new Error(`Timed out downloading ${url} after ${FETCH_TIMEOUT_MS}ms`);
}
async function fetchWithTimeout(url) {
try {
return await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
} catch (error) {
if (isTimeoutError(error)) {
throw officeCliTimeoutError(url);
}
throw error;
}
}
async function fetchBytes(url) {
const response = await fetchWithTimeout(url);
if (!response.ok) throw new Error(`Failed to download ${url}: HTTP ${response.status}`);
try {
return await response.arrayBuffer();
} catch (error) {
if (isTimeoutError(error)) throw officeCliTimeoutError(url);
throw error;
}
}
async function fetchText(url) {
const response = await fetchWithTimeout(url);
if (!response.ok) throw new Error(`Failed to download ${url}: HTTP ${response.status}`);
try {
return await response.text();
} catch (error) {
if (isTimeoutError(error)) throw officeCliTimeoutError(url);
throw error;
}
}
async function verifyOfficeCliVersion(binaryPath, expectedVersion) {
const { stdout } = await execFileAsync(binaryPath, ['--version'], {
env: { ...process.env, OFFICECLI_SKIP_UPDATE: '1' },
});
if (!officeCliVersionMatches(stdout, expectedVersion)) {
throw new Error(
`OfficeCLI version mismatch: expected ${expectedVersion}, got ${stdout.trim()}`,
);
}
}
export async function prepareOfficeCli(
targetPlatform = process.platform,
targetArch = process.arch,
) {
const target = `${targetPlatform}-${targetArch}`;
const asset = assetForTarget(targetPlatform, targetArch);
const assetUrl = officeCliDownloadUrl(officeCli.version, asset);
const sums = parseSha256Sums(await fetchText(officeCliSha256SumsUrl(officeCli.version)));
const upstream = sums.get(asset);
if (!upstream) throw new Error(`SHA256SUMS does not include ${asset}`);
const data = await fetchBytes(assetUrl);
const actual = sha256(data);
assertOfficeCliChecksums({
asset,
actual,
upstream,
pinned: officeCli.sha256?.[target],
});
await mkdir(toolsDir, { recursive: true });
await rm(join(toolsDir, 'officecli'), { force: true });
await rm(join(toolsDir, 'officecli.exe'), { force: true });
const destination = join(toolsDir, binaryNameForPlatform(targetPlatform));
await writeFile(destination, Buffer.from(data));
if (targetPlatform !== 'win32') await chmod(destination, 0o755);
if (targetPlatform === process.platform && targetArch === process.arch) {
await verifyOfficeCliVersion(destination, officeCli.version);
}
return { asset, destination, version: officeCli.version };
}
function readArg(name) {
const index = process.argv.indexOf(name);
return index === -1 ? undefined : process.argv[index + 1];
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const platform = readArg('--platform') ?? process.platform;
const arch = readArg('--arch') ?? process.arch;
const result = await prepareOfficeCli(platform, arch);
process.stdout.write(
`Prepared OfficeCLI ${result.version} for ${platform}-${arch}: ${result.destination}\n`,
);
}