fix(desktop): show 'hermes update' guidance for CLI installs instead of dead-end error

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.
This commit is contained in:
emozilla 2026-05-29 01:53:43 -04:00
parent 006136c4ab
commit ce31ec09b9
4 changed files with 125 additions and 14 deletions

View file

@ -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 })

View file

@ -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<DesktopUpdateStage, string> = {
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' && <ApplyingView apply={apply} />}
{phase === 'manual' && (
<ManualView command={apply.command ?? 'hermes update'} onDone={() => handleClose(false)} />
)}
{phase === 'error' && (
<ErrorView message={apply.message} onDismiss={() => 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 (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Terminal className="size-7" />
</span>
<DialogTitle className="text-center text-xl">Update from your terminal</DialogTitle>
<DialogDescription className="text-center text-sm">
You installed Hermes from the command line, so updates run there too. Paste this into your terminal:
</DialogDescription>
</div>
<button
type="button"
onClick={handleCopy}
className="group flex w-full items-center justify-between gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-3 text-left transition-colors hover:border-border hover:bg-muted/50"
>
<code className="select-all font-mono text-sm text-foreground">
<span className="text-muted-foreground">$ </span>
{command}
</code>
<span className="flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors group-hover:text-foreground">
{copied ? (
<>
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" />
Copied
</>
) : (
<>
<Copy className="size-3.5" />
Copy
</>
)}
</span>
</button>
<p className="text-center text-xs text-muted-foreground">
Hermes will pick up the new version next time you launch it.
</p>
<Button className="h-10 text-sm font-semibold" onClick={onDone} variant="outline">
Done
</Button>
</div>
)
}
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
const label = STAGE_LABELS[apply.stage] ?? 'Updating Hermes…'

View file

@ -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

View file

@ -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<DesktopVersionInfo | null>(null)
export const $updateApply = atom<UpdateApplyState>(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
})
}