mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
feat(desktop): ts-ify everything
This commit is contained in:
parent
fac85518fc
commit
39d09453f9
116 changed files with 2500 additions and 1756 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//!
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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`)
|
||||
}
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 () => {
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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=/)
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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' })
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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/)
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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')
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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-'))
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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')
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 = {
|
||||
|
|
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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])
|
||||
|
|
@ -14,7 +14,5 @@ function setJsonRequestHeaders(request) {
|
|||
request.setHeader('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serializeJsonBody,
|
||||
setJsonRequestHeaders
|
||||
}
|
||||
export { serializeJsonBody,
|
||||
setJsonRequestHeaders }
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
|
|
@ -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(
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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/)
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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([
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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/)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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')
|
||||
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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'])
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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/**",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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-'))
|
||||
|
|
@ -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)
|
||||
}
|
||||
11
apps/desktop/scripts/assert-root-install.mjs
Normal file
11
apps/desktop/scripts/assert-root-install.mjs
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
60
apps/desktop/scripts/bundle-electron-main.mjs
Normal file
60
apps/desktop/scripts/bundle-electron-main.mjs
Normal 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}`)
|
||||
|
|
@ -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)'}`)
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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')
|
||||
|
||||
22
apps/desktop/scripts/rebuild-native.mjs
Normal file
22
apps/desktop/scripts/rebuild-native.mjs
Normal 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 })
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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 => {
|
||||
|
|
@ -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()
|
||||
137
apps/desktop/scripts/stage-native-deps.mjs
Normal file
137
apps/desktop/scripts/stage-native-deps.mjs
Normal 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 })
|
||||
}
|
||||
|
|
@ -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(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
8
apps/desktop/scripts/utils.mjs
Normal file
8
apps/desktop/scripts/utils.mjs
Normal 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;
|
||||
}
|
||||
|
|
@ -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 + ")" : "") +
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ import {
|
|||
_submitInFlight,
|
||||
type GatewayRequest,
|
||||
inlineErrorMessage,
|
||||
isGatewayTimeoutError,
|
||||
isProviderSetupError,
|
||||
isSessionBusyError,
|
||||
isGatewayTimeoutError,
|
||||
isSessionNotFoundError,
|
||||
type SubmitTextOptions,
|
||||
withSessionBusyRetry
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue