mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
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:
parent
006136c4ab
commit
ce31ec09b9
4 changed files with 125 additions and 14 deletions
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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…'
|
||||
|
||||
|
|
|
|||
7
apps/desktop/src/global.d.ts
vendored
7
apps/desktop/src/global.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue