#!/usr/bin/env node /** * Vendor the OfficeCLI binary into `PC/resources/officecli/`. * * OfficeCLI (https://github.com/iOfficeAI/OfficeCLI, Apache-2.0) is a single * self-contained native binary (embedded .NET runtime, no Office/LibreOffice * needed) that we bundle as the built-in engine for reading/creating/editing/ * rendering docx/xlsx/pptx. Release assets are RAW binaries (not archives), so * this just downloads, sha256-verifies against a PINNED map, and chmods. * * Supply-chain guard: the expected hashes below are pinned in-repo. A download * whose hash does not match is deleted and the run fails — upstream tampering or * a moved tag cannot silently land a different binary. To bump the version, * change VERSION + SHA256 together (copy the release's SHA256SUMS). * * Layout: `resources/officecli/` (e.g. `officecli-mac-arm64`, * `officecli-win-x64.exe`). The engine layer resolves the asset for the running * platform via `ASSETS['${process.platform}-${process.arch}']`. * * Selection: by default fetches the CURRENT platform/arch. Multi-arch release * builds pass explicit --platform targets, just like ensure-runtime. Flags: * --all every shippable target (mac + win, both arches) * --root= destination root (default: PC/resources/officecli) * --platform= a specific `${platform}-${arch}` (repeatable) * --force re-download even if a valid copy exists * --check verify the selected asset(s) exist + match pinned * hashes, but do not download * --optional exit 0 on download failure; manual dev escape hatch * --prune after fetching, delete any officecli-* asset NOT in * the selected set (LICENSE kept). Used by the release * pipeline so each platform's build source carries only * its own target binary, never sibling-OS binaries that * build-source-cache copied in. * * Binaries are gitignored (~33MB each); the installer picks them up via * electron-builder `extraResources`. Idempotent + offline-tolerant: a present, * hash-valid copy is left untouched and a failed download with a valid copy * already on disk is not an error. */ const fs = require('node:fs'); const path = require('node:path'); const http = require('node:http'); const https = require('node:https'); const crypto = require('node:crypto'); const { spawnSync } = require('node:child_process'); const VERSION = 'v1.0.139'; const REPO = 'iOfficeAI/OfficeCLI'; const RELEASE_BASE_URL = process.env.OFFICECLI_RELEASE_BASE_URL || `https://github.com/${REPO}/releases/download/${VERSION}`; const LICENSE_URL = process.env.OFFICECLI_LICENSE_URL || `https://raw.githubusercontent.com/${REPO}/${VERSION}/LICENSE`; const DOWNLOAD_RETRIES = Number(process.env.OFFICECLI_FETCH_RETRIES || 3); const STALL_TIMEOUT_MS = Number(process.env.OFFICECLI_FETCH_STALL_MS || 120_000); const pcRoot = path.resolve(__dirname, '..'); let destDir = path.join(pcRoot, 'resources', 'officecli'); // `${process.platform}-${process.arch}` -> release asset name. Desktop targets // only (mac + win); linux assets exist upstream but we do not ship them. const ASSETS = { 'darwin-arm64': 'officecli-mac-arm64', 'darwin-x64': 'officecli-mac-x64', 'win32-x64': 'officecli-win-x64.exe', 'win32-arm64': 'officecli-win-arm64.exe', }; // Pinned sha256 for VERSION (from the release SHA256SUMS). Update with VERSION. const SHA256 = { 'officecli-mac-arm64': '393874f79db58222bdbede7f4f942f2536580386923857d1b5ad9754efe80c19', 'officecli-mac-x64': '6a931d424975dded6ae413c8c1f63d00dfb30a4bd4bd50352964782d13299f5c', 'officecli-win-x64.exe': '864e0580c8e8c91a6aa4a4c1e8900551c8d4aa648ff10136ceed3a6ba5310888', 'officecli-win-arm64.exe': '6d80a93ba0c9cafb2b52048efbb403cd761b35126130fa8166383599aa91d96e', }; function parseArgs(argv) { const opts = { all: false, check: false, force: false, optional: false, prune: false, root: destDir, platforms: [] }; for (const a of argv) { if (a === '--all') opts.all = true; else if (a === '--check') opts.check = true; else if (a === '--force') opts.force = true; else if (a === '--optional') opts.optional = true; else if (a === '--prune') opts.prune = true; else if (a.startsWith('--root=')) opts.root = path.resolve(a.slice('--root='.length)); else if (a.startsWith('--platform=')) opts.platforms.push(a.slice('--platform='.length)); else throw new Error(`unknown argument: ${a}`); } return opts; } function selectAssets(opts) { let keys; if (opts.platforms.length) keys = opts.platforms; else if (opts.all) keys = Object.keys(ASSETS); else keys = [`${process.platform}-${process.arch}`]; const names = []; for (const k of keys) { const name = ASSETS[k]; if (!name) throw new Error(`no OfficeCLI asset for platform key "${k}" (known: ${Object.keys(ASSETS).join(', ')})`); if (!names.includes(name)) names.push(name); } if (!names.length) throw new Error(`no shippable OfficeCLI asset for ${process.platform}-${process.arch}`); return names; } function sha256File(filePath) { return new Promise((resolve, reject) => { const hash = crypto.createHash('sha256'); const s = fs.createReadStream(filePath); s.on('error', reject); s.on('data', (c) => hash.update(c)); s.on('end', () => resolve(hash.digest('hex'))); }); } function requestClient(url) { const parsed = new URL(url); if (parsed.protocol === 'http:') return http; if (parsed.protocol === 'https:') return https; throw new Error(`unsupported URL protocol for ${url}`); } function partialSize(filePath) { try { return fs.statSync(filePath).size; } catch { return 0; } } function downloadOnce(url, outPath, redirects = 0) { return new Promise((resolve, reject) => { if (redirects > 5) { reject(new Error(`too many redirects for ${url}`)); return; } let file = null; let stallTimer = null; const resumeFrom = partialSize(outPath); const headers = { 'User-Agent': 'orkas-fetch-officecli' }; if (resumeFrom > 0) headers.Range = `bytes=${resumeFrom}-`; const cleanup = () => { if (stallTimer) clearTimeout(stallTimer); if (file) file.close(); }; const fail = (err) => { cleanup(); reject(err); }; const req = requestClient(url).get(url, { headers }, (res) => { if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); cleanup(); downloadOnce(res.headers.location, outPath, redirects + 1).then(resolve, reject); return; } let append = false; let initialDone = 0; if (resumeFrom > 0 && res.statusCode === 206) { append = true; initialDone = resumeFrom; } else if (resumeFrom > 0 && res.statusCode === 200) { console.warn(`[officecli] server ignored Range for ${path.basename(outPath)}; restarting download`); fs.rmSync(outPath, { force: true }); } else if (res.statusCode !== 200) { res.resume(); fail(new Error(`HTTP ${res.statusCode} fetching ${url}`)); return; } file = fs.createWriteStream(outPath, { flags: append ? 'a' : 'w' }); const contentLength = Number(res.headers['content-length'] || 0); const total = contentLength ? initialDone + contentLength : 0; let done = initialDone; let lastPct = -1; const armStallTimer = () => { if (stallTimer) clearTimeout(stallTimer); stallTimer = setTimeout(() => { req.destroy(new Error(`timeout after ${STALL_TIMEOUT_MS}ms with no download progress`)); }, STALL_TIMEOUT_MS); }; armStallTimer(); res.on('data', (chunk) => { done += chunk.length; armStallTimer(); if (total) { const pct = Math.floor((done / total) * 100); if (pct !== lastPct && pct % 10 === 0) { process.stdout.write(`\r ${(done / 1048576).toFixed(1)}MB / ${(total / 1048576).toFixed(1)}MB (${pct}%)`); lastPct = pct; } } }); res.pipe(file); file.on('finish', () => { cleanup(); if (total) process.stdout.write('\n'); resolve(); }); file.on('error', fail); }); req.on('error', fail); req.setTimeout(STALL_TIMEOUT_MS, () => req.destroy(new Error('timeout'))); }); } async function download(url, outPath) { let lastError = null; for (let attempt = 1; attempt <= DOWNLOAD_RETRIES + 1; attempt += 1) { try { await downloadOnce(url, outPath); return; } catch (err) { lastError = err; if (attempt <= DOWNLOAD_RETRIES) { console.warn(`[officecli] download failed (${err.message}); retrying ${attempt}/${DOWNLOAD_RETRIES}`); } } } throw lastError; } function doctorAsset(name, finalPath) { if (!name.endsWith('.exe')) { try { fs.chmodSync(finalPath, 0o755); } catch { /* best-effort on readonly filesystems */ } } if (process.platform === 'darwin') { spawnSync('xattr', ['-d', 'com.apple.quarantine', finalPath], { timeout: 30_000, stdio: 'ignore' }); } } async function verifyAsset(name) { const expected = SHA256[name]; if (!expected) throw new Error(`no pinned sha256 for asset "${name}" — refusing to fetch unverifiable binary`); const finalPath = path.join(destDir, name); if (!fs.existsSync(finalPath)) throw new Error(`${name} missing at ${finalPath}`); const have = await sha256File(finalPath); if (have !== expected) { throw new Error(`sha256 mismatch for ${name}\n expected ${expected}\n got ${have}`); } doctorAsset(name, finalPath); return finalPath; } async function fetchAsset(name, force) { const expected = SHA256[name]; if (!expected) throw new Error(`no pinned sha256 for asset "${name}" — refusing to fetch unverifiable binary`); const finalPath = path.join(destDir, name); const isExe = name.endsWith('.exe'); if (!force && fs.existsSync(finalPath)) { try { await verifyAsset(name); console.log(`[officecli] ${name} present and verified, skipping`); return; } catch (err) { console.warn(`[officecli] ${name} on disk is not verified (${err.message}), re-fetching`); } } const url = `${RELEASE_BASE_URL}/${name}`; const partPath = `${finalPath}.part`; if (force) fs.rmSync(partPath, { force: true }); const resumeFrom = partialSize(partPath); if (resumeFrom > 0) console.log(`[officecli] resuming ${name} from ${resumeFrom} bytes`); console.log(`[officecli] fetching ${url}`); await download(url, partPath); const got = await sha256File(partPath); if (got !== expected) { fs.rmSync(partPath, { force: true }); throw new Error(`sha256 mismatch for ${name}\n expected ${expected}\n got ${got}`); } fs.renameSync(partPath, finalPath); if (!isExe) fs.chmodSync(finalPath, 0o755); doctorAsset(name, finalPath); console.log(`[officecli] ${name} verified -> ${finalPath}`); } // Drop sibling-OS binaries the source copy may have carried in, so a packaged // build ships only the asset(s) it just fetched. Scoped strictly to KNOWN // officecli asset names (never LICENSE or unrelated files); .part temporaries // are left for the next run / electron-builder filter to ignore. function pruneAssets(keep) { const keepSet = new Set(keep); const known = new Set(Object.values(ASSETS)); if (!fs.existsSync(destDir)) return; for (const name of fs.readdirSync(destDir)) { if (known.has(name) && !keepSet.has(name)) { fs.rmSync(path.join(destDir, name), { force: true }); console.log(`[officecli] pruned non-target asset ${name}`); } } } // Apache-2.0 requires shipping the license; best-effort, never fails the run. async function fetchLicense() { const out = path.join(destDir, 'LICENSE'); if (fs.existsSync(out)) return; try { await download(LICENSE_URL, out); console.log('[officecli] LICENSE fetched'); } catch (err) { console.warn(`[officecli] could not fetch LICENSE (${err.message}) — add it manually before release`); } } async function main() { const opts = parseArgs(process.argv.slice(2)); destDir = opts.root; fs.mkdirSync(destDir, { recursive: true }); const assets = selectAssets(opts); console.log(`[officecli] ${VERSION} -> ${destDir} (${assets.join(', ')})`); let firstError = null; for (const name of assets) { try { if (opts.check) { const finalPath = await verifyAsset(name); console.log(`[officecli] ${name} verified -> ${finalPath}`); } else { await fetchAsset(name, opts.force); } } catch (err) { const finalPath = path.join(destDir, name); if (fs.existsSync(finalPath) && (await sha256File(finalPath)) === SHA256[name]) { doctorAsset(name, finalPath); console.warn(`[officecli] fetch of ${name} failed but a valid copy exists: ${err.message}`); } else { firstError = firstError || err; console.error(`[officecli] ${name}: ${err.message}`); } } } if (opts.prune) pruneAssets(assets); if (opts.check) { const license = path.join(destDir, 'LICENSE'); if (!fs.existsSync(license)) firstError = firstError || new Error(`LICENSE missing at ${license}`); } else { await fetchLicense(); } if (firstError) { if (opts.optional) { console.warn(`[officecli] optional fetch failed; Office document tools may be unavailable until this succeeds: ${firstError.message}`); return; } throw firstError; } } // This file is also the single source of truth consumed by the real-binary // smoke and packaged-artifact validator. Keep imports side-effect free so // those gates cannot silently drift from the version/hash used by packaging. module.exports = Object.freeze({ VERSION, ASSETS: Object.freeze({ ...ASSETS }), SHA256: Object.freeze({ ...SHA256 }), }); if (require.main === module) { main().catch((err) => { console.error(`[officecli] ERROR: ${err.message}`); process.exit(1); }); }