feat(desktop): ts-ify everything

This commit is contained in:
ethernet 2026-06-29 16:31:36 -04:00
parent fac85518fc
commit 39d09453f9
116 changed files with 2500 additions and 1756 deletions

View file

@ -1,6 +1,6 @@
//! Bootstrap orchestration. //! Bootstrap orchestration.
//! //!
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`. //! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.ts`.
//! Drives install.ps1 / install.sh stage-by-stage, emits progress events //! Drives install.ps1 / install.sh stage-by-stage, emits progress events
//! over the Tauri `bootstrap` channel, writes a forensic log to //! over the Tauri `bootstrap` channel, writes a forensic log to
//! HERMES_HOME/logs/bootstrap-<timestamp>.log. //! HERMES_HOME/logs/bootstrap-<timestamp>.log.

View file

@ -1,6 +1,6 @@
//! Event types streamed from Rust → React. //! Event types streamed from Rust → React.
//! //!
//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape //! These mirror `apps/desktop/electron/bootstrap-runner.ts`'s event shape
//! 1:1 so the React installer code can be roughly identical to the Electron //! 1:1 so the React installer code can be roughly identical to the Electron
//! install-overlay we'll replace. //! install-overlay we'll replace.
//! //!

View file

@ -8,7 +8,7 @@
//! 3. Network: download from GitHub raw at a pinned commit or branch. //! 3. Network: download from GitHub raw at a pinned commit or branch.
//! Commit pins are immutable; branch pins are HEAD-tracking. //! Commit pins are immutable; branch pins are HEAD-tracking.
//! //!
//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`, //! Mirrors `apps/desktop/electron/bootstrap-runner.ts`'s `resolveInstallScript`,
//! but the dev-checkout resolution is driven by an env var rather than the //! but the dev-checkout resolution is driven by an env var rather than the
//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant //! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant
//! to live OUTSIDE any repo checkout. //! to live OUTSIDE any repo checkout.
@ -64,7 +64,7 @@ impl ScriptKind {
} }
/// Validates a string looks like a git SHA (7+ hex chars). Mirrors /// Validates a string looks like a git SHA (7+ hex chars). Mirrors
/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs. /// `STAMP_COMMIT_RE` from bootstrap-runner.ts.
fn is_valid_commit(s: &str) -> bool { fn is_valid_commit(s: &str) -> bool {
let len = s.len(); let len = s.len();
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit()) (7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())

View file

@ -150,7 +150,7 @@ fn repair_macos_installer_helper(path: &Path) {
fn repair_macos_installer_helper(_path: &Path) {} fn repair_macos_installer_helper(_path: &Path) {}
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file /// Where install.ps1 writes the bootstrap-complete marker (existence-only file
/// the Electron app also checks). Per main.cjs: /// the Electron app also checks). Per main.ts:
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete') /// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so /// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
/// this is a probe helper, not a definitive path. /// this is a probe helper, not a definitive path.

View file

@ -1,6 +1,6 @@
//! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh. //! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh.
//! //!
//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same //! Port of `spawnPowerShell` from bootstrap-runner.ts, with the same
//! line-buffered stdout/stderr streaming + cancellation semantics. //! line-buffered stdout/stderr streaming + cancellation semantics.
//! //!
//! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File <script>`. //! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File <script>`.
@ -19,7 +19,7 @@ pub struct StreamSink {
pub on_stderr_line: Box<dyn Fn(&str) + Send + Sync>, pub on_stderr_line: Box<dyn Fn(&str) + Send + Sync>,
} }
/// Outcome of a script invocation. Mirrors bootstrap-runner.cjs's /// Outcome of a script invocation. Mirrors bootstrap-runner.ts's
/// `{stdout, stderr, code, signal, killed}` shape. /// `{stdout, stderr, code, signal, killed}` shape.
#[derive(Debug)] #[derive(Debug)]
pub struct ScriptResult { pub struct ScriptResult {
@ -258,7 +258,7 @@ fn interpreter_label() -> String {
/// Parses the LAST line of stdout that looks like a JSON object matching /// Parses the LAST line of stdout that looks like a JSON object matching
/// the install.ps1 stage-result contract: `{ok: bool, stage: string, ...}`. /// the install.ps1 stage-result contract: `{ok: bool, stage: string, ...}`.
/// ///
/// Mirrors `parseStageResult` from bootstrap-runner.cjs. install.ps1 may /// Mirrors `parseStageResult` from bootstrap-runner.ts. install.ps1 may
/// print info/banner lines before the result frame; we scan from the end. /// print info/banner lines before the result frame; we scan from the end.
pub fn parse_stage_result(stdout: &str) -> Option<crate::events::StageResultPayload> { pub fn parse_stage_result(stdout: &str) -> Option<crate::events::StageResultPayload> {
for line in stdout.lines().rev() { for line in stdout.lines().rev() {

View file

@ -85,7 +85,7 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig
### How it works ### How it works
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.cjs` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.cjs`. The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.ts` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.ts`.
### Verification ### Verification

View file

@ -1,9 +1,9 @@
'use strict' 'use strict'
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import test from 'node:test'
const { serveBackendArgs, dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs') import { dashboardFallbackArgs, serveBackendArgs, sourceDeclaresServe } from './backend-command'
test('serveBackendArgs builds a headless serve invocation', () => { test('serveBackendArgs builds a headless serve invocation', () => {
assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0']) assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0'])
@ -61,5 +61,6 @@ test('sourceDeclaresServe does not false-positive on the substring "server"', ()
dashboard_parser = subparsers.add_parser("dashboard", help="Start the web UI dashboard") dashboard_parser = subparsers.add_parser("dashboard", help="Start the web UI dashboard")
from hermes_cli.web_server import start_server # web server from hermes_cli.web_server import start_server # web server
` `
assert.equal(sourceDeclaresServe(oldSource), false) assert.equal(sourceDeclaresServe(oldSource), false)
}) })

View file

@ -17,8 +17,9 @@
* Build the canonical headless backend argv (always `serve`). * Build the canonical headless backend argv (always `serve`).
* @param {string} [profile] optional Hermes profile to pin via `--profile`. * @param {string} [profile] optional Hermes profile to pin via `--profile`.
*/ */
function serveBackendArgs(profile) { export function serveBackendArgs(profile?: string) {
const head = profile ? ['--profile', profile] : [] const head = profile ? ['--profile', profile] : []
return [...head, 'serve', '--host', '127.0.0.1', '--port', '0'] return [...head, 'serve', '--host', '127.0.0.1', '--port', '0']
} }
@ -28,9 +29,11 @@ function serveBackendArgs(profile) {
* `-m hermes_cli.main` and any `--profile <name>`). Returns a copy; if there is * `-m hermes_cli.main` and any `--profile <name>`). Returns a copy; if there is
* no `serve` token the argv is returned unchanged. * no `serve` token the argv is returned unchanged.
*/ */
function dashboardFallbackArgs(args) { export function dashboardFallbackArgs(args) {
const i = args.indexOf('serve') const i = args.indexOf('serve')
if (i === -1) return args.slice()
if (i === -1) {return args.slice()}
return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)] return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)]
} }
@ -40,12 +43,6 @@ function dashboardFallbackArgs(args) {
* specifically so the substring "server" (e.g. "start_server", "web server") * specifically so the substring "server" (e.g. "start_server", "web server")
* never produces a false positive. * never produces a false positive.
*/ */
function sourceDeclaresServe(dashboardPySource) { export function sourceDeclaresServe(dashboardPySource) {
return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || '')) return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || ''))
} }
module.exports = {
serveBackendArgs,
dashboardFallbackArgs,
sourceDeclaresServe
}

View file

@ -1,15 +1,13 @@
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import path from 'node:path'
const path = require('node:path') import test from 'node:test'
const { import { appendUniquePathEntries,
POSIX_SANE_PATH_ENTRIES,
appendUniquePathEntries,
buildDesktopBackendEnv, buildDesktopBackendEnv,
buildDesktopBackendPath, buildDesktopBackendPath,
normalizeHermesHomeRoot, normalizeHermesHomeRoot,
pathEnvKey pathEnvKey,
} = require('./backend-env.cjs') POSIX_SANE_PATH_ENTRIES } from './backend-env'
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => { test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
const result = buildDesktopBackendPath({ const result = buildDesktopBackendPath({

View file

@ -1,4 +1,4 @@
const path = require('node:path') import path from 'node:path'
// Match the POSIX fallback surface used by the Python terminal environment. // Match the POSIX fallback surface used by the Python terminal environment.
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin, // macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
@ -23,12 +23,14 @@ function pathModuleForPlatform(platform = process.platform) {
} }
function pathEnvKey(env = process.env, platform = process.platform) { function pathEnvKey(env = process.env, platform = process.platform) {
if (platform !== 'win32') return 'PATH' if (platform !== 'win32') {return 'PATH'}
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH' return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
} }
function currentPathValue(env = process.env, platform = process.platform) { function currentPathValue(env = process.env, platform = process.platform) {
const key = pathEnvKey(env, platform) const key = pathEnvKey(env, platform)
return env?.[key] || '' return env?.[key] || ''
} }
@ -37,10 +39,11 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
const ordered = [] const ordered = []
for (const entry of entries) { for (const entry of entries) {
if (!entry) continue if (!entry) {continue}
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter) const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
for (const part of parts) { for (const part of parts) {
if (!part || seen.has(part)) continue if (!part || seen.has(part)) {continue}
seen.add(part) seen.add(part)
ordered.push(part) ordered.push(part)
} }
@ -55,7 +58,7 @@ function buildDesktopBackendPath({
currentPath = '', currentPath = '',
platform = process.platform, platform = process.platform,
pathModule = pathModuleForPlatform(platform) pathModule = pathModuleForPlatform(platform)
} = {}) { }: any = {}) {
const delimiter = delimiterForPlatform(platform) const delimiter = delimiterForPlatform(platform)
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
@ -64,13 +67,15 @@ function buildDesktopBackendPath({
return appendUniquePathEntries([hermesNodeBin, venvBin, currentPath, saneEntries], { delimiter }) return appendUniquePathEntries([hermesNodeBin, venvBin, currentPath, saneEntries], { delimiter })
} }
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) { function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) }: any = {}) {
if (!hermesHome) return hermesHome if (!hermesHome) {return hermesHome}
const resolved = pathModule.resolve(String(hermesHome)) const resolved = pathModule.resolve(String(hermesHome))
const parent = pathModule.dirname(resolved) const parent = pathModule.dirname(resolved)
if (pathModule.basename(parent).toLowerCase() === 'profiles') { if (pathModule.basename(parent).toLowerCase() === 'profiles') {
return pathModule.dirname(parent) return pathModule.dirname(parent)
} }
return resolved return resolved
} }
@ -81,7 +86,7 @@ function buildDesktopBackendEnv({
currentEnv = process.env, currentEnv = process.env,
platform = process.platform, platform = process.platform,
pathModule = pathModuleForPlatform(platform) pathModule = pathModuleForPlatform(platform)
} = {}) { }: any = {}) {
const delimiter = delimiterForPlatform(platform) const delimiter = delimiterForPlatform(platform)
const currentPythonPath = currentEnv?.PYTHONPATH || '' const currentPythonPath = currentEnv?.PYTHONPATH || ''
const key = pathEnvKey(currentEnv, platform) const key = pathEnvKey(currentEnv, platform)
@ -98,12 +103,10 @@ function buildDesktopBackendEnv({
} }
} }
module.exports = { export { appendUniquePathEntries,
POSIX_SANE_PATH_ENTRIES,
appendUniquePathEntries,
buildDesktopBackendEnv, buildDesktopBackendEnv,
buildDesktopBackendPath, buildDesktopBackendPath,
delimiterForPlatform, delimiterForPlatform,
normalizeHermesHomeRoot, normalizeHermesHomeRoot,
pathEnvKey pathEnvKey,
} POSIX_SANE_PATH_ENTRIES }

View file

