#!/usr/bin/env node /** * scripts/offline-install-check.mjs -- offline installer integrity check for APS desktop. * * Generates and verifies a SHA-256 checksum manifest for the NSIS installer and related * release artifacts, and runs a dry-run install-path check. The expected install * directory is derived read-only from apps/desktop/package.json (electron-builder NSIS * per-user default): %LOCALAPPDATA%\Programs\. * * Usage: * node scripts/offline-install-check.mjs generate + verify + dry-run * node scripts/offline-install-check.mjs --verify verify an existing manifest * node scripts/offline-install-check.mjs --portable copy installer next to manifest and * emit paths relative to the package dir * node scripts/offline-install-check.mjs --out-dir manifest output dir (default build/offline-check) * node scripts/offline-install-check.mjs --root base dir used to resolve manifest paths * node scripts/offline-install-check.mjs --localappdata

override %LOCALAPPDATA% for the dry-run * node scripts/offline-install-check.mjs --json machine-readable JSON report * node scripts/offline-install-check.mjs --self-test internal round-trip smoke (temp files only) * * Exit codes: * 0 PASS -- installer present, size ok, every manifest checksum verifies * 1 FAIL -- installer missing/too small, checksum mismatch, or unreadable artifact * * Offline transfer flow: * 1. On the build machine: node scripts/offline-install-check.mjs --portable * -> build/offline-check/ now contains installer + checksums.sha256 + * offline-install-manifest.json (paths relative to that folder). * 2. Copy that folder to USB media, then on the target offline machine run: * node scripts/offline-install-check.mjs --verify --root * 3. Install and confirm the app lands in %LOCALAPPDATA%\Programs\ * (the dry-run output prints the expected path on the current machine). */ import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const DEFAULT_OUT_DIR = path.join(repoRoot, 'build', 'offline-check'); const INSTALLER_MIN_BYTES = 50 * 1024 * 1024; const SIDECAR_MIN_BYTES = 5 * 1024 * 1024; const MANIFEST_FILE = 'offline-install-manifest.json'; const CHECKSUMS_FILE = 'checksums.sha256'; const EXIT_PASS = 0; const EXIT_FAIL = 1; export function toPosix(value) { return String(value).replaceAll('\\', '/'); } export function readDesktopConfig(root) { const file = path.join(root, 'apps', 'desktop', 'package.json'); const raw = JSON.parse(fs.readFileSync(file, 'utf8')); const build = raw.build || {}; return { file, version: raw.version || '0.0.0', productName: build.productName || raw.name || 'aps-agent', appId: build.appId || 'unknown', artifactName: (build.win && build.win.artifactName) || 'aps-agent-desktop-' + '$' + '{version}' + '.' + '$' + '{ext}', outputDir: (build.directories && build.directories.output) || 'release', }; } export function resolveInstallerName(config) { const versionToken = '$' + '{version}'; const extToken = '$' + '{ext}'; return String(config.artifactName || '') .replace(versionToken, config.version) .replace(extToken, 'exe'); } export function resolveArtifacts(root, config) { const desktopDir = path.join(root, 'apps', 'desktop', config.outputDir); const entries = [ { id: 'nsis-installer', kind: 'installer', file: path.join(desktopDir, resolveInstallerName(config)), minBytes: INSTALLER_MIN_BYTES }, { id: 'sidecar-exe', kind: 'sidecar', file: path.join(root, 'dist', 'sidecar', 'aps-sidecar', 'aps-sidecar.exe'), minBytes: SIDECAR_MIN_BYTES }, { id: 'desktop-frozen-exe', kind: 'desktop', file: path.join(desktopDir, 'win-unpacked', config.productName + '.exe'), minBytes: SIDECAR_MIN_BYTES }, { id: 'bundled-sidecar-exe', kind: 'sidecar', file: path.join(desktopDir, 'win-unpacked', 'resources', 'sidecar', 'aps-sidecar.exe'), minBytes: SIDECAR_MIN_BYTES }, ]; return entries.map((entry) => { const stat = fs.existsSync(entry.file) ? fs.statSync(entry.file) : null; return Object.assign({}, entry, { exists: Boolean(stat), bytes: stat ? stat.size : 0, sizeOk: Boolean(stat && stat.size >= entry.minBytes) }); }); } export async function sha256File(file) { const hash = crypto.createHash('sha256'); const stream = fs.createReadStream(file); for await (const chunk of stream) hash.update(chunk); return hash.digest('hex'); } export function checksumsContent(entries, baseDir) { return entries .map((entry) => entry.sha256 + ' ' + toPosix(path.relative(baseDir, entry.file))) .join('\n') + '\n'; } export function parseChecksumsFile(content) { const entries = []; const lines = String(content || '').split(/\r?\n/); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const match = /^([0-9a-fA-F]{64})\s+\*?\s*(.+)$/.exec(trimmed); if (!match) throw new Error('Malformed checksums line: ' + line); entries.push({ sha256: match[1].toLowerCase(), file: match[2].trim() }); } return entries; } export function formatBytes(bytes) { if (!Number.isFinite(bytes)) return '?'; const units = ['B', 'KB', 'MB', 'GB']; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return (unit === 0 ? String(value) : value.toFixed(1)) + ' ' + units[unit]; } export function expectedInstallDir(localAppData, productName) { if (!localAppData) return null; return path.join(localAppData, 'Programs', productName); } export function dryRunInstallPath(localAppData, productName) { const dir = expectedInstallDir(localAppData, productName); if (!dir) { return { expectedInstallDir: null, exists: false, structure: null, layoutValid: false, reason: 'LOCALAPPDATA is not set' }; } const result = { expectedInstallDir: dir, exists: fs.existsSync(dir), structure: null, layoutValid: null, reason: null }; if (!result.exists) { result.reason = 'expected install dir absent (dry-run only; actual install needs a clean offline machine)'; return result; } const appExe = fs.existsSync(path.join(dir, productName + '.exe')); const sidecarExe = fs.existsSync(path.join(dir, 'resources', 'sidecar', 'aps-sidecar.exe')); result.structure = { appExe, sidecarExe }; result.layoutValid = Boolean(appExe && sidecarExe); result.reason = result.layoutValid ? 'existing install layout detected' : 'install dir exists but layout is incomplete/stale'; return result; } export function checkInstaller(file, minBytes) { const stat = fs.existsSync(file) ? fs.statSync(file) : null; return { exists: Boolean(stat), bytes: stat ? stat.size : 0, sizeOk: Boolean(stat && stat.size >= minBytes), minBytes }; } export function buildManifest(entries, config, baseDir) { return { schemaVersion: 1, generatedBy: 'scripts/offline-install-check.mjs', generatedAt: new Date().toISOString(), productName: config.productName, version: config.version, appId: config.appId, baseDir, expectedInstallDir: expectedInstallDir(process.env.LOCALAPPDATA, config.productName), notes: 'Local integrity manifest for the offline-install smoke; not a signed attestation. Ship the installer together with checksums.sha256 to the target machine.', entries: entries.map((entry) => ({ id: entry.id, kind: entry.kind, file: toPosix(path.relative(baseDir, entry.file)), sha256: entry.sha256, bytes: entry.bytes, minBytes: entry.minBytes, })), }; } export async function verifyEntries(entries, root) { const results = []; for (const entry of entries) { const file = path.resolve(root, entry.file); const exists = fs.existsSync(file); let actual = null; let bytes = null; let error = null; if (exists) { try { actual = await sha256File(file); bytes = fs.statSync(file).size; } catch (caught) { error = String((caught && caught.message) || caught); } } if (!entry.sha256 && !error) error = 'manifest entry missing sha256'; const sizeOk = exists && !error && bytes !== null && bytes >= (entry.minBytes || 1); results.push({ id: entry.id, kind: entry.kind, file: entry.file, exists, actual, bytes, expected: entry.sha256, expectedBytes: entry.bytes, match: Boolean(exists && !error && entry.sha256 && actual === entry.sha256), sizeOk, error, }); } return results; } export function printReport(report) { const lines = []; lines.push('Offline installer integrity check (' + report.mode + ')'); if (report.reason) lines.push(' reason: ' + report.reason); if (report.productName) lines.push(' product: ' + report.productName + (report.version ? ' ' + report.version : '')); if (report.manifest) lines.push(' manifest: ' + report.manifest); if (report.checksums) lines.push(' checksums: ' + report.checksums); for (const entry of report.entries || []) { const tag = entry.exists && entry.match && entry.sizeOk ? 'PASS' : 'FAIL'; let detail = 'sha256 ' + (entry.actual || 'n/a'); if (entry.bytes != null) detail += ' / ' + formatBytes(entry.bytes); if (entry.expectedBytes != null) detail += ' (expected ' + formatBytes(entry.expectedBytes) + ')'; if (entry.error) detail += ' error: ' + entry.error; lines.push(' [' + tag + '] ' + (entry.id || entry.file) + ' -- ' + entry.file + ' ' + detail); } for (const missing of report.missing || []) { lines.push(' [MISSING] ' + missing.id + ' -- ' + missing.file + ' (not packaged; skipped)'); } if (report.dryRun) { lines.push(' dry-run install path: ' + (report.dryRun.expectedInstallDir || 'n/a')); lines.push(' dry-run status: ' + (report.dryRun.reason || 'n/a')); } lines.push('Verdict: ' + report.verdict); console.log(lines.join('\n')); } export function printHelp() { console.log([ 'Usage: node scripts/offline-install-check.mjs [options]', '', 'Generate/verify a SHA-256 checksum manifest for the NSIS installer and run a', 'dry-run install-path check.', '', 'Options:', ' --verify verify an existing manifest (default: generate then verify)', ' --portable copy the installer next to the manifest and emit paths', ' relative to the package dir (offline transfer bundle)', ' --out-dir

manifest output dir (default build/offline-check)', ' --root base dir used to resolve manifest paths (default repo root)', ' --localappdata override %LOCALAPPDATA% for the dry-run', ' --manifest manifest to verify (default /offline-install-manifest.json)', ' --json machine-readable JSON report', ' --self-test internal round-trip smoke (temp files only)', ' -h, --help this help', '', 'Exit codes: 0 PASS / 1 FAIL', ].join('\n')); } export function parseArgs(argv) { const args = { verify: false, portable: false, json: false, selfTest: false, help: false, outDir: DEFAULT_OUT_DIR, root: repoRoot, localAppData: process.env.LOCALAPPDATA || null, manifest: null, }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--verify') args.verify = true; else if (arg === '--portable') args.portable = true; else if (arg === '--json') args.json = true; else if (arg === '--self-test') args.selfTest = true; else if (arg === '--help' || arg === '-h') args.help = true; else if (arg === '--out-dir') { i += 1; args.outDir = path.resolve(argv[i]); } else if (arg === '--root') { i += 1; args.root = path.resolve(argv[i]); } else if (arg === '--localappdata') { i += 1; args.localAppData = argv[i]; } else if (arg === '--manifest') { i += 1; args.manifest = path.resolve(argv[i]); } else if (arg.startsWith('--')) { throw new Error('Unknown option: ' + arg); } else { throw new Error('Unexpected positional argument: ' + arg); } } return args; } async function runGenerate(args) { const config = readDesktopConfig(repoRoot); const artifacts = resolveArtifacts(repoRoot, config); const installer = artifacts.find((artifact) => artifact.id === 'nsis-installer'); const missing = artifacts.filter((artifact) => !artifact.exists); const tooSmall = artifacts.filter((artifact) => artifact.exists && !artifact.sizeOk); if (!installer.exists) { return { verdict: 'FAIL', mode: 'generate', reason: 'NSIS installer missing: ' + installer.file, missing: missing.map((artifact) => artifact.id), productName: config.productName, version: config.version }; } if (!installer.sizeOk) { return { verdict: 'FAIL', mode: 'generate', reason: 'NSIS installer too small: ' + formatBytes(installer.bytes) + ' (< ' + formatBytes(installer.minBytes) + ')', installer: { file: installer.file, bytes: installer.bytes }, productName: config.productName, version: config.version }; } if (tooSmall.length > 0) { return { verdict: 'FAIL', mode: 'generate', reason: 'artifact too small: ' + tooSmall.map((artifact) => artifact.id + ' ' + formatBytes(artifact.bytes)).join(', '), productName: config.productName, version: config.version }; } fs.mkdirSync(args.outDir, { recursive: true }); let included; let baseDir; if (args.portable) { const portableInstaller = path.join(args.outDir, path.basename(installer.file)); fs.copyFileSync(installer.file, portableInstaller); const stat = fs.statSync(portableInstaller); included = [Object.assign({}, installer, { file: portableInstaller, exists: true, bytes: stat.size, sizeOk: stat.size >= installer.minBytes })]; included[0].sha256 = await sha256File(portableInstaller); baseDir = args.outDir; } else { included = []; for (const artifact of artifacts) { if (!artifact.exists) continue; artifact.sha256 = await sha256File(artifact.file); included.push(artifact); } baseDir = args.root; } const manifest = buildManifest(included, config, baseDir); const manifestPath = path.join(args.outDir, MANIFEST_FILE); const checksumsPath = path.join(args.outDir, CHECKSUMS_FILE); fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); fs.writeFileSync(checksumsPath, checksumsContent(included, baseDir), 'utf8'); const results = await verifyEntries(manifest.entries, baseDir); const dryRun = dryRunInstallPath(args.localAppData, config.productName); const failed = results.filter((entry) => !(entry.exists && entry.match && entry.sizeOk)); return { verdict: failed.length === 0 ? 'PASS' : 'FAIL', mode: 'generate', portable: args.portable, productName: config.productName, version: config.version, manifest: manifestPath, checksums: checksumsPath, baseDir, expectedInstallDir: dryRun.expectedInstallDir, dryRun, missing: missing.map((artifact) => ({ id: artifact.id, file: toPosix(path.relative(repoRoot, artifact.file)) })), entries: results, failedCount: failed.length, }; } async function runVerify(args) { const manifestPath = args.manifest || path.join(args.outDir, MANIFEST_FILE); if (!fs.existsSync(manifestPath)) { return { verdict: 'FAIL', mode: 'verify', reason: 'manifest not found: ' + manifestPath, manifest: manifestPath }; } let manifest; try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (error) { return { verdict: 'FAIL', mode: 'verify', reason: 'manifest unreadable: ' + String((error && error.message) || error), manifest: manifestPath }; } if (manifest.schemaVersion !== 1) { return { verdict: 'FAIL', mode: 'verify', reason: 'unsupported manifest schemaVersion: ' + manifest.schemaVersion, manifest: manifestPath }; } const results = await verifyEntries(manifest.entries || [], args.root); const config = readDesktopConfig(repoRoot); const productName = manifest.productName || config.productName; const dryRun = dryRunInstallPath(args.localAppData, productName); const failed = results.filter((entry) => !(entry.exists && entry.match && entry.sizeOk)); return { verdict: failed.length === 0 ? 'PASS' : 'FAIL', mode: 'verify', productName, version: manifest.version, manifest: manifestPath, root: args.root, expectedInstallDir: dryRun.expectedInstallDir, dryRun, entries: results, failedCount: failed.length, }; } export async function runSelfTest() { const checks = []; const check = async (name, fn) => { try { await fn(); checks.push({ name, pass: true }); } catch (error) { checks.push({ name, pass: false, error: String((error && error.message) || error) }); } }; const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aps-offline-self-')); try { const installer = path.join(root, 'installer.exe'); const sidecar = path.join(root, 'sidecar.exe'); fs.writeFileSync(installer, Buffer.alloc(256 * 1024, 7)); fs.writeFileSync(sidecar, Buffer.alloc(4096, 9)); await check('sha256File is deterministic lowercase hex', async () => { const first = await sha256File(installer); const second = await sha256File(installer); if (first !== second || !/^[0-9a-f]{64}$/.test(first)) throw new Error('bad hash'); }); await check('checksumsContent + parseChecksumsFile round trip', async () => { const installerHash = await sha256File(installer); const content = checksumsContent([{ file: installer, sha256: installerHash }], root); const parsed = parseChecksumsFile(content); if (parsed.length !== 1 || parsed[0].sha256 !== installerHash || parsed[0].file !== 'installer.exe') { throw new Error('round trip mismatch'); } }); await check('expectedInstallDir under LOCALAPPDATA Programs', () => { const dir = expectedInstallDir('C:\\Users\\tester\\AppData\\Local', '工业智核 APS'); if (dir !== 'C:\\Users\\tester\\AppData\\Local\\Programs\\工业智核 APS') throw new Error('got ' + dir); }); await check('dryRunInstallPath absent dir', () => { const result = dryRunInstallPath(path.join(root, 'no-such-local'), '工业智核 APS'); if (result.exists || result.layoutValid !== null) throw new Error('expected absent dir'); if (result.expectedInstallDir.indexOf('Programs' + path.sep + '工业智核 APS') === -1) throw new Error('bad expected dir'); }); await check('dryRunInstallPath detects full layout', () => { const local = path.join(root, 'LocalAppData'); const installDir = path.join(local, 'Programs', '工业智核 APS'); fs.mkdirSync(path.join(installDir, 'resources', 'sidecar'), { recursive: true }); fs.writeFileSync(path.join(installDir, '工业智核 APS.exe'), 'x'); fs.writeFileSync(path.join(installDir, 'resources', 'sidecar', 'aps-sidecar.exe'), 'y'); const result = dryRunInstallPath(local, '工业智核 APS'); if (!result.exists || !result.layoutValid) throw new Error('expected valid layout, got ' + JSON.stringify(result)); }); await check('checkInstaller size gate', () => { const ok = checkInstaller(installer, 1024); if (!ok.sizeOk) throw new Error('expected sizeOk'); const missing = checkInstaller(path.join(root, 'nope.exe'), 1024); if (missing.exists) throw new Error('expected missing'); const small = checkInstaller(sidecar, 10 * 1024 * 1024); if (small.sizeOk) throw new Error('expected too small'); }); await check('manifest verify detects tampering', async () => { const config = { productName: '工业智核 APS', version: '0.1.0', appId: 'com.aps.agent' }; const installerHash = await sha256File(installer); const sidecarHash = await sha256File(sidecar); const entries = [ { id: 'nsis-installer', kind: 'installer', file: installer, sha256: installerHash, bytes: fs.statSync(installer).size, minBytes: 1024 }, { id: 'sidecar-exe', kind: 'sidecar', file: sidecar, sha256: sidecarHash, bytes: fs.statSync(sidecar).size, minBytes: 1024 }, ]; const manifest = buildManifest(entries, config, root); const first = await verifyEntries(manifest.entries, root); if (!first.every((entry) => entry.match && entry.sizeOk)) throw new Error('clean verify should pass'); fs.appendFileSync(sidecar, 'tampered'); const second = await verifyEntries(manifest.entries, root); const tampered = second.find((entry) => entry.id === 'sidecar-exe'); if (tampered.match || !tampered.exists) throw new Error('tampered entry should mismatch'); }); } finally { fs.rmSync(root, { recursive: true, force: true }); } let failed = 0; for (const entry of checks) { if (entry.pass) { console.log(' [PASS] ' + entry.name); } else { failed += 1; console.log(' [FAIL] ' + entry.name + ': ' + entry.error); } } console.log('offline-install-check self-test: ' + (failed === 0 ? 'PASS (' + checks.length + '/' + checks.length + ')' : 'FAIL (' + (checks.length - failed) + '/' + checks.length + ')')); process.exitCode = failed === 0 ? EXIT_PASS : EXIT_FAIL; } async function main() { let args; try { args = parseArgs(process.argv.slice(2)); } catch (error) { console.error('offline-install-check: ' + String((error && error.message) || error)); process.exitCode = EXIT_FAIL; return; } if (args.help) { printHelp(); return; } if (args.selfTest) { await runSelfTest(); return; } const report = args.verify ? await runVerify(args) : await runGenerate(args); if (args.json) { console.log(JSON.stringify(report, null, 2)); } else { printReport(report); } process.exitCode = report.verdict === 'PASS' ? EXIT_PASS : EXIT_FAIL; } const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; if (isMain) { await main(); }