diff --git a/scripts/ci/verify-installer.js b/scripts/ci/verify-installer.js index 683e7d861..fe20502f7 100644 --- a/scripts/ci/verify-installer.js +++ b/scripts/ci/verify-installer.js @@ -1,5 +1,6 @@ #!/usr/bin/env node -// Verifies the Windows NSIS installer: default safely observes an existing install (dir, an uninstaller that exists, shortcuts); --destructive runs install->verify->uninstall but refuses to clobber an existing install unless --force (clean machine / CI). +// Verifies the Windows installer (Squirrel, per electron/package.json win.target): default safely observes an existing install (dir, an uninstaller that exists, shortcuts); --destructive runs install->verify->uninstall but refuses to clobber an existing install unless --force (clean machine / CI). +// Squirrel conventions, same as smoke-windows-packaged.yml: the setup exe lands in dist\squirrel-windows\, installs per-user into %LOCALAPPDATA%\OpenSwarm as a stub launcher beside a versioned app-\ folder, honours --silent (NOT /S, which opens a UI and waits for a click), and uninstalls through Update.exe --uninstall. 'use strict'; const fs = require('fs'); @@ -18,16 +19,31 @@ function parseArgs(argv) { return out; } -const INSTALL_DIR = path.join(process.env.LOCALAPPDATA || '', 'Programs', 'OpenSwarm'); +const INSTALL_DIR = path.join(process.env.LOCALAPPDATA || '', 'OpenSwarm'); const DATA_DIR = path.join(process.env.APPDATA || '', 'OpenSwarm'); const failures = []; const ok = (m) => process.stdout.write(` ok ${m}\n`); const bad = (m) => { failures.push(m); process.stdout.write(` FAIL ${m}\n`); }; const exists = (p) => { try { fs.statSync(p); return true; } catch { return false; } } +// electron-builder writes the Squirrel setup under dist\squirrel-windows\; the release flow copies a stable-named alias to dist\ root, so accept either. function defaultSetup() { - const p = path.join(h.REPO_ROOT, 'electron', 'dist', 'OpenSwarm-Setup-x64.exe'); - return exists(p) ? p : null; + const dist = path.join(h.REPO_ROOT, 'electron', 'dist'); + for (const p of [path.join(dist, 'squirrel-windows', 'OpenSwarm-Setup-x64.exe'), path.join(dist, 'OpenSwarm-Setup-x64.exe')]) { + if (exists(p)) return p; + } + return null; +} + +// The versioned exe (app-\OpenSwarm.exe) is the one that holds resources\ and is worth launching; the root OpenSwarm.exe is Squirrel's stub launcher. +function installedAppExe() { + let dirs = []; + try { dirs = fs.readdirSync(INSTALL_DIR).filter((d) => /^app-/i.test(d)).sort().reverse(); } catch { return null; } + for (const d of dirs) { + const exe = path.join(INSTALL_DIR, d, 'OpenSwarm.exe'); + if (exists(exe)) return exe; + } + return null; } // The shipped installer must be a real, complete PE (catch a 0-byte stub or truncated upload). @@ -43,13 +59,14 @@ function checkSetupArtifact(setup) { function regUninstall() { try { - const ps = `$ErrorActionPreference='SilentlyContinue'; Get-ChildItem 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall' | ForEach-Object { $p=Get-ItemProperty $_.PSPath; if ($p.DisplayName -like '*OpenSwarm*') { $p.DisplayName + '|' + $p.QuietUninstallString } }`; + const ps = `$ErrorActionPreference='SilentlyContinue'; Get-ChildItem 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall' | ForEach-Object { $p=Get-ItemProperty $_.PSPath; if ($p.DisplayName -like '*OpenSwarm*') { $p.DisplayName + '|' + $p.QuietUninstallString + '|' + $p.UninstallString } }`; return execSync(`powershell -NoProfile -Command "${ps.replace(/"/g, '\\"')}"`, { encoding: 'utf8' }).trim(); } catch { return ''; } } +// Squirrel registers `"...\\Update.exe" --uninstall` (an NSIS build registered "Uninstall OpenSwarm.exe"); accept both. function uninstallerPathFromReg(reg) { - const m = reg.match(/"([^"]*Uninstall OpenSwarm\.exe)"/i) || reg.match(/([A-Za-z]:\\[^|]*Uninstall OpenSwarm\.exe)/i); + const m = reg.match(/"([^"]*(?:Update\.exe|Uninstall OpenSwarm\.exe))"/i) || reg.match(/([A-Za-z]:\\[^|"]*(?:Update\.exe|Uninstall OpenSwarm\.exe))/i); return m ? m[1] : null; } @@ -70,8 +87,12 @@ function findShortcuts() { function assertInstalled() { if (!exists(INSTALL_DIR)) { bad(`install dir missing: ${INSTALL_DIR}`); return; } ok(`install dir present: ${INSTALL_DIR}`); - for (const f of ['OpenSwarm.exe', 'resources', 'locales']) { - if (exists(path.join(INSTALL_DIR, f))) ok(`contains ${f}`); else bad(`install dir missing ${f}`); + const appExe = installedAppExe(); + if (!appExe) { bad(`no app-*\\OpenSwarm.exe under ${INSTALL_DIR}`); return; } + ok(`versioned app present: ${appExe}`); + const appDir = path.dirname(appExe); + for (const f of ['resources', 'locales']) { + if (exists(path.join(appDir, f))) ok(`contains ${f}`); else bad(`app dir missing ${f}`); } const reg = regUninstall(); if (!/OpenSwarm/i.test(reg)) bad('no OpenSwarm uninstall entry in HKCU registry'); @@ -99,25 +120,25 @@ function runDestructive(setup) { } process.stdout.write('\n[destructive] installing silently...\n'); killRunning(); - const installedExe = path.join(INSTALL_DIR, 'OpenSwarm.exe'); - // Blocking: spawnSync returns only once the installer has fully finished - // (files + registry uninstall entry + shortcuts are all written, the last of - // which assertInstalled checks). The timeout is the safety net: the silent - // path now skips the unbounded --prewarm step (see installer-recovery.nsh), so - // a clean extract finishes well inside this, but if a future installer change - // reintroduces a synchronous stall this fails the gate in minutes instead of - // hanging the whole job until it is force-cancelled. - const inst = spawnSync(setup, ['/S'], { stdio: 'inherit', timeout: 300000 }); - if (inst.error) bad(`Setup.exe /S did not complete: ${inst.error.message}`); - else if (inst.status !== 0 && inst.status !== null) bad(`Setup.exe /S exited ${inst.status}`); - // oneClick installer can return microseconds before the exe handle settles. + // Blocking: spawnSync returns only once the installer has fully finished (files + registry + // uninstall entry + shortcuts are all written, the last of which assertInstalled checks). The + // timeout is the safety net: a wrong switch makes Squirrel open a UI and wait forever, which + // looks like a hang, so fail in minutes with the Squirrel log instead of eating the whole job. + const inst = spawnSync(setup, ['--silent'], { stdio: 'inherit', timeout: 600000 }); + if (inst.error) { + bad(`Setup.exe --silent did not complete: ${inst.error.message}`); + const log = path.join(process.env.LOCALAPPDATA || '', 'SquirrelTemp', 'SquirrelSetup.log'); + if (exists(log)) process.stdout.write(`--- SquirrelSetup.log (tail) ---\n${fs.readFileSync(log, 'utf8').split(/\r?\n/).slice(-60).join('\n')}\n`); + } else if (inst.status !== 0 && inst.status !== null) bad(`Setup.exe --silent exited ${inst.status}`); + // The installer can return microseconds before the versioned exe handle settles. const deadline = Date.now() + 30000; - while (Date.now() < deadline && !exists(installedExe)) { execSync('powershell -NoProfile -Command "Start-Sleep -Milliseconds 1000"'); } - killRunning(); // defensive: ensure nothing the installer launched is holding the dir + while (Date.now() < deadline && !installedAppExe()) { execSync('powershell -NoProfile -Command "Start-Sleep -Milliseconds 1000"'); } + killRunning(); // defensive: Squirrel launches the app after install; nothing may hold the dir assertInstalled(); - // Launch the INSTALLED exe through the full gate (boot/serve/provenance/etc.). - if (exists(installedExe)) { + // Launch the INSTALLED (versioned) exe through the full gate (boot/serve/provenance/etc.). + const installedExe = installedAppExe(); + if (installedExe) { process.stdout.write('\n[destructive] verifying the INSTALLED app...\n'); const v = spawnSync(process.execPath, [path.join(__dirname, 'verify-all.js'), '--app', installedExe], { stdio: 'inherit' }); if (v.status !== 0) bad('verify-all against the installed app failed'); @@ -127,11 +148,14 @@ function runDestructive(setup) { killRunning(); const reg = regUninstall(); const up = uninstallerPathFromReg(reg); - if (up && exists(up)) { const un = spawnSync(up, ['/currentuser', '/S'], { stdio: 'inherit' }); if (un.status) bad(`uninstaller exited ${un.status}`); } - else bad('could not find the uninstaller to run'); + if (up && exists(up)) { + const args = /Update\.exe$/i.test(up) ? ['--uninstall'] : ['/currentuser', '/S']; + const un = spawnSync(up, args, { stdio: 'inherit', timeout: 300000 }); + if (un.status) bad(`uninstaller exited ${un.status}`); + } else bad('could not find the uninstaller to run'); const delDeadline = Date.now() + 60000; - while (Date.now() < delDeadline && exists(path.join(INSTALL_DIR, 'OpenSwarm.exe'))) { execSync('powershell -NoProfile -Command "Start-Sleep -Milliseconds 1000"'); } - if (exists(path.join(INSTALL_DIR, 'OpenSwarm.exe'))) bad('uninstall left OpenSwarm.exe behind'); + while (Date.now() < delDeadline && installedAppExe()) { execSync('powershell -NoProfile -Command "Start-Sleep -Milliseconds 1000"'); } + if (installedAppExe()) bad('uninstall left a versioned app folder behind'); else ok('uninstall removed the app'); // deleteAppDataOnUninstall:false - user data MUST survive an uninstall. if (exists(DATA_DIR)) ok(`user data preserved across uninstall: ${DATA_DIR}`); @@ -140,7 +164,7 @@ function runDestructive(setup) { function main() { const args = parseArgs(process.argv.slice(2)); - if (process.platform !== 'win32') { process.stdout.write('SKIP: NSIS installer check is Windows-only.\n'); process.exit(0); } + if (process.platform !== 'win32') { process.stdout.write('SKIP: Windows installer check is Windows-only.\n'); process.exit(0); } const setup = args.setup || defaultSetup(); if (!setup) { process.stderr.write('INSTALLER FAIL: no Setup.exe found; build first or pass --setup.\n'); process.exit(1); }