@ -1,17 +1,17 @@
/** /**
* Tests for electron/backend-probes.cjs. * Tests for electron/backend-probes.ts.
* *
* Run with: node --test electron/backend-probes.test.cjs * Run with: node --test electron/backend-probes.test.ts
* (Wired into npm test:desktop:platforms in package.json.) * (Wired into npm test:desktop:platforms in package.json.)
*/ */
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import fs from 'node:fs'
const fs = require('node:fs') import os from 'node:os'
const os = require('node:os') import path from 'node:path'
const path = require('node:path') import test from 'node:test'
const { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } = require('./backend-probes.cjs') import { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } from './backend-probes'
// Resolve the host's own Node binary -- guaranteed to be on disk and // Resolve the host's own Node binary -- guaranteed to be on disk and
// runnable. We use it as both a stand-in for "a python that doesn't // runnable. We use it as both a stand-in for "a python that doesn't
@ -67,6 +67,7 @@ test('verifyHermesCli returns true when --version exits 0', () => {
// verifyHermesCli only cares about the exit code. // verifyHermesCli only cares about the exit code.
const scriptPath = path.join(os.tmpdir(), `hermes-probes-ok-${Date.now()}-${process.pid}.cjs`) const scriptPath = path.join(os.tmpdir(), `hermes-probes-ok-${Date.now()}-${process.pid}.cjs`)
fs.writeFileSync(scriptPath, 'process.exit(0)\n') fs.writeFileSync(scriptPath, 'process.exit(0)\n')
try { try {
// Use node as the launcher and our script as the "command". Pass // Use node as the launcher and our script as the "command". Pass
// shell:false (default) -- node is a real binary, no shim. // shell:false (default) -- node is a real binary, no shim.

View file

@ -1,8 +1,8 @@
/** /**
* backend-probes.cjs * backend-probes.ts
* *
* Cheap "does this candidate backend actually work" checks used by * Cheap "does this candidate backend actually work" checks used by
* resolveHermesBackend (main.cjs). The resolver walks a ladder of * resolveHermesBackend (main.ts). The resolver walks a ladder of
* candidates -- bootstrap marker, `hermes` on PATH, system Python with * candidates -- bootstrap marker, `hermes` on PATH, system Python with
* hermes_cli installed -- and historically returned the first candidate * hermes_cli installed -- and historically returned the first candidate
* whose binary existed on disk. That assumption breaks when a user has * whose binary existed on disk. That assumption breaks when a user has
@ -27,12 +27,12 @@
* via the caller's catch block if it chooses) * via the caller's catch block if it chooses)
* - any throw -> false (never propagate -- resolver wants a boolean) * - any throw -> false (never propagate -- resolver wants a boolean)
* *
* Kept in a standalone cjs module so it can be unit-tested with * Kept in a standalone ts module so it can be unit-tested with
* `node --test` without dragging in the electron runtime (same pattern * `node --test` without dragging in the electron runtime (same pattern
* as bootstrap-platform.cjs and hardening.cjs). * as bootstrap-platform.ts and hardening.ts).
*/ */
const { execFileSync } = require('node:child_process') import { execFileSync } from 'node:child_process'
const PROBE_TIMEOUT_MS = 5000 const PROBE_TIMEOUT_MS = 5000
@ -62,12 +62,12 @@ function hermesRuntimeImportProbe() {
* through PYTHONPATH but lack PyYAML, then die on the first real CLI import. * through PYTHONPATH but lack PyYAML, then die on the first real CLI import.
* *
* @param {string} pythonPath - Absolute path to a python.exe / python. * @param {string} pythonPath - Absolute path to a python.exe / python.
* @param {object} [opts]
* @param {object} [opts.env] - Additional environment for the probe. * @param {object} [opts.env] - Additional environment for the probe.
* @returns {boolean} * @returns {boolean}
*/ */
function canImportHermesCli(pythonPath, opts = {}) { function canImportHermesCli(pythonPath: string, opts:{env?: Record<string, string>} = {}) {
if (!pythonPath) return false if (!pythonPath) {return false}
try { try {
execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], { execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
env: { ...process.env, ...(opts.env || {}) }, env: { ...process.env, ...(opts.env || {}) },
@ -75,6 +75,7 @@ function canImportHermesCli(pythonPath, opts = {}) {
timeout: PROBE_TIMEOUT_MS, timeout: PROBE_TIMEOUT_MS,
windowsHide: true windowsHide: true
}) })
return true return true
} catch { } catch {
return false return false
@ -95,31 +96,30 @@ function canImportHermesCli(pythonPath, opts = {}) {
* *
* @param {string} hermesCommand - Resolved absolute path to a hermes * @param {string} hermesCommand - Resolved absolute path to a hermes
* executable (or an interpreter+script wrapper). * executable (or an interpreter+script wrapper).
* @param {object} [opts]
* @param {boolean} [opts.shell] - Whether to run through a shell. For * @param {boolean} [opts.shell] - Whether to run through a shell. For
* .cmd/.bat shims on Windows execFileSync needs shell:true to find * .cmd/.bat shims on Windows execFileSync needs shell:true to find
* the cmd interpreter; mirrors the same flag isCommandScript() drives * the cmd interpreter; mirrors the same flag isCommandScript() drives
* in resolveHermesBackend. * in resolveHermesBackend.
* @returns {boolean} * @returns {boolean}
*/ */
function verifyHermesCli(hermesCommand, opts = {}) { function verifyHermesCli(hermesCommand: string, opts?: {shell?: boolean}) {
if (!hermesCommand) return false if (!hermesCommand) {return false}
try { try {
execFileSync(hermesCommand, ['--version'], { execFileSync(hermesCommand, ['--version'], {
stdio: 'ignore', stdio: 'ignore',
timeout: PROBE_TIMEOUT_MS, timeout: PROBE_TIMEOUT_MS,
shell: Boolean(opts.shell), shell: Boolean(opts?.shell),
windowsHide: true windowsHide: true
}) })
return true return true
} catch { } catch {
return false return false
} }
} }
module.exports = { export { canImportHermesCli,
canImportHermesCli,
hermesRuntimeImportProbe, hermesRuntimeImportProbe,
verifyHermesCli, PROBE_TIMEOUT_MS,
PROBE_TIMEOUT_MS verifyHermesCli }
}

View file

@ -1,7 +1,7 @@
/** /**
* Tests for electron/backend-ready.cjs. * Tests for electron/backend-ready.ts.
* *
* Run with: node --test electron/backend-ready.test.cjs * Run with: node --test electron/backend-ready.test.ts
* (Wired into npm test:desktop:platforms in package.json.) * (Wired into npm test:desktop:platforms in package.json.)
* *
* Covers the cold-start port-announcement deadline (issue #50209): the clock * Covers the cold-start port-announcement deadline (issue #50209): the clock
@ -11,29 +11,32 @@
* HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS, clamped to a 45s floor. * HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS, clamped to a 45s floor.
*/ */
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import { EventEmitter } from 'node:events'
const { EventEmitter } = require('node:events') import fs from 'node:fs'
const fs = require('node:fs') import os from 'node:os'
const os = require('node:os') import path from 'node:path'
const path = require('node:path') import test from 'node:test'
const { import { DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
readDashboardReadyFile, readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
waitForDashboardPort, waitForDashboardPort,
waitForDashboardPortAnnouncement, waitForDashboardPortAnnouncement,
waitForDashboardReadyFile, waitForDashboardReadyFile } from './backend-ready'
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS, type FakeChildProcess = EventEmitter & {
MIN_PORT_ANNOUNCE_TIMEOUT_MS stdout: EventEmitter
} = require('./backend-ready.cjs') }
// A minimal stand-in for a spawned child process: an EventEmitter with a // A minimal stand-in for a spawned child process: an EventEmitter with a
// stdout EventEmitter, matching the surface waitForDashboardPort consumes // stdout EventEmitter, matching the surface waitForDashboardPort consumes
// (child.stdout.on('data'), child.on('exit'|'error') + the .off() teardown). // (child.stdout.on('data'), child.on('exit'|'error') + the .off() teardown).
function makeFakeChild() { function makeFakeChild(): FakeChildProcess {
const child = new EventEmitter() const child = new EventEmitter() as FakeChildProcess
child.stdout = new EventEmitter() child.stdout = new EventEmitter()
return child return child
} }
@ -139,6 +142,7 @@ test('a late announcement after timeout does not throw (listeners torn down)', a
function mkTmpReadyFile() { function mkTmpReadyFile() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-')) const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-'))
return { return {
dir, dir,
file: path.join(dir, 'ready.json'), file: path.join(dir, 'ready.json'),
@ -148,6 +152,7 @@ function mkTmpReadyFile() {
test('readDashboardReadyFile returns a valid port from JSON', () => { test('readDashboardReadyFile returns a valid port from JSON', () => {
const tmp = mkTmpReadyFile() const tmp = mkTmpReadyFile()
try { try {
fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 })) fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 }))
assert.equal(readDashboardReadyFile(tmp.file), 4567) assert.equal(readDashboardReadyFile(tmp.file), 4567)
@ -158,6 +163,7 @@ test('readDashboardReadyFile returns a valid port from JSON', () => {
test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => { test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => {
const tmp = mkTmpReadyFile() const tmp = mkTmpReadyFile()
try { try {
assert.equal(readDashboardReadyFile(tmp.file), null) assert.equal(readDashboardReadyFile(tmp.file), null)
fs.writeFileSync(tmp.file, '{') fs.writeFileSync(tmp.file, '{')
@ -172,6 +178,7 @@ test('readDashboardReadyFile ignores missing, malformed, or invalid files', () =
test('waitForDashboardReadyFile resolves when the ready file appears', async () => { test('waitForDashboardReadyFile resolves when the ready file appears', async () => {
const tmp = mkTmpReadyFile() const tmp = mkTmpReadyFile()
const child = makeFakeChild() const child = makeFakeChild()
try { try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000) const p = waitForDashboardReadyFile(tmp.file, child, 1000)
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20) setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20)
@ -184,6 +191,7 @@ test('waitForDashboardReadyFile resolves when the ready file appears', async ()
test('waitForDashboardPortAnnouncement uses ready file when provided', async () => { test('waitForDashboardPortAnnouncement uses ready file when provided', async () => {
const tmp = mkTmpReadyFile() const tmp = mkTmpReadyFile()
const child = makeFakeChild() const child = makeFakeChild()
try { try {
const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 }) const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 })
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20) setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20)
@ -196,6 +204,7 @@ test('waitForDashboardPortAnnouncement uses ready file when provided', async ()
test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => { test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => {
const tmp = mkTmpReadyFile() const tmp = mkTmpReadyFile()
const child = makeFakeChild() const child = makeFakeChild()
try { try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000) const p = waitForDashboardReadyFile(tmp.file, child, 1000)
child.emit('exit', 1, null) child.emit('exit', 1, null)

View file

@ -1,4 +1,4 @@
const fs = require('node:fs') import fs from 'node:fs'
// `hermes serve` announces HERMES_BACKEND_READY; the legacy `hermes dashboard` // `hermes serve` announces HERMES_BACKEND_READY; the legacy `hermes dashboard`
// backend announces HERMES_DASHBOARD_READY. Accept either so the desktop spawn // backend announces HERMES_DASHBOARD_READY. Accept either so the desktop spawn
@ -26,9 +26,11 @@ const MIN_PORT_ANNOUNCE_TIMEOUT_MS = 45_000
*/ */
function resolvePortAnnounceTimeoutMs(env = process.env) { function resolvePortAnnounceTimeoutMs(env = process.env) {
const parsed = Number(env.HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS) const parsed = Number(env.HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS)
if (Number.isFinite(parsed) && parsed > 0) { if (Number.isFinite(parsed) && parsed > 0) {
return Math.max(MIN_PORT_ANNOUNCE_TIMEOUT_MS, Math.round(parsed)) return Math.max(MIN_PORT_ANNOUNCE_TIMEOUT_MS, Math.round(parsed))
} }
return DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS return DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS
} }
@ -55,7 +57,7 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
let done = false let done = false
function cleanup() { function cleanup() {
if (done) return if (done) {return}
done = true done = true
clearTimeout(timer) clearTimeout(timer)
child.stdout.off('data', onData) child.stdout.off('data', onData)
@ -66,13 +68,16 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
function onData(chunk) { function onData(chunk) {
buf += chunk.toString() buf += chunk.toString()
let nl let nl
while ((nl = buf.indexOf('\n')) !== -1) { while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl) const line = buf.slice(0, nl)
buf = buf.slice(nl + 1) buf = buf.slice(nl + 1)
const m = line.match(_READY_RE) const m = line.match(_READY_RE)
if (m) { if (m) {
cleanup() cleanup()
resolve(parseInt(m[1], 10)) resolve(parseInt(m[1], 10))
return return
} }
} }
@ -99,11 +104,13 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
}) })
} }
function readDashboardReadyFile(readyFile) { function readDashboardReadyFile(readyFile: fs.PathOrFileDescriptor) {
if (!readyFile) return null if (!readyFile) {return null}
try { try {
const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8')) const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8'))
const port = Number(parsed?.port) const port = Number(parsed?.port)
return Number.isInteger(port) && port > 0 ? port : null return Number.isInteger(port) && port > 0 ? port : null
} catch { } catch {
return null return null
@ -116,16 +123,18 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
let interval = null let interval = null
function cleanup() { function cleanup() {
if (done) return if (done) {return}
done = true done = true
clearTimeout(timer) clearTimeout(timer)
if (interval) clearInterval(interval)
if (interval) {clearInterval(interval)}
child.off('exit', onExit) child.off('exit', onExit)
child.off('error', onError) child.off('error', onError)
} }
function check() { function check() {
const port = readDashboardReadyFile(readyFile) const port = readDashboardReadyFile(readyFile)
if (port) { if (port) {
cleanup() cleanup()
resolve(port) resolve(port)
@ -150,25 +159,29 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
child.on('exit', onExit) child.on('exit', onExit)
child.on('error', onError) child.on('error', onError)
interval = setInterval(check, 50) interval = setInterval(check, 50)
if (typeof interval.unref === 'function') interval.unref()
if (typeof interval.unref === 'function') {interval.unref()}
check() check()
}) })
} }
function waitForDashboardPortAnnouncement(child, options = {}) { function waitForDashboardPortAnnouncement(child, options: {
readyFile?: fs.PathOrFileDescriptor,
timeoutMs?: number
} = {}) {
const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs() const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs()
if (options.readyFile) { if (options.readyFile) {
return waitForDashboardReadyFile(options.readyFile, child, timeoutMs) return waitForDashboardReadyFile(options.readyFile, child, timeoutMs)
} }
return waitForDashboardPort(child, timeoutMs) return waitForDashboardPort(child, timeoutMs)
} }
module.exports = { export { DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
waitForDashboardPort, MIN_PORT_ANNOUNCE_TIMEOUT_MS,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile,
readDashboardReadyFile, readDashboardReadyFile,
resolvePortAnnounceTimeoutMs, resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS, waitForDashboardPort,
MIN_PORT_ANNOUNCE_TIMEOUT_MS waitForDashboardPortAnnouncement,
} waitForDashboardReadyFile }

View file

@ -1,14 +1,10 @@
const assert = require('node:assert/strict') import assert from 'node:assert/strict'
const fs = require('node:fs') import test from 'node:test'
const path = require('node:path')
const test = require('node:test')
const { import { bundledRuntimeImportCheck,
bundledRuntimeImportCheck,
detectRemoteDisplay, detectRemoteDisplay,
isWindowsBinaryPathInWsl, isWindowsBinaryPathInWsl,
isWslEnvironment isWslEnvironment } from './bootstrap-platform'
} = require('./bootstrap-platform.cjs')
test('isWslEnvironment detects WSL2 env vars on linux', () => { test('isWslEnvironment detects WSL2 env vars on linux', () => {
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true) assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
@ -85,27 +81,3 @@ test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both wa
null null
) )
}) })
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
const electronDir = __dirname
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
// - electron: provided by the electron runtime, always resolvable in packaged builds.
// - node-pty: hoisted by workspace dedup AND shipped via extraResources to
// resources/native-deps/node-pty (see scripts/stage-native-deps.cjs). main.cjs
// has a try/catch fallback at line ~38 that resolves the staged copy when the
// bare require fails in the packaged asar, so the bare require itself is by
// design rather than an oversight.
const allowedBareRequires = new Set(['electron', 'node-pty'])
const requirePattern = /require\(['"]([^'"]+)['"]\)/g
for (const entrypoint of entrypoints) {
const source = fs.readFileSync(path.join(electronDir, entrypoint), 'utf8')
const bareRequires = Array.from(source.matchAll(requirePattern))
.map(match => match[1])
.filter(specifier => !specifier.startsWith('node:'))
.filter(specifier => !specifier.startsWith('.'))
.filter(specifier => !allowedBareRequires.has(specifier))
assert.deepEqual(bareRequires, [], `${entrypoint} has unpackaged runtime requires`)
}
})

View file

@ -1,20 +1,23 @@
const fs = require('node:fs') import fs from 'node:fs'
function isWslEnvironment(env = process.env, platform = process.platform, kernelRelease = null) { function isWslEnvironment(env = process.env, platform = process.platform, kernelRelease = null) {
if (platform !== 'linux') return false if (platform !== 'linux') {return false}
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {return true}
try { try {
const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8') const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8')
return /microsoft|wsl/i.test(release) return /microsoft|wsl/i.test(release)
} catch { } catch {
return false return false
} }
} }
function isWindowsBinaryPathInWsl(filePath, options = {}) { function isWindowsBinaryPathInWsl(filePath, options: {isWsl?: boolean, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform} = {}) {
const isWsl = options.isWsl ?? isWslEnvironment(options.env, options.platform) const isWsl = options.isWsl ?? isWslEnvironment(options.env, options.platform)
if (!isWsl) return false
if (!isWsl) {return false}
const normalized = String(filePath || '') const normalized = String(filePath || '')
.replace(/\\/g, '/') .replace(/\\/g, '/')
@ -48,19 +51,21 @@ const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off'])
* *
* Pure + dependency-free so it can be unit-tested and called before app ready. * Pure + dependency-free so it can be unit-tested and called before app ready.
*/ */
function detectRemoteDisplay(options = {}) { function detectRemoteDisplay(options: {env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform} = {}) {
const env = options.env ?? process.env const env = options.env ?? process.env
const platform = options.platform ?? process.platform const platform = options.platform ?? process.platform
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '') const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
.trim() .trim()
.toLowerCase() .toLowerCase()
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
if (GPU_OVERRIDE_OFF.has(override)) return null if (GPU_OVERRIDE_ON.has(override)) {return 'override (HERMES_DESKTOP_DISABLE_GPU)'}
if (GPU_OVERRIDE_OFF.has(override)) {return null}
// Launched from an SSH session → the display is X11-forwarded or otherwise // Launched from an SSH session → the display is X11-forwarded or otherwise
// remote. Covers the common `ssh user@box` + GUI-forwarding case. // remote. Covers the common `ssh user@box` + GUI-forwarding case.
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'ssh-session' if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) {return 'ssh-session'}
if (platform === 'linux') { if (platform === 'linux') {
// X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a // X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a
@ -68,6 +73,7 @@ function detectRemoteDisplay(options = {}) {
// NB: WSLg deliberately isn't treated as remote — it reports // NB: WSLg deliberately isn't treated as remote — it reports
// GPU-accelerated vGPU surfaces locally and doesn't show the flicker. // GPU-accelerated vGPU surfaces locally and doesn't show the flicker.
const display = String(env.DISPLAY || '') const display = String(env.DISPLAY || '')
if (display.includes(':') && display.split(':')[0]) { if (display.includes(':') && display.split(':')[0]) {
return `x11-forwarding (DISPLAY=${display})` return `x11-forwarding (DISPLAY=${display})`
} }
@ -77,15 +83,14 @@ function detectRemoteDisplay(options = {}) {
// RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is // RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is
// "Console". // "Console".
const sessionName = String(env.SESSIONNAME || '') const sessionName = String(env.SESSIONNAME || '')
if (/^rdp-/i.test(sessionName)) return `rdp (SESSIONNAME=${sessionName})`
if (/^rdp-/i.test(sessionName)) {return `rdp (SESSIONNAME=${sessionName})`}
} }
return null return null
} }
module.exports = { export { bundledRuntimeImportCheck,
bundledRuntimeImportCheck,
detectRemoteDisplay, detectRemoteDisplay,
isWindowsBinaryPathInWsl, isWindowsBinaryPathInWsl,
isWslEnvironment isWslEnvironment }
}

View file

@ -1,15 +1,13 @@
const assert = require('node:assert/strict') import assert from 'node:assert/strict'
const test = require('node:test') import fs from 'node:fs'
const fs = require('node:fs') import os from 'node:os'
const os = require('node:os') import path from 'node:path'
const path = require('node:path') import test from 'node:test'
const { import { cachedScriptPath,
runBootstrap,
resolveInstallScript,
installedAgentInstallScript, installedAgentInstallScript,
cachedScriptPath resolveInstallScript,
} = require('./bootstrap-runner.cjs') runBootstrap } from './bootstrap-runner'
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh' const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
@ -22,6 +20,7 @@ test('runBootstrap bails immediately when the signal is already aborted', async
controller.abort() controller.abort()
const events = [] const events = []
const result = await runBootstrap({ const result = await runBootstrap({
installStamp: null, installStamp: null,
activeRoot: '/tmp/hermes-runner-test', activeRoot: '/tmp/hermes-runner-test',
@ -42,6 +41,7 @@ test('runBootstrap bails immediately when the signal is already aborted', async
test('installedAgentInstallScript resolves the installer in the agent checkout', () => { test('installedAgentInstallScript resolves the installer in the agent checkout', () => {
const home = mkTmpHome() const home = mkTmpHome()
try { try {
assert.equal(installedAgentInstallScript(home), null, 'absent before the checkout exists') assert.equal(installedAgentInstallScript(home), null, 'absent before the checkout exists')
@ -59,6 +59,7 @@ test('installedAgentInstallScript resolves the installer in the agent checkout',
test('resolveInstallScript prefers a cached script without touching the network', async () => { test('resolveInstallScript prefers a cached script without touching the network', async () => {
const home = mkTmpHome() const home = mkTmpHome()
try { try {
const commit = 'a'.repeat(40) const commit = 'a'.repeat(40)
const cached = cachedScriptPath(home, commit) const cached = cachedScriptPath(home, commit)
@ -66,6 +67,7 @@ test('resolveInstallScript prefers a cached script without touching the network'
fs.writeFileSync(cached, '#!/bin/sh\necho cached\n') fs.writeFileSync(cached, '#!/bin/sh\necho cached\n')
const logs = [] const logs = []
const result = await resolveInstallScript({ const result = await resolveInstallScript({
installStamp: { commit }, installStamp: { commit },
sourceRepoRoot: null, sourceRepoRoot: null,
@ -82,6 +84,7 @@ test('resolveInstallScript prefers a cached script without touching the network'
test('resolveInstallScript falls back to the installed agent checkout on a 404', async () => { test('resolveInstallScript falls back to the installed agent checkout on a 404', async () => {
const home = mkTmpHome() const home = mkTmpHome()
try { try {
const commit = 'a'.repeat(40) const commit = 'a'.repeat(40)
// Seed the installed agent checkout so the fallback has something to resolve. // Seed the installed agent checkout so the fallback has something to resolve.
@ -91,6 +94,7 @@ test('resolveInstallScript falls back to the installed agent checkout on a 404',
fs.writeFileSync(installed, '#!/bin/sh\necho fallback\n') fs.writeFileSync(installed, '#!/bin/sh\necho fallback\n')
const logs = [] const logs = []
const result = await resolveInstallScript({ const result = await resolveInstallScript({
installStamp: { commit }, installStamp: { commit },
sourceRepoRoot: null, sourceRepoRoot: null,
@ -117,6 +121,7 @@ test('resolveInstallScript falls back to the installed agent checkout on a 404',
test('resolveInstallScript rethrows when the 404 fallback is unavailable', async () => { test('resolveInstallScript rethrows when the 404 fallback is unavailable', async () => {
const home = mkTmpHome() const home = mkTmpHome()
try { try {
const commit = 'a'.repeat(40) const commit = 'a'.repeat(40)
// No installed agent checkout seeded -> nothing to fall back to. // No installed agent checkout seeded -> nothing to fall back to.

View file

@ -1,16 +1,14 @@
'use strict'
/** /**
* bootstrap-runner.cjs * bootstrap-runner.ts
* *
* Drives apps/desktop's first-launch install of Hermes Agent by spawning * Drives apps/desktop's first-launch install of Hermes Agent by spawning
* scripts/install.ps1 stage-by-stage and streaming progress events back to * scripts/install.ps1 stage-by-stage and streaming progress events back to
* the renderer. * the renderer.
* *
* Wired from electron/main.cjs: * Wired from electron/main.ts:
* const { runBootstrap } = require('./bootstrap-runner.cjs') * import { runBootstrap }from './bootstrap-runner.ts'
* const result = await runBootstrap({ * const result = await runBootstrap({
* installStamp, // INSTALL_STAMP from main.cjs (may be null in dev) * installStamp, // INSTALL_STAMP from main.ts (may be null in dev)
* activeRoot, // ACTIVE_HERMES_ROOT * activeRoot, // ACTIVE_HERMES_ROOT
* sourceRepoRoot, // SOURCE_REPO_ROOT (for dev install.ps1 lookup) * sourceRepoRoot, // SOURCE_REPO_ROOT (for dev install.ps1 lookup)
* hermesHome, // HERMES_HOME * hermesHome, // HERMES_HOME
@ -34,11 +32,11 @@
* no UI consumes them yet) * no UI consumes them yet)
*/ */
const fs = require('node:fs') import { spawn } from 'node:child_process'
const fsp = require('node:fs/promises') import fs from 'node:fs'
const path = require('node:path') import fsp from 'node:fs/promises'
const https = require('node:https') import https from 'node:https'
const { spawn } = require('node:child_process') import path from 'node:path'
const IS_WINDOWS = process.platform === 'win32' const IS_WINDOWS = process.platform === 'win32'
@ -46,6 +44,7 @@ function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) { if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options return options
} }
return { ...options, windowsHide: true } return { ...options, windowsHide: true }
} }
@ -71,10 +70,12 @@ function installScriptKind() {
} }
function resolveLocalInstallScript(sourceRepoRoot) { function resolveLocalInstallScript(sourceRepoRoot) {
if (!sourceRepoRoot) return null if (!sourceRepoRoot) {return null}
const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName()) const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName())
try { try {
fs.accessSync(candidate, fs.constants.R_OK) fs.accessSync(candidate, fs.constants.R_OK)
return candidate return candidate
} catch { } catch {
return null return null
@ -90,10 +91,12 @@ function bootstrapCacheDir(hermesHome) {
// the pinned commit can't be fetched from GitHub (e.g. a locally-built desktop // the pinned commit can't be fetched from GitHub (e.g. a locally-built desktop
// app stamped to an unpushed HEAD). // app stamped to an unpushed HEAD).
function installedAgentInstallScript(hermesHome) { function installedAgentInstallScript(hermesHome) {
if (!hermesHome) return null if (!hermesHome) {return null}
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName()) const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
try { try {
fs.accessSync(candidate, fs.constants.R_OK) fs.accessSync(candidate, fs.constants.R_OK)
return candidate return candidate
} catch { } catch {
return null return null
@ -110,6 +113,7 @@ function downloadInstallScript(commit, destPath) {
// verification beyond "did the file we wrote pass a syntax probe." // verification beyond "did the file we wrote pass a syntax probe."
const scriptName = installScriptName() const scriptName = installScriptName()
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/${scriptName}` const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/${scriptName}`
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(destPath), { recursive: true }) fs.mkdirSync(path.dirname(destPath), { recursive: true })
const tmpPath = destPath + '.tmp' const tmpPath = destPath + '.tmp'
@ -129,8 +133,10 @@ function downloadInstallScript(commit, destPath) {
`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}` `Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`
) )
) )
return return
} }
const out2 = fs.createWriteStream(tmpPath) const out2 = fs.createWriteStream(tmpPath)
res2.pipe(out2) res2.pipe(out2)
out2.on('finish', () => { out2.on('finish', () => {
@ -141,18 +147,24 @@ function downloadInstallScript(commit, destPath) {
out2.on('error', reject) out2.on('error', reject)
}) })
.on('error', reject) .on('error', reject)
return return
} }
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
out.close() out.close()
try { try {
fs.unlinkSync(tmpPath) fs.unlinkSync(tmpPath)
} catch { } catch {
void 0 void 0
} }
reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`)) reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`))
return return
} }
res.pipe(out) res.pipe(out)
out.on('finish', () => { out.on('finish', () => {
out.close() out.close()
@ -165,6 +177,7 @@ function downloadInstallScript(commit, destPath) {
} catch { } catch {
void 0 void 0
} }
reject(err) reject(err)
}) })
}) })
@ -174,6 +187,7 @@ function downloadInstallScript(commit, destPath) {
} catch { } catch {
void 0 void 0
} }
reject(err) reject(err)
}) })
}) })
@ -187,11 +201,13 @@ async function resolveInstallScript({
_download = downloadInstallScript _download = downloadInstallScript
}) { }) {
// 1. Dev shortcut: prefer a local checkout's installer so we can iterate // 1. Dev shortcut: prefer a local checkout's installer so we can iterate
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve // without pushing. SOURCE_REPO_ROOT comes from main.ts (path.resolve
// of APP_ROOT/../..). // of APP_ROOT/../..).
const localScript = resolveLocalInstallScript(sourceRepoRoot) const localScript = resolveLocalInstallScript(sourceRepoRoot)
if (localScript) { if (localScript) {
emit({ type: 'log', line: `[bootstrap] using local ${installScriptName()} at ${localScript}` }) emit({ type: 'log', line: `[bootstrap] using local ${installScriptName()} at ${localScript}` })
return { path: localScript, source: 'local', kind: installScriptKind() } return { path: localScript, source: 'local', kind: installScriptKind() }
} }
@ -204,12 +220,14 @@ async function resolveInstallScript({
} }
const cached = cachedScriptPath(hermesHome, installStamp.commit) const cached = cachedScriptPath(hermesHome, installStamp.commit)
try { try {
await fsp.access(cached, fs.constants.R_OK) await fsp.access(cached, fs.constants.R_OK)
emit({ emit({
type: 'log', type: 'log',
line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}` line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}`
}) })
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() } return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
} catch { } catch {
// not cached; download // not cached; download
@ -219,17 +237,20 @@ async function resolveInstallScript({
type: 'log', type: 'log',
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub` line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
}) })
try { try {
await _download(installStamp.commit, cached) await _download(installStamp.commit, cached)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` }) emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() } return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
} catch (err) { } catch (err) {
// The pinned commit may not be fetchable from GitHub -- most commonly a // The pinned commit may not be fetchable from GitHub -- most commonly a
// locally-built desktop app stamped to an unpushed HEAD (see // locally-built desktop app stamped to an unpushed HEAD (see
// write-build-stamp.cjs fromLocalGit). Fall back to the installer that // write-build-stamp.mjs fromLocalGit). Fall back to the installer that
// ships inside the already-installed agent checkout so dev/self-builds can // ships inside the already-installed agent checkout so dev/self-builds can
// still bootstrap instead of dying with a fatal 404. // still bootstrap instead of dying with a fatal 404.
const installed = installedAgentInstallScript(hermesHome) const installed = installedAgentInstallScript(hermesHome)
if (installed) { if (installed) {
emit({ emit({
type: 'log', type: 'log',
@ -237,15 +258,18 @@ async function resolveInstallScript({
`[bootstrap] GitHub fetch failed (${err.message}); ` + `[bootstrap] GitHub fetch failed (${err.message}); ` +
`falling back to installed agent ${installScriptName()} at ${installed}` `falling back to installed agent ${installScriptName()} at ${installed}`
}) })
try { try {
fs.mkdirSync(path.dirname(cached), { recursive: true }) fs.mkdirSync(path.dirname(cached), { recursive: true })
fs.copyFileSync(installed, cached) fs.copyFileSync(installed, cached)
return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() } return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
} catch { } catch {
// Cache copy failed (read-only FS, etc.) -- use the source path directly. // Cache copy failed (read-only FS, etc.) -- use the source path directly.
return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() } return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
} }
} }
throw err throw err
} }
} }
@ -271,31 +295,37 @@ function powershellUnderRoot(root) {
function resolveWindowsPowerShell() { function resolveWindowsPowerShell() {
for (const v of ['SystemRoot', 'windir']) { for (const v of ['SystemRoot', 'windir']) {
const root = process.env[v] const root = process.env[v]
if (root) { if (root) {
const candidate = powershellUnderRoot(root) const candidate = powershellUnderRoot(root)
try { try {
if (fs.statSync(candidate).isFile()) return candidate if (fs.statSync(candidate).isFile()) {return candidate}
} catch { } catch {
void 0 void 0
} }
} }
} }
const pathDirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean) const pathDirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean)
for (const exe of ['powershell.exe', 'pwsh.exe']) { for (const exe of ['powershell.exe', 'pwsh.exe']) {
for (const dir of pathDirs) { for (const dir of pathDirs) {
const candidate = path.join(dir, exe) const candidate = path.join(dir, exe)
try { try {
if (fs.statSync(candidate).isFile()) return candidate if (fs.statSync(candidate).isFile()) {return candidate}
} catch { } catch {
void 0 void 0
} }
} }
} }
return 'powershell.exe' return 'powershell.exe'
} }
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) { function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
return new Promise((resolve, reject) => { return new Promise<any>((resolve, reject) => {
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh' const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args] const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
@ -319,12 +349,14 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const onAbort = () => { const onAbort = () => {
killed = true killed = true
try { try {
child.kill('SIGTERM') child.kill('SIGTERM')
} catch { } catch {
void 0 void 0
} }
} }
if (abortSignal) { if (abortSignal) {
if (abortSignal.aborted) { if (abortSignal.aborted) {
onAbort() onAbort()
@ -342,10 +374,12 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
stdout += chunk stdout += chunk
stdoutBuf += chunk stdoutBuf += chunk
let nl let nl
while ((nl = stdoutBuf.indexOf('\n')) !== -1) { while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '') const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1) stdoutBuf = stdoutBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
if (line) {emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })}
} }
}) })
@ -354,30 +388,34 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
stderr += chunk stderr += chunk
stderrBuf += chunk stderrBuf += chunk
let nl let nl
while ((nl = stderrBuf.indexOf('\n')) !== -1) { while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '') const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
stderrBuf = stderrBuf.slice(nl + 1) stderrBuf = stderrBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
if (line) {emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })}
} }
}) })
child.on('error', err => { child.on('error', err => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort) if (abortSignal) {abortSignal.removeEventListener('abort', onAbort)}
reject(err) reject(err)
}) })
child.on('close', (code, signal) => { child.on('close', (code, signal) => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort) if (abortSignal) {abortSignal.removeEventListener('abort', onAbort)}
// Flush any trailing bytes // Flush any trailing bytes
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' }) if (stdoutBuf) {emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' } as any)}
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
resolve({ stdout, stderr, code, signal, killed }) if (stderrBuf) {emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' } as any)}
resolve({ stdout, stderr, code, signal, killed } as any)
}) })
}) })
} }
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) { function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
return new Promise((resolve, reject) => { return new Promise<any>((resolve, reject) => {
const child = spawn('bash', [scriptPath, ...args], { const child = spawn('bash', [scriptPath, ...args], {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: { env: {
@ -392,12 +430,14 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
const onAbort = () => { const onAbort = () => {
killed = true killed = true
try { try {
child.kill('SIGTERM') child.kill('SIGTERM')
} catch { } catch {
void 0 void 0
} }
} }
if (abortSignal) { if (abortSignal) {
if (abortSignal.aborted) { if (abortSignal.aborted) {
onAbort() onAbort()
@ -414,10 +454,12 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
stdout += chunk stdout += chunk
stdoutBuf += chunk stdoutBuf += chunk
let nl let nl
while ((nl = stdoutBuf.indexOf('\n')) !== -1) { while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '') const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1) stdoutBuf = stdoutBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
if (line) {emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })}
} }
}) })
@ -426,22 +468,26 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
stderr += chunk stderr += chunk
stderrBuf += chunk stderrBuf += chunk
let nl let nl
while ((nl = stderrBuf.indexOf('\n')) !== -1) { while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '') const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
stderrBuf = stderrBuf.slice(nl + 1) stderrBuf = stderrBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
if (line) {emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })}
} }
}) })
child.on('error', err => { child.on('error', err => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort) if (abortSignal) {abortSignal.removeEventListener('abort', onAbort)}
reject(err) reject(err)
}) })
child.on('close', (code, signal) => { child.on('close', (code, signal) => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort) if (abortSignal) {abortSignal.removeEventListener('abort', onAbort)}
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' }) if (stdoutBuf) {emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })}
if (stderrBuf) {emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })}
resolve({ stdout, stderr, code, signal, killed }) resolve({ stdout, stderr, code, signal, killed })
}) })
}) })
@ -456,48 +502,60 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
// instead of falling back to install.ps1's default ($Branch = "main"). // instead of falling back to install.ps1's default ($Branch = "main").
function buildPinArgs(installStamp) { function buildPinArgs(installStamp) {
const args = [] const args = []
if (installStamp && installStamp.commit) { if (installStamp && installStamp.commit) {
args.push('-Commit', installStamp.commit) args.push('-Commit', installStamp.commit)
} }
if (installStamp && installStamp.branch) { if (installStamp && installStamp.branch) {
args.push('-Branch', installStamp.branch) args.push('-Branch', installStamp.branch)
} }
return args return args
} }
function buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) { function buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) {
const args = ['--dir', activeRoot, '--hermes-home', hermesHome] const args = ['--dir', activeRoot, '--hermes-home', hermesHome]
if (installStamp && installStamp.branch) { if (installStamp && installStamp.branch) {
args.push('--branch', installStamp.branch) args.push('--branch', installStamp.branch)
} }
if (installStamp && installStamp.commit) { if (installStamp && installStamp.commit) {
args.push('--commit', installStamp.commit) args.push('--commit', installStamp.commit)
} }
return args return args
} }
async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp }) { async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp }) {
const isPosix = installerKind === 'posix' const isPosix = installerKind === 'posix'
const args = isPosix const args = isPosix
? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })] ? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
: ['-Manifest', ...buildPinArgs(installStamp)] : ['-Manifest', ...buildPinArgs(installStamp)]
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, { const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit, emit,
stageName: '__manifest__', stageName: '__manifest__',
hermesHome hermesHome
}) })
if (result.code !== 0) { if (result.code !== 0) {
throw new Error( throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}` `${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`
) )
} }
// The manifest is the LAST JSON line on stdout (install.ps1 may print // The manifest is the LAST JSON line on stdout (install.ps1 may print
// banner / info lines first depending on Console.OutputEncoding effects). // banner / info lines first depending on Console.OutputEncoding effects).
// Find the last line that parses as JSON with a `stages` field. // Find the last line that parses as JSON with a `stages` field.
const lines = result.stdout.split(/\r?\n/).filter(Boolean) const lines = result.stdout.split(/\r?\n/).filter(Boolean)
for (let i = lines.length - 1; i >= 0; i--) { for (let i = lines.length - 1; i >= 0; i--) {
try { try {
const parsed = JSON.parse(lines[i]) const parsed = JSON.parse(lines[i])
if (parsed && Array.isArray(parsed.stages)) { if (parsed && Array.isArray(parsed.stages)) {
return parsed return parsed
} }
@ -505,6 +563,7 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
void 0 void 0
} }
} }
throw new Error( throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}` `${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`
) )
@ -515,9 +574,11 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
// for the double-emit bug we addressed in the install.ps1 PR). // for the double-emit bug we addressed in the install.ps1 PR).
function parseStageResult(stdout) { function parseStageResult(stdout) {
const lines = stdout.split(/\r?\n/).filter(Boolean) const lines = stdout.split(/\r?\n/).filter(Boolean)
for (let i = lines.length - 1; i >= 0; i--) { for (let i = lines.length - 1; i >= 0; i--) {
try { try {
const parsed = JSON.parse(lines[i]) const parsed = JSON.parse(lines[i])
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') { if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
return parsed return parsed
} }
@ -525,6 +586,7 @@ function parseStageResult(stdout) {
void 0 void 0
} }
} }
return null return null
} }
@ -533,6 +595,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
emit({ type: 'stage', name: stage.name, state: 'running' }) emit({ type: 'stage', name: stage.name, state: 'running' })
const isPosix = installerKind === 'posix' const isPosix = installerKind === 'posix'
const args = isPosix const args = isPosix
? [ ? [
'--stage', '--stage',
@ -542,6 +605,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })
] ]
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)] : ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, { const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit, emit,
stageName: stage.name, stageName: stage.name,
@ -554,6 +618,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
if (result.killed) { if (result.killed) {
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, error: 'cancelled by user' } const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, error: 'cancelled by user' }
emit(ev) emit(ev)
return ev return ev
} }
@ -568,20 +633,26 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
error: `${isPosix ? 'install.sh --stage' : 'install.ps1 -Stage'} ${stage.name} produced no JSON result frame (exit=${result.code})`, error: `${isPosix ? 'install.sh --stage' : 'install.ps1 -Stage'} ${stage.name} produced no JSON result frame (exit=${result.code})`,
json: null json: null
} }
emit(ev) emit(ev)
return ev return ev
} }
if (json.ok && json.skipped) { if (json.ok && json.skipped) {
const ev = { type: 'stage', name: stage.name, state: 'skipped', durationMs, json } const ev = { type: 'stage', name: stage.name, state: 'skipped', durationMs, json }
emit(ev) emit(ev)
return ev return ev
} }
if (json.ok) { if (json.ok) {
const ev = { type: 'stage', name: stage.name, state: 'succeeded', durationMs, json } const ev = { type: 'stage', name: stage.name, state: 'succeeded', durationMs, json }
emit(ev) emit(ev)
return ev return ev
} }
const ev = { const ev = {
type: 'stage', type: 'stage',
name: stage.name, name: stage.name,
@ -590,7 +661,9 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
json, json,
error: json.reason || `exit code ${result.code}` error: json.reason || `exit code ${result.code}`
} }
emit(ev) emit(ev)
return ev return ev
} }
@ -603,6 +676,7 @@ function openRunLog(logRoot) {
const ts = new Date().toISOString().replace(/[:.]/g, '-') const ts = new Date().toISOString().replace(/[:.]/g, '-')
const logPath = path.join(logRoot, `bootstrap-${ts}.log`) const logPath = path.join(logRoot, `bootstrap-${ts}.log`)
const stream = fs.createWriteStream(logPath, { flags: 'a' }) const stream = fs.createWriteStream(logPath, { flags: 'a' })
return { path: logPath, stream } return { path: logPath, stream }
} }
@ -619,7 +693,7 @@ async function runBootstrap(opts) {
logRoot, logRoot,
onEvent, onEvent,
abortSignal, abortSignal,
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides writeMarker // callback to write the bootstrap-complete marker; main.ts provides
} = opts } = opts
// Bail before spawning anything if the user already cancelled — otherwise an // Bail before spawning anything if the user already cancelled — otherwise an
@ -633,6 +707,7 @@ async function runBootstrap(opts) {
void 0 void 0
} }
} }
return { ok: false, cancelled: true } return { ok: false, cancelled: true }
} }
@ -646,8 +721,9 @@ async function runBootstrap(opts) {
} catch { } catch {
void 0 void 0
} }
try { try {
if (typeof onEvent === 'function') onEvent(ev) if (typeof onEvent === 'function') {onEvent(ev)}
} catch (err) { } catch (err) {
// Don't let a subscriber bug crash the bootstrap // Don't let a subscriber bug crash the bootstrap
runLog.stream.write(`emit error: ${err && err.message}\n`) runLog.stream.write(`emit error: ${err && err.message}\n`)
@ -677,6 +753,7 @@ async function runBootstrap(opts) {
activeRoot, activeRoot,
installStamp installStamp
}) })
emit({ emit({
type: 'manifest', type: 'manifest',
stages: manifest.stages, stages: manifest.stages,
@ -690,8 +767,10 @@ async function runBootstrap(opts) {
for (const stage of manifest.stages) { for (const stage of manifest.stages) {
if (abortSignal && abortSignal.aborted) { if (abortSignal && abortSignal.aborted) {
emit({ type: 'failed', error: 'bootstrap cancelled by user' }) emit({ type: 'failed', error: 'bootstrap cancelled by user' })
return { ok: false, cancelled: true } return { ok: false, cancelled: true }
} }
const ev = await runStage({ const ev = await runStage({
scriptPath: scriptInfo.path, scriptPath: scriptInfo.path,
installerKind, installerKind,
@ -702,9 +781,11 @@ async function runBootstrap(opts) {
abortSignal, abortSignal,
installStamp installStamp
}) })
if (ev.state === 'failed') { if (ev.state === 'failed') {
emit({ type: 'failed', stage: stage.name, error: ev.error || 'stage failed' }) emit({ type: 'failed', stage: stage.name, error: (ev as any).error || 'stage failed' })
return { ok: false, failedStage: stage.name, error: ev.error }
return { ok: false, failedStage: stage.name, error: (ev as any).error }
} }
} }
@ -713,11 +794,14 @@ async function runBootstrap(opts) {
pinnedCommit: installStamp ? installStamp.commit : null, pinnedCommit: installStamp ? installStamp.commit : null,
pinnedBranch: installStamp ? installStamp.branch : null pinnedBranch: installStamp ? installStamp.branch : null
} }
const marker = typeof writeMarker === 'function' ? writeMarker(markerPayload) : markerPayload const marker = typeof writeMarker === 'function' ? writeMarker(markerPayload) : markerPayload
emit({ type: 'complete', marker }) emit({ type: 'complete', marker })
return { ok: true, marker } return { ok: true, marker }
} catch (err) { } catch (err) {
emit({ type: 'failed', error: err.message || String(err) }) emit({ type: 'failed', error: err.message || String(err) })
return { ok: false, error: err.message || String(err) } return { ok: false, error: err.message || String(err) }
} finally { } finally {
try { try {
@ -728,12 +812,10 @@ async function runBootstrap(opts) {
} }
} }
module.exports = { export { cachedScriptPath,
runBootstrap, installedAgentInstallScript,
// Exposed for testability // Exposed for testability
parseStageResult, parseStageResult,
resolveLocalInstallScript,
resolveInstallScript, resolveInstallScript,
installedAgentInstallScript, resolveLocalInstallScript,
cachedScriptPath runBootstrap }
}

View file

@ -1,7 +1,7 @@
/** /**
* Tests for electron/connection-config.cjs. * Tests for electron/connection-config.ts.
* *
* Run with: node --test electron/connection-config.test.cjs * Run with: node --test electron/connection-config.test.ts
* (Wire into npm test:desktop:platforms in package.json.) * (Wire into npm test:desktop:platforms in package.json.)
* *
* These are the pure helpers behind the remote-gateway connection settings: * These are the pure helpers behind the remote-gateway connection settings:
@ -10,26 +10,24 @@
* and the OAuth session-cookie detector. * and the OAuth session-cookie detector.
*/ */
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import test from 'node:test'
const { import { AT_COOKIE_VARIANTS,
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus, authModeFromStatus,
buildGatewayWsUrl, buildGatewayWsUrl,
buildGatewayWsUrlWithTicket, buildGatewayWsUrlWithTicket,
connectionScopeKey, connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession, cookiesHaveLiveSession,
normAuthMode, cookiesHaveSession,
normalizeRemoteBaseUrl, normalizeRemoteBaseUrl,
normAuthMode,
pathWithGlobalRemoteProfile, pathWithGlobalRemoteProfile,
profileRemoteOverride, profileRemoteOverride,
resolveAuthMode, resolveAuthMode,
resolveTestWsUrl, resolveTestWsUrl,
tokenPreview RT_COOKIE_VARIANTS,
} = require('./connection-config.cjs') tokenPreview } from './connection-config'
// --- connectionScopeKey / normAuthMode --- // --- connectionScopeKey / normAuthMode ---
@ -73,6 +71,7 @@ test('profileRemoteOverride returns the per-profile remote with defaulted auth m
coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } } coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } }
} }
} }
assert.deepEqual(profileRemoteOverride(config, 'coder'), { assert.deepEqual(profileRemoteOverride(config, 'coder'), {
url: 'https://coder.example.com/hermes', url: 'https://coder.example.com/hermes',
authMode: 'token', authMode: 'token',
@ -365,6 +364,7 @@ test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
const url = await resolveTestWsUrl('https://gw.example.com', 'oauth', null, { const url = await resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
mintTicket: async () => 'tkt-9' mintTicket: async () => 'tkt-9'
}) })
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9') assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
}) })
@ -376,13 +376,14 @@ test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validatio
throw new Error('401 ticket mint failed') throw new Error('401 ticket mint failed')
} }
}), }),
err => { (err: any) => {
// Actionable, points the user at re-auth, and preserves the cause + flag // Actionable, points the user at re-auth, and preserves the cause + flag
// the boot overlay uses to offer a sign-in prompt. // the boot overlay uses to offer a sign-in prompt.
assert.match(err.message, /WebSocket ticket/i) assert.match(err.message, /WebSocket ticket/i)
assert.match(err.message, /sign in again/i) assert.match(err.message, /sign in again/i)
assert.equal(err.needsOauthLogin, true) assert.equal(err.needsOauthLogin, true)
assert.ok(err.cause instanceof Error) assert.ok(err.cause instanceof Error)
return true return true
} }
) )

View file

