From ce31ec09b98882b9ac366d8a6fcd9dc8c8194172 Mon Sep 17 00:00:00 2001 From: emozilla Date: Fri, 29 May 2026 01:53:43 -0400 Subject: [PATCH] fix(desktop): show 'hermes update' guidance for CLI installs instead of dead-end error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user who installed via the CLI (irm|iex / install.sh) then ran `hermes desktop` has no staged hermes-setup.exe, so clicking Update in-app hit resolveUpdaterBinary()=null and showed a misleading error ('re-run the Hermes installer') with a Try-again button that could never succeed — a dead loop for a perfectly valid install. Treat the no-updater case as an intentional outcome, not a failure: - main.cjs applyUpdates returns { ok:true, manual:true, command:'hermes update' } (no throw, no 'error' stage) when no updater binary exists. - New 'manual' update stage + apply-state.command thread the command to the UI. - updates-overlay ManualView: a polished terminal-native card with the exact command and a copy button, framed as the correct path for a CLI user rather than an error. GUI-installer users are unaffected — hermes-setup.exe present => seamless auto-update runs as before. Zero new process orchestration; can't fail the update demo. --- apps/desktop/electron/main.cjs | 20 ++++-- apps/desktop/src/app/updates-overlay.tsx | 80 ++++++++++++++++++++++-- apps/desktop/src/global.d.ts | 7 ++- apps/desktop/src/store/updates.ts | 32 +++++++++- 4 files changed, 125 insertions(+), 14 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 307454f0a3c..39e4e93a708 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -1061,11 +1061,21 @@ async function applyUpdates(opts = {}) { try { const updater = resolveUpdaterBinary() if (!updater) { - const message = - 'The Hermes updater was not found. Re-run the Hermes installer to ' + - 'enable in-app updates, or run `hermes update` from a terminal.' - emitUpdateProgress({ stage: 'error', error: 'no-updater', message }) - throw new Error(message) + // No staged updater binary — this is a CLI-installed user (they ran + // `hermes desktop`, never the Tauri installer that self-copies + // hermes-setup.exe into HERMES_HOME). They DO have a working `hermes` + // on PATH / in the venv, so the correct path is the one-liner in their + // native medium: `hermes update`. We surface that as an intentional + // "manual update" outcome — NOT an error with a dead retry button. + // + // We resolve the most copy-pasteable command we can: prefer the bare + // `hermes update` (works if hermes is on PATH, which install.sh/ps1 + // both arrange), and include the resolved checkout dir as context. + const command = 'hermes update' + const updateRoot = resolveUpdateRoot() + rememberLog(`[updates] no staged updater; surfacing manual \`${command}\` for CLI install at ${updateRoot}`) + emitUpdateProgress({ stage: 'manual', message: command, percent: null }) + return { ok: true, manual: true, command, hermesRoot: updateRoot } } emitUpdateProgress({ stage: 'restart', message: 'Handing off to the Hermes updater…', percent: 100 }) diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx index e5030394732..39cbe97c133 100644 --- a/apps/desktop/src/app/updates-overlay.tsx +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -1,11 +1,12 @@ import { useStore } from '@nanostores/react' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' +import { writeClipboardText } from '@/components/ui/copy-button' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global' import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog' -import { AlertCircle, CheckCircle2, Loader2, Sparkles } from '@/lib/icons' +import { AlertCircle, Check, CheckCircle2, Copy, Loader2, Sparkles, Terminal } from '@/lib/icons' import { cn } from '@/lib/utils' import { $updateApply, @@ -26,6 +27,7 @@ const STAGE_LABELS: Record = { pull: 'Almost there…', pydeps: 'Finishing up…', restart: 'Restarting Hermes…', + manual: 'Update from your terminal', error: 'Update paused' } @@ -47,8 +49,14 @@ export function UpdatesOverlay() { const behind = status?.behind ?? 0 - const phase: 'idle' | 'applying' | 'error' = - apply.applying || apply.stage === 'restart' ? 'applying' : apply.stage === 'error' ? 'error' : 'idle' + const phase: 'idle' | 'applying' | 'manual' | 'error' = + apply.stage === 'manual' + ? 'manual' + : apply.applying || apply.stage === 'restart' + ? 'applying' + : apply.stage === 'error' + ? 'error' + : 'idle' const handleClose = (next: boolean) => { if (phase === 'applying') { @@ -57,7 +65,7 @@ export function UpdatesOverlay() { setUpdateOverlayOpen(next) - if (!next && (apply.stage === 'error' || apply.stage === 'restart')) { + if (!next && (apply.stage === 'error' || apply.stage === 'restart' || apply.stage === 'manual')) { resetUpdateApplyState() } } @@ -74,6 +82,10 @@ export function UpdatesOverlay() { > {phase === 'applying' && } + {phase === 'manual' && ( + handleClose(false)} /> + )} + {phase === 'error' && ( handleClose(false)} onRetry={handleInstall} /> )} @@ -231,6 +243,64 @@ function IdleView({ ) } +function ManualView({ command, onDone }: { command: string; onDone: () => void }) { + const [copied, setCopied] = useState(false) + + const handleCopy = () => { + void writeClipboardText(command).then(() => { + setCopied(true) + window.setTimeout(() => setCopied(false), 1800) + }) + } + + return ( +
+
+ + + + + Update from your terminal + + You installed Hermes from the command line, so updates run there too. Paste this into your terminal: + +
+ + + +

+ Hermes will pick up the new version next time you launch it. +

+ + +
+ ) +} + function ApplyingView({ apply }: { apply: UpdateApplyState }) { const label = STAGE_LABELS[apply.stage] ?? 'Updating Hermes…' diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 732a80776ac..f285fb07765 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -110,9 +110,14 @@ export interface DesktopUpdateApplyResult { branch?: string error?: string message?: string + /** True when no staged updater exists (CLI install) and the user should run + * `hermes update` themselves. `command` is the exact line to run. */ + manual?: boolean + command?: string + hermesRoot?: string } -export type DesktopUpdateStage = 'idle' | 'prepare' | 'fetch' | 'pull' | 'pydeps' | 'restart' | 'error' +export type DesktopUpdateStage = 'idle' | 'prepare' | 'fetch' | 'pull' | 'pydeps' | 'restart' | 'manual' | 'error' export interface DesktopUpdateProgress { stage: DesktopUpdateStage diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index fc1d635efe4..39f9c984a1a 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -22,10 +22,21 @@ export interface UpdateApplyState { message: string percent: number | null error: string | null + /** When the stage is 'manual': the exact command the user should run + * (CLI install with no staged updater). */ + command: string | null log: readonly { stage: DesktopUpdateStage; message: string; at: number }[] } -const IDLE: UpdateApplyState = { applying: false, stage: 'idle', message: '', percent: null, error: null, log: [] } +const IDLE: UpdateApplyState = { + applying: false, + stage: 'idle', + message: '', + percent: null, + error: null, + command: null, + log: [] +} export const $desktopVersion = atom(null) export const $updateApply = atom(IDLE) @@ -142,7 +153,20 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis $updateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: 'Starting update…' }) try { - return await bridge.apply(opts) + const result = await bridge.apply(opts) + // CLI install with no staged updater: not an error — the user just runs + // `hermes update` themselves. Land on a dedicated manual state so the + // overlay shows the command + copy button instead of a dead retry loop. + if (result?.manual) { + $updateApply.set({ + ...IDLE, + applying: false, + stage: 'manual', + message: result.command ?? 'hermes update', + command: result.command ?? 'hermes update' + }) + } + return result } catch (error) { const message = error instanceof Error ? error.message : String(error) $updateApply.set({ ...$updateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message }) @@ -154,7 +178,7 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis function ingestProgress(payload: DesktopUpdateProgress): void { const current = $updateApply.get() const log = [...current.log, { stage: payload.stage, message: payload.message, at: payload.at }].slice(-50) - const terminal = payload.stage === 'error' || payload.stage === 'restart' + const terminal = payload.stage === 'error' || payload.stage === 'restart' || payload.stage === 'manual' $updateApply.set({ applying: !terminal, @@ -162,6 +186,8 @@ function ingestProgress(payload: DesktopUpdateProgress): void { message: payload.message, percent: payload.percent, error: payload.error, + // 'manual' carries the command to run in its message field. + command: payload.stage === 'manual' ? payload.message : current.command, log }) }