aps-agent/scripts/nsis-install-smoke.mjs

804 lines
38 KiB
JavaScript

#!/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\<productName>) or any system directory.
*
* Usage:
* node scripts/nsis-install-smoke.mjs [installer.exe] [options]
* node scripts/nsis-install-smoke.mjs --installer <path> --run-uninstaller
* node scripts/nsis-install-smoke.mjs --self-test
*
* Options:
* positional / --installer <path> explicit NSIS installer path
* --expected-sha256 <hex> expected sha256 (overrides manifest)
* --manifest <path> offline manifest with the expected sha256
* (default build/offline-check/offline-install-manifest.json)
* --timeout <ms> 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 <n> alive-check window in seconds (default 8)
* --temp-root <dir> 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 <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=<temp>) 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 <path> explicit NSIS installer path (or first positional arg)',
' --expected-sha256 <hex> expected sha256 (overrides the manifest)',
' --manifest <path> manifest with expected sha256 (default build/offline-check/offline-install-manifest.json)',
' --timeout <ms> 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 <n> alive-check window (default 8)',
' --temp-root <dir> temp root for the install dir (default os.tmpdir())',
' --keep-temp-dir keep the temp install dir for inspection',
' --top-n <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 <path>.';
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();
}