@ -1,13 +1,13 @@
/** /**
* connection-config.cjs * connection-config.ts
* *
* Pure, electron-free helpers for the desktop's remote-gateway connection * Pure, electron-free helpers for the desktop's remote-gateway connection
* config: URL normalization, WS-URL construction (token vs OAuth ticket), * config: URL normalization, WS-URL construction (token vs OAuth ticket),
* auth-mode classification, and the auth-mode coercion rules. * auth-mode classification, and the auth-mode coercion rules.
* *
* Kept standalone (no `require('electron')`) so it can be unit-tested with * Kept standalone (no `import 'electron'`) so it can be unit-tested with
* `node --test` same pattern as backend-probes.cjs / bootstrap-platform.cjs. * `node --test` same pattern as backend-probes.ts / bootstrap-platform.ts.
* main.cjs requires these and wires them into the electron-coupled IPC layer. * main.ts requires these and wires them into the electron-coupled IPC layer.
* *
* Background on the two auth models a remote gateway can use: * Background on the two auth models a remote gateway can use:
* - 'token': legacy static dashboard session token. REST uses an * - 'token': legacy static dashboard session token. REST uses an
@ -45,6 +45,7 @@ function normalizeRemoteBaseUrl(rawUrl) {
} }
let parsed let parsed
try { try {
parsed = new URL(value) parsed = new URL(value)
} catch (error) { } catch (error) {
@ -83,7 +84,7 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* exercise the same transport the app actually uses. * exercise the same transport the app actually uses.
* *
* The OAuth ticket-minter is injected (`mintTicket(baseUrl) -> Promise<ticket>`) * The OAuth ticket-minter is injected (`mintTicket(baseUrl) -> Promise<ticket>`)
* so this stays electron-free and unit-testable; main.cjs passes the real * so this stays electron-free and unit-testable; main.ts passes the real
* `mintGatewayWsTicket`. * `mintGatewayWsTicket`.
* *
* Return semantics: * Return semantics:
@ -93,7 +94,7 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* - oauth, mint fails THROWS (NOT a skip) * - oauth, mint fails THROWS (NOT a skip)
* *
* The oauth-mint-failure throw is the important case: the real boot path * The oauth-mint-failure throw is the important case: the real boot path
* (resolveRemoteBackend in main.cjs) treats a mint failure as a hard * (resolveRemoteBackend in main.ts) treats a mint failure as a hard
* "session expired" auth error and refuses to connect. Swallowing it here * "session expired" auth error and refuses to connect. Swallowing it here
* would re-introduce the exact false-positive this test exists to catch * would re-introduce the exact false-positive this test exists to catch
* HTTP /api/status passes, the test reports "reachable", then the renderer * HTTP /api/status passes, the test reports "reachable", then the renderer
@ -105,13 +106,16 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* @param {{ mintTicket: (baseUrl: string) => Promise<string> }} deps * @param {{ mintTicket: (baseUrl: string) => Promise<string> }} deps
* @returns {Promise<string|null>} * @returns {Promise<string|null>}
*/ */
async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) { async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) {
if (authMode === 'oauth') { if (authMode === 'oauth') {
const mintTicket = deps.mintTicket const mintTicket = deps.mintTicket
if (typeof mintTicket !== 'function') { if (typeof mintTicket !== 'function') {
throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.') throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.')
} }
let ticket let ticket
try { try {
ticket = await mintTicket(baseUrl) ticket = await mintTicket(baseUrl)
} catch (error) { } catch (error) {
@ -119,15 +123,19 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' + 'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
'(it may have expired). Open Settings → Gateway and sign in again.' '(it may have expired). Open Settings → Gateway and sign in again.'
) )
err.needsOauthLogin = true
;(err as any).needsOauthLogin = true
err.cause = error err.cause = error
throw err throw err
} }
return buildGatewayWsUrlWithTicket(baseUrl, ticket) return buildGatewayWsUrlWithTicket(baseUrl, ticket)
} }
if (!token) { if (!token) {
return null return null
} }
return buildGatewayWsUrl(baseUrl, token) return buildGatewayWsUrl(baseUrl, token)
} }
@ -148,17 +156,19 @@ function normAuthMode(mode) {
* *
* The config may carry a `profiles` map keyed by name; an entry counts as an * The config may carry a `profiles` map keyed by name; an entry counts as an
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token` * override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
* is the raw stored secret; main.cjs decrypts it. Returns * is the raw stored secret; main.ts decrypts it. Returns
* `{ url, authMode, token } | null`. * `{ url, authMode, token } | null`.
*/ */
function profileRemoteOverride(config, profile) { function profileRemoteOverride(config, profile) {
const key = connectionScopeKey(profile) const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null const entry = key ? config?.profiles?.[key] : null
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') { if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
return null return null
} }
const url = String(entry.url || '').trim() const url = String(entry.url || '').trim()
if (!url) { if (!url) {
return null return null
} }
@ -172,18 +182,21 @@ function profileRemoteOverride(config, profile) {
* query parameter. Local pooled backends and per-profile remote overrides do not * query parameter. Local pooled backends and per-profile remote overrides do not
* need this: they already run against a backend scoped to the target profile. * need this: they already run against a backend scoped to the target profile.
*/ */
function pathWithGlobalRemoteProfile(path, profile, opts = {}) { function pathWithGlobalRemoteProfile(path, profile, opts: any = {}) {
const scopedProfile = connectionScopeKey(profile) const scopedProfile = connectionScopeKey(profile)
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) { if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
return path return path
} }
const rawPath = String(path || '') const rawPath = String(path || '')
if (!rawPath) { if (!rawPath) {
return path return path
} }
let parsed let parsed
try { try {
parsed = new URL(rawPath, 'http://hermes.local') parsed = new URL(rawPath, 'http://hermes.local')
} catch { } catch {
@ -224,9 +237,12 @@ function authModeFromStatus(statusBody) {
* Returns 'oauth' | 'token'. * Returns 'oauth' | 'token'.
*/ */
function resolveAuthMode(inputAuthMode, existingAuthMode) { function resolveAuthMode(inputAuthMode, existingAuthMode) {
if (inputAuthMode === 'oauth') return 'oauth' if (inputAuthMode === 'oauth') {return 'oauth'}
if (inputAuthMode === 'token') return 'token'
if (existingAuthMode === 'oauth') return 'oauth' if (inputAuthMode === 'token') {return 'token'}
if (existingAuthMode === 'oauth') {return 'oauth'}
return 'token' return 'token'
} }
@ -242,7 +258,8 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
* need to know whether an unexpired access token is present right now. * need to know whether an unexpired access token is present right now.
*/ */
function cookiesHaveSession(cookies) { function cookiesHaveSession(cookies) {
if (!Array.isArray(cookies)) return false if (!Array.isArray(cookies)) {return false}
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value) return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
} }
@ -260,24 +277,23 @@ function cookiesHaveSession(cookies) {
* the RT is also dead/revoked). * the RT is also dead/revoked).
*/ */
function cookiesHaveLiveSession(cookies) { function cookiesHaveLiveSession(cookies) {
if (!Array.isArray(cookies)) return false if (!Array.isArray(cookies)) {return false}
return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))) return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
} }
module.exports = { export { AT_COOKIE_VARIANTS,
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus, authModeFromStatus,
buildGatewayWsUrl, buildGatewayWsUrl,
buildGatewayWsUrlWithTicket, buildGatewayWsUrlWithTicket,
connectionScopeKey, connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession, cookiesHaveLiveSession,
normAuthMode, cookiesHaveSession,
normalizeRemoteBaseUrl, normalizeRemoteBaseUrl,
normAuthMode,
pathWithGlobalRemoteProfile, pathWithGlobalRemoteProfile,
profileRemoteOverride, profileRemoteOverride,
resolveAuthMode, resolveAuthMode,
resolveTestWsUrl, resolveTestWsUrl,
tokenPreview RT_COOKIE_VARIANTS,
} tokenPreview }

View file

@ -1,21 +1,19 @@
/** /**
* Tests for electron/dashboard-token.cjs. * Tests for electron/dashboard-token.ts.
* *
* Run with: node --test electron/dashboard-token.test.cjs * Run with: node --test electron/dashboard-token.test.ts
* (Wired into npm test:desktop:platforms in package.json.) * (Wired into npm test:desktop:platforms in package.json.)
*/ */
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import test from 'node:test'
const { import { adoptServedDashboardToken,
adoptServedDashboardToken,
dashboardIndexUrl, dashboardIndexUrl,
extractInjectedDashboardToken, extractInjectedDashboardToken,
fetchPublicText, fetchPublicText,
isForeignBackendToken, isForeignBackendToken,
resolveServedDashboardToken resolveServedDashboardToken } from './dashboard-token'
} = require('./dashboard-token.cjs')
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => { test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>' const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
@ -39,9 +37,11 @@ test('dashboardIndexUrl preserves dashboard path prefixes', () => {
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => { test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
const logs = [] const logs = []
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async url => { fetchText: async url => {
assert.equal(url, 'http://127.0.0.1:9120/') assert.equal(url, 'http://127.0.0.1:9120/')
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>' return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
}, },
rememberLog: line => logs.push(line) rememberLog: line => logs.push(line)
@ -100,8 +100,9 @@ test('isForeignBackendToken only flags a mismatched token from a dead child', ()
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false], [{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false] [{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
] ]
for (const [input, expected] of cases) { for (const [input, expected] of cases) {
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input)) assert.equal(isForeignBackendToken(input as any), expected, JSON.stringify(input))
} }
}) })
@ -128,6 +129,7 @@ test('adoptServedDashboardToken refuses a foreign token when our child is dead',
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => { test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
const logs = [] const logs = []
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true, childAlive: () => true,
fetchText: async () => { fetchText: async () => {

View file

@ -9,29 +9,35 @@
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000 const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
async function fetchPublicText(url, options = {}) { async function fetchPublicText(url, options: any = {}) {
const { protocol } = new URL(url) const { protocol } = new URL(url)
if (protocol !== 'http:' && protocol !== 'https:') { if (protocol !== 'http:' && protocol !== 'https:') {
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`) throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
} }
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => { const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
if (error.name === 'TimeoutError') { if (error.name === 'TimeoutError') {
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`) throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
} }
throw error throw error
}) })
const text = await res.text() const text = await res.text()
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`) if (!res.ok) {throw new Error(`${res.status}: ${text || res.statusText}`)}
return text return text
} }
function extractInjectedDashboardToken(html) { function extractInjectedDashboardToken(html) {
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || '')) const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
if (!match) return null
if (!match) {return null}
try { try {
return JSON.parse(match[1]) return JSON.parse(match[1])
} catch { } catch {
@ -43,11 +49,13 @@ function dashboardIndexUrl(baseUrl) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/` return `${String(baseUrl || '').replace(/\/+$/, '')}/`
} }
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) { async function resolveServedDashboardToken(baseUrl, fallbackToken, options: any = {}) {
const fetchText = options.fetchText || fetchPublicText const fetchText = options.fetchText || fetchPublicText
const html = await fetchText(dashboardIndexUrl(baseUrl), { const html = await fetchText(dashboardIndexUrl(baseUrl), {
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
}) })
const servedToken = extractInjectedDashboardToken(html) const servedToken = extractInjectedDashboardToken(html)
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') { if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
@ -76,6 +84,7 @@ function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) { async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => { const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`) options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
return spawnToken return spawnToken
}) })
@ -88,12 +97,10 @@ async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, labe
return servedToken return servedToken
} }
module.exports = { export { adoptServedDashboardToken,
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
adoptServedDashboardToken,
dashboardIndexUrl, dashboardIndexUrl,
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
extractInjectedDashboardToken, extractInjectedDashboardToken,
fetchPublicText, fetchPublicText,
isForeignBackendToken, isForeignBackendToken,
resolveServedDashboardToken resolveServedDashboardToken }
}

View file

@ -1,7 +1,7 @@
/** /**
* Tests for electron/desktop-uninstall.cjs. * Tests for electron/desktop-uninstall.ts.
* *
* Run with: node --test electron/desktop-uninstall.test.cjs * Run with: node --test electron/desktop-uninstall.test.ts
* (Wired into npm test:desktop:platforms in package.json.) * (Wired into npm test:desktop:platforms in package.json.)
* *
* These are the pure helpers behind the desktop Chat GUI uninstaller: the * These are the pure helpers behind the desktop Chat GUI uninstaller: the
@ -9,19 +9,17 @@
* cleanup-script builders (POSIX + Windows). * cleanup-script builders (POSIX + Windows).
*/ */
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import test from 'node:test'
const { import { buildPosixCleanupScript,
UNINSTALL_MODES,
buildPosixCleanupScript,
buildWindowsCleanupScript, buildWindowsCleanupScript,
modeRemovesAgent, modeRemovesAgent,
modeRemovesUserData, modeRemovesUserData,
resolveRemovableAppPath, resolveRemovableAppPath,
shouldRemoveAppBundle, shouldRemoveAppBundle,
uninstallArgsForMode UNINSTALL_MODES,
} = require('./desktop-uninstall.cjs') uninstallArgsForMode } from './desktop-uninstall'
// --- uninstallArgsForMode --- // --- uninstallArgsForMode ---
@ -132,6 +130,7 @@ test('buildPosixCleanupScript waits for the PID, runs the uninstall module, remo
appPath: '/opt/hermes/linux-unpacked', appPath: '/opt/hermes/linux-unpacked',
hermesHome: '/home/x/.hermes' hermesHome: '/home/x/.hermes'
}) })
assert.match(script, /^#!\/bin\/bash/) assert.match(script, /^#!\/bin\/bash/)
assert.match(script, /pid=4321/) assert.match(script, /pid=4321/)
assert.match(script, /kill -0 "\$pid"/) assert.match(script, /kill -0 "\$pid"/)
@ -152,6 +151,7 @@ test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/fu
appPath: null, appPath: null,
hermesHome: '/home/x/.hermes' hermesHome: '/home/x/.hermes'
}) })
// System python + source on PYTHONPATH so import hermes_cli works while the // System python + source on PYTHONPATH so import hermes_cli works while the
// venv is torn down. // venv is torn down.
assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/) assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/)
@ -168,6 +168,7 @@ test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', (
appPath: null, appPath: null,
hermesHome: '/h' hermesHome: '/h'
}) })
assert.doesNotMatch(script, /export PYTHONPATH/) assert.doesNotMatch(script, /export PYTHONPATH/)
}) })
@ -181,6 +182,7 @@ test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => {
appPath: null, appPath: null,
hermesHome: '/h' hermesHome: '/h'
}) })
assert.doesNotMatch(script, /rm -rf '\//) assert.doesNotMatch(script, /rm -rf '\//)
// Still runs the uninstall. // Still runs the uninstall.
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/) assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/)
@ -196,6 +198,7 @@ test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () =
appPath: null, appPath: null,
hermesHome: '/h' hermesHome: '/h'
}) })
// The apostrophe is closed-escaped-reopened so the shell sees the literal. // The apostrophe is closed-escaped-reopened so the shell sees the literal.
assert.match(script, /'\/home\/o'\\''brien\/python'/) assert.match(script, /'\/home\/o'\\''brien\/python'/)
}) })
@ -212,6 +215,7 @@ test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir b
appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes', appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes',
hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes' hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes'
}) })
assert.match(script, /@echo off/) assert.match(script, /@echo off/)
assert.match(script, /set "PID=9988"/) assert.match(script, /set "PID=9988"/)
// PYTHONPATH set so a system python can import hermes_cli from source. // PYTHONPATH set so a system python can import hermes_cli from source.
@ -238,6 +242,7 @@ test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, n
appPath: null, appPath: null,
hermesHome: 'C:\\h' hermesHome: 'C:\\h'
}) })
assert.doesNotMatch(script, /rmdir/) assert.doesNotMatch(script, /rmdir/)
assert.doesNotMatch(script, /set "PYTHONPATH=/) assert.doesNotMatch(script, /set "PYTHONPATH=/)
}) })

View file

@ -1,14 +1,14 @@
/** /**
* desktop-uninstall.cjs * desktop-uninstall.ts
* *
* Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map * Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map
* the three user-facing uninstall modes to the `hermes uninstall` CLI flags, * the three user-facing uninstall modes to the `hermes uninstall` CLI flags,
* resolve the running app bundle/exe so a detached cleanup script can remove * resolve the running app bundle/exe so a detached cleanup script can remove
* it after the app quits, and build that cleanup script for each OS. * it after the app quits, and build that cleanup script for each OS.
* *
* Kept standalone (no `require('electron')`) so it can be unit-tested with * Kept standalone (no ` import 'electron'`) so it can be unit-tested with
* `node --test` same pattern as connection-config.cjs / backend-probes.cjs. * `node --test` same pattern as connection-config.ts / backend-probes.ts.
* main.cjs requires these and wires them into the electron-coupled IPC layer. * main.ts requires these and wires them into the electron-coupled IPC layer.
* *
* The three modes mirror the CLI's options exactly: * The three modes mirror the CLI's options exactly:
* - 'gui' remove ONLY the Chat GUI, keep the agent + all user data. * - 'gui' remove ONLY the Chat GUI, keep the agent + all user data.
@ -23,10 +23,10 @@
* app bundle (locked on macOS/Windows while the process is alive). So we hand * app bundle (locked on macOS/Windows while the process is alive). So we hand
* the work to a detached child that waits for this app's PID to exit, runs the * the work to a detached child that waits for this app's PID to exit, runs the
* Python uninstall, then removes the app bundle then the app quits. Same * Python uninstall, then removes the app bundle then the app quits. Same
* shape as the self-update swap-and-relaunch flow already in main.cjs. * shape as the self-update swap-and-relaunch flow already in main.ts.
*/ */
const path = require('node:path') import path from 'node:path'
const UNINSTALL_MODES = ['gui', 'lite', 'full'] const UNINSTALL_MODES = ['gui', 'lite', 'full']
@ -41,6 +41,7 @@ function uninstallArgsForMode(mode) {
if (!UNINSTALL_MODES.includes(mode)) { if (!UNINSTALL_MODES.includes(mode)) {
throw new Error(`Unknown uninstall mode: ${mode}`) throw new Error(`Unknown uninstall mode: ${mode}`)
} }
return ['-m', 'hermes_cli.uninstall', '--mode', mode] return ['-m', 'hermes_cli.uninstall', '--mode', mode]
} }
@ -65,9 +66,10 @@ function modeRemovesUserData(mode) {
* Returns null when we can't confidently identify a removable bundle (e.g. * Returns null when we can't confidently identify a removable bundle (e.g.
* running from a dev checkout, or a system-package install we must not rmtree). * running from a dev checkout, or a system-package install we must not rmtree).
*/ */
function resolveRemovableAppPath(execPath, platform, env = {}) { function resolveRemovableAppPath(execPath, platform, env: any = {}) {
const exe = String(execPath || '') const exe = String(execPath || '')
if (!exe) return null
if (!exe) {return null}
// Use the path flavor that matches the TARGET platform, not the host running // Use the path flavor that matches the TARGET platform, not the host running
// this code — so the Windows branch parses backslash paths correctly even // this code — so the Windows branch parses backslash paths correctly even
@ -79,22 +81,28 @@ function resolveRemovableAppPath(execPath, platform, env = {}) {
const macOsDir = p.dirname(exe) // …/Contents/MacOS const macOsDir = p.dirname(exe) // …/Contents/MacOS
const contents = p.dirname(macOsDir) // …/Contents const contents = p.dirname(macOsDir) // …/Contents
const appBundle = p.dirname(contents) // …/Hermes.app const appBundle = p.dirname(contents) // …/Hermes.app
if (appBundle.endsWith('.app')) return appBundle
if (appBundle.endsWith('.app')) {return appBundle}
return null return null
} }
if (platform === 'win32') { if (platform === 'win32') {
// NSIS per-user installs Hermes.exe directly in the install dir. // NSIS per-user installs Hermes.exe directly in the install dir.
const dir = p.dirname(exe) const dir = p.dirname(exe)
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) {return dir}
return null return null
} }
// Linux: an AppImage exposes its own path via the APPIMAGE env var. // Linux: an AppImage exposes its own path via the APPIMAGE env var.
if (env.APPIMAGE) return env.APPIMAGE if (env.APPIMAGE) {return env.APPIMAGE}
// Unpacked electron-builder tree: …/linux-unpacked/hermes // Unpacked electron-builder tree: …/linux-unpacked/hermes
const dir = p.dirname(exe) const dir = p.dirname(exe)
if (/-unpacked$/.test(dir)) return dir
if (/-unpacked$/.test(dir)) {return dir}
return null return null
} }
@ -121,6 +129,7 @@ function shouldRemoveAppBundle(isPackaged, appPath) {
*/ */
function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) { function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'` const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
const lines = [ const lines = [
'#!/bin/bash', '#!/bin/bash',
'set -u', 'set -u',
@ -135,16 +144,21 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
'fi', 'fi',
`export HERMES_HOME=${q(hermesHome)}` `export HERMES_HOME=${q(hermesHome)}`
] ]
if (pythonPath) { if (pythonPath) {
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`) lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
} }
lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`) lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`)
if (appPath) { if (appPath) {
lines.push(`rm -rf ${q(appPath)} || true`) lines.push(`rm -rf ${q(appPath)} || true`)
} }
// Self-delete the script. // Self-delete the script.
lines.push('rm -f "$0" 2>/dev/null || true') lines.push('rm -f "$0" 2>/dev/null || true')
lines.push('') lines.push('')
return lines.join('\n') return lines.join('\n')
} }
@ -180,15 +194,18 @@ function buildWindowsCleanupScript({
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be // under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
// a problem, but Hermes install paths don't use them. // a problem, but Hermes install paths don't use them.
const q = s => `"${String(s).replace(/"/g, '')}"` const q = s => `"${String(s).replace(/"/g, '')}"`
const lines = [ const lines = [
'@echo off', '@echo off',
'setlocal enableextensions', 'setlocal enableextensions',
`set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`, `set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`,
`set "PID=${pid}"` `set "PID=${pid}"`
] ]
if (pythonPath) { if (pythonPath) {
lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`) lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`)
} }
lines.push( lines.push(
'set /a waited=0', 'set /a waited=0',
':waitloop', ':waitloop',
@ -206,6 +223,7 @@ function buildWindowsCleanupScript({
`cd /d ${q(agentRoot)}`, `cd /d ${q(agentRoot)}`,
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}` `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}`
) )
if (appPath) { if (appPath) {
lines.push( lines.push(
'set /a tries=0', 'set /a tries=0',
@ -220,18 +238,18 @@ function buildWindowsCleanupScript({
':rmdone' ':rmdone'
) )
} }
lines.push('del "%~f0"') lines.push('del "%~f0"')
lines.push('') lines.push('')
return lines.join('\r\n') return lines.join('\r\n')
} }
module.exports = { export { buildPosixCleanupScript,
UNINSTALL_MODES,
buildPosixCleanupScript,
buildWindowsCleanupScript, buildWindowsCleanupScript,
modeRemovesAgent, modeRemovesAgent,
modeRemovesUserData, modeRemovesUserData,
resolveRemovableAppPath, resolveRemovableAppPath,
shouldRemoveAppBundle, shouldRemoveAppBundle,
uninstallArgsForMode UNINSTALL_MODES,
} uninstallArgsForMode }

View file

@ -1,9 +1,8 @@
'use strict' import { session } from 'electron'
const { session } = require('electron')
const EMBED_SESSION_PARTITION = 'persist:hermes-embed' const EMBED_SESSION_PARTITION = 'persist:hermes-embed'
const EMBED_REFERER = 'https://www.youtube.com/' const EMBED_REFERER = 'https://www.youtube.com/'
const YOUTUBE_REFERER_HOST_RE = const YOUTUBE_REFERER_HOST_RE =
/(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i /(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i
@ -23,6 +22,7 @@ function installEmbedRefererForSession(embedSession) {
if (!YOUTUBE_REFERER_HOST_RE.test(host)) { if (!YOUTUBE_REFERER_HOST_RE.test(host)) {
callback({ requestHeaders: details.requestHeaders }) callback({ requestHeaders: details.requestHeaders })
return return
} }
@ -45,4 +45,4 @@ function installEmbedReferer() {
} }
} }
module.exports = { installEmbedReferer } export { installEmbedReferer }

View file

