mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
wip thin client
This commit is contained in:
parent
cdb1dfbc49
commit
4dce531189
20 changed files with 507 additions and 241 deletions
20
apps/desktop/electron/build-mode.cjs
Normal file
20
apps/desktop/electron/build-mode.cjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
'use strict'
|
||||
|
||||
/**
|
||||
* build-mode.cjs — pure helper for the desktop's thin-vs-thick build mode.
|
||||
*
|
||||
* The desktop ships in two shapes:
|
||||
* - thick (default): bundles the first-launch bootstrap installer, can
|
||||
* spawn a local Hermes backend, and supports in-app self-update.
|
||||
* - thin: no bootstrap, no local backend, no self-update. Connects ONLY
|
||||
* to a remote gateway. Used for sandboxed/package-managed deployments
|
||||
* (Flatpak, Snap, etc.) where the agent lives elsewhere.
|
||||
*
|
||||
* The esbuild bundler bakes this env var into the source code, so it's read at build time, not runtime.
|
||||
*/
|
||||
|
||||
function isThinClient() {
|
||||
return process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
|
||||
}
|
||||
|
||||
module.exports = { isThinClient }
|
||||
41
apps/desktop/electron/build-mode.test.cjs
Normal file
41
apps/desktop/electron/build-mode.test.cjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// We test build-mode.cjs by controlling process.env directly. The module
|
||||
// reads process.env.HERMES_DESKTOP_BUILD_MODE at call time (not import time),
|
||||
// so we can mutate the env and re-require to exercise both modes.
|
||||
|
||||
function freshModule() {
|
||||
// Bust the require cache so the module re-evaluates with the current env.
|
||||
delete require.cache[require.resolve('./build-mode.cjs')]
|
||||
return require('./build-mode.cjs')
|
||||
}
|
||||
|
||||
test('isThinClient returns false by default (thick mode)', () => {
|
||||
const prev = process.env.HERMES_DESKTOP_BUILD_MODE
|
||||
delete process.env.HERMES_DESKTOP_BUILD_MODE
|
||||
const { isThinClient } = freshModule()
|
||||
assert.equal(isThinClient(), false)
|
||||
process.env.HERMES_DESKTOP_BUILD_MODE = prev
|
||||
})
|
||||
|
||||
test('isThinClient returns true when HERMES_DESKTOP_BUILD_MODE=thin', () => {
|
||||
const prev = process.env.HERMES_DESKTOP_BUILD_MODE
|
||||
process.env.HERMES_DESKTOP_BUILD_MODE = 'thin'
|
||||
const { isThinClient } = freshModule()
|
||||
assert.equal(isThinClient(), true)
|
||||
process.env.HERMES_DESKTOP_BUILD_MODE = prev
|
||||
})
|
||||
|
||||
test('isThinClient returns false for non-thin values', () => {
|
||||
const prev = process.env.HERMES_DESKTOP_BUILD_MODE
|
||||
process.env.HERMES_DESKTOP_BUILD_MODE = 'thick'
|
||||
const { isThinClient } = freshModule()
|
||||
assert.equal(isThinClient(), false)
|
||||
process.env.HERMES_DESKTOP_BUILD_MODE = 'thick-client'
|
||||
const { isThinClient: isThin2 } = freshModule()
|
||||
assert.equal(isThin2(), false)
|
||||
process.env.HERMES_DESKTOP_BUILD_MODE = prev
|
||||
})
|
||||
|
|
@ -24,6 +24,7 @@ const https = require('node:https')
|
|||
const path = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const { execFileSync, spawn } = require('node:child_process')
|
||||
const { isThinClient } = require('./build-mode.cjs')
|
||||
const { installEmbedReferer } = require('./embed-referer.cjs')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
|
|
@ -1839,6 +1840,17 @@ async function resolveHealedBranch(updateRoot, branch) {
|
|||
}
|
||||
|
||||
async function checkUpdates() {
|
||||
// Thin client: no local checkout to git-pull, no bundled updater. Updates
|
||||
// come from the package manager (Flatpak, Snap, etc.), not in-app self-update.
|
||||
if (isThinClient()) {
|
||||
return {
|
||||
supported: false,
|
||||
reason: 'thin-client',
|
||||
message: 'Updates are managed by your package manager. This is a thin client build.',
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
let { branch } = readDesktopUpdateConfig()
|
||||
const gitDir = path.join(updateRoot, '.git')
|
||||
|
|
@ -1972,6 +1984,10 @@ let updateInFlight = false
|
|||
// updater isn't staged (e.g. a dev/source run that never went through the
|
||||
// installer); callers degrade gracefully.
|
||||
function resolveUpdaterBinary() {
|
||||
// Thin client: no staged Tauri updater — the packaged app is managed
|
||||
// externally. Returning null lets the existing callers degrade gracefully
|
||||
// (the manual-command path), though applyUpdates already short-circuits.
|
||||
if (isThinClient()) return null
|
||||
const name = IS_WINDOWS ? 'hermes-setup.exe' : 'hermes-setup'
|
||||
const candidate = path.join(HERMES_HOME, name)
|
||||
return fileExists(candidate) ? candidate : null
|
||||
|
|
@ -2130,6 +2146,16 @@ async function releaseBackendLock(updateRoot, tag) {
|
|||
// Detection (checkUpdates / commit changelog / "N behind") stays in the UI;
|
||||
// only this apply action changed.
|
||||
async function applyUpdates(opts = {}) {
|
||||
// Thin client: self-update is not supported. The packaged app is managed
|
||||
// externally (Flatpak, Snap, etc.).
|
||||
if (isThinClient()) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'unsupported',
|
||||
message: 'Self-update is not available in thin client builds. Use your package manager to update.'
|
||||
}
|
||||
}
|
||||
|
||||
if (updateInFlight) {
|
||||
throw new Error('An update is already in progress.')
|
||||
}
|
||||
|
|
@ -2831,6 +2857,23 @@ function createActiveBackend(dashboardArgs) {
|
|||
}
|
||||
|
||||
function resolveHermesBackend(dashboardArgs) {
|
||||
// Thin client: no local backend, no bootstrap. The only valid path is a
|
||||
// remote gateway connection. Returning the bootstrap-needed sentinel here
|
||||
// would kick off install.ps1, which the thin build doesn't ship. Instead
|
||||
// return a dedicated sentinel so startHermes() can produce a clear error
|
||||
// directing the user to configure a remote gateway.
|
||||
if (isThinClient()) {
|
||||
return {
|
||||
kind: 'thin-client-no-local',
|
||||
label: 'Thin client build — remote gateway required',
|
||||
command: null,
|
||||
args: dashboardArgs,
|
||||
bootstrap: false,
|
||||
env: {},
|
||||
shell: false
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer
|
||||
// checkout. Honour it as-is (no bootstrap; the user is driving).
|
||||
const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT)
|
||||
|
|
@ -2974,6 +3017,19 @@ function resolveHermesBackend(dashboardArgs) {
|
|||
}
|
||||
|
||||
async function ensureRuntime(backend) {
|
||||
// Thin client: resolveHermesBackend returned a sentinel telling us there
|
||||
// is no local backend to spawn. Rather than crashing on a null command
|
||||
// spawn, throw a clear error so startHermes() catches it and the boot-
|
||||
// failure overlay surfaces the "configure a remote gateway" message.
|
||||
if (backend.kind === 'thin-client-no-local') {
|
||||
const err = new Error(
|
||||
'This is a thin client build with no bundled Hermes agent. ' +
|
||||
'Go to Settings → Gateway and configure a remote gateway URL.'
|
||||
)
|
||||
err.isThinClientNoLocal = true
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!backend.bootstrap) {
|
||||
await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32)
|
||||
return applyWindowsNoConsoleSpawnHints(backend)
|
||||
|
|
@ -4985,6 +5041,10 @@ async function testDesktopConnectionConfig(input = {}) {
|
|||
if (authMode !== 'oauth') {
|
||||
token = decryptDesktopSecret(block.token)
|
||||
}
|
||||
} else if (isThinClient()) {
|
||||
// Thin client: no local backend to test against. A "local" connection
|
||||
// test is meaningless — there's no bundled agent to reach.
|
||||
throw new Error('Local connection test is not available in thin client builds. Configure a remote gateway URL.')
|
||||
} else {
|
||||
const remote = (await resolveRemoteBackend(key)) || (await startHermes())
|
||||
baseUrl = remote.baseUrl
|
||||
|
|
@ -5401,6 +5461,17 @@ async function startHermes() {
|
|||
}
|
||||
}
|
||||
|
||||
// Thin client: the remote check above was the only path. If we get here,
|
||||
// no remote was configured — refuse to spawn a local backend.
|
||||
if (isThinClient()) {
|
||||
const err = new Error(
|
||||
'No remote gateway configured. This thin client build requires a remote gateway. ' +
|
||||
'Go to Settings → Gateway to configure one.'
|
||||
)
|
||||
err.isThinClientNoRemote = true
|
||||
throw err
|
||||
}
|
||||
|
||||
// Mutual exclusion with an in-app update (#50238). If this instance was
|
||||
// relaunched while the Tauri updater is still applying an update, spawning
|
||||
// a local backend now re-locks the venv shim and gets killed by the
|
||||
|
|
@ -6120,6 +6191,10 @@ ipcMain.on('hermes:pet-overlay:control', (_event, payload) => {
|
|||
mainWindow.webContents.send('hermes:pet-overlay:control', payload)
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// Thin client: no bootstrap to reset. Just reload the window.
|
||||
if (isThinClient()) {
|
||||
return { ok: true }
|
||||
}
|
||||
// Renderer's "Reload and retry" path. Clear the latched failure and
|
||||
// reset connection state so the next startHermes() call restarts the
|
||||
// full backend flow (including a fresh runBootstrap pass).
|
||||
|
|
@ -6140,6 +6215,10 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
|
|||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:repair', async () => {
|
||||
// Thin client: no local install to repair.
|
||||
if (isThinClient()) {
|
||||
return { ok: true }
|
||||
}
|
||||
// Forceful repair: drop the bootstrap-complete marker so the next
|
||||
// startHermes() re-runs the full installer (refreshing a broken/partial
|
||||
// venv), and clear any latched failure + live connection. The renderer
|
||||
|
|
@ -7157,6 +7236,23 @@ function uninstallVenvPython() {
|
|||
}
|
||||
|
||||
async function getUninstallSummary() {
|
||||
// Thin client: no local agent install to uninstall. Return a minimal
|
||||
// summary so the settings UI can show "nothing to remove" instead of
|
||||
// probing for a venv that doesn't exist.
|
||||
if (isThinClient()) {
|
||||
return {
|
||||
hermes_home: HERMES_HOME,
|
||||
agent_installed: false,
|
||||
gui_installed: true,
|
||||
source_built_artifacts: [],
|
||||
packaged_app_paths: [],
|
||||
userdata_dir: app.getPath('userData'),
|
||||
userdata_exists: true,
|
||||
platform: process.platform,
|
||||
probe: 'thin-client'
|
||||
}
|
||||
}
|
||||
|
||||
const py = uninstallVenvPython()
|
||||
const agentRoot = ACTIVE_HERMES_ROOT
|
||||
// Fast JS-side fallback used when the agent venv is gone (lite client) or the
|
||||
|
|
@ -7329,6 +7425,11 @@ async function runDesktopUninstall(mode) {
|
|||
|
||||
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
|
||||
ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
|
||||
// Thin client: no local agent to uninstall. The packaged app is managed
|
||||
// by the OS package manager.
|
||||
if (isThinClient()) {
|
||||
return { ok: false, error: 'unsupported', message: 'Uninstall is handled by your package manager in thin client builds.' }
|
||||
}
|
||||
const mode = payload && typeof payload === 'object' ? payload.mode : payload
|
||||
return runDesktopUninstall(String(mode || ''))
|
||||
})
|
||||
|
|
@ -7421,6 +7522,7 @@ function registerDeepLinkProtocol() {
|
|||
// whole new app instead of routing into the running one.
|
||||
const _gotSingleInstanceLock = app.requestSingleInstanceLock()
|
||||
if (!_gotSingleInstanceLock) {
|
||||
console.log("Hermes Desktop is already running, exiting.")
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
|
|
|
|||
|
|
@ -18,11 +18,13 @@
|
|||
"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 .",
|
||||
"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 && node scripts/bundle-electron-main.mjs && npm run postbuild",
|
||||
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && npm run postbuild",
|
||||
"build:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run build",
|
||||
"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",
|
||||
"pack": "npm run build && npm run builder -- --dir",
|
||||
"pack:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run pack",
|
||||
"dist": "npm run build && npm run builder",
|
||||
"dist:mac": "npm run build && npm run builder -- --mac",
|
||||
"dist:mac:dmg": "npm run build && npm run builder -- --mac dmg",
|
||||
|
|
@ -31,13 +33,15 @@
|
|||
"dist:win:msi": "npm run build && npm run builder -- --win msi",
|
||||
"dist:win:nsis": "npm run build && npm run builder -- --win nsis",
|
||||
"dist:linux": "npm run build && npm run builder -- --linux AppImage deb rpm",
|
||||
"dist:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run dist",
|
||||
"dist:linux:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run dist:linux",
|
||||
"test:desktop": "node scripts/test-desktop.mjs",
|
||||
"test:desktop:all": "node scripts/test-desktop.mjs all",
|
||||
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
|
||||
"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",
|
||||
"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/build-mode.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",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
|
|
|||
|
|
@ -2,10 +2,32 @@
|
|||
* Desktop bundles ship precompiled renderer assets. Returning false here tells
|
||||
* electron-builder to skip the node_modules collector/install step, which
|
||||
* 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`.
|
||||
* deterministic across environments.
|
||||
*
|
||||
* In thin-client builds we also strip the install-stamp and native-deps
|
||||
* extraResources entries — no bootstrap, no local PTY.
|
||||
*/
|
||||
module.exports = async function beforeBuild() {
|
||||
const path = require('node:path')
|
||||
|
||||
const THIN_CLIENT = process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
|
||||
|
||||
module.exports = async function beforeBuild(context) {
|
||||
if (THIN_CLIENT && context.packager) {
|
||||
// Strip install-stamp.json and native-deps from extraResources — neither
|
||||
// exists in a thin build (write-build-stamp and stage-native-deps are
|
||||
// skipped in build:thin).
|
||||
const buildConfig = context.packager.config
|
||||
if (Array.isArray(buildConfig.extraResources)) {
|
||||
buildConfig.extraResources = buildConfig.extraResources.filter(
|
||||
entry => {
|
||||
const to = typeof entry === 'object' && entry ? entry.to : null
|
||||
if (to === 'install-stamp.json' || to === 'native-deps') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ const root = resolve(here, '..')
|
|||
const entry = resolve(root, 'electron/main.cjs')
|
||||
const tmp = resolve(root, 'electron/main.bundled.cjs')
|
||||
|
||||
// Thin-client builds strip bootstrap, local backend, and self-update.
|
||||
// Bake the flag into the bundle so build-mode.cjs's process.env read resolves
|
||||
// to a string literal at runtime (no env var needed in the packaged app).
|
||||
const thinClient = process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
|
||||
|
||||
await build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
|
|
@ -24,6 +29,9 @@ await build({
|
|||
target: 'node20',
|
||||
outfile: tmp,
|
||||
external: ['electron', 'node-pty'],
|
||||
define: {
|
||||
'process.env.HERMES_DESKTOP_BUILD_MODE': JSON.stringify(thinClient ? 'thin' : '')
|
||||
},
|
||||
logLevel: 'info'
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use strict"
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* Writes apps/desktop/build/install-stamp.json with the git ref the desktop
|
||||
|
|
@ -17,29 +17,30 @@
|
|||
* }
|
||||
*
|
||||
* Source preference order:
|
||||
* 1. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with
|
||||
* 1. BUILD_STAMP env var (json)
|
||||
* 2. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with
|
||||
* shallow clones, detached HEADs, etc. in CI.
|
||||
* 2. Local `git rev-parse` against the parent repo (../..).
|
||||
* 3. Local `git rev-parse` against the parent repo (../..).
|
||||
*
|
||||
* Dev / out-of-repo builds without git produce an explicit error rather than
|
||||
* silently writing an unstamped manifest -- the packaged app refuses to
|
||||
* bootstrap without a stamp.
|
||||
*/
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execSync } = require('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 = 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')
|
||||
|
||||
function tryExec(cmd, opts) {
|
||||
try {
|
||||
return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...opts }).trim()
|
||||
return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], ...opts }).trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
|
@ -53,52 +54,79 @@ function fromCI() {
|
|||
commit: sha,
|
||||
branch: branch,
|
||||
dirty: false, // CI builds from a checkout-of-ref by definition
|
||||
source: "ci"
|
||||
source: 'ci'
|
||||
}
|
||||
}
|
||||
|
||||
function fromLocalGit() {
|
||||
const sha = tryExec("git rev-parse HEAD", { cwd: REPO_ROOT })
|
||||
const sha = tryExec('git rev-parse HEAD', { cwd: REPO_ROOT })
|
||||
if (!sha) return null
|
||||
const branch = tryExec("git rev-parse --abbrev-ref HEAD", { cwd: REPO_ROOT })
|
||||
const branch = tryExec('git rev-parse --abbrev-ref HEAD', { cwd: REPO_ROOT })
|
||||
// `git status --porcelain -uno` is empty iff tracked files match HEAD.
|
||||
// We exclude untracked files (-uno) intentionally: a developer who's
|
||||
// checked out an installer scratch dir alongside the repo shouldn't
|
||||
// poison every local build with a [DIRTY] stamp. We DO care about
|
||||
// tracked-but-modified files because those mean the .exe content
|
||||
// differs from the commit being pinned.
|
||||
const status = tryExec("git status --porcelain -uno", { cwd: REPO_ROOT })
|
||||
const status = tryExec('git status --porcelain -uno', { cwd: REPO_ROOT })
|
||||
const dirty = status !== null && status.length > 0
|
||||
return {
|
||||
commit: sha,
|
||||
branch: branch === "HEAD" ? null : branch, // detached HEAD -> null
|
||||
branch: branch === 'HEAD' ? null : branch, // detached HEAD -> null
|
||||
dirty: dirty,
|
||||
source: "local"
|
||||
source: 'local'
|
||||
}
|
||||
}
|
||||
|
||||
function fromEnv() {
|
||||
const stamp = process.env.BUILD_STAMP
|
||||
if (!stamp) return null
|
||||
|
||||
const json = JSON.parse(stamp)
|
||||
if (json.schemaVersion !== 1) {
|
||||
throw new Error('Schema version !== 1')
|
||||
}
|
||||
if (typeof json.branch !== 'string') {
|
||||
throw new Error('Expected branch to be string')
|
||||
}
|
||||
if (typeof json.commit !== 'string') {
|
||||
throw new Error('Expected commit to be string')
|
||||
}
|
||||
if (typeof json.builtAt !== 'string') {
|
||||
throw new Error('Expected builtAt to be string')
|
||||
}
|
||||
if (typeof json.dirty !== 'boolean') {
|
||||
throw new Error('Expected dirty to be boolean')
|
||||
}
|
||||
if (typeof json.source !== 'string') {
|
||||
throw new Error('Expected source to be string')
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
function main() {
|
||||
const stamp = fromCI() || fromLocalGit()
|
||||
const stamp = fromEnv() || fromCI() || fromLocalGit()
|
||||
if (!stamp || !stamp.commit) {
|
||||
console.error(
|
||||
"[write-build-stamp] ERROR: could not determine git commit.\n" +
|
||||
" - $GITHUB_SHA not set\n" +
|
||||
" - `git rev-parse HEAD` failed at " +
|
||||
'[write-build-stamp] ERROR: could not determine git commit.\n' +
|
||||
' - $GITHUB_SHA not set\n' +
|
||||
' - `git rev-parse HEAD` failed at ' +
|
||||
REPO_ROOT +
|
||||
"\n" +
|
||||
"Packaged builds require a git ref to pin first-launch install.ps1\n" +
|
||||
"against. Run from a git checkout or set $GITHUB_SHA explicitly."
|
||||
'\n' +
|
||||
'Packaged builds require a git ref to pin first-launch install.ps1\n' +
|
||||
'against. Run from a git checkout or set $GITHUB_SHA explicitly.'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (stamp.dirty) {
|
||||
console.warn(
|
||||
"[write-build-stamp] WARNING: working tree is dirty.\n" +
|
||||
" Pinning to " +
|
||||
'[write-build-stamp] WARNING: working tree is dirty.\n' +
|
||||
' Pinning to ' +
|
||||
stamp.commit.slice(0, 12) +
|
||||
" but the packaged code may differ from that commit.\n" +
|
||||
" Commit your changes before publishing this build."
|
||||
' but the packaged code may differ from that commit.\n' +
|
||||
' Commit your changes before publishing this build.'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -112,14 +140,14 @@ function main() {
|
|||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true })
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8")
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + '\n', 'utf8')
|
||||
console.log(
|
||||
"[write-build-stamp] wrote " +
|
||||
'[write-build-stamp] wrote ' +
|
||||
path.relative(REPO_ROOT, OUT_FILE) +
|
||||
" -> " +
|
||||
' -> ' +
|
||||
stamp.commit.slice(0, 12) +
|
||||
(stamp.branch ? " (" + stamp.branch + ")" : "") +
|
||||
(stamp.dirty ? " [DIRTY]" : "")
|
||||
(stamp.branch ? ' (' + stamp.branch + ')' : '') +
|
||||
(stamp.dirty ? ' [DIRTY]' : '')
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { BrandMark } from '@/components/brand-mark'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { isThinClient } from '@/lib/build-mode'
|
||||
import { CheckCircle2, ExternalLink, Loader2, RefreshCw } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
|
|
@ -106,75 +107,79 @@ export function AboutSettings() {
|
|||
</div>
|
||||
|
||||
<div className="mx-auto mt-4 w-full max-w-2xl">
|
||||
<SectionHeading icon={RefreshCw} title={a.updates} />
|
||||
{isThinClient() ? null : (
|
||||
<>
|
||||
<SectionHeading icon={RefreshCw} title={a.updates} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border px-4 py-3 text-sm',
|
||||
statusTone === 'available' && 'border-primary/30 bg-primary/5 text-foreground',
|
||||
statusTone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive',
|
||||
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{statusTone === 'available' ? (
|
||||
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
|
||||
) : statusTone === 'error' ? null : (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{statusLine}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
|
||||
{justChecked && !checking ? a.justNowSuffix : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4">
|
||||
<Button
|
||||
disabled={checking || applying || !supported}
|
||||
onClick={() => void handleCheck()}
|
||||
size="sm"
|
||||
variant="textStrong"
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border px-4 py-3 text-sm',
|
||||
statusTone === 'available' && 'border-primary/30 bg-primary/5 text-foreground',
|
||||
statusTone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive',
|
||||
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
|
||||
)}
|
||||
>
|
||||
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
{checking ? a.checking : a.checkNow}
|
||||
</Button>
|
||||
<div className="flex items-start gap-2">
|
||||
{statusTone === 'available' ? (
|
||||
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
|
||||
) : statusTone === 'error' ? null : (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{statusLine}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
|
||||
{justChecked && !checking ? a.justNowSuffix : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{behind > 0 && supported && !applying && (
|
||||
<>
|
||||
<Button onClick={() => startActiveUpdate()} size="sm">
|
||||
{a.updateNow}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4">
|
||||
<Button
|
||||
disabled={checking || applying || !supported}
|
||||
onClick={() => void handleCheck()}
|
||||
size="sm"
|
||||
variant="textStrong"
|
||||
>
|
||||
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
{checking ? a.checking : a.checkNow}
|
||||
</Button>
|
||||
<Button onClick={() => openUpdatesWindow()} size="sm" variant="textStrong">
|
||||
{a.seeWhatsNew}
|
||||
|
||||
{behind > 0 && supported && !applying && (
|
||||
<>
|
||||
<Button onClick={() => startActiveUpdate()} size="sm">
|
||||
{a.updateNow}
|
||||
</Button>
|
||||
<Button onClick={() => openUpdatesWindow()} size="sm" variant="textStrong">
|
||||
{a.seeWhatsNew}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button asChild className="ml-auto" size="sm" variant="text">
|
||||
<a
|
||||
href={RELEASE_NOTES_URL}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL)
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
{a.releaseNotes}
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button asChild className="ml-auto" size="sm" variant="text">
|
||||
<a
|
||||
href={RELEASE_NOTES_URL}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL)
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
{a.releaseNotes}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ListRow
|
||||
description={a.automaticUpdatesDesc}
|
||||
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
|
||||
title={a.automaticUpdates}
|
||||
/>
|
||||
<ListRow
|
||||
description={a.automaticUpdatesDesc}
|
||||
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
|
||||
title={a.automaticUpdates}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<UninstallSection />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Input } from '@/components/ui/input'
|
||||
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { isThinClient } from '@/lib/build-mode'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -466,16 +467,18 @@ export function GatewaySettings() {
|
|||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{isThinClient() ? null : (
|
||||
<ModeCard
|
||||
active={state.mode === 'local'}
|
||||
description={g.localDesc}
|
||||
disabled={state.envOverride}
|
||||
icon={Monitor}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
|
||||
title={g.localTitle}
|
||||
/>
|
||||
)}
|
||||
<ModeCard
|
||||
active={state.mode === 'local'}
|
||||
description={g.localDesc}
|
||||
disabled={state.envOverride}
|
||||
icon={Monitor}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
|
||||
title={g.localTitle}
|
||||
/>
|
||||
<ModeCard
|
||||
active={state.mode === 'remote'}
|
||||
active={state.mode === 'remote' || isThinClient()}
|
||||
description={g.remoteDesc}
|
||||
disabled={state.envOverride}
|
||||
icon={Globe}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ErrorIcon } from '@/components/ui/error-state'
|
|||
import { LogView } from '@/components/ui/log-view'
|
||||
import type { DesktopConnectionConfig } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { isThinClient } from '@/lib/build-mode'
|
||||
import { FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -205,16 +206,18 @@ export function BootFailureOverlay() {
|
|||
{copy.retry}
|
||||
</Button>
|
||||
)}
|
||||
{!remoteReauth ? (
|
||||
{remoteReauth ? null : !isThinClient() ? (
|
||||
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="secondary">
|
||||
{busy === 'repair' ? <Loader2 className="animate-spin" /> : <Wrench />}
|
||||
{copy.repairInstall}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="secondary">
|
||||
{busy === 'local' ? <Loader2 className="animate-spin" /> : null}
|
||||
{copy.useLocalGateway}
|
||||
</Button>
|
||||
{isThinClient() ? null : (
|
||||
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="secondary">
|
||||
{busy === 'local' ? <Loader2 className="animate-spin" /> : null}
|
||||
{copy.useLocalGateway}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={openLogs} variant="ghost">
|
||||
<FileText />
|
||||
{copy.openLogs}
|
||||
|
|
|
|||
25
apps/desktop/src/lib/build-mode.ts
Normal file
25
apps/desktop/src/lib/build-mode.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Build-mode flag for the renderer.
|
||||
*
|
||||
* `__VITE_THIN_CLIENT__` is injected at compile time by vite.config.ts
|
||||
* (see `define`). In dev it reads the live env var so
|
||||
* `HERMES_DESKTOP_BUILD_MODE=thin npm run dev` works for testing.
|
||||
*
|
||||
* Thin-client builds:
|
||||
* - No first-launch bootstrap (the app has no bundled installer)
|
||||
* - No local backend spawn (the app connects ONLY to a remote gateway)
|
||||
* - No in-app self-update (updates come from the package manager)
|
||||
*/
|
||||
|
||||
// In a packaged build, __VITE_THIN_CLIENT__ is replaced with a literal
|
||||
// boolean by vite's `define`. In dev (no define) the typeof check falls
|
||||
// through to the live env-var read so `HERMES_DESKTOP_BUILD_MODE=thin npm
|
||||
// run dev` works.
|
||||
const THIN_CLIENT =
|
||||
typeof __VITE_THIN_CLIENT__ !== 'undefined'
|
||||
? __VITE_THIN_CLIENT__
|
||||
: false
|
||||
|
||||
export function isThinClient(): boolean {
|
||||
return THIN_CLIENT
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import type {
|
|||
DesktopVersionInfo
|
||||
} from '@/global'
|
||||
import { checkHermesUpdate, getActionStatus, updateHermes } from '@/hermes'
|
||||
import { isThinClient } from '@/lib/build-mode'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import { dismissNotification, notify } from '@/store/notifications'
|
||||
|
|
@ -194,7 +195,8 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
|||
}
|
||||
|
||||
export function openUpdatesWindow(): void {
|
||||
openUpdateOverlayFor(isRemoteMode() ? 'backend' : 'client')
|
||||
// Thin client: no client self-update — always target the backend (remote).
|
||||
openUpdateOverlayFor(isThinClient() || isRemoteMode() ? 'backend' : 'client')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -205,7 +207,7 @@ export function openUpdatesWindow(): void {
|
|||
* only be able to open the changelog overlay.
|
||||
*/
|
||||
export function startActiveUpdate(): void {
|
||||
const target: UpdateTarget = isRemoteMode() ? 'backend' : 'client'
|
||||
const target: UpdateTarget = isThinClient() || isRemoteMode() ? 'backend' : 'client'
|
||||
$updateOverlayTarget.set(target)
|
||||
$updateOverlayOpen.set(true)
|
||||
void (target === 'backend' ? applyBackendUpdate() : applyUpdates())
|
||||
|
|
@ -611,9 +613,18 @@ export function startUpdatePoller(): void {
|
|||
}
|
||||
|
||||
pollerStarted = true
|
||||
|
||||
// Thin client: no client self-update, but still poll for backend updates
|
||||
// (the remote gateway may have new versions available).
|
||||
if (!isThinClient()) {
|
||||
void checkUpdates()
|
||||
}
|
||||
void checkBackendUpdates()
|
||||
void refreshDesktopVersion()
|
||||
bridge.onProgress(ingestProgress)
|
||||
|
||||
if (!isThinClient()) {
|
||||
bridge.onProgress(ingestProgress)
|
||||
}
|
||||
|
||||
// The poller starts at mount, before the gateway connects — so the first
|
||||
// backend check above sees mode≠remote and no-ops. Re-check once the
|
||||
|
|
|
|||
4
apps/desktop/src/vite-env.d.ts
vendored
4
apps/desktop/src/vite-env.d.ts
vendored
|
|
@ -1 +1,5 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
// Build-time constant injected by vite.config.ts `define`.
|
||||
// True for thin-client builds (no bootstrap, no local backend, no self-update).
|
||||
declare const __VITE_THIN_CLIENT__: boolean
|
||||
|
|
|
|||
|
|
@ -3,8 +3,16 @@ import react from '@vitejs/plugin-react'
|
|||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
// Thin-client builds strip bootstrap, local backend, and self-update so the
|
||||
// app connects ONLY to a remote gateway. Set HERMES_DESKTOP_BUILD_MODE=thin
|
||||
// at build time; the renderer reads the baked-in VITE_THIN_CLIENT constant.
|
||||
const THIN_CLIENT = process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
define: {
|
||||
__VITE_THIN_CLIENT__: JSON.stringify(THIN_CLIENT)
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
css: {
|
||||
// Pin an explicit (empty) PostCSS config. Tailwind is handled entirely by
|
||||
|
|
|
|||
|
|
@ -13,20 +13,21 @@
|
|||
makeWrapper,
|
||||
hermesNpmLib,
|
||||
electron,
|
||||
hermesAgent,
|
||||
hermesAgent ? null,
|
||||
...
|
||||
}:
|
||||
let
|
||||
npm = hermesNpmLib.mkNpmPassthru {
|
||||
folder = "apps/desktop";
|
||||
attr = "desktop";
|
||||
pname = "hermes-desktop";
|
||||
};
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json"));
|
||||
version = packageJson.version;
|
||||
|
||||
# Build the renderer (dist/ + electron/ + package.json).
|
||||
isThinClient = hermesAgent == null;
|
||||
|
||||
# Build the renderqer (dist/ + electron/ + package.json).
|
||||
renderer = pkgs.buildNpmPackage (
|
||||
npm
|
||||
// {
|
||||
|
|
@ -34,6 +35,9 @@ let
|
|||
inherit version;
|
||||
doCheck = true;
|
||||
|
||||
HERMES_DESKTOP_BUILD_MODE = if isThinClient then "thin" else "";
|
||||
BUILD_STAMP = ''{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix","builtAt":"0"}'';
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
|
|
@ -41,7 +45,6 @@ let
|
|||
# at first-launch to pin the install.ps1 git ref; informational in
|
||||
# nix builds (the backend comes from the derivation directly).
|
||||
mkdir -p apps/desktop/build
|
||||
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > apps/desktop/build/install-stamp.json
|
||||
|
||||
# patch shebangs in node_modules/.bin so npm exec can find the
|
||||
# nix-store equivalents of /usr/bin/env (which doesn't exist in the sandbox)
|
||||
|
|
@ -50,18 +53,7 @@ let
|
|||
pushd apps/desktop
|
||||
# stage node-pty native binaries into build/native-deps for the final nix output
|
||||
npm rebuild node-pty --build-from-source
|
||||
node scripts/stage-native-deps.cjs
|
||||
|
||||
npm exec tsc -b
|
||||
npm exec vite build
|
||||
|
||||
# Bundle the electron main into a single self-contained file so
|
||||
# the nix output doesn't need node_modules/. simple-git (the only
|
||||
# external runtime dep of the electron main) gets inlined; electron
|
||||
# and node-pty are external (provided by the runtime / native-deps).
|
||||
# preload.cjs stays separate — Electron loads it via __dirname, not
|
||||
# require(), so it must remain a standalone file.
|
||||
node scripts/bundle-electron-main.mjs
|
||||
npm run build
|
||||
popd
|
||||
|
||||
runHook postBuild
|
||||
|
|
@ -72,8 +64,6 @@ let
|
|||
|
||||
pushd apps/desktop
|
||||
|
||||
npm run postbuild
|
||||
|
||||
# validate staged node-pty native binary is present
|
||||
STAGED_PTY_NODE="./build/native-deps/node-pty/build/Release/pty.node"
|
||||
|
||||
|
|
@ -131,15 +121,15 @@ stdenv.mkDerivation {
|
|||
substituteInPlace $out/share/hermes-desktop/electron/main.cjs \
|
||||
--replace-fail "process.resourcesPath" "'$out/share/hermes-desktop'"
|
||||
|
||||
# Wrap the nixpkgs electron binary to launch our app. Set
|
||||
# HERMES_DESKTOP_HERMES to the absolute path of the nix-built `hermes`
|
||||
# binary so the desktop's resolver step 4 ("existing Hermes CLI on
|
||||
# PATH") uses our fully wrapped binary — venv with all deps,
|
||||
# Wrap the nixpkgs electron binary to launch our app.
|
||||
# If we're building thick, set HERMES_DESKTOP_HERMES to the absolute path of the
|
||||
# nix-built `hermes` binary so the desktop's resolver step 4
|
||||
# ("existing Hermes CLI on PATH") uses our fully wrapped binary & venv with all deps,
|
||||
# bundled skills/plugins, runtime PATH (ripgrep/git/ffmpeg/etc).
|
||||
# No reimplementation of the agent resolver in the wrapper.
|
||||
|
||||
makeWrapper ${lib.getExe electron} $out/bin/hermes-desktop \
|
||||
--add-flags "$out/share/hermes-desktop" \
|
||||
--set HERMES_DESKTOP_HERMES "${lib.getExe hermesAgent}" \
|
||||
${if isThinClient then "" else ''--set HERMES_DESKTOP_HERMES "${lib.getExe hermesAgent}"''}\
|
||||
--set ELECTRON_IS_DEV 0
|
||||
|
||||
runHook postInstall
|
||||
|
|
|
|||
|
|
@ -10,14 +10,15 @@
|
|||
makeWrapper,
|
||||
callPackage,
|
||||
python312,
|
||||
nodejs_22,
|
||||
electron,
|
||||
ripgrep,
|
||||
git,
|
||||
openssh,
|
||||
ffmpeg,
|
||||
tirith,
|
||||
|
||||
# for building stuff w/ consistent node versions
|
||||
hermesNpmLib,
|
||||
|
||||
# linux-only deps
|
||||
wl-clipboard,
|
||||
xclip,
|
||||
|
|
@ -26,7 +27,6 @@
|
|||
uv2nix,
|
||||
pyproject-nix,
|
||||
pyproject-build-systems,
|
||||
npm-lockfile-fix,
|
||||
# Locked git revision of the flake source — embedded so banner.py can
|
||||
# check for updates without needing a local .git directory. Null for
|
||||
# impure / dirty builds where flakes can't determine a rev.
|
||||
|
|
@ -36,16 +36,11 @@
|
|||
extraDependencyGroups ? [ ],
|
||||
}:
|
||||
let
|
||||
nodejs = nodejs_22;
|
||||
hermesVenv = callPackage ./python.nix {
|
||||
inherit uv2nix pyproject-nix pyproject-build-systems;
|
||||
dependency-groups = [ "all" ] ++ extraDependencyGroups;
|
||||
};
|
||||
|
||||
hermesNpmLib = callPackage ./lib.nix {
|
||||
inherit npm-lockfile-fix nodejs;
|
||||
};
|
||||
|
||||
hermesTui = callPackage ./tui.nix {
|
||||
inherit hermesNpmLib;
|
||||
};
|
||||
|
|
@ -83,7 +78,7 @@ let
|
|||
bundledLocales = lib.cleanSource ../locales;
|
||||
|
||||
runtimeDeps = [
|
||||
nodejs
|
||||
hermesNpmLib.nodejs
|
||||
ripgrep
|
||||
git
|
||||
openssh
|
||||
|
|
@ -182,7 +177,7 @@ stdenv.mkDerivation (finalAttrs: {
|
|||
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
|
||||
--set HERMES_TUI_DIR $out/ui-tui \
|
||||
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
|
||||
--set HERMES_NODE ${lib.getExe nodejs} \
|
||||
--set HERMES_NODE ${lib.getExe hermesNpmLib.nodejs} \
|
||||
${lib.optionalString (rev != null) ''--set HERMES_REVISION ${rev} \''}
|
||||
${lib.optionalString (extraPythonPackages != [ ]) ''--suffix PYTHONPATH : "${pythonPath}"''}
|
||||
'')
|
||||
|
|
@ -204,24 +199,10 @@ stdenv.mkDerivation (finalAttrs: {
|
|||
|
||||
passthru = {
|
||||
inherit
|
||||
hermesTui
|
||||
hermesWeb
|
||||
hermesNpmLib
|
||||
hermesVenv
|
||||
;
|
||||
|
||||
# `hermesDesktop` references `finalAttrs.finalPackage` (this whole
|
||||
# derivation, after all overrides are applied) so the desktop wrapper
|
||||
# can prepend its `/bin` to PATH. The desktop's resolver step 4
|
||||
# ("existing hermes on PATH") then picks up the fully wrapped
|
||||
# `hermes` binary — venv with all deps, bundled skills/plugins,
|
||||
# runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation
|
||||
# of the agent resolution in the desktop wrapper.
|
||||
hermesDesktop = callPackage ./desktop.nix {
|
||||
inherit hermesNpmLib electron;
|
||||
hermesAgent = finalAttrs.finalPackage;
|
||||
};
|
||||
|
||||
devShellHook = ''
|
||||
STAMP=".nix-stamps/hermes-agent"
|
||||
STAMP_VALUE="${pyprojectHash}:${uvLockHash}"
|
||||
|
|
|
|||
27
nix/lib.nix
27
nix/lib.nix
|
|
@ -11,8 +11,6 @@
|
|||
# root to update the lockfile, then `npm ci` if the lockfile changed.
|
||||
{
|
||||
pkgs,
|
||||
npm-lockfile-fix,
|
||||
nodejs,
|
||||
}:
|
||||
let
|
||||
# The workspace root — where the single package-lock.json lives.
|
||||
|
|
@ -23,17 +21,19 @@ let
|
|||
# lockfile is the single source of truth — no separate dependency hash to
|
||||
# keep in sync with it.
|
||||
npmDeps = pkgs.importNpmLock.importNpmLock { npmRoot = src; };
|
||||
nodejs = pkgs.nodejs_22;
|
||||
in
|
||||
{
|
||||
inherit nodejs;
|
||||
|
||||
# Returns a buildNpmPackage-compatible attrs set that provides:
|
||||
# src, npmDeps, npmRoot — workspace source + importNpmLock dep set
|
||||
# npmConfigHook — importNpmLock's offline `npm install` hook
|
||||
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
|
||||
# passthru.packageJsonPath — relative path to this workspace's package.json
|
||||
# nodejs — fixed nodejs version for all packages we use in the repo
|
||||
#
|
||||
# Usage:
|
||||
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; pname = "hermes-tui"; };
|
||||
# pkgs.buildNpmPackage (npm // {
|
||||
# sourceRoot = "ui-tui";
|
||||
# buildPhase = '' ... '';
|
||||
|
|
@ -42,7 +42,6 @@ in
|
|||
mkNpmPassthru =
|
||||
{
|
||||
folder, # repo-relative folder with package.json, e.g. "ui-tui"
|
||||
attr, # flake package attr, e.g. "tui"
|
||||
...
|
||||
}:
|
||||
let
|
||||
|
|
@ -61,24 +60,6 @@ in
|
|||
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
|
||||
|
||||
nativeBuildInputs = [
|
||||
(pkgs.writeShellScriptBin "update_${attr}_lockfile" ''
|
||||
set -euox pipefail
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
# All workspace packages share the root lockfile.
|
||||
cd "$REPO_ROOT"
|
||||
rm -rf node_modules/
|
||||
${pkgs.lib.getExe' nodejs "npm"} cache clean --force
|
||||
CI=true ${pkgs.lib.getExe' nodejs "npm"} install --workspaces
|
||||
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
|
||||
|
||||
nix build .#${attr}
|
||||
echo "Lockfile updated and build verified for .#${attr}"
|
||||
'')
|
||||
];
|
||||
|
||||
passthru = {
|
||||
packageJsonPath = "${folder}/package.json";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,15 +2,34 @@
|
|||
{ inputs, ... }:
|
||||
{
|
||||
perSystem =
|
||||
{ pkgs, lib, inputs', ... }:
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
hermesNpmLib = pkgs.callPackage ./lib.nix { };
|
||||
hermesAgent = pkgs.callPackage ./hermes-agent.nix {
|
||||
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
|
||||
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
|
||||
# Only embed clean revs — dirtyRev doesn't represent any upstream
|
||||
inherit (inputs)
|
||||
uv2nix
|
||||
pyproject-nix
|
||||
pyproject-build-systems
|
||||
;
|
||||
inherit
|
||||
hermesNpmLib
|
||||
;
|
||||
|
||||
# Only embed clean revs. dirtyRev doesn't represent any upstream
|
||||
# commit, so comparing it would always claim "update available".
|
||||
rev = inputs.self.rev or null;
|
||||
};
|
||||
|
||||
desktop = pkgs.callPackage ./desktop.nix {
|
||||
inherit hermesAgent hermesNpmLib;
|
||||
};
|
||||
desktop-thin = pkgs.callPackage ./desktop.nix {
|
||||
inherit hermesNpmLib;
|
||||
};
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
|
|
@ -44,12 +63,11 @@
|
|||
"parallel-web"
|
||||
"tts-premium"
|
||||
"voice"
|
||||
] ++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ];
|
||||
]
|
||||
++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ];
|
||||
};
|
||||
|
||||
tui = hermesAgent.hermesTui;
|
||||
web = hermesAgent.hermesWeb;
|
||||
desktop = hermesAgent.hermesDesktop;
|
||||
inherit desktop desktop-thin;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
46
nix/tui.nix
46
nix/tui.nix
|
|
@ -1,33 +1,39 @@
|
|||
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
|
||||
{ pkgs, hermesNpmLib, ... }:
|
||||
let
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||
npm = hermesNpmLib.mkNpmPassthru {
|
||||
folder = "ui-tui";
|
||||
pname = "hermes-tui";
|
||||
};
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/ui-tui/package.json"));
|
||||
version = packageJson.version;
|
||||
in
|
||||
pkgs.buildNpmPackage (npm // {
|
||||
pname = "hermes-tui";
|
||||
inherit version;
|
||||
pkgs.buildNpmPackage (
|
||||
npm
|
||||
// {
|
||||
pname = "hermes-tui";
|
||||
inherit version;
|
||||
|
||||
doCheck = false;
|
||||
doCheck = false;
|
||||
|
||||
buildPhase = ''
|
||||
# esbuild bundles everything — no need for tsc or vite.
|
||||
# Run from the workspace root where node_modules/ lives.
|
||||
node ui-tui/scripts/build.mjs
|
||||
'';
|
||||
buildPhase = ''
|
||||
# esbuild bundles everything — no need for tsc or vite.
|
||||
# Run from the workspace root where node_modules/ lives.
|
||||
node ui-tui/scripts/build.mjs
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/lib/hermes-tui
|
||||
# esbuild writes to ui-tui/dist/ from the source root (no cd).
|
||||
cp -r ui-tui/dist $out/lib/hermes-tui/dist
|
||||
mkdir -p $out/lib/hermes-tui
|
||||
# esbuild writes to ui-tui/dist/ from the source root (no cd).
|
||||
cp -r ui-tui/dist $out/lib/hermes-tui/dist
|
||||
|
||||
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
|
||||
cp ui-tui/package.json $out/lib/hermes-tui/
|
||||
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
|
||||
cp ui-tui/package.json $out/lib/hermes-tui/
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
})
|
||||
runHook postInstall
|
||||
'';
|
||||
}
|
||||
)
|
||||
|
|
|
|||
52
nix/web.nix
52
nix/web.nix
|
|
@ -1,34 +1,40 @@
|
|||
# nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build
|
||||
{ pkgs, hermesNpmLib, ... }:
|
||||
let
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
|
||||
npm = hermesNpmLib.mkNpmPassthru {
|
||||
folder = "web";
|
||||
pname = "hermes-web";
|
||||
};
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/web/package.json"));
|
||||
version = packageJson.version;
|
||||
in
|
||||
pkgs.buildNpmPackage (npm // {
|
||||
pname = "hermes-web";
|
||||
inherit version;
|
||||
pkgs.buildNpmPackage (
|
||||
npm
|
||||
// {
|
||||
pname = "hermes-web";
|
||||
inherit version;
|
||||
|
||||
doCheck = false;
|
||||
doCheck = false;
|
||||
|
||||
buildPhase = ''
|
||||
# Build from web/ so vite.config.ts and tsconfig resolve correctly.
|
||||
# The workspace root's node_modules/ is at ../node_modules/.
|
||||
cd web
|
||||
node ../node_modules/typescript/bin/tsc -b
|
||||
# outDir in vite.config.ts points to ../hermes_cli/web_dist for the
|
||||
# monorepo layout. Override with --outDir dist for the nix build.
|
||||
node ../node_modules/vite/bin/vite.js build --outDir dist
|
||||
buildPhase = ''
|
||||
# Build from web/ so vite.config.ts and tsconfig resolve correctly.
|
||||
# The workspace root's node_modules/ is at ../node_modules/.
|
||||
cd web
|
||||
node ../node_modules/typescript/bin/tsc -b
|
||||
# outDir in vite.config.ts points to ../hermes_cli/web_dist for the
|
||||
# monorepo layout. Override with --outDir dist for the nix build.
|
||||
node ../node_modules/vite/bin/vite.js build --outDir dist
|
||||
|
||||
# Return to source root so installPhase paths are correct.
|
||||
cd ..
|
||||
'';
|
||||
# Return to source root so installPhase paths are correct.
|
||||
cd ..
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
# vite writes to web/dist/ (we cd'd there, overrode outDir, then cd'd back).
|
||||
cp -r web/dist $out
|
||||
runHook postInstall
|
||||
'';
|
||||
})
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
# vite writes to web/dist/ (we cd'd there, overrode outDir, then cd'd back).
|
||||
cp -r web/dist $out
|
||||
runHook postInstall
|
||||
'';
|
||||
}
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue