mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 15:55:37 +08:00
Compare commits
10 Commits
feat/slack
...
bb/main-ve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d19cfbb78 | ||
|
|
150e023fe2 | ||
|
|
8a48d54193 | ||
|
|
e167ed7bb1 | ||
|
|
0ed0c2d39f | ||
|
|
c147270a1c | ||
|
|
880f5837a1 | ||
|
|
f3ce17bf9e | ||
|
|
b29bb6ef9d | ||
|
|
025c8f0604 |
105
apps/desktop/electron/fs-ipc.cjs
Normal file
105
apps/desktop/electron/fs-ipc.cjs
Normal file
@@ -0,0 +1,105 @@
|
||||
'use strict'
|
||||
|
||||
const { shell } = require('electron')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
// Filesystem IPC: read-dir, git-root, reveal, rename, write-text, trash. Path
|
||||
// hardening + `~` expansion + dir-existence checks live in the main process and
|
||||
// are injected so this module stays side-effect free.
|
||||
function registerFsIpc({ directoryExists, expandUserPath, ipcMain }) {
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
|
||||
// Reveal a path in the OS file manager (Finder / Explorer / Files).
|
||||
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
shell.showItemInFolder(target)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Rename a file/folder in place. The renderer passes the existing path + a new
|
||||
// base name; the destination is resolved in the SAME parent dir so a rename can
|
||||
// never move the item elsewhere or traverse out. Rejects on a name collision.
|
||||
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
|
||||
const src = String(targetPath || '').trim()
|
||||
const name = String(newName || '').trim()
|
||||
|
||||
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
||||
throw new Error('Invalid rename')
|
||||
}
|
||||
|
||||
const dst = path.join(path.dirname(src), name)
|
||||
|
||||
if (dst === src) {
|
||||
return { path: dst }
|
||||
}
|
||||
|
||||
if (fs.existsSync(dst)) {
|
||||
throw new Error(`"${name}" already exists`)
|
||||
}
|
||||
|
||||
await fs.promises.rename(src, dst)
|
||||
|
||||
return { path: dst }
|
||||
})
|
||||
|
||||
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
|
||||
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
|
||||
// this never creates directory trees or escapes the allowed roots, and content
|
||||
// is size-capped so it can't be abused as a bulk-write primitive.
|
||||
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
|
||||
const raw = String(filePath || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error('Invalid path')
|
||||
}
|
||||
|
||||
const text = String(content ?? '')
|
||||
|
||||
if (text.length > 1_000_000) {
|
||||
throw new Error('Content too large')
|
||||
}
|
||||
|
||||
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
|
||||
|
||||
if (!directoryExists(path.dirname(resolved))) {
|
||||
throw new Error('Parent directory does not exist')
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(resolved, text, 'utf8')
|
||||
|
||||
return { path: resolved }
|
||||
})
|
||||
|
||||
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
|
||||
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
|
||||
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
throw new Error('Invalid delete')
|
||||
}
|
||||
|
||||
await shell.trashItem(target)
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerFsIpc }
|
||||
49
apps/desktop/electron/fs-ipc.test.cjs
Normal file
49
apps/desktop/electron/fs-ipc.test.cjs
Normal file
@@ -0,0 +1,49 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerFsIpc } = require('./fs-ipc.cjs')
|
||||
|
||||
function fakeIpcMain() {
|
||||
const handlers = new Map()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('registerFsIpc wires only hermes:fs:* channels, each to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerFsIpc({ ipcMain, directoryExists: () => true, expandUserPath: p => p })
|
||||
|
||||
assert.ok(ipcMain.handlers.size >= 6, `expected the full fs surface, got ${ipcMain.handlers.size}`)
|
||||
|
||||
for (const [channel, handler] of ipcMain.handlers) {
|
||||
assert.match(channel, /^hermes:fs:/, `${channel} is not an fs channel`)
|
||||
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
|
||||
}
|
||||
|
||||
for (const channel of ['hermes:fs:readDir', 'hermes:fs:rename', 'hermes:fs:trash']) {
|
||||
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('rename rejects names that traverse out of the parent dir', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerFsIpc({ ipcMain, directoryExists: () => true, expandUserPath: p => p })
|
||||
|
||||
for (const bad of ['..', '.', 'a/b', 'a\\b']) {
|
||||
await assert.rejects(
|
||||
() => ipcMain.handlers.get('hermes:fs:rename')({}, '/tmp/x', bad),
|
||||
/Invalid rename/,
|
||||
`"${bad}" should be rejected`
|
||||
)
|
||||
}
|
||||
})
|
||||
96
apps/desktop/electron/git-ipc.cjs
Normal file
96
apps/desktop/electron/git-ipc.cjs
Normal file
@@ -0,0 +1,96 @@
|
||||
'use strict'
|
||||
|
||||
const { scanGitRepos } = require('./git-repo-scan.cjs')
|
||||
const {
|
||||
fileDiffVsHead,
|
||||
repoStatus,
|
||||
reviewCommit,
|
||||
reviewCommitContext,
|
||||
reviewCreatePr,
|
||||
reviewDiff,
|
||||
reviewList,
|
||||
reviewPush,
|
||||
reviewRevParse,
|
||||
reviewRevert,
|
||||
reviewShipInfo,
|
||||
reviewStage,
|
||||
reviewUnstage
|
||||
} = require('./git-review-ops.cjs')
|
||||
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
|
||||
|
||||
// Register the git/worktree/review IPC handlers. Thin delegators to the
|
||||
// git-*-ops sibling modules; the git/gh binary resolution lives in the main
|
||||
// process (Windows PATH discovery) and is injected so this module stays pure.
|
||||
function registerGitIpc({ ipcMain, resolveGitBinary, resolveGhBinary }) {
|
||||
// Git-driven worktree management ("Start work" flow). Errors surface to the
|
||||
// renderer as rejected promises so it can toast a friendly message.
|
||||
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
|
||||
addWorktree(repoPath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
|
||||
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
||||
switchBranch(repoPath, branch, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
// hides cleanly rather than erroring.
|
||||
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
|
||||
|
||||
// Codex-style review pane: list changed files for a scope, fetch one file's
|
||||
// unified diff, and stage / unstage / revert. Reads return empty on failure;
|
||||
// mutations reject so the renderer can toast.
|
||||
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
|
||||
reviewList(repoPath, scope, baseRef, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
|
||||
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
|
||||
)
|
||||
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
|
||||
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
|
||||
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
|
||||
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
|
||||
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
|
||||
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
|
||||
reviewRevParse(repoPath, ref, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
|
||||
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
|
||||
reviewCommitContext(repoPath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
|
||||
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
|
||||
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
|
||||
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
|
||||
)
|
||||
|
||||
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
|
||||
// no native addon). Never throws to the renderer — failures yield an empty list.
|
||||
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
|
||||
try {
|
||||
return await scanGitRepos(roots || [], options || {})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerGitIpc }
|
||||
61
apps/desktop/electron/git-ipc.test.cjs
Normal file
61
apps/desktop/electron/git-ipc.test.cjs
Normal file
@@ -0,0 +1,61 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerGitIpc } = require('./git-ipc.cjs')
|
||||
|
||||
function fakeIpcMain() {
|
||||
const handlers = new Map()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('registerGitIpc wires only hermes:git:* channels, each to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerGitIpc({ ipcMain, resolveGitBinary: () => 'git', resolveGhBinary: () => 'gh' })
|
||||
|
||||
assert.ok(ipcMain.handlers.size >= 19, `expected the full git surface, got ${ipcMain.handlers.size}`)
|
||||
|
||||
for (const [channel, handler] of ipcMain.handlers) {
|
||||
assert.match(channel, /^hermes:git:/, `${channel} is not a git channel`)
|
||||
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
|
||||
}
|
||||
|
||||
// Spot-check the load-bearing channels across the worktree / review / scan groups.
|
||||
for (const channel of ['hermes:git:worktreeList', 'hermes:git:review:commit', 'hermes:git:scanRepos']) {
|
||||
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('handlers thread the injected resolver into the ops layer', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
const calls = []
|
||||
|
||||
registerGitIpc({
|
||||
ipcMain,
|
||||
resolveGitBinary: () => {
|
||||
calls.push('git')
|
||||
|
||||
return 'git'
|
||||
},
|
||||
resolveGhBinary: () => 'gh'
|
||||
})
|
||||
|
||||
// The resolver is consulted synchronously to build the ops call; whatever the
|
||||
// ops layer does with a non-repo path is irrelevant to the wiring.
|
||||
try {
|
||||
await ipcMain.handlers.get('hermes:git:worktreeList')({}, '/definitely/not/a/repo')
|
||||
} catch {
|
||||
// ops layer may reject on a bad path — not what this test asserts.
|
||||
}
|
||||
|
||||
assert.deepEqual(calls, ['git'])
|
||||
})
|
||||
27
apps/desktop/electron/logs-ipc.cjs
Normal file
27
apps/desktop/electron/logs-ipc.cjs
Normal file
@@ -0,0 +1,27 @@
|
||||
'use strict'
|
||||
|
||||
const { shell } = require('electron')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// Desktop-log IPC: reveal the log file in the OS file manager + return the
|
||||
// recent in-memory tail. The log path, the in-memory ring buffer, and the
|
||||
// file-exists probe live in the main process and are injected.
|
||||
function registerLogsIpc({ DESKTOP_LOG_PATH, fileExists, hermesLog, ipcMain }) {
|
||||
ipcMain.handle('hermes:logs:reveal', async () => {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
||||
if (!fileExists(DESKTOP_LOG_PATH)) {
|
||||
await fs.promises.appendFile(DESKTOP_LOG_PATH, '')
|
||||
}
|
||||
shell.showItemInFolder(DESKTOP_LOG_PATH)
|
||||
return { ok: true, path: DESKTOP_LOG_PATH }
|
||||
} catch (error) {
|
||||
return { ok: false, path: DESKTOP_LOG_PATH, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
|
||||
}
|
||||
|
||||
module.exports = { registerLogsIpc }
|
||||
44
apps/desktop/electron/logs-ipc.test.cjs
Normal file
44
apps/desktop/electron/logs-ipc.test.cjs
Normal file
@@ -0,0 +1,44 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerLogsIpc } = require('./logs-ipc.cjs')
|
||||
|
||||
function fakeIpcMain() {
|
||||
const handlers = new Map()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('registerLogsIpc wires only hermes:logs:* channels, each to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerLogsIpc({ ipcMain, DESKTOP_LOG_PATH: '/tmp/desktop.log', fileExists: () => true, hermesLog: [] })
|
||||
|
||||
assert.deepEqual([...ipcMain.handlers.keys()].sort(), ['hermes:logs:recent', 'hermes:logs:reveal'])
|
||||
|
||||
for (const handler of ipcMain.handlers.values()) {
|
||||
assert.equal(typeof handler, 'function')
|
||||
}
|
||||
})
|
||||
|
||||
test('logs:recent returns the injected path and the last 200 buffered lines', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
const hermesLog = Array.from({ length: 250 }, (_, i) => `line ${i}`)
|
||||
|
||||
registerLogsIpc({ ipcMain, DESKTOP_LOG_PATH: '/tmp/desktop.log', fileExists: () => true, hermesLog })
|
||||
|
||||
const res = await ipcMain.handlers.get('hermes:logs:recent')({})
|
||||
|
||||
assert.equal(res.path, '/tmp/desktop.log')
|
||||
assert.equal(res.lines.length, 200)
|
||||
assert.equal(res.lines[0], 'line 50')
|
||||
assert.equal(res.lines.at(-1), 'line 249')
|
||||
})
|
||||
@@ -41,12 +41,10 @@ const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs')
|
||||
const { dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
||||
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
||||
const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs')
|
||||
const { nativeOverlayWidth: computeNativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { readLiveUpdateMarker } = require('./update-marker.cjs')
|
||||
const {
|
||||
resolveUnpackedRelease,
|
||||
@@ -57,24 +55,15 @@ const {
|
||||
collectRelaunchEnv,
|
||||
buildRelaunchScript
|
||||
} = require('./update-relaunch.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
|
||||
const {
|
||||
fileDiffVsHead,
|
||||
repoStatus,
|
||||
reviewCommit,
|
||||
reviewCommitContext,
|
||||
reviewCreatePr,
|
||||
reviewDiff,
|
||||
reviewList,
|
||||
reviewPush,
|
||||
reviewRevParse,
|
||||
reviewRevert,
|
||||
reviewShipInfo,
|
||||
reviewStage,
|
||||
reviewUnstage
|
||||
} = require('./git-review-ops.cjs')
|
||||
const { scanGitRepos } = require('./git-repo-scan.cjs')
|
||||
const { registerGitIpc } = require('./git-ipc.cjs')
|
||||
const { registerFsIpc } = require('./fs-ipc.cjs')
|
||||
const { registerTerminalIpc } = require('./terminal-ipc.cjs')
|
||||
const { registerUpdatesIpc } = require('./updates-ipc.cjs')
|
||||
const { registerLogsIpc } = require('./logs-ipc.cjs')
|
||||
const { registerProjectDirIpc } = require('./project-dir-ipc.cjs')
|
||||
const { registerVscodeThemeIpc } = require('./vscode-theme-ipc.cjs')
|
||||
const { registerUninstallIpc } = require('./uninstall-ipc.cjs')
|
||||
const { registerVersionIpc } = require('./version-ipc.cjs')
|
||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
|
||||
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||
@@ -1361,10 +1350,7 @@ function backendSupportsServe(backend) {
|
||||
let supported = null
|
||||
if (backend.root) {
|
||||
try {
|
||||
const src = fs.readFileSync(
|
||||
path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'),
|
||||
'utf8'
|
||||
)
|
||||
const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8')
|
||||
supported = sourceDeclaresServe(src)
|
||||
} catch {
|
||||
supported = null // source unreadable — fall through to the probe
|
||||
@@ -2292,9 +2278,7 @@ async function handOffWindowsBootstrapRecovery(reason) {
|
||||
// --repair (full venv recreate) and drove reinstall loops. The venv interpreter
|
||||
// and the bootstrap-complete marker are present earlier and are better signals.
|
||||
const haveRealInstall =
|
||||
fileExists(venvPython) ||
|
||||
fileExists(venvHermes) ||
|
||||
fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
|
||||
fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
|
||||
const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
@@ -6682,60 +6666,20 @@ ipcMain.handle('hermes:openPreviewInBrowser', async (_event, url) => {
|
||||
// settings mount and seeds the value into the picker; writing back persists
|
||||
// it via writeDefaultProjectDir so resolveHermesCwd picks it up on the next
|
||||
// session spawn (no app restart needed).
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
|
||||
dir: readDefaultProjectDir(),
|
||||
defaultLabel: app.getPath('home'),
|
||||
resolvedCwd: resolveHermesCwd()
|
||||
}))
|
||||
|
||||
ipcMain.handle('hermes:workspace:sanitize', async (_event, cwd) => sanitizeWorkspaceCwd(cwd))
|
||||
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
|
||||
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
|
||||
|
||||
if (next) {
|
||||
try {
|
||||
fs.mkdirSync(next, { recursive: true })
|
||||
} catch (error) {
|
||||
throw new Error(`Could not create directory: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
writeDefaultProjectDir(next)
|
||||
|
||||
return { dir: next }
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Choose default project directory',
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: readDefaultProjectDir() || app.getPath('home')
|
||||
})
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { canceled: true, dir: null }
|
||||
}
|
||||
|
||||
return { canceled: false, dir: result.filePaths[0] }
|
||||
// Default-project-dir + workspace settings IPC lives in project-dir-ipc.cjs;
|
||||
// config readers/writers + cwd resolvers are injected.
|
||||
registerProjectDirIpc({
|
||||
ipcMain,
|
||||
readDefaultProjectDir,
|
||||
resolveHermesCwd,
|
||||
sanitizeWorkspaceCwd,
|
||||
writeDefaultProjectDir
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
|
||||
|
||||
ipcMain.handle('hermes:logs:reveal', async () => {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
||||
if (!fileExists(DESKTOP_LOG_PATH)) {
|
||||
await fs.promises.appendFile(DESKTOP_LOG_PATH, '')
|
||||
}
|
||||
shell.showItemInFolder(DESKTOP_LOG_PATH)
|
||||
return { ok: true, path: DESKTOP_LOG_PATH }
|
||||
} catch (error) {
|
||||
return { ok: false, path: DESKTOP_LOG_PATH, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
|
||||
// Desktop-log IPC lives in logs-ipc.cjs; log path + ring buffer are injected.
|
||||
registerLogsIpc({ DESKTOP_LOG_PATH, fileExists, hermesLog, ipcMain })
|
||||
|
||||
function isExecutableFile(filePath) {
|
||||
if (!filePath || !path.isAbsolute(filePath)) {
|
||||
@@ -6919,257 +6863,36 @@ function disposeTerminalSession(id) {
|
||||
return true
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
|
||||
// Filesystem IPC lives in fs-ipc.cjs; main-process path helpers are injected.
|
||||
registerFsIpc({ ipcMain, directoryExists, expandUserPath })
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
// Git/worktree/review IPC lives in git-ipc.cjs; the git + gh binary resolvers
|
||||
// stay here (Windows PATH discovery) and are injected into the registrar.
|
||||
registerGitIpc({ ipcMain, resolveGitBinary, resolveGhBinary })
|
||||
|
||||
// Reveal a path in the OS file manager (Finder / Explorer / Files).
|
||||
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
shell.showItemInFolder(target)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
// Terminal/PTY IPC lives in terminal-ipc.cjs; the PTY runtime, session
|
||||
// registry, and shell helpers stay in the main process and are injected.
|
||||
registerTerminalIpc({
|
||||
disposeTerminalSession,
|
||||
ensureSpawnHelperExecutable,
|
||||
ipcMain,
|
||||
nodePty,
|
||||
safeTerminalCwd,
|
||||
terminalChannel,
|
||||
terminalSessions,
|
||||
terminalShellCommand,
|
||||
terminalShellEnv
|
||||
})
|
||||
|
||||
// Rename a file/folder in place. The renderer passes the existing path + a new
|
||||
// base name; the destination is resolved in the SAME parent dir so a rename can
|
||||
// never move the item elsewhere or traverse out. Rejects on a name collision.
|
||||
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
|
||||
const src = String(targetPath || '').trim()
|
||||
const name = String(newName || '').trim()
|
||||
|
||||
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
||||
throw new Error('Invalid rename')
|
||||
}
|
||||
|
||||
const dst = path.join(path.dirname(src), name)
|
||||
|
||||
if (dst === src) {
|
||||
return { path: dst }
|
||||
}
|
||||
|
||||
if (fs.existsSync(dst)) {
|
||||
throw new Error(`"${name}" already exists`)
|
||||
}
|
||||
|
||||
await fs.promises.rename(src, dst)
|
||||
|
||||
return { path: dst }
|
||||
})
|
||||
|
||||
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
|
||||
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
|
||||
// this never creates directory trees or escapes the allowed roots, and content
|
||||
// is size-capped so it can't be abused as a bulk-write primitive.
|
||||
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
|
||||
const raw = String(filePath || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error('Invalid path')
|
||||
}
|
||||
|
||||
const text = String(content ?? '')
|
||||
|
||||
if (text.length > 1_000_000) {
|
||||
throw new Error('Content too large')
|
||||
}
|
||||
|
||||
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
|
||||
|
||||
if (!directoryExists(path.dirname(resolved))) {
|
||||
throw new Error('Parent directory does not exist')
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(resolved, text, 'utf8')
|
||||
|
||||
return { path: resolved }
|
||||
})
|
||||
|
||||
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
|
||||
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
|
||||
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
throw new Error('Invalid delete')
|
||||
}
|
||||
|
||||
await shell.trashItem(target)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Git-driven worktree management ("Start work" flow). Errors surface to the
|
||||
// renderer as rejected promises so it can toast a friendly message.
|
||||
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
|
||||
addWorktree(repoPath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
|
||||
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
||||
switchBranch(repoPath, branch, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
// hides cleanly rather than erroring.
|
||||
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
|
||||
|
||||
// Codex-style review pane: list changed files for a scope, fetch one file's
|
||||
// unified diff, and stage / unstage / revert. Reads return empty on failure;
|
||||
// mutations reject so the renderer can toast.
|
||||
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
|
||||
reviewList(repoPath, scope, baseRef, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
|
||||
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
|
||||
)
|
||||
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
|
||||
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
|
||||
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
|
||||
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
|
||||
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
|
||||
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
|
||||
reviewRevParse(repoPath, ref, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
|
||||
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
|
||||
reviewCommitContext(repoPath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
|
||||
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
|
||||
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
|
||||
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
|
||||
)
|
||||
|
||||
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
|
||||
// no native addon). Never throws to the renderer — failures yield an empty list.
|
||||
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
|
||||
try {
|
||||
return await scanGitRepos(roots || [], options || {})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
|
||||
}
|
||||
|
||||
ensureSpawnHelperExecutable()
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const { args, command, name } = terminalShellCommand()
|
||||
const cwd = safeTerminalCwd(payload?.cwd)
|
||||
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
|
||||
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
|
||||
const ptyProcess = nodePty.spawn(command, args, {
|
||||
cols,
|
||||
cwd,
|
||||
env: terminalShellEnv(),
|
||||
name: 'xterm-256color',
|
||||
rows
|
||||
})
|
||||
|
||||
terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id })
|
||||
|
||||
const send = (suffix, payload) => {
|
||||
if (event.sender.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
event.sender.send(terminalChannel(id, suffix), payload)
|
||||
}
|
||||
|
||||
ptyProcess.onData(data => send('data', data))
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
terminalSessions.delete(id)
|
||||
send('exit', { code: exitCode, signal: signal || null })
|
||||
})
|
||||
event.sender.once('destroyed', () => disposeTerminalSession(id))
|
||||
|
||||
return { cwd, id, shell: name }
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:write', (_event, id, data) => {
|
||||
const sessionInfo = terminalSessions.get(String(id || ''))
|
||||
|
||||
if (!sessionInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
sessionInfo.pty.write(String(data || ''))
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => {
|
||||
const sessionInfo = terminalSessions.get(String(id || ''))
|
||||
|
||||
if (!sessionInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80)
|
||||
const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24)
|
||||
|
||||
sessionInfo.pty.resize(cols, rows)
|
||||
|
||||
return true
|
||||
})
|
||||
ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))
|
||||
|
||||
ipcMain.handle('hermes:updates:check', async () =>
|
||||
checkUpdates().catch(error => ({
|
||||
supported: true,
|
||||
branch: readDesktopUpdateConfig().branch,
|
||||
error: 'check-failed',
|
||||
message: error?.message || String(error),
|
||||
fetchedAt: Date.now()
|
||||
}))
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:updates:apply', async (_event, payload) =>
|
||||
applyUpdates(payload || {}).catch(error => ({
|
||||
ok: false,
|
||||
error: 'apply-failed',
|
||||
message: error?.message || String(error)
|
||||
}))
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:updates:branch:get', async () => readDesktopUpdateConfig())
|
||||
|
||||
ipcMain.handle('hermes:updates:branch:set', async (_event, name) => {
|
||||
const branch = typeof name === 'string' && name.trim() ? name.trim() : DEFAULT_UPDATE_BRANCH
|
||||
writeDesktopUpdateConfig({ branch })
|
||||
return { branch }
|
||||
// Auto-update IPC lives in updates-ipc.cjs; the update engine + on-disk
|
||||
// config stay in the main process and are injected.
|
||||
registerUpdatesIpc({
|
||||
applyUpdates,
|
||||
checkUpdates,
|
||||
DEFAULT_UPDATE_BRANCH,
|
||||
ipcMain,
|
||||
readDesktopUpdateConfig,
|
||||
writeDesktopUpdateConfig
|
||||
})
|
||||
|
||||
// Resolve the canonical Hermes version (the one `release.py` bumps in
|
||||
@@ -7207,13 +6930,8 @@ function showAboutPanelFresh() {
|
||||
app.showAboutPanel()
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:version', async () => ({
|
||||
appVersion: resolveHermesVersion(),
|
||||
electronVersion: process.versions.electron,
|
||||
nodeVersion: process.versions.node,
|
||||
platform: process.platform,
|
||||
hermesRoot: resolveUpdateRoot()
|
||||
}))
|
||||
// App-version IPC lives in version-ipc.cjs; the version + root resolvers are injected.
|
||||
registerVersionIpc({ ipcMain, resolveHermesVersion, resolveUpdateRoot })
|
||||
|
||||
// ===========================================================================
|
||||
// Uninstall — remove the Chat GUI (and optionally the agent / user data).
|
||||
@@ -7406,18 +7124,11 @@ async function runDesktopUninstall(mode) {
|
||||
return { ok: true, mode, willRemoveAppBundle: Boolean(removeBundle), scriptPath }
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
|
||||
ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
|
||||
const mode = payload && typeof payload === 'object' ? payload.mode : payload
|
||||
return runDesktopUninstall(String(mode || ''))
|
||||
})
|
||||
// Uninstall IPC lives in uninstall-ipc.cjs; the uninstall engine is injected.
|
||||
registerUninstallIpc({ getUninstallSummary, ipcMain, runDesktopUninstall })
|
||||
|
||||
// Download a VS Code Marketplace extension and return the raw color-theme JSON
|
||||
// it contributes. No theme code is executed — we only read JSON from the .vsix.
|
||||
ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketplaceThemes(String(id || '')))
|
||||
|
||||
// Search the Marketplace for color-theme extensions (empty query = top installs).
|
||||
ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20))
|
||||
// VS Code Marketplace theme IPC lives in vscode-theme-ipc.cjs.
|
||||
registerVscodeThemeIpc({ ipcMain })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hermes:// deep links (e.g. hermes://blueprint/morning-brief?time=08:00).
|
||||
|
||||
55
apps/desktop/electron/project-dir-ipc.cjs
Normal file
55
apps/desktop/electron/project-dir-ipc.cjs
Normal file
@@ -0,0 +1,55 @@
|
||||
'use strict'
|
||||
|
||||
const { app, dialog } = require('electron')
|
||||
const fs = require('fs')
|
||||
|
||||
// Default-project-directory + workspace-cwd settings IPC: read / write / native
|
||||
// directory picker, plus workspace-cwd sanitize. The config readers/writers and
|
||||
// cwd resolvers live in the main process and are injected.
|
||||
function registerProjectDirIpc({
|
||||
ipcMain,
|
||||
readDefaultProjectDir,
|
||||
resolveHermesCwd,
|
||||
sanitizeWorkspaceCwd,
|
||||
writeDefaultProjectDir
|
||||
}) {
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
|
||||
dir: readDefaultProjectDir(),
|
||||
defaultLabel: app.getPath('home'),
|
||||
resolvedCwd: resolveHermesCwd()
|
||||
}))
|
||||
|
||||
ipcMain.handle('hermes:workspace:sanitize', async (_event, cwd) => sanitizeWorkspaceCwd(cwd))
|
||||
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
|
||||
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
|
||||
|
||||
if (next) {
|
||||
try {
|
||||
fs.mkdirSync(next, { recursive: true })
|
||||
} catch (error) {
|
||||
throw new Error(`Could not create directory: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
writeDefaultProjectDir(next)
|
||||
|
||||
return { dir: next }
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Choose default project directory',
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: readDefaultProjectDir() || app.getPath('home')
|
||||
})
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { canceled: true, dir: null }
|
||||
}
|
||||
|
||||
return { canceled: false, dir: result.filePaths[0] }
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerProjectDirIpc }
|
||||
63
apps/desktop/electron/project-dir-ipc.test.cjs
Normal file
63
apps/desktop/electron/project-dir-ipc.test.cjs
Normal file
@@ -0,0 +1,63 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerProjectDirIpc } = require('./project-dir-ipc.cjs')
|
||||
|
||||
function fakeIpcMain() {
|
||||
const handlers = new Map()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deps(overrides = {}) {
|
||||
return {
|
||||
readDefaultProjectDir: () => '/projects',
|
||||
resolveHermesCwd: () => '/cwd',
|
||||
sanitizeWorkspaceCwd: cwd => `safe:${cwd}`,
|
||||
writeDefaultProjectDir: () => {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test('registerProjectDirIpc wires the project-dir + workspace settings channels', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerProjectDirIpc({ ipcMain, ...deps() })
|
||||
|
||||
assert.deepEqual([...ipcMain.handlers.keys()].sort(), [
|
||||
'hermes:setting:defaultProjectDir:get',
|
||||
'hermes:setting:defaultProjectDir:pick',
|
||||
'hermes:setting:defaultProjectDir:set',
|
||||
'hermes:workspace:sanitize'
|
||||
])
|
||||
})
|
||||
|
||||
// `get` / `pick` touch Electron's `app` / `dialog`, which are unavailable under
|
||||
// `node --test` (require('electron') is a path stub), so they're exercised in-app
|
||||
// only. The wiring of all four channels is covered by the surface test above.
|
||||
|
||||
test('set normalizes a blank dir to null and persists that (clears the override)', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
const writes = []
|
||||
|
||||
registerProjectDirIpc({ ipcMain, ...deps({ writeDefaultProjectDir: d => writes.push(d) }) })
|
||||
|
||||
assert.deepEqual(await ipcMain.handlers.get('hermes:setting:defaultProjectDir:set')({}, ' '), { dir: null })
|
||||
assert.deepEqual(writes, [null])
|
||||
})
|
||||
|
||||
test('workspace:sanitize delegates to the injected sanitizer', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerProjectDirIpc({ ipcMain, ...deps() })
|
||||
|
||||
assert.equal(await ipcMain.handlers.get('hermes:workspace:sanitize')({}, '/x'), 'safe:/x')
|
||||
})
|
||||
89
apps/desktop/electron/terminal-ipc.cjs
Normal file
89
apps/desktop/electron/terminal-ipc.cjs
Normal file
@@ -0,0 +1,89 @@
|
||||
'use strict'
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
// Terminal (PTY) IPC: start / write / resize / dispose. The PTY runtime, the
|
||||
// shared session registry, and the shell-spec/env/cwd helpers all live in the
|
||||
// main process (deep Windows-PATH + app-path coupling) and are injected, so this
|
||||
// module only owns the request wiring.
|
||||
function registerTerminalIpc({
|
||||
disposeTerminalSession,
|
||||
ensureSpawnHelperExecutable,
|
||||
ipcMain,
|
||||
nodePty,
|
||||
safeTerminalCwd,
|
||||
terminalChannel,
|
||||
terminalSessions,
|
||||
terminalShellCommand,
|
||||
terminalShellEnv
|
||||
}) {
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
|
||||
}
|
||||
|
||||
ensureSpawnHelperExecutable()
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const { args, command, name } = terminalShellCommand()
|
||||
const cwd = safeTerminalCwd(payload?.cwd)
|
||||
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
|
||||
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
|
||||
const ptyProcess = nodePty.spawn(command, args, {
|
||||
cols,
|
||||
cwd,
|
||||
env: terminalShellEnv(),
|
||||
name: 'xterm-256color',
|
||||
rows
|
||||
})
|
||||
|
||||
terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id })
|
||||
|
||||
const send = (suffix, payload) => {
|
||||
if (event.sender.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
event.sender.send(terminalChannel(id, suffix), payload)
|
||||
}
|
||||
|
||||
ptyProcess.onData(data => send('data', data))
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
terminalSessions.delete(id)
|
||||
send('exit', { code: exitCode, signal: signal || null })
|
||||
})
|
||||
event.sender.once('destroyed', () => disposeTerminalSession(id))
|
||||
|
||||
return { cwd, id, shell: name }
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:write', (_event, id, data) => {
|
||||
const sessionInfo = terminalSessions.get(String(id || ''))
|
||||
|
||||
if (!sessionInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
sessionInfo.pty.write(String(data || ''))
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => {
|
||||
const sessionInfo = terminalSessions.get(String(id || ''))
|
||||
|
||||
if (!sessionInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80)
|
||||
const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24)
|
||||
|
||||
sessionInfo.pty.resize(cols, rows)
|
||||
|
||||
return true
|
||||
})
|
||||
ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))
|
||||
}
|
||||
|
||||
module.exports = { registerTerminalIpc }
|
||||
69
apps/desktop/electron/terminal-ipc.test.cjs
Normal file
69
apps/desktop/electron/terminal-ipc.test.cjs
Normal file
@@ -0,0 +1,69 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerTerminalIpc } = require('./terminal-ipc.cjs')
|
||||
|
||||
function fakeIpcMain() {
|
||||
const handlers = new Map()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deps(overrides = {}) {
|
||||
return {
|
||||
disposeTerminalSession: () => true,
|
||||
ensureSpawnHelperExecutable: () => {},
|
||||
nodePty: { spawn: () => ({ onData() {}, onExit() {} }) },
|
||||
safeTerminalCwd: c => c || '/',
|
||||
terminalChannel: (id, suffix) => `hermes:terminal:${id}:${suffix}`,
|
||||
terminalSessions: new Map(),
|
||||
terminalShellCommand: () => ({ args: [], command: 'sh', name: 'sh' }),
|
||||
terminalShellEnv: () => ({}),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test('registerTerminalIpc wires only hermes:terminal:* channels, each to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerTerminalIpc({ ipcMain, ...deps() })
|
||||
|
||||
assert.ok(ipcMain.handlers.size >= 4, `expected the full terminal surface, got ${ipcMain.handlers.size}`)
|
||||
|
||||
for (const [channel, handler] of ipcMain.handlers) {
|
||||
assert.match(channel, /^hermes:terminal:/, `${channel} is not a terminal channel`)
|
||||
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
|
||||
}
|
||||
|
||||
for (const channel of ['hermes:terminal:start', 'hermes:terminal:write', 'hermes:terminal:resize']) {
|
||||
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('write / resize on an unknown session id return false instead of throwing', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerTerminalIpc({ ipcMain, ...deps() })
|
||||
|
||||
assert.equal(await ipcMain.handlers.get('hermes:terminal:write')({}, 'nope', 'x'), false)
|
||||
assert.equal(await ipcMain.handlers.get('hermes:terminal:resize')({}, 'nope', {}), false)
|
||||
})
|
||||
|
||||
test('start surfaces a clear error when the PTY runtime is unavailable', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerTerminalIpc({ ipcMain, ...deps({ nodePty: null }) })
|
||||
|
||||
await assert.rejects(
|
||||
() => ipcMain.handlers.get('hermes:terminal:start')({ sender: {} }, {}),
|
||||
/PTY support is unavailable/
|
||||
)
|
||||
})
|
||||
14
apps/desktop/electron/uninstall-ipc.cjs
Normal file
14
apps/desktop/electron/uninstall-ipc.cjs
Normal file
@@ -0,0 +1,14 @@
|
||||
'use strict'
|
||||
|
||||
// Uninstall IPC: summarize what a desktop uninstall would remove + run it
|
||||
// (GUI-only / lite / full). Both delegate to the main-process uninstall engine,
|
||||
// which is injected.
|
||||
function registerUninstallIpc({ getUninstallSummary, ipcMain, runDesktopUninstall }) {
|
||||
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
|
||||
ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
|
||||
const mode = payload && typeof payload === 'object' ? payload.mode : payload
|
||||
return runDesktopUninstall(String(mode || ''))
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerUninstallIpc }
|
||||
51
apps/desktop/electron/uninstall-ipc.test.cjs
Normal file
51
apps/desktop/electron/uninstall-ipc.test.cjs
Normal file
@@ -0,0 +1,51 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerUninstallIpc } = require('./uninstall-ipc.cjs')
|
||||
|
||||
function fakeIpcMain() {
|
||||
const handlers = new Map()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('registerUninstallIpc wires only hermes:uninstall:* channels, each to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerUninstallIpc({ ipcMain, getUninstallSummary: async () => ({}), runDesktopUninstall: async () => ({}) })
|
||||
|
||||
assert.deepEqual([...ipcMain.handlers.keys()].sort(), ['hermes:uninstall:run', 'hermes:uninstall:summary'])
|
||||
|
||||
for (const handler of ipcMain.handlers.values()) {
|
||||
assert.equal(typeof handler, 'function')
|
||||
}
|
||||
})
|
||||
|
||||
test('run normalizes both the {mode} object form and the bare-string form', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
const modes = []
|
||||
|
||||
registerUninstallIpc({
|
||||
ipcMain,
|
||||
getUninstallSummary: async () => ({}),
|
||||
runDesktopUninstall: async mode => {
|
||||
modes.push(mode)
|
||||
|
||||
return { mode }
|
||||
}
|
||||
})
|
||||
|
||||
await ipcMain.handlers.get('hermes:uninstall:run')({}, { mode: 'full' })
|
||||
await ipcMain.handlers.get('hermes:uninstall:run')({}, 'lite')
|
||||
await ipcMain.handlers.get('hermes:uninstall:run')({}, null)
|
||||
|
||||
assert.deepEqual(modes, ['full', 'lite', ''])
|
||||
})
|
||||
41
apps/desktop/electron/updates-ipc.cjs
Normal file
41
apps/desktop/electron/updates-ipc.cjs
Normal file
@@ -0,0 +1,41 @@
|
||||
'use strict'
|
||||
|
||||
// Auto-update IPC: check / apply / branch get+set. The update engine
|
||||
// (checkUpdates/applyUpdates) and the on-disk update config live in the main
|
||||
// process and are injected, so this module owns only the request wiring.
|
||||
function registerUpdatesIpc({
|
||||
applyUpdates,
|
||||
checkUpdates,
|
||||
DEFAULT_UPDATE_BRANCH,
|
||||
ipcMain,
|
||||
readDesktopUpdateConfig,
|
||||
writeDesktopUpdateConfig
|
||||
}) {
|
||||
ipcMain.handle('hermes:updates:check', async () =>
|
||||
checkUpdates().catch(error => ({
|
||||
supported: true,
|
||||
branch: readDesktopUpdateConfig().branch,
|
||||
error: 'check-failed',
|
||||
message: error?.message || String(error),
|
||||
fetchedAt: Date.now()
|
||||
}))
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:updates:apply', async (_event, payload) =>
|
||||
applyUpdates(payload || {}).catch(error => ({
|
||||
ok: false,
|
||||
error: 'apply-failed',
|
||||
message: error?.message || String(error)
|
||||
}))
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:updates:branch:get', async () => readDesktopUpdateConfig())
|
||||
|
||||
ipcMain.handle('hermes:updates:branch:set', async (_event, name) => {
|
||||
const branch = typeof name === 'string' && name.trim() ? name.trim() : DEFAULT_UPDATE_BRANCH
|
||||
writeDesktopUpdateConfig({ branch })
|
||||
return { branch }
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerUpdatesIpc }
|
||||
76
apps/desktop/electron/updates-ipc.test.cjs
Normal file
76
apps/desktop/electron/updates-ipc.test.cjs
Normal file
@@ -0,0 +1,76 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerUpdatesIpc } = require('./updates-ipc.cjs')
|
||||
|
||||
function fakeIpcMain() {
|
||||
const handlers = new Map()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deps(overrides = {}) {
|
||||
return {
|
||||
applyUpdates: async () => ({ ok: true }),
|
||||
checkUpdates: async () => ({ supported: true }),
|
||||
DEFAULT_UPDATE_BRANCH: 'main',
|
||||
readDesktopUpdateConfig: () => ({ branch: 'main' }),
|
||||
writeDesktopUpdateConfig: () => {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test('registerUpdatesIpc wires only hermes:updates:* channels, each to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerUpdatesIpc({ ipcMain, ...deps() })
|
||||
|
||||
assert.ok(ipcMain.handlers.size >= 4, `expected the full updates surface, got ${ipcMain.handlers.size}`)
|
||||
|
||||
for (const [channel, handler] of ipcMain.handlers) {
|
||||
assert.match(channel, /^hermes:updates:/, `${channel} is not an updates channel`)
|
||||
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
|
||||
}
|
||||
|
||||
for (const channel of ['hermes:updates:check', 'hermes:updates:apply', 'hermes:updates:branch:set']) {
|
||||
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('branch:set falls back to the default branch for blank input and persists it', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
const writes = []
|
||||
|
||||
registerUpdatesIpc({ ipcMain, ...deps({ writeDesktopUpdateConfig: c => writes.push(c) }) })
|
||||
|
||||
assert.deepEqual(await ipcMain.handlers.get('hermes:updates:branch:set')({}, ' '), { branch: 'main' })
|
||||
assert.deepEqual(await ipcMain.handlers.get('hermes:updates:branch:set')({}, 'dev'), { branch: 'dev' })
|
||||
assert.deepEqual(writes, [{ branch: 'main' }, { branch: 'dev' }])
|
||||
})
|
||||
|
||||
test('check swallows engine failures into a structured error payload', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerUpdatesIpc({
|
||||
ipcMain,
|
||||
...deps({
|
||||
checkUpdates: async () => {
|
||||
throw new Error('network down')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const res = await ipcMain.handlers.get('hermes:updates:check')({})
|
||||
|
||||
assert.equal(res.error, 'check-failed')
|
||||
assert.equal(res.message, 'network down')
|
||||
assert.equal(res.branch, 'main')
|
||||
})
|
||||
17
apps/desktop/electron/version-ipc.cjs
Normal file
17
apps/desktop/electron/version-ipc.cjs
Normal file
@@ -0,0 +1,17 @@
|
||||
'use strict'
|
||||
|
||||
// App-version IPC: report the canonical Hermes version (resolved from the source
|
||||
// tree, falling back to the Electron app version) alongside the Electron/Node
|
||||
// runtime versions + the resolved Hermes root. The version + root resolvers live
|
||||
// in the main process and are injected.
|
||||
function registerVersionIpc({ ipcMain, resolveHermesVersion, resolveUpdateRoot }) {
|
||||
ipcMain.handle('hermes:version', async () => ({
|
||||
appVersion: resolveHermesVersion(),
|
||||
electronVersion: process.versions.electron,
|
||||
nodeVersion: process.versions.node,
|
||||
platform: process.platform,
|
||||
hermesRoot: resolveUpdateRoot()
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = { registerVersionIpc }
|
||||
41
apps/desktop/electron/version-ipc.test.cjs
Normal file
41
apps/desktop/electron/version-ipc.test.cjs
Normal file
@@ -0,0 +1,41 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerVersionIpc } = require('./version-ipc.cjs')
|
||||
|
||||
function fakeIpcMain() {
|
||||
const handlers = new Map()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('registerVersionIpc wires hermes:version to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerVersionIpc({ ipcMain, resolveHermesVersion: () => '1.2.3', resolveUpdateRoot: () => '/root' })
|
||||
|
||||
assert.deepEqual([...ipcMain.handlers.keys()], ['hermes:version'])
|
||||
assert.equal(typeof ipcMain.handlers.get('hermes:version'), 'function')
|
||||
})
|
||||
|
||||
test('version reports the resolved Hermes version + root alongside runtime versions', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerVersionIpc({ ipcMain, resolveHermesVersion: () => '1.2.3', resolveUpdateRoot: () => '/root' })
|
||||
|
||||
const res = await ipcMain.handlers.get('hermes:version')({})
|
||||
|
||||
assert.equal(res.appVersion, '1.2.3')
|
||||
assert.equal(res.hermesRoot, '/root')
|
||||
assert.equal(res.electronVersion, process.versions.electron)
|
||||
assert.equal(res.nodeVersion, process.versions.node)
|
||||
assert.equal(res.platform, process.platform)
|
||||
})
|
||||
19
apps/desktop/electron/vscode-theme-ipc.cjs
Normal file
19
apps/desktop/electron/vscode-theme-ipc.cjs
Normal file
@@ -0,0 +1,19 @@
|
||||
'use strict'
|
||||
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
|
||||
// VS Code Marketplace color-theme IPC: fetch a theme by extension id + search the
|
||||
// marketplace. Both delegate to the vscode-marketplace sibling module; no theme
|
||||
// code is ever executed (only JSON is read from the .vsix).
|
||||
function registerVscodeThemeIpc({ ipcMain }) {
|
||||
// Download a VS Code Marketplace extension and return the raw color-theme JSON
|
||||
// it contributes. No theme code is executed — we only read JSON from the .vsix.
|
||||
ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketplaceThemes(String(id || '')))
|
||||
|
||||
// Search the Marketplace for color-theme extensions (empty query = top installs).
|
||||
ipcMain.handle('hermes:vscode-theme:search', async (_event, query) =>
|
||||
searchMarketplaceThemes(String(query || ''), 20)
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { registerVscodeThemeIpc }
|
||||
30
apps/desktop/electron/vscode-theme-ipc.test.cjs
Normal file
30
apps/desktop/electron/vscode-theme-ipc.test.cjs
Normal file
@@ -0,0 +1,30 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerVscodeThemeIpc } = require('./vscode-theme-ipc.cjs')
|
||||
|
||||
function fakeIpcMain() {
|
||||
const handlers = new Map()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('registerVscodeThemeIpc wires only hermes:vscode-theme:* channels, each to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerVscodeThemeIpc({ ipcMain })
|
||||
|
||||
assert.deepEqual([...ipcMain.handlers.keys()].sort(), ['hermes:vscode-theme:fetch', 'hermes:vscode-theme:search'])
|
||||
|
||||
for (const handler of ipcMain.handlers.values()) {
|
||||
assert.equal(typeof handler, 'function')
|
||||
}
|
||||
})
|
||||
@@ -37,7 +37,7 @@
|
||||
"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/windows-hermes-resolution.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-ipc.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/windows-hermes-resolution.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
Reference in New Issue
Block a user