804 lines
38 KiB
JavaScript
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();
|
|
}
|