mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 15:55:37 +08:00
Compare commits
2 Commits
main
...
thin-clien
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a8d4da69a | ||
|
|
4dce531189 |
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]' : '')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overla
|
||||
import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { isThinClient } from '@/lib/build-mode'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
@@ -1108,7 +1109,7 @@ export function DesktopController() {
|
||||
{!isSecondaryWindow() && <DesktopInstallOverlay />}
|
||||
{!isSecondaryWindow() && (
|
||||
<DesktopOnboardingOverlay
|
||||
enabled={gatewayState === 'open'}
|
||||
enabled={isThinClient() || gatewayState === 'open'}
|
||||
onCompleted={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
|
||||
@@ -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}
|
||||
|
||||
238
apps/desktop/src/components/OnboardingGatewayConnection.tsx
Normal file
238
apps/desktop/src/components/OnboardingGatewayConnection.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
// ─── Gateway connect form ───────────────────────────────────────────────
|
||||
// Shown on thin-client first run (the only onboarding path) and on the
|
||||
// thick client when the user picks "Connect to a remote gateway". Reuses
|
||||
// the same desktop bridge IPC (probe / save / apply / oauth-login) as
|
||||
// Settings → Gateway, but wrapped in the onboarding card chrome.
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import type { DesktopConnectionProbeResult } from "@/global"
|
||||
import { useI18n } from "@/i18n"
|
||||
import { AlertCircle, ChevronLeft, Globe, Loader2, LogIn } from "@/lib/icons"
|
||||
import { gatewayOauthLogin, saveGatewayConnection } from "@/store/onboarding"
|
||||
|
||||
import { Button } from "./ui/button"
|
||||
import { ErrorIcon } from "./ui/error-state"
|
||||
import { Input } from "./ui/input"
|
||||
|
||||
type GatewayProbeStatus = 'idle' | 'probing' | 'done' | 'error'
|
||||
|
||||
function useGatewayProbe(url: string) {
|
||||
const [status, setStatus] = useState<GatewayProbeStatus>('idle')
|
||||
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(null)
|
||||
const seq = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = url.trim()
|
||||
|
||||
if (!trimmed || !/^https?:\/\//i.test(trimmed)) {
|
||||
setStatus('idle')
|
||||
setProbe(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.probeConnectionConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = ++seq.current
|
||||
setStatus('probing')
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
desktop
|
||||
.probeConnectionConfig(trimmed)
|
||||
.then(result => {
|
||||
if (seq.current !== current) {
|
||||
return
|
||||
}
|
||||
|
||||
setProbe(result)
|
||||
setStatus(result.reachable ? 'done' : 'error')
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq.current !== current) {
|
||||
return
|
||||
}
|
||||
|
||||
setProbe(null)
|
||||
setStatus('error')
|
||||
})
|
||||
}, 500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [url])
|
||||
|
||||
return { status, probe }
|
||||
}
|
||||
|
||||
export function GatewayConnectForm({ onBack }: { onBack: null | (() => void) }) {
|
||||
const { t } = useI18n()
|
||||
const g = t.onboarding.gateway
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState<'save' | 'signin' | null>(null)
|
||||
|
||||
const { status: probeStatus, probe } = useGatewayProbe(url)
|
||||
|
||||
const trimmedUrl = url.trim()
|
||||
const hasUrl = Boolean(trimmedUrl) && /^https?:\/\//i.test(trimmedUrl)
|
||||
|
||||
// Effective auth mode: a reachable probe wins; otherwise fall back to
|
||||
// 'token' so the token box is visible by default.
|
||||
const authMode = probeStatus === 'done' && probe && probe.authMode !== 'unknown'
|
||||
? probe.authMode
|
||||
: 'token'
|
||||
|
||||
const authResolved = probeStatus === 'done'
|
||||
|
||||
const providers = probe?.providers ?? []
|
||||
|
||||
const providerLabel = providers.length === 1
|
||||
? (providers[0].displayName || providers[0].name)
|
||||
: providers.length > 1
|
||||
? providers.map(p => p.displayName || p.name).join(' / ')
|
||||
: g.identityProvider
|
||||
|
||||
const isPasswordProvider = providers.length > 0 && providers.every(p => p.supportsPassword)
|
||||
|
||||
const canSubmit = hasUrl && (authMode === 'oauth' ? false : Boolean(token.trim()))
|
||||
|
||||
const submit = async () => {
|
||||
if (!hasUrl || busy) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy('save')
|
||||
setError(null)
|
||||
|
||||
const result = await saveGatewayConnection(trimmedUrl, authMode, token.trim() || undefined)
|
||||
|
||||
if (!result.ok) {
|
||||
setError(result.message ?? g.saveFailed)
|
||||
setBusy(null)
|
||||
}
|
||||
// On success, applyConnectionConfig reloads the window — no need to clear busy.
|
||||
}
|
||||
|
||||
const signIn = async () => {
|
||||
if (!hasUrl || busy) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy('signin')
|
||||
setError(null)
|
||||
|
||||
const result = await gatewayOauthLogin(trimmedUrl)
|
||||
|
||||
if (!result.ok) {
|
||||
setError(result.message ?? g.signInFailed)
|
||||
setBusy(null)
|
||||
}
|
||||
// On success, applyConnectionConfig reloads the window.
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{onBack ? (
|
||||
<Button className="-mt-1 self-start font-medium" onClick={onBack} size="xs" type="button" variant="text">
|
||||
<ChevronLeft className="size-3" />
|
||||
{g.backToProviders}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium text-muted-foreground" htmlFor="gw-url">
|
||||
{g.urlLabel}
|
||||
</label>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
className={`font-mono`}
|
||||
// className={`font-mono ${hasUrl ? (probeStatus === 'probing' ? 'border-orange' : 'border-green') : ''}`}
|
||||
id="gw-url"
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder="https://gateway.example.com/hermes"
|
||||
value={url}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{g.urlHint}</p>
|
||||
</div>
|
||||
|
||||
{/* Probe status */}
|
||||
{hasUrl && probeStatus === 'probing' ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{g.probing}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasUrl && probeStatus === 'error' ? (
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
{g.probeError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* OAuth / password gateways: show sign-in button */}
|
||||
{hasUrl && authResolved && authMode === 'oauth' ? (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={Boolean(busy)} onClick={() => void signIn()}>
|
||||
{busy === 'signin' ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isPasswordProvider ? g.passwordHint : g.oauthHint(providerLabel)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Token gateways: show token input */}
|
||||
{hasUrl && authResolved && authMode === 'token' ? (
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium text-muted-foreground" htmlFor="gw-token">
|
||||
{g.tokenLabel}
|
||||
</label>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className="font-mono"
|
||||
id="gw-token"
|
||||
onChange={e => setToken(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && void submit()}
|
||||
placeholder={g.tokenPlaceholder}
|
||||
type="password"
|
||||
value={token}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{g.tokenHint}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* While probing (or probe error) and no saved config, neither auth UI shows —
|
||||
show a hint instead of a blank gap. */}
|
||||
{hasUrl && !authResolved ? (
|
||||
<p className="text-xs text-muted-foreground">{g.probing}</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="flex items-start gap-2 text-sm text-destructive">
|
||||
<ErrorIcon className="mt-0.5 shrink-0" size="0.875rem" />
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 pt-1">
|
||||
<div />
|
||||
<Button disabled={!canSubmit || Boolean(busy)} onClick={() => void submit()}>
|
||||
{busy === 'save' ? <Loader2 className="animate-spin" /> : <Globe />}
|
||||
{busy === 'save' ? g.connecting : g.connect}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -37,10 +38,16 @@ export function BootFailureOverlay() {
|
||||
const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null)
|
||||
|
||||
const visible = Boolean(boot.error) && !boot.running
|
||||
|
||||
// While first-run onboarding owns the picker/flow we let it surface its own
|
||||
// progress; the recovery overlay is for hard failures, which it covers via a
|
||||
// higher z-index regardless of onboarding state.
|
||||
const suppressed = onboarding.flow.status !== 'idle' && onboarding.flow.status !== 'error'
|
||||
// Thin client: when the gateway connect form is showing (no remote configured
|
||||
// yet), suppress the boot-failure overlay — the "no remote" error is expected
|
||||
// and the user is already looking at the form to fix it.
|
||||
const suppressed =
|
||||
(onboarding.flow.status !== 'idle' && onboarding.flow.status !== 'error') ||
|
||||
onboarding.gatewayMode
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
@@ -205,16 +212,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}
|
||||
|
||||
@@ -27,7 +27,8 @@ function setProviders(providers: OAuthProvider[]) {
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
localEndpoint: false,
|
||||
gatewayMode: false
|
||||
} satisfies DesktopOnboardingState)
|
||||
}
|
||||
|
||||
@@ -51,7 +52,8 @@ afterEach(() => {
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
localEndpoint: false,
|
||||
gatewayMode: false
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { ErrorIcon } from '@/components/ui/error-state'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import type { DesktopConnectionProbeResult } from '@/global'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, KeyRound, Loader2, Terminal } from '@/lib/icons'
|
||||
import { isThinClient } from '@/lib/build-mode'
|
||||
import { AlertCircle, Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, Globe, KeyRound, Loader2, LogIn, Terminal } from '@/lib/icons'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopBoot, type DesktopBootState } from '@/store/boot'
|
||||
@@ -25,19 +27,24 @@ import {
|
||||
DEFAULT_MANUAL_ONBOARDING_REASON,
|
||||
DEFAULT_ONBOARDING_REASON,
|
||||
dismissFirstRunOnboarding,
|
||||
exitGatewayMode,
|
||||
gatewayOauthLogin,
|
||||
type OnboardingContext,
|
||||
type OnboardingFlow,
|
||||
peekPendingProviderOAuth,
|
||||
recheckExternalSignin,
|
||||
refreshOnboarding,
|
||||
saveGatewayConnection,
|
||||
saveOnboardingApiKey,
|
||||
setOnboardingCode,
|
||||
setOnboardingMode,
|
||||
setOnboardingModel,
|
||||
startGatewayOnboarding,
|
||||
startProviderOAuth,
|
||||
submitOnboardingCode
|
||||
} from '@/store/onboarding'
|
||||
import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes'
|
||||
import { GatewayConnectForm } from './OnboardingGatewayConnection'
|
||||
|
||||
interface DesktopOnboardingOverlayProps {
|
||||
enabled: boolean
|
||||
@@ -267,7 +274,10 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
// The user chose "I'll choose a provider later" on first run. Stay out of the
|
||||
// way on every subsequent launch — they re-enter via Settings → Providers
|
||||
// (manual mode), which sets manual=true and bypasses this gate.
|
||||
if (onboarding.firstRunSkipped && !onboarding.manual) {
|
||||
// Thin client: never respect the skip — there's no "choose later" escape from
|
||||
// the gateway form (it's the only path), and a skipped thin client has no
|
||||
// working backend to fall back on.
|
||||
if (onboarding.firstRunSkipped && !onboarding.manual && !isThinClient()) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -288,7 +298,9 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
// In manual mode the app is already configured, so the flow is "ready"
|
||||
// immediately — no runtime gate needed. Otherwise wait for the readiness
|
||||
// check (configured === false) before showing the picker.
|
||||
const ready = onboarding.manual || (enabled && onboarding.configured === false)
|
||||
// Gateway mode (thin client or user picked "remote gateway") is also ready
|
||||
// immediately — there's no provider list to wait for.
|
||||
const ready = onboarding.manual || onboarding.gatewayMode || (enabled && onboarding.configured === false)
|
||||
const showPicker = flow.status === 'idle' || flow.status === 'success'
|
||||
// The final "you're in" screen drops the card chrome and floats centered on
|
||||
// the surface — same bare, cinematic treatment as the connecting overlay.
|
||||
@@ -332,7 +344,11 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
<div className="grid gap-3 p-5">
|
||||
{reason ? <ReasonNotice reason={reason} /> : null}
|
||||
{ready ? (
|
||||
showPicker ? (
|
||||
onboarding.gatewayMode && showPicker ? (
|
||||
<GatewayConnectForm
|
||||
onBack={isThinClient() ? null : exitGatewayMode}
|
||||
/>
|
||||
) : showPicker ? (
|
||||
<Picker ctx={ctx} />
|
||||
) : (
|
||||
<FlowPanel ctx={ctx} flow={flow} leaving={leaving} onBegin={finalizeOnboarding} />
|
||||
@@ -397,6 +413,7 @@ function Header() {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export const FEATURED_ID = 'nous'
|
||||
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
|
||||
|
||||
@@ -491,15 +508,27 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||
In manual mode the overlay already has a close affordance, so the
|
||||
"choose later" escape would be redundant — hide it. */}
|
||||
{manual ? <span /> : <ChooseLaterLink />}
|
||||
<Button
|
||||
className="-mr-2 font-medium"
|
||||
onClick={() => setOnboardingMode('apikey')}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{t.onboarding.haveApiKey}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="font-medium"
|
||||
onClick={() => startGatewayOnboarding()}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
<Globe className="size-3" />
|
||||
{t.onboarding.connectRemoteGateway}
|
||||
</Button>
|
||||
<Button
|
||||
className="-mr-2 font-medium"
|
||||
onClick={() => setOnboardingMode('apikey')}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{t.onboarding.haveApiKey}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -811,6 +840,10 @@ function FlowPanel({
|
||||
return <DecodedLabel text={t.onboarding.connectedPicking(title)} />
|
||||
}
|
||||
|
||||
if (flow.status === 'gateway_connected') {
|
||||
return <Status>{t.onboarding.gateway.connecting}</Status>
|
||||
}
|
||||
|
||||
if (flow.status === 'confirming_model') {
|
||||
return <ConfirmingModelPanel flow={flow} leaving={leaving} onBegin={onBegin} />
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ function resetStores() {
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
localEndpoint: false,
|
||||
gatewayMode: false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1735,7 +1735,27 @@ export const en: Translations = {
|
||||
price: (input, output) => `${input} in / ${output} out per Mtok`,
|
||||
change: 'Change',
|
||||
startChatting: 'Begin',
|
||||
docs: provider => `${provider} docs`
|
||||
docs: provider => `${provider} docs`,
|
||||
connectRemoteGateway: 'Connect to a remote gateway',
|
||||
gateway: {
|
||||
urlLabel: 'Gateway URL',
|
||||
urlHint: 'Base URL for the remote Hermes gateway. Path prefixes are supported, e.g. /hermes.',
|
||||
probing: 'Checking how this gateway authenticates…',
|
||||
probeError: 'Could not reach this gateway yet. Check the URL — the auth method will appear once it responds.',
|
||||
signIn: 'Sign in',
|
||||
signInWith: provider => `Sign in with ${provider}`,
|
||||
passwordHint: 'This gateway uses a username and password. Sign in to authorize this app.',
|
||||
oauthHint: provider => `This gateway uses OAuth. Sign in with ${provider} to authorize this app.`,
|
||||
tokenLabel: 'Session token',
|
||||
tokenPlaceholder: 'Paste session token',
|
||||
tokenHint: 'The dashboard session token used for REST and WebSocket access.',
|
||||
connect: 'Connect',
|
||||
connecting: 'Connecting',
|
||||
saveFailed: 'Could not save the gateway connection.',
|
||||
signInFailed: 'Sign-in failed. Try again.',
|
||||
backToProviders: 'Back to providers',
|
||||
identityProvider: 'the gateway'
|
||||
}
|
||||
},
|
||||
|
||||
modelPicker: {
|
||||
|
||||
@@ -1859,7 +1859,27 @@ export const ja = defineLocale({
|
||||
price: (input, output) => `${input} 入力 / ${output} 出力 per Mtok`,
|
||||
change: '変更',
|
||||
startChatting: '始める',
|
||||
docs: provider => `${provider} ドキュメント`
|
||||
docs: provider => `${provider} ドキュメント`,
|
||||
connectRemoteGateway: 'リモートゲートウェイに接続',
|
||||
gateway: {
|
||||
urlLabel: 'ゲートウェイ URL',
|
||||
urlHint: 'リモート Hermes ゲートウェイのベース URL。パスプレフィックス対応(例: /hermes)。',
|
||||
probing: 'ゲートウェイの認証方式を確認中…',
|
||||
probeError: 'ゲートウェイに接続できません。URL を確認してください — 応答後に認証方式が表示されます。',
|
||||
signIn: 'サインイン',
|
||||
signInWith: provider => `${provider} でサインイン`,
|
||||
passwordHint: 'このゲートウェイはユーザー名とパスワードを使用します。サインインしてアプリを認証してください。',
|
||||
oauthHint: provider => `このゲートウェイは OAuth を使用します。${provider} でサインインしてアプリを認証してください。`,
|
||||
tokenLabel: 'セッショントークン',
|
||||
tokenPlaceholder: 'セッショントークンを貼り付け',
|
||||
tokenHint: 'REST および WebSocket アクセスに使用されるダッシュボードセッショントークン。',
|
||||
connect: '接続',
|
||||
connecting: '接続中',
|
||||
saveFailed: 'ゲートウェイ接続を保存できませんでした。',
|
||||
signInFailed: 'サインインに失敗しました。再試行してください。',
|
||||
backToProviders: 'プロバイダーに戻る',
|
||||
identityProvider: 'ゲートウェイ'
|
||||
}
|
||||
},
|
||||
|
||||
modelPicker: {
|
||||
|
||||
@@ -1393,6 +1393,26 @@ export interface Translations {
|
||||
change: string
|
||||
startChatting: string
|
||||
docs: (provider: string) => string
|
||||
connectRemoteGateway: string
|
||||
gateway: {
|
||||
urlLabel: string
|
||||
urlHint: string
|
||||
probing: string
|
||||
probeError: string
|
||||
signIn: string
|
||||
signInWith: (provider: string) => string
|
||||
passwordHint: string
|
||||
oauthHint: (provider: string) => string
|
||||
tokenLabel: string
|
||||
tokenPlaceholder: string
|
||||
tokenHint: string
|
||||
connect: string
|
||||
connecting: string
|
||||
saveFailed: string
|
||||
signInFailed: string
|
||||
backToProviders: string
|
||||
identityProvider: string
|
||||
}
|
||||
}
|
||||
|
||||
modelPicker: {
|
||||
|
||||
@@ -1797,7 +1797,27 @@ export const zhHant = defineLocale({
|
||||
price: (input, output) => `${input} 輸入 / ${output} 輸出 每 Mtok`,
|
||||
change: '變更',
|
||||
startChatting: '開始',
|
||||
docs: provider => `${provider} 文件`
|
||||
docs: provider => `${provider} 文件`,
|
||||
connectRemoteGateway: '連線到遠端閘道',
|
||||
gateway: {
|
||||
urlLabel: '閘道 URL',
|
||||
urlHint: '遠端 Hermes 閘道的基礎 URL。支援路徑前綴,例如 /hermes。',
|
||||
probing: '正在檢查閘道認證方式…',
|
||||
probeError: '暫時無法連線到此閘道。請檢查 URL — 認證方式將在閘道回應後顯示。',
|
||||
signIn: '登入',
|
||||
signInWith: provider => `使用 ${provider} 登入`,
|
||||
passwordHint: '此閘道使用使用者名稱和密碼。登入以授權此應用。',
|
||||
oauthHint: provider => `此閘道使用 OAuth。使用 ${provider} 登入以授權此應用。`,
|
||||
tokenLabel: '工作階段權杖',
|
||||
tokenPlaceholder: '貼上工作階段權杖',
|
||||
tokenHint: '用於 REST 和 WebSocket 存取的儀表板工作階段權杖。',
|
||||
connect: '連線',
|
||||
connecting: '正在連線',
|
||||
saveFailed: '無法儲存閘道連線。',
|
||||
signInFailed: '登入失敗。請重試。',
|
||||
backToProviders: '返回提供商列表',
|
||||
identityProvider: '閘道'
|
||||
}
|
||||
},
|
||||
|
||||
modelPicker: {
|
||||
|
||||
@@ -1909,7 +1909,27 @@ export const zh: Translations = {
|
||||
price: (input, output) => `${input} 输入 / ${output} 输出每 Mtok`,
|
||||
change: '更改',
|
||||
startChatting: '开始',
|
||||
docs: provider => `${provider} 文档`
|
||||
docs: provider => `${provider} 文档`,
|
||||
connectRemoteGateway: '连接到远程网关',
|
||||
gateway: {
|
||||
urlLabel: '网关地址',
|
||||
urlHint: '远程 Hermes 网关的基础 URL。支持路径前缀,例如 /hermes。',
|
||||
probing: '正在检查网关认证方式…',
|
||||
probeError: '暂时无法连接到此网关。请检查 URL — 认证方式将在网关响应后显示。',
|
||||
signIn: '登录',
|
||||
signInWith: provider => `使用 ${provider} 登录`,
|
||||
passwordHint: '此网关使用用户名和密码。登录以授权此应用。',
|
||||
oauthHint: provider => `此网关使用 OAuth。使用 ${provider} 登录以授权此应用。`,
|
||||
tokenLabel: '会话令牌',
|
||||
tokenPlaceholder: '粘贴会话令牌',
|
||||
tokenHint: '用于 REST 和 WebSocket 访问的仪表板会话令牌。',
|
||||
connect: '连接',
|
||||
connecting: '正在连接',
|
||||
saveFailed: '无法保存网关连接。',
|
||||
signInFailed: '登录失败。请重试。',
|
||||
backToProviders: '返回提供商列表',
|
||||
identityProvider: '网关'
|
||||
}
|
||||
},
|
||||
|
||||
modelPicker: {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -35,6 +35,7 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
|
||||
firstRunSkipped: false,
|
||||
manual: false,
|
||||
localEndpoint: false,
|
||||
gatewayMode: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
submitOAuthCode,
|
||||
validateProviderCredential
|
||||
} from '@/hermes'
|
||||
import { isThinClient } from '@/lib/build-mode'
|
||||
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/types/hermes'
|
||||
@@ -20,7 +21,7 @@ type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }>
|
||||
type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }>
|
||||
type LoopbackStart = Extract<OAuthStartResponse, { flow: 'loopback' }>
|
||||
|
||||
export type OnboardingMode = 'apikey' | 'oauth'
|
||||
export type OnboardingMode = 'apikey' | 'oauth' | 'gateway'
|
||||
|
||||
export type OnboardingFlow =
|
||||
| { status: 'idle' }
|
||||
@@ -34,6 +35,14 @@ export type OnboardingFlow =
|
||||
| { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
|
||||
| { copied: boolean; provider: OAuthProvider; status: 'external_pending' }
|
||||
| { provider: OAuthProvider; status: 'success' }
|
||||
| {
|
||||
// Gateway onboarding: a remote gateway was configured (URL + auth),
|
||||
// and the connection was saved+applied. The overlay shows a brief
|
||||
// "connected" confirm before completing — the apply already reloads
|
||||
// the window, so this is mostly a transitional state.
|
||||
gatewayUrl: string
|
||||
status: 'gateway_connected'
|
||||
}
|
||||
| {
|
||||
// After successful credential acquisition, before completing
|
||||
// onboarding: show the user which model they're getting and let
|
||||
@@ -77,6 +86,11 @@ export interface DesktopOnboardingState {
|
||||
* custom endpoint"). Forces the API-key form with the local option
|
||||
* preselected instead of the OAuth picker. */
|
||||
localEndpoint: boolean
|
||||
/** True when the overlay should show the gateway-connection form instead
|
||||
* of the provider picker. Set on thin-client builds (where
|
||||
* there's no local backend to configure providers for), or when the user
|
||||
* picks "Connect to a remote gateway" from the thick-client picker. */
|
||||
gatewayMode: boolean
|
||||
}
|
||||
|
||||
export interface OnboardingContext {
|
||||
@@ -156,7 +170,8 @@ const INITIAL: DesktopOnboardingState = {
|
||||
requested: false,
|
||||
firstRunSkipped: readCachedSkipped(),
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
localEndpoint: false,
|
||||
gatewayMode: false
|
||||
}
|
||||
|
||||
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
|
||||
@@ -430,6 +445,105 @@ export function startManualLocalEndpoint(reason: null | string = null) {
|
||||
})
|
||||
}
|
||||
|
||||
// Open the onboarding overlay on the gateway-connection form. Used on the
|
||||
// thin client's first run (no local backend → must configure a remote
|
||||
// gateway), and on the thick client when the user picks "Connect to a
|
||||
// remote gateway" from the provider picker. The form drives the same
|
||||
// desktop bridge IPC (probe / save / apply / oauth-login / test) as the
|
||||
// Settings → Gateway page — just wrapped in the onboarding chrome.
|
||||
export function startGatewayOnboarding(reason: null | string = null) {
|
||||
pendingProviderOAuthId = null
|
||||
patch({
|
||||
gatewayMode: true,
|
||||
manual: false,
|
||||
mode: 'gateway',
|
||||
requested: true,
|
||||
localEndpoint: false,
|
||||
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
|
||||
flow: { status: 'idle' }
|
||||
})
|
||||
}
|
||||
|
||||
// Save + apply a remote gateway connection from the onboarding form. On
|
||||
// success, `applyConnectionConfig` reloads the window — so we just set the
|
||||
// transitional "gateway_connected" flow state and let the reload handle
|
||||
// the rest. On failure, surface the error in the form.
|
||||
export async function saveGatewayConnection(
|
||||
url: string,
|
||||
authMode: 'oauth' | 'token',
|
||||
token: string | undefined
|
||||
): Promise<{ ok: boolean; message?: string }> {
|
||||
const trimmedUrl = url.trim()
|
||||
|
||||
if (!trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
|
||||
return { ok: false, message: 'Enter a valid gateway URL (https://…).' }
|
||||
}
|
||||
|
||||
try {
|
||||
await window.hermesDesktop?.saveConnectionConfig({
|
||||
mode: 'remote',
|
||||
remoteUrl: trimmedUrl,
|
||||
remoteAuthMode: authMode,
|
||||
remoteToken: authMode === 'token' ? token?.trim() || undefined : undefined
|
||||
})
|
||||
// applyConnectionConfig restarts the backend / reloads the window.
|
||||
await window.hermesDesktop?.applyConnectionConfig({
|
||||
mode: 'remote',
|
||||
remoteUrl: trimmedUrl,
|
||||
remoteAuthMode: authMode,
|
||||
remoteToken: authMode === 'token' ? token?.trim() || undefined : undefined
|
||||
})
|
||||
setFlow({ status: 'gateway_connected', gatewayUrl: trimmedUrl })
|
||||
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
}
|
||||
|
||||
// Initiate OAuth sign-in against a remote gateway from the onboarding form.
|
||||
// Mirrors GatewaySettings.signIn: save the URL + oauth mode first (so the login
|
||||
// window has a target), then open the login window. On success, apply the
|
||||
// connection which reloads the window.
|
||||
export async function gatewayOauthLogin(
|
||||
url: string
|
||||
): Promise<{ ok: boolean; connected: boolean; message?: string }> {
|
||||
const trimmedUrl = url.trim()
|
||||
|
||||
if (!trimmedUrl) {
|
||||
return { ok: false, connected: false, message: 'Enter a gateway URL first.' }
|
||||
}
|
||||
|
||||
try {
|
||||
await window.hermesDesktop?.saveConnectionConfig({
|
||||
mode: 'remote',
|
||||
remoteUrl: trimmedUrl,
|
||||
remoteAuthMode: 'oauth'
|
||||
})
|
||||
|
||||
const result = await window.hermesDesktop?.oauthLoginConnectionConfig(trimmedUrl)
|
||||
|
||||
if (result?.connected) {
|
||||
await window.hermesDesktop?.applyConnectionConfig({
|
||||
mode: 'remote',
|
||||
remoteUrl: trimmedUrl,
|
||||
remoteAuthMode: 'oauth'
|
||||
})
|
||||
setFlow({ status: 'gateway_connected', gatewayUrl: trimmedUrl })
|
||||
|
||||
return { ok: true, connected: true }
|
||||
}
|
||||
|
||||
return { ok: false, connected: false, message: 'Sign-in was not completed.' }
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return { ok: false, connected: false, message: msg }
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot hand-off used when the dedicated Providers settings page launches a
|
||||
// specific provider's sign-in: we open the manual onboarding overlay AND
|
||||
// remember which provider to start, so the overlay drives that exact OAuth
|
||||
@@ -461,7 +575,7 @@ export function clearPendingProviderOAuth() {
|
||||
export function closeManualOnboarding() {
|
||||
pendingProviderOAuthId = null
|
||||
|
||||
patch({ manual: false, requested: false, localEndpoint: false, flow: { status: 'idle' } })
|
||||
patch({ manual: false, requested: false, localEndpoint: false, gatewayMode: false, flow: { status: 'idle' } })
|
||||
}
|
||||
|
||||
export function completeDesktopOnboarding() {
|
||||
@@ -479,7 +593,8 @@ export function completeDesktopOnboarding() {
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
localEndpoint: false,
|
||||
gatewayMode: false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -492,13 +607,21 @@ export function completeDesktopOnboarding() {
|
||||
export function dismissFirstRunOnboarding() {
|
||||
clearPoll()
|
||||
writeCachedSkipped(true)
|
||||
patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, flow: { status: 'idle' } })
|
||||
patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, gatewayMode: false, flow: { status: 'idle' } })
|
||||
}
|
||||
|
||||
export function setOnboardingMode(mode: OnboardingMode) {
|
||||
patch({ mode })
|
||||
}
|
||||
|
||||
// Exit the gateway connect form and return to the provider picker.
|
||||
// Only used on the thick client where the user can go back from the
|
||||
// gateway form to the provider list. On thin client the gateway form
|
||||
// is the only option, so the back button is hidden.
|
||||
export function exitGatewayMode() {
|
||||
patch({ gatewayMode: false, mode: 'oauth', flow: { status: 'idle' } })
|
||||
}
|
||||
|
||||
export async function refreshOnboarding(ctx: OnboardingContext) {
|
||||
// Manual mode (user opened the selector from a working app): never
|
||||
// auto-dismiss on runtime-ready — the whole point is to let them add /
|
||||
@@ -510,6 +633,34 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Thin client: there's no local backend, so the runtime check always
|
||||
// fails. Instead of showing the provider picker, check if a remote
|
||||
// gateway is already configured. If it is, the boot will succeed and
|
||||
// onCompleted fires. If not, show the gateway connect form.
|
||||
if (isThinClient()) {
|
||||
const config = await window.hermesDesktop?.getConnectionConfig?.().catch(() => null)
|
||||
|
||||
if (config?.mode === 'remote' && config.remoteUrl) {
|
||||
// A remote gateway is configured — let the boot proceed. The gateway
|
||||
// boot hook will either connect or surface a reauth failure.
|
||||
completeDesktopOnboarding()
|
||||
ctx.onCompleted?.()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// No remote gateway configured — show the gateway connect form.
|
||||
writeCachedConfigured(false)
|
||||
patch({
|
||||
configured: false,
|
||||
gatewayMode: true,
|
||||
mode: 'gateway',
|
||||
reason: 'Connect to a remote gateway to get started.'
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const runtime = await checkRuntime(ctx)
|
||||
|
||||
if (runtime.ready) {
|
||||
|
||||
@@ -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
|
||||
'';
|
||||
}
|
||||
)
|
||||
|
||||
0
scripts/slice_tests.py
Normal file
0
scripts/slice_tests.py
Normal file
Reference in New Issue
Block a user