@ -1,19 +1,17 @@
'use strict' import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { pathToFileURL } from 'node:url'
const assert = require('node:assert/strict') import { readDirForIpc } from './fs-read-dir'
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { pathToFileURL } = require('node:url')
const { readDirForIpc } = require('./fs-read-dir.cjs')
function mkTmpDir() { function mkTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-')) return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
} }
function fakeDirent(name, flags = {}) { function fakeDirent(name, flags: any = {}) {
return { return {
name, name,
isDirectory: () => Boolean(flags.directory), isDirectory: () => Boolean(flags.directory),
@ -109,10 +107,12 @@ test('readDirForIpc accepts file URLs for directories', async () => {
test('readDirForIpc returns invalid-path for blank or non-string input', async () => { test('readDirForIpc returns invalid-path for blank or non-string input', async () => {
let readdirCalls = 0 let readdirCalls = 0
const fsImpl = { const fsImpl = {
promises: { promises: {
readdir: async () => { readdir: async () => {
readdirCalls += 1 readdirCalls += 1
return [] return []
} }
} }
@ -126,10 +126,12 @@ test('readDirForIpc returns invalid-path for blank or non-string input', async (
test('readDirForIpc rejects Windows device paths before readdir', async () => { test('readDirForIpc rejects Windows device paths before readdir', async () => {
let readdirCalls = 0 let readdirCalls = 0
const fsImpl = { const fsImpl = {
promises: { promises: {
readdir: async () => { readdir: async () => {
readdirCalls += 1 readdirCalls += 1
return [] return []
} }
} }
@ -224,6 +226,7 @@ test('readDirForIpc allows expanding symlink or junction directories outside the
fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok') fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
const linkPath = path.join(root, 'outside-link') const linkPath = path.join(root, 'outside-link')
try { try {
fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir') fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
} catch (error) { } catch (error) {
@ -252,6 +255,7 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th
const input = path.join('virtual-root') const input = path.join('virtual-root')
const resolved = path.resolve(input) const resolved = path.resolve(input)
const statCalls = [] const statCalls = []
const fsImpl = { const fsImpl = {
promises: { promises: {
readdir: async () => [ readdir: async () => [
@ -266,9 +270,11 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th
} }
statCalls.push(fullPath) statCalls.push(fullPath)
if (fullPath.endsWith(`${path.sep}linked-dir`)) { if (fullPath.endsWith(`${path.sep}linked-dir`)) {
return { isDirectory: () => true } return { isDirectory: () => true }
} }
throw Object.assign(new Error('gone'), { code: 'ENOENT' }) throw Object.assign(new Error('gone'), { code: 'ENOENT' })
} }
} }
@ -301,12 +307,15 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
let peak = 0 let peak = 0
let releaseStats let releaseStats
let markFirstStatStarted let markFirstStatStarted
const statsReleased = new Promise(resolve => { const statsReleased = new Promise(resolve => {
releaseStats = resolve releaseStats = resolve
}) })
const firstStatStarted = new Promise(resolve => { const firstStatStarted = new Promise(resolve => {
markFirstStatStarted = resolve markFirstStatStarted = resolve
}) })
const fsImpl = { const fsImpl = {
promises: { promises: {
readdir: async () => [ readdir: async () => [
@ -326,6 +335,7 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
active -= 1 active -= 1
const name = path.basename(fullPath) const name = path.basename(fullPath)
if (name === failedName) { if (name === failedName) {
throw Object.assign(new Error('gone'), { code: 'ENOENT' }) throw Object.assign(new Error('gone'), { code: 'ENOENT' })
} }

View file

@ -1,8 +1,7 @@
'use strict' import fs from 'node:fs'
import path from 'node:path'
const fs = require('node:fs') import { resolveDirectoryForIpc } from './hardening'
const path = require('node:path')
const { resolveDirectoryForIpc } = require('./hardening.cjs')
const FS_READDIR_STAT_CONCURRENCY = 16 const FS_READDIR_STAT_CONCURRENCY = 16
@ -37,7 +36,7 @@ function direntIsSymbolicLink(dirent) {
} }
function shouldStatDirent(dirent) { function shouldStatDirent(dirent) {
if (direntIsDirectory(dirent)) return false if (direntIsDirectory(dirent)) {return false}
return direntIsSymbolicLink(dirent) || !direntIsFile(dirent) return direntIsSymbolicLink(dirent) || !direntIsFile(dirent)
} }
@ -70,13 +69,13 @@ async function mapWithStatConcurrency(items, mapper) {
} }
const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length) const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length)
const workers = Array.from({ length: workerCount }, () => runWorker()) const workers = Array.from({ length: workerCount } as any, () => runWorker())
await Promise.all(workers) await Promise.all(workers)
return results return results
} }
async function readDirForIpc(dirPath, options = {}) { async function readDirForIpc(dirPath, options: any = {}) {
const fsImpl = options.fs || fs const fsImpl = options.fs || fs
let resolved let resolved
@ -102,6 +101,4 @@ async function readDirForIpc(dirPath, options = {}) {
} }
} }
module.exports = { export { readDirForIpc }
readDirForIpc
}

View file

@ -1,7 +1,7 @@
/** /**
* Tests for electron/gateway-ws-probe.cjs. * Tests for electron/gateway-ws-probe.ts.
* *
* Run with: node --test electron/gateway-ws-probe.test.cjs * Run with: node --test electron/gateway-ws-probe.test.ts
* (Wired into npm test:desktop:platforms in package.json.) * (Wired into npm test:desktop:platforms in package.json.)
* *
* The probe drives a real WebSocket handshake for the "Test remote" button. * The probe drives a real WebSocket handshake for the "Test remote" button.
@ -9,16 +9,20 @@
* outcome (open, frame, error, early close, never-opens) without a network. * outcome (open, frame, error, early close, never-opens) without a network.
*/ */
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import test from 'node:test'
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') import { probeGatewayWebSocket } from './gateway-ws-probe'
// Minimal WebSocket double: records listeners synchronously (the probe attaches // Minimal WebSocket double: records listeners synchronously (the probe attaches
// them in its executor) and exposes emit() so the test can replay events. // them in its executor) and exposes emit() so the test can replay events.
function makeFakeWs() { function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } {
const instances = [] const instances = []
class FakeWs { class FakeWs {
url: string
closed = false
listeners: Record<string, any[]> = {}
constructor(url) { constructor(url) {
this.url = url this.url = url
this.listeners = {} this.listeners = {}
@ -32,9 +36,12 @@ function makeFakeWs() {
this.closed = true this.closed = true
} }
emit(type, event) { emit(type, event) {
for (const fn of this.listeners[type] || []) fn(event) for (const fn of this.listeners[type] || []) {
fn(event)
}
} }
} }
return { FakeWs, instances } return { FakeWs, instances }
} }
@ -51,11 +58,13 @@ test('probe resolves ok when the socket opens and stays open', async () => {
test('probe resolves ok immediately when a frame arrives', async () => { test('probe resolves ok immediately when a frame arrives', async () => {
const { FakeWs, instances } = makeFakeWs() const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', {
WebSocketImpl: FakeWs, WebSocketImpl: FakeWs,
connectTimeoutMs: 1_000, connectTimeoutMs: 1_000,
readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer
}) })
instances[0].emit('open') instances[0].emit('open')
instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' }) instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' })
const result = await promise const result = await promise
@ -95,11 +104,13 @@ test('probe fails when the gateway accepts then immediately closes (auth rejecte
test('probe times out when the socket never opens', async () => { test('probe times out when the socket never opens', async () => {
const { FakeWs } = makeFakeWs() const { FakeWs } = makeFakeWs()
const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', { const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', {
WebSocketImpl: FakeWs, WebSocketImpl: FakeWs,
connectTimeoutMs: 20, connectTimeoutMs: 20,
readyGraceMs: 10 readyGraceMs: 10
}) })
assert.equal(result.ok, false) assert.equal(result.ok, false)
assert.match(result.reason, /Timed out/) assert.match(result.reason, /Timed out/)
}) })

View file

@ -36,13 +36,13 @@ const DEFAULT_READY_GRACE_MS = 750
* Attempt a live WebSocket connection and classify the outcome. * Attempt a live WebSocket connection and classify the outcome.
* *
* @param {string} wsUrl - Fully-formed ws(s):// URL including the credential. * @param {string} wsUrl - Fully-formed ws(s):// URL including the credential.
* @param {object} [options]
* @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor.
* @param {number} [options.connectTimeoutMs]
* @param {number} [options.readyGraceMs]
* @returns {Promise<{ ok: boolean, reason?: string }>} * @returns {Promise<{ ok: boolean, reason?: string }>}
*/ */
function probeGatewayWebSocket(wsUrl, options = {}) { function probeGatewayWebSocket<T>(wsUrl: string, options:{
WebSocketImpl?: any,
connectTimeoutMs?: number
readyGraceMs?: number
} = {}) {
const WebSocketImpl = options.WebSocketImpl const WebSocketImpl = options.WebSocketImpl
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS
@ -54,7 +54,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
}) })
} }
return new Promise(resolve => { return new Promise<any>(resolve => {
let settled = false let settled = false
let opened = false let opened = false
let connectTimer = null let connectTimer = null
@ -66,6 +66,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
clearTimeout(connectTimer) clearTimeout(connectTimer)
connectTimer = null connectTimer = null
} }
if (graceTimer !== null) { if (graceTimer !== null) {
clearTimeout(graceTimer) clearTimeout(graceTimer)
graceTimer = null graceTimer = null
@ -73,14 +74,16 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
} }
const finish = result => { const finish = result => {
if (settled) return if (settled) {return}
settled = true settled = true
clearTimers() clearTimers()
try { try {
socket?.close?.() socket?.close?.()
} catch { } catch {
// ignore — best effort teardown // ignore — best effort teardown
} }
resolve(result) resolve(result)
} }
@ -91,11 +94,12 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
ok: false, ok: false,
reason: error instanceof Error ? error.message : String(error) reason: error instanceof Error ? error.message : String(error)
}) })
return return
} }
const onOpen = () => { const onOpen = () => {
if (settled) return if (settled) {return}
opened = true opened = true
// Upgrade accepted. Give the server a brief window to reject the // Upgrade accepted. Give the server a brief window to reject the
// credential post-handshake (early close) before declaring success. // credential post-handshake (early close) before declaring success.
@ -118,7 +122,8 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
} }
const onClose = event => { const onClose = event => {
if (settled) return if (settled) {return}
if (opened) { if (opened) {
// Opened, then closed inside the grace window: the upgrade was accepted // Opened, then closed inside the grace window: the upgrade was accepted
// but the session was refused (e.g. ws-ticket/token rejected, or a // but the session was refused (e.g. ws-ticket/token rejected, or a
@ -127,8 +132,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
ok: false, ok: false,
reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).') reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).')
}) })
return return
} }
finish({ finish({
ok: false, ok: false,
reason: closeReason(event, 'The gateway closed the WebSocket before it opened.') reason: closeReason(event, 'The gateway closed the WebSocket before it opened.')
@ -154,8 +161,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
function addListener(socket, type, handler) { function addListener(socket, type, handler) {
if (typeof socket.addEventListener === 'function') { if (typeof socket.addEventListener === 'function') {
socket.addEventListener(type, handler) socket.addEventListener(type, handler)
return return
} }
// Node's global WebSocket implements addEventListener; this fallback keeps the // Node's global WebSocket implements addEventListener; this fallback keeps the
// helper usable with the `ws` package's EventEmitter shape too. // helper usable with the `ws` package's EventEmitter shape too.
if (typeof socket.on === 'function') { if (typeof socket.on === 'function') {
@ -164,25 +173,31 @@ function addListener(socket, type, handler) {
} }
function extractErrorReason(event) { function extractErrorReason(event) {
if (!event) return '' if (!event) {return ''}
if (event instanceof Error) return event.message
if (event instanceof Error) {return event.message}
const err = event.error || event.message const err = event.error || event.message
if (err instanceof Error) return err.message
if (typeof err === 'string') return err if (err instanceof Error) {return err.message}
if (typeof err === 'string') {return err}
return '' return ''
} }
function closeReason(event, fallback) { function closeReason(event, fallback) {
const code = event && typeof event.code === 'number' ? event.code : null const code = event && typeof event.code === 'number' ? event.code : null
const reason = event && typeof event.reason === 'string' ? event.reason.trim() : '' const reason = event && typeof event.reason === 'string' ? event.reason.trim() : ''
if (code && reason) return `${fallback} (code ${code}: ${reason})`
if (code) return `${fallback} (code ${code})` if (code && reason) {return `${fallback} (code ${code}: ${reason})`}
if (reason) return `${fallback} (${reason})`
if (code) {return `${fallback} (code ${code})`}
if (reason) {return `${fallback} (${reason})`}
return fallback return fallback
} }
module.exports = { export { DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_READY_GRACE_MS, DEFAULT_READY_GRACE_MS,
probeGatewayWebSocket probeGatewayWebSocket }
}

View file

@ -1,14 +1,12 @@
'use strict'
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs` // Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
// — no native addon, so it just works for anyone who pulls main (no // — no native addon, so it just works for anyone who pulls main (no
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git` // electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the // (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
// first scan stays fast. Results are cached by the backend after the first run. // first scan stays fast. Results are cached by the backend after the first run.
const fs = require('node:fs') import fs from 'node:fs'
const os = require('node:os') import os from 'node:os'
const path = require('node:path') import path from 'node:path'
const fsp = fs.promises const fsp = fs.promises
@ -36,14 +34,14 @@ async function mapLimit(items, limit, fn) {
} }
} }
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker))
} }
/** /**
* Scan `roots` (default: the home dir) for git repositories. Returns deduped * Scan `roots` (default: the home dir) for git repositories. Returns deduped
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3). * `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
*/ */
async function scanGitRepos(roots, options = {}) { async function scanGitRepos(roots, options: any = {}) {
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()] const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
const found = new Map() const found = new Map()
@ -54,6 +52,7 @@ async function scanGitRepos(roots, options = {}) {
} }
let entries let entries
try { try {
entries = await fsp.readdir(dir, { withFileTypes: true }) entries = await fsp.readdir(dir, { withFileTypes: true })
} catch { } catch {
@ -73,6 +72,7 @@ async function scanGitRepos(roots, options = {}) {
} }
const subdirs = [] const subdirs = []
for (const entry of entries) { for (const entry of entries) {
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no // Real directories only (skip symlinks to avoid loops), no hidden dirs, no
// known heavy trees. // known heavy trees.
@ -93,4 +93,4 @@ async function scanGitRepos(roots, options = {}) {
return [...found.entries()].map(([root, label]) => ({ label, root })) return [...found.entries()].map(([root, label]) => ({ label, root }))
} }
module.exports = { scanGitRepos } export { scanGitRepos }

View file

@ -1,9 +1,7 @@
'use strict' import assert from 'node:assert/strict'
import test from 'node:test'
const assert = require('node:assert/strict') import { resolveRenamePath } from './git-review-ops'
const test = require('node:test')
const { resolveRenamePath } = require('./git-review-ops.cjs')
test('resolveRenamePath: plain path is unchanged', () => { test('resolveRenamePath: plain path is unchanged', () => {
assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts') assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts')

View file

@ -1,37 +1,16 @@
'use strict'
// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git` // Git ops backing the coding rail + Codex-style review pane. Built on `simple-git`
// (a maintained wrapper around the system git binary — same git the rest of the // (a maintained wrapper around the system git binary — same git the rest of the
// app shells to, no native build) so we read structured status()/diffSummary() // app shells to, no native build) so we read structured status()/diffSummary()
// results instead of hand-parsing porcelain. Reads degrade to null/empty on a // results instead of hand-parsing porcelain. Reads degrade to null/empty on a
// non-repo / remote backend; mutations reject so the renderer can toast. // non-repo / remote backend; mutations reject so the renderer can toast.
const { execFile } = require('node:child_process') import { execFile } from 'node:child_process'
const fs = require('node:fs/promises') import fs from 'node:fs/promises'
const path = require('node:path') import path from 'node:path'
// `simple-git` is a pure-JS runtime dep that workspace dedup hoists into the import simpleGit from "simple-git";
// repo-root node_modules. Packaged builds set `files:` in package.json, which
// excludes node_modules from the asar, so the normal require() fails at launch
// (issue #52735: "Cannot find module 'simple-git'"). We ship the dep's
// closure under resources/native-deps/vendor/node_modules/ via extraResources
// + scripts/stage-native-deps.cjs, and resolve from there when the hoisted
// require() isn't reachable. The `vendor/` nesting matters: electron-builder
// drops a node_modules dir at the root of an extraResources copy but keeps a
// nested one. Dev mode never hits the fallback -- Node's normal lookup finds
// the hoisted copy.
let simpleGit
try {
simpleGit = require('simple-git')
} catch {
const resourcesPath = process.resourcesPath
if (!resourcesPath) {
throw new Error("git-review IPC: 'simple-git' not found and no resourcesPath to fall back to")
}
simpleGit = require(path.join(resourcesPath, 'native-deps', 'vendor', 'node_modules', 'simple-git'))
}
const { resolveRequestedPathForIpc } = require('./hardening.cjs') import { resolveRequestedPathForIpc } from './hardening'
const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000 const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000
const COMMIT_CONTEXT_UNTRACKED_MAX = 80 const COMMIT_CONTEXT_UNTRACKED_MAX = 80
@ -52,7 +31,7 @@ function ghEnv(ghBin) {
// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on // Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on
// availability/auth without a throw. gh missing/unauthed → ok:false. // availability/auth without a throw. gh missing/unauthed → ok:false.
function runGh(args, cwd, ghBin) { function runGh(args, cwd, ghBin): Promise<{ok: boolean, stdout: string}> {
return new Promise(resolve => { return new Promise(resolve => {
execFile( execFile(
ghBin || 'gh', ghBin || 'gh',
@ -260,10 +239,11 @@ async function reviewList(repoPath, scope, baseRef, gitBin) {
const range = scope === 'branch' ? `${base}...HEAD` : base const range = scope === 'branch' ? `${base}...HEAD` : base
const summary = await git.diffSummary([range]) const summary = await git.diffSummary([range])
const files = summary.files.map(file => ({ const files = summary.files.map(file => ({
path: resolveRenamePath(file.file), path: resolveRenamePath(file.file),
added: file.binary ? 0 : file.insertions, added: 'insertions' in file ? file.insertions : 0 ,
removed: file.binary ? 0 : file.deletions, removed: 'deletions' in file ? file.deletions : 0 ,
status: 'M', status: 'M',
staged: false staged: false
})) }))
@ -291,6 +271,7 @@ async function reviewList(repoPath, scope, baseRef, gitBin) {
git.diffSummary(['--cached']), git.diffSummary(['--cached']),
git.diffSummary([]) git.diffSummary([])
]) ])
const stagedCounts = countsByPath(staged) const stagedCounts = countsByPath(staged)
const unstagedCounts = countsByPath(unstaged) const unstagedCounts = countsByPath(unstaged)
@ -495,6 +476,7 @@ async function reviewCommitContext(repoPath, gitBin) {
const safe = args => git.diff(args).catch(() => '') const safe = args => git.diff(args).catch(() => '')
let status let status
try { try {
status = await git.status() status = await git.status()
} catch { } catch {
@ -510,9 +492,11 @@ async function reviewCommitContext(repoPath, gitBin) {
// Untracked files have no diff — list them so new files aren't invisible. // Untracked files have no diff — list them so new files aren't invisible.
const untracked = status.not_added || [] const untracked = status.not_added || []
if (untracked.length > 0) { if (untracked.length > 0) {
const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX) const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX)
const omitted = untracked.length - visible.length const omitted = untracked.length - visible.length
const note = const note =
`\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` + `\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` +
(omitted > 0 ? `# ... ${omitted} more omitted\n` : '') (omitted > 0 ? `# ... ${omitted} more omitted\n` : '')
@ -607,6 +591,7 @@ async function repoStatus(repoPath, gitBin) {
// fail soft and hide the coding rail instead of spamming IPC handler errors. // fail soft and hide the coding rail instead of spamming IPC handler errors.
try { try {
const stat = await fs.stat(cwd) const stat = await fs.stat(cwd)
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
return null return null
} }
@ -615,11 +600,13 @@ async function repoStatus(repoPath, gitBin) {
} }
let git let git
try { try {
git = gitFor(cwd, gitBin) git = gitFor(cwd, gitBin)
} catch { } catch {
return null return null
} }
let status let status
try { try {
@ -630,6 +617,7 @@ async function repoStatus(repoPath, gitBin) {
} }
const detached = typeof status.detached === 'boolean' ? status.detached : !status.current const detached = typeof status.detached === 'boolean' ? status.detached : !status.current
const files = status.files.map(file => ({ const files = status.files.map(file => ({
path: file.path, path: file.path,
staged: isStaged(file), staged: isStaged(file),
@ -671,10 +659,12 @@ async function repoStatus(repoPath, gitBin) {
// can't stall the probe. // can't stall the probe.
try { try {
const untracked = status.not_added.slice(0, 500) const untracked = status.not_added.slice(0, 500)
for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) { for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
const batch = await Promise.all( const batch = await Promise.all(
untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path)) untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path))
) )
result.added += batch.reduce((sum, n) => sum + n, 0) result.added += batch.reduce((sum, n) => sum + n, 0)
} }
} catch { } catch {
@ -684,8 +674,7 @@ async function repoStatus(repoPath, gitBin) {
return result return result
} }
module.exports = { export { branchBase,
branchBase,
fileDiffVsHead, fileDiffVsHead,
repoStatus, repoStatus,
resolveRenamePath, resolveRenamePath,
@ -695,9 +684,8 @@ module.exports = {
reviewDiff, reviewDiff,
reviewList, reviewList,
reviewPush, reviewPush,
reviewRevParse,
reviewRevert, reviewRevert,
reviewRevParse,
reviewShipInfo, reviewShipInfo,
reviewStage, reviewStage,
reviewUnstage reviewUnstage }
}

View file

@ -1,13 +1,11 @@
'use strict' import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { pathToFileURL } from 'node:url'
const assert = require('node:assert/strict') import { gitRootForIpc } from './git-root'
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { pathToFileURL } = require('node:url')
const { gitRootForIpc } = require('./git-root.cjs')
function mkTmpDir() { function mkTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-')) return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))

View file

@ -1,8 +1,7 @@
'use strict' import fs from 'node:fs'
import path from 'node:path'
const fs = require('node:fs') import { resolveRequestedPathForIpc } from './hardening'
const path = require('node:path')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
function findGitRoot(start, fsImpl = fs) { function findGitRoot(start, fsImpl = fs) {
let dir = start let dir = start
@ -28,7 +27,7 @@ function findGitRoot(start, fsImpl = fs) {
return null return null
} }
async function gitRootForIpc(startPath, options = {}) { async function gitRootForIpc(startPath, options: {fs?: typeof fs} = {}) {
const fsImpl = options.fs || fs const fsImpl = options.fs || fs
let resolved let resolved
@ -48,7 +47,5 @@ async function gitRootForIpc(startPath, options = {}) {
} }
} }
module.exports = { export { findGitRoot,
findGitRoot, gitRootForIpc }
gitRootForIpc
}

View file

@ -1,20 +1,16 @@
'use strict' import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
const assert = require('node:assert/strict') import { addWorktree,
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const {
addWorktree,
ensureGitRepo, ensureGitRepo,
listBranches, listBranches,
parseWorktrees, parseWorktrees,
sanitizeBranch, sanitizeBranch,
switchBranch switchBranch } from './git-worktree-ops'
} = require('./git-worktree-ops.cjs')
test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => { test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => {
assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes') assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes')

View file

@ -1,16 +1,14 @@
'use strict'
// Git-driven worktree operations for the desktop "Start work" flow: spin up a // Git-driven worktree operations for the desktop "Start work" flow: spin up a
// fresh worktree the lightest way (`git worktree add -b`), list real worktrees, // fresh worktree the lightest way (`git worktree add -b`), list real worktrees,
// and remove them. Git is the source of truth; the renderer just drives these. // and remove them. Git is the source of truth; the renderer just drives these.
const path = require('node:path') import { execFile } from 'node:child_process'
const fs = require('node:fs') import fs from 'node:fs'
const { execFile } = require('node:child_process') import path from 'node:path'
const { resolveRequestedPathForIpc } = require('./hardening.cjs') import { resolveRequestedPathForIpc } from './hardening'
function runGit(gitBin, args, cwd) { function runGit(gitBin, args, cwd): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile( execFile(
gitBin, gitBin,
@ -306,6 +304,7 @@ async function listBranches(repoPath, gitBin) {
['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'], ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'],
resolved resolved
) )
const trees = await listWorktrees(resolved, gitBin) const trees = await listWorktrees(resolved, gitBin)
const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path])) const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path]))
const trunk = await defaultBranch(gitBin, resolved) const trunk = await defaultBranch(gitBin, resolved)
@ -338,13 +337,11 @@ async function switchBranch(repoPath, branch, gitBin) {
return { branch: target } return { branch: target }
} }
module.exports = { export { addWorktree,
addWorktree,
ensureGitRepo, ensureGitRepo,
listBranches, listBranches,
listWorktrees, listWorktrees,
parseWorktrees, parseWorktrees,
removeWorktree, removeWorktree,
sanitizeBranch, sanitizeBranch,
switchBranch switchBranch }
}

View file

@ -1,23 +1,22 @@
const assert = require('node:assert/strict') import assert from 'node:assert/strict'
const fs = require('node:fs') import fs from 'node:fs'
const os = require('node:os') import os from 'node:os'
const path = require('node:path') import path from 'node:path'
const test = require('node:test') import test from 'node:test'
const { pathToFileURL } = require('node:url') import { pathToFileURL } from 'node:url'
const { import { DEFAULT_FETCH_TIMEOUT_MS,
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret, encryptDesktopSecret,
resolveDirectoryForIpc, resolveDirectoryForIpc,
resolveReadableFileForIpc, resolveReadableFileForIpc,
resolveRequestedPathForIpc, resolveRequestedPathForIpc,
resolveTimeoutMs, resolveTimeoutMs,
sensitiveFileBlockReason sensitiveFileBlockReason } from './hardening'
} = require('./hardening.cjs')
async function rejectsWithCode(promise, code) { async function rejectsWithCode(promise, code: string) {
await assert.rejects(promise, error => { await assert.rejects(promise, (error: any) => {
assert.equal(error?.code, code) assert.equal(error?.code, code)
return true return true
}) })
} }
@ -76,8 +75,9 @@ test('path helpers reject blank non-string NUL and Windows device syntax', async
for (const devicePath of devicePaths) { for (const devicePath of devicePaths) {
assert.throws( assert.throws(
() => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }), () => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
error => { (error: any) => {
assert.equal(error?.code, 'device-path') assert.equal(error?.code, 'device-path')
return true return true
} }
) )
@ -86,8 +86,9 @@ test('path helpers reject blank non-string NUL and Windows device syntax', async
assert.throws( assert.throws(
() => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), () => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
error => { (error: any) => {
assert.equal(error?.code, 'invalid-path') assert.equal(error?.code, 'invalid-path')
return true return true
} }
) )
@ -131,19 +132,23 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
maxBytes: 256, maxBytes: 256,
purpose: 'File preview' purpose: 'File preview'
}) })
assert.equal(fromRelative.resolvedPath, textPath) assert.equal(fromRelative.resolvedPath, textPath)
assert.equal(fromRelative.stat.size, 11) assert.equal(fromRelative.stat.size, 11)
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), { const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
purpose: 'File preview' purpose: 'File preview'
}) })
assert.equal(fromFileUrl.resolvedPath, textPath) assert.equal(fromFileUrl.resolvedPath, textPath)
const spacedPath = path.join(tempDir, 'notes with spaces.txt') const spacedPath = path.join(tempDir, 'notes with spaces.txt')
fs.writeFileSync(spacedPath, 'space ok', 'utf8') fs.writeFileSync(spacedPath, 'space ok', 'utf8')
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), { const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
purpose: 'File preview' purpose: 'File preview'
}) })
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath) assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
await assert.rejects( await assert.rejects(
@ -184,9 +189,11 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
const envTemplatePath = path.join(tempDir, '.env.example') const envTemplatePath = path.join(tempDir, '.env.example')
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8') fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, { const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
purpose: 'File preview' purpose: 'File preview'
}) })
assert.equal(envTemplate.resolvedPath, envTemplatePath) assert.equal(envTemplate.resolvedPath, envTemplatePath)
}) })
@ -229,8 +236,10 @@ test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', as
} catch (error) { } catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') { if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`symlink creation is not permitted on this platform (${error.code})`) t.skip(`symlink creation is not permitted on this platform (${error.code})`)
return return
} }
throw error throw error
} }
@ -268,8 +277,10 @@ test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t =
} catch (error) { } catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') { if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`) t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
return return
} }
throw error throw error
} }

View file

