diff --git a/apps/bootstrap-installer/src-tauri/src/update.rs b/apps/bootstrap-installer/src-tauri/src/update.rs index 539f69e9f785..ca296a52189d 100644 --- a/apps/bootstrap-installer/src-tauri/src/update.rs +++ b/apps/bootstrap-installer/src-tauri/src/update.rs @@ -224,17 +224,7 @@ async fn run_update(app: AppHandle) -> Result<()> { &format!("[update] updating against branch {update_branch}"), ); let child_env = update_child_env(&install_root); - let mut update_args: Vec = - vec!["update".into(), "--yes".into(), "--gateway".into()]; - // --force skips `hermes update`'s Windows running-exe guard (which would - // `sys.exit(2)` and dead-end the handoff). By contract the desktop has - // already exited and waited for the install locks to clear before launching - // us, and wait_for_install_locks_free below force-kills any straggler — so by the - // time `hermes update` runs there is no legitimate hermes.exe to protect, - // and the guard would only produce a false "Hermes is still running" stop. - update_args.push("--force".into()); - update_args.push("--branch".into()); - update_args.push(update_branch); + let update_args = build_update_args(&update_branch); emit_stage(&app, "update", StageState::Running, None, None); let started = Instant::now(); @@ -762,6 +752,25 @@ where .filter(|s| !s.is_empty()) } +fn build_update_args(update_branch: &str) -> Vec { + vec![ + "update".into(), + "--yes".into(), + "--gateway".into(), + // Desktop updates replace the app underneath the user. Force the same + // restore point users can request from the CLI before any mutation. + "--backup".into(), + // --force skips `hermes update`'s Windows running-exe guard (which + // would `sys.exit(2)` and dead-end the handoff). By contract the + // desktop has already exited and waited for the venv shim to unlock + // before launching us, and wait_for_venv_free force-kills any + // straggler, so the guard would only produce a false stop. + "--force".into(), + "--branch".into(), + update_branch.into(), + ] +} + fn target_app_from_args(args: I) -> Option where I: IntoIterator, @@ -1101,6 +1110,25 @@ mod tests { assert_eq!(update_branch_from_args(["--update"]), None); } + #[test] + fn desktop_update_args_force_pre_update_backup() { + let args = build_update_args("main"); + + assert!(args.contains(&"--backup".to_string())); + assert_eq!( + args, + vec![ + "update".to_string(), + "--yes".to_string(), + "--gateway".to_string(), + "--backup".to_string(), + "--force".to_string(), + "--branch".to_string(), + "main".to_string(), + ] + ); + } + #[test] fn rebuild_retries_only_on_failure() { assert!(!rebuild_needs_retry(Some(0)), "a clean rebuild must not retry"); diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index ce42e3474dc2..b2aad20e21ea 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -59,6 +59,12 @@ const { worktreesForIpc } = require('./git-worktrees.cjs') const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') const { runRebuildWithRetry } = require('./update-rebuild.cjs') +const { + buildHermesUpdateArgs, + buildManualHermesUpdateCommand, + createUpdaterLaunchPlan +} = require('./update-handoff.cjs') +const { killHermesRuntimeProcessesForUpdate } = require('./update-processes.cjs') const { buildPosixCleanupScript, buildWindowsCleanupScript, @@ -1913,6 +1919,17 @@ async function releaseBackendLock(updateRoot, tag) { stopAllPoolBackends() for (const pid of pids) forceKillProcessTree(pid) + const runtimePids = killHermesRuntimeProcessesForUpdate(updateRoot, { + currentPid: process.pid, + killTree: forceKillProcessTree, + onListError: err => + rememberLog(`[updates] could not enumerate Hermes runtime processes before update: ${err?.message || err}`), + onError: (pid, err) => rememberLog(`[updates] failed to stop Hermes runtime process ${pid}: ${err?.message || err}`) + }) + if (runtimePids.length) { + rememberLog(`[updates] stopped ${runtimePids.length} Hermes runtime process(es) before update`) + } + const shim = venvHermesShimPath(updateRoot) const deadlineMs = Date.now() + 15000 while (Date.now() < deadlineMs) { @@ -1959,21 +1976,22 @@ async function applyUpdates(opts = {}) { // hermes-setup.exe into HERMES_HOME). They DO have a working `hermes` // on PATH / in the venv, so the correct path is the one-liner in their // native medium. We show the EXACT command, branch-pinned to the - // checkout they're on — bare `hermes update` defaults to main and would - // silently switch a bb/gui (or any non-main) install off-branch. Mirror - // the GUI button's contract: append --branch for non-main - // checkouts, keep it bare for main so the card stays clean. + // checkout they're on — unpinned `hermes update --backup` defaults to + // main and would silently switch a bb/gui (or any non-main) install + // off-branch. Mirror the GUI button's contract: append --branch + // for non-main checkouts, omit it for main so the card stays + // clean. const updateRoot = resolveUpdateRoot() - let command = 'hermes update' + let command = buildManualHermesUpdateCommand() try { const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot }) const current = (head.stdout || '').trim() if (head.code === 0 && current && current !== 'HEAD') { const branch = await resolveHealedBranch(updateRoot, current) - if (branch !== 'main') command = `hermes update --branch ${branch}` + if (branch !== 'main') command = buildManualHermesUpdateCommand(branch) } } catch { - // Best-effort: fall back to bare `hermes update` if branch detection fails. + // Best-effort: fall back to branch-agnostic `hermes update --backup` if detection fails. } rememberLog(`[updates] no staged updater; surfacing manual \`${command}\` for CLI install at ${updateRoot}`) emitUpdateProgress({ stage: 'manual', message: command, percent: null }) @@ -2005,20 +2023,29 @@ async function applyUpdates(opts = {}) { // Detached so the updater outlives this process — it needs us GONE before // `hermes update` will run (the venv shim is locked while we live). - const child = spawn(updater, updaterArgs, { + const launch = createUpdaterLaunchPlan({ + handoffDir: path.join(HERMES_HOME, 'logs'), + isWindows: IS_WINDOWS, + updater, + updaterArgs + }) + const child = spawn(launch.command, launch.args, { cwd: HERMES_HOME, env: { ...process.env, HERMES_HOME, PATH: pathWithHermesManagedNode(venvBin) }, - detached: true, + detached: launch.detached, stdio: 'ignore', - windowsHide: false + windowsHide: launch.windowsHide }) child.unref() - rememberLog(`[updates] launched updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release venv shim`) + rememberLog( + `[updates] launched updater: ${launch.command} ${launch.args.join(' ')}; ` + + `script=${launch.scriptPath || 'direct'}; exiting desktop to release venv shim` + ) // Linger on the "updating — don't reopen" overlay long enough for the user // to actually read it (and to bridge the gap until the updater's own window @@ -2136,8 +2163,9 @@ async function applyUpdatesPosixInApp() { const updateRoot = resolveUpdateRoot() const hermes = resolveHermesCliBinary(updateRoot) if (!hermes) { - emitUpdateProgress({ stage: 'manual', message: 'hermes update', percent: null }) - return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot } + const command = buildManualHermesUpdateCommand() + emitUpdateProgress({ stage: 'manual', message: command, percent: null }) + return { ok: true, manual: true, command, hermesRoot: updateRoot } } // Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s @@ -2174,19 +2202,19 @@ async function applyUpdatesPosixInApp() { // Branch-pin so a non-main checkout doesn't get switched to main (and self-heal // to main when the pinned branch no longer exists on origin). - let branchArgs = [] + let updateBranch = null try { const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot }) const current = (head.stdout || '').trim() if (head.code === 0 && current && current !== 'HEAD') { - branchArgs = ['--branch', await resolveHealedBranch(updateRoot, current)] + updateBranch = await resolveHealedBranch(updateRoot, current) } } catch { // best effort } emitUpdateProgress({ stage: 'update', message: 'Updating Hermes (git + dependencies)…', percent: 10 }) - const updated = await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], { + const updated = await runStreamedUpdate(hermes, buildHermesUpdateArgs({ assumeYes: true, branch: updateBranch }), { cwd: updateRoot, env, stage: 'update' diff --git a/apps/desktop/electron/update-handoff-smoke.cjs b/apps/desktop/electron/update-handoff-smoke.cjs new file mode 100644 index 000000000000..0f3a17d9fc52 --- /dev/null +++ b/apps/desktop/electron/update-handoff-smoke.cjs @@ -0,0 +1,626 @@ +const fs = require('node:fs') +const net = require('node:net') +const os = require('node:os') +const path = require('node:path') +const { spawn, spawnSync } = require('node:child_process') + +const DESKTOP_ROOT = path.resolve(__dirname, '..') +const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') +const DEFAULT_APP_EXE = path.join(DESKTOP_ROOT, 'release', 'win-unpacked', 'Hermes.exe') +const POWERSHELL_EXE = path.join( + process.env.WINDIR || 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' +) + +function fail(message) { + throw new Error(message) +} + +function skip(message) { + console.log(`Skipping desktop update handoff smoke: ${message}`) +} + +function quotePowerShell(value) { + return `'${String(value).replace(/'/g, "''")}'` +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: options.stdio || 'pipe', + windowsHide: true, + ...options + }) + if (result.status !== 0) { + fail( + [ + `Command failed: ${command} ${args.join(' ')}`, + result.stdout ? `stdout:\n${result.stdout}` : '', + result.stderr ? `stderr:\n${result.stderr}` : '' + ] + .filter(Boolean) + .join('\n') + ) + } + return result +} + +function mkdirp(dir) { + fs.mkdirSync(dir, { recursive: true }) +} + +function writeFile(file, contents) { + mkdirp(path.dirname(file)) + fs.writeFileSync(file, contents) +} + +function createFakeInstalledHermesRoot(updateRoot) { + writeFile(path.join(updateRoot, 'hermes_cli', '__init__.py'), '') + writeFile( + path.join(updateRoot, 'hermes_cli', 'main.py'), + [ + 'import argparse', + 'import json', + 'from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer', + '', + 'class Handler(BaseHTTPRequestHandler):', + ' def do_GET(self):', + ' if self.path.startswith("/api/status"):', + ' body = json.dumps({"ok": True, "smoke": True}).encode("utf-8")', + ' self.send_response(200)', + ' self.send_header("Content-Type", "application/json")', + ' self.send_header("Content-Length", str(len(body)))', + ' self.end_headers()', + ' self.wfile.write(body)', + ' return', + ' self.send_response(404)', + ' self.end_headers()', + '', + ' def log_message(self, *_args):', + ' return', + '', + 'if __name__ == "__main__":', + ' parser = argparse.ArgumentParser()', + ' parser.add_argument("command", nargs="?")', + ' parser.add_argument("--no-open", action="store_true")', + ' parser.add_argument("--host", default="127.0.0.1")', + ' parser.add_argument("--port", type=int, required=True)', + ' args = parser.parse_args()', + ' if args.command != "dashboard":', + ' raise SystemExit(f"unsupported smoke command: {args.command}")', + ' ThreadingHTTPServer((args.host, args.port), Handler).serve_forever()', + '' + ].join('\n') + ) + writeFile( + path.join(updateRoot, '.hermes-bootstrap-complete'), + JSON.stringify( + { + schemaVersion: 1, + pinnedCommit: '0000000', + pinnedBranch: 'main', + completedAt: new Date().toISOString(), + desktopVersion: 'smoke' + }, + null, + 2 + ) + '\n' + ) +} + +function resolveSmokePython() { + const candidates = [ + process.env.HERMES_DESKTOP_SMOKE_PYTHON, + path.join(REPO_ROOT, 'venv', 'Scripts', 'python.exe') + ].filter(Boolean) + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return path.resolve(candidate) + } + + const result = spawnSync('py', ['-3.11', '-c', 'import sys; print(sys.executable)'], { + encoding: 'utf8', + stdio: 'pipe', + windowsHide: true + }) + if (result.status === 0 && result.stdout.trim() && fs.existsSync(result.stdout.trim())) { + return result.stdout.trim() + } + + return null +} + +function isLocked(file) { + let fd + try { + fd = fs.openSync(file, 'r+') + return false + } catch { + return fs.existsSync(file) + } finally { + if (fd !== undefined) fs.closeSync(fd) + } +} + +async function sleep(ms) { + await new Promise(resolve => setTimeout(resolve, ms)) +} + +async function waitFor(predicate, { label, timeoutMs = 30_000, intervalMs = 100 } = {}) { + const deadline = Date.now() + timeoutMs + let lastError = null + while (Date.now() < deadline) { + try { + const result = await predicate() + if (result) return result + } catch (error) { + lastError = error + } + await sleep(intervalMs) + } + if (lastError) { + fail(`Timed out waiting for ${label}: ${lastError.message}`) + } + fail(`Timed out waiting for ${label}`) +} + +function waitForExit(child, timeoutMs, label) { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve({ code: child.exitCode, signal: child.signalCode }) + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`${label} did not exit within ${timeoutMs}ms`)) + }, timeoutMs) + child.once('exit', (code, signal) => { + clearTimeout(timer) + resolve({ code, signal }) + }) + }) +} + +function taskkill(pid) { + if (!Number.isInteger(pid) || pid <= 0) return + spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true + }) +} + +function isProcessRunning(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false + const result = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; if ($p) { '1' }` + ], + { + encoding: 'utf8', + stdio: 'pipe', + timeout: 5000, + windowsHide: true + } + ) + return result.status === 0 && result.stdout.trim() === '1' +} + +function smokeAppPath() { + const entries = [ + path.join(process.env.WINDIR || 'C:\\Windows', 'System32'), + process.env.WINDIR || 'C:\\Windows', + path.join(process.env.WINDIR || 'C:\\Windows', 'System32', 'Wbem'), + path.join(process.env.WINDIR || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0') + ] + return entries.join(path.delimiter) +} + +function dotnetTargetFramework() { + const result = spawnSync('dotnet', ['--list-sdks'], { + encoding: 'utf8', + stdio: 'pipe', + windowsHide: true + }) + if (result.error) { + if (result.error.code === 'ENOENT') return null + fail(`Could not run dotnet --list-sdks: ${result.error.message}`) + } + if (result.status !== 0) return null + + const majors = result.stdout + .split(/\r?\n/) + .map(line => Number.parseInt(line.split('.')[0], 10)) + .filter(Number.isInteger) + const major = Math.max(...majors) + if (!Number.isFinite(major) || major <= 0) return null + return `net${major}.0` +} + +function compileFakeUpdater(homeDir, sourceDir, targetFramework) { + const projectDir = path.join(sourceDir, 'FakeHermesUpdater') + const publishDir = path.join(projectDir, 'publish') + mkdirp(projectDir) + + writeFile( + path.join(projectDir, 'FakeHermesUpdater.csproj'), + ` + + Exe + ${targetFramework} + enable + enable + + +` + ) + + writeFile( + path.join(projectDir, 'Program.cs'), + `using System.Diagnostics; +using System.Text.Json; + +var log = Environment.GetEnvironmentVariable("HERMES_FAKE_UPDATER_LOG"); +if (string.IsNullOrWhiteSpace(log)) +{ + log = Path.Combine(Environment.GetEnvironmentVariable("HERMES_HOME") ?? AppContext.BaseDirectory, "fake-updater.json"); +} +Directory.CreateDirectory(Path.GetDirectoryName(log)!); + +int? relaunchPid = null; +var relaunch = Environment.GetEnvironmentVariable("HERMES_FAKE_UPDATER_RELAUNCH"); +if (!string.IsNullOrWhiteSpace(relaunch) && File.Exists(relaunch)) +{ + var psi = new ProcessStartInfo(relaunch) + { + UseShellExecute = false, + WorkingDirectory = Path.GetDirectoryName(relaunch)! + }; + psi.Environment["HERMES_FAKE_RELAUNCHED"] = "1"; + foreach (var key in new[] { + "HERMES_HOME", + "HERMES_DESKTOP_USER_DATA_DIR", + "HERMES_DESKTOP_HERMES_ROOT", + "HERMES_DESKTOP_BOOT_FAKE", + "HERMES_DESKTOP_BOOT_FAKE_STEP_MS", + "HERMES_DESKTOP_DISABLE_GPU" + }) + { + var value = Environment.GetEnvironmentVariable(key); + if (value is not null) psi.Environment[key] = value; + } + relaunchPid = Process.Start(psi)?.Id; +} + +var payload = new +{ + pid = Environment.ProcessId, + args, + cwd = Environment.CurrentDirectory, + hermesHome = Environment.GetEnvironmentVariable("HERMES_HOME"), + path = Environment.GetEnvironmentVariable("PATH"), + relaunchPid, + startedAtUtc = DateTimeOffset.UtcNow +}; +File.WriteAllText(log, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })); +` + ) + + run( + 'dotnet', + [ + 'publish', + projectDir, + '-c', + 'Release', + '-r', + 'win-x64', + '--self-contained', + 'false', + '-p:PublishSingleFile=true', + '-p:DebugType=None', + '-p:DebugSymbols=false', + '-o', + publishDir + ], + { + env: { + ...process.env, + DOTNET_CLI_TELEMETRY_OPTOUT: '1', + DOTNET_NOLOGO: '1', + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' + } + } + ) + + const builtExe = path.join(publishDir, 'FakeHermesUpdater.exe') + const updater = path.join(homeDir, 'hermes-setup.exe') + fs.copyFileSync(builtExe, updater) + return updater +} + +function createLockedRuntime(updateRoot, shimPath) { + const lockHolder = path.join(updateRoot, 'hermes_cli', 'lock-holder.js') + mkdirp(path.dirname(shimPath)) + writeFile(shimPath, 'fake shim locked by update smoke\n') + + const lockScript = [ + `$stream = [System.IO.File]::Open(${quotePowerShell(shimPath)}, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)`, + 'try { Start-Sleep -Seconds 120 } finally { $stream.Dispose() }' + ].join('; ') + writeFile( + lockHolder, + `const { spawn } = require('node:child_process') + +const child = spawn(${JSON.stringify(POWERSHELL_EXE)}, [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + ${JSON.stringify(lockScript)} +], { + stdio: 'ignore', + windowsHide: true +}) + +child.once('exit', (code) => process.exit(code ?? 0)) +process.once('exit', () => { + try { + child.kill() + } catch { + // Best effort; the update smoke kills the whole process tree. + } +}) +setInterval(() => {}, 1000) +` + ) + + return spawn( + process.execPath, + [lockHolder, 'hermes_cli.main'], + { + cwd: updateRoot, + stdio: 'ignore', + windowsHide: true + } + ) +} + +function getFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const port = typeof address === 'object' && address ? address.port : null + server.close(() => { + if (!port) reject(new Error('Could not allocate a port')) + else resolve(port) + }) + }) + }) +} + +async function fetchJson(url) { + const response = await fetch(url) + if (!response.ok) fail(`${url} returned HTTP ${response.status}`) + return await response.json() +} + +async function waitForRendererTarget(port) { + return await waitFor( + async () => { + const targets = await fetchJson(`http://127.0.0.1:${port}/json/list`) + return targets.find(target => target.type === 'page' && target.webSocketDebuggerUrl) + }, + { label: 'Electron renderer debug target', timeoutMs: 45_000, intervalMs: 250 } + ) +} + +function cdpCall(ws, id, method, params) { + return new Promise((resolve, reject) => { + const handleMessage = event => { + const message = JSON.parse(String(event.data)) + if (message.id !== id) return + ws.removeEventListener('message', handleMessage) + if (message.error) reject(new Error(`${method} failed: ${JSON.stringify(message.error)}`)) + else resolve(message) + } + ws.addEventListener('message', handleMessage) + ws.send(JSON.stringify({ id, method, params })) + }) +} + +async function evaluateInRenderer(webSocketDebuggerUrl, expression) { + if (typeof WebSocket !== 'function') fail('This smoke requires a Node runtime with global WebSocket support') + + const ws = new WebSocket(webSocketDebuggerUrl) + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve, { once: true }) + ws.addEventListener('error', reject, { once: true }) + }) + + try { + await cdpCall(ws, 1, 'Runtime.enable') + const response = await cdpCall(ws, 2, 'Runtime.evaluate', { + awaitPromise: true, + expression, + returnByValue: true, + timeout: 20_000 + }) + if (response.result.exceptionDetails) { + fail(JSON.stringify(response.result.exceptionDetails, null, 2)) + } + return response.result.result.value + } finally { + ws.close() + } +} + +function isExpectedHandoffRendererShutdown(error) { + return /Execution context was destroyed/i.test(String(error?.message || error)) +} + +async function main() { + if (process.platform !== 'win32') { + skip('Windows-only smoke is not available on this platform.') + return + } + if (typeof WebSocket !== 'function') { + skip('Node runtime does not provide global WebSocket support.') + return + } + + const appExe = path.resolve(process.env.HERMES_DESKTOP_SMOKE_APP || DEFAULT_APP_EXE) + if (!fs.existsSync(appExe)) { + fail(`Missing built desktop app at ${appExe}. Run "python -m hermes_cli.main desktop --build-only" first.`) + } + if (!fs.existsSync(POWERSHELL_EXE)) fail(`Missing PowerShell at ${POWERSHELL_EXE}`) + + const targetFramework = dotnetTargetFramework() + if (!targetFramework) { + skip('dotnet SDK is not available to build the fake updater.') + return + } + const smokePython = resolveSmokePython() + if (!smokePython) { + skip('Python 3.11 is not available for the fake dashboard backend.') + return + } + + const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '') + const smokeRoot = path.resolve( + process.env.HERMES_DESKTOP_UPDATE_SMOKE_ROOT || + path.join(os.tmpdir(), `hermes-desktop-update-handoff-${stamp}`) + ) + const homeDir = path.join(smokeRoot, 'home') + const userDataDir = path.join(smokeRoot, 'user-data') + const updateRoot = path.join(smokeRoot, 'hermes-agent') + const shimPath = path.join(updateRoot, 'venv', 'Scripts', 'hermes.exe') + const updaterLog = path.join(smokeRoot, 'fake-updater.json') + const desktopLog = path.join(homeDir, 'logs', 'desktop.log') + let relaunchedPid = null + + mkdirp(homeDir) + mkdirp(userDataDir) + mkdirp(path.dirname(shimPath)) + createFakeInstalledHermesRoot(updateRoot) + compileFakeUpdater(homeDir, smokeRoot, targetFramework) + + const runtime = createLockedRuntime(updateRoot, shimPath) + await waitFor(() => isLocked(shimPath), { + label: 'fake Hermes venv shim lock', + timeoutMs: 10_000, + intervalMs: 100 + }) + + const port = await getFreePort() + const app = spawn(appExe, [`--remote-debugging-port=${port}`], { + cwd: path.dirname(appExe), + env: { + ...process.env, + HERMES_DESKTOP_BOOT_FAKE: '1', + HERMES_DESKTOP_BOOT_FAKE_STEP_MS: '120', + HERMES_DESKTOP_DISABLE_GPU: '1', + HERMES_DESKTOP_HERMES_ROOT: updateRoot, + HERMES_DESKTOP_PYTHON: smokePython, + HERMES_DESKTOP_USER_DATA_DIR: userDataDir, + HERMES_FAKE_UPDATER_LOG: updaterLog, + HERMES_FAKE_UPDATER_RELAUNCH: appExe, + HERMES_HOME: homeDir, + PATH: smokeAppPath() + }, + stdio: 'ignore', + windowsHide: false + }) + + try { + const target = await waitForRendererTarget(port) + let result = null + let rendererClosedDuringHandoff = false + try { + result = await evaluateInRenderer( + target.webSocketDebuggerUrl, + `(async () => { + const start = Date.now() + while (!window.hermesDesktop?.updates?.apply) { + if (Date.now() - start > 15000) throw new Error('updates bridge was not exposed') + await new Promise(resolve => setTimeout(resolve, 100)) + } + window.__hermesUpdateProgress = [] + window.hermesDesktop.updates.onProgress?.(payload => window.__hermesUpdateProgress.push(payload)) + return await window.hermesDesktop.updates.apply({}) + })()` + ) + } catch (error) { + if (!isExpectedHandoffRendererShutdown(error)) throw error + rendererClosedDuringHandoff = true + } + + if (!rendererClosedDuringHandoff && (!result || result.ok !== true || result.handedOff !== true || result.manual)) { + fail(`Expected handed-off update result, got ${JSON.stringify(result)}`) + } + + await waitFor(() => fs.existsSync(updaterLog), { + label: 'fake updater log', + timeoutMs: 20_000, + intervalMs: 100 + }) + await waitForExit(app, 15_000, 'Hermes desktop') + await waitForExit(runtime, 10_000, 'locked fake runtime') + + if (isLocked(shimPath)) fail('The fake venv shim is still locked after handoff') + + const fakeUpdater = JSON.parse(fs.readFileSync(updaterLog, 'utf8')) + const args = Array.isArray(fakeUpdater.args) ? fakeUpdater.args : [] + if (!args.includes('--update')) fail(`Fake updater did not receive --update: ${JSON.stringify(args)}`) + if (!args.includes('--branch')) fail(`Fake updater did not receive --branch: ${JSON.stringify(args)}`) + if (!args.includes('main')) fail(`Fake updater did not receive main branch: ${JSON.stringify(args)}`) + relaunchedPid = Number(fakeUpdater.relaunchPid) + if (!Number.isInteger(relaunchedPid) || relaunchedPid <= 0) { + fail(`Fake updater did not relaunch Hermes: ${JSON.stringify(fakeUpdater)}`) + } + await waitFor(() => isProcessRunning(relaunchedPid), { + label: 'relaunched Hermes desktop process', + timeoutMs: 10_000, + intervalMs: 250 + }) + + const log = fs.existsSync(desktopLog) ? fs.readFileSync(desktopLog, 'utf8') : '' + for (const needle of [ + 'stopped 1 Hermes runtime process(es) before update', + 'venv shim unlocked; safe to proceed', + 'launched updater:' + ]) { + if (!log.includes(needle)) fail(`Desktop log is missing "${needle}"`) + } + + console.log('Desktop update handoff smoke passed.') + console.log(` smoke root: ${smokeRoot}`) + console.log(` updater log: ${updaterLog}`) + console.log(` desktop log: ${desktopLog}`) + taskkill(relaunchedPid) + } catch (error) { + try { + if (!relaunchedPid && fs.existsSync(updaterLog)) { + relaunchedPid = Number(JSON.parse(fs.readFileSync(updaterLog, 'utf8')).relaunchPid) + } + } catch { + relaunchedPid = null + } + taskkill(relaunchedPid) + taskkill(app.pid) + taskkill(runtime.pid) + console.error(`Desktop update handoff smoke failed. Smoke root preserved at ${smokeRoot}`) + throw error + } +} + +main().catch(error => { + console.error(error.stack || error.message || String(error)) + process.exit(1) +}) diff --git a/apps/desktop/electron/update-handoff.cjs b/apps/desktop/electron/update-handoff.cjs new file mode 100644 index 000000000000..b423f8dfcb4d --- /dev/null +++ b/apps/desktop/electron/update-handoff.cjs @@ -0,0 +1,85 @@ +const fs = require('node:fs') +const path = require('node:path') + +function quoteBatchArg(value) { + const escaped = String(value) + .replace(/%/g, '%%') + .replace(/"/g, '""') + return `"${escaped}"` +} + +function buildHermesUpdateArgs({ assumeYes = false, branch = null } = {}) { + const args = ['update'] + if (assumeYes) args.push('--yes') + args.push('--backup') + if (branch) args.push('--branch', branch) + return args +} + +function buildManualHermesUpdateCommand(branch = null) { + return ['hermes', ...buildHermesUpdateArgs({ branch })].join(' ') +} + +function buildVisibleWindowsUpdaterScript(updater, updaterArgs = []) { + const command = [quoteBatchArg(updater), ...updaterArgs.map(quoteBatchArg)].join(' ') + return [ + '@echo off', + 'setlocal', + 'title Hermes update', + 'echo [Hermes] Closing Hermes runtime processes before update...', + 'echo [Hermes] Running Hermes updater...', + 'echo [Hermes] Relaunching Hermes when the updater finishes...', + 'echo.', + `echo [Hermes] Command: ${command}`, + command, + 'set "HERMES_UPDATE_EXIT=%ERRORLEVEL%"', + 'if "%HERMES_UPDATE_EXIT%"=="0" (', + ' echo.', + ' echo [Hermes] Update command finished. Closing this window shortly...', + ' timeout /t 2 /nobreak >nul', + ')', + 'if not "%HERMES_UPDATE_EXIT%"=="0" (', + ' echo.', + ' echo [Hermes] Updater failed with exit code %HERMES_UPDATE_EXIT%.', + ' echo [Hermes] Press any key to close this window.', + ' pause >nul', + ')', + 'exit /b %HERMES_UPDATE_EXIT%', + '' + ].join('\r\n') +} + +function createUpdaterLaunchPlan({ handoffDir, isWindows = process.platform === 'win32', updater, updaterArgs = [] }) { + if (!isWindows) { + return { + args: updaterArgs, + command: updater, + detached: true, + scriptPath: null, + windowsHide: false + } + } + + if (!handoffDir) { + throw new Error('createUpdaterLaunchPlan requires handoffDir on Windows') + } + + fs.mkdirSync(handoffDir, { recursive: true }) + const scriptPath = path.join(handoffDir, `hermes-updater-${process.pid}-${Date.now()}.cmd`) + fs.writeFileSync(scriptPath, buildVisibleWindowsUpdaterScript(updater, updaterArgs), 'utf8') + + return { + args: ['/d', '/s', '/c', scriptPath], + command: 'cmd.exe', + detached: true, + scriptPath, + windowsHide: false + } +} + +module.exports = { + buildHermesUpdateArgs, + buildManualHermesUpdateCommand, + buildVisibleWindowsUpdaterScript, + createUpdaterLaunchPlan +} diff --git a/apps/desktop/electron/update-handoff.test.cjs b/apps/desktop/electron/update-handoff.test.cjs new file mode 100644 index 000000000000..6012f1fa0771 --- /dev/null +++ b/apps/desktop/electron/update-handoff.test.cjs @@ -0,0 +1,81 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const test = require('node:test') + +const { + buildHermesUpdateArgs, + buildManualHermesUpdateCommand, + buildVisibleWindowsUpdaterScript, + createUpdaterLaunchPlan +} = require('./update-handoff.cjs') + +test('builds Hermes CLI update args with a forced pre-update backup', () => { + assert.deepEqual(buildHermesUpdateArgs({ assumeYes: true }), ['update', '--yes', '--backup']) + assert.deepEqual(buildHermesUpdateArgs({ assumeYes: true, branch: 'release/x' }), [ + 'update', + '--yes', + '--backup', + '--branch', + 'release/x' + ]) +}) + +test('builds manual update commands with a forced pre-update backup', () => { + assert.equal(buildManualHermesUpdateCommand(), 'hermes update --backup') + assert.equal(buildManualHermesUpdateCommand('feature/x'), 'hermes update --backup --branch feature/x') +}) + +test('builds a visible Windows updater script with human-readable progress', () => { + const script = buildVisibleWindowsUpdaterScript(String.raw`C:\Program Files\Hermes\hermes-setup.exe`, [ + '--update', + '--branch', + 'main' + ]) + + assert.match(script, /title Hermes update/) + assert.match(script, /Closing Hermes runtime processes/) + assert.match(script, /Running Hermes updater/) + assert.match(script, /Relaunching Hermes when the updater finishes/) + assert.match(script, /Update command finished/) + assert.match(script, /timeout \/t 2 \/nobreak/) + assert.match(script, /"C:\\Program Files\\Hermes\\hermes-setup\.exe" "--update" "--branch" "main"/) +}) + +test('creates a cmd.exe launch plan for Windows updater handoff', () => { + const handoffDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-update-handoff-test-')) + try { + const plan = createUpdaterLaunchPlan({ + handoffDir, + isWindows: true, + updater: String.raw`C:\Program Files\Hermes\hermes-setup.exe`, + updaterArgs: ['--update', '--branch', 'main'] + }) + + assert.equal(plan.command, 'cmd.exe') + assert.deepEqual(plan.args.slice(0, 3), ['/d', '/s', '/c']) + assert.equal(plan.detached, true) + assert.equal(plan.windowsHide, false) + assert.equal(path.dirname(plan.scriptPath), handoffDir) + assert.ok(fs.existsSync(plan.scriptPath)) + assert.match(fs.readFileSync(plan.scriptPath, 'utf8'), /Running Hermes updater/) + } finally { + fs.rmSync(handoffDir, { recursive: true, force: true }) + } +}) + +test('keeps non-Windows updater launches direct', () => { + const plan = createUpdaterLaunchPlan({ + handoffDir: '/tmp', + isWindows: false, + updater: '/Applications/Hermes.app/Contents/MacOS/hermes-setup', + updaterArgs: ['--update', '--branch', 'main'] + }) + + assert.equal(plan.command, '/Applications/Hermes.app/Contents/MacOS/hermes-setup') + assert.deepEqual(plan.args, ['--update', '--branch', 'main']) + assert.equal(plan.scriptPath, null) + assert.equal(plan.detached, true) + assert.equal(plan.windowsHide, false) +}) diff --git a/apps/desktop/electron/update-processes.cjs b/apps/desktop/electron/update-processes.cjs new file mode 100644 index 000000000000..73dbdfcc623b --- /dev/null +++ b/apps/desktop/electron/update-processes.cjs @@ -0,0 +1,172 @@ +const path = require('node:path') +const { execFileSync } = require('node:child_process') + +const RUNTIME_PROCESS_NAMES = new Set([ + 'hermes.exe', + 'node.exe', + 'node', + 'npm.cmd', + 'npm.exe', + 'python.exe', + 'pythonw.exe', + 'python', + 'python3', + 'uv.exe', + 'uvicorn.exe' +]) + +function normalizePathText(value) { + return String(value || '') + .replace(/\//g, '\\') + .toLowerCase() +} + +function basenameLower(filePath, fallback = '') { + const value = String(filePath || fallback || '').trim() + if (!value) return '' + return path.basename(value).toLowerCase() +} + +function isUnderPath(candidate, root) { + const normalizedCandidate = normalizePathText(candidate) + const normalizedRoot = normalizePathText(root).replace(/\\+$/, '') + if (!normalizedCandidate || !normalizedRoot) return false + return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}\\`) +} + +function textMentionsPath(text, root) { + const normalizedText = normalizePathText(text) + const normalizedRoot = normalizePathText(root).replace(/\\+$/, '') + if (!normalizedText || !normalizedRoot) return false + let index = normalizedText.indexOf(normalizedRoot) + while (index !== -1) { + const after = normalizedText[index + normalizedRoot.length] + if (!after || after === '\\' || after === '"' || after === "'" || /\s/.test(after)) return true + index = normalizedText.indexOf(normalizedRoot, index + normalizedRoot.length) + } + return false +} + +function commandLooksHermesOwned(commandLine) { + const command = normalizePathText(commandLine) + return ( + command.includes('hermes_cli.') || + command.includes('\\scripts\\whatsapp-bridge\\') + ) +} + +function isHermesRuntimeProcess(processInfo, { currentPid = process.pid, updateRoot } = {}) { + if (!processInfo || !updateRoot) return false + + const pid = Number(processInfo.pid) + if (!Number.isInteger(pid) || pid <= 0 || pid === currentPid) return false + + const executablePath = processInfo.executablePath || '' + const commandLine = processInfo.commandLine || '' + const imageName = basenameLower(processInfo.name || executablePath) + const executableName = basenameLower(executablePath) + const processName = imageName || executableName + + if (!RUNTIME_PROCESS_NAMES.has(processName)) return false + + const venvRoot = path.join(updateRoot, 'venv') + const executableInInstall = isUnderPath(executablePath, updateRoot) + const executableInVenv = isUnderPath(executablePath, venvRoot) + const commandMentionsInstall = textMentionsPath(commandLine, updateRoot) + const commandIsHermes = commandLooksHermesOwned(commandLine) + + return executableInVenv || executableInInstall || (commandMentionsInstall && commandIsHermes) +} + +function collectHermesRuntimeProcessIds(processes, { currentPid = process.pid, updateRoot } = {}) { + return (Array.isArray(processes) ? processes : []) + .filter(processInfo => isHermesRuntimeProcess(processInfo, { currentPid, updateRoot })) + .map(processInfo => Number(processInfo.pid)) + .filter(pid => Number.isInteger(pid) && pid > 0) +} + +function parseWindowsProcessList(raw) { + if (!String(raw || '').trim()) return [] + + let parsed + try { + parsed = JSON.parse(raw) + } catch { + return [] + } + + const rows = Array.isArray(parsed) ? parsed : [parsed] + return rows + .filter(Boolean) + .map(row => ({ + pid: Number(row.ProcessId ?? row.processId ?? row.pid), + name: String(row.Name ?? row.name ?? ''), + executablePath: String(row.ExecutablePath ?? row.executablePath ?? ''), + commandLine: String(row.CommandLine ?? row.commandLine ?? '') + })) + .filter(row => Number.isInteger(row.pid) && row.pid > 0) +} + +function listWindowsProcesses(options = {}) { + const { onError } = options + const script = + "$ErrorActionPreference = 'Stop'; " + + 'Get-CimInstance Win32_Process | ' + + 'Select-Object ProcessId,Name,ExecutablePath,CommandLine | ' + + 'ConvertTo-Json -Compress' + + try { + const raw = execFileSync('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + timeout: 5000, + windowsHide: true + }) + return parseWindowsProcessList(raw) + } catch (err) { + onError?.(err) + return [] + } +} + +function killHermesRuntimeProcessesForUpdate(updateRoot, options = {}) { + const { + currentPid = process.pid, + killTree, + listProcesses = listWindowsProcesses, + onKilled, + onError, + onListError + } = options + + let processes + try { + processes = listProcesses === listWindowsProcesses ? listProcesses({ onError: onListError }) : listProcesses() + } catch (err) { + onListError?.(err) + return [] + } + + const pids = collectHermesRuntimeProcessIds(processes, { currentPid, updateRoot }) + if (pids.length && typeof killTree !== 'function') { + throw new Error('killHermesRuntimeProcessesForUpdate requires a killTree callback when runtime processes match') + } + + for (const pid of pids) { + try { + killTree(pid) + onKilled?.(pid) + } catch (err) { + onError?.(pid, err) + } + } + return pids +} + +module.exports = { + collectHermesRuntimeProcessIds, + isHermesRuntimeProcess, + killHermesRuntimeProcessesForUpdate, + listWindowsProcesses, + parseWindowsProcessList +} diff --git a/apps/desktop/electron/update-processes.test.cjs b/apps/desktop/electron/update-processes.test.cjs new file mode 100644 index 000000000000..6387e6e32426 --- /dev/null +++ b/apps/desktop/electron/update-processes.test.cjs @@ -0,0 +1,232 @@ +const assert = require('node:assert/strict') +const test = require('node:test') + +const { + collectHermesRuntimeProcessIds, + isHermesRuntimeProcess, + killHermesRuntimeProcessesForUpdate, + listWindowsProcesses, + parseWindowsProcessList +} = require('./update-processes.cjs') + +const ROOT = String.raw`G:\hermes\hermes-agent` +const POSIX_ROOT = '/Users/willie/hermes-agent' + +test('targets a stray venv python gateway that would lock installed packages', () => { + assert.equal( + isHermesRuntimeProcess( + { + pid: 4242, + name: 'pythonw.exe', + executablePath: String.raw`G:\hermes\hermes-agent\venv\Scripts\pythonw.exe`, + commandLine: String.raw`"G:\hermes\hermes-agent\venv\Scripts\pythonw.exe" -m hermes_cli.main gateway run` + }, + { currentPid: 1, updateRoot: ROOT } + ), + true + ) +}) + +test('matches Hermes runtime processes with POSIX-shaped paths too', () => { + assert.equal( + isHermesRuntimeProcess( + { + pid: 5252, + name: 'python', + executablePath: '/Users/willie/hermes-agent/venv/bin/python', + commandLine: '/Users/willie/hermes-agent/venv/bin/python -m hermes_cli.main gateway run' + }, + { currentPid: 1, updateRoot: POSIX_ROOT } + ), + true + ) +}) + +test('targets any hermes_cli module command that references the target checkout', () => { + assert.equal( + isHermesRuntimeProcess( + { + pid: 5353, + name: 'python.exe', + executablePath: String.raw`C:\Python311\python.exe`, + commandLine: String.raw`"C:\Python311\python.exe" -m hermes_cli.cron --root G:\hermes\hermes-agent` + }, + { currentPid: 1, updateRoot: ROOT } + ), + true + ) +}) + +test('does not target unrelated python or an editor merely mentioning the checkout', () => { + assert.equal( + isHermesRuntimeProcess( + { + pid: 5001, + name: 'python.exe', + executablePath: String.raw`C:\Python311\python.exe`, + commandLine: String.raw`"C:\Python311\python.exe" C:\other\script.py` + }, + { currentPid: 1, updateRoot: ROOT } + ), + false + ) + + assert.equal( + isHermesRuntimeProcess( + { + pid: 5002, + name: 'Code.exe', + executablePath: String.raw`C:\Users\willi\AppData\Local\Programs\Microsoft VS Code\Code.exe`, + commandLine: String.raw`"Code.exe" "G:\hermes\hermes-agent\README.md"` + }, + { currentPid: 1, updateRoot: ROOT } + ), + false + ) + + assert.equal( + isHermesRuntimeProcess( + { + pid: 5003, + name: 'node.exe', + executablePath: String.raw`C:\Program Files\nodejs\node.exe`, + commandLine: String.raw`"C:\Program Files\nodejs\node.exe" C:\tools\lint.js G:\hermes\hermes-agent` + }, + { currentPid: 1, updateRoot: ROOT } + ), + false + ) +}) + +test('does not target a different Hermes checkout with a sibling path prefix', () => { + assert.equal( + isHermesRuntimeProcess( + { + pid: 5050, + name: 'pythonw.exe', + executablePath: String.raw`G:\hermes\hermes-agent-old\venv\Scripts\pythonw.exe`, + commandLine: String.raw`"G:\hermes\hermes-agent-old\venv\Scripts\pythonw.exe" -m hermes_cli.main gateway run` + }, + { currentPid: 1, updateRoot: ROOT } + ), + false + ) +}) + +test('collects only Hermes-owned runtime process ids and excludes this process', () => { + const ids = collectHermesRuntimeProcessIds( + [ + { + pid: 111, + name: 'pythonw.exe', + executablePath: String.raw`G:\hermes\hermes-agent\venv\Scripts\pythonw.exe`, + commandLine: String.raw`"G:\hermes\hermes-agent\venv\Scripts\pythonw.exe" -m hermes_cli.main gateway run` + }, + { + pid: 222, + name: 'node.exe', + executablePath: String.raw`C:\Program Files\nodejs\node.exe`, + commandLine: String.raw`"node.exe" G:\hermes\hermes-agent\scripts\whatsapp-bridge\bridge.js` + }, + { + pid: 333, + name: 'python.exe', + executablePath: String.raw`G:\hermes\hermes-agent\venv\Scripts\python.exe`, + commandLine: String.raw`"G:\hermes\hermes-agent\venv\Scripts\python.exe" -m pip install -e .[all]` + }, + { + pid: 444, + name: 'python.exe', + executablePath: String.raw`G:\other\venv\Scripts\python.exe`, + commandLine: String.raw`"G:\other\venv\Scripts\python.exe" -m pip install something` + } + ], + { currentPid: 333, updateRoot: ROOT } + ) + + assert.deepEqual(ids, [111, 222]) +}) + +test('parses PowerShell ConvertTo-Json process output for one or many rows', () => { + assert.deepEqual(parseWindowsProcessList(''), []) + assert.deepEqual( + parseWindowsProcessList( + '{"ProcessId":9512,"Name":"pythonw.exe","ExecutablePath":"G:\\\\hermes\\\\hermes-agent\\\\venv\\\\Scripts\\\\pythonw.exe","CommandLine":"pythonw -m hermes_cli.main gateway run"}' + ), + [ + { + pid: 9512, + name: 'pythonw.exe', + executablePath: String.raw`G:\hermes\hermes-agent\venv\Scripts\pythonw.exe`, + commandLine: 'pythonw -m hermes_cli.main gateway run' + } + ] + ) + assert.equal(parseWindowsProcessList('[{"ProcessId":1},{"ProcessId":2}]').length, 2) +}) + +test('lists Windows processes from PowerShell with a valid command', { skip: process.platform !== 'win32' }, () => { + const rows = listWindowsProcesses() + + assert.ok(rows.some(row => row.pid === process.pid), 'expected current Node process in Win32_Process output') +}) + +test('kill helper uses injected process listing and tree kill function', () => { + const killed = [] + const pids = killHermesRuntimeProcessesForUpdate(ROOT, { + currentPid: 1, + killTree: pid => killed.push(pid), + listProcesses: () => [ + { + pid: 777, + name: 'pythonw.exe', + executablePath: String.raw`G:\hermes\hermes-agent\venv\Scripts\pythonw.exe`, + commandLine: String.raw`"G:\hermes\hermes-agent\venv\Scripts\pythonw.exe" -m hermes_cli.main gateway run` + }, + { + pid: 888, + name: 'python.exe', + executablePath: String.raw`C:\Python311\python.exe`, + commandLine: String.raw`"C:\Python311\python.exe" C:\other\script.py` + } + ] + }) + + assert.deepEqual(pids, [777]) + assert.deepEqual(killed, [777]) +}) + +test('kill helper throws when matching processes are found without a tree killer', () => { + assert.throws( + () => + killHermesRuntimeProcessesForUpdate(ROOT, { + currentPid: 1, + listProcesses: () => [ + { + pid: 999, + name: 'pythonw.exe', + executablePath: String.raw`G:\hermes\hermes-agent\venv\Scripts\pythonw.exe`, + commandLine: String.raw`"G:\hermes\hermes-agent\venv\Scripts\pythonw.exe" -m hermes_cli.main gateway run` + } + ] + }), + /killTree/ + ) +}) + +test('kill helper reports process enumeration failures', () => { + const errors = [] + const pids = killHermesRuntimeProcessesForUpdate(ROOT, { + currentPid: 1, + killTree: () => { + throw new Error('should not be called') + }, + listProcesses: () => { + throw new Error('process listing denied') + }, + onListError: err => errors.push(err.message) + }) + + assert.deepEqual(pids, []) + assert.deepEqual(errors, ['process listing denied']) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5ab505971395..32ed8241eb60 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,9 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs electron/update-processes.test.cjs electron/update-handoff.test.cjs", + "smoke:desktop:update-handoff": "node electron/update-handoff-smoke.cjs", + "type-check": "tsc -b", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix",