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.
//!
//! 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
//! over the Tauri `bootstrap` channel, writes a forensic log to
//! HERMES_HOME/logs/bootstrap-<timestamp>.log.

View file

@ -1,6 +1,6 @@
//! 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
//! install-overlay we'll replace.
//!

View file

@ -8,7 +8,7 @@
//! 3. Network: download from GitHub raw at a pinned commit or branch.
//! 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
//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant
//! to live OUTSIDE any repo checkout.
@ -64,7 +64,7 @@ impl ScriptKind {
}
/// 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 {
let len = s.len();
(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) {}
/// 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')
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
/// 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.
//!
//! 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.
//!
//! 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>,
}
/// 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.
#[derive(Debug)]
pub struct ScriptResult {
@ -258,7 +258,7 @@ fn interpreter_label() -> String {
/// Parses the LAST line of stdout that looks like a JSON object matching
/// 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.
pub fn parse_stage_result(stdout: &str) -> Option<crate::events::StageResultPayload> {
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
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

View file

@ -1,9 +1,9 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from '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', () => {
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")
from hermes_cli.web_server import start_server # web server
`
assert.equal(sourceDeclaresServe(oldSource), false)
})

View file

@ -17,8 +17,9 @@
* Build the canonical headless backend argv (always `serve`).
* @param {string} [profile] optional Hermes profile to pin via `--profile`.
*/
function serveBackendArgs(profile) {
export function serveBackendArgs(profile?: string) {
const head = profile ? ['--profile', profile] : []
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
* no `serve` token the argv is returned unchanged.
*/
function dashboardFallbackArgs(args) {
export function dashboardFallbackArgs(args) {
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)]
}
@ -40,12 +43,6 @@ function dashboardFallbackArgs(args) {
* specifically so the substring "server" (e.g. "start_server", "web server")
* never produces a false positive.
*/
function sourceDeclaresServe(dashboardPySource) {
export function sourceDeclaresServe(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')
const assert = require('node:assert/strict')
const path = require('node:path')
import assert from 'node:assert/strict'
import path from 'node:path'
import test from 'node:test'
const {
POSIX_SANE_PATH_ENTRIES,
appendUniquePathEntries,
import { appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
normalizeHermesHomeRoot,
pathEnvKey
} = require('./backend-env.cjs')
pathEnvKey,
POSIX_SANE_PATH_ENTRIES } from './backend-env'
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
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.
// 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) {
if (platform !== 'win32') return 'PATH'
if (platform !== 'win32') {return 'PATH'}
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
}
function currentPathValue(env = process.env, platform = process.platform) {
const key = pathEnvKey(env, platform)
return env?.[key] || ''
}
@ -37,10 +39,11 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
const ordered = []
for (const entry of entries) {
if (!entry) continue
if (!entry) {continue}
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
for (const part of parts) {
if (!part || seen.has(part)) continue
if (!part || seen.has(part)) {continue}
seen.add(part)
ordered.push(part)
}
@ -55,7 +58,7 @@ function buildDesktopBackendPath({
currentPath = '',
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
} = {}) {
}: any = {}) {
const delimiter = delimiterForPlatform(platform)
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', '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 })
}
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {
if (!hermesHome) return hermesHome
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) }: any = {}) {
if (!hermesHome) {return hermesHome}
const resolved = pathModule.resolve(String(hermesHome))
const parent = pathModule.dirname(resolved)
if (pathModule.basename(parent).toLowerCase() === 'profiles') {
return pathModule.dirname(parent)
}
return resolved
}
@ -81,7 +86,7 @@ function buildDesktopBackendEnv({
currentEnv = process.env,
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
} = {}) {
}: any = {}) {
const delimiter = delimiterForPlatform(platform)
const currentPythonPath = currentEnv?.PYTHONPATH || ''
const key = pathEnvKey(currentEnv, platform)
@ -98,12 +103,10 @@ function buildDesktopBackendEnv({
}
}
module.exports = {
POSIX_SANE_PATH_ENTRIES,
appendUniquePathEntries,
export { appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
delimiterForPlatform,
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.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
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'
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
// 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.
const scriptPath = path.join(os.tmpdir(), `hermes-probes-ok-${Date.now()}-${process.pid}.cjs`)
fs.writeFileSync(scriptPath, 'process.exit(0)\n')
try {
// Use node as the launcher and our script as the "command". Pass
// 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
* 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
* hermes_cli installed -- and historically returned the first candidate
* 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)
* - 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
* 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
@ -62,12 +62,12 @@ function hermesRuntimeImportProbe() {
* through PYTHONPATH but lack PyYAML, then die on the first real CLI import.
*
* @param {string} pythonPath - Absolute path to a python.exe / python.
* @param {object} [opts]
* @param {object} [opts.env] - Additional environment for the probe.
* @returns {boolean}
*/
function canImportHermesCli(pythonPath, opts = {}) {
if (!pythonPath) return false
function canImportHermesCli(pythonPath: string, opts:{env?: Record<string, string>} = {}) {
if (!pythonPath) {return false}
try {
execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
env: { ...process.env, ...(opts.env || {}) },
@ -75,6 +75,7 @@ function canImportHermesCli(pythonPath, opts = {}) {
timeout: PROBE_TIMEOUT_MS,
windowsHide: true
})
return true
} catch {
return false
@ -95,31 +96,30 @@ function canImportHermesCli(pythonPath, opts = {}) {
*
* @param {string} hermesCommand - Resolved absolute path to a hermes
* executable (or an interpreter+script wrapper).
* @param {object} [opts]
* @param {boolean} [opts.shell] - Whether to run through a shell. For
* .cmd/.bat shims on Windows execFileSync needs shell:true to find
* the cmd interpreter; mirrors the same flag isCommandScript() drives
* in resolveHermesBackend.
* @returns {boolean}
*/
function verifyHermesCli(hermesCommand, opts = {}) {
if (!hermesCommand) return false
function verifyHermesCli(hermesCommand: string, opts?: {shell?: boolean}) {
if (!hermesCommand) {return false}
try {
execFileSync(hermesCommand, ['--version'], {
stdio: 'ignore',
timeout: PROBE_TIMEOUT_MS,
shell: Boolean(opts.shell),
shell: Boolean(opts?.shell),
windowsHide: true
})
return true
} catch {
return false
}
}
module.exports = {
canImportHermesCli,
export { canImportHermesCli,
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.)
*
* 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.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { EventEmitter } = require('node:events')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
const {
import { DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS
} = require('./backend-ready.cjs')
waitForDashboardReadyFile } from './backend-ready'
type FakeChildProcess = EventEmitter & {
stdout: EventEmitter
}
// A minimal stand-in for a spawned child process: an EventEmitter with a
// stdout EventEmitter, matching the surface waitForDashboardPort consumes
// (child.stdout.on('data'), child.on('exit'|'error') + the .off() teardown).
function makeFakeChild() {
const child = new EventEmitter()
function makeFakeChild(): FakeChildProcess {
const child = new EventEmitter() as FakeChildProcess
child.stdout = new EventEmitter()
return child
}
@ -139,6 +142,7 @@ test('a late announcement after timeout does not throw (listeners torn down)', a
function mkTmpReadyFile() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-'))
return {
dir,
file: path.join(dir, 'ready.json'),
@ -148,6 +152,7 @@ function mkTmpReadyFile() {
test('readDashboardReadyFile returns a valid port from JSON', () => {
const tmp = mkTmpReadyFile()
try {
fs.writeFileSync(tmp.file, JSON.stringify({ port: 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', () => {
const tmp = mkTmpReadyFile()
try {
assert.equal(readDashboardReadyFile(tmp.file), null)
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 () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
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 () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 })
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 () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
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`
// 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) {
const parsed = Number(env.HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS)
if (Number.isFinite(parsed) && parsed > 0) {
return Math.max(MIN_PORT_ANNOUNCE_TIMEOUT_MS, Math.round(parsed))
}
return DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS
}
@ -55,7 +57,7 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
let done = false
function cleanup() {
if (done) return
if (done) {return}
done = true
clearTimeout(timer)
child.stdout.off('data', onData)
@ -66,13 +68,16 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
function onData(chunk) {
buf += chunk.toString()
let nl
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl)
buf = buf.slice(nl + 1)
const m = line.match(_READY_RE)
if (m) {
cleanup()
resolve(parseInt(m[1], 10))
return
}
}
@ -99,11 +104,13 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
})
}
function readDashboardReadyFile(readyFile) {
if (!readyFile) return null
function readDashboardReadyFile(readyFile: fs.PathOrFileDescriptor) {
if (!readyFile) {return null}
try {
const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8'))
const port = Number(parsed?.port)
return Number.isInteger(port) && port > 0 ? port : null
} catch {
return null
@ -116,16 +123,18 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
let interval = null
function cleanup() {
if (done) return
if (done) {return}
done = true
clearTimeout(timer)
if (interval) clearInterval(interval)
if (interval) {clearInterval(interval)}
child.off('exit', onExit)
child.off('error', onError)
}
function check() {
const port = readDashboardReadyFile(readyFile)
if (port) {
cleanup()
resolve(port)
@ -150,25 +159,29 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
child.on('exit', onExit)
child.on('error', onError)
interval = setInterval(check, 50)
if (typeof interval.unref === 'function') interval.unref()
if (typeof interval.unref === 'function') {interval.unref()}
check()
})
}
function waitForDashboardPortAnnouncement(child, options = {}) {
function waitForDashboardPortAnnouncement(child, options: {
readyFile?: fs.PathOrFileDescriptor,
timeoutMs?: number
} = {}) {
const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs()
if (options.readyFile) {
return waitForDashboardReadyFile(options.readyFile, child, timeoutMs)
}
return waitForDashboardPort(child, timeoutMs)
}
module.exports = {
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile,
export { DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS
}
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile }

View file

@ -1,14 +1,10 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
import assert from 'node:assert/strict'
import test from 'node:test'
const {
bundledRuntimeImportCheck,
import { bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment
} = require('./bootstrap-platform.cjs')
isWslEnvironment } from './bootstrap-platform'
test('isWslEnvironment detects WSL2 env vars on linux', () => {
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
)
})
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) {
if (platform !== 'linux') return false
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true
if (platform !== 'linux') {return false}
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {return true}
try {
const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8')
return /microsoft|wsl/i.test(release)
} catch {
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)
if (!isWsl) return false
if (!isWsl) {return false}
const normalized = String(filePath || '')
.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.
*/
function detectRemoteDisplay(options = {}) {
function detectRemoteDisplay(options: {env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform} = {}) {
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
.trim()
.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
// 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') {
// 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
// GPU-accelerated vGPU surfaces locally and doesn't show the flicker.
const display = String(env.DISPLAY || '')
if (display.includes(':') && display.split(':')[0]) {
return `x11-forwarding (DISPLAY=${display})`
}
@ -77,15 +83,14 @@ function detectRemoteDisplay(options = {}) {
// RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is
// "Console".
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
}
module.exports = {
bundledRuntimeImportCheck,
export { bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment
}
isWslEnvironment }

View file

@ -1,15 +1,13 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
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'
const {
runBootstrap,
resolveInstallScript,
import { cachedScriptPath,
installedAgentInstallScript,
cachedScriptPath
} = require('./bootstrap-runner.cjs')
resolveInstallScript,
runBootstrap } from './bootstrap-runner'
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()
const events = []
const result = await runBootstrap({
installStamp: null,
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', () => {
const home = mkTmpHome()
try {
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 () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
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')
const logs = []
const result = await resolveInstallScript({
installStamp: { commit },
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 () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
// 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')
const logs = []
const result = await resolveInstallScript({
installStamp: { commit },
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 () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
// 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
* scripts/install.ps1 stage-by-stage and streaming progress events back to
* the renderer.
*
* Wired from electron/main.cjs:
* const { runBootstrap } = require('./bootstrap-runner.cjs')
* Wired from electron/main.ts:
* import { runBootstrap }from './bootstrap-runner.ts'
* 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
* sourceRepoRoot, // SOURCE_REPO_ROOT (for dev install.ps1 lookup)
* hermesHome, // HERMES_HOME
@ -34,11 +32,11 @@
* no UI consumes them yet)
*/
const fs = require('node:fs')
const fsp = require('node:fs/promises')
const path = require('node:path')
const https = require('node:https')
const { spawn } = require('node:child_process')
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import https from 'node:https'
import path from 'node:path'
const IS_WINDOWS = process.platform === 'win32'
@ -46,6 +44,7 @@ function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
@ -71,10 +70,12 @@ function installScriptKind() {
}
function resolveLocalInstallScript(sourceRepoRoot) {
if (!sourceRepoRoot) return null
if (!sourceRepoRoot) {return null}
const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName())
try {
fs.accessSync(candidate, fs.constants.R_OK)
return candidate
} catch {
return null
@ -90,10 +91,12 @@ function bootstrapCacheDir(hermesHome) {
// the pinned commit can't be fetched from GitHub (e.g. a locally-built desktop
// app stamped to an unpushed HEAD).
function installedAgentInstallScript(hermesHome) {
if (!hermesHome) return null
if (!hermesHome) {return null}
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
try {
fs.accessSync(candidate, fs.constants.R_OK)
return candidate
} catch {
return null
@ -110,6 +113,7 @@ function downloadInstallScript(commit, destPath) {
// verification beyond "did the file we wrote pass a syntax probe."
const scriptName = installScriptName()
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/${scriptName}`
return new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(destPath), { recursive: true })
const tmpPath = destPath + '.tmp'
@ -129,8 +133,10 @@ function downloadInstallScript(commit, destPath) {
`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`
)
)
return
}
const out2 = fs.createWriteStream(tmpPath)
res2.pipe(out2)
out2.on('finish', () => {
@ -141,18 +147,24 @@ function downloadInstallScript(commit, destPath) {
out2.on('error', reject)
})
.on('error', reject)
return
}
if (res.statusCode !== 200) {
out.close()
try {
fs.unlinkSync(tmpPath)
} catch {
void 0
}
reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`))
return
}
res.pipe(out)
out.on('finish', () => {
out.close()
@ -165,6 +177,7 @@ function downloadInstallScript(commit, destPath) {
} catch {
void 0
}
reject(err)
})
})
@ -174,6 +187,7 @@ function downloadInstallScript(commit, destPath) {
} catch {
void 0
}
reject(err)
})
})
@ -187,11 +201,13 @@ async function resolveInstallScript({
_download = downloadInstallScript
}) {
// 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/../..).
const localScript = resolveLocalInstallScript(sourceRepoRoot)
if (localScript) {
emit({ type: 'log', line: `[bootstrap] using local ${installScriptName()} at ${localScript}` })
return { path: localScript, source: 'local', kind: installScriptKind() }
}
@ -204,12 +220,14 @@ async function resolveInstallScript({
}
const cached = cachedScriptPath(hermesHome, installStamp.commit)
try {
await fsp.access(cached, fs.constants.R_OK)
emit({
type: 'log',
line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}`
})
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
} catch {
// not cached; download
@ -219,17 +237,20 @@ async function resolveInstallScript({
type: 'log',
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
})
try {
await _download(installStamp.commit, cached)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
} catch (err) {
// The pinned commit may not be fetchable from GitHub -- most commonly a
// 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
// still bootstrap instead of dying with a fatal 404.
const installed = installedAgentInstallScript(hermesHome)
if (installed) {
emit({
type: 'log',
@ -237,15 +258,18 @@ async function resolveInstallScript({
`[bootstrap] GitHub fetch failed (${err.message}); ` +
`falling back to installed agent ${installScriptName()} at ${installed}`
})
try {
fs.mkdirSync(path.dirname(cached), { recursive: true })
fs.copyFileSync(installed, cached)
return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
} catch {
// Cache copy failed (read-only FS, etc.) -- use the source path directly.
return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
}
}
throw err
}
}
@ -271,31 +295,37 @@ function powershellUnderRoot(root) {
function resolveWindowsPowerShell() {
for (const v of ['SystemRoot', 'windir']) {
const root = process.env[v]
if (root) {
const candidate = powershellUnderRoot(root)
try {
if (fs.statSync(candidate).isFile()) return candidate
if (fs.statSync(candidate).isFile()) {return candidate}
} catch {
void 0
}
}
}
const pathDirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean)
for (const exe of ['powershell.exe', 'pwsh.exe']) {
for (const dir of pathDirs) {
const candidate = path.join(dir, exe)
try {
if (fs.statSync(candidate).isFile()) return candidate
if (fs.statSync(candidate).isFile()) {return candidate}
} catch {
void 0
}
}
}
return 'powershell.exe'
}
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
return new Promise((resolve, reject) => {
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
return new Promise<any>((resolve, reject) => {
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
@ -319,12 +349,14 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const onAbort = () => {
killed = true
try {
child.kill('SIGTERM')
} catch {
void 0
}
}
if (abortSignal) {
if (abortSignal.aborted) {
onAbort()
@ -342,10 +374,12 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
stdout += chunk
stdoutBuf += chunk
let nl
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
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
stderrBuf += chunk
let nl
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
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 => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
if (abortSignal) {abortSignal.removeEventListener('abort', onAbort)}
reject(err)
})
child.on('close', (code, signal) => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
if (abortSignal) {abortSignal.removeEventListener('abort', onAbort)}
// Flush any trailing bytes
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 })
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' } as any)}
resolve({ stdout, stderr, code, signal, killed } as any)
})
})
}
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
return new Promise((resolve, reject) => {
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
return new Promise<any>((resolve, reject) => {
const child = spawn('bash', [scriptPath, ...args], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
@ -392,12 +430,14 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
const onAbort = () => {
killed = true
try {
child.kill('SIGTERM')
} catch {
void 0
}
}
if (abortSignal) {
if (abortSignal.aborted) {
onAbort()
@ -414,10 +454,12 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
stdout += chunk
stdoutBuf += chunk
let nl
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
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
stderrBuf += chunk
let nl
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
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 => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
if (abortSignal) {abortSignal.removeEventListener('abort', onAbort)}
reject(err)
})
child.on('close', (code, signal) => {
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 (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' })}
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").
function buildPinArgs(installStamp) {
const args = []
if (installStamp && installStamp.commit) {
args.push('-Commit', installStamp.commit)
}
if (installStamp && installStamp.branch) {
args.push('-Branch', installStamp.branch)
}
return args
}
function buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) {
const args = ['--dir', activeRoot, '--hermes-home', hermesHome]
if (installStamp && installStamp.branch) {
args.push('--branch', installStamp.branch)
}
if (installStamp && installStamp.commit) {
args.push('--commit', installStamp.commit)
}
return args
}
async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp }) {
const isPosix = installerKind === 'posix'
const args = isPosix
? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
: ['-Manifest', ...buildPinArgs(installStamp)]
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit,
stageName: '__manifest__',
hermesHome
})
if (result.code !== 0) {
throw new Error(
`${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
// banner / info lines first depending on Console.OutputEncoding effects).
// Find the last line that parses as JSON with a `stages` field.
const lines = result.stdout.split(/\r?\n/).filter(Boolean)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i])
if (parsed && Array.isArray(parsed.stages)) {
return parsed
}
@ -505,6 +563,7 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
void 0
}
}
throw new Error(
`${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).
function parseStageResult(stdout) {
const lines = stdout.split(/\r?\n/).filter(Boolean)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i])
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
return parsed
}
@ -525,6 +586,7 @@ function parseStageResult(stdout) {
void 0
}
}
return null
}
@ -533,6 +595,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
emit({ type: 'stage', name: stage.name, state: 'running' })
const isPosix = installerKind === 'posix'
const args = isPosix
? [
'--stage',
@ -542,6 +605,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })
]
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit,
stageName: stage.name,
@ -554,6 +618,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
if (result.killed) {
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, error: 'cancelled by user' }
emit(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})`,
json: null
}
emit(ev)
return ev
}
if (json.ok && json.skipped) {
const ev = { type: 'stage', name: stage.name, state: 'skipped', durationMs, json }
emit(ev)
return ev
}
if (json.ok) {
const ev = { type: 'stage', name: stage.name, state: 'succeeded', durationMs, json }
emit(ev)
return ev
}
const ev = {
type: 'stage',
name: stage.name,
@ -590,7 +661,9 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
json,
error: json.reason || `exit code ${result.code}`
}
emit(ev)
return ev
}
@ -603,6 +676,7 @@ function openRunLog(logRoot) {
const ts = new Date().toISOString().replace(/[:.]/g, '-')
const logPath = path.join(logRoot, `bootstrap-${ts}.log`)
const stream = fs.createWriteStream(logPath, { flags: 'a' })
return { path: logPath, stream }
}
@ -619,7 +693,7 @@ async function runBootstrap(opts) {
logRoot,
onEvent,
abortSignal,
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
writeMarker // callback to write the bootstrap-complete marker; main.ts provides
} = opts
// Bail before spawning anything if the user already cancelled — otherwise an
@ -633,6 +707,7 @@ async function runBootstrap(opts) {
void 0
}
}
return { ok: false, cancelled: true }
}
@ -646,8 +721,9 @@ async function runBootstrap(opts) {
} catch {
void 0
}
try {
if (typeof onEvent === 'function') onEvent(ev)
if (typeof onEvent === 'function') {onEvent(ev)}
} catch (err) {
// Don't let a subscriber bug crash the bootstrap
runLog.stream.write(`emit error: ${err && err.message}\n`)
@ -677,6 +753,7 @@ async function runBootstrap(opts) {
activeRoot,
installStamp
})
emit({
type: 'manifest',
stages: manifest.stages,
@ -690,8 +767,10 @@ async function runBootstrap(opts) {
for (const stage of manifest.stages) {
if (abortSignal && abortSignal.aborted) {
emit({ type: 'failed', error: 'bootstrap cancelled by user' })
return { ok: false, cancelled: true }
}
const ev = await runStage({
scriptPath: scriptInfo.path,
installerKind,
@ -702,9 +781,11 @@ async function runBootstrap(opts) {
abortSignal,
installStamp
})
if (ev.state === 'failed') {
emit({ type: 'failed', stage: stage.name, error: ev.error || 'stage failed' })
return { ok: false, failedStage: stage.name, error: ev.error }
emit({ type: 'failed', stage: stage.name, error: (ev as any).error || 'stage failed' })
return { ok: false, failedStage: stage.name, error: (ev as any).error }
}
}
@ -713,11 +794,14 @@ async function runBootstrap(opts) {
pinnedCommit: installStamp ? installStamp.commit : null,
pinnedBranch: installStamp ? installStamp.branch : null
}
const marker = typeof writeMarker === 'function' ? writeMarker(markerPayload) : markerPayload
emit({ type: 'complete', marker })
return { ok: true, marker }
} catch (err) {
emit({ type: 'failed', error: err.message || String(err) })
return { ok: false, error: err.message || String(err) }
} finally {
try {
@ -728,12 +812,10 @@ async function runBootstrap(opts) {
}
}
module.exports = {
runBootstrap,
export { cachedScriptPath,
installedAgentInstallScript,
// Exposed for testability
parseStageResult,
resolveLocalInstallScript,
resolveInstallScript,
installedAgentInstallScript,
cachedScriptPath
}
resolveLocalInstallScript,
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.)
*
* These are the pure helpers behind the remote-gateway connection settings:
@ -10,26 +10,24 @@
* and the OAuth session-cookie detector.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
import test from 'node:test'
const {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
import { AT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
cookiesHaveSession,
normalizeRemoteBaseUrl,
normAuthMode,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
} = require('./connection-config.cjs')
RT_COOKIE_VARIANTS,
tokenPreview } from './connection-config'
// --- 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' } }
}
}
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
url: 'https://coder.example.com/hermes',
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, {
mintTicket: async () => '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')
}
}),
err => {
(err: any) => {
// Actionable, points the user at re-auth, and preserves the cause + flag
// the boot overlay uses to offer a sign-in prompt.
assert.match(err.message, /WebSocket ticket/i)
assert.match(err.message, /sign in again/i)
assert.equal(err.needsOauthLogin, true)
assert.ok(err.cause instanceof Error)
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
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
* auth-mode classification, and the auth-mode coercion rules.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` same pattern as backend-probes.cjs / bootstrap-platform.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
* Kept standalone (no `import 'electron'`) so it can be unit-tested with
* `node --test` same pattern as backend-probes.ts / bootstrap-platform.ts.
* main.ts requires these and wires them into the electron-coupled IPC layer.
*
* Background on the two auth models a remote gateway can use:
* - 'token': legacy static dashboard session token. REST uses an
@ -45,6 +45,7 @@ function normalizeRemoteBaseUrl(rawUrl) {
}
let parsed
try {
parsed = new URL(value)
} catch (error) {
@ -83,7 +84,7 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* exercise the same transport the app actually uses.
*
* 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`.
*
* Return semantics:
@ -93,7 +94,7 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* - oauth, mint fails THROWS (NOT a skip)
*
* 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
* would re-introduce the exact false-positive this test exists to catch
* 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
* @returns {Promise<string|null>}
*/
async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) {
if (authMode === 'oauth') {
const mintTicket = deps.mintTicket
if (typeof mintTicket !== 'function') {
throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.')
}
let ticket
try {
ticket = await mintTicket(baseUrl)
} 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 ' +
'(it may have expired). Open Settings → Gateway and sign in again.'
)
err.needsOauthLogin = true
;(err as any).needsOauthLogin = true
err.cause = error
throw err
}
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
}
if (!token) {
return null
}
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
* 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`.
*/
function profileRemoteOverride(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
return null
}
const url = String(entry.url || '').trim()
if (!url) {
return null
}
@ -172,18 +182,21 @@ function profileRemoteOverride(config, profile) {
* 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.
*/
function pathWithGlobalRemoteProfile(path, profile, opts = {}) {
function pathWithGlobalRemoteProfile(path, profile, opts: any = {}) {
const scopedProfile = connectionScopeKey(profile)
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
return path
}
const rawPath = String(path || '')
if (!rawPath) {
return path
}
let parsed
try {
parsed = new URL(rawPath, 'http://hermes.local')
} catch {
@ -224,9 +237,12 @@ function authModeFromStatus(statusBody) {
* Returns 'oauth' | 'token'.
*/
function resolveAuthMode(inputAuthMode, existingAuthMode) {
if (inputAuthMode === 'oauth') return 'oauth'
if (inputAuthMode === 'token') return 'token'
if (existingAuthMode === 'oauth') return 'oauth'
if (inputAuthMode === 'oauth') {return 'oauth'}
if (inputAuthMode === 'token') {return 'token'}
if (existingAuthMode === 'oauth') {return 'oauth'}
return 'token'
}
@ -242,7 +258,8 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
* need to know whether an unexpired access token is present right now.
*/
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)
}
@ -260,24 +277,23 @@ function cookiesHaveSession(cookies) {
* the RT is also dead/revoked).
*/
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)))
}
module.exports = {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
export { AT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
cookiesHaveSession,
normalizeRemoteBaseUrl,
normAuthMode,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
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.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
import test from 'node:test'
const {
adoptServedDashboardToken,
import { adoptServedDashboardToken,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,
resolveServedDashboardToken
} = require('./dashboard-token.cjs')
resolveServedDashboardToken } from './dashboard-token'
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
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 () => {
const logs = []
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async url => {
assert.equal(url, 'http://127.0.0.1:9120/')
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
},
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: '', spawnToken: 'mine', childAlive: false }, false]
]
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 () => {
const logs = []
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true,
fetchText: async () => {

View file

@ -9,29 +9,35 @@
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
async function fetchPublicText(url, options = {}) {
async function fetchPublicText(url, options: any = {}) {
const { protocol } = new URL(url)
if (protocol !== 'http:' && protocol !== 'https:') {
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
if (error.name === 'TimeoutError') {
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
}
throw error
})
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
}
function extractInjectedDashboardToken(html) {
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
if (!match) return null
if (!match) {return null}
try {
return JSON.parse(match[1])
} catch {
@ -43,11 +49,13 @@ function dashboardIndexUrl(baseUrl) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
}
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
async function resolveServedDashboardToken(baseUrl, fallbackToken, options: any = {}) {
const fetchText = options.fetchText || fetchPublicText
const html = await fetchText(dashboardIndexUrl(baseUrl), {
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
})
const servedToken = extractInjectedDashboardToken(html)
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 }) {
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
return spawnToken
})
@ -88,12 +97,10 @@ async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, labe
return servedToken
}
module.exports = {
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
adoptServedDashboardToken,
export { adoptServedDashboardToken,
dashboardIndexUrl,
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
extractInjectedDashboardToken,
fetchPublicText,
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.)
*
* These are the pure helpers behind the desktop Chat GUI uninstaller: the
@ -9,19 +9,17 @@
* cleanup-script builders (POSIX + Windows).
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
import test from 'node:test'
const {
UNINSTALL_MODES,
buildPosixCleanupScript,
import { buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
uninstallArgsForMode
} = require('./desktop-uninstall.cjs')
UNINSTALL_MODES,
uninstallArgsForMode } from './desktop-uninstall'
// --- uninstallArgsForMode ---
@ -132,6 +130,7 @@ test('buildPosixCleanupScript waits for the PID, runs the uninstall module, remo
appPath: '/opt/hermes/linux-unpacked',
hermesHome: '/home/x/.hermes'
})
assert.match(script, /^#!\/bin\/bash/)
assert.match(script, /pid=4321/)
assert.match(script, /kill -0 "\$pid"/)
@ -152,6 +151,7 @@ test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/fu
appPath: null,
hermesHome: '/home/x/.hermes'
})
// System python + source on PYTHONPATH so import hermes_cli works while the
// venv is torn down.
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,
hermesHome: '/h'
})
assert.doesNotMatch(script, /export PYTHONPATH/)
})
@ -181,6 +182,7 @@ test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => {
appPath: null,
hermesHome: '/h'
})
assert.doesNotMatch(script, /rm -rf '\//)
// Still runs the uninstall.
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/)
@ -196,6 +198,7 @@ test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () =
appPath: null,
hermesHome: '/h'
})
// The apostrophe is closed-escaped-reopened so the shell sees the literal.
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',
hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes'
})
assert.match(script, /@echo off/)
assert.match(script, /set "PID=9988"/)
// 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,
hermesHome: 'C:\\h'
})
assert.doesNotMatch(script, /rmdir/)
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
* 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
* 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
* `node --test` same pattern as connection-config.cjs / backend-probes.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
* Kept standalone (no ` import 'electron'`) so it can be unit-tested with
* `node --test` same pattern as connection-config.ts / backend-probes.ts.
* main.ts requires these and wires them into the electron-coupled IPC layer.
*
* The three modes mirror the CLI's options exactly:
* - '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
* 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
* 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']
@ -41,6 +41,7 @@ function uninstallArgsForMode(mode) {
if (!UNINSTALL_MODES.includes(mode)) {
throw new Error(`Unknown 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.
* 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 || '')
if (!exe) return null
if (!exe) {return null}
// Use the path flavor that matches the TARGET platform, not the host running
// 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 contents = p.dirname(macOsDir) // …/Contents
const appBundle = p.dirname(contents) // …/Hermes.app
if (appBundle.endsWith('.app')) return appBundle
if (appBundle.endsWith('.app')) {return appBundle}
return null
}
if (platform === 'win32') {
// NSIS per-user installs Hermes.exe directly in the install dir.
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
}
// 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
const dir = p.dirname(exe)
if (/-unpacked$/.test(dir)) return dir
if (/-unpacked$/.test(dir)) {return dir}
return null
}
@ -121,6 +129,7 @@ function shouldRemoveAppBundle(isPackaged, appPath) {
*/
function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
const lines = [
'#!/bin/bash',
'set -u',
@ -135,16 +144,21 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
'fi',
`export HERMES_HOME=${q(hermesHome)}`
]
if (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`)
if (appPath) {
lines.push(`rm -rf ${q(appPath)} || true`)
}
// Self-delete the script.
lines.push('rm -f "$0" 2>/dev/null || true')
lines.push('')
return lines.join('\n')
}
@ -180,15 +194,18 @@ function buildWindowsCleanupScript({
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
// a problem, but Hermes install paths don't use them.
const q = s => `"${String(s).replace(/"/g, '')}"`
const lines = [
'@echo off',
'setlocal enableextensions',
`set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`,
`set "PID=${pid}"`
]
if (pythonPath) {
lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`)
}
lines.push(
'set /a waited=0',
':waitloop',
@ -206,6 +223,7 @@ function buildWindowsCleanupScript({
`cd /d ${q(agentRoot)}`,
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}`
)
if (appPath) {
lines.push(
'set /a tries=0',
@ -220,18 +238,18 @@ function buildWindowsCleanupScript({
':rmdone'
)
}
lines.push('del "%~f0"')
lines.push('')
return lines.join('\r\n')
}
module.exports = {
UNINSTALL_MODES,
buildPosixCleanupScript,
export { buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
uninstallArgsForMode
}
UNINSTALL_MODES,
uninstallArgsForMode }

View file

@ -1,9 +1,8 @@
'use strict'
const { session } = require('electron')
import { session } from 'electron'
const EMBED_SESSION_PARTITION = 'persist:hermes-embed'
const EMBED_REFERER = 'https://www.youtube.com/'
const YOUTUBE_REFERER_HOST_RE =
/(^|\.)(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)) {
callback({ requestHeaders: details.requestHeaders })
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')
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')
import { readDirForIpc } from './fs-read-dir'
function mkTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
}
function fakeDirent(name, flags = {}) {
function fakeDirent(name, flags: any = {}) {
return {
name,
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 () => {
let readdirCalls = 0
const fsImpl = {
promises: {
readdir: async () => {
readdirCalls += 1
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 () => {
let readdirCalls = 0
const fsImpl = {
promises: {
readdir: async () => {
readdirCalls += 1
return []
}
}
@ -224,6 +226,7 @@ test('readDirForIpc allows expanding symlink or junction directories outside the
fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
const linkPath = path.join(root, 'outside-link')
try {
fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
} catch (error) {
@ -252,6 +255,7 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th
const input = path.join('virtual-root')
const resolved = path.resolve(input)
const statCalls = []
const fsImpl = {
promises: {
readdir: async () => [
@ -266,9 +270,11 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th
}
statCalls.push(fullPath)
if (fullPath.endsWith(`${path.sep}linked-dir`)) {
return { isDirectory: () => true }
}
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 releaseStats
let markFirstStatStarted
const statsReleased = new Promise(resolve => {
releaseStats = resolve
})
const firstStatStarted = new Promise(resolve => {
markFirstStatStarted = resolve
})
const fsImpl = {
promises: {
readdir: async () => [
@ -326,6 +335,7 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
active -= 1
const name = path.basename(fullPath)
if (name === failedName) {
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')
const path = require('node:path')
const { resolveDirectoryForIpc } = require('./hardening.cjs')
import { resolveDirectoryForIpc } from './hardening'
const FS_READDIR_STAT_CONCURRENCY = 16
@ -37,7 +36,7 @@ function direntIsSymbolicLink(dirent) {
}
function shouldStatDirent(dirent) {
if (direntIsDirectory(dirent)) return false
if (direntIsDirectory(dirent)) {return false}
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 workers = Array.from({ length: workerCount }, () => runWorker())
const workers = Array.from({ length: workerCount } as any, () => runWorker())
await Promise.all(workers)
return results
}
async function readDirForIpc(dirPath, options = {}) {
async function readDirForIpc(dirPath, options: any = {}) {
const fsImpl = options.fs || fs
let resolved
@ -102,6 +101,4 @@ async function readDirForIpc(dirPath, options = {}) {
}
}
module.exports = {
readDirForIpc
}
export { 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.)
*
* 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.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from '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
// 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 = []
class FakeWs {
url: string
closed = false
listeners: Record<string, any[]> = {}
constructor(url) {
this.url = url
this.listeners = {}
@ -32,9 +36,12 @@ function makeFakeWs() {
this.closed = true
}
emit(type, event) {
for (const fn of this.listeners[type] || []) fn(event)
for (const fn of this.listeners[type] || []) {
fn(event)
}
}
}
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 () => {
const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', {
WebSocketImpl: FakeWs,
connectTimeoutMs: 1_000,
readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer
})
instances[0].emit('open')
instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' })
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 () => {
const { FakeWs } = makeFakeWs()
const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', {
WebSocketImpl: FakeWs,
connectTimeoutMs: 20,
readyGraceMs: 10
})
assert.equal(result.ok, false)
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.
*
* @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 }>}
*/
function probeGatewayWebSocket(wsUrl, options = {}) {
function probeGatewayWebSocket<T>(wsUrl: string, options:{
WebSocketImpl?: any,
connectTimeoutMs?: number
readyGraceMs?: number
} = {}) {
const WebSocketImpl = options.WebSocketImpl
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_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 opened = false
let connectTimer = null
@ -66,6 +66,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
clearTimeout(connectTimer)
connectTimer = null
}
if (graceTimer !== null) {
clearTimeout(graceTimer)
graceTimer = null
@ -73,14 +74,16 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
}
const finish = result => {
if (settled) return
if (settled) {return}
settled = true
clearTimers()
try {
socket?.close?.()
} catch {
// ignore — best effort teardown
}
resolve(result)
}
@ -91,11 +94,12 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
ok: false,
reason: error instanceof Error ? error.message : String(error)
})
return
}
const onOpen = () => {
if (settled) return
if (settled) {return}
opened = true
// Upgrade accepted. Give the server a brief window to reject the
// credential post-handshake (early close) before declaring success.
@ -118,7 +122,8 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
}
const onClose = event => {
if (settled) return
if (settled) {return}
if (opened) {
// Opened, then closed inside the grace window: the upgrade was accepted
// but the session was refused (e.g. ws-ticket/token rejected, or a
@ -127,8 +132,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
ok: false,
reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).')
})
return
}
finish({
ok: false,
reason: closeReason(event, 'The gateway closed the WebSocket before it opened.')
@ -154,8 +161,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
function addListener(socket, type, handler) {
if (typeof socket.addEventListener === 'function') {
socket.addEventListener(type, handler)
return
}
// Node's global WebSocket implements addEventListener; this fallback keeps the
// helper usable with the `ws` package's EventEmitter shape too.
if (typeof socket.on === 'function') {
@ -164,25 +173,31 @@ function addListener(socket, type, handler) {
}
function extractErrorReason(event) {
if (!event) return ''
if (event instanceof Error) return event.message
if (!event) {return ''}
if (event instanceof Error) {return 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 ''
}
function closeReason(event, fallback) {
const code = event && typeof event.code === 'number' ? event.code : null
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 (reason) return `${fallback} (${reason})`
if (code && reason) {return `${fallback} (code ${code}: ${reason})`}
if (code) {return `${fallback} (code ${code})`}
if (reason) {return `${fallback} (${reason})`}
return fallback
}
module.exports = {
DEFAULT_CONNECT_TIMEOUT_MS,
export { DEFAULT_CONNECT_TIMEOUT_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`
// — 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`
// (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.
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
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
* `{ 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 searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
const found = new Map()
@ -54,6 +52,7 @@ async function scanGitRepos(roots, options = {}) {
}
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
@ -73,6 +72,7 @@ async function scanGitRepos(roots, options = {}) {
}
const subdirs = []
for (const entry of entries) {
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
// known heavy trees.
@ -93,4 +93,4 @@ async function scanGitRepos(roots, options = {}) {
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')
const test = require('node:test')
const { resolveRenamePath } = require('./git-review-ops.cjs')
import { resolveRenamePath } from './git-review-ops'
test('resolveRenamePath: plain path is unchanged', () => {
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`
// (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()
// results instead of hand-parsing porcelain. Reads degrade to null/empty on a
// non-repo / remote backend; mutations reject so the renderer can toast.
const { execFile } = require('node:child_process')
const fs = require('node:fs/promises')
const path = require('node:path')
import { execFile } from 'node:child_process'
import fs from 'node:fs/promises'
import path from 'node:path'
// `simple-git` is a pure-JS runtime dep that workspace dedup hoists into the
// 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'))
}
import simpleGit from "simple-git";
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
import { resolveRequestedPathForIpc } from './hardening'
const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000
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
// 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 => {
execFile(
ghBin || 'gh',
@ -260,10 +239,11 @@ async function reviewList(repoPath, scope, baseRef, gitBin) {
const range = scope === 'branch' ? `${base}...HEAD` : base
const summary = await git.diffSummary([range])
const files = summary.files.map(file => ({
path: resolveRenamePath(file.file),
added: file.binary ? 0 : file.insertions,
removed: file.binary ? 0 : file.deletions,
added: 'insertions' in file ? file.insertions : 0 ,
removed: 'deletions' in file ? file.deletions : 0 ,
status: 'M',
staged: false
}))
@ -291,6 +271,7 @@ async function reviewList(repoPath, scope, baseRef, gitBin) {
git.diffSummary(['--cached']),
git.diffSummary([])
])
const stagedCounts = countsByPath(staged)
const unstagedCounts = countsByPath(unstaged)
@ -495,6 +476,7 @@ async function reviewCommitContext(repoPath, gitBin) {
const safe = args => git.diff(args).catch(() => '')
let status
try {
status = await git.status()
} catch {
@ -510,9 +492,11 @@ async function reviewCommitContext(repoPath, gitBin) {
// Untracked files have no diff — list them so new files aren't invisible.
const untracked = status.not_added || []
if (untracked.length > 0) {
const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX)
const omitted = untracked.length - visible.length
const note =
`\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\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.
try {
const stat = await fs.stat(cwd)
if (!stat.isDirectory()) {
return null
}
@ -615,11 +600,13 @@ async function repoStatus(repoPath, gitBin) {
}
let git
try {
git = gitFor(cwd, gitBin)
} catch {
return null
}
let status
try {
@ -630,6 +617,7 @@ async function repoStatus(repoPath, gitBin) {
}
const detached = typeof status.detached === 'boolean' ? status.detached : !status.current
const files = status.files.map(file => ({
path: file.path,
staged: isStaged(file),
@ -671,10 +659,12 @@ async function repoStatus(repoPath, gitBin) {
// can't stall the probe.
try {
const untracked = status.not_added.slice(0, 500)
for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
const batch = await Promise.all(
untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path))
)
result.added += batch.reduce((sum, n) => sum + n, 0)
}
} catch {
@ -684,8 +674,7 @@ async function repoStatus(repoPath, gitBin) {
return result
}
module.exports = {
branchBase,
export { branchBase,
fileDiffVsHead,
repoStatus,
resolveRenamePath,
@ -695,9 +684,8 @@ module.exports = {
reviewDiff,
reviewList,
reviewPush,
reviewRevParse,
reviewRevert,
reviewRevParse,
reviewShipInfo,
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')
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')
import { gitRootForIpc } from './git-root'
function mkTmpDir() {
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')
const path = require('node:path')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
import { resolveRequestedPathForIpc } from './hardening'
function findGitRoot(start, fsImpl = fs) {
let dir = start
@ -28,7 +27,7 @@ function findGitRoot(start, fsImpl = fs) {
return null
}
async function gitRootForIpc(startPath, options = {}) {
async function gitRootForIpc(startPath, options: {fs?: typeof fs} = {}) {
const fsImpl = options.fs || fs
let resolved
@ -48,7 +47,5 @@ async function gitRootForIpc(startPath, options = {}) {
}
}
module.exports = {
findGitRoot,
gitRootForIpc
}
export { findGitRoot,
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')
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,
import { addWorktree,
ensureGitRepo,
listBranches,
parseWorktrees,
sanitizeBranch,
switchBranch
} = require('./git-worktree-ops.cjs')
switchBranch } from './git-worktree-ops'
test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => {
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
// 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.
const path = require('node:path')
const fs = require('node:fs')
const { execFile } = require('node:child_process')
import { execFile } from 'node:child_process'
import fs from 'node:fs'
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) => {
execFile(
gitBin,
@ -306,6 +304,7 @@ async function listBranches(repoPath, gitBin) {
['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'],
resolved
)
const trees = await listWorktrees(resolved, gitBin)
const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path]))
const trunk = await defaultBranch(gitBin, resolved)
@ -338,13 +337,11 @@ async function switchBranch(repoPath, branch, gitBin) {
return { branch: target }
}
module.exports = {
addWorktree,
export { addWorktree,
ensureGitRepo,
listBranches,
listWorktrees,
parseWorktrees,
removeWorktree,
sanitizeBranch,
switchBranch
}
switchBranch }

View file

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

View file

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

View file

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

View file

@ -1,11 +1,9 @@
'use strict'
// 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
// 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.
function linkTitleWindowOptions(partitionSession) {
export function linkTitleWindowOptions(partitionSession) {
return {
show: false,
width: 1280,
@ -25,7 +23,7 @@ function linkTitleWindowOptions(partitionSession) {
// 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
// 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))
try {
@ -41,7 +39,7 @@ function createLinkTitleWindow(BrowserWindow, partitionSession) {
// Cancel any download the title-fetch window triggers. Without this, a link
// artifact URL served with Content-Disposition: attachment auto-downloads every
// time the Artifacts page renders and fetchLinkTitle loads it.
function guardLinkTitleSession(partitionSession) {
export function guardLinkTitleSession(partitionSession) {
try {
partitionSession.on('will-download', (_event, item) => item.cancel())
} catch {
@ -52,20 +50,15 @@ function guardLinkTitleSession(partitionSession) {
// 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
// guard isDestroyed and swallow Electron's "Object has been destroyed" throws.
function readLinkTitleWindowTitle(window) {
export function readLinkTitleWindowTitle(window) {
try {
if (!window || window.isDestroyed()) return ''
if (!window || window.isDestroyed()) {return ''}
const contents = window.webContents
if (!contents || contents.isDestroyed()) return ''
if (!contents || contents.isDestroyed()) {return ''}
return contents.getTitle() || ''
} catch {
return ''
}
}
module.exports = {
createLinkTitleWindow,
guardLinkTitleSession,
linkTitleWindowOptions,
readLinkTitleWindowTitle
}

View file

@ -1,13 +1,13 @@
/**
* 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')
const assert = require('node:assert/strict')
import assert from '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', () => {
assert.equal(serializeJsonBody(undefined), undefined)
@ -21,6 +21,7 @@ test('serializeJsonBody JSON-encodes request bodies', () => {
test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => {
const headers = []
const request = {
setHeader(name, value) {
headers.push([name, value])

View file

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

View file

@ -6,18 +6,21 @@
* this guard is scoped to fetchJsonViaOauthSession only.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
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 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() {
const start = source.indexOf('function fetchJsonViaOauthSession')
const end = source.indexOf('// Mint a single-use WS ticket', start)
assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist')
assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist')
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', {
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
@ -24,12 +24,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onState: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:pet-overlay:state', listener)
return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener)
},
// Main renderer subscribes to overlay control messages.
onControl: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:pet-overlay:control', listener)
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
}
},
@ -87,6 +89,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('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 listener = (_event, payload) => callback(payload)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
},
onExit: (id, callback) => {
const channel = `hermes:terminal:${id}:exit`
const listener = (_event, payload) => callback(payload)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
}
},
onClosePreviewRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:close-preview-requested', listener)
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
},
onOpenUpdatesRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:open-updates', listener)
return () => ipcRenderer.removeListener('hermes:open-updates', listener)
},
onDeepLink: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:deep-link', listener)
return () => ipcRenderer.removeListener('hermes:deep-link', listener)
},
signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'),
onWindowStateChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:window-state-changed', listener)
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
},
onFocusSession: callback => {
const listener = (_event, sessionId) => callback(sessionId)
ipcRenderer.on('hermes:focus-session', listener)
return () => ipcRenderer.removeListener('hermes:focus-session', listener)
},
onNotificationAction: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:notification-action', listener)
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
},
onPreviewFileChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:preview-file-changed', listener)
return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener)
},
onBackendExit: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:backend-exit', listener)
return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
},
onPowerResume: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:power-resume', listener)
return () => ipcRenderer.removeListener('hermes:power-resume', listener)
},
onBootProgress: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:boot-progress', listener)
return () => ipcRenderer.removeListener('hermes:boot-progress', listener)
},
// 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
// current snapshot via getBootstrapState() to recover after a devtools
// reload mid-bootstrap.
@ -204,6 +219,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onBootstrapEvent: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:bootstrap:event', listener)
return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener)
},
getVersion: () => ipcRenderer.invoke('hermes:version'),
@ -220,6 +236,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onProgress: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:updates:progress', listener)
return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
}
},

View file

@ -1,11 +1,11 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
const ELECTRON_DIR = __dirname
const ELECTRON_DIR = import.meta.dirname
function readElectronFile(name) {
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', () => {
const source = readElectronFile('main.cjs')
const source = readElectronFile('main.ts')
// Locate the function definition and its closing brace.
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', () => {
const source = readElectronFile('main.cjs')
const source = readElectronFile('main.ts')
// The handler must capture prepareProfileDeleteRequest's return value.
assert.match(

View file

@ -1,11 +1,9 @@
const assert = require('node:assert/strict')
const test = require('node:test')
import assert from 'node:assert/strict'
import test from 'node:test'
const {
buildSessionWindowUrl,
import { buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry
} = require('./session-windows.cjs')
createSessionWindowRegistry } from './session-windows'
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
// 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()
let built = 0
const win = makeFakeWindow()
const factory = () => {
built += 1
@ -145,6 +144,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', ()
let built = 0
const second = makeFakeWindow()
const result = registry.openOrFocus('s1', () => {
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', () => {
const registry = createSessionWindowRegistry()
let built = 0
const factory = () => {
built += 1

View file

@ -1,9 +1,9 @@
// 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
// 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
// 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
// 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:
// 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
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
function chatWindowWebPreferences(preloadPath) {
function chatWindowWebPreferences(preloadPath: string) {
return {
preload: preloadPath,
contextIsolation: true,
@ -42,7 +42,7 @@ function chatWindowWebPreferences(preloadPath) {
// 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
// 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 route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
@ -115,10 +115,8 @@ function createSessionWindowRegistry() {
}
}
module.exports = {
buildSessionWindowUrl,
export { buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
}
SESSION_WINDOW_MIN_WIDTH }

View file

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

View file

@ -1,6 +1,4 @@
'use strict'
const OVERLAY_FALLBACK_WIDTH = 144
export const OVERLAY_FALLBACK_WIDTH = 144
/**
* 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
*/
function nativeOverlayWidth({ isMac = false } = {}) {
if (isMac) return 0
export function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = false } = {}) {
if (isMac) {return 0}
return OVERLAY_FALLBACK_WIDTH
}
// 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
// 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+)
@ -36,8 +35,7 @@ const MACOS_TAHOE_DARWIN_MAJOR = 25
*
* @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
}
module.exports = { MACOS_TAHOE_DARWIN_MAJOR, OVERLAY_FALLBACK_WIDTH, macTitleBarOverlayHeight, nativeOverlayWidth }

View file

@ -1,7 +1,7 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveBehindCount, shouldCountCommits } from './update-count'
// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0`
// 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
// number worth computing. On a SHALLOW checkout (installer clones with
// --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.
function resolveBehindCount({ countStr, currentSha, targetSha, 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 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
* 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.)
*
* 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.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('fs')
const os = require('os')
const path = require('path')
import fs from 'fs'
import assert from 'node:assert/strict'
import test from 'node:test'
import os from 'os'
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) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-marker-${tag}-`))
return dir
}
@ -29,10 +30,11 @@ function writeMarker(home, pid, startedAtSec) {
fs.writeFileSync(markerPath(home), `${pid}\n${startedAtSec}`)
}
const ALIVE = () => true // injected kill that "succeeds" => pid alive
const DEAD = () => {
const err = new Error('no such process')
err.code = 'ESRCH'
const ALIVE: typeof process.kill = () => true // injected kill that "succeeds" => pid alive
const DEAD : typeof process.kill= () => {
const err = new Error('no such process');
(err as any).code = 'ESRCH'
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)', () => {
const eperm = () => {
const err = new Error('operation not permitted')
err.code = 'EPERM'
const err = new Error('operation not permitted');
(err as any).code = 'EPERM'
throw err
}
assert.equal(isPidAlive(4242, eperm), true)
})

View file

@ -16,20 +16,20 @@
*
* This module holds the PURE, side-effect-light logic (path, pid liveness,
* 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.
*/
const fs = require('fs')
const path = require('path')
import fs from 'fs'
import path from 'path'
// 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
// 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.
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')
}
@ -37,10 +37,12 @@ function markerPath(hermesHome) {
// not deliver a signal — it just probes existence/permission. ESRCH => dead;
// EPERM => alive but owned by another user (still "alive" for our purposes).
// Injectable `kill` keeps it unit-testable.
function isPidAlive(pid, kill = process.kill.bind(process)) {
if (!Number.isInteger(pid) || pid <= 0) return false
export function isPidAlive(pid, kill: typeof process.kill = process.kill.bind(process)) {
if (!Number.isInteger(pid) || pid <= 0) {return false}
try {
kill(pid, 0)
return true
} catch (err) {
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
* 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)
let raw
try {
raw = fs.readFileSync(file, 'utf8')
} catch {
@ -80,8 +85,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD
} catch {
void 0
}
return null
}
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
* 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 startedAt = Math.floor(now() / 1000)
try {
fs.writeFileSync(file, `${pid}\n${startedAt}\n`, 'utf8')
} catch {
@ -117,11 +125,3 @@ function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) {
// 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.
*
* 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.)
*
* 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.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from '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', () => {
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 () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: 0 })
})
assert.deepEqual(codes, [0])
assert.equal(result.code, 0)
})
test('a failed first rebuild retries once and succeeds', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: attempt === 0 ? 1 : 0 })
})
assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 0)
})
test('a rebuild that keeps failing runs at most twice and reports the failure', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: 1, error: 'rebuild-failed' })
})
assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 1)
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.
*
@ -20,10 +18,12 @@ function shouldRetryRebuild(code) {
*/
async function runRebuildWithRetry(rebuild) {
let result = await rebuild(0)
if (shouldRetryRebuild(result.code)) {
result = await rebuild(1)
}
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).
*
* 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.)
*
* What this locks (review acceptance criteria for PR #45205):
@ -17,24 +17,22 @@
* (keep a working window) unless a non-interactive fallback applies.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
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 {
unpackedDirName,
resolveUnpackedRelease,
decideRelaunchOutcome,
sandboxPreflight,
sandboxFallbackFromEnv,
import { buildRelaunchScript,
collectRelaunchArgs,
collectRelaunchEnv,
buildRelaunchScript,
shellQuote
} = require('./update-relaunch.cjs')
decideRelaunchOutcome,
resolveUnpackedRelease,
sandboxFallbackFromEnv,
sandboxPreflight,
shellQuote,
unpackedDirName } from './update-relaunch'
const ROOT = '/home/u/.hermes/hermes-agent'
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 throwStat = () => {
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
'--remote-debugging-port=9222' // internal — drop
]
assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/agent/42', '--profile=work'])
assert.deepEqual(collectRelaunchArgs(undefined), [])
})
@ -165,6 +165,7 @@ test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt-
HOME: '/home/u', // not preserved
UNRELATED: 'x'
}
assert.deepEqual(collectRelaunchEnv(env), {
HERMES_HOME: '/home/u/.hermes',
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.
const tmp = path.join(os.tmpdir(), `hermes-relaunch-test-${Date.now()}.sh`)
fs.writeFileSync(tmp, script)
try {
execFileSync('bash', ['-n', tmp], { stdio: 'pipe' })
} finally {
@ -222,13 +224,16 @@ test('buildRelaunchScript with no args/env still lints clean', () => {
env: {},
cwd: ''
})
const tmp = path.join(os.tmpdir(), `hermes-relaunch-test2-${Date.now()}.sh`)
fs.writeFileSync(tmp, script)
try {
execFileSync('bash', ['-n', tmp], { stdio: 'pipe' })
} finally {
fs.rmSync(tmp, { force: true })
}
// exec line has no trailing args.
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).
*
* 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?"
* decision is unit-testable without booting Electron (main.cjs
* decision is unit-testable without booting Electron (main.ts
* `require('electron')` at load).
*
* Background
@ -37,12 +35,14 @@
* 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.
function unpackedDirName(platform) {
if (platform === 'darwin') return 'mac-unpacked' // not used (mac swaps bundles)
if (platform === 'win32') return 'win-unpacked'
if (platform === 'darwin') {return 'mac-unpacked'} // not used (mac swaps bundles)
if (platform === 'win32') {return 'win-unpacked'}
return 'linux-unpacked'
}
@ -56,15 +56,17 @@ function unpackedDirName(platform) {
* `.../release/linux-unpacked-evil` can't masquerade as `.../release/linux-unpacked`.
*/
function resolveUnpackedRelease(execPath, updateRoot, platform) {
if (!execPath || !updateRoot) return null
if (!execPath || !updateRoot) {return null}
const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release')
const unpacked = path.join(releaseDir, unpackedDirName(platform))
const normalizedExec = path.resolve(String(execPath))
// execPath must be the unpacked dir itself or a descendant of it.
const withSep = unpacked.endsWith(path.sep) ? unpacked : unpacked + path.sep
if (normalizedExec === unpacked || normalizedExec.startsWith(withSep)) {
return unpacked
}
return null
}
@ -81,8 +83,10 @@ function resolveUnpackedRelease(execPath, updateRoot, platform) {
* app. Closeable manual-restart terminal state.
*/
function decideRelaunchOutcome({ underUnpacked, sandboxOk }) {
if (!underUnpacked) return 'guiSkew'
if (!sandboxOk) return 'manual'
if (!underUnpacked) {return 'guiSkew'}
if (!sandboxOk) {return 'manual'}
return 'relaunch'
}
@ -99,9 +103,10 @@ function decideRelaunchOutcome({ underUnpacked, sandboxOk }) {
* `statSync` is injectable so this is testable without a real setuid file.
*/
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')
let st
try {
st = statSync(sandboxPath)
} catch {
@ -109,15 +114,20 @@ function sandboxPreflight(unpackedDir, statSync) {
// sandbox; nothing to block the relaunch.
return { ok: true, reason: 'no-sandbox-helper', path: sandboxPath }
}
const ownedByRoot = st.uid === 0
const hasSetuid = (st.mode & 0o4000) !== 0
if (ownedByRoot && hasSetuid) {
return { ok: true, reason: 'launchable', path: sandboxPath }
}
if (!ownedByRoot && !hasSetuid) {
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 }
}
@ -126,7 +136,7 @@ function sandboxPreflight(unpackedDir, statSync) {
* environment. The reviewer asked us to integrate with any existing
* `--no-sandbox` / chrome-sandbox handling. A repo grep found NO existing
* 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
* (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
@ -137,8 +147,11 @@ function sandboxPreflight(unpackedDir, statSync) {
*/
function sandboxFallbackFromEnv(env, launchArgs) {
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
}
@ -176,9 +189,11 @@ const INTERNAL_ARG_PREFIXES = [
* the exec path itself; there is no entry-script arg as in a dev run).
*/
function collectRelaunchArgs(argv) {
if (!Array.isArray(argv)) return []
if (!Array.isArray(argv)) {return []}
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 =>
prefix.endsWith('=') ? arg.startsWith(prefix) : arg === prefix || arg.startsWith(prefix + '=')
)
@ -197,13 +212,17 @@ const PRESERVED_ENV_PREFIXES = ['HERMES_DESKTOP_']
function collectRelaunchEnv(env) {
const out = {}
if (!env || typeof env !== 'object') return out
if (!env || typeof env !== 'object') {return out}
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))) {
out[key] = String(value)
}
}
return out
}
@ -223,8 +242,10 @@ function buildRelaunchScript({ pid, execPath, args, env, cwd }) {
const exports = Object.entries(env || {})
.map(([k, v]) => `export ${k}=${shellQuote(v)}`)
.join('\n')
const quotedArgs = (args || []).map(shellQuote).join(' ')
const cwdLine = cwd ? `cd ${shellQuote(cwd)} 2>/dev/null || true` : ''
// NOTE: `exec` replaces the watcher process with the relaunched app, so the
// re-exec inherits exactly the env/cwd we set above.
return `#!/bin/bash
@ -249,17 +270,15 @@ exec ${shellQuote(execPath)}${quotedArgs ? ' ' + quotedArgs : ''}
`
}
module.exports = {
unpackedDirName,
resolveUnpackedRelease,
decideRelaunchOutcome,
sandboxPreflight,
sandboxFallbackFromEnv,
export { buildRelaunchScript,
collectRelaunchArgs,
collectRelaunchEnv,
buildRelaunchScript,
shellQuote,
decideRelaunchOutcome,
INTERNAL_ARG_PREFIXES,
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.
*
* 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.)
*
* Why this matters: a public install can carry
@ -15,16 +15,14 @@
* never prompts and should keep the normal fetch path).
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
import test from 'node:test'
const {
OFFICIAL_REPO_HTTPS_URL,
OFFICIAL_REPO_CANONICAL,
canonicalGitHubRemote,
import { canonicalGitHubRemote,
isOfficialSshRemote,
isSshRemote,
isOfficialSshRemote
} = require('./update-remote.cjs')
OFFICIAL_REPO_CANONICAL,
OFFICIAL_REPO_HTTPS_URL } from './update-remote'
test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => {
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
* unchanged.
*
* Extracted from main.cjs so the security-critical remote detection is unit
* testable without booting Electron (main.cjs requires('electron') at load).
* Extracted from main.ts so the security-critical remote detection is unit
* testable without booting Electron (main.ts requires('electron') at load).
*/
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
// compare equal.
function canonicalGitHubRemote(url) {
if (!url) return ''
if (!url) {return ''}
let value = String(url).trim()
if (value.startsWith('git@github.com:')) {
value = `github.com/${value.slice('git@github.com:'.length)}`
} else if (value.startsWith('ssh://git@github.com/')) {
@ -28,13 +29,17 @@ function canonicalGitHubRemote(url) {
} else {
try {
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 {
// Leave non-URL forms unchanged.
}
}
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()
}
@ -42,6 +47,7 @@ function isSshRemote(url) {
const value = String(url || '')
.trim()
.toLowerCase()
return value.startsWith('git@') || value.startsWith('ssh://')
}
@ -49,10 +55,8 @@ function isOfficialSshRemote(url) {
return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL
}
module.exports = {
OFFICIAL_REPO_HTTPS_URL,
OFFICIAL_REPO_CANONICAL,
canonicalGitHubRemote,
export { canonicalGitHubRemote,
isOfficialSshRemote,
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')
const test = require('node:test')
const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs')
import { __testing, extractThemes, readCentralDirectory } from './vscode-marketplace'
// Build a minimal zip with stored (uncompressed) entries so the test controls
// 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' }]
}
})
const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } })
const zip = makeZip([

View file

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

View file

@ -4,19 +4,17 @@
* clamping, and the debounce that collapses mid-drag write storms.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
import test from 'node:test'
const {
DEFAULT_WIDTH,
import { computeWindowOptions,
debounce,
DEFAULT_HEIGHT,
MIN_WIDTH,
DEFAULT_WIDTH,
MIN_HEIGHT,
sanitizeWindowState,
MIN_WIDTH,
onScreen,
computeWindowOptions,
debounce
} = require('./window-state.cjs')
sanitizeWindowState } from './window-state'
// A single 1920×1080 monitor (work area trimmed for the taskbar).
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 => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0
const d = debounce(() => {
calls += 1
}, 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 => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0
const d = debounce(() => {
calls += 1
}, 250)

View file

@ -2,7 +2,7 @@
* Pure geometry helpers for window-state.json restoring the main window's
* size, position, and maximized flag across launches. Side-effect-free so the
* 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.
*/
@ -21,41 +21,59 @@ const MIN_VISIBLE = 48
const finite = v => typeof v === 'number' && Number.isFinite(v)
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
// and floored; x/y survive only as a finite pair; isMaximized is strict.
function sanitizeWindowState(raw) {
if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) return null
function sanitizeWindowState(raw?: any): SanitizedWindowState | 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)),
height: Math.max(MIN_HEIGHT, Math.round(raw.height)),
isMaximized: raw.isMaximized === true
isMaximized: raw.isMaximized === true,
}
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)
}
return state
}
// True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both
// axes. `displays` is Electron's screen.getAllDisplays() shape.
function onScreen(bounds, displays) {
if (!Array.isArray(displays)) return false
if (!Array.isArray(displays)) {return false}
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 y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y)
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
// 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.
// Sets x/y only when still on-screen; otherwise Electron centers the window.
function computeWindowOptions(state, displays) {
const opts = {
function computeWindowOptions(state, displays): WindowOptions {
const opts: WindowOptions = {
width: finite(state?.width) ? state.width : DEFAULT_WIDTH,
height: finite(state?.height) ? state.height : DEFAULT_HEIGHT
}
@ -67,6 +85,7 @@ function computeWindowOptions(state, displays) {
: m,
{ width: 0, height: 0 }
)
if (cap.width && cap.height) {
opts.width = clamp(opts.width, MIN_WIDTH, cap.width)
opts.height = clamp(opts.height, MIN_HEIGHT, cap.height)
@ -78,9 +97,10 @@ function computeWindowOptions(state, displays) {
finite(state.y) &&
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
}
return opts
}
@ -89,6 +109,7 @@ function computeWindowOptions(state, displays) {
// cancels the pending timer — used on close, before the window is gone.
function debounce(fn, delayMs) {
let timer = null
const debounced = () => {
clearTimeout(timer)
timer = setTimeout(() => {
@ -96,22 +117,22 @@ function debounce(fn, delayMs) {
fn()
}, delayMs)
}
debounced.flush = () => {
clearTimeout(timer)
timer = null
fn()
}
return debounced
}
module.exports = {
DEFAULT_WIDTH,
export { computeWindowOptions,
debounce,
DEFAULT_HEIGHT,
MIN_WIDTH,
DEFAULT_WIDTH,
MIN_HEIGHT,
MIN_VISIBLE,
sanitizeWindowState,
MIN_WIDTH,
onScreen,
computeWindowOptions,
debounce
}
sanitizeWindowState }

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 assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ELECTRON_DIR = path.dirname(fileURLToPath(import.meta.url))
// TODO FIXME these tests all grep source code for specific things. This is an antipattern.
// Tests should NEVER read src, only assert behavior.
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
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', () => {
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\(\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', () => {
const source = readElectronFile('main.cjs')
const source = readElectronFile('main.ts')
// The flash fix is structural: the backend runs as a console-subsystem
// 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', () => {
const source = readElectronFile('main.cjs')
const source = readElectronFile('main.ts')
const helperIndex = source.indexOf('function stopBackendChild(child)')
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', () => {
const source = readElectronFile('main.cjs')
const source = readElectronFile('main.ts')
assert.match(source, /windowsHide: false/)
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', () => {
const source = readElectronFile('bootstrap-runner.cjs')
const source = readElectronFile('bootstrap-runner.ts')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/)

View file

@ -1,9 +1,7 @@
'use strict'
// Regression guards for Windows `hermes` resolution in main.cjs.
// Regression guards for Windows `hermes` resolution in main.ts.
//
// main.cjs 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
// main.ts has no module.exports, so these follow the repo's source-assertion
// test pattern (see windows-child-process.test.ts). They pin the two Windows
// resolution bugs that caused desktop reinstall loops:
// 1. findOnPath() tried the empty extension FIRST, so an extensionless
// 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
// falling through to the bootstrap installer.
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
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 __dirname = path.dirname(fileURLToPath(import.meta.url))
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', () => {
@ -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', () => {
const source = readMain()
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)
const fnEnd = source.indexOf('\nfunction ', fnStart + 1)
const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd)

View file

@ -1,7 +1,7 @@
const assert = require('node:assert/strict')
const { test } = require('node:test')
import assert from 'node:assert/strict'
import { test } from 'node:test'
const { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } = require('./windows-user-env.cjs')
import { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } from './windows-user-env'
// ── parseRegQueryValue ─────────────────────────────────────────────────────
@ -42,25 +42,32 @@ test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () =>
test('readWindowsUserEnvVar returns null off Windows without spawning', () => {
let spawned = false
const exec = () => {
spawned = true
return ''
}
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null)
assert.equal(spawned, false)
})
test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => {
const calls = []
const exec = (cmd, args) => {
calls.push([cmd, args])
return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n'
}
const value = readWindowsUserEnvVar('HERMES_HOME', {
platform: 'win32',
env: { DRIVE: 'F:' },
exec
})
assert.equal(value, 'F:\\Hermes')
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 = () => {
throw new Error('reg exited 1')
}
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
// (HKCU\Environment).
@ -10,7 +10,7 @@
// gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading
// 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:
//
@ -20,15 +20,18 @@ const { execFileSync } = require('node:child_process')
// Returns the raw value string (spaces inside the value preserved), or null when
// the requested value line isn't present.
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+(.*)$/
for (const rawLine of String(stdout).split(/\r?\n/)) {
const line = rawLine.trim()
const match = line.match(typePattern)
if (match && match[1].toLowerCase() === name.toLowerCase()) {
return match[2]
}
}
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
// the common F:\... case. Unknown references are left verbatim.
function expandWindowsEnvRefs(value, env = process.env) {
if (!value) return value
if (!value) {return value}
return value.replace(/%([^%]+)%/g, (whole, name) => {
const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase())
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
// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero
// (the value doesn't exist), or when the value is empty.
function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync } = {}) {
if (platform !== 'win32' || !name) return null
function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync }: {
platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, exec?: typeof execFileSync | ((file?: string, args?: any) => string)
} = {}) {
if (platform !== 'win32' || !name) {return null}
let stdout
try {
stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], {
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.
return null
}
const raw = parseRegQueryValue(stdout, name)
if (raw == null) return null
if (raw == null) {return null}
const expanded = expandWindowsEnvRefs(raw, env).trim()
return expanded || null
}
module.exports = {
expandWindowsEnvRefs,
export { expandWindowsEnvRefs,
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')
const assert = require('node:assert/strict')
const path = require('node:path')
import assert from 'node:assert/strict'
import path from 'node:path'
import test from 'node:test'
const { isPackagedInstallPath } = require('./workspace-cwd.cjs')
import { isPackagedInstallPath } from './workspace-cwd'
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. */
function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
function isPackagedInstallPath(dir, { installRoots, isPackaged }: { installRoots: string[], isPackaged:boolean }) {
if (!isPackaged || !dir) {
return false
}
@ -21,7 +21,7 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
return true
}
const rel = path.relative(root, resolved)
const rel = path.relative(root, resolved) as any
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
return true
@ -31,4 +31,4 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
return false
}
module.exports = { isPackagedInstallPath }
export { isPackagedInstallPath }

View file

@ -1,12 +1,10 @@
const assert = require('node:assert/strict')
const test = require('node:test')
import assert from 'node:assert/strict'
import test from 'node:test'
const {
decodeClipboardImageBase64,
import { decodeClipboardImageBase64,
encodePowerShellCommand,
powershellCandidates,
readWslWindowsClipboardImage
} = require('./wsl-clipboard-image.cjs')
readWslWindowsClipboardImage } from './wsl-clipboard-image'
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', () => {
const png = fakePngBuffer()
const calls = []
const exec = (cmd, args) => {
const exec = ((cmd, args) => {
calls.push({ cmd, args })
return png.toString('base64')
}
}) as any
const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe'] })
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)', () => {
let count = 0
const exec = () => {
const exec =(() => {
count += 1
return ''
}
return ''
} )as any
const result = readWslWindowsClipboardImage({
exec,
candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
})
assert.equal(result, null)
// Empty stdout means "no image on the clipboard" — don't probe further candidates.
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', () => {
const png = fakePngBuffer()
const seen = []
const exec = cmd => {
seen.push(cmd)
if (cmd === 'powershell.exe') {
throw Object.assign(new Error('not found'), { code: 'ENOENT' })
}
return png.toString('base64')
return png.toString('base64') as any
}
const result = readWslWindowsClipboardImage({
exec,
candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
})
assert.ok(result && result.equals(png))
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
// 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
// off a single-threaded apartment. We emit base64 (not raw bytes) so the PNG
@ -33,9 +33,11 @@ function powershellCandidates() {
function decodeClipboardImageBase64(stdout) {
const b64 = String(stdout || '').trim()
if (!b64) return null
if (!b64) {return null}
let buffer
try {
buffer = Buffer.from(b64, 'base64')
} catch {
@ -44,6 +46,7 @@ function decodeClipboardImageBase64(stdout) {
// Guard against partial / garbage output: require a real PNG signature.
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)) {
return null
}
@ -54,7 +57,7 @@ function decodeClipboardImageBase64(stdout) {
// 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.
// 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)
for (const ps of candidates) {
@ -72,10 +75,13 @@ function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powers
stdio: ['ignore', 'pipe', 'ignore']
}
)
const decoded = decodeClipboardImageBase64(stdout)
if (decoded) return decoded
if (decoded) {return decoded}
// 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 {
// This powershell.exe candidate is missing/failed — try the next one.
}
@ -84,9 +90,7 @@ function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powers
return null
}
module.exports = {
decodeClipboardImageBase64,
export { decodeClipboardImageBase64,
encodePowerShellCommand,
powershellCandidates,
readWslWindowsClipboardImage
}
readWslWindowsClipboardImage }

View file

@ -4,10 +4,10 @@
* roundtrip stability of the preset percentages.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from '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', () => {
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', () => {
const levels = [90, 100, 110, 125, 150, 175].map(percentToZoomLevel)
for (let i = 1; i < levels.length; i++) {
assert.ok(levels[i] > levels[i - 1])
}

View file

@ -6,29 +6,24 @@
* 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 MIN_ZOOM_LEVEL = -9
const MAX_ZOOM_LEVEL = 9
function clampZoomLevel(value) {
if (!Number.isFinite(value)) return 0
export function clampZoomLevel(value) {
if (!Number.isFinite(value)) {return 0}
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)
}
function percentToZoomLevel(percent) {
if (!Number.isFinite(percent) || percent <= 0) return 0
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
}
export function percentToZoomLevel(percent) {
if (!Number.isFinite(percent) || percent <= 0) {return 0}
module.exports = {
ZOOM_STORAGE_KEY,
clampZoomLevel,
percentToZoomLevel,
zoomLevelToPercent
}
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
}

View file

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

View file

@ -6,22 +6,22 @@
"description": "Native desktop shell for Hermes Agent.",
"author": "Nous Research",
"type": "module",
"main": "electron/main.cjs",
"main": "dist/electron-main.mjs",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"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:renderer": "node scripts/assert-root-install.cjs && 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 .",
"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: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 .",
"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 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 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 NODE_OPTIONS=tsx HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron electron/main.ts",
"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",
"postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
"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.mjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
"dist:mac": "npm run build && npm run builder -- --mac",
@ -37,14 +37,14 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/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",
"lint": "eslint src/ electron/",
"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",
"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": {
"@assistant-ui/react": "^0.12.28",
@ -117,6 +117,7 @@
"web-haptics": "^0.0.6"
},
"devDependencies": {
"@electron/rebuild": "^4.0.6",
"@eslint/js": "^9.39.4",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
@ -132,6 +133,7 @@
"cross-env": "^10.1.0",
"electron": "40.10.2",
"electron-builder": "^26.8.1",
"esbuild": "^0.28.1",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0",
"eslint-plugin-react": "^7.37.5",
@ -141,6 +143,7 @@
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"rcedit": "^5.0.2",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5",
@ -167,29 +170,24 @@
"files": [
"dist/**",
"assets/**",
"electron/**",
"public/**",
"package.json"
],
"beforeBuild": "scripts/before-build.cjs",
"beforePack": "scripts/before-pack.cjs",
"afterPack": "scripts/after-pack.cjs",
"beforeBuild": "scripts/before-build.mjs",
"beforePack": "scripts/before-pack.mjs",
"afterPack": "scripts/after-pack.mjs",
"extraResources": [
{
"from": "build/install-stamp.json",
"to": "install-stamp.json"
},
{
"from": "build/native-deps",
"to": "native-deps"
},
{
"from": "assets/icon.ico",
"to": "icon.ico"
}
],
"asar": true,
"afterSign": "scripts/notarize.cjs",
"afterSign": "scripts/notarize.mjs",
"asarUnpack": [
"**/*.node",
"**/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
* 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
* 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
@ -19,18 +19,18 @@
* - 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') {
return
}
const productName = context.packager?.appInfo?.productFilename || 'Hermes'
const exe = path.join(context.appOutDir, `${productName}.exe`)
const desktopRoot = path.resolve(__dirname, '..')
const desktopRoot = path.resolve(import.meta.dirname, '..')
try {
await stampExeIdentity(exe, desktopRoot)

View file

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

View file

@ -1,10 +1,10 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
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'
const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs')
import { checkDistBuilt } from '../scripts/assert-dist-built.mjs'
function makeDist(extra) {
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
* deterministic across environments. The Hermes Agent Python payload is no
* 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
}

View file

@ -1,10 +1,11 @@
'use strict'
/**
* before-pack.cjs electron-builder beforePack hook.
* before-pack.mjs electron-builder beforePack hook.
*
* Removes any stale unpacked app directory (`appOutDir`) before
* electron-builder stages the Electron binaries into it.
* Two responsibilities:
*
* 1. Removes any stale unpacked app directory (`appOutDir`) before
* electron-builder stages the Electron binaries into it.
*
* WHY THIS EXISTS
* ---------------
@ -41,30 +42,41 @@
* resolve rather than throw worst case electron-builder hits the original
* 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:
* - appOutDir: the unpacked app directory about to be staged
* - 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')
function cleanStaleAppOutDir(appOutDir) {
export function cleanStaleAppOutDir(appOutDir) {
if (!appOutDir || typeof appOutDir !== 'string') {
return false
}
if (!fs.existsSync(appOutDir)) {
if (!existsSync(appOutDir)) {
return false
}
// Recursive + force so a half-written tree (read-only bits, partial files)
// can't block the wipe. retry/maxRetries rides out transient EBUSY on
// 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
}
exports.cleanStaleAppOutDir = cleanStaleAppOutDir
exports.default = async function beforePack(context) {
export default async function beforePack(context) {
const appOutDir = context && context.appOutDir
try {
if (cleanStaleAppOutDir(appOutDir)) {
@ -75,4 +87,26 @@ exports.default = async function beforePack(context) {
// directory (permissions, mount) is still diagnosable.
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')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
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'
const { cleanStaleAppOutDir } = require('../scripts/before-pack.cjs')
import beforePack, { cleanStaleAppOutDir } from '../scripts/before-pack.mjs'
test('cleanStaleAppOutDir removes a populated unpacked directory', () => {
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 () => {
const { default: beforePack } = require('../scripts/before-pack.cjs')
// 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
// 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')
const os = require('node:os')
const path = require('node:path')
const { execFile } = require('node:child_process')
import { existsSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { execFile } from 'node:child_process'
function run(command, args) {
return new Promise((resolve, reject) => {
@ -26,7 +26,7 @@ function resolveApiKeyPath(rawValue) {
const value = String(rawValue || '').trim()
if (!value) return { keyPath: '', cleanup: () => {} }
if (fs.existsSync(value)) {
if (existsSync(value)) {
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')
}
const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`)
fs.writeFileSync(tempPath, value, 'utf8')
const tempPath = join(tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`)
writeFileSync(tempPath, value, 'utf8')
return {
keyPath: tempPath,
cleanup: () => fs.rmSync(tempPath, { force: true })
cleanup: () => rmSync(tempPath, { force: true })
}
}
async function main() {
const artifactPath = process.argv[2]
if (!artifactPath || !fs.existsSync(artifactPath)) {
if (!artifactPath || !existsSync(artifactPath)) {
throw new Error(`Missing artifact to notarize: ${artifactPath || '(none)'}`)
}

View file

@ -1,7 +1,7 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFile } = require('node:child_process')
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFile } from 'node:child_process'
function run(command, args) {
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
if (electronPlatformName !== 'darwin') return

View file

@ -1,11 +1,11 @@
const fs = require('node:fs')
const path = require('node:path')
import fs from 'node:fs'
import path from 'node:path'
if (process.platform !== 'darwin') {
process.exit(0)
}
const desktopRoot = path.resolve(__dirname, '..')
const desktopRoot = path.resolve(import.meta.dirname, '..')
const repoRoot = path.resolve(desktopRoot, '..', '..')
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
// re-unpack a broken Electron.app; reusing the installed dist dodges that.
// npm workspace hoisting is non-deterministic — require.resolve finds electron
// wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let
// electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR).
const fs = require("node:fs")
const path = require("node:path")
const { spawnSync } = require("node:child_process")
import fs from "node:fs"
import path from "node:path"
import { spawnSync } from "node:child_process"
import { createRequire } from "node:module"
const require = createRequire(import.meta.url)
function electronDistDir() {
try {

View file

@ -1,5 +1,5 @@
#!/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
// signing path.
//
@ -20,7 +20,7 @@
//
// 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
// --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
@ -28,40 +28,34 @@
// shipped a stock "Electron" exe. Keeping it in afterPack closes that gap.
//
// 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,
// 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).
const path = require('node:path')
const fs = require('node:fs')
import { resolve, join } from 'node:path'
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
// failure. `desktopRoot` defaults to this script's package root so the icon and
// the rcedit dependency resolve regardless of cwd.
async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..')) {
if (!exe || !fs.existsSync(exe)) {
async function stampExeIdentity(exe, desktopRoot = resolve(import.meta.dirname, '..')) {
if (!exe || !existsSync(exe)) {
throw new Error(`target exe not found: ${exe}`)
}
// Icon lives at apps/desktop/assets/icon.ico
const icon = path.join(desktopRoot, 'assets', 'icon.ico')
if (!fs.existsSync(icon)) {
const icon = join(desktopRoot, 'assets', 'icon.ico')
if (!existsSync(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] icon: ${icon}`)
@ -78,13 +72,13 @@ async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..')
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>`.
if (require.main === module) {
// CLI entry point: `node scripts/set-exe-identity.mjs <exe>`.
if (isMain(import.meta.url)) {
const exe = process.argv[2]
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)
}
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 { listPackage } from '@electron/asar'
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8'))
import PACKAGE_JSON from '../package.json' with { type: 'json' }
const MODE = process.argv[2] || 'help'
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 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
// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own
// HERMES_HOME and never touches this.
@ -83,17 +84,23 @@ function exists(target) {
return fs.existsSync(target)
}
// Match nodepty native binding location to what main.cjs's resolver fallback
// expects (apps/desktop/electron/main.cjs, packaged-build branch). Upstream
// node-pty 1.x is N-API based and ships per-arch prebuilts under
// prebuilds/<platform>-<arch>/ instead of build/Release/. We check the
// per-arch dir since that's what stage-native-deps actually copies.
// Match node-pty native binding location to what the bundled electron-main.cjs
// resolves at runtime. stage-native-deps.mjs stages node-pty into
// dist/node_modules/node-pty, and dist/** is asarUnpacked (see package.json
// build.asarUnpack), so in a packaged build it lands under
// 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() {
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 buildReleaseDir = path.join(root, 'build', 'Release')
return {
packageJson: path.join(root, 'package.json'),
prebuildsDir,
buildReleaseDir,
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
// launch via install.ps1's stage protocol).
// - install-stamp.json IS shipped in resources/ with a valid commit + branch.
// - native-deps/@homebridge/node-pty-prebuilt-multiarch/ IS shipped with
// the package.json + lib/ + at least one .node binary (the renderer's
// - node-pty IS shipped inside app.asar.unpacked/dist/node_modules/node-pty
// with package.json + lib/ + at least one .node binary (the renderer's
// integrated terminal needs this; see Phase 1F.6).
// - The renderer's dist/index.html is reachable (either unpacked or
// inside app.asar).
@ -320,24 +327,35 @@ function validateBundle() {
// Positive assertion: node-pty native deps shipped
const native = expectedNativeDepPaths()
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)) {
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)) {
die(`Missing node-pty prebuilds dir for ${PLATFORM}-${ARCH}: ${native.prebuildsDir}`)
// The native binary lands in prebuilds/<platform>-<arch>/ (downloaded prebuild)
// 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) {
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
// it manifests as "ENOENT: spawn-helper" on first pty.spawn() call.
if (PLATFORM === 'darwin') {
const spawnHelper = path.join(native.prebuildsDir, 'spawn-helper')
if (!exists(spawnHelper)) {
die(`Missing node-pty spawn-helper (required on darwin): ${spawnHelper}`)
const spawnHelper = nativeBinaryDirs
.map(dir => path.join(dir, 'spawn-helper'))
.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
* .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
* 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):
* {
@ -26,16 +26,16 @@
* bootstrap without a stamp.
*/
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
import { mkdirSync, writeFileSync } from "fs"
import { resolve, join, relative } from "path"
import { execSync } from "child_process"
const STAMP_SCHEMA_VERSION = 1
const DESKTOP_ROOT = path.resolve(__dirname, "..")
const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..")
const OUT_DIR = path.join(DESKTOP_ROOT, "build")
const OUT_FILE = path.join(OUT_DIR, "install-stamp.json")
const DESKTOP_ROOT = resolve(import.meta.dirname, "..")
const REPO_ROOT = resolve(DESKTOP_ROOT, "..", "..")
const OUT_DIR = join(DESKTOP_ROOT, "build")
const OUT_FILE = join(OUT_DIR, "install-stamp.json")
function tryExec(cmd, opts) {
try {
@ -111,11 +111,11 @@ function main() {
source: stamp.source
}
fs.mkdirSync(OUT_DIR, { recursive: true })
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8")
mkdirSync(OUT_DIR, { recursive: true })
writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8")
console.log(
"[write-build-stamp] wrote " +
path.relative(REPO_ROOT, OUT_FILE) +
relative(REPO_ROOT, OUT_FILE) +
" -> " +
stamp.commit.slice(0, 12) +
(stamp.branch ? " (" + stamp.branch + ")" : "") +

View file

@ -847,7 +847,7 @@ export function DesktopController() {
// window's gateway (the overlay has none) so it survives restart.
setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale))
// 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.
setPetOverlayOpenAppHandler(() => {
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 () => {
// 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.
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,

View file

@ -21,9 +21,9 @@ import {
_submitInFlight,
type GatewayRequest,
inlineErrorMessage,
isGatewayTimeoutError,
isProviderSetupError,
isSessionBusyError,
isGatewayTimeoutError,
isSessionNotFoundError,
type SubmitTextOptions,
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
// 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
// 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

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