#!/usr/bin/env node /** * scripts/nsis-install-smoke.mjs -- conservative NSIS temp-dir install smoke for APS desktop. * * round-44 parallel sub agent (GG): matrix 99/118 local handoff -- actually run the * NSIS installer (electron-builder) in SILENT mode into a random system temp * directory, verify the installed layout, optionally health-launch the main exe, * then uninstall/remove the temp directory. Never touches the real install * location (%LOCALAPPDATA%\Programs\) or any system directory. * * Usage: * node scripts/nsis-install-smoke.mjs [installer.exe] [options] * node scripts/nsis-install-smoke.mjs --installer --run-uninstaller * node scripts/nsis-install-smoke.mjs --self-test * * Options: * positional / --installer explicit NSIS installer path * --expected-sha256 expected sha256 (overrides manifest) * --manifest offline manifest with the expected sha256 * (default build/offline-check/offline-install-manifest.json) * --timeout installer timeout (default 600000 = 10 min) * --run-uninstaller uninstall with the installer-generated uninstaller after * checks (removes the installer's own shortcuts/uninstall * registry entry); otherwise the temp dir is deleted directly * --launch health-launch the installed main exe for --launch-seconds * then force-kill the process tree (default: OFF) * --no-launch explicit opt-out (the default) * --launch-seconds alive-check window in seconds (default 8) * --temp-root temp root for the install dir (default os.tmpdir()); * use an ASCII dir on machines with non-ASCII %TEMP% * --keep-temp-dir keep the temp install dir for inspection * --top-n printed content-list entries (default 10) * --json machine-readable JSON report * --self-test internal logic smoke (no install, no cleanup) * -h, --help this help * * Exit codes: * 0 PASS -- installer verified, silent temp install OK, layout OK, cleanup OK * 1 FAIL -- checksum mismatch, install failure, invalid layout, cleanup failure, non-Windows * 2 NONE -- no NSIS installer artifact found (explicit error message) * * Safety boundaries: * - Install dir = fs.mkdtemp() under the system temp root; /D= points NSIS there * (/D must be the last argument and contain no quotes; path is ASCII here). * - This script itself never writes %LOCALAPPDATA%\Programs, registry, or system * directories. The NSIS installer (electron-builder) may create a per-user * uninstall registry entry and start-menu shortcut for the temp install; * --run-uninstaller removes them, direct temp-dir deletion (default) leaves the * installer's own bookkeeping behind pointing at a deleted path (documented). * - Temp dirs are only removed after assertSafeTempDir() confirms the resolved * path is under the temp root, is not the root itself, and matches the smoke * prefix (Windows safety rule: never recursively delete an unverified path). * - The installed exe is not launched by default (avoids resident processes); * --launch starts it and always force-kills the tree afterwards. Any lingering * process whose executable path is under the temp install dir is killed too * (e.g. installer auto-launch residue). */ import { spawn, spawnSync } from 'node:child_process'; 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'; import { sha256File, readDesktopConfig, resolveInstallerName, formatBytes, } from './offline-install-check.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const DEFAULT_MANIFEST = path.join(repoRoot, 'build', 'offline-check', 'offline-install-manifest.json'); const INSTALLER_MIN_BYTES = 50 * 1024 * 1024; const DEFAULT_TIMEOUT_MS = 600000; const DEFAULT_UNINSTALL_TIMEOUT_MS = 120000; const DEFAULT_LAUNCH_SECONDS = 8; const DEFAULT_TOP_N = 10; const SMOKE_PREFIX = 'aps-nsis-smoke-'; const EXIT_PASS = 0; const EXIT_FAIL = 1; const EXIT_NO_INSTALLER = 2; function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } export function parseArgs(argv) { const args = { installer: null, expectedSha256: null, manifestPath: null, timeoutMs: DEFAULT_TIMEOUT_MS, runUninstaller: false, launch: false, launchSeconds: DEFAULT_LAUNCH_SECONDS, tempRoot: os.tmpdir(), keepTempDir: false, topN: DEFAULT_TOP_N, json: false, selfTest: false, help: false, }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; const next = () => { if (i + 1 >= argv.length) throw new Error('missing value for ' + arg); i += 1; return argv[i]; }; if (arg === '--installer') args.installer = path.resolve(next()); else if (arg === '--expected-sha256') args.expectedSha256 = String(next()).toLowerCase(); else if (arg === '--manifest') args.manifestPath = path.resolve(next()); else if (arg === '--timeout') args.timeoutMs = Number(next()); else if (arg === '--run-uninstaller') args.runUninstaller = true; else if (arg === '--launch') args.launch = true; else if (arg === '--no-launch') args.launch = false; else if (arg === '--launch-seconds') args.launchSeconds = Number(next()); else if (arg === '--temp-root') args.tempRoot = path.resolve(next()); else if (arg === '--keep-temp-dir') args.keepTempDir = true; else if (arg === '--top-n') args.topN = Number(next()); 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.startsWith('--')) throw new Error('Unknown option: ' + arg); else if (!args.installer) args.installer = path.resolve(arg); else throw new Error('Unexpected positional argument: ' + arg); } if (!Number.isFinite(args.timeoutMs) || args.timeoutMs < 1000) throw new Error('--timeout must be >= 1000 ms'); if (!Number.isFinite(args.launchSeconds) || args.launchSeconds < 1) throw new Error('--launch-seconds must be >= 1'); if (!Number.isFinite(args.topN) || args.topN < 1) throw new Error('--top-n must be >= 1'); if (args.expectedSha256 && !/^[0-9a-f]{64}$/.test(args.expectedSha256)) { throw new Error('--expected-sha256 must be 64 lowercase hex chars'); } return args; } /** * Path safety guard: the resolved target must live under the temp root, must not * be the root itself, and its basename must match the smoke prefix. Throw on * anything else so a computed path can never be recursively deleted. */ export function assertSafeTempDir(dir, tmpRoot = os.tmpdir(), prefix = SMOKE_PREFIX) { const resolved = path.resolve(String(dir)); const root = path.resolve(String(tmpRoot)); if (resolved === root) { throw new Error('refusing to operate on the temp root itself: ' + resolved); } const relative = path.relative(root, resolved); if (!relative || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) { throw new Error('target is outside the allowed temp root (' + root + '): ' + resolved); } const base = path.basename(resolved); if (!base.startsWith(prefix)) { throw new Error('target basename does not match smoke prefix "' + prefix + '": ' + base); } return { resolved, root, relative, base }; } export function expectedSha256FromManifest(manifest) { if (!manifest || !Array.isArray(manifest.entries)) return null; const entry = manifest.entries.find((item) => item && item.id === 'nsis-installer'); if (!entry || typeof entry.sha256 !== 'string') return null; const hash = entry.sha256.toLowerCase(); return /^[0-9a-f]{64}$/.test(hash) ? hash : null; } export function findInstaller({ root = repoRoot, explicit = null, manifest = null, config = null, minBytes = INSTALLER_MIN_BYTES } = {}) { // An explicit path is authoritative: check it and report without further discovery. if (explicit) { const file = path.resolve(explicit); const stat = fs.existsSync(file) ? fs.statSync(file) : null; return { source: 'explicit', file, bytes: stat ? stat.size : 0, exists: Boolean(stat && stat.size >= minBytes), candidates: [file] }; } let cfg = config; if (!cfg) { try { cfg = readDesktopConfig(root); } catch { cfg = { outputDir: 'release', artifactName: '', version: '' }; } } const name = resolveInstallerName(cfg); const candidates = []; if (manifest) { const base = manifest.baseDir ? path.resolve(manifest.baseDir) : root; const entry = (manifest.entries || []).find((item) => item && item.id === 'nsis-installer'); if (entry) candidates.push({ source: 'manifest', file: path.resolve(base, entry.file) }); } const releaseDir = path.join(root, 'apps', 'desktop', cfg.outputDir || 'release'); candidates.push({ source: 'desktop-release', file: path.join(releaseDir, name) }); candidates.push({ source: 'offline-check', file: path.join(root, 'build', 'offline-check', name) }); if (fs.existsSync(releaseDir)) { const hits = fs.readdirSync(releaseDir, { withFileTypes: true }) .filter((entry) => entry.isFile() && /\.exe$/i.test(entry.name)) .map((entry) => { const file = path.join(releaseDir, entry.name); let bytes = 0; try { bytes = fs.statSync(file).size; } catch { /* unreadable */ } return { source: 'release-glob', file, bytes }; }) .filter((candidate) => candidate.bytes >= minBytes) .sort((a, b) => b.bytes - a.bytes); if (hits.length > 0) candidates.push(hits[0]); } const seen = new Set(); const all = []; for (const candidate of candidates) { if (seen.has(candidate.file)) continue; seen.add(candidate.file); all.push(candidate.file); const stat = fs.existsSync(candidate.file) ? fs.statSync(candidate.file) : null; if (stat && stat.size >= minBytes) { return { source: candidate.source, file: candidate.file, bytes: stat.size, exists: true, candidates: all }; } } return { source: candidates[0] ? candidates[0].source : 'none', file: candidates[0] ? candidates[0].file : null, bytes: 0, exists: false, candidates: all }; } export function findUninstaller(installDir, productName) { if (!fs.existsSync(installDir)) return null; const exact = path.join(installDir, 'Uninstall ' + productName + '.exe'); if (fs.existsSync(exact)) return exact; const hit = fs.readdirSync(installDir, { withFileTypes: true }).find( (entry) => entry.isFile() && /^Uninstall .*\.exe$/i.test(entry.name), ); return hit ? path.join(installDir, hit.name) : null; } /** * Verify the electron-builder NSIS installed layout under a temp dir: * main exe + resources/ + app.asar (or resources/app) + bundled sidecar. */ export function verifyInstallLayout(installDir, productName, { expectSidecar = true } = {}) { const result = { exists: false, valid: false, mainExe: false, resources: false, appAsar: false, appDir: false, sidecar: false, uninstaller: null, reasons: [], }; if (!fs.existsSync(installDir)) { result.reasons.push('install dir absent'); return result; } result.exists = true; const mainExe = path.join(installDir, productName + '.exe'); result.mainExe = fs.existsSync(mainExe); if (!result.mainExe) result.reasons.push('main executable missing: ' + productName + '.exe'); const resourcesDir = path.join(installDir, 'resources'); result.resources = fs.existsSync(resourcesDir); if (!result.resources) result.reasons.push('resources/ dir missing'); result.appAsar = fs.existsSync(path.join(resourcesDir, 'app.asar')); result.appDir = fs.existsSync(path.join(resourcesDir, 'app')); if (!result.appAsar && !result.appDir) result.reasons.push('app.asar / resources/app missing'); result.sidecar = fs.existsSync(path.join(resourcesDir, 'sidecar', 'aps-sidecar.exe')); if (expectSidecar && !result.sidecar) result.reasons.push('resources/sidecar/aps-sidecar.exe missing'); result.uninstaller = findUninstaller(installDir, productName); result.valid = Boolean( result.mainExe && result.resources && (result.appAsar || result.appDir) && (!expectSidecar || result.sidecar), ); return result; } export function listContents(dir, topN = DEFAULT_TOP_N) { if (!fs.existsSync(dir)) return { fileCount: 0, totalBytes: 0, top: [] }; const files = []; const walk = (current) => { for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const full = path.join(current, entry.name); if (entry.isDirectory()) walk(full); else if (entry.isFile()) { let bytes = 0; try { bytes = fs.statSync(full).size; } catch { /* locked/unreadable */ } files.push({ file: full, bytes }); } } }; walk(dir); const totalBytes = files.reduce((sum, file) => sum + file.bytes, 0); const top = files.sort((a, b) => b.bytes - a.bytes).slice(0, topN); return { fileCount: files.length, totalBytes, top }; } export function runInstaller(installerPath, installDir, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { return new Promise((resolve, reject) => { const started = Date.now(); // NSIS: /S silent; /D= install dir must be the LAST argument, no quotes. const args = ['/S', `/D=${installDir}`]; const child = spawn(installerPath, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { if (stdout.length < 8192) stdout += String(chunk); }); child.stderr.on('data', (chunk) => { if (stderr.length < 8192) stderr += String(chunk); }); const timer = setTimeout(() => { killPids([child.pid]); reject(new Error('installer timed out after ' + timeoutMs + ' ms')); }, timeoutMs); child.once('error', (error) => { clearTimeout(timer); reject(error); }); child.once('exit', (code) => { clearTimeout(timer); resolve({ exitCode: code, elapsedMs: Date.now() - started, stdout, stderr }); }); }); } export function runUninstaller(uninstallerPath, { timeoutMs = DEFAULT_UNINSTALL_TIMEOUT_MS } = {}) { return new Promise((resolve, reject) => { const started = Date.now(); const child = spawn(uninstallerPath, ['/S'], { windowsHide: true, stdio: 'ignore' }); const timer = setTimeout(() => { killPids([child.pid]); reject(new Error('uninstaller timed out after ' + timeoutMs + ' ms')); }, timeoutMs); child.once('error', (error) => { clearTimeout(timer); reject(error); }); child.once('exit', (code) => { clearTimeout(timer); resolve({ exitCode: code, elapsedMs: Date.now() - started }); }); }); } /** List process ids whose executable path is under dir (Windows only; best effort). */ export function processesUnderDir(dir) { if (process.platform !== 'win32') return []; const pattern = String(dir).replace(/'/g, "''") + '\\*'; const script = "Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and ($_.ExecutablePath -like '" + pattern + "') } | ForEach-Object { $_.ProcessId }"; const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', windowsHide: true, timeout: 30000, }); if (result.status !== 0) return []; return String(result.stdout || '') .split(/\r?\n/) .map((line) => Number(line.trim())) .filter((pid) => Number.isInteger(pid) && pid > 0); } export function killPids(pids) { let killed = 0; for (const pid of pids || []) { if (!Number.isInteger(pid) || pid <= 0) continue; const result = spawnSync('taskkill.exe', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }); if (result.status === 0) killed += 1; } return killed; } /** Poll until a path disappears (the uninstaller removes files asynchronously after exit). */ export async function waitForPathRemoval(dir, { timeoutMs = 25000, intervalMs = 300 } = {}) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (!fs.existsSync(dir)) return true; await delay(intervalMs); } return !fs.existsSync(dir); } /** Remove a temp dir only after assertSafeTempDir() passes; retry while files are locked. */ export async function safeRemoveTempDir(dir, { keep = false, retries = 8 } = {}) { const guarded = assertSafeTempDir(dir); if (keep) return { removed: false, kept: true, dir: guarded.resolved }; let lastError = null; for (let attempt = 0; attempt < retries; attempt += 1) { try { fs.rmSync(guarded.resolved, { recursive: true, force: true }); return { removed: true, kept: false, dir: guarded.resolved }; } catch (error) { lastError = error; await delay(Math.min(3000, 500 * (attempt + 1))); } } throw new Error('failed to remove temp dir ' + guarded.resolved + ': ' + String((lastError && lastError.message) || lastError)); } /** Optional short health launch: keep the main exe alive for N seconds, then kill the tree. */ export async function launchSmoke(mainExe, installDir, { seconds = DEFAULT_LAUNCH_SECONDS } = {}) { const userDataDir = path.join(installDir, '.smoke-user-data'); const started = Date.now(); const child = spawn(mainExe, [`--user-data-dir=${userDataDir}`], { cwd: installDir, windowsHide: true, stdio: 'ignore', }); const aliveAtEnd = await new Promise((resolve) => { const timer = setTimeout(() => resolve(true), seconds * 1000); child.once('exit', () => { clearTimeout(timer); resolve(false); }); child.once('error', () => { clearTimeout(timer); resolve(false); }); }); const elapsedMs = Date.now() - started; const killed = killPids([child.pid]); await delay(500); return { pid: child.pid, aliveAtEnd, elapsedMs, killed }; } export function printHelp() { console.log([ 'Usage: node scripts/nsis-install-smoke.mjs [installer.exe] [options]', '', 'Conservative NSIS temp-dir install smoke: silent install (/S /D=) into a', 'random system temp dir, verify layout, optional short health launch, then', 'uninstall or delete the temp dir. Never touches the real install location.', '', 'Options:', ' --installer explicit NSIS installer path (or first positional arg)', ' --expected-sha256 expected sha256 (overrides the manifest)', ' --manifest manifest with expected sha256 (default build/offline-check/offline-install-manifest.json)', ' --timeout installer timeout (default 600000)', ' --run-uninstaller uninstall with the generated uninstaller after checks', ' --launch health-launch the main exe for --launch-seconds (default OFF)', ' --no-launch explicit opt-out (the default)', ' --launch-seconds alive-check window (default 8)', ' --temp-root temp root for the install dir (default os.tmpdir())', ' --keep-temp-dir keep the temp install dir for inspection', ' --top-n printed content-list entries (default 10)', ' --json machine-readable JSON report', ' --self-test internal logic smoke (no install)', ' -h, --help this help', '', 'Exit codes: 0 PASS / 1 FAIL / 2 NO_INSTALLER', ].join('\n')); } function printReport(report) { const lines = []; lines.push('NSIS temp-dir install smoke (' + report.mode + ')'); if (report.installer) { const tag = report.installer.exists ? (report.installer.match ? 'PASS' : (report.installer.match === false ? 'FAIL' : 'n/a')) : 'MISSING'; lines.push(' installer: [' + tag + '] ' + (report.installer.file || 'n/a') + (report.installer.bytes ? ' (' + formatBytes(report.installer.bytes) + ')' : '') + ' sha256 ' + (report.installer.sha256 || 'n/a') + (report.installer.expectedSha256 ? ' (expected ' + report.installer.expectedSha256 + ')' : '')); } if (report.install) { lines.push(' install: exit ' + report.install.exitCode + ' in ' + (report.install.elapsedMs != null ? (report.install.elapsedMs / 1000).toFixed(1) + 's' : 'n/a') + ' -> ' + report.install.dir); } if (report.layout) { const tag = report.layout.valid ? 'PASS' : 'FAIL'; lines.push(' layout: [' + tag + '] mainExe=' + report.layout.mainExe + ' resources=' + report.layout.resources + ' appAsar=' + report.layout.appAsar + ' sidecar=' + report.layout.sidecar + ' uninstaller=' + (report.layout.uninstaller ? path.basename(report.layout.uninstaller) : 'missing') + (report.layout.reasons.length ? ' reasons: ' + report.layout.reasons.join('; ') : '')); lines.push(' contents: ' + report.layout.fileCount + ' files / ' + formatBytes(report.layout.totalBytes)); for (const item of report.contents || []) { lines.push(' ' + formatBytes(item.bytes).padStart(10) + ' ' + item.file); } } if (report.launch) { if (report.launch.enabled) { lines.push(' launch: alive-after-' + (report.launch.elapsedMs / 1000).toFixed(1) + 's=' + report.launch.aliveAtEnd + ' pid=' + report.launch.pid + ' killed=' + report.launch.killed); } else { lines.push(' launch: disabled (default; use --launch for a short health start)'); } } if (report.cleanup) { const detail = report.cleanup.mode === 'keep' ? 'kept at ' + report.cleanup.dir : 'mode=' + report.cleanup.mode + (report.cleanup.uninstallerExitCode != null ? ' uninstaller-exit=' + report.cleanup.uninstallerExitCode : '') + (report.cleanup.residueKilled != null ? ' residue-killed=' + report.cleanup.residueKilled : '') + ' removed=' + report.cleanup.removed; lines.push(' cleanup: ' + detail); } for (const warning of report.warnings || []) { lines.push(' warning: ' + warning); } if (report.reason) lines.push(' reason: ' + report.reason); lines.push('Verdict: ' + report.verdict); console.log(lines.join('\n')); } async function runSmoke(args) { const report = { verdict: 'FAIL', mode: 'temp-install', generatedAt: new Date().toISOString(), warnings: [], }; const config = readDesktopConfig(repoRoot); const manifestPath = args.manifestPath || DEFAULT_MANIFEST; let manifest = null; if (fs.existsSync(manifestPath)) { try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (error) { report.warnings.push('manifest unreadable: ' + manifestPath); } } const found = findInstaller({ root: repoRoot, explicit: args.installer, manifest, config }); report.installer = { source: found.source, file: found.file, bytes: found.bytes, exists: found.exists }; if (!found.exists) { report.verdict = 'NO_INSTALLER'; report.reason = 'NSIS installer artifact not found (searched: ' + (found.candidates.length ? found.candidates.join('; ') : 'nothing') + '). Build it with "npm run build:desktop" or pass --installer .'; return report; } report.installer.sha256 = await sha256File(found.file); const expected = args.expectedSha256 || expectedSha256FromManifest(manifest); report.installer.expectedSha256 = expected; report.installer.match = expected ? report.installer.sha256 === expected : null; if (expected && !report.installer.match) { report.verdict = 'FAIL'; report.reason = 'installer sha256 mismatch: got ' + report.installer.sha256 + ', expected ' + expected; return report; } if (!expected) { report.warnings.push('no expected sha256 available (no manifest / --expected-sha256); integrity reported only'); } if (/[^\x00-\x7F]/.test(args.tempRoot)) { report.warnings.push('temp root contains non-ASCII characters: ' + args.tempRoot + '; prefer an ASCII --temp-root for NSIS compatibility'); } let createdDir = null; let cleanupError = null; try { createdDir = fs.mkdtempSync(path.join(args.tempRoot, SMOKE_PREFIX)); const installDir = createdDir; report.install = { dir: installDir }; const installResult = await runInstaller(found.file, installDir, { timeoutMs: args.timeoutMs }); report.install.exitCode = installResult.exitCode; report.install.elapsedMs = installResult.elapsedMs; if (installResult.stdout.trim()) report.install.stdout = installResult.stdout.trim().slice(0, 2000); if (installResult.stderr.trim()) report.install.stderr = installResult.stderr.trim().slice(0, 2000); if (installResult.exitCode !== 0) { report.verdict = 'FAIL'; report.reason = 'silent install failed with exit code ' + installResult.exitCode + (installResult.stderr.trim() ? ': ' + installResult.stderr.trim().slice(0, 500) : ''); return report; } const layout = verifyInstallLayout(installDir, config.productName); const contents = listContents(installDir, args.topN); report.layout = Object.assign({}, layout, { fileCount: contents.fileCount, totalBytes: contents.totalBytes }); report.contents = contents.top.map((entry) => ({ file: path.relative(installDir, entry.file), bytes: entry.bytes })); if (!layout.valid) { report.verdict = 'FAIL'; report.reason = 'installed layout invalid: ' + layout.reasons.join('; '); return report; } if (args.launch) { const launch = await launchSmoke(path.join(installDir, config.productName + '.exe'), installDir, { seconds: args.launchSeconds }); report.launch = { enabled: true, pid: launch.pid, aliveAtEnd: launch.aliveAtEnd, elapsedMs: launch.elapsedMs, killed: launch.killed }; if (!launch.aliveAtEnd) { report.verdict = 'FAIL'; report.reason = 'main exe exited before the ' + args.launchSeconds + 's health window'; return report; } } else { report.launch = { enabled: false, note: 'default: no launch to avoid resident processes; use --launch for a short health start' }; } report.cleanup = { mode: args.keepTempDir ? 'keep' : (args.runUninstaller ? 'uninstaller' : 'delete') }; if (args.keepTempDir) { report.cleanup.dir = installDir; report.warnings.push('--keep-temp-dir: leaving install at ' + installDir + ' for inspection (remove manually when done)'); } else { const residue = processesUnderDir(installDir); if (residue.length > 0) { const killed = killPids(residue); report.cleanup.residue = residue; report.cleanup.residueKilled = killed; if (killed > 0) report.warnings.push('killed ' + killed + ' lingering process(es) under the temp install (installer auto-launch residue)'); } if (args.runUninstaller) { const uninstaller = findUninstaller(installDir, config.productName); if (uninstaller) { try { const uninstallResult = await runUninstaller(uninstaller); report.cleanup.uninstallerExitCode = uninstallResult.exitCode; report.cleanup.uninstallerElapsedMs = uninstallResult.elapsedMs; if (uninstallResult.exitCode !== 0) { report.warnings.push('uninstaller exited with code ' + uninstallResult.exitCode + '; falling back to direct temp-dir removal'); } else { // The uninstaller removes files asynchronously after its process exits. const gone = await waitForPathRemoval(installDir); if (!gone) report.warnings.push('uninstaller finished but the temp dir lingered; removing it directly'); } } catch (error) { report.warnings.push('uninstaller failed: ' + String((error && error.message) || error) + '; falling back to direct temp-dir removal'); } } else { report.warnings.push('uninstaller exe not found; falling back to direct temp-dir removal'); } } try { if (fs.existsSync(installDir)) { const lingering = processesUnderDir(installDir); if (lingering.length > 0) killPids(lingering); const removedResult = await safeRemoveTempDir(installDir); report.cleanup.removed = removedResult.removed; } else { report.cleanup.removed = true; // uninstaller already removed it } report.cleanup.dir = installDir; } catch (error) { cleanupError = String((error && error.message) || error); } } } finally { // Safety net: never leave our own temp dir behind on any exit path. if (createdDir && !args.keepTempDir && fs.existsSync(createdDir)) { const gone = await waitForPathRemoval(createdDir, { timeoutMs: 10000 }); if (!gone) { try { await safeRemoveTempDir(createdDir); report.warnings.push('safety-net cleanup removed leftover temp dir: ' + createdDir); } catch (error) { cleanupError = cleanupError || String((error && error.message) || error); report.warnings.push('safety-net cleanup failed: ' + String((error && error.message) || error)); } } } } if (cleanupError) { report.verdict = 'FAIL'; report.reason = 'cleanup failed: ' + cleanupError; report.cleanup = report.cleanup || {}; report.cleanup.removed = false; return report; } if (createdDir && !args.keepTempDir && fs.existsSync(createdDir)) { report.verdict = 'FAIL'; report.reason = 'temp dir still exists after cleanup: ' + createdDir; return report; } report.verdict = 'PASS'; return report; } async function runSelfTest() { const results = []; const runCheck = async (name, fn) => { try { const outcome = fn(); if (outcome && typeof outcome.then === 'function') await outcome; results.push({ name, pass: true }); console.log(' [PASS] ' + name); } catch (error) { results.push({ name, pass: false }); console.log(' [FAIL] ' + name + ': ' + String((error && error.message) || error)); } }; const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'aps-nsis-selftest-')); try { await runCheck('parseArgs: flags and positional installer', () => { const args = parseArgs([ 'C:/tmp/aps-agent-desktop-0.1.0.exe', '--run-uninstaller', '--launch', '--launch-seconds', '5', '--timeout', '30000', '--expected-sha256', 'a'.repeat(64), ]); if (args.installer !== path.resolve('C:/tmp/aps-agent-desktop-0.1.0.exe')) throw new Error('installer not parsed'); if (!args.runUninstaller || !args.launch) throw new Error('flags not parsed'); if (args.launchSeconds !== 5 || args.timeoutMs !== 30000) throw new Error('numbers not parsed'); if (args.expectedSha256 !== 'a'.repeat(64)) throw new Error('hash not normalized'); }); await runCheck('parseArgs: --no-launch disables launch', () => { const args = parseArgs(['--launch', '--no-launch']); if (args.launch) throw new Error('--no-launch ignored'); }); await runCheck('parseArgs: rejects unknown option', () => { let threw = false; try { parseArgs(['--bogus']); } catch { threw = true; } if (!threw) throw new Error('expected throw'); }); await runCheck('assertSafeTempDir: accepts own mkdtemp dir', () => { const dir = fs.mkdtempSync(path.join(tmpRoot, 'aps-nsis-smoke-')); const guarded = assertSafeTempDir(dir, tmpRoot); if (guarded.resolved !== path.resolve(dir)) throw new Error('resolution mismatch'); fs.rmSync(dir, { recursive: true, force: true }); }); await runCheck('assertSafeTempDir: rejects the temp root itself', () => { let threw = false; try { assertSafeTempDir(tmpRoot, tmpRoot); } catch { threw = true; } if (!threw) throw new Error('expected throw for temp root'); }); await runCheck('assertSafeTempDir: rejects a path outside the temp root', () => { let threw = false; try { assertSafeTempDir(path.join(os.tmpdir(), 'unrelated'), tmpRoot); } catch { threw = true; } if (!threw) throw new Error('expected throw for outside path'); }); await runCheck('assertSafeTempDir: rejects wrong prefix', () => { const dir = fs.mkdtempSync(path.join(tmpRoot, 'other-prefix-')); let threw = false; try { assertSafeTempDir(dir, tmpRoot); } catch { threw = true; } if (!threw) throw new Error('expected throw for wrong prefix'); fs.rmSync(dir, { recursive: true, force: true }); }); await runCheck('verifyInstallLayout: main-exe layout is valid', () => { const installDir = path.join(tmpRoot, 'aps-nsis-smoke-layout1'); fs.mkdirSync(path.join(installDir, 'resources', 'sidecar'), { recursive: true }); fs.writeFileSync(path.join(installDir, '工业智核 APS.exe'), 'x'); fs.writeFileSync(path.join(installDir, 'resources', 'app.asar'), 'x'); fs.writeFileSync(path.join(installDir, 'resources', 'sidecar', 'aps-sidecar.exe'), 'x'); fs.writeFileSync(path.join(installDir, 'Uninstall 工业智核 APS.exe'), 'x'); const result = verifyInstallLayout(installDir, '工业智核 APS'); if (!result.valid) throw new Error('expected valid layout, got ' + JSON.stringify(result.reasons)); if (path.basename(result.uninstaller) !== 'Uninstall 工业智核 APS.exe') throw new Error('uninstaller not found'); }); await runCheck('verifyInstallLayout: missing resources is invalid', () => { const installDir = path.join(tmpRoot, 'aps-nsis-smoke-layout2'); fs.mkdirSync(installDir, { recursive: true }); fs.writeFileSync(path.join(installDir, '工业智核 APS.exe'), 'x'); const result = verifyInstallLayout(installDir, '工业智核 APS'); if (result.valid) throw new Error('expected invalid layout'); }); await runCheck('findInstaller: explicit missing path reports missing', () => { const found = findInstaller({ root: tmpRoot, explicit: path.join(tmpRoot, 'nope.exe'), minBytes: 1 }); if (found.exists) throw new Error('expected missing'); if (found.candidates.length !== 1) throw new Error('expected explicit candidate only'); }); await runCheck('findInstaller: manifest entry is resolved', () => { const fakeDir = path.join(tmpRoot, 'fake-repo'); const releaseDir = path.join(fakeDir, 'apps', 'desktop', 'release'); fs.mkdirSync(releaseDir, { recursive: true }); const installer = path.join(releaseDir, 'aps-agent-desktop-0.1.0.exe'); fs.writeFileSync(installer, Buffer.alloc(1024, 1)); const manifest = { baseDir: fakeDir, entries: [{ id: 'nsis-installer', file: 'apps/desktop/release/aps-agent-desktop-0.1.0.exe', sha256: 'a'.repeat(64) }] }; const config = { outputDir: 'release', artifactName: 'aps-agent-desktop-${version}.${ext}', version: '0.1.0' }; const found = findInstaller({ root: fakeDir, manifest, config, minBytes: 1 }); if (!found.exists || found.source !== 'manifest') throw new Error('expected manifest discovery, got ' + found.source); }); await runCheck('expectedSha256FromManifest: reads nsis-installer entry', () => { const hash = expectedSha256FromManifest({ entries: [{ id: 'nsis-installer', sha256: 'ABC'.repeat(21) + 'A' }] }); if (hash !== 'abc'.repeat(21) + 'a') throw new Error('hash not normalized'); if (expectedSha256FromManifest({ entries: [] }) !== null) throw new Error('expected null'); if (expectedSha256FromManifest({ entries: [{ id: 'nsis-installer', sha256: 'zz' }] }) !== null) throw new Error('expected null for bad hash'); }); await runCheck('listContents: sorted top-N by size', () => { const dir = path.join(tmpRoot, 'aps-nsis-smoke-contents'); fs.mkdirSync(path.join(dir, 'sub'), { recursive: true }); fs.writeFileSync(path.join(dir, 'small.bin'), Buffer.alloc(10, 1)); fs.writeFileSync(path.join(dir, 'big.bin'), Buffer.alloc(100, 2)); fs.writeFileSync(path.join(dir, 'sub', 'mid.bin'), Buffer.alloc(50, 3)); const result = listContents(dir, 2); if (result.fileCount !== 3 || result.totalBytes !== 160) throw new Error('count/bytes mismatch'); if (result.top[0].bytes !== 100 || result.top[1].bytes !== 50) throw new Error('sort order wrong'); if (path.relative(dir, result.top[0].file) !== 'big.bin') throw new Error('relative path wrong'); }); await runCheck('findUninstaller: exact and glob fallback', () => { const dir = path.join(tmpRoot, 'aps-nsis-smoke-uninstall'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'Uninstall 工业智核 APS.exe'), 'x'); if (path.basename(findUninstaller(dir, '工业智核 APS')) !== 'Uninstall 工业智核 APS.exe') throw new Error('exact match failed'); fs.rmSync(path.join(dir, 'Uninstall 工业智核 APS.exe')); fs.writeFileSync(path.join(dir, 'Uninstall other.exe'), 'x'); if (path.basename(findUninstaller(dir, '工业智核 APS')) !== 'Uninstall other.exe') throw new Error('glob fallback failed'); }); } finally { fs.rmSync(tmpRoot, { recursive: true, force: true }); } const failed = results.filter((entry) => !entry.pass).length; console.log('nsis-install-smoke self-test: ' + (failed === 0 ? 'PASS (' + results.length + '/' + results.length + ')' : 'FAIL (' + (results.length - failed) + '/' + results.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('nsis-install-smoke: ' + String((error && error.message) || error)); process.exitCode = EXIT_FAIL; return; } if (args.help) { printHelp(); return; } if (args.selfTest) { await runSelfTest(); return; } if (process.platform !== 'win32') { console.error('nsis-install-smoke: NSIS install smoke requires Windows'); process.exitCode = EXIT_FAIL; return; } const report = await runSmoke(args); if (args.json) { console.log(JSON.stringify(report, null, 2)); } else { printReport(report); } process.exitCode = report.verdict === 'PASS' ? EXIT_PASS : (report.verdict === 'NO_INSTALLER' ? EXIT_NO_INSTALLER : EXIT_FAIL); } const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; if (isMain) { await main(); }