@ -1,7 +1,7 @@
const fs = require('node:fs') import fs from 'node:fs'
const os = require('node:os') import os from 'node:os'
const path = require('node:path') import path from 'node:path'
const { fileURLToPath } = require('node:url') import { fileURLToPath } from 'node:url'
const DEFAULT_FETCH_TIMEOUT_MS = 15_000 const DEFAULT_FETCH_TIMEOUT_MS = 15_000
const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024 const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
@ -13,6 +13,7 @@ const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) { function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) {
const fallback = const fallback =
Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS
const parsed = Number(timeoutMs) const parsed = Number(timeoutMs)
if (Number.isFinite(parsed) && parsed > 0) { if (Number.isFinite(parsed) && parsed > 0) {
@ -62,6 +63,7 @@ function sensitiveFileBlockReason(filePath) {
const normalized = String(filePath || '') const normalized = String(filePath || '')
.replace(/\\/g, '/') .replace(/\\/g, '/')
.toLowerCase() .toLowerCase()
const basename = path.basename(normalized) const basename = path.basename(normalized)
const ext = path.extname(basename) const ext = path.extname(basename)
@ -87,6 +89,7 @@ function sensitiveFileBlockReason(filePath) {
if (basename.startsWith('.env.')) { if (basename.startsWith('.env.')) {
const suffix = basename.slice('.env.'.length) const suffix = basename.slice('.env.'.length)
if (!SAFE_ENV_SUFFIXES.has(suffix)) { if (!SAFE_ENV_SUFFIXES.has(suffix)) {
return `${basename} is blocked because it appears to contain environment secrets.` return `${basename} is blocked because it appears to contain environment secrets.`
} }
@ -107,9 +110,10 @@ function sensitiveFileBlockReason(filePath) {
return null return null
} }
function ipcPathError(code, message) { function ipcPathError(code: any, message: string): Error & {code: any} {
const error = new Error(message) const error = new Error(message) as Error & {code: any}
error.code = code (error as any).code = code
return error return error
} }
@ -129,6 +133,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
} }
const normalized = raw.replace(/\\/g, '/').toLowerCase() const normalized = raw.replace(/\\/g, '/').toLowerCase()
if ( if (
normalized.startsWith('//?/') || normalized.startsWith('//?/') ||
normalized.startsWith('//./') || normalized.startsWith('//./') ||
@ -141,7 +146,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
return raw return raw
} }
function resolveRequestedPathForIpc(filePath, options = {}) { function resolveRequestedPathForIpc(filePath, options: {purpose?: string, baseDir?: fs.PathOrFileDescriptor} = {}) {
const purpose = String(options.purpose || 'File read') const purpose = String(options.purpose || 'File read')
let raw = rejectUnsafePathSyntax(filePath, purpose) let raw = rejectUnsafePathSyntax(filePath, purpose)
@ -154,17 +159,21 @@ function resolveRequestedPathForIpc(filePath, options = {}) {
if (/^file:/i.test(raw)) { if (/^file:/i.test(raw)) {
let resolvedPath let resolvedPath
try { try {
const parsed = new URL(raw) const parsed = new URL(raw)
if (parsed.protocol !== 'file:') { if (parsed.protocol !== 'file:') {
throw new Error('not a file URL') throw new Error('not a file URL')
} }
resolvedPath = fileURLToPath(parsed) resolvedPath = fileURLToPath(parsed)
} catch { } catch {
throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`) throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`)
} }
rejectUnsafePathSyntax(resolvedPath, purpose) rejectUnsafePathSyntax(resolvedPath, purpose)
return path.resolve(resolvedPath) return path.resolve(resolvedPath)
} }
@ -178,14 +187,16 @@ function resolveRequestedPathForIpc(filePath, options = {}) {
return resolvedPath return resolvedPath
} }
async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) { async function statForIpc(fsImpl: {promises: {stat: typeof fs.promises.stat}}, resolvedPath, purpose, typeLabel) {
try { try {
return await fsImpl.promises.stat(resolvedPath) return await fsImpl.promises.stat(resolvedPath)
} catch (error) { } catch (error) {
const code = error && typeof error === 'object' ? error.code : '' const code = error && typeof error === 'object' ? error.code : ''
if (code === 'ENOENT' || code === 'ENOTDIR') { if (code === 'ENOENT' || code === 'ENOTDIR') {
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`) throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
} }
throw ipcPathError( throw ipcPathError(
code || 'read-error', code || 'read-error',
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}` `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
@ -201,6 +212,7 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
try { try {
const realPath = await fsImpl.promises.realpath(resolvedPath) const realPath = await fsImpl.promises.realpath(resolvedPath)
rejectUnsafePathSyntax(realPath, purpose) rejectUnsafePathSyntax(realPath, purpose)
return realPath return realPath
} catch (error) { } catch (error) {
const code = error && typeof error === 'object' ? error.code : '' const code = error && typeof error === 'object' ? error.code : ''
@ -213,12 +225,13 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
function rejectSensitiveFilePath(filePath, purpose) { function rejectSensitiveFilePath(filePath, purpose) {
const blockReason = sensitiveFileBlockReason(filePath) const blockReason = sensitiveFileBlockReason(filePath)
if (blockReason) { if (blockReason) {
throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`) throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`)
} }
} }
async function resolveDirectoryForIpc(dirPath, options = {}) { async function resolveDirectoryForIpc(dirPath, options: {purpose?: string , baseDir?: fs.PathOrFileDescriptor, fs?: {promises:{stat: typeof fs.promises.stat}}} = {}) {
const purpose = String(options.purpose || 'Directory read') const purpose = String(options.purpose || 'Directory read')
const fsImpl = options.fs || fs const fsImpl = options.fs || fs
const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose }) const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose })
@ -233,7 +246,7 @@ async function resolveDirectoryForIpc(dirPath, options = {}) {
return { realPath, resolvedPath, stat } return { realPath, resolvedPath, stat }
} }
async function resolveReadableFileForIpc(filePath, options = {}) { async function resolveReadableFileForIpc(filePath, options: {purpose?: string , baseDir?: fs.PathOrFileDescriptor, fs?: typeof fs, blockSensitive?: boolean, maxBytes?: number} = {}) {
const purpose = String(options.purpose || 'File read') const purpose = String(options.purpose || 'File read')
const fsImpl = options.fs || fs const fsImpl = options.fs || fs
const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose }) const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose })
@ -253,11 +266,13 @@ async function resolveReadableFileForIpc(filePath, options = {}) {
} }
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose) const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
if (options.blockSensitive !== false) { if (options.blockSensitive !== false) {
rejectSensitiveFilePath(realPath, purpose) rejectSensitiveFilePath(realPath, purpose)
} }
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
if (maxBytes && stat.size > maxBytes) { if (maxBytes && stat.size > maxBytes) {
throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`) throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
} }
@ -271,15 +286,13 @@ async function resolveReadableFileForIpc(filePath, options = {}) {
return { realPath, resolvedPath, stat } return { realPath, resolvedPath, stat }
} }
module.exports = { export { DATA_URL_READ_MAX_BYTES,
DATA_URL_READ_MAX_BYTES,
DEFAULT_FETCH_TIMEOUT_MS, DEFAULT_FETCH_TIMEOUT_MS,
TEXT_PREVIEW_SOURCE_MAX_BYTES,
encryptDesktopSecret, encryptDesktopSecret,
rejectUnsafePathSyntax, rejectUnsafePathSyntax,
resolveDirectoryForIpc, resolveDirectoryForIpc,
resolveReadableFileForIpc, resolveReadableFileForIpc,
resolveRequestedPathForIpc, resolveRequestedPathForIpc,
resolveTimeoutMs, resolveTimeoutMs,
sensitiveFileBlockReason sensitiveFileBlockReason,
} TEXT_PREVIEW_SOURCE_MAX_BYTES }

View file

@ -1,15 +1,14 @@
const assert = require('node:assert/strict') import assert from 'node:assert/strict'
const test = require('node:test') import test from 'node:test'
const { import { createLinkTitleWindow,
createLinkTitleWindow,
guardLinkTitleSession, guardLinkTitleSession,
linkTitleWindowOptions, linkTitleWindowOptions,
readLinkTitleWindowTitle readLinkTitleWindowTitle } from './link-title-window'
} = require('./link-title-window.cjs')
function makeFakeBrowserWindow() { function makeFakeBrowserWindow() {
const calls = { audioMuted: [] } const calls = { audioMuted: [] }
const FakeBrowserWindow = function (options) { const FakeBrowserWindow = function (options) {
this.options = options this.options = options
this.webContents = { this.webContents = {

View file

@ -1,11 +1,9 @@
'use strict'
// Hidden BrowserWindow used by tier-2 link-title resolution: when curl can't // Hidden BrowserWindow used by tier-2 link-title resolution: when curl can't
// read a page <title> (bot walls, JS-rendered pages), we briefly load the URL // read a page <title> (bot walls, JS-rendered pages), we briefly load the URL
// in an offscreen window and read its title. That window loads arbitrary // in an offscreen window and read its title. That window loads arbitrary
// user-linked pages, so it must never emit sound or trigger real downloads. // user-linked pages, so it must never emit sound or trigger real downloads.
function linkTitleWindowOptions(partitionSession) { export function linkTitleWindowOptions(partitionSession) {
return { return {
show: false, show: false,
width: 1280, width: 1280,
@ -25,7 +23,7 @@ function linkTitleWindowOptions(partitionSession) {
// Create the offscreen title-fetch window and immediately mute it. Without the // Create the offscreen title-fetch window and immediately mute it. Without the
// mute, autoplaying media on the loaded page (e.g. a YouTube link) leaks ~2s of // mute, autoplaying media on the loaded page (e.g. a YouTube link) leaks ~2s of
// audio every time a session containing such links is re-rendered. See #49505. // audio every time a session containing such links is re-rendered. See #49505.
function createLinkTitleWindow(BrowserWindow, partitionSession) { export function createLinkTitleWindow(BrowserWindow, partitionSession) {
const window = new BrowserWindow(linkTitleWindowOptions(partitionSession)) const window = new BrowserWindow(linkTitleWindowOptions(partitionSession))
try { try {
@ -41,7 +39,7 @@ function createLinkTitleWindow(BrowserWindow, partitionSession) {
// Cancel any download the title-fetch window triggers. Without this, a link // Cancel any download the title-fetch window triggers. Without this, a link
// artifact URL served with Content-Disposition: attachment auto-downloads every // artifact URL served with Content-Disposition: attachment auto-downloads every
// time the Artifacts page renders and fetchLinkTitle loads it. // time the Artifacts page renders and fetchLinkTitle loads it.
function guardLinkTitleSession(partitionSession) { export function guardLinkTitleSession(partitionSession) {
try { try {
partitionSession.on('will-download', (_event, item) => item.cancel()) partitionSession.on('will-download', (_event, item) => item.cancel())
} catch { } catch {
@ -52,20 +50,15 @@ function guardLinkTitleSession(partitionSession) {
// Read the page title from a title-fetch window. Callers schedule this from // Read the page title from a title-fetch window. Callers schedule this from
// timers that can fire after finish() destroys the window, so every access must // timers that can fire after finish() destroys the window, so every access must
// guard isDestroyed and swallow Electron's "Object has been destroyed" throws. // guard isDestroyed and swallow Electron's "Object has been destroyed" throws.
function readLinkTitleWindowTitle(window) { export function readLinkTitleWindowTitle(window) {
try { try {
if (!window || window.isDestroyed()) return '' if (!window || window.isDestroyed()) {return ''}
const contents = window.webContents const contents = window.webContents
if (!contents || contents.isDestroyed()) return ''
if (!contents || contents.isDestroyed()) {return ''}
return contents.getTitle() || '' return contents.getTitle() || ''
} catch { } catch {
return '' return ''
} }
} }
module.exports = {
createLinkTitleWindow,
guardLinkTitleSession,
linkTitleWindowOptions,
readLinkTitleWindowTitle
}

View file

@ -1,13 +1,13 @@
/** /**
* Tests for OAuth-session Electron net.request helpers. * Tests for OAuth-session Electron net.request helpers.
* *
* Run with: node --test electron/oauth-net-request.test.cjs * Run with: node --test electron/oauth-net-request.test.ts
*/ */
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import test from 'node:test'
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
test('serializeJsonBody returns undefined for absent bodies', () => { test('serializeJsonBody returns undefined for absent bodies', () => {
assert.equal(serializeJsonBody(undefined), undefined) assert.equal(serializeJsonBody(undefined), undefined)
@ -21,6 +21,7 @@ test('serializeJsonBody JSON-encodes request bodies', () => {
test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => { test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => {
const headers = [] const headers = []
const request = { const request = {
setHeader(name, value) { setHeader(name, value) {
headers.push([name, value]) headers.push([name, value])

View file

@ -14,7 +14,5 @@ function setJsonRequestHeaders(request) {
request.setHeader('Content-Type', 'application/json') request.setHeader('Content-Type', 'application/json')
} }
module.exports = { export { serializeJsonBody,
serializeJsonBody, setJsonRequestHeaders }
setJsonRequestHeaders
}

View file

@ -6,18 +6,21 @@
* this guard is scoped to fetchJsonViaOauthSession only. * this guard is scoped to fetchJsonViaOauthSession only.
*/ */
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import fs from 'node:fs'
const fs = require('node:fs') import path from 'node:path'
const path = require('node:path') import test from 'node:test'
import { fileURLToPath } from 'node:url'
const source = fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8') const __dirname = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
function extractFetchJsonViaOauthSession() { function extractFetchJsonViaOauthSession() {
const start = source.indexOf('function fetchJsonViaOauthSession') const start = source.indexOf('function fetchJsonViaOauthSession')
const end = source.indexOf('// Mint a single-use WS ticket', start) const end = source.indexOf('// Mint a single-use WS ticket', start)
assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist') assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist')
assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist') assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist')
return source.slice(start, end) return source.slice(start, end)
} }

View file

@ -1,4 +1,4 @@
const { contextBridge, ipcRenderer, webUtils } = require('electron') import { contextBridge, ipcRenderer, webUtils } from 'electron'
contextBridge.exposeInMainWorld('hermesDesktop', { contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
@ -24,12 +24,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onState: callback => { onState: callback => {
const listener = (_event, payload) => callback(payload) const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:pet-overlay:state', listener) ipcRenderer.on('hermes:pet-overlay:state', listener)
return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener) return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener)
}, },
// Main renderer subscribes to overlay control messages. // Main renderer subscribes to overlay control messages.
onControl: callback => { onControl: callback => {
const listener = (_event, payload) => callback(payload) const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:pet-overlay:control', listener) ipcRenderer.on('hermes:pet-overlay:control', listener)
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener) return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
} }
}, },
@ -87,6 +89,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onChanged: callback => { onChanged: callback => {
const listener = (_event, payload) => callback(payload) const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:zoom:changed', listener) ipcRenderer.on('hermes:zoom:changed', listener)
return () => ipcRenderer.removeListener('hermes:zoom:changed', listener) return () => ipcRenderer.removeListener('hermes:zoom:changed', listener)
} }
}, },
@ -132,68 +135,80 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
const channel = `hermes:terminal:${id}:data` const channel = `hermes:terminal:${id}:data`
const listener = (_event, payload) => callback(payload) const listener = (_event, payload) => callback(payload)
ipcRenderer.on(channel, listener) ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener) return () => ipcRenderer.removeListener(channel, listener)
}, },
onExit: (id, callback) => { onExit: (id, callback) => {
const channel = `hermes:terminal:${id}:exit` const channel = `hermes:terminal:${id}:exit`
const listener = (_event, payload) => callback(payload) const listener = (_event, payload) => callback(payload)
ipcRenderer.on(channel, listener) ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener) return () => ipcRenderer.removeListener(channel, listener)
} }
}, },
onClosePreviewRequested: callback => { onClosePreviewRequested: callback => {
const listener = () => callback() const listener = () => callback()
ipcRenderer.on('hermes:close-preview-requested', listener) ipcRenderer.on('hermes:close-preview-requested', listener)
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener) return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
}, },
onOpenUpdatesRequested: callback => { onOpenUpdatesRequested: callback => {
const listener = () => callback() const listener = () => callback()
ipcRenderer.on('hermes:open-updates', listener) ipcRenderer.on('hermes:open-updates', listener)
return () => ipcRenderer.removeListener('hermes:open-updates', listener) return () => ipcRenderer.removeListener('hermes:open-updates', listener)
}, },
onDeepLink: callback => { onDeepLink: callback => {
const listener = (_event, payload) => callback(payload) const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:deep-link', listener) ipcRenderer.on('hermes:deep-link', listener)
return () => ipcRenderer.removeListener('hermes:deep-link', listener) return () => ipcRenderer.removeListener('hermes:deep-link', listener)
}, },
signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'), signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'),
onWindowStateChanged: callback => { onWindowStateChanged: callback => {
const listener = (_event, payload) => callback(payload) const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:window-state-changed', listener) ipcRenderer.on('hermes:window-state-changed', listener)
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener) return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
}, },
onFocusSession: callback => { onFocusSession: callback => {
const listener = (_event, sessionId) => callback(sessionId) const listener = (_event, sessionId) => callback(sessionId)
ipcRenderer.on('hermes:focus-session', listener) ipcRenderer.on('hermes:focus-session', listener)
return () => ipcRenderer.removeListener('hermes:focus-session', listener) return () => ipcRenderer.removeListener('hermes:focus-session', listener)
}, },
onNotificationAction: callback => { onNotificationAction: callback => {
const listener = (_event, payload) => callback(payload) const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:notification-action', listener) ipcRenderer.on('hermes:notification-action', listener)
return () => ipcRenderer.removeListener('hermes:notification-action', listener) return () => ipcRenderer.removeListener('hermes:notification-action', listener)
}, },
onPreviewFileChanged: callback => { onPreviewFileChanged: callback => {
const listener = (_event, payload) => callback(payload) const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:preview-file-changed', listener) ipcRenderer.on('hermes:preview-file-changed', listener)
return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener) return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener)
}, },
onBackendExit: callback => { onBackendExit: callback => {
const listener = (_event, payload) => callback(payload) const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:backend-exit', listener) ipcRenderer.on('hermes:backend-exit', listener)
return () => ipcRenderer.removeListener('hermes:backend-exit', listener) return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
}, },
onPowerResume: callback => { onPowerResume: callback => {
const listener = () => callback() const listener = () => callback()
ipcRenderer.on('hermes:power-resume', listener) ipcRenderer.on('hermes:power-resume', listener)
return () => ipcRenderer.removeListener('hermes:power-resume', listener) return () => ipcRenderer.removeListener('hermes:power-resume', listener)
}, },
onBootProgress: callback => { onBootProgress: callback => {
const listener = (_event, payload) => callback(payload) const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:boot-progress', listener) ipcRenderer.on('hermes:boot-progress', listener)
return () => ipcRenderer.removeListener('hermes:boot-progress', listener) return () => ipcRenderer.removeListener('hermes:boot-progress', listener)
}, },
// First-launch bootstrap progress -- emitted by the install.ps1 stage // First-launch bootstrap progress -- emitted by the install.ps1 stage
// runner in main.cjs (apps/desktop/electron/bootstrap-runner.cjs). // runner in main.ts (apps/desktop/electron/bootstrap-runner.ts).
// Renderer's install overlay subscribes to live events and queries the // Renderer's install overlay subscribes to live events and queries the
// current snapshot via getBootstrapState() to recover after a devtools // current snapshot via getBootstrapState() to recover after a devtools
// reload mid-bootstrap. // reload mid-bootstrap.
@ -204,6 +219,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onBootstrapEvent: callback => { onBootstrapEvent: callback => {
const listener = (_event, payload) => callback(payload) const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:bootstrap:event', listener) ipcRenderer.on('hermes:bootstrap:event', listener)
return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener) return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener)
}, },
getVersion: () => ipcRenderer.invoke('hermes:version'), getVersion: () => ipcRenderer.invoke('hermes:version'),
@ -220,6 +236,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onProgress: callback => { onProgress: callback => {
const listener = (_event, payload) => callback(payload) const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:updates:progress', listener) ipcRenderer.on('hermes:updates:progress', listener)
return () => ipcRenderer.removeListener('hermes:updates:progress', listener) return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
} }
}, },

View file

@ -1,11 +1,11 @@
'use strict' 'use strict'
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import fs from 'node:fs'
const fs = require('node:fs') import path from 'node:path'
const path = require('node:path') import test from 'node:test'
const ELECTRON_DIR = __dirname const ELECTRON_DIR = import.meta.dirname
function readElectronFile(name) { function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
@ -17,7 +17,7 @@ function readElectronFile(name) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
test('prepareProfileDeleteRequest returns the torn-down profile name', () => { test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
const source = readElectronFile('main.cjs') const source = readElectronFile('main.ts')
// Locate the function definition and its closing brace. // Locate the function definition and its closing brace.
const fnStart = source.indexOf('async function prepareProfileDeleteRequest(') const fnStart = source.indexOf('async function prepareProfileDeleteRequest(')
@ -36,7 +36,7 @@ test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
}) })
test('hermes:api handler routes profile-delete requests to the primary backend', () => { test('hermes:api handler routes profile-delete requests to the primary backend', () => {
const source = readElectronFile('main.cjs') const source = readElectronFile('main.ts')
// The handler must capture prepareProfileDeleteRequest's return value. // The handler must capture prepareProfileDeleteRequest's return value.
assert.match( assert.match(

View file

@ -1,11 +1,9 @@
const assert = require('node:assert/strict') import assert from 'node:assert/strict'
const test = require('node:test') import test from 'node:test'
const { import { buildSessionWindowUrl,
buildSessionWindowUrl,
chatWindowWebPreferences, chatWindowWebPreferences,
createSessionWindowRegistry createSessionWindowRegistry } from './session-windows'
} = require('./session-windows.cjs')
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a // A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
// test fire the 'closed' event, mirroring the slice of the Electron API the // test fire the 'closed' event, mirroring the slice of the Electron API the
@ -96,6 +94,7 @@ test('registry opens one window per session and focuses on re-open', () => {
const registry = createSessionWindowRegistry() const registry = createSessionWindowRegistry()
let built = 0 let built = 0
const win = makeFakeWindow() const win = makeFakeWindow()
const factory = () => { const factory = () => {
built += 1 built += 1
@ -145,6 +144,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', ()
let built = 0 let built = 0
const second = makeFakeWindow() const second = makeFakeWindow()
const result = registry.openOrFocus('s1', () => { const result = registry.openOrFocus('s1', () => {
built += 1 built += 1
@ -158,6 +158,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', ()
test('registry ignores empty / non-string session ids', () => { test('registry ignores empty / non-string session ids', () => {
const registry = createSessionWindowRegistry() const registry = createSessionWindowRegistry()
let built = 0 let built = 0
const factory = () => { const factory = () => {
built += 1 built += 1

View file

@ -1,9 +1,9 @@
// Secondary "session windows" — one extra OS window per chat so a user can // Secondary "session windows" — one extra OS window per chat so a user can
// work with multiple chats side by side. The pure, Electron-free pieces live // work with multiple chats side by side. The pure, Electron-free pieces live
// here so they can be unit-tested with node --test (mirroring how the rest of // here so they can be unit-tested with node --test (mirroring how the rest of
// electron/*.cjs splits testable logic out of the main.cjs monolith). // electron/*.ts splits testable logic out of the main.ts monolith).
const { pathToFileURL } = require('node:url') import { pathToFileURL } from 'node:url'
// Secondary windows open at the minimum usable size — a compact side panel for // Secondary windows open at the minimum usable size — a compact side panel for
// subagent watch / cmd-click session pop-out, not a second full desktop. // subagent watch / cmd-click session pop-out, not a second full desktop.
@ -12,7 +12,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// Shared webPreferences for every window that renders the chat transcript — the // Shared webPreferences for every window that renders the chat transcript — the
// primary window AND the secondary session windows. Keeping it in one place is // primary window AND the secondary session windows. Keeping it in one place is
// the whole point: the two BrowserWindow definitions in main.cjs used to be // the whole point: the two BrowserWindow definitions in main.ts used to be
// hand-copied, and the secondary windows silently lost `backgroundThrottling: // hand-copied, and the secondary windows silently lost `backgroundThrottling:
// false`, so a streamed answer stalled until the window regained focus. // false`, so a streamed answer stalled until the window regained focus.
// //
@ -21,7 +21,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// blurred/occluded windows. A streaming chat app must keep painting in the // blurred/occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected // background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname. // because it depends on the Electron entry's __dirname.
function chatWindowWebPreferences(preloadPath) { function chatWindowWebPreferences(preloadPath: string) {
return { return {
preload: preloadPath, preload: preloadPath,
contextIsolation: true, contextIsolation: true,
@ -42,7 +42,7 @@ function chatWindowWebPreferences(preloadPath) {
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's // scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
// session): the renderer resumes it lazily so the gateway never builds an agent // session): the renderer resumes it lazily so the gateway never builds an agent
// just to stream into it. // just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) { function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch, newSession }: any = {}) {
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}` const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}` const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
@ -115,10 +115,8 @@ function createSessionWindowRegistry() {
} }
} }
module.exports = { export { buildSessionWindowUrl,
buildSessionWindowUrl,
chatWindowWebPreferences, chatWindowWebPreferences,
createSessionWindowRegistry, createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT, SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH SESSION_WINDOW_MIN_WIDTH }
}

View file

@ -1,12 +1,7 @@
const assert = require('node:assert/strict') import assert from 'node:assert/strict'
const test = require('node:test') import test from 'node:test'
const { import { MACOS_TAHOE_DARWIN_MAJOR, macTitleBarOverlayHeight, nativeOverlayWidth, OVERLAY_FALLBACK_WIDTH } from './titlebar-overlay-width'
MACOS_TAHOE_DARWIN_MAJOR,
OVERLAY_FALLBACK_WIDTH,
macTitleBarOverlayHeight,
nativeOverlayWidth
} = require('./titlebar-overlay-width.cjs')
// This static reservation is only the pre-layout FALLBACK. Once laid out the // This static reservation is only the pre-layout FALLBACK. Once laid out the
// renderer reads the exact width from navigator.windowControlsOverlay // renderer reads the exact width from navigator.windowControlsOverlay

View file

@ -1,6 +1,4 @@
'use strict' export const OVERLAY_FALLBACK_WIDTH = 144
const OVERLAY_FALLBACK_WIDTH = 144
/** /**
* Static pre-layout reservation (px) for the right-side native window-controls * Static pre-layout reservation (px) for the right-side native window-controls
@ -16,15 +14,16 @@ const OVERLAY_FALLBACK_WIDTH = 144
* *
* @param {{ isMac?: boolean }} opts * @param {{ isMac?: boolean }} opts
*/ */
function nativeOverlayWidth({ isMac = false } = {}) { export function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = false } = {}) {
if (isMac) return 0 if (isMac) {return 0}
return OVERLAY_FALLBACK_WIDTH return OVERLAY_FALLBACK_WIDTH
} }
// macOS Tahoe ships as Darwin 25 (Sequoia is 24); the Darwin number is truthful, // macOS Tahoe ships as Darwin 25 (Sequoia is 24); the Darwin number is truthful,
// unlike the product version which macOS reports as 16 or 26 depending on the // unlike the product version which macOS reports as 16 or 26 depending on the
// build SDK. // build SDK.
const MACOS_TAHOE_DARWIN_MAJOR = 25 export const MACOS_TAHOE_DARWIN_MAJOR = 25
/** /**
* Height (px) to pass to `titleBarOverlay` on macOS. Tahoe (Darwin 25+) * Height (px) to pass to `titleBarOverlay` on macOS. Tahoe (Darwin 25+)
@ -36,8 +35,7 @@ const MACOS_TAHOE_DARWIN_MAJOR = 25
* *
* @param {{ darwinMajor?: number, titlebarHeight?: number }} opts * @param {{ darwinMajor?: number, titlebarHeight?: number }} opts
*/ */
function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) { export function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) {
return darwinMajor >= MACOS_TAHOE_DARWIN_MAJOR ? 0 : titlebarHeight return darwinMajor >= MACOS_TAHOE_DARWIN_MAJOR ? 0 : titlebarHeight
} }
module.exports = { MACOS_TAHOE_DARWIN_MAJOR, OVERLAY_FALLBACK_WIDTH, macTitleBarOverlayHeight, nativeOverlayWidth }

View file

@ -1,7 +1,7 @@
'use strict' import assert from 'node:assert/strict'
const test = require('node:test') import test from 'node:test'
const assert = require('node:assert/strict')
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') import { resolveBehindCount, shouldCountCommits } from './update-count'
// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0` // FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0`
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus // unconditionally, so a shallow checkout with no merge-base surfaced the bogus

View file

@ -1,5 +1,3 @@
'use strict'
// Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful // Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful
// number worth computing. On a SHALLOW checkout (installer clones with // number worth computing. On a SHALLOW checkout (installer clones with
// --depth 1) the local history often shares no merge-base with the freshly // --depth 1) the local history often shares no merge-base with the freshly
@ -19,10 +17,12 @@ function shouldCountCommits({ isShallow, hasMergeBase }) {
// (developers / Docker dev images) keep the exact count path unchanged. // (developers / Docker dev images) keep the exact count path unchanged.
function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) { function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) {
if (!shouldCountCommits({ isShallow, hasMergeBase })) { if (!shouldCountCommits({ isShallow, hasMergeBase })) {
if (currentSha && targetSha && currentSha === targetSha) return 0 if (currentSha && targetSha && currentSha === targetSha) {return 0}
return 1 // behind by an unknown amount — show a generic "update available" return 1 // behind by an unknown amount — show a generic "update available"
} }
return Number.parseInt(countStr, 10) || 0 return Number.parseInt(countStr, 10) || 0
} }
module.exports = { resolveBehindCount, shouldCountCommits } export { resolveBehindCount, shouldCountCommits }

View file

@ -1,9 +1,9 @@
/** /**
* Tests for electron/update-marker.cjs the in-app update mutual-exclusion * Tests for electron/update-marker.ts the in-app update mutual-exclusion
* marker that prevents a desktop relaunched mid-update from spawning a backend * marker that prevents a desktop relaunched mid-update from spawning a backend
* the updater then kills in a loop (#50238). * the updater then kills in a loop (#50238).
* *
* Run with: node --test electron/update-marker.test.cjs * Run with: node --test electron/update-marker.test.ts
* (Wired into npm test:desktop:platforms in package.json.) * (Wired into npm test:desktop:platforms in package.json.)
* *
* Why this matters: the gate must (a) report a live update only when the * Why this matters: the gate must (a) report a live update only when the
@ -12,16 +12,17 @@
* strand future launches, and (c) self-heal by deleting a stale marker file. * strand future launches, and (c) self-heal by deleting a stale marker file.
*/ */
const test = require('node:test') import fs from 'fs'
const assert = require('node:assert/strict') import assert from 'node:assert/strict'
const fs = require('fs') import test from 'node:test'
const os = require('os') import os from 'os'
const path = require('path') import path from 'path'
const { markerPath, isPidAlive, readLiveUpdateMarker, writeUpdateMarker, UPDATE_MARKER_MAX_AGE_MS } = require('./update-marker.cjs') import { isPidAlive, markerPath, readLiveUpdateMarker, UPDATE_MARKER_MAX_AGE_MS, writeUpdateMarker } from './update-marker'
function tmpHome(tag) { function tmpHome(tag) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-marker-${tag}-`)) const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-marker-${tag}-`))
return dir return dir
} }
@ -29,10 +30,11 @@ function writeMarker(home, pid, startedAtSec) {
fs.writeFileSync(markerPath(home), `${pid}\n${startedAtSec}`) fs.writeFileSync(markerPath(home), `${pid}\n${startedAtSec}`)
} }
const ALIVE = () => true // injected kill that "succeeds" => pid alive const ALIVE: typeof process.kill = () => true // injected kill that "succeeds" => pid alive
const DEAD = () => {
const err = new Error('no such process') const DEAD : typeof process.kill= () => {
err.code = 'ESRCH' const err = new Error('no such process');
(err as any).code = 'ESRCH'
throw err throw err
} }
@ -84,10 +86,11 @@ test('isPidAlive: own pid is alive, impossible pid is dead', () => {
test('isPidAlive: EPERM counts as alive (process owned by another user)', () => { test('isPidAlive: EPERM counts as alive (process owned by another user)', () => {
const eperm = () => { const eperm = () => {
const err = new Error('operation not permitted') const err = new Error('operation not permitted');
err.code = 'EPERM' (err as any).code = 'EPERM'
throw err throw err
} }
assert.equal(isPidAlive(4242, eperm), true) assert.equal(isPidAlive(4242, eperm), true)
}) })

View file

@ -16,20 +16,20 @@
* *
* This module holds the PURE, side-effect-light logic (path, pid liveness, * This module holds the PURE, side-effect-light logic (path, pid liveness,
* parse + staleness) so it is unit-testable without booting Electron. The * parse + staleness) so it is unit-testable without booting Electron. The
* polling/boot-progress wrapper lives in main.cjs where the boot-progress and * polling/boot-progress wrapper lives in main.ts where the boot-progress and
* log sinks are. * log sinks are.
*/ */
const fs = require('fs') import fs from 'fs'
const path = require('path') import path from 'path'
// Even with a live-looking PID, never treat a marker older than this as a live // Even with a live-looking PID, never treat a marker older than this as a live
// update. A full update (git pull + pip + desktop rebuild) is minutes, not tens // update. A full update (git pull + pip + desktop rebuild) is minutes, not tens
// of minutes; past this the marker is almost certainly stale (e.g. the OS // of minutes; past this the marker is almost certainly stale (e.g. the OS
// recycled the pid onto an unrelated process), so the gate self-heals. // recycled the pid onto an unrelated process), so the gate self-heals.
const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000 export const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000
function markerPath(hermesHome) { export function markerPath(hermesHome) {
return path.join(hermesHome, '.hermes-update-in-progress') return path.join(hermesHome, '.hermes-update-in-progress')
} }
@ -37,10 +37,12 @@ function markerPath(hermesHome) {
// not deliver a signal — it just probes existence/permission. ESRCH => dead; // not deliver a signal — it just probes existence/permission. ESRCH => dead;
// EPERM => alive but owned by another user (still "alive" for our purposes). // EPERM => alive but owned by another user (still "alive" for our purposes).
// Injectable `kill` keeps it unit-testable. // Injectable `kill` keeps it unit-testable.
function isPidAlive(pid, kill = process.kill.bind(process)) { export function isPidAlive(pid, kill: typeof process.kill = process.kill.bind(process)) {
if (!Number.isInteger(pid) || pid <= 0) return false if (!Number.isInteger(pid) || pid <= 0) {return false}
try { try {
kill(pid, 0) kill(pid, 0)
return true return true
} catch (err) { } catch (err) {
return Boolean(err && err.code === 'EPERM') return Boolean(err && err.code === 'EPERM')
@ -59,9 +61,12 @@ function isPidAlive(pid, kill = process.kill.bind(process)) {
* Pure-ish: file I/O against the given path, plus an injectable pid probe and * Pure-ish: file I/O against the given path, plus an injectable pid probe and
* clock for tests. * clock for tests.
*/ */
function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPDATE_MARKER_MAX_AGE_MS } = {}) { export function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPDATE_MARKER_MAX_AGE_MS }: {
now?: () => number, maxAgeMs?: number, kill?: typeof process.kill
} = {}) {
const file = markerPath(hermesHome) const file = markerPath(hermesHome)
let raw let raw
try { try {
raw = fs.readFileSync(file, 'utf8') raw = fs.readFileSync(file, 'utf8')
} catch { } catch {
@ -80,8 +85,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD
} catch { } catch {
void 0 void 0
} }
return null return null
} }
return { pid, ageMs } return { pid, ageMs }
} }
@ -107,9 +114,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD
* If the updater never starts (spawn failure) the marker still contains a * If the updater never starts (spawn failure) the marker still contains a
* real PID, so `readLiveUpdateMarker` will self-heal once that PID exits. * real PID, so `readLiveUpdateMarker` will self-heal once that PID exits.
*/ */
function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) { export function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) {
const file = markerPath(hermesHome) const file = markerPath(hermesHome)
const startedAt = Math.floor(now() / 1000) const startedAt = Math.floor(now() / 1000)
try { try {
fs.writeFileSync(file, `${pid}\n${startedAt}\n`, 'utf8') fs.writeFileSync(file, `${pid}\n${startedAt}\n`, 'utf8')
} catch { } catch {
@ -117,11 +125,3 @@ function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) {
// updater will write its own when it reaches run_update. // updater will write its own when it reaches run_update.
} }
} }
module.exports = {
UPDATE_MARKER_MAX_AGE_MS,
markerPath,
isPidAlive,
readLiveUpdateMarker,
writeUpdateMarker
}

View file

@ -1,8 +1,8 @@
/** /**
* Tests for electron/update-rebuild.cjs the retry-once policy for the desktop * Tests for electron/update-rebuild.ts the retry-once policy for the desktop
* `--build-only` rebuild during self-update. * `--build-only` rebuild during self-update.
* *
* Run with: node --test electron/update-rebuild.test.cjs * Run with: node --test electron/update-rebuild.test.ts
* (Wired into npm test:desktop:platforms in package.json.) * (Wired into npm test:desktop:platforms in package.json.)
* *
* Why this matters: a first rebuild can return nonzero on a still-settling tree * Why this matters: a first rebuild can return nonzero on a still-settling tree
@ -12,10 +12,10 @@
* success, and must run at most twice. * success, and must run at most twice.
*/ */
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import test from 'node:test'
const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs') import { runRebuildWithRetry, shouldRetryRebuild } from './update-rebuild'
test('shouldRetryRebuild retries only on a non-success exit', () => { test('shouldRetryRebuild retries only on a non-success exit', () => {
assert.equal(shouldRetryRebuild(0), false) assert.equal(shouldRetryRebuild(0), false)
@ -25,30 +25,39 @@ test('shouldRetryRebuild retries only on a non-success exit', () => {
test('a clean first rebuild runs once and does not retry', async () => { test('a clean first rebuild runs once and does not retry', async () => {
const codes = [] const codes = []
const result = await runRebuildWithRetry(attempt => { const result = await runRebuildWithRetry(attempt => {
codes.push(attempt) codes.push(attempt)
return Promise.resolve({ code: 0 }) return Promise.resolve({ code: 0 })
}) })
assert.deepEqual(codes, [0]) assert.deepEqual(codes, [0])
assert.equal(result.code, 0) assert.equal(result.code, 0)
}) })
test('a failed first rebuild retries once and succeeds', async () => { test('a failed first rebuild retries once and succeeds', async () => {
const codes = [] const codes = []
const result = await runRebuildWithRetry(attempt => { const result = await runRebuildWithRetry(attempt => {
codes.push(attempt) codes.push(attempt)
return Promise.resolve({ code: attempt === 0 ? 1 : 0 }) return Promise.resolve({ code: attempt === 0 ? 1 : 0 })
}) })
assert.deepEqual(codes, [0, 1]) assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 0) assert.equal(result.code, 0)
}) })
test('a rebuild that keeps failing runs at most twice and reports the failure', async () => { test('a rebuild that keeps failing runs at most twice and reports the failure', async () => {
const codes = [] const codes = []
const result = await runRebuildWithRetry(attempt => { const result = await runRebuildWithRetry(attempt => {
codes.push(attempt) codes.push(attempt)
return Promise.resolve({ code: 1, error: 'rebuild-failed' }) return Promise.resolve({ code: 1, error: 'rebuild-failed' })
}) })
assert.deepEqual(codes, [0, 1]) assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 1) assert.equal(result.code, 1)
assert.equal(result.error, 'rebuild-failed') assert.equal(result.error, 'rebuild-failed')

View file

@ -1,5 +1,3 @@
'use strict'
/** /**
* Retry-once policy for the desktop `--build-only` rebuild during self-update. * Retry-once policy for the desktop `--build-only` rebuild during self-update.
* *
@ -20,10 +18,12 @@ function shouldRetryRebuild(code) {
*/ */
async function runRebuildWithRetry(rebuild) { async function runRebuildWithRetry(rebuild) {
let result = await rebuild(0) let result = await rebuild(0)
if (shouldRetryRebuild(result.code)) { if (shouldRetryRebuild(result.code)) {
result = await rebuild(1) result = await rebuild(1)
} }
return result return result
} }
module.exports = { shouldRetryRebuild, runRebuildWithRetry } export { runRebuildWithRetry, shouldRetryRebuild }

View file

@ -1,8 +1,8 @@
/** /**
* Tests for electron/update-relaunch.cjs the pure decision + script helpers * Tests for electron/update-relaunch.ts the pure decision + script helpers
* behind the Linux in-app update relaunch (#45205). * behind the Linux in-app update relaunch (#45205).
* *
* Run with: node --test electron/update-relaunch.test.cjs * Run with: node --test electron/update-relaunch.test.ts
* (Wired into npm test:desktop:platforms in package.json.) * (Wired into npm test:desktop:platforms in package.json.)
* *
* What this locks (review acceptance criteria for PR #45205): * What this locks (review acceptance criteria for PR #45205):
@ -17,24 +17,22 @@
* (keep a working window) unless a non-interactive fallback applies. * (keep a working window) unless a non-interactive fallback applies.
*/ */
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import { execFileSync } from 'node:child_process'
const fs = require('node:fs') import fs from 'node:fs'
const os = require('node:os') import os from 'node:os'
const path = require('node:path') import path from 'node:path'
const { execFileSync } = require('node:child_process') import test from 'node:test'
const { import { buildRelaunchScript,
unpackedDirName,
resolveUnpackedRelease,
decideRelaunchOutcome,
sandboxPreflight,
sandboxFallbackFromEnv,
collectRelaunchArgs, collectRelaunchArgs,
collectRelaunchEnv, collectRelaunchEnv,
buildRelaunchScript, decideRelaunchOutcome,
shellQuote resolveUnpackedRelease,
} = require('./update-relaunch.cjs') sandboxFallbackFromEnv,
sandboxPreflight,
shellQuote,
unpackedDirName } from './update-relaunch'
const ROOT = '/home/u/.hermes/hermes-agent' const ROOT = '/home/u/.hermes/hermes-agent'
const UNPACKED = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked') const UNPACKED = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked')
@ -91,6 +89,7 @@ test('decideRelaunchOutcome: only under-unpacked + sandbox-ok relaunches', () =>
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const fakeStat = (uid, mode) => () => ({ uid, mode }) const fakeStat = (uid, mode) => () => ({ uid, mode })
const throwStat = () => { const throwStat = () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
} }
@ -150,6 +149,7 @@ test('collectRelaunchArgs drops Electron internals, keeps user/launcher args', (
'--profile=work', // app flag — keep '--profile=work', // app flag — keep
'--remote-debugging-port=9222' // internal — drop '--remote-debugging-port=9222' // internal — drop
] ]
assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/agent/42', '--profile=work']) assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/agent/42', '--profile=work'])
assert.deepEqual(collectRelaunchArgs(undefined), []) assert.deepEqual(collectRelaunchArgs(undefined), [])
}) })
@ -165,6 +165,7 @@ test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt-
HOME: '/home/u', // not preserved HOME: '/home/u', // not preserved
UNRELATED: 'x' UNRELATED: 'x'
} }
assert.deepEqual(collectRelaunchEnv(env), { assert.deepEqual(collectRelaunchEnv(env), {
HERMES_HOME: '/home/u/.hermes', HERMES_HOME: '/home/u/.hermes',
HERMES_DESKTOP_REMOTE_URL: 'http://box:9119', HERMES_DESKTOP_REMOTE_URL: 'http://box:9119',
@ -207,6 +208,7 @@ test('buildRelaunchScript embeds pid/exec/args/env/cwd and is valid bash', () =>
// It must be syntactically valid bash (`bash -n`). Write to a temp file and lint. // It must be syntactically valid bash (`bash -n`). Write to a temp file and lint.
const tmp = path.join(os.tmpdir(), `hermes-relaunch-test-${Date.now()}.sh`) const tmp = path.join(os.tmpdir(), `hermes-relaunch-test-${Date.now()}.sh`)
fs.writeFileSync(tmp, script) fs.writeFileSync(tmp, script)
try { try {
execFileSync('bash', ['-n', tmp], { stdio: 'pipe' }) execFileSync('bash', ['-n', tmp], { stdio: 'pipe' })
} finally { } finally {
@ -222,13 +224,16 @@ test('buildRelaunchScript with no args/env still lints clean', () => {
env: {}, env: {},
cwd: '' cwd: ''
}) })
const tmp = path.join(os.tmpdir(), `hermes-relaunch-test2-${Date.now()}.sh`) const tmp = path.join(os.tmpdir(), `hermes-relaunch-test2-${Date.now()}.sh`)
fs.writeFileSync(tmp, script) fs.writeFileSync(tmp, script)
try { try {
execFileSync('bash', ['-n', tmp], { stdio: 'pipe' }) execFileSync('bash', ['-n', tmp], { stdio: 'pipe' })
} finally { } finally {
fs.rmSync(tmp, { force: true }) fs.rmSync(tmp, { force: true })
} }
// exec line has no trailing args. // exec line has no trailing args.
assert.match(script, /exec '\/opt\/Hermes\/Hermes'\n/) assert.match(script, /exec '\/opt\/Hermes\/Hermes'\n/)
}) })

View file

@ -1,12 +1,10 @@
'use strict'
/** /**
* update-relaunch.cjs pure decision + script-generation helpers for the * update-relaunch.ts pure decision + script-generation helpers for the
* Linux in-app update relaunch (#45205). * Linux in-app update relaunch (#45205).
* *
* Extracted from main.cjs's `applyUpdatesPosixInApp` so the security- and * Extracted from main.ts's `applyUpdatesPosixInApp` so the security- and
* correctness-critical "do we relaunch, or land on a manual terminal state?" * correctness-critical "do we relaunch, or land on a manual terminal state?"
* decision is unit-testable without booting Electron (main.cjs * decision is unit-testable without booting Electron (main.ts
* `require('electron')` at load). * `require('electron')` at load).
* *
* Background * Background
@ -37,12 +35,14 @@
* the closeable manual-restart terminal state instead. * the closeable manual-restart terminal state instead.
*/ */
const path = require('node:path') import path from 'node:path'
// Map process.platform → electron-builder's `release/<dir>-unpacked` name. // Map process.platform → electron-builder's `release/<dir>-unpacked` name.
function unpackedDirName(platform) { function unpackedDirName(platform) {
if (platform === 'darwin') return 'mac-unpacked' // not used (mac swaps bundles) if (platform === 'darwin') {return 'mac-unpacked'} // not used (mac swaps bundles)
if (platform === 'win32') return 'win-unpacked'
if (platform === 'win32') {return 'win-unpacked'}
return 'linux-unpacked' return 'linux-unpacked'
} }
@ -56,15 +56,17 @@ function unpackedDirName(platform) {
* `.../release/linux-unpacked-evil` can't masquerade as `.../release/linux-unpacked`. * `.../release/linux-unpacked-evil` can't masquerade as `.../release/linux-unpacked`.
*/ */
function resolveUnpackedRelease(execPath, updateRoot, platform) { function resolveUnpackedRelease(execPath, updateRoot, platform) {
if (!execPath || !updateRoot) return null if (!execPath || !updateRoot) {return null}
const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release') const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release')
const unpacked = path.join(releaseDir, unpackedDirName(platform)) const unpacked = path.join(releaseDir, unpackedDirName(platform))
const normalizedExec = path.resolve(String(execPath)) const normalizedExec = path.resolve(String(execPath))
// execPath must be the unpacked dir itself or a descendant of it. // execPath must be the unpacked dir itself or a descendant of it.
const withSep = unpacked.endsWith(path.sep) ? unpacked : unpacked + path.sep const withSep = unpacked.endsWith(path.sep) ? unpacked : unpacked + path.sep
if (normalizedExec === unpacked || normalizedExec.startsWith(withSep)) { if (normalizedExec === unpacked || normalizedExec.startsWith(withSep)) {
return unpacked return unpacked
} }
return null return null
} }
@ -81,8 +83,10 @@ function resolveUnpackedRelease(execPath, updateRoot, platform) {
* app. Closeable manual-restart terminal state. * app. Closeable manual-restart terminal state.
*/ */
function decideRelaunchOutcome({ underUnpacked, sandboxOk }) { function decideRelaunchOutcome({ underUnpacked, sandboxOk }) {
if (!underUnpacked) return 'guiSkew' if (!underUnpacked) {return 'guiSkew'}
if (!sandboxOk) return 'manual'
if (!sandboxOk) {return 'manual'}
return 'relaunch' return 'relaunch'
} }
@ -99,9 +103,10 @@ function decideRelaunchOutcome({ underUnpacked, sandboxOk }) {
* `statSync` is injectable so this is testable without a real setuid file. * `statSync` is injectable so this is testable without a real setuid file.
*/ */
function sandboxPreflight(unpackedDir, statSync) { function sandboxPreflight(unpackedDir, statSync) {
if (!unpackedDir) return { ok: false, reason: 'no-unpacked-dir', path: null } if (!unpackedDir) {return { ok: false, reason: 'no-unpacked-dir', path: null }}
const sandboxPath = path.join(unpackedDir, 'chrome-sandbox') const sandboxPath = path.join(unpackedDir, 'chrome-sandbox')
let st let st
try { try {
st = statSync(sandboxPath) st = statSync(sandboxPath)
} catch { } catch {
@ -109,15 +114,20 @@ function sandboxPreflight(unpackedDir, statSync) {
// sandbox; nothing to block the relaunch. // sandbox; nothing to block the relaunch.
return { ok: true, reason: 'no-sandbox-helper', path: sandboxPath } return { ok: true, reason: 'no-sandbox-helper', path: sandboxPath }
} }
const ownedByRoot = st.uid === 0 const ownedByRoot = st.uid === 0
const hasSetuid = (st.mode & 0o4000) !== 0 const hasSetuid = (st.mode & 0o4000) !== 0
if (ownedByRoot && hasSetuid) { if (ownedByRoot && hasSetuid) {
return { ok: true, reason: 'launchable', path: sandboxPath } return { ok: true, reason: 'launchable', path: sandboxPath }
} }
if (!ownedByRoot && !hasSetuid) { if (!ownedByRoot && !hasSetuid) {
return { ok: false, reason: 'not-root-not-setuid', path: sandboxPath } return { ok: false, reason: 'not-root-not-setuid', path: sandboxPath }
} }
if (!ownedByRoot) return { ok: false, reason: 'not-root', path: sandboxPath }
if (!ownedByRoot) {return { ok: false, reason: 'not-root', path: sandboxPath }}
return { ok: false, reason: 'not-setuid', path: sandboxPath } return { ok: false, reason: 'not-setuid', path: sandboxPath }
} }
@ -126,7 +136,7 @@ function sandboxPreflight(unpackedDir, statSync) {
* environment. The reviewer asked us to integrate with any existing * environment. The reviewer asked us to integrate with any existing
* `--no-sandbox` / chrome-sandbox handling. A repo grep found NO existing * `--no-sandbox` / chrome-sandbox handling. A repo grep found NO existing
* non-interactive sandbox fallback in the desktop app (the only chrome-sandbox * non-interactive sandbox fallback in the desktop app (the only chrome-sandbox
* reference is documentation in scripts/before-pack.cjs). The one signal that * reference is documentation in scripts/before-pack.ts). The one signal that
* DOES exist is the standard Electron escape hatch: ELECTRON_DISABLE_SANDBOX=1 * DOES exist is the standard Electron escape hatch: ELECTRON_DISABLE_SANDBOX=1
* (and the equivalent `--no-sandbox` already present in the launch args). If * (and the equivalent `--no-sandbox` already present in the launch args). If
* the user has set that, the rebuilt binary will start even with a broken * the user has set that, the rebuilt binary will start even with a broken
@ -137,8 +147,11 @@ function sandboxPreflight(unpackedDir, statSync) {
*/ */
function sandboxFallbackFromEnv(env, launchArgs) { function sandboxFallbackFromEnv(env, launchArgs) {
const disable = String((env && env.ELECTRON_DISABLE_SANDBOX) || '').trim() const disable = String((env && env.ELECTRON_DISABLE_SANDBOX) || '').trim()
if (disable === '1' || disable.toLowerCase() === 'true') return true
if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) return true if (disable === '1' || disable.toLowerCase() === 'true') {return true}
if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) {return true}
return false return false
} }
@ -176,9 +189,11 @@ const INTERNAL_ARG_PREFIXES = [
* the exec path itself; there is no entry-script arg as in a dev run). * the exec path itself; there is no entry-script arg as in a dev run).
*/ */
function collectRelaunchArgs(argv) { function collectRelaunchArgs(argv) {
if (!Array.isArray(argv)) return [] if (!Array.isArray(argv)) {return []}
return argv.filter(arg => { return argv.filter(arg => {
if (typeof arg !== 'string' || arg.length === 0) return false if (typeof arg !== 'string' || arg.length === 0) {return false}
return !INTERNAL_ARG_PREFIXES.some(prefix => return !INTERNAL_ARG_PREFIXES.some(prefix =>
prefix.endsWith('=') ? arg.startsWith(prefix) : arg === prefix || arg.startsWith(prefix + '=') prefix.endsWith('=') ? arg.startsWith(prefix) : arg === prefix || arg.startsWith(prefix + '=')
) )
@ -197,13 +212,17 @@ const PRESERVED_ENV_PREFIXES = ['HERMES_DESKTOP_']
function collectRelaunchEnv(env) { function collectRelaunchEnv(env) {
const out = {} const out = {}
if (!env || typeof env !== 'object') return out
if (!env || typeof env !== 'object') {return out}
for (const [key, value] of Object.entries(env)) { for (const [key, value] of Object.entries(env)) {
if (value == null) continue if (value == null) {continue}
if (PRESERVED_ENV_KEYS.includes(key) || PRESERVED_ENV_PREFIXES.some(p => key.startsWith(p))) { if (PRESERVED_ENV_KEYS.includes(key) || PRESERVED_ENV_PREFIXES.some(p => key.startsWith(p))) {
out[key] = String(value) out[key] = String(value)
} }
} }
return out return out
} }
@ -223,8 +242,10 @@ function buildRelaunchScript({ pid, execPath, args, env, cwd }) {
const exports = Object.entries(env || {}) const exports = Object.entries(env || {})
.map(([k, v]) => `export ${k}=${shellQuote(v)}`) .map(([k, v]) => `export ${k}=${shellQuote(v)}`)
.join('\n') .join('\n')
const quotedArgs = (args || []).map(shellQuote).join(' ') const quotedArgs = (args || []).map(shellQuote).join(' ')
const cwdLine = cwd ? `cd ${shellQuote(cwd)} 2>/dev/null || true` : '' const cwdLine = cwd ? `cd ${shellQuote(cwd)} 2>/dev/null || true` : ''
// NOTE: `exec` replaces the watcher process with the relaunched app, so the // NOTE: `exec` replaces the watcher process with the relaunched app, so the
// re-exec inherits exactly the env/cwd we set above. // re-exec inherits exactly the env/cwd we set above.
return `#!/bin/bash return `#!/bin/bash
@ -249,17 +270,15 @@ exec ${shellQuote(execPath)}${quotedArgs ? ' ' + quotedArgs : ''}
` `
} }
module.exports = { export { buildRelaunchScript,
unpackedDirName,
resolveUnpackedRelease,
decideRelaunchOutcome,
sandboxPreflight,
sandboxFallbackFromEnv,
collectRelaunchArgs, collectRelaunchArgs,
collectRelaunchEnv, collectRelaunchEnv,
buildRelaunchScript, decideRelaunchOutcome,
shellQuote,
INTERNAL_ARG_PREFIXES, INTERNAL_ARG_PREFIXES,
PRESERVED_ENV_KEYS, PRESERVED_ENV_KEYS,
PRESERVED_ENV_PREFIXES PRESERVED_ENV_PREFIXES,
} resolveUnpackedRelease,
sandboxFallbackFromEnv,
sandboxPreflight,
shellQuote,
unpackedDirName }

View file

@ -1,8 +1,8 @@
/** /**
* Tests for electron/update-remote.cjs the remote-detection helpers that * Tests for electron/update-remote.ts the remote-detection helpers that
* keep passive update checks off the SSH origin for official installs. * keep passive update checks off the SSH origin for official installs.
* *
* Run with: node --test electron/update-remote.test.cjs * Run with: node --test electron/update-remote.test.ts
* (Wired into npm test:desktop:platforms in package.json.) * (Wired into npm test:desktop:platforms in package.json.)
* *
* Why this matters: a public install can carry * Why this matters: a public install can carry
@ -15,16 +15,14 @@
* never prompts and should keep the normal fetch path). * never prompts and should keep the normal fetch path).
*/ */
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import test from 'node:test'
const { import { canonicalGitHubRemote,
OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote,
OFFICIAL_REPO_CANONICAL,
canonicalGitHubRemote,
isSshRemote, isSshRemote,
isOfficialSshRemote OFFICIAL_REPO_CANONICAL,
} = require('./update-remote.cjs') OFFICIAL_REPO_HTTPS_URL } from './update-remote'
test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => { test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => {
assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL) assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)

View file

@ -8,8 +8,8 @@
* which needs no auth and cannot prompt. Active update/apply flows are left * which needs no auth and cannot prompt. Active update/apply flows are left
* unchanged. * unchanged.
* *
* Extracted from main.cjs so the security-critical remote detection is unit * Extracted from main.ts so the security-critical remote detection is unit
* testable without booting Electron (main.cjs requires('electron') at load). * testable without booting Electron (main.ts requires('electron') at load).
*/ */
const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git' const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git'
@ -19,8 +19,9 @@ const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent'
// no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo // no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo
// compare equal. // compare equal.
function canonicalGitHubRemote(url) { function canonicalGitHubRemote(url) {
if (!url) return '' if (!url) {return ''}
let value = String(url).trim() let value = String(url).trim()
if (value.startsWith('git@github.com:')) { if (value.startsWith('git@github.com:')) {
value = `github.com/${value.slice('git@github.com:'.length)}` value = `github.com/${value.slice('git@github.com:'.length)}`
} else if (value.startsWith('ssh://git@github.com/')) { } else if (value.startsWith('ssh://git@github.com/')) {
@ -28,13 +29,17 @@ function canonicalGitHubRemote(url) {
} else { } else {
try { try {
const parsed = new URL(value) const parsed = new URL(value)
if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}`
if (parsed.hostname && parsed.pathname) {value = `${parsed.hostname}${parsed.pathname}`}
} catch { } catch {
// Leave non-URL forms unchanged. // Leave non-URL forms unchanged.
} }
} }
value = value.trim().replace(/\/+$/, '') value = value.trim().replace(/\/+$/, '')
if (value.endsWith('.git')) value = value.slice(0, -4)
if (value.endsWith('.git')) {value = value.slice(0, -4)}
return value.toLowerCase() return value.toLowerCase()
} }
@ -42,6 +47,7 @@ function isSshRemote(url) {
const value = String(url || '') const value = String(url || '')
.trim() .trim()
.toLowerCase() .toLowerCase()
return value.startsWith('git@') || value.startsWith('ssh://') return value.startsWith('git@') || value.startsWith('ssh://')
} }
@ -49,10 +55,8 @@ function isOfficialSshRemote(url) {
return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL
} }
module.exports = { export { canonicalGitHubRemote,
OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote,
OFFICIAL_REPO_CANONICAL,
canonicalGitHubRemote,
isSshRemote, isSshRemote,
isOfficialSshRemote OFFICIAL_REPO_CANONICAL,
} OFFICIAL_REPO_HTTPS_URL }

View file

@ -1,9 +1,7 @@
'use strict' import assert from 'node:assert'
import test from 'node:test'
const assert = require('node:assert') import { __testing, extractThemes, readCentralDirectory } from './vscode-marketplace'
const test = require('node:test')
const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs')
// Build a minimal zip with stored (uncompressed) entries so the test controls // Build a minimal zip with stored (uncompressed) entries so the test controls
// the bytes exactly — exercises the central-directory reader + theme extraction // the bytes exactly — exercises the central-directory reader + theme extraction
@ -72,6 +70,7 @@ test('extractThemes reads contributed color themes (resolving ./ paths)', () =>
themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }] themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }]
} }
}) })
const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } }) const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } })
const zip = makeZip([ const zip = makeZip([

View file

@ -1,5 +1,3 @@
'use strict'
/** /**
* VS Code Marketplace color-theme fetcher (main process). * VS Code Marketplace color-theme fetcher (main process).
* *
@ -14,8 +12,8 @@
* zip library into the desktop bundle for a feature this small. * zip library into the desktop bundle for a feature this small.
*/ */
const https = require('node:https') import https from 'node:https'
const zlib = require('node:zlib') import zlib from 'node:zlib'
const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery' const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery'
const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage' const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage'
@ -30,7 +28,7 @@ function request(
url, url,
{ method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {},
redirectsLeft = MAX_REDIRECTS redirectsLeft = MAX_REDIRECTS
) { ): Promise<Buffer<ArrayBuffer>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = https.request(url, { method, headers }, res => { const req = https.request(url, { method, headers }, res => {
const status = res.statusCode ?? 0 const status = res.statusCode ?? 0
@ -102,6 +100,7 @@ async function resolveExtension(id) {
// IncludeCategoryAndTags | IncludeLatestVersionOnly = 914. // IncludeCategoryAndTags | IncludeLatestVersionOnly = 914.
flags: 914 flags: 914
}) })
const extension = json?.results?.[0]?.extensions?.[0] const extension = json?.results?.[0]?.extensions?.[0]
if (!extension) { if (!extension) {
@ -127,6 +126,7 @@ async function resolveExtension(id) {
/** POST an ExtensionQuery payload and return the parsed gallery response. */ /** POST an ExtensionQuery payload and return the parsed gallery response. */
async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) { async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) {
const body = JSON.stringify(payload) const body = JSON.stringify(payload)
const raw = await request(GALLERY_QUERY_URL, { const raw = await request(GALLERY_QUERY_URL, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -332,10 +332,12 @@ async function fetchMarketplaceThemes(id) {
return { extensionId: trimmed, displayName, themes } return { extensionId: trimmed, displayName, themes }
} }
module.exports = { const __testing = { themeEntryName, looksLikeIconTheme }
fetchMarketplaceThemes,
searchMarketplaceThemes, export {
__testing,
extractThemes, extractThemes,
fetchMarketplaceThemes,
readCentralDirectory, readCentralDirectory,
__testing: { themeEntryName, looksLikeIconTheme } searchMarketplaceThemes
} }

View file

@ -4,19 +4,17 @@
* clamping, and the debounce that collapses mid-drag write storms. * clamping, and the debounce that collapses mid-drag write storms.
*/ */
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import test from 'node:test'
const { import { computeWindowOptions,
DEFAULT_WIDTH, debounce,
DEFAULT_HEIGHT, DEFAULT_HEIGHT,
MIN_WIDTH, DEFAULT_WIDTH,
MIN_HEIGHT, MIN_HEIGHT,
sanitizeWindowState, MIN_WIDTH,
onScreen, onScreen,
computeWindowOptions, sanitizeWindowState } from './window-state'
debounce
} = require('./window-state.cjs')
// A single 1920×1080 monitor (work area trimmed for the taskbar). // A single 1920×1080 monitor (work area trimmed for the taskbar).
const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }] const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }]
@ -121,6 +119,7 @@ test('computeWindowOptions does not clamp when displays are unknown', () => {
test('debounce coalesces a burst into one trailing run', t => { test('debounce coalesces a burst into one trailing run', t => {
t.mock.timers.enable({ apis: ['setTimeout'] }) t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0 let calls = 0
const d = debounce(() => { const d = debounce(() => {
calls += 1 calls += 1
}, 250) }, 250)
@ -138,6 +137,7 @@ test('debounce coalesces a burst into one trailing run', t => {
test('debounce.flush runs now and cancels the pending timer', t => { test('debounce.flush runs now and cancels the pending timer', t => {
t.mock.timers.enable({ apis: ['setTimeout'] }) t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0 let calls = 0
const d = debounce(() => { const d = debounce(() => {
calls += 1 calls += 1
}, 250) }, 250)

View file

@ -2,7 +2,7 @@
* Pure geometry helpers for window-state.json restoring the main window's * Pure geometry helpers for window-state.json restoring the main window's
* size, position, and maximized flag across launches. Side-effect-free so the * size, position, and maximized flag across launches. Side-effect-free so the
* part that actually matters (rejecting garbage + off-screen bounds) is * part that actually matters (rejecting garbage + off-screen bounds) is
* unit-testable without booting Electron; main.cjs owns the file I/O and the * unit-testable without booting Electron; main.ts owns the file I/O and the
* live `screen` displays. * live `screen` displays.
*/ */
@ -21,41 +21,59 @@ const MIN_VISIBLE = 48
const finite = v => typeof v === 'number' && Number.isFinite(v) const finite = v => typeof v === 'number' && Number.isFinite(v)
const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi)) const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi))
interface SanitizedWindowState{
width: number, height: number, isMaximized: boolean, x?: number,y?: number
}
// Parse raw JSON → clean state, or null if garbage. width/height are required // Parse raw JSON → clean state, or null if garbage. width/height are required
// and floored; x/y survive only as a finite pair; isMaximized is strict. // and floored; x/y survive only as a finite pair; isMaximized is strict.
function sanitizeWindowState(raw) { function sanitizeWindowState(raw?: any): SanitizedWindowState | null
if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) return null
const state = {
{
if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) {return null}
const state: SanitizedWindowState = {
width: Math.max(MIN_WIDTH, Math.round(raw.width)), width: Math.max(MIN_WIDTH, Math.round(raw.width)),
height: Math.max(MIN_HEIGHT, Math.round(raw.height)), height: Math.max(MIN_HEIGHT, Math.round(raw.height)),
isMaximized: raw.isMaximized === true isMaximized: raw.isMaximized === true,
} }
if (finite(raw.x) && finite(raw.y)) { if (finite(raw.x) && finite(raw.y)) {
state.x = Math.round(raw.x) state.x = Math.round(raw.x);
state.y = Math.round(raw.y) state.y = Math.round(raw.y)
} }
return state return state
} }
// True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both // True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both
// axes. `displays` is Electron's screen.getAllDisplays() shape. // axes. `displays` is Electron's screen.getAllDisplays() shape.
function onScreen(bounds, displays) { function onScreen(bounds, displays) {
if (!Array.isArray(displays)) return false if (!Array.isArray(displays)) {return false}
return displays.some(({ workArea: a } = {}) => { return displays.some(({ workArea: a } = {}) => {
if (!a) return false if (!a) {return false}
const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x) const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x)
const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y) const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y)
return x >= MIN_VISIBLE && y >= MIN_VISIBLE return x >= MIN_VISIBLE && y >= MIN_VISIBLE
}) })
} }
interface WindowOptions {
width: number
height: number
x?: number
y?: number
}
// Sanitized state (or null) → BrowserWindow size/position options. Always sets // Sanitized state (or null) → BrowserWindow size/position options. Always sets
// width/height, capped to the largest current display so a size saved on a // width/height, capped to the largest current display so a size saved on a
// since-disconnected bigger monitor can't exceed any screen the user now has. // since-disconnected bigger monitor can't exceed any screen the user now has.
// Sets x/y only when still on-screen; otherwise Electron centers the window. // Sets x/y only when still on-screen; otherwise Electron centers the window.
function computeWindowOptions(state, displays) { function computeWindowOptions(state, displays): WindowOptions {
const opts = { const opts: WindowOptions = {
width: finite(state?.width) ? state.width : DEFAULT_WIDTH, width: finite(state?.width) ? state.width : DEFAULT_WIDTH,
height: finite(state?.height) ? state.height : DEFAULT_HEIGHT height: finite(state?.height) ? state.height : DEFAULT_HEIGHT
} }
@ -67,6 +85,7 @@ function computeWindowOptions(state, displays) {
: m, : m,
{ width: 0, height: 0 } { width: 0, height: 0 }
) )
if (cap.width && cap.height) { if (cap.width && cap.height) {
opts.width = clamp(opts.width, MIN_WIDTH, cap.width) opts.width = clamp(opts.width, MIN_WIDTH, cap.width)
opts.height = clamp(opts.height, MIN_HEIGHT, cap.height) opts.height = clamp(opts.height, MIN_HEIGHT, cap.height)
@ -78,9 +97,10 @@ function computeWindowOptions(state, displays) {
finite(state.y) && finite(state.y) &&
onScreen({ x: state.x, y: state.y, width: opts.width, height: opts.height }, displays) onScreen({ x: state.x, y: state.y, width: opts.width, height: opts.height }, displays)
) { ) {
opts.x = state.x opts.x = state.x;
opts.y = state.y opts.y = state.y
} }
return opts return opts
} }
@ -89,6 +109,7 @@ function computeWindowOptions(state, displays) {
// cancels the pending timer — used on close, before the window is gone. // cancels the pending timer — used on close, before the window is gone.
function debounce(fn, delayMs) { function debounce(fn, delayMs) {
let timer = null let timer = null
const debounced = () => { const debounced = () => {
clearTimeout(timer) clearTimeout(timer)
timer = setTimeout(() => { timer = setTimeout(() => {
@ -96,22 +117,22 @@ function debounce(fn, delayMs) {
fn() fn()
}, delayMs) }, delayMs)
} }
debounced.flush = () => { debounced.flush = () => {
clearTimeout(timer) clearTimeout(timer)
timer = null timer = null
fn() fn()
} }
return debounced return debounced
} }
module.exports = { export { computeWindowOptions,
DEFAULT_WIDTH, debounce,
DEFAULT_HEIGHT, DEFAULT_HEIGHT,
MIN_WIDTH, DEFAULT_WIDTH,
MIN_HEIGHT, MIN_HEIGHT,
MIN_VISIBLE, MIN_VISIBLE,
sanitizeWindowState, MIN_WIDTH,
onScreen, onScreen,
computeWindowOptions, sanitizeWindowState }
debounce
}

View file

@ -1,11 +1,14 @@
'use strict' import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const test = require('node:test') const ELECTRON_DIR = path.dirname(fileURLToPath(import.meta.url))
const assert = require('node:assert/strict')
const fs = require('node:fs') // TODO FIXME these tests all grep source code for specific things. This is an antipattern.
const path = require('node:path') // Tests should NEVER read src, only assert behavior.
const ELECTRON_DIR = __dirname
function readElectronFile(name) { function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
@ -24,9 +27,9 @@ function requireHiddenChildOptions(source, needle) {
} }
test('desktop background child processes opt into hidden Windows consoles', () => { test('desktop background child processes opt into hidden Windows consoles', () => {
const source = readElectronFile('main.cjs') const source = readElectronFile('main.ts')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) assert.match(source, /function hiddenWindowsChildOptions\(options: any = \{\}\)/)
requireHiddenChildOptions(source, "execFileSync(\n 'reg'") requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/) requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/)
@ -45,7 +48,7 @@ test('desktop background child processes opt into hidden Windows consoles', () =
}) })
test('desktop backend launches console python so child consoles are inherited, not pythonw', () => { test('desktop backend launches console python so child consoles are inherited, not pythonw', () => {
const source = readElectronFile('main.cjs') const source = readElectronFile('main.ts')
// The flash fix is structural: the backend runs as a console-subsystem // The flash fix is structural: the backend runs as a console-subsystem
// python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it // python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it
@ -75,7 +78,7 @@ test('desktop backend launches console python so child consoles are inherited, n
}) })
test('desktop backend teardown tree-kills Windows backend descendants', () => { test('desktop backend teardown tree-kills Windows backend descendants', () => {
const source = readElectronFile('main.cjs') const source = readElectronFile('main.ts')
const helperIndex = source.indexOf('function stopBackendChild(child)') const helperIndex = source.indexOf('function stopBackendChild(child)')
assert.notEqual(helperIndex, -1, 'missing backend teardown helper') assert.notEqual(helperIndex, -1, 'missing backend teardown helper')
@ -98,7 +101,7 @@ test('desktop backend teardown tree-kills Windows backend descendants', () => {
}) })
test('intentional or interactive desktop child processes stay documented', () => { test('intentional or interactive desktop child processes stay documented', () => {
const source = readElectronFile('main.cjs') const source = readElectronFile('main.ts')
assert.match(source, /windowsHide: false/) assert.match(source, /windowsHide: false/)
assert.match(source, /handOffWindowsBootstrapRecovery/) assert.match(source, /handOffWindowsBootstrapRecovery/)
@ -109,7 +112,7 @@ test('intentional or interactive desktop child processes stay documented', () =>
}) })
test('bootstrap PowerShell runner hides Windows console children', () => { test('bootstrap PowerShell runner hides Windows console children', () => {
const source = readElectronFile('bootstrap-runner.cjs') const source = readElectronFile('bootstrap-runner.ts')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/) requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/)

View file

@ -1,9 +1,7 @@
'use strict' // Regression guards for Windows `hermes` resolution in main.ts.
// Regression guards for Windows `hermes` resolution in main.cjs.
// //
// main.cjs has no module.exports, so these follow the repo's source-assertion // main.ts has no module.exports, so these follow the repo's source-assertion
// test pattern (see windows-child-process.test.cjs). They pin the two Windows // test pattern (see windows-child-process.test.ts). They pin the two Windows
// resolution bugs that caused desktop reinstall loops: // resolution bugs that caused desktop reinstall loops:
// 1. findOnPath() tried the empty extension FIRST, so an extensionless // 1. findOnPath() tried the empty extension FIRST, so an extensionless
// Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the // Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the
@ -20,13 +18,16 @@
// Retry / "Repair install" resolved the same dead interpreter instead of // Retry / "Repair install" resolved the same dead interpreter instead of
// falling through to the bootstrap installer. // falling through to the bootstrap installer.
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import fs from 'node:fs'
const fs = require('node:fs') import path from 'node:path'
const path = require('node:path') import test from 'node:test'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function readMain() { function readMain() {
return fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8').replace(/\r\n/g, '\n') return fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8').replace(/\r\n/g, '\n')
} }
test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => { test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => {
@ -66,7 +67,7 @@ test('Windows bootstrap recovery chooses --update when any real-install signal i
test('unwrapWindowsVenvHermesCommand smoke-tests the venv python before trusting it', () => { test('unwrapWindowsVenvHermesCommand smoke-tests the venv python before trusting it', () => {
const source = readMain() const source = readMain()
const fnStart = source.indexOf('function unwrapWindowsVenvHermesCommand(') const fnStart = source.indexOf('function unwrapWindowsVenvHermesCommand(')
assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.cjs') assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.ts')
// Slice out just the function body (up to the next top-level function decl) // Slice out just the function body (up to the next top-level function decl)
const fnEnd = source.indexOf('\nfunction ', fnStart + 1) const fnEnd = source.indexOf('\nfunction ', fnStart + 1)
const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd) const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd)

View file

@ -1,7 +1,7 @@
const assert = require('node:assert/strict') import assert from 'node:assert/strict'
const { test } = require('node:test') import { test } from 'node:test'
const { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } = require('./windows-user-env.cjs') import { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } from './windows-user-env'
// ── parseRegQueryValue ───────────────────────────────────────────────────── // ── parseRegQueryValue ─────────────────────────────────────────────────────
@ -42,25 +42,32 @@ test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () =>
test('readWindowsUserEnvVar returns null off Windows without spawning', () => { test('readWindowsUserEnvVar returns null off Windows without spawning', () => {
let spawned = false let spawned = false
const exec = () => { const exec = () => {
spawned = true spawned = true
return '' return ''
} }
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null) assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null)
assert.equal(spawned, false) assert.equal(spawned, false)
}) })
test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => { test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => {
const calls = [] const calls = []
const exec = (cmd, args) => { const exec = (cmd, args) => {
calls.push([cmd, args]) calls.push([cmd, args])
return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n' return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n'
} }
const value = readWindowsUserEnvVar('HERMES_HOME', { const value = readWindowsUserEnvVar('HERMES_HOME', {
platform: 'win32', platform: 'win32',
env: { DRIVE: 'F:' }, env: { DRIVE: 'F:' },
exec exec
}) })
assert.equal(value, 'F:\\Hermes') assert.equal(value, 'F:\\Hermes')
assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]]) assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]])
}) })
@ -69,6 +76,7 @@ test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing)
const exec = () => { const exec = () => {
throw new Error('reg exited 1') throw new Error('reg exited 1')
} }
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null) assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null)
}) })

View file

@ -1,4 +1,4 @@
// windows-user-env.cjs // windows-user-env.ts
// //
// Read a User-scoped environment variable straight from the Windows registry // Read a User-scoped environment variable straight from the Windows registry
// (HKCU\Environment). // (HKCU\Environment).
@ -10,7 +10,7 @@
// gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading // gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading
// the live registry value closes the gap. See #45471. // the live registry value closes the gap. See #45471.
const { execFileSync } = require('node:child_process') import { execFileSync } from 'node:child_process'
// Parse the output of `reg query HKCU\Environment /v <name>`, which looks like: // Parse the output of `reg query HKCU\Environment /v <name>`, which looks like:
// //
@ -20,15 +20,18 @@ const { execFileSync } = require('node:child_process')
// Returns the raw value string (spaces inside the value preserved), or null when // Returns the raw value string (spaces inside the value preserved), or null when
// the requested value line isn't present. // the requested value line isn't present.
function parseRegQueryValue(stdout, name) { function parseRegQueryValue(stdout, name) {
if (!stdout || !name) return null if (!stdout || !name) {return null}
const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/ const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
for (const rawLine of String(stdout).split(/\r?\n/)) { for (const rawLine of String(stdout).split(/\r?\n/)) {
const line = rawLine.trim() const line = rawLine.trim()
const match = line.match(typePattern) const match = line.match(typePattern)
if (match && match[1].toLowerCase() === name.toLowerCase()) { if (match && match[1].toLowerCase() === name.toLowerCase()) {
return match[2] return match[2]
} }
} }
return null return null
} }
@ -36,9 +39,11 @@ function parseRegQueryValue(stdout, name) {
// unexpanded references; plain REG_SZ paths have none, so this is a no-op for // unexpanded references; plain REG_SZ paths have none, so this is a no-op for
// the common F:\... case. Unknown references are left verbatim. // the common F:\... case. Unknown references are left verbatim.
function expandWindowsEnvRefs(value, env = process.env) { function expandWindowsEnvRefs(value, env = process.env) {
if (!value) return value if (!value) {return value}
return value.replace(/%([^%]+)%/g, (whole, name) => { return value.replace(/%([^%]+)%/g, (whole, name) => {
const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase()) const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase())
return key != null && env[key] != null ? env[key] : whole return key != null && env[key] != null ? env[key] : whole
}) })
} }
@ -46,9 +51,12 @@ function expandWindowsEnvRefs(value, env = process.env) {
// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null // Read a User-scoped env var from HKCU\Environment. Windows-only: returns null
// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero // off-Windows (without spawning), on any spawn error, when `reg` exits non-zero
// (the value doesn't exist), or when the value is empty. // (the value doesn't exist), or when the value is empty.
function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync } = {}) { function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync }: {
if (platform !== 'win32' || !name) return null platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, exec?: typeof execFileSync | ((file?: string, args?: any) => string)
} = {}) {
if (platform !== 'win32' || !name) {return null}
let stdout let stdout
try { try {
stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], { stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], {
encoding: 'utf8', encoding: 'utf8',
@ -59,14 +67,15 @@ function readWindowsUserEnvVar(name, { platform = process.platform, env = proces
// `reg` missing, or value absent (reg exits 1) — caller falls back. // `reg` missing, or value absent (reg exits 1) — caller falls back.
return null return null
} }
const raw = parseRegQueryValue(stdout, name) const raw = parseRegQueryValue(stdout, name)
if (raw == null) return null
if (raw == null) {return null}
const expanded = expandWindowsEnvRefs(raw, env).trim() const expanded = expandWindowsEnvRefs(raw, env).trim()
return expanded || null return expanded || null
} }
module.exports = { export { expandWindowsEnvRefs,
expandWindowsEnvRefs,
parseRegQueryValue, parseRegQueryValue,
readWindowsUserEnvVar readWindowsUserEnvVar }
}

View file

@ -1,14 +1,14 @@
/** /**
* Tests for electron/workspace-cwd.cjs. * Tests for electron/workspace-cwd.ts.
* *
* Run with: node --test electron/workspace-cwd.test.cjs * Run with: node --test electron/workspace-cwd.test.ts
*/ */
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import path from 'node:path'
const path = require('node:path') import test from 'node:test'
const { isPackagedInstallPath } = require('./workspace-cwd.cjs') import { isPackagedInstallPath } from './workspace-cwd'
const installRoot = path.resolve('/opt/Hermes') const installRoot = path.resolve('/opt/Hermes')

View file

@ -1,7 +1,7 @@
const path = require('node:path') import path from 'node:path'
/** True when `dir` lives inside a packaged app bundle / install tree. */ /** True when `dir` lives inside a packaged app bundle / install tree. */
function isPackagedInstallPath(dir, { installRoots, isPackaged }) { function isPackagedInstallPath(dir, { installRoots, isPackaged }: { installRoots: string[], isPackaged:boolean }) {
if (!isPackaged || !dir) { if (!isPackaged || !dir) {
return false return false
} }
@ -21,7 +21,7 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
return true return true
} }
const rel = path.relative(root, resolved) const rel = path.relative(root, resolved) as any
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
return true return true
@ -31,4 +31,4 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
return false return false
} }
module.exports = { isPackagedInstallPath } export { isPackagedInstallPath }

View file

@ -1,12 +1,10 @@
const assert = require('node:assert/strict') import assert from 'node:assert/strict'
const test = require('node:test') import test from 'node:test'
const { import { decodeClipboardImageBase64,
decodeClipboardImageBase64,
encodePowerShellCommand, encodePowerShellCommand,
powershellCandidates, powershellCandidates,
readWslWindowsClipboardImage readWslWindowsClipboardImage } from './wsl-clipboard-image'
} = require('./wsl-clipboard-image.cjs')
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
@ -49,10 +47,12 @@ test('decodeClipboardImageBase64 rejects base64 without a PNG signature', () =>
test('readWslWindowsClipboardImage decodes the first candidate that returns a PNG', () => { test('readWslWindowsClipboardImage decodes the first candidate that returns a PNG', () => {
const png = fakePngBuffer() const png = fakePngBuffer()
const calls = [] const calls = []
const exec = (cmd, args) => {
const exec = ((cmd, args) => {
calls.push({ cmd, args }) calls.push({ cmd, args })
return png.toString('base64') return png.toString('base64')
} }) as any
const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe'] }) const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe'] })
assert.ok(result && result.equals(png)) assert.ok(result && result.equals(png))
@ -65,15 +65,18 @@ test('readWslWindowsClipboardImage decodes the first candidate that returns a PN
test('readWslWindowsClipboardImage returns null and stops when stdout is empty (no image)', () => { test('readWslWindowsClipboardImage returns null and stops when stdout is empty (no image)', () => {
let count = 0 let count = 0
const exec = () => {
const exec =(() => {
count += 1 count += 1
return '' return ''
} } )as any
const result = readWslWindowsClipboardImage({ const result = readWslWindowsClipboardImage({
exec, exec,
candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'] candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
}) })
assert.equal(result, null) assert.equal(result, null)
// Empty stdout means "no image on the clipboard" — don't probe further candidates. // Empty stdout means "no image on the clipboard" — don't probe further candidates.
assert.equal(count, 1) assert.equal(count, 1)
@ -82,18 +85,22 @@ test('readWslWindowsClipboardImage returns null and stops when stdout is empty (
test('readWslWindowsClipboardImage falls through to the next candidate when one throws', () => { test('readWslWindowsClipboardImage falls through to the next candidate when one throws', () => {
const png = fakePngBuffer() const png = fakePngBuffer()
const seen = [] const seen = []
const exec = cmd => { const exec = cmd => {
seen.push(cmd) seen.push(cmd)
if (cmd === 'powershell.exe') { if (cmd === 'powershell.exe') {
throw Object.assign(new Error('not found'), { code: 'ENOENT' }) throw Object.assign(new Error('not found'), { code: 'ENOENT' })
} }
return png.toString('base64')
return png.toString('base64') as any
} }
const result = readWslWindowsClipboardImage({ const result = readWslWindowsClipboardImage({
exec, exec,
candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'] candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
}) })
assert.ok(result && result.equals(png)) assert.ok(result && result.equals(png))
assert.deepEqual(seen, ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']) assert.deepEqual(seen, ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'])
}) })

View file

@ -1,7 +1,7 @@
// Pull a Windows-host clipboard image from inside WSL2 via PowerShell (WSLg // Pull a Windows-host clipboard image from inside WSL2 via PowerShell (WSLg
// bridges text but not images). Returns PNG bytes or null; exec injectable. // bridges text but not images). Returns PNG bytes or null; exec injectable.
const { execFileSync } = require('node:child_process') import { execFileSync } from 'node:child_process'
// STA is mandatory: System.Windows.Forms.Clipboard throws ThreadStateException // STA is mandatory: System.Windows.Forms.Clipboard throws ThreadStateException
// off a single-threaded apartment. We emit base64 (not raw bytes) so the PNG // off a single-threaded apartment. We emit base64 (not raw bytes) so the PNG
@ -33,9 +33,11 @@ function powershellCandidates() {
function decodeClipboardImageBase64(stdout) { function decodeClipboardImageBase64(stdout) {
const b64 = String(stdout || '').trim() const b64 = String(stdout || '').trim()
if (!b64) return null
if (!b64) {return null}
let buffer let buffer
try { try {
buffer = Buffer.from(b64, 'base64') buffer = Buffer.from(b64, 'base64')
} catch { } catch {
@ -44,6 +46,7 @@ function decodeClipboardImageBase64(stdout) {
// Guard against partial / garbage output: require a real PNG signature. // Guard against partial / garbage output: require a real PNG signature.
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
if (buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) { if (buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
return null return null
} }
@ -54,7 +57,7 @@ function decodeClipboardImageBase64(stdout) {
// Read the Windows clipboard image from inside WSL. Returns a PNG Buffer, or // Read the Windows clipboard image from inside WSL. Returns a PNG Buffer, or
// null when there's no image, PowerShell is unreachable, or output is invalid. // null when there's no image, PowerShell is unreachable, or output is invalid.
// Linux-only by contract (caller gates on IS_WSL); never throws. // Linux-only by contract (caller gates on IS_WSL); never throws.
function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() } = {}) { function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() }: {exec?: typeof execFileSync, candidates?: string[]} = {}) {
const encoded = encodePowerShellCommand(PS_SCRIPT) const encoded = encodePowerShellCommand(PS_SCRIPT)
for (const ps of candidates) { for (const ps of candidates) {
@ -72,10 +75,13 @@ function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powers
stdio: ['ignore', 'pipe', 'ignore'] stdio: ['ignore', 'pipe', 'ignore']
} }
) )
const decoded = decodeClipboardImageBase64(stdout) const decoded = decodeClipboardImageBase64(stdout)
if (decoded) return decoded
if (decoded) {return decoded}
// Empty stdout = no image on the clipboard; stop, don't try fallbacks. // Empty stdout = no image on the clipboard; stop, don't try fallbacks.
if (String(stdout || '').trim() === '') return null if (String(stdout || '').trim() === '') {return null}
} catch { } catch {
// This powershell.exe candidate is missing/failed — try the next one. // This powershell.exe candidate is missing/failed — try the next one.
} }
@ -84,9 +90,7 @@ function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powers
return null return null
} }
module.exports = { export { decodeClipboardImageBase64,
decodeClipboardImageBase64,
encodePowerShellCommand, encodePowerShellCommand,
powershellCandidates, powershellCandidates,
readWslWindowsClipboardImage readWslWindowsClipboardImage }
}

View file

@ -4,10 +4,10 @@
* roundtrip stability of the preset percentages. * roundtrip stability of the preset percentages.
*/ */
const test = require('node:test') import assert from 'node:assert/strict'
const assert = require('node:assert/strict') import test from 'node:test'
const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs') import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom'
test('storage key stays stable so persisted zoom survives upgrades', () => { test('storage key stays stable so persisted zoom survives upgrades', () => {
assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel') assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel')
@ -43,6 +43,7 @@ test('preset percentages roundtrip within rounding', () => {
test('conversion is monotonic across the preset range', () => { test('conversion is monotonic across the preset range', () => {
const levels = [90, 100, 110, 125, 150, 175].map(percentToZoomLevel) const levels = [90, 100, 110, 125, 150, 175].map(percentToZoomLevel)
for (let i = 1; i < levels.length; i++) { for (let i = 1; i < levels.length; i++) {
assert.ok(levels[i] > levels[i - 1]) assert.ok(levels[i] > levels[i - 1])
} }

View file

@ -6,29 +6,24 @@
* factor = 1.2 ^ level. * factor = 1.2 ^ level.
*/ */
const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' export const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'
const ZOOM_FACTOR_BASE = 1.2 const ZOOM_FACTOR_BASE = 1.2
const MIN_ZOOM_LEVEL = -9 const MIN_ZOOM_LEVEL = -9
const MAX_ZOOM_LEVEL = 9 const MAX_ZOOM_LEVEL = 9
function clampZoomLevel(value) { export function clampZoomLevel(value) {
if (!Number.isFinite(value)) return 0 if (!Number.isFinite(value)) {return 0}
return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
} }
function zoomLevelToPercent(level) { export function zoomLevelToPercent(level) {
return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100) return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100)
} }
function percentToZoomLevel(percent) { export function percentToZoomLevel(percent) {
if (!Number.isFinite(percent) || percent <= 0) return 0 if (!Number.isFinite(percent) || percent <= 0) {return 0}
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
} }
module.exports = {
ZOOM_STORAGE_KEY,
clampZoomLevel,
percentToZoomLevel,
zoomLevelToPercent
}

View file

@ -105,12 +105,12 @@ export default [
} }
}, },
{ {
files: ['**/*.js', '**/*.cjs'], files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
ignores: ['**/node_modules/**', '**/dist/**'], ignores: ['**/node_modules/**', '**/dist/**'],
languageOptions: { languageOptions: {
ecmaVersion: 'latest', ecmaVersion: 'latest',
globals: { ...globals.node }, globals: { ...globals.node },
sourceType: 'commonjs' sourceType: 'module'
} }
}, },
{ {

View file

@ -6,22 +6,22 @@
"description": "Native desktop shell for Hermes Agent.", "description": "Native desktop shell for Hermes Agent.",
"author": "Nous Research", "author": "Nous Research",
"type": "module", "type": "module",
"main": "electron/main.cjs", "main": "dist/electron-main.mjs",
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"scripts": { "scripts": {
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"", "dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev", "dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
"dev:renderer": "node scripts/assert-root-install.cjs && vite --host 127.0.0.1 --port 5174", "dev:renderer": "node scripts/assert-root-install.mjs && vite --host 127.0.0.1 --port 5174",
"dev:electron": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "dev:electron": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 NODE_OPTIONS=tsx electron electron/main.ts",
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 NODE_OPTIONS=tsx electron --inspect=9229 electron/main.ts",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof NODE_OPTIONS=tsx HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron electron/main.ts",
"start": "npm run build && electron .", "start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild", "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
"postbuild": "node scripts/assert-dist-built.cjs", "postbuild": "node scripts/assert-dist-built.mjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs", "prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs", "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs",
"pack": "npm run build && npm run builder -- --dir", "pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder", "dist": "npm run build && npm run builder",
"dist:mac": "npm run build && npm run builder -- --mac", "dist:mac": "npm run build && npm run builder -- --mac",
@ -37,14 +37,14 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh", "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/git-worktree-ops.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/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/zoom.test.cjs electron/windows-hermes-resolution.test.cjs electron/oauth-session-request.test.cjs", "test:desktop:platforms": "node --test electron/bootstrap-platform.test.ts electron/hardening.test.ts electron/backend-env.test.ts electron/backend-probes.test.ts electron/backend-ready.test.ts electron/bootstrap-runner.test.ts electron/connection-config.test.ts electron/dashboard-token.test.ts electron/gateway-ws-probe.test.ts electron/oauth-net-request.test.ts electron/desktop-uninstall.test.ts electron/session-windows.test.ts electron/link-title-window.test.ts electron/workspace-cwd.test.ts electron/fs-read-dir.test.ts electron/git-root.test.ts electron/git-worktree-ops.test.ts electron/windows-child-process.test.ts electron/update-remote.test.ts electron/update-count.test.ts electron/update-rebuild.test.ts electron/update-marker.test.ts electron/update-relaunch.test.ts electron/windows-user-env.test.ts electron/wsl-clipboard-image.test.ts electron/titlebar-overlay-width.test.ts electron/window-state.test.ts electron/zoom.test.ts electron/windows-hermes-resolution.test.ts electron/oauth-session-request.test.ts",
"typecheck": "tsc -p . --noEmit", "typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/", "lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix", "lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'", "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'",
"fix": "npm run lint:fix && npm run fmt", "fix": "npm run lint:fix && npm run fmt",
"test:ui": "vitest run --environment jsdom", "test:ui": "vitest run --environment jsdom",
"preview": "node scripts/assert-root-install.cjs && vite preview --host 127.0.0.1 --port 4174" "preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174"
}, },
"dependencies": { "dependencies": {
"@assistant-ui/react": "^0.12.28", "@assistant-ui/react": "^0.12.28",
@ -117,6 +117,7 @@
"web-haptics": "^0.0.6" "web-haptics": "^0.0.6"
}, },
"devDependencies": { "devDependencies": {
"@electron/rebuild": "^4.0.6",
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
@ -132,6 +133,7 @@
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron": "40.10.2", "electron": "40.10.2",
"electron-builder": "^26.8.1", "electron-builder": "^26.8.1",
"esbuild": "^0.28.1",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0", "eslint-plugin-perfectionist": "^5.9.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
@ -141,6 +143,7 @@
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"rcedit": "^5.0.2", "rcedit": "^5.0.2",
"tsx": "^4.22.4",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.0.10", "vite": "^8.0.10",
"vitest": "^4.1.5", "vitest": "^4.1.5",
@ -167,29 +170,24 @@
"files": [ "files": [
"dist/**", "dist/**",
"assets/**", "assets/**",
"electron/**",
"public/**", "public/**",
"package.json" "package.json"
], ],
"beforeBuild": "scripts/before-build.cjs", "beforeBuild": "scripts/before-build.mjs",
"beforePack": "scripts/before-pack.cjs", "beforePack": "scripts/before-pack.mjs",
"afterPack": "scripts/after-pack.cjs", "afterPack": "scripts/after-pack.mjs",
"extraResources": [ "extraResources": [
{ {
"from": "build/install-stamp.json", "from": "build/install-stamp.json",
"to": "install-stamp.json" "to": "install-stamp.json"
}, },
{
"from": "build/native-deps",
"to": "native-deps"
},
{ {
"from": "assets/icon.ico", "from": "assets/icon.ico",
"to": "icon.ico" "to": "icon.ico"
} }
], ],
"asar": true, "asar": true,
"afterSign": "scripts/notarize.cjs", "afterSign": "scripts/notarize.mjs",
"asarUnpack": [ "asarUnpack": [
"**/*.node", "**/*.node",
"**/prebuilds/**", "**/prebuilds/**",

View file

@ -1,8 +1,8 @@
/** /**
* after-pack.cjs electron-builder afterPack hook. * after-pack.mjs electron-builder afterPack hook.
* *
* Stamps the Hermes icon + identity onto the packed Windows Hermes.exe via * Stamps the Hermes icon + identity onto the packed Windows Hermes.exe via
* rcedit (delegated to set-exe-identity.cjs). This runs for EVERY packed build * rcedit (delegated to set-exe-identity.mjs). This runs for EVERY packed build
* first install, `hermes desktop`, the installer's --update rebuild, and a * first install, `hermes desktop`, the installer's --update rebuild, and a
* dev's manual `npm run pack` so the branded exe can never silently revert * dev's manual `npm run pack` so the branded exe can never silently revert
* to the stock "Electron" icon/name (the bug when the stamp lived only in * to the stock "Electron" icon/name (the bug when the stamp lived only in
@ -19,18 +19,18 @@
* - packager.appInfo.productFilename: the exe basename (e.g. 'Hermes') * - packager.appInfo.productFilename: the exe basename (e.g. 'Hermes')
*/ */
const path = require('node:path') import path from 'node:path'
const { stampExeIdentity } = require('./set-exe-identity.cjs') import { stampExeIdentity } from './set-exe-identity.mjs'
exports.default = async function afterPack(context) { export default async function afterPack(context) {
if (context.electronPlatformName !== 'win32') { if (context.electronPlatformName !== 'win32') {
return return
} }
const productName = context.packager?.appInfo?.productFilename || 'Hermes' const productName = context.packager?.appInfo?.productFilename || 'Hermes'
const exe = path.join(context.appOutDir, `${productName}.exe`) const exe = path.join(context.appOutDir, `${productName}.exe`)
const desktopRoot = path.resolve(__dirname, '..') const desktopRoot = path.resolve(import.meta.dirname, '..')
try { try {
await stampExeIdentity(exe, desktopRoot) await stampExeIdentity(exe, desktopRoot)

View file

@ -13,31 +13,32 @@
// inherits it. It fails loud and early instead of shipping a broken bundle. // inherits it. It fails loud and early instead of shipping a broken bundle.
// See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404). // See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404).
const fs = require("fs") import { existsSync, statSync, readdirSync } from "fs"
const path = require("path") import { join, resolve } from "path"
import { isMain } from "./utils.mjs"
// Pure check — returns { ok: true } or { ok: false, error: "..." }. // Pure check — returns { ok: true } or { ok: false, error: "..." }.
// Kept side-effect-free so it can be unit tested without spawning a process. // Kept side-effect-free so it can be unit tested without spawning a process.
function checkDistBuilt(distDir) { export function checkDistBuilt(distDir) {
if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) { if (!existsSync(distDir) || !statSync(distDir).isDirectory()) {
return { ok: false, error: `no dist directory at ${distDir}` } return { ok: false, error: `no dist directory at ${distDir}` }
} }
const indexHtml = path.join(distDir, "index.html") const indexHtml = join(distDir, "index.html")
if (!fs.existsSync(indexHtml) || !fs.statSync(indexHtml).isFile()) { if (!existsSync(indexHtml) || !statSync(indexHtml).isFile()) {
return { ok: false, error: `dist/index.html is missing at ${indexHtml}` } return { ok: false, error: `dist/index.html is missing at ${indexHtml}` }
} }
if (fs.statSync(indexHtml).size === 0) { if (statSync(indexHtml).size === 0) {
return { ok: false, error: `dist/index.html is empty at ${indexHtml}` } return { ok: false, error: `dist/index.html is empty at ${indexHtml}` }
} }
// index.html alone isn't enough — vite emits hashed JS into dist/assets. // index.html alone isn't enough — vite emits hashed JS into dist/assets.
// An index.html with no script bundle still blank-pages. // An index.html with no script bundle still blank-pages.
const assetsDir = path.join(distDir, "assets") const assetsDir = join(distDir, "assets")
const hasAssets = const hasAssets =
fs.existsSync(assetsDir) && existsSync(assetsDir) &&
fs.statSync(assetsDir).isDirectory() && statSync(assetsDir).isDirectory() &&
fs.readdirSync(assetsDir).some(name => name.endsWith(".js")) readdirSync(assetsDir).some(name => name.endsWith(".js"))
if (!hasAssets) { if (!hasAssets) {
return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` } return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` }
} }
@ -46,8 +47,8 @@ function checkDistBuilt(distDir) {
} }
function main() { function main() {
const desktopRoot = path.resolve(__dirname, "..") const desktopRoot = resolve(import.meta.dirname, "..")
const distDir = path.join(desktopRoot, "dist") const distDir = join(desktopRoot, "dist")
const result = checkDistBuilt(distDir) const result = checkDistBuilt(distDir)
if (!result.ok) { if (!result.ok) {
@ -63,8 +64,8 @@ function main() {
console.log("✓ assert-dist-built: dist/index.html + assets present") console.log("✓ assert-dist-built: dist/index.html + assets present")
} }
if (require.main === module) { if (isMain(import.meta.url)) {
main() main()
} }
module.exports = { checkDistBuilt } export default { checkDistBuilt }

View file

@ -1,10 +1,10 @@
const assert = require('node:assert/strict') import assert from 'node:assert/strict'
const fs = require('node:fs') import fs from 'node:fs'
const os = require('node:os') import os from 'node:os'
const path = require('node:path') import path from 'node:path'
const test = require('node:test') import test from 'node:test'
const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs') import { checkDistBuilt } from '../scripts/assert-dist-built.mjs'
function makeDist(extra) { function makeDist(extra) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-')) const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))

View file

@ -1,13 +0,0 @@
"use strict"
const fs = require("fs")
const path = require("path")
const root = path.resolve(__dirname, "..", "..", "..")
try {
fs.accessSync(path.join(root, "node_modules", "vite", "package.json"))
} catch {
console.error(`Run from repo root: cd ${root} && npm ci`)
process.exit(1)
}

View file

@ -0,0 +1,11 @@
import { accessSync } from "fs"
import { resolve, join } from "path"
const root = resolve(import.meta.dirname, "..", "..", "..")
try {
accessSync(join(root, "node_modules", "vite", "package.json"))
} catch {
console.error(`Run from repo root: cd ${root} && npm ci`)
process.exit(1)
}

View file

@ -4,8 +4,8 @@
* avoids workspace dependency graph explosions and keeps packaging * avoids workspace dependency graph explosions and keeps packaging
* deterministic across environments. The Hermes Agent Python payload is no * deterministic across environments. The Hermes Agent Python payload is no
* longer bundled; the Electron app fetches it at first launch via * longer bundled; the Electron app fetches it at first launch via
* `install.ps1`'s stage protocol (Windows). See `electron/main.cjs`. * `install.ps1`'s stage protocol (Windows). See `electron/main.ts`.
*/ */
module.exports = async function beforeBuild() { export default async function beforeBuild() {
return false return false
} }

View file

@ -1,10 +1,11 @@
'use strict' 'use strict'
/** /**
* before-pack.cjs electron-builder beforePack hook. * before-pack.mjs electron-builder beforePack hook.
* *
* Removes any stale unpacked app directory (`appOutDir`) before * Two responsibilities:
* electron-builder stages the Electron binaries into it. *
* 1. Removes any stale unpacked app directory (`appOutDir`) before
* electron-builder stages the Electron binaries into it.
* *
* WHY THIS EXISTS * WHY THIS EXISTS
* --------------- * ---------------
@ -41,30 +42,41 @@
* resolve rather than throw worst case electron-builder hits the original * resolve rather than throw worst case electron-builder hits the original
* ENOENT, which is no worse than not having this hook at all. * ENOENT, which is no worse than not having this hook at all.
* *
* 2. Re-stages node-pty's native files for the ACTUAL target platform/arch
* of this pack. `npm run build` already staged node-pty once for the
* host machine (see scripts/stage-native-deps.mjs), which is correct for
* single-arch builds matching the host. But electron-builder can target
* a different arch than the host (cross-build), or pack multiple archs
* from one `npm run build` (e.g. `dist:mac` => x64 + arm64). Only this
* hook knows the real per-target arch, via `context.arch` /
* `context.electronPlatformName` so it re-stages on top of whatever
* `npm run build` left behind, per target, right before files are read
* for packing.
*
* electron-builder passes a context with: * electron-builder passes a context with:
* - appOutDir: the unpacked app directory about to be staged * - appOutDir: the unpacked app directory about to be staged
* - electronPlatformName: 'win32' | 'darwin' | 'linux' * - electronPlatformName: 'win32' | 'darwin' | 'linux'
* - arch: Arch enum (0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal)
*/ */
import { existsSync, rmSync } from 'node:fs'
import { Arch } from 'electron-builder'
import { stageNodePty } from './stage-native-deps.mjs'
const fs = require('node:fs') export function cleanStaleAppOutDir(appOutDir) {
function cleanStaleAppOutDir(appOutDir) {
if (!appOutDir || typeof appOutDir !== 'string') { if (!appOutDir || typeof appOutDir !== 'string') {
return false return false
} }
if (!fs.existsSync(appOutDir)) { if (!existsSync(appOutDir)) {
return false return false
} }
// Recursive + force so a half-written tree (read-only bits, partial files) // Recursive + force so a half-written tree (read-only bits, partial files)
// can't block the wipe. retry/maxRetries rides out transient EBUSY on // can't block the wipe. retry/maxRetries rides out transient EBUSY on
// Windows where an AV/indexer may briefly hold a handle. // Windows where an AV/indexer may briefly hold a handle.
fs.rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
return true return true
} }
exports.cleanStaleAppOutDir = cleanStaleAppOutDir export default async function beforePack(context) {
exports.default = async function beforePack(context) {
const appOutDir = context && context.appOutDir const appOutDir = context && context.appOutDir
try { try {
if (cleanStaleAppOutDir(appOutDir)) { if (cleanStaleAppOutDir(appOutDir)) {
@ -75,4 +87,26 @@ exports.default = async function beforePack(context) {
// directory (permissions, mount) is still diagnosable. // directory (permissions, mount) is still diagnosable.
console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`) console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`)
} }
try {
const platform = context && context.electronPlatformName
const archName = context && typeof context.arch === 'number' ? Arch[context.arch] : undefined
if (platform && archName) {
if (archName === 'universal') {
console.warn(
'[before-pack] target arch is "universal" — node-pty has no universal prebuild; ' +
'staged binary will be whichever single-arch copy npm run build left behind. ' +
'lipo-merge x64/arm64 .node files manually if you need a true universal build.'
)
} else {
await stageNodePty({ platform, arch: archName })
console.log(`[before-pack] re-staged node-pty for target ${platform}-${archName}`)
}
}
} catch (err) {
// This one SHOULD fail the build — a missing/wrong native binary for the
// target arch means a broken package shipped to users, which is worse
// than a build that fails loudly here.
throw new Error(`[before-pack] failed to stage node-pty for this target: ${err.message}`)
}
} }

View file

@ -1,10 +1,10 @@
const assert = require('node:assert/strict') import assert from 'node:assert/strict'
const fs = require('node:fs') import fs from 'node:fs'
const os = require('node:os') import os from 'node:os'
const path = require('node:path') import path from 'node:path'
const test = require('node:test') import test from 'node:test'
const { cleanStaleAppOutDir } = require('../scripts/before-pack.cjs') import beforePack, { cleanStaleAppOutDir } from '../scripts/before-pack.mjs'
test('cleanStaleAppOutDir removes a populated unpacked directory', () => { test('cleanStaleAppOutDir removes a populated unpacked directory', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
@ -45,7 +45,6 @@ test('cleanStaleAppOutDir ignores empty or invalid input', () => {
}) })
test('beforePack default export resolves even when cleanup throws', async () => { test('beforePack default export resolves even when cleanup throws', async () => {
const { default: beforePack } = require('../scripts/before-pack.cjs')
// A directory path that rmSync can't remove is simulated by passing a // A directory path that rmSync can't remove is simulated by passing a
// context whose appOutDir is a file the hook will try (and be allowed) to // context whose appOutDir is a file the hook will try (and be allowed) to
// remove; the contract under test is that the hook never rejects. // remove; the contract under test is that the hook never rejects.

View file

@ -0,0 +1,60 @@
#!/usr/bin/env node
// bundle-electron-main.mjs — bundles electron/main.ts and electron/preload.ts
// into self-contained js files in dist/ so the packaged app doesn't need
// node_modules/ or tsx at runtime.
//
// Output:
// dist/electron-main.mjs (MJS bundle — entry point for packaged app)
// dist/electron-preload.js (CJS bundle — loaded via BrowserWindow preload)
//
// `electron` and `node-pty` are external (provided by the runtime / staged
// separately via stage-native-deps).
import { build } from 'esbuild'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { mkdirSync } from 'node:fs'
const here = dirname(fileURLToPath(import.meta.url))
const root = resolve(here, '..')
const distDir = resolve(root, 'dist')
mkdirSync(distDir, { recursive: true })
const mainEntry = resolve(root, 'electron/main.ts')
const mainOut = resolve(distDir, 'electron-main.mjs')
const preloadEntry = resolve(root, 'electron/preload.ts')
const preloadOut = resolve(distDir, 'electron-preload.js')
const external = ['electron', 'node-pty', 'fs']
const define = {
'process.env.HERMES_DESKTOP_IS_PACKAGED': JSON.stringify(true)
}
// Bundle main.ts → dist/electron-main.mjs
await build({
entryPoints: [mainEntry],
bundle: true,
platform: 'node',
format: 'esm',
target: 'node20',
outfile: mainOut,
external,
banner: {
js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
},
define,
logLevel: 'info',
})
console.log(`bundled ${mainOut}`)
// Bundle preload.ts → dist/electron-preload.cjs
await build({
entryPoints: [preloadEntry],
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
outfile: preloadOut,
external,
define,
logLevel: 'info',
})
console.log(`bundled ${preloadOut}`)

View file

@ -1,7 +1,7 @@
const fs = require('node:fs') import { existsSync, writeFileSync, rmSync } from 'node:fs'
const os = require('node:os') import { tmpdir } from 'node:os'
const path = require('node:path') import { join } from 'node:path'
const { execFile } = require('node:child_process') import { execFile } from 'node:child_process'
function run(command, args) { function run(command, args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -26,7 +26,7 @@ function resolveApiKeyPath(rawValue) {
const value = String(rawValue || '').trim() const value = String(rawValue || '').trim()
if (!value) return { keyPath: '', cleanup: () => {} } if (!value) return { keyPath: '', cleanup: () => {} }
if (fs.existsSync(value)) { if (existsSync(value)) {
return { keyPath: value, cleanup: () => {} } return { keyPath: value, cleanup: () => {} }
} }
@ -34,17 +34,17 @@ function resolveApiKeyPath(rawValue) {
throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content') throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content')
} }
const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`) const tempPath = join(tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`)
fs.writeFileSync(tempPath, value, 'utf8') writeFileSync(tempPath, value, 'utf8')
return { return {
keyPath: tempPath, keyPath: tempPath,
cleanup: () => fs.rmSync(tempPath, { force: true }) cleanup: () => rmSync(tempPath, { force: true })
} }
} }
async function main() { async function main() {
const artifactPath = process.argv[2] const artifactPath = process.argv[2]
if (!artifactPath || !fs.existsSync(artifactPath)) { if (!artifactPath || !existsSync(artifactPath)) {
throw new Error(`Missing artifact to notarize: ${artifactPath || '(none)'}`) throw new Error(`Missing artifact to notarize: ${artifactPath || '(none)'}`)
} }

View file

@ -1,7 +1,7 @@
const fs = require('node:fs') import fs from 'node:fs'
const os = require('node:os') import os from 'node:os'
const path = require('node:path') import path from 'node:path'
const { execFile } = require('node:child_process') import { execFile } from 'node:child_process'
function run(command, args) { function run(command, args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -49,7 +49,7 @@ function resolveApiKeyPath(rawValue) {
} }
} }
exports.default = async function notarize(context) { export default async function notarize(context) {
const { electronPlatformName, appOutDir, packager } = context const { electronPlatformName, appOutDir, packager } = context
if (electronPlatformName !== 'darwin') return if (electronPlatformName !== 'darwin') return

View file

@ -1,11 +1,11 @@
const fs = require('node:fs') import fs from 'node:fs'
const path = require('node:path') import path from 'node:path'
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
process.exit(0) process.exit(0)
} }
const desktopRoot = path.resolve(__dirname, '..') const desktopRoot = path.resolve(import.meta.dirname, '..')
const repoRoot = path.resolve(desktopRoot, '..', '..') const repoRoot = path.resolve(desktopRoot, '..', '..')
const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js') const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js')

View file

@ -0,0 +1,22 @@
// rebuild-native.mjs
import { rebuild } from '@electron/rebuild'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { isMain } from './utils.mjs'
import packageJson from '../package.json' with { type: 'json' }
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
export async function rebuildNodePty({ arch = process.arch } = {}) {
await rebuild({
buildPath: projectRoot, // where node_modules lives
electronVersion: packageJson.devDependencies.electron.replace('^', ''),
arch,
onlyModules: ['node-pty'],
force: true
})
}
if (isMain(import.meta.url)) {
const [arch] = process.argv.slice(2)
await rebuildNodePty({ arch })
}

View file

@ -1,14 +1,15 @@
"use strict"
// Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can // Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can
// re-unpack a broken Electron.app; reusing the installed dist dodges that. // re-unpack a broken Electron.app; reusing the installed dist dodges that.
// npm workspace hoisting is non-deterministic — require.resolve finds electron // npm workspace hoisting is non-deterministic — require.resolve finds electron
// wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let // wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let
// electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR). // electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR).
const fs = require("node:fs") import fs from "node:fs"
const path = require("node:path") import path from "node:path"
const { spawnSync } = require("node:child_process") import { spawnSync } from "node:child_process"
import { createRequire } from "node:module"
const require = createRequire(import.meta.url)
function electronDistDir() { function electronDistDir() {
try { try {

View file

@ -1,5 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
// set-exe-identity.cjs — stamp the Hermes icon + version metadata onto the // set-exe-identity.mjs — stamp the Hermes icon + version metadata onto the
// built Hermes.exe using rcedit, completely decoupled from electron-builder's // built Hermes.exe using rcedit, completely decoupled from electron-builder's
// signing path. // signing path.
// //
@ -20,7 +20,7 @@
// //
// HOW IT RUNS // HOW IT RUNS
// ----------- // -----------
// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.cjs), // Primarily as an electron-builder `afterPack` hook (scripts/after-pack.mjs),
// so EVERY packed build — first install, `hermes desktop`, the installer's // so EVERY packed build — first install, `hermes desktop`, the installer's
// --update rebuild, or a dev's manual `npm run pack` — gets a branded exe from // --update rebuild, or a dev's manual `npm run pack` — gets a branded exe from
// one place. Previously this stamp lived only in install.ps1, so the update // one place. Previously this stamp lived only in install.ps1, so the update
@ -28,40 +28,34 @@
// shipped a stock "Electron" exe. Keeping it in afterPack closes that gap. // shipped a stock "Electron" exe. Keeping it in afterPack closes that gap.
// //
// Also runnable standalone for ad-hoc re-stamping: // Also runnable standalone for ad-hoc re-stamping:
// node scripts/set-exe-identity.cjs <path-to-Hermes.exe> // node scripts/set-exe-identity.mjs <path-to-Hermes.exe>
// //
// Exits 0 on success, non-zero on failure when run as a CLI. As a hook, // Exits 0 on success, non-zero on failure when run as a CLI. As a hook,
// stampExeIdentity() resolves on success and rejects on failure; the caller // stampExeIdentity() resolves on success and rejects on failure; the caller
// (after-pack.cjs) swallows the rejection so a stamp failure never fails an // (after-pack.mjs) swallows the rejection so a stamp failure never fails an
// otherwise-good build (worst case: stock icon, not a broken app). // otherwise-good build (worst case: stock icon, not a broken app).
const path = require('node:path') import { resolve, join } from 'node:path'
const fs = require('node:fs') import { existsSync } from 'node:fs'
import { rcedit } from 'rcedit'
import { isMain } from './utils.mjs'
// Stamp the Hermes icon + identity onto `exe`. Resolves on success, throws on // Stamp the Hermes icon + identity onto `exe`. Resolves on success, throws on
// failure. `desktopRoot` defaults to this script's package root so the icon and // failure. `desktopRoot` defaults to this script's package root so the icon and
// the rcedit dependency resolve regardless of cwd. // the rcedit dependency resolve regardless of cwd.
async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..')) { async function stampExeIdentity(exe, desktopRoot = resolve(import.meta.dirname, '..')) {
if (!exe || !fs.existsSync(exe)) { if (!exe || !existsSync(exe)) {
throw new Error(`target exe not found: ${exe}`) throw new Error(`target exe not found: ${exe}`)
} }
// Icon lives at apps/desktop/assets/icon.ico // Icon lives at apps/desktop/assets/icon.ico
const icon = path.join(desktopRoot, 'assets', 'icon.ico') const icon = join(desktopRoot, 'assets', 'icon.ico')
if (!fs.existsSync(icon)) { if (!existsSync(icon)) {
throw new Error(`icon not found: ${icon}`) throw new Error(`icon not found: ${icon}`)
} }
// rcedit is a direct devDependency of apps/desktop, so it resolves whether
// we're run from the desktop dir or the repo root (workspace hoist).
// rcedit@5 exports a NAMED `rcedit` function (CommonJS: { rcedit }), not a
// default export.
const mod = require('rcedit')
const rcedit = typeof mod === 'function' ? mod : mod.rcedit
if (typeof rcedit !== 'function') {
throw new Error(`unexpected rcedit export shape: ${typeof mod} keys=${Object.keys(mod)}`)
}
console.log(`[set-exe-identity] stamping ${exe}`) console.log(`[set-exe-identity] stamping ${exe}`)
console.log(`[set-exe-identity] icon: ${icon}`) console.log(`[set-exe-identity] icon: ${icon}`)
@ -78,13 +72,13 @@ async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..')
console.log('[set-exe-identity] done — Hermes icon + identity stamped') console.log('[set-exe-identity] done — Hermes icon + identity stamped')
} }
module.exports = { stampExeIdentity } export { stampExeIdentity }
// CLI entry point: `node scripts/set-exe-identity.cjs <exe>`. // CLI entry point: `node scripts/set-exe-identity.mjs <exe>`.
if (require.main === module) { if (isMain(import.meta.url)) {
const exe = process.argv[2] const exe = process.argv[2]
if (!exe) { if (!exe) {
console.error('[set-exe-identity] usage: set-exe-identity.cjs <path-to-exe>') console.error('[set-exe-identity] usage: set-exe-identity.mjs <path-to-exe>')
process.exit(2) process.exit(2)
} }
stampExeIdentity(exe).catch(err => { stampExeIdentity(exe).catch(err => {

View file

@ -1,283 +0,0 @@
'use strict'
/**
* Stage native node-modules dependencies for electron-builder packaging.
*
* Workspace dedup hoists `node-pty` into the root `node_modules/`, which
* electron-builder's default file collector (when `files:` is explicitly set
* in package.json) cannot reach. The result: packaged builds ship with no
* .node binaries and PTY initialization fails at runtime ("PTY support is
* unavailable").
*
* Rather than restructure the workspace dedup (would require nohoist /
* package.json shenanigans and risk breaking dev) or balloon the package
* with the whole node_modules tree, we copy ONLY the runtime-essential
* files of the native dep into apps/desktop/build/native-deps/ and ship
* THAT subtree via extraResources. main.cjs falls back to require()-ing
* from process.resourcesPath when the hoisted-root require fails.
*
* Runs as part of `npm run build`. Idempotent -- always re-stages on each
* build to pick up native binary updates.
*
* Layout note: upstream node-pty (microsoft/node-pty 1.x) is N-API based
* and ships its prebuilts under `prebuilds/<platform>-<arch>/` instead of
* `build/Release/`. Its runtime resolver (lib/utils.js) checks
* build/Release first and falls through to the per-arch prebuilds dir, so
* shipping only the latter is sufficient for packaged runs. Per-arch
* staging keeps the resource bundle lean -- we only need the target
* arch's prebuilt, not all of them.
*/
const fs = require('node:fs')
const path = require('node:path')
const APP_ROOT = path.resolve(__dirname, '..')
const REPO_ROOT = path.resolve(APP_ROOT, '..', '..')
const STAGE_ROOT = path.join(APP_ROOT, 'build', 'native-deps')
// The target arch may be overridden by electron-builder via npm_config_arch
// (e.g. `npm run dist -- --arm64`); fall back to the build host's arch.
const TARGET_ARCH = process.env.npm_config_arch || process.arch
const TARGET_PLATFORM = process.platform
// Modules to stage. The "from" path is the hoisted location in the workspace
// root; "to" is the layout we want inside build/native-deps/. The "include"
// globs (relative to "from") select the runtime-essential files. Anything
// outside the include list is left behind (source, deps/, scripts/, etc.).
const NATIVE_DEPS = [
{
from: path.join(REPO_ROOT, 'node_modules', 'node-pty'),
to: path.join(STAGE_ROOT, 'node-pty'),
include: [
'package.json',
'lib/*.js',
'lib/**/*.js',
'build/Release/*.node',
// Per-arch runtime payload. Explicit file types so we don't ship the
// ~25 MB of .pdb debug symbols that prebuild-install bundles for
// Windows crash analysis -- not used at runtime, would just bloat
// the installer.
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.node`,
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.dll`,
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.exe`,
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/spawn-helper`,
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/conpty/*`
]
}
]
// Pure-JS runtime dependencies that the packaged electron main require()s but
// that workspace dedup hoists into the repo-root node_modules -- out of reach
// of electron-builder's file collector, exactly like node-pty above. Unlike
// node-pty there is no native binary to select; we stage each package's whole
// directory into build/native-deps/vendor/node_modules/<name> so the dep's own
// internal require()s resolve against a real node_modules tree, and the
// requiring file (electron/git-review-ops.cjs) falls back to that path via
// process.resourcesPath when the normal require() fails. See issue #52735
// (packaged app crashed at launch on `Cannot find module 'simple-git'`).
//
// The closure is resolved at stage time by walking dependencies +
// optionalDependencies, so a simple-git version bump that pulls in a new
// transitive dep can't silently re-introduce the crash.
//
// Layout note: the closure lands in build/native-deps/vendor/node_modules/,
// NOT build/native-deps/node_modules/. electron-builder's file collector
// hard-drops a `node_modules` directory that sits at the ROOT of an
// extraResources copy (app-builder-lib/out/util/filter.js: `if (relative ===
// "node_modules") return false`), but keeps a NESTED one. Nesting under
// `vendor/` makes node_modules a subdirectory so it survives packing; the
// require() fallback in git-review-ops.cjs resolves the matching
// vendor/node_modules path.
const JS_DEP_ROOTS = ['simple-git']
const JS_DEP_STAGE_ROOT = path.join(STAGE_ROOT, 'vendor', 'node_modules')
function rmrf(target) {
fs.rmSync(target, { recursive: true, force: true })
}
function ensureDir(target) {
fs.mkdirSync(target, { recursive: true })
}
function walk(root) {
const results = []
const stack = [root]
while (stack.length) {
const current = stack.pop()
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(current, entry.name)
if (entry.isDirectory()) {
stack.push(full)
} else if (entry.isFile()) {
results.push(full)
}
}
}
return results
}
// Match a relative path against simple ** and * glob patterns. Implementation
// is intentionally tiny -- the include lists are small and don't need full
// minimatch support.
function matchGlob(rel, pattern) {
const r = rel.replace(/\\/g, '/')
const re = new RegExp(
'^' +
pattern
.replace(/\\/g, '/')
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '__DOUBLE_STAR__')
.replace(/\*/g, '[^/]*')
.replace(/__DOUBLE_STAR__/g, '.*') +
'$'
)
return re.test(r)
}
function stageOne(spec) {
if (!fs.existsSync(spec.from)) {
throw new Error(
`stage-native-deps: source missing at ${spec.from}. Run \`npm install\` ` +
`at the workspace root first.`
)
}
rmrf(spec.to)
ensureDir(spec.to)
const files = walk(spec.from)
let copied = 0
for (const abs of files) {
const rel = path.relative(spec.from, abs)
const included = spec.include.some(g => matchGlob(rel, g))
if (!included) continue
const dest = path.join(spec.to, rel)
ensureDir(path.dirname(dest))
fs.copyFileSync(abs, dest)
// node-pty's darwin spawn-helper and the Windows helper binaries
// (OpenConsole.exe, winpty-agent.exe) are invoked via posix_spawn /
// CreateProcess at runtime, so they must remain executable in the
// staged tree. fs.copyFileSync preserves source mode on POSIX, but we
// re-assert +x defensively for the darwin spawn-helper (no extension
// means a stripped mode would be silently broken at runtime).
if (path.basename(rel) === 'spawn-helper' && process.platform !== 'win32') {
try { fs.chmodSync(dest, 0o755) } catch { /* best-effort */ }
}
copied += 1
}
console.log(`[stage-native-deps] ${path.relative(APP_ROOT, spec.to)}: ${copied} files`)
}
// Resolve a package's directory by name, searching the repo-root node_modules
// first (where workspace dedup hoists everything) and then the requiring
// package's own node_modules for any non-hoisted nested copy.
//
// We deliberately do NOT use require.resolve(`${name}/package.json`): packages
// with an "exports" map that doesn't list "./package.json" (e.g. simple-git
// 3.x) make that subpath unresolvable under Node's exports enforcement
// (ERR_PACKAGE_PATH_NOT_EXPORTED), which fails on CI even though it happened to
// work locally. Instead resolve the package's main entry (exports-aware) and
// walk up to the directory whose package.json's "name" matches.
function resolvePkgDir(name, fromDir) {
const searchPaths = [fromDir, REPO_ROOT, path.join(REPO_ROOT, 'node_modules')]
let entry
try {
entry = require.resolve(name, { paths: searchPaths })
} catch {
return null
}
// Walk up from the resolved entry file to the package root: the first
// ancestor dir whose package.json declares this package's name.
let dir = path.dirname(entry)
while (true) {
const pjPath = path.join(dir, 'package.json')
try {
const pj = JSON.parse(fs.readFileSync(pjPath, 'utf8'))
if (pj.name === name) {
return dir
}
} catch {
// no package.json here (or unreadable) — keep walking up
}
const parent = path.dirname(dir)
if (parent === dir) {
return null
}
dir = parent
}
}
// Walk dependencies + optionalDependencies from each root package and return
// the set of resolved package directories in the runtime closure. Keyed by
// package name so a dep reached via two paths is staged once.
function resolveJsClosure(roots) {
const closure = new Map() // name -> absolute package dir
const stack = roots.map(name => ({ name, fromDir: REPO_ROOT }))
while (stack.length) {
const { name, fromDir } = stack.pop()
if (closure.has(name)) continue
const dir = resolvePkgDir(name, fromDir)
if (!dir) {
throw new Error(
`stage-native-deps: could not resolve '${name}' for the simple-git ` +
`closure. Run \`npm install\` at the workspace root first.`
)
}
closure.set(name, dir)
let pj
try {
pj = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
} catch {
continue
}
const deps = { ...(pj.dependencies || {}), ...(pj.optionalDependencies || {}) }
for (const depName of Object.keys(deps)) {
stack.push({ name: depName, fromDir: dir })
}
}
return closure
}
// Stage the resolved JS dependency closure into build/native-deps/vendor/node_modules/
// so the packaged app (and the nix output) can require() it from
// process.resourcesPath when the hoisted-root require() isn't reachable. Each
// package is copied whole (minus node_modules/ — the closure is flattened so
// every dep already has its own top-level entry) into a real node_modules
// layout, which keeps the deps' own internal require()s working unchanged.
function stageJsClosure(roots) {
const closure = resolveJsClosure(roots)
rmrf(JS_DEP_STAGE_ROOT)
ensureDir(JS_DEP_STAGE_ROOT)
let staged = 0
for (const [name, fromDir] of closure) {
const dest = path.join(JS_DEP_STAGE_ROOT, name)
ensureDir(path.dirname(dest))
// Copy the package directory but skip any nested node_modules/ — the
// closure is flattened, so nested copies would just bloat the bundle.
fs.cpSync(fromDir, dest, {
recursive: true,
filter: src => path.basename(src) !== 'node_modules'
})
staged += 1
}
console.log(
`[stage-native-deps] vendor/node_modules/: ${staged} package(s) ` +
`(${[...closure.keys()].sort().join(', ')})`
)
}
function main() {
rmrf(STAGE_ROOT)
ensureDir(STAGE_ROOT)
for (const spec of NATIVE_DEPS) {
stageOne(spec)
}
stageJsClosure(JS_DEP_ROOTS)
}
main()

View file

@ -0,0 +1,137 @@
#!/usr/bin/env node
// stage-native-deps.mjs — stages node-pty's native runtime dependencies
//
// Usage:
// node scripts/stage-native-deps.mjs # host platform/arch
// node scripts/stage-native-deps.mjs win32 arm64 # explicit target
//
// Also exported as `stageNodePty({ platform, arch })` for use from
// before-pack.mjs, where electron-builder gives you the real per-target
// platform/arch during multi-arch builds.
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { dirname, resolve, join } from 'node:path'
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
rmSync
} from 'node:fs'
import { isMain } from './utils.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const projectRoot = resolve(here, '..')
const require = createRequire(import.meta.url)
/**
* Locate node-pty's package root via real module resolution, so this
* works whether it's hoisted to a workspace root or local to this app.
*/
function resolveNodePtyRoot() {
const pkgJsonPath = require.resolve('node-pty/package.json', {
paths: [projectRoot]
})
return dirname(pkgJsonPath)
}
function copyGlobByExt(srcDir, destDir, extensions) {
if (!existsSync(srcDir)) return
mkdirSync(destDir, { recursive: true })
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
copyGlobByExt(join(srcDir, entry.name), join(destDir, entry.name), extensions)
continue
}
if (extensions.some((ext) => entry.name.endsWith(ext))) {
mkdirSync(destDir, { recursive: true })
cpSync(join(srcDir, entry.name), join(destDir, entry.name))
}
}
}
/**
* Copies the locally-compiled build/Release output (used when no prebuild
* was available and node-pty was built from source for the host machine).
*
* Filters by name/pattern rather than extension only: macOS builds a
* separate `spawn-helper` executable (no file extension) that
* lib/unixTerminal.js requires at a fixed relative path. Filtering this
* directory by ['.node'] silently drops it the package then looks
* fine, ships fine, and crashes the first time a terminal is spawned.
* Directories are copied wholesale to also cover any nested native
* payload (e.g. a conpty/ subfolder some build layouts produce).
*/
function copyBuildRelease(srcDir, destDir) {
if (!existsSync(srcDir)) return
mkdirSync(destDir, { recursive: true })
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
cpSync(join(srcDir, entry.name), join(destDir, entry.name), { recursive: true })
continue
}
if (entry.name === 'spawn-helper' || /\.(node|dll|exe)$/.test(entry.name)) {
cpSync(join(srcDir, entry.name), join(destDir, entry.name))
}
}
}
export function stageNodePty({ platform = process.platform, arch = process.arch } = {}) {
const srcRoot = resolveNodePtyRoot()
const destRoot = resolve(projectRoot, 'dist/node_modules/node-pty')
rmSync(destRoot, { recursive: true, force: true })
mkdirSync(destRoot, { recursive: true })
// package.json — needed so `require('node-pty')` resolves the package
// (reads "main") rather than treating it as a directory with no entry.
cpSync(join(srcRoot, 'package.json'), join(destRoot, 'package.json'))
// lib/**/*.js — the JS surface node-pty's `main` points into.
copyGlobByExt(join(srcRoot, 'lib'), join(destRoot, 'lib'), ['.js'])
// build/Release/* — present when node-pty was compiled locally
// (e.g. no prebuild available for this Electron ABI/platform combo).
// Some installs won't have this at all if prebuild-install succeeded.
copyBuildRelease(join(srcRoot, 'build/Release'), join(destRoot, 'build/Release'))
// prebuilds/<platform>-<arch>/* — the prebuild-install payload for the
// *target* we're packaging, not necessarily the host running this script.
// Explicit extensions only, to skip the ~25MB of Windows .pdb symbols
// prebuild-install bundles alongside the .node/.dll.
const prebuildDir = join(srcRoot, 'prebuilds', `${platform}-${arch}`)
if (existsSync(prebuildDir)) {
const destPrebuild = join(destRoot, 'prebuilds', `${platform}-${arch}`)
mkdirSync(destPrebuild, { recursive: true })
for (const entry of readdirSync(prebuildDir, { withFileTypes: true })) {
if (entry.name === 'conpty' && entry.isDirectory()) {
cpSync(join(prebuildDir, 'conpty'), join(destPrebuild, 'conpty'), { recursive: true })
continue
}
if (entry.isFile() && /\.(node|dll|exe)$/.test(entry.name)) {
cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name))
continue
}
if (entry.name === 'spawn-helper') {
cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name))
}
}
} else {
console.warn(
`[stage-native-deps] no prebuild found at prebuilds/${platform}-${arch} for node-pty. ` +
`If build/Release/* above is also empty, this target will fail at runtime. ` +
`Run "npx electron-rebuild -w node-pty" for this target, or check that ` +
`node-pty's published prebuilds cover ${platform}-${arch}.`
)
}
console.log(`[stage-native-deps] staged node-pty (${platform}-${arch}) -> ${destRoot}`)
return destRoot
}
// Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch]
if (isMain(import.meta.url)) {
const [platform, arch] = process.argv.slice(2)
stageNodePty({ platform, arch })
}

View file

@ -5,10 +5,11 @@ import { spawn, spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { listPackage } from '@electron/asar' import { listPackage } from '@electron/asar'
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') import PACKAGE_JSON from '../package.json' with { type: 'json' }
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8'))
const MODE = process.argv[2] || 'help' const MODE = process.argv[2] || 'help'
const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64' const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release') const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
const PLATFORM = process.platform const PLATFORM = process.platform
@ -48,7 +49,7 @@ const APP = (() => {
} }
})() })()
// Default HERMES_HOME for non-sandboxed runs -- matches main.cjs's // Default HERMES_HOME for non-sandboxed runs -- matches main.ts's
// resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere // resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere
// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own // it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own
// HERMES_HOME and never touches this. // HERMES_HOME and never touches this.
@ -83,17 +84,23 @@ function exists(target) {
return fs.existsSync(target) return fs.existsSync(target)
} }
// Match nodepty native binding location to what main.cjs's resolver fallback // Match node-pty native binding location to what the bundled electron-main.cjs
// expects (apps/desktop/electron/main.cjs, packaged-build branch). Upstream // resolves at runtime. stage-native-deps.mjs stages node-pty into
// node-pty 1.x is N-API based and ships per-arch prebuilts under // dist/node_modules/node-pty, and dist/** is asarUnpacked (see package.json
// prebuilds/<platform>-<arch>/ instead of build/Release/. We check the // build.asarUnpack), so in a packaged build it lands under
// per-arch dir since that's what stage-native-deps actually copies. // resources/app.asar.unpacked/dist/node_modules/node-pty — reachable by a bare
// require('node-pty') from the bundle. Upstream node-pty 1.x is N-API based and
// ships per-arch prebuilts under prebuilds/<platform>-<arch>/; nix/local builds
// instead compile from source into build/Release/. The stage script copies
// whichever is present, so we accept either as the native payload.
function expectedNativeDepPaths() { function expectedNativeDepPaths() {
const root = path.join(APP.resourcesPath, 'native-deps', 'node-pty') const root = path.join(APP.resourcesPath, 'app.asar.unpacked', 'dist', 'node_modules', 'node-pty')
const prebuildsDir = path.join(root, 'prebuilds', `${PLATFORM}-${ARCH}`) const prebuildsDir = path.join(root, 'prebuilds', `${PLATFORM}-${ARCH}`)
const buildReleaseDir = path.join(root, 'build', 'Release')
return { return {
packageJson: path.join(root, 'package.json'), packageJson: path.join(root, 'package.json'),
prebuildsDir, prebuildsDir,
buildReleaseDir,
libIndex: path.join(root, 'lib', 'index.js') libIndex: path.join(root, 'lib', 'index.js')
} }
} }
@ -279,8 +286,8 @@ function launchFresh() {
// - The Hermes Agent Python payload is NOT shipped (it's fetched at first // - The Hermes Agent Python payload is NOT shipped (it's fetched at first
// launch via install.ps1's stage protocol). // launch via install.ps1's stage protocol).
// - install-stamp.json IS shipped in resources/ with a valid commit + branch. // - install-stamp.json IS shipped in resources/ with a valid commit + branch.
// - native-deps/@homebridge/node-pty-prebuilt-multiarch/ IS shipped with // - node-pty IS shipped inside app.asar.unpacked/dist/node_modules/node-pty
// the package.json + lib/ + at least one .node binary (the renderer's // with package.json + lib/ + at least one .node binary (the renderer's
// integrated terminal needs this; see Phase 1F.6). // integrated terminal needs this; see Phase 1F.6).
// - The renderer's dist/index.html is reachable (either unpacked or // - The renderer's dist/index.html is reachable (either unpacked or
// inside app.asar). // inside app.asar).
@ -320,24 +327,35 @@ function validateBundle() {
// Positive assertion: node-pty native deps shipped // Positive assertion: node-pty native deps shipped
const native = expectedNativeDepPaths() const native = expectedNativeDepPaths()
if (!exists(native.packageJson)) { if (!exists(native.packageJson)) {
die(`Missing node-pty package.json in resources/native-deps: ${native.packageJson}`) die(`Missing node-pty package.json in app.asar.unpacked: ${native.packageJson}`)
} }
if (!exists(native.libIndex)) { if (!exists(native.libIndex)) {
die(`Missing node-pty lib/index.js in resources/native-deps: ${native.libIndex}`) die(`Missing node-pty lib/index.js in app.asar.unpacked: ${native.libIndex}`)
} }
if (!exists(native.prebuildsDir)) { // The native binary lands in prebuilds/<platform>-<arch>/ (downloaded prebuild)
die(`Missing node-pty prebuilds dir for ${PLATFORM}-${ARCH}: ${native.prebuildsDir}`) // OR build/Release/ (compiled from source). stage-native-deps.mjs copies
// whichever is present, so accept either.
const nativeBinaryDirs = [native.prebuildsDir, native.buildReleaseDir].filter(exists)
if (nativeBinaryDirs.length === 0) {
die(
`Missing node-pty native binary dir for ${PLATFORM}-${ARCH}: neither ` +
`${native.prebuildsDir} nor ${native.buildReleaseDir} exists`
)
} }
const nodeBinaries = fs.readdirSync(native.prebuildsDir).filter(name => name.endsWith('.node')) const nodeBinaries = nativeBinaryDirs.flatMap(dir =>
fs.readdirSync(dir).filter(name => name.endsWith('.node'))
)
if (nodeBinaries.length === 0) { if (nodeBinaries.length === 0) {
die(`No .node native binaries found in: ${native.prebuildsDir}`) die(`No .node native binaries found in: ${nativeBinaryDirs.join(', ')}`)
} }
// Darwin requires a runtime-execed spawn-helper alongside pty.node; missing // Darwin requires a runtime-execed spawn-helper alongside pty.node; missing
// it manifests as "ENOENT: spawn-helper" on first pty.spawn() call. // it manifests as "ENOENT: spawn-helper" on first pty.spawn() call.
if (PLATFORM === 'darwin') { if (PLATFORM === 'darwin') {
const spawnHelper = path.join(native.prebuildsDir, 'spawn-helper') const spawnHelper = nativeBinaryDirs
if (!exists(spawnHelper)) { .map(dir => path.join(dir, 'spawn-helper'))
die(`Missing node-pty spawn-helper (required on darwin): ${spawnHelper}`) .find(exists)
if (!spawnHelper) {
die(`Missing node-pty spawn-helper (required on darwin) in: ${nativeBinaryDirs.join(', ')}`)
} }
} }

View file

@ -0,0 +1,8 @@
import { pathToFileURL } from 'node:url';
// returns true if the passsed file is being invoked from node,
// not imported.
export function isMain(importMetaUrl) {
return importMetaUrl === pathToFileURL(process.argv[1]).href;
}

View file

@ -4,7 +4,7 @@
* Writes apps/desktop/build/install-stamp.json with the git ref the desktop * Writes apps/desktop/build/install-stamp.json with the git ref the desktop
* .exe should pin to at first-launch bootstrap time. This file ships inside * .exe should pin to at first-launch bootstrap time. This file ships inside
* the packaged app via electron-builder's extraResources entry and is read * the packaged app via electron-builder's extraResources entry and is read
* by electron/main.cjs to drive the install.ps1 stage bootstrap flow. * by electron/main.ts to drive the install.ps1 stage bootstrap flow.
* *
* Schema (subject to bump via STAMP_SCHEMA_VERSION): * Schema (subject to bump via STAMP_SCHEMA_VERSION):
* { * {
@ -26,16 +26,16 @@
* bootstrap without a stamp. * bootstrap without a stamp.
*/ */
const fs = require("fs") import { mkdirSync, writeFileSync } from "fs"
const path = require("path") import { resolve, join, relative } from "path"
const { execSync } = require("child_process") import { execSync } from "child_process"
const STAMP_SCHEMA_VERSION = 1 const STAMP_SCHEMA_VERSION = 1
const DESKTOP_ROOT = path.resolve(__dirname, "..") const DESKTOP_ROOT = resolve(import.meta.dirname, "..")
const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..") const REPO_ROOT = resolve(DESKTOP_ROOT, "..", "..")
const OUT_DIR = path.join(DESKTOP_ROOT, "build") const OUT_DIR = join(DESKTOP_ROOT, "build")
const OUT_FILE = path.join(OUT_DIR, "install-stamp.json") const OUT_FILE = join(OUT_DIR, "install-stamp.json")
function tryExec(cmd, opts) { function tryExec(cmd, opts) {
try { try {
@ -111,11 +111,11 @@ function main() {
source: stamp.source source: stamp.source
} }
fs.mkdirSync(OUT_DIR, { recursive: true }) mkdirSync(OUT_DIR, { recursive: true })
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8") writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8")
console.log( console.log(
"[write-build-stamp] wrote " + "[write-build-stamp] wrote " +
path.relative(REPO_ROOT, OUT_FILE) + relative(REPO_ROOT, OUT_FILE) +
" -> " + " -> " +
stamp.commit.slice(0, 12) + stamp.commit.slice(0, 12) +
(stamp.branch ? " (" + stamp.branch + ")" : "") + (stamp.branch ? " (" + stamp.branch + ")" : "") +

View file

@ -847,7 +847,7 @@ export function DesktopController() {
// window's gateway (the overlay has none) so it survives restart. // window's gateway (the overlay has none) so it survives restart.
setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale)) setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale))
// Mail icon: $sessions is ordered most-recent-first; the pet is global (not // Mail icon: $sessions is ordered most-recent-first; the pet is global (not
// per session) so "most recent" is the right target. main.cjs already raised // per session) so "most recent" is the right target. main.ts already raised
// the window before forwarding this. // the window before forwarding this.
setPetOverlayOpenAppHandler(() => { setPetOverlayOpenAppHandler(() => {
const recent = $sessions.get()[0] const recent = $sessions.get()[0]

View file

@ -1422,7 +1422,7 @@ describe('uploadComposerAttachment remote read failures', () => {
}) })
it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => { it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => {
// electron/hardening.cjs rejects the readFileDataUrl IPC with this exact // electron/hardening.ts rejects the readFileDataUrl IPC with this exact
// shape when a file exceeds DATA_URL_READ_MAX_BYTES. // shape when a file exceeds DATA_URL_READ_MAX_BYTES.
Object.defineProperty(window, 'hermesDesktop', { Object.defineProperty(window, 'hermesDesktop', {
configurable: true, configurable: true,

View file

@ -21,9 +21,9 @@ import {
_submitInFlight, _submitInFlight,
type GatewayRequest, type GatewayRequest,
inlineErrorMessage, inlineErrorMessage,
isGatewayTimeoutError,
isProviderSetupError, isProviderSetupError,
isSessionBusyError, isSessionBusyError,
isGatewayTimeoutError,
isSessionNotFoundError, isSessionNotFoundError,
type SubmitTextOptions, type SubmitTextOptions,
withSessionBusyRetry withSessionBusyRetry

View file

@ -142,7 +142,7 @@ export async function readFileDataUrlForAttach(filePath: string): Promise<string
} }
// The readFileDataUrl IPC base64-loads the whole file into memory and is // The readFileDataUrl IPC base64-loads the whole file into memory and is
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which // hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.ts, which
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In // rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
// remote mode every attachment's bytes go through that read, so a big file // remote mode every attachment's bytes go through that read, so a big file
// surfaces that internal message verbatim in the failure toast. Translate it // surfaces that internal message verbatim in the failure toast. Translate it

Some files were not shown because too many files have changed in this diff Show more