feat: add safe desktop update system v2 (#221)
* Revert "fix: show Agent update blocked CTA" This reverts commit8721ae07cc. * Revert "fix: surface blocked Agent updates" This reverts commit5feac16ecd. * Revert "feat: split Workspace and Agent update flows" This reverts commitda11ce22e9. * Revert "fix: avoid unsafe upstream updater merge" This reverts commit1c865abd81. * Revert "feat: show Hermes update release notes" This reverts commit7d8d4d65a8. * Revert "fix: harden Hermes updater flow" This reverts commit73e47ae5c8. * Revert "feat: add Hermes workspace updater" This reverts commit53b7073706. * feat: add update system v2 * fix: fetch before update fast-forward check * fix: handle silent git checks in updater * fix: update configured Agent repo directly * fix: persist update release notes server-side * fix: repair release notes persistence syntax * fix: flag assistant role-confusion output * docs: add i18n contribution guide --------- Co-authored-by: Aurora release bot <release@outsourc-e.com>
This commit is contained in:
50
docs/desktop-update-system.md
Normal file
50
docs/desktop-update-system.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Hermes Workspace Desktop Update System
|
||||
|
||||
This branch introduces the update contract that the DMG/EXE packaging should use.
|
||||
|
||||
## Products
|
||||
|
||||
Hermes ships two separately updateable products:
|
||||
|
||||
1. **Hermes Workspace**: the UI/server shell.
|
||||
2. **Hermes Agent**: the local agent/gateway runtime.
|
||||
|
||||
They must not be modeled as two remotes in the same git checkout. The Workspace updater updates Workspace. The Agent updater updates the installed/bundled Agent.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/update/status`
|
||||
- returns Workspace + Agent version/install/update state.
|
||||
- `POST /api/update/workspace`
|
||||
- applies a Workspace update only when safe.
|
||||
- `POST /api/update/agent`
|
||||
- applies an Agent update only when safe.
|
||||
|
||||
## Install kinds
|
||||
|
||||
Current implementation detects:
|
||||
|
||||
- `git`: development/source checkout.
|
||||
- `docker`: running in container, update is not applied in-process.
|
||||
- `desktop`: reserved for DMG/EXE auto-updater integration.
|
||||
- `unknown`: cannot safely update automatically.
|
||||
|
||||
## Git/dev behavior
|
||||
|
||||
For git installs:
|
||||
|
||||
- Workspace updates use `origin/<branch>` and require a clean, fast-forwardable checkout.
|
||||
- Agent updates call the Agent's own `hermes update` command and require a clean Agent checkout.
|
||||
- Dirty or non-fast-forward states are blocked and surfaced as review-required, not as a copy-command primary path.
|
||||
|
||||
## Desktop behavior to wire next
|
||||
|
||||
The packaged app should set `HERMES_WORKSPACE_DESKTOP=1` and provide a desktop updater bridge that:
|
||||
|
||||
1. Checks a signed update manifest or GitHub Release.
|
||||
2. Downloads the Workspace app update through Electron auto-updater or equivalent.
|
||||
3. Updates the bundled Hermes Agent payload separately.
|
||||
4. Restarts Workspace + Agent after update.
|
||||
5. Stores release notes for the first screen after update.
|
||||
|
||||
The UI already expects product-level update status and release notes, so the desktop bridge should map into the same `/api/update/*` contract.
|
||||
80
docs/i18n-contributing.md
Normal file
80
docs/i18n-contributing.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Contributing UI translations
|
||||
|
||||
Hermes Workspace currently uses a lightweight translation map for the UI strings that have been wired for localization.
|
||||
|
||||
## Translation file
|
||||
|
||||
Translations live in:
|
||||
|
||||
```text
|
||||
src/lib/i18n.ts
|
||||
```
|
||||
|
||||
The important pieces are:
|
||||
|
||||
- `EN`: the source English keys.
|
||||
- `ZH`: Simplified Chinese translations.
|
||||
- `RU`: Russian translations.
|
||||
- `LOCALES`: maps language ids to translation maps.
|
||||
- `LOCALE_LABELS`: labels shown in the language selector.
|
||||
|
||||
## Adding or improving Chinese translations
|
||||
|
||||
1. Open `src/lib/i18n.ts`.
|
||||
2. Find the `ZH` object.
|
||||
3. Update the value on the right side of each key.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
const ZH: LocaleTranslations = {
|
||||
'nav.dashboard': '仪表板',
|
||||
'nav.chat': '聊天',
|
||||
}
|
||||
```
|
||||
|
||||
Keep the key names exactly the same. Only edit the translated text.
|
||||
|
||||
## Adding new translatable UI text
|
||||
|
||||
If you find hardcoded English UI text:
|
||||
|
||||
1. Add a new key to `EN`.
|
||||
2. Add the same key to every locale map (`ZH`, `RU`, etc.).
|
||||
3. Replace the hardcoded text in the component with `t('your.newKey')`.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
// src/lib/i18n.ts
|
||||
const EN = {
|
||||
'common.retry': 'Retry',
|
||||
} as const
|
||||
|
||||
const ZH: LocaleTranslations = {
|
||||
'common.retry': '重试',
|
||||
}
|
||||
```
|
||||
|
||||
Then in the component:
|
||||
|
||||
```tsx
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
;<button>{t('common.retry')}</button>
|
||||
```
|
||||
|
||||
## Testing locally
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run src/lib/i18n.test.ts
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Then open Settings → Language and switch to the target language.
|
||||
|
||||
## Current limitation
|
||||
|
||||
Not every UI string has been migrated to the translation map yet. If text remains in English after switching languages, it likely means that component still has hardcoded text and needs to be wired to `t(...)` first.
|
||||
@@ -1,891 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import {
|
||||
ArrowUp02Icon,
|
||||
Cancel01Icon,
|
||||
Loading03Icon,
|
||||
Settings02Icon,
|
||||
Tick01Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
|
||||
type RemoteName = 'origin' | 'upstream'
|
||||
type UpdatePhase = 'idle' | 'updating' | 'done' | 'error'
|
||||
|
||||
type RemoteStatus = {
|
||||
name: RemoteName
|
||||
label: string
|
||||
remoteHead: string | null
|
||||
currentHead: string | null
|
||||
updateAvailable: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type UpdateStatus = {
|
||||
ok: boolean
|
||||
app: {
|
||||
version: string
|
||||
branch: string | null
|
||||
currentHead: string | null
|
||||
dirty: boolean
|
||||
}
|
||||
remotes: Array<RemoteStatus>
|
||||
updateAvailable: boolean
|
||||
}
|
||||
|
||||
type ReleaseNoteSection = {
|
||||
name: RemoteName | 'agent'
|
||||
label: string
|
||||
from: string | null
|
||||
to: string | null
|
||||
commits: Array<string>
|
||||
}
|
||||
|
||||
type StoredReleaseNotes = {
|
||||
id: string
|
||||
updatedAt: number
|
||||
sections: Array<ReleaseNoteSection>
|
||||
}
|
||||
|
||||
type UpdateResult = {
|
||||
ok: boolean
|
||||
updated?: Array<RemoteName>
|
||||
skipped?: Array<{ name: RemoteName; reason: string }>
|
||||
restartRequired?: boolean
|
||||
releaseNotes?: Array<ReleaseNoteSection>
|
||||
error?: string
|
||||
}
|
||||
|
||||
type AgentUpdateStatus = {
|
||||
ok: boolean
|
||||
app: {
|
||||
name: string
|
||||
version: string
|
||||
path: string | null
|
||||
repoPath: string | null
|
||||
branch: string | null
|
||||
currentHead: string | null
|
||||
dirty: boolean
|
||||
}
|
||||
remote: {
|
||||
label: string
|
||||
url: string | null
|
||||
repoMatches: boolean
|
||||
currentHead: string | null
|
||||
remoteHead: string | null
|
||||
updateAvailable: boolean
|
||||
canUpdate: boolean
|
||||
error: string | null
|
||||
}
|
||||
updateAvailable: boolean
|
||||
manualCommand: string
|
||||
}
|
||||
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000
|
||||
const DISMISS_KEY = 'hermes-update-dismissed-heads'
|
||||
const AUTO_UPDATE_KEY = 'hermes-workspace-auto-update'
|
||||
const RELEASE_NOTES_KEY = 'hermes-update-release-notes'
|
||||
const RELEASE_NOTES_SEEN_KEY = 'hermes-update-release-notes-seen'
|
||||
const AGENT_DISMISS_KEY = 'hermes-agent-update-v2-dismissed-head'
|
||||
const AGENT_AUTO_UPDATE_KEY = 'hermes-agent-auto-update'
|
||||
|
||||
function shortSha(value: string | null | undefined): string {
|
||||
return value ? value.slice(0, 7) : 'unknown'
|
||||
}
|
||||
|
||||
function headsKey(remotes: Array<RemoteStatus>): string {
|
||||
return remotes
|
||||
.filter((remote) => remote.updateAvailable)
|
||||
.map((remote) => `${remote.name}:${remote.remoteHead ?? 'unknown'}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
}
|
||||
|
||||
function releaseNotesId(sections: Array<ReleaseNoteSection>): string {
|
||||
return sections
|
||||
.map(
|
||||
(section) =>
|
||||
`${section.name}:${section.from ?? 'unknown'}:${section.to ?? 'unknown'}`,
|
||||
)
|
||||
.sort()
|
||||
.join('|')
|
||||
}
|
||||
|
||||
function readStoredReleaseNotes(): StoredReleaseNotes | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const raw = localStorage.getItem(RELEASE_NOTES_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as StoredReleaseNotes
|
||||
if (!parsed?.id || !Array.isArray(parsed.sections)) return null
|
||||
if (localStorage.getItem(RELEASE_NOTES_SEEN_KEY) === parsed.id) return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function storeReleaseNotes(
|
||||
sections: Array<ReleaseNoteSection>,
|
||||
): StoredReleaseNotes | null {
|
||||
if (typeof window === 'undefined' || sections.length === 0) return null
|
||||
const notes: StoredReleaseNotes = {
|
||||
id: releaseNotesId(sections),
|
||||
updatedAt: Date.now(),
|
||||
sections,
|
||||
}
|
||||
localStorage.setItem(RELEASE_NOTES_KEY, JSON.stringify(notes))
|
||||
localStorage.removeItem(RELEASE_NOTES_SEEN_KEY)
|
||||
return notes
|
||||
}
|
||||
|
||||
function markReleaseNotesSeen(notes: StoredReleaseNotes): void {
|
||||
localStorage.setItem(RELEASE_NOTES_SEEN_KEY, notes.id)
|
||||
}
|
||||
|
||||
export function HermesUpdateNotifier() {
|
||||
const queryClient = useQueryClient()
|
||||
const [dismissed, setDismissed] = useState<string | null>(null)
|
||||
const [autoUpdate, setAutoUpdate] = useState(false)
|
||||
const [phase, setPhase] = useState<UpdatePhase>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [agentDismissed, setAgentDismissed] = useState<string | null>(null)
|
||||
const [agentAutoUpdate, setAgentAutoUpdate] = useState(false)
|
||||
const [agentPhase, setAgentPhase] = useState<UpdatePhase>('idle')
|
||||
const [agentErrorMsg, setAgentErrorMsg] = useState('')
|
||||
const [agentProgress, setAgentProgress] = useState(0)
|
||||
const [releaseNotes, setReleaseNotes] = useState<StoredReleaseNotes | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
setDismissed(localStorage.getItem(DISMISS_KEY))
|
||||
setAutoUpdate(localStorage.getItem(AUTO_UPDATE_KEY) === 'true')
|
||||
setAgentDismissed(localStorage.getItem(AGENT_DISMISS_KEY))
|
||||
setAgentAutoUpdate(localStorage.getItem(AGENT_AUTO_UPDATE_KEY) === 'true')
|
||||
setReleaseNotes(readStoredReleaseNotes())
|
||||
}, [])
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['hermes-update-check'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/claude-update')
|
||||
if (!res.ok) return null
|
||||
return res.json() as Promise<UpdateStatus>
|
||||
},
|
||||
refetchInterval: CHECK_INTERVAL_MS,
|
||||
staleTime: CHECK_INTERVAL_MS,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const { data: agentData } = useQuery({
|
||||
queryKey: ['hermes-agent-update-check'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/hermes-agent-update')
|
||||
if (!res.ok) return null
|
||||
return res.json() as Promise<AgentUpdateStatus>
|
||||
},
|
||||
refetchInterval: CHECK_INTERVAL_MS,
|
||||
staleTime: CHECK_INTERVAL_MS,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const updateHeadsKey = useMemo(
|
||||
() => headsKey(data?.remotes ?? []),
|
||||
[data?.remotes],
|
||||
)
|
||||
const updateRemotes =
|
||||
data?.remotes.filter((remote) => remote.updateAvailable) ?? []
|
||||
const target =
|
||||
updateRemotes.length > 1 ? 'all' : (updateRemotes[0]?.name ?? 'origin')
|
||||
const visible = Boolean(
|
||||
data?.updateAvailable &&
|
||||
updateHeadsKey &&
|
||||
dismissed !== updateHeadsKey &&
|
||||
phase !== 'done',
|
||||
)
|
||||
const isUpdating = phase === 'updating'
|
||||
const agentHeadsKey = agentData?.remote.remoteHead ?? ''
|
||||
const agentManualCommand = agentData?.app.repoPath
|
||||
? `cd ${agentData.app.repoPath} && git status && hermes update`
|
||||
: (agentData?.manualCommand ?? 'hermes update')
|
||||
const agentVisible = Boolean(
|
||||
agentData?.updateAvailable &&
|
||||
agentHeadsKey &&
|
||||
agentDismissed !== agentHeadsKey &&
|
||||
agentPhase !== 'done',
|
||||
)
|
||||
const agentIsUpdating = agentPhase === 'updating'
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoUpdate || !data?.updateAvailable || !visible || phase !== 'idle')
|
||||
return
|
||||
if (data.app.dirty) return
|
||||
void handleUpdate()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoUpdate, data?.updateAvailable, data?.app.dirty, visible, phase])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!agentAutoUpdate ||
|
||||
!agentData?.updateAvailable ||
|
||||
!agentVisible ||
|
||||
agentPhase !== 'idle'
|
||||
)
|
||||
return
|
||||
if (!agentData.remote.canUpdate) return
|
||||
void handleAgentUpdate()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
agentAutoUpdate,
|
||||
agentData?.updateAvailable,
|
||||
agentData?.remote.canUpdate,
|
||||
agentVisible,
|
||||
agentPhase,
|
||||
])
|
||||
|
||||
function handleDismiss() {
|
||||
if (!updateHeadsKey) return
|
||||
localStorage.setItem(DISMISS_KEY, updateHeadsKey)
|
||||
setDismissed(updateHeadsKey)
|
||||
}
|
||||
|
||||
function toggleAutoUpdate() {
|
||||
const next = !autoUpdate
|
||||
setAutoUpdate(next)
|
||||
localStorage.setItem(AUTO_UPDATE_KEY, String(next))
|
||||
}
|
||||
|
||||
function handleAgentDismiss() {
|
||||
if (!agentHeadsKey) return
|
||||
localStorage.setItem(AGENT_DISMISS_KEY, agentHeadsKey)
|
||||
setAgentDismissed(agentHeadsKey)
|
||||
}
|
||||
|
||||
function toggleAgentAutoUpdate() {
|
||||
const next = !agentAutoUpdate
|
||||
setAgentAutoUpdate(next)
|
||||
localStorage.setItem(AGENT_AUTO_UPDATE_KEY, String(next))
|
||||
}
|
||||
|
||||
async function copyAgentManualCommand() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(agentManualCommand)
|
||||
toast('Copied Hermes Agent manual update command.', { type: 'success' })
|
||||
} catch {
|
||||
toast(agentManualCommand, { type: 'info', duration: 9000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
setPhase('updating')
|
||||
setErrorMsg('')
|
||||
setProgress(10)
|
||||
|
||||
const progressTimer = window.setInterval(() => {
|
||||
setProgress((value) => Math.min(value + 3, 88))
|
||||
}, 400)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/claude-update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target }),
|
||||
})
|
||||
const result = (await res.json()) as UpdateResult
|
||||
window.clearInterval(progressTimer)
|
||||
|
||||
if (!res.ok || !result.ok) {
|
||||
setPhase('error')
|
||||
setProgress(0)
|
||||
setErrorMsg(result.error || 'Update failed')
|
||||
return
|
||||
}
|
||||
|
||||
setProgress(100)
|
||||
setPhase('done')
|
||||
if (updateHeadsKey) {
|
||||
localStorage.setItem(DISMISS_KEY, updateHeadsKey)
|
||||
setDismissed(updateHeadsKey)
|
||||
}
|
||||
const storedNotes = result.releaseNotes?.length
|
||||
? storeReleaseNotes(result.releaseNotes)
|
||||
: null
|
||||
if (storedNotes) setReleaseNotes(storedNotes)
|
||||
await queryClient.invalidateQueries({ queryKey: ['hermes-update-check'] })
|
||||
toast(
|
||||
result.restartRequired
|
||||
? 'Hermes Workspace update installed. Restart the Workspace process to run the new code.'
|
||||
: 'Hermes Workspace is already up to date.',
|
||||
{ type: 'success', duration: 7000 },
|
||||
)
|
||||
} catch (err) {
|
||||
window.clearInterval(progressTimer)
|
||||
setPhase('error')
|
||||
setProgress(0)
|
||||
setErrorMsg(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAgentUpdate() {
|
||||
setAgentPhase('updating')
|
||||
setAgentErrorMsg('')
|
||||
setAgentProgress(10)
|
||||
|
||||
const progressTimer = window.setInterval(() => {
|
||||
setAgentProgress((value) => Math.min(value + 2, 88))
|
||||
}, 600)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/hermes-agent-update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const result = (await res.json()) as UpdateResult
|
||||
window.clearInterval(progressTimer)
|
||||
|
||||
if (!res.ok || !result.ok) {
|
||||
setAgentPhase('error')
|
||||
setAgentProgress(0)
|
||||
setAgentErrorMsg(result.error || 'Hermes Agent update failed')
|
||||
return
|
||||
}
|
||||
|
||||
setAgentProgress(100)
|
||||
setAgentPhase('done')
|
||||
if (agentHeadsKey) {
|
||||
localStorage.setItem(AGENT_DISMISS_KEY, agentHeadsKey)
|
||||
setAgentDismissed(agentHeadsKey)
|
||||
}
|
||||
const storedNotes = result.releaseNotes?.length
|
||||
? storeReleaseNotes(result.releaseNotes)
|
||||
: null
|
||||
if (storedNotes) setReleaseNotes(storedNotes)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['hermes-agent-update-check'],
|
||||
})
|
||||
toast(
|
||||
'Hermes Agent update installed. Restart running agent/gateway processes to use it.',
|
||||
{
|
||||
type: 'success',
|
||||
duration: 7000,
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
window.clearInterval(progressTimer)
|
||||
setAgentPhase('error')
|
||||
setAgentProgress(0)
|
||||
setAgentErrorMsg(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
function closeReleaseNotes() {
|
||||
if (releaseNotes) markReleaseNotesSeen(releaseNotes)
|
||||
setReleaseNotes(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReleaseNotesModal notes={releaseNotes} onClose={closeReleaseNotes} />
|
||||
<AnimatePresence>
|
||||
{agentVisible && agentData ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -40, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -40, scale: 0.96 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="fixed left-1/2 top-[calc(var(--titlebar-h,0px)+1rem)] z-[9998] w-[90vw] max-w-md -translate-x-1/2 overflow-hidden rounded-2xl shadow-2xl"
|
||||
style={{
|
||||
background: 'var(--theme-card)',
|
||||
border: '1px solid var(--theme-border)',
|
||||
color: 'var(--theme-text)',
|
||||
boxShadow: 'var(--theme-shadow-3)',
|
||||
}}
|
||||
>
|
||||
{agentIsUpdating ? (
|
||||
<motion.div
|
||||
className="h-0.5 origin-left"
|
||||
style={{ background: 'var(--theme-accent)' }}
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: agentProgress / 100 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex items-center gap-3 px-5 py-3.5">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-xl',
|
||||
agentPhase === 'error' ? 'bg-red-500/15' : '',
|
||||
)}
|
||||
style={
|
||||
agentPhase === 'idle' || agentPhase === 'updating'
|
||||
? {
|
||||
background:
|
||||
'color-mix(in srgb, var(--theme-accent) 14%, transparent)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{agentIsUpdating ? (
|
||||
<HugeiconsIcon
|
||||
icon={Loading03Icon}
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
className="animate-spin"
|
||||
style={{ color: 'var(--theme-accent)' }}
|
||||
/>
|
||||
) : (
|
||||
<HugeiconsIcon
|
||||
icon={ArrowUp02Icon}
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
style={{ color: 'var(--theme-accent)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: 'var(--theme-text)' }}
|
||||
>
|
||||
{agentPhase === 'updating'
|
||||
? 'Updating Hermes Agent...'
|
||||
: agentPhase === 'error'
|
||||
? 'Hermes Agent update failed'
|
||||
: 'Hermes Agent update available'}
|
||||
</p>
|
||||
<p
|
||||
className="truncate text-xs"
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
>
|
||||
{agentPhase === 'error'
|
||||
? agentErrorMsg
|
||||
: agentData.remote.error
|
||||
? `Update blocked: ${agentData.remote.error}`
|
||||
: `${shortSha(agentData.remote.currentHead)} → ${shortSha(agentData.remote.remoteHead)}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{agentPhase === 'idle' || agentPhase === 'error' ? (
|
||||
agentData.remote.canUpdate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAgentUpdate}
|
||||
className="rounded-lg px-4 py-1.5 text-xs font-semibold text-white transition-opacity hover:opacity-90"
|
||||
style={{ background: 'var(--theme-accent)' }}
|
||||
>
|
||||
{agentPhase === 'error' ? 'Retry' : 'Install'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyAgentManualCommand}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-opacity hover:opacity-90"
|
||||
style={{ background: 'var(--theme-accent)' }}
|
||||
title={agentManualCommand}
|
||||
>
|
||||
Copy update command
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAgentDismiss}
|
||||
className="rounded-lg p-1.5 transition-colors hover:opacity-80"
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
aria-label="Dismiss Hermes Agent update"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Cancel01Icon}
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agentPhase === 'idle' || agentPhase === 'error' ? (
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 border-t px-5 py-2.5"
|
||||
style={{ borderColor: 'var(--theme-border)' }}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<HugeiconsIcon
|
||||
icon={Settings02Icon}
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
/>
|
||||
<span
|
||||
className="truncate text-xs"
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
>
|
||||
{agentData.remote.canUpdate
|
||||
? 'Auto-update Agent when safe'
|
||||
: 'Manual cleanup required. Copy the command, fix local changes, then run it.'}
|
||||
</span>
|
||||
</div>
|
||||
{agentData.remote.canUpdate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAgentAutoUpdate}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors duration-200"
|
||||
style={{
|
||||
background: agentAutoUpdate
|
||||
? 'var(--theme-accent)'
|
||||
: 'var(--theme-card2)',
|
||||
}}
|
||||
role="switch"
|
||||
aria-checked={agentAutoUpdate}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none mt-0.5 inline-block size-4 rounded-full bg-white shadow-sm transition-transform duration-200',
|
||||
agentAutoUpdate
|
||||
? 'translate-x-[17px]'
|
||||
: 'translate-x-0.5',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<code
|
||||
className="hidden max-w-[11rem] shrink-0 truncate rounded px-2 py-1 text-[10px] sm:block"
|
||||
style={{
|
||||
background: 'var(--theme-card2)',
|
||||
color: 'var(--theme-muted)',
|
||||
}}
|
||||
title={agentManualCommand}
|
||||
>
|
||||
hermes update
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{visible && data ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -40, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -40, scale: 0.96 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
className={cn(
|
||||
'fixed left-1/2 z-[9998] w-[90vw] max-w-md -translate-x-1/2 overflow-hidden rounded-2xl shadow-2xl',
|
||||
agentVisible
|
||||
? 'top-[calc(var(--titlebar-h,0px)+7rem)]'
|
||||
: 'top-[calc(var(--titlebar-h,0px)+1rem)]',
|
||||
)}
|
||||
style={{
|
||||
background: 'var(--theme-card)',
|
||||
border: '1px solid var(--theme-border)',
|
||||
color: 'var(--theme-text)',
|
||||
boxShadow: 'var(--theme-shadow-3)',
|
||||
}}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<motion.div
|
||||
className="h-0.5 origin-left"
|
||||
style={{ background: 'var(--theme-accent)' }}
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: progress / 100 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
/>
|
||||
) : null}
|
||||
{phase === 'done' ? <div className="h-0.5 bg-green-500" /> : null}
|
||||
|
||||
<div className="flex items-center gap-3 px-5 py-3.5">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-xl',
|
||||
phase === 'error'
|
||||
? 'bg-red-500/15'
|
||||
: phase === 'done'
|
||||
? 'bg-green-500/15'
|
||||
: '',
|
||||
)}
|
||||
style={
|
||||
phase === 'idle' || phase === 'updating'
|
||||
? {
|
||||
background:
|
||||
'color-mix(in srgb, var(--theme-accent) 14%, transparent)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<HugeiconsIcon
|
||||
icon={Loading03Icon}
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
className="animate-spin"
|
||||
style={{ color: 'var(--theme-accent)' }}
|
||||
/>
|
||||
) : phase === 'done' ? (
|
||||
<HugeiconsIcon
|
||||
icon={Tick01Icon}
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
className="text-green-400"
|
||||
/>
|
||||
) : (
|
||||
<HugeiconsIcon
|
||||
icon={ArrowUp02Icon}
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
style={{ color: 'var(--theme-accent)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: 'var(--theme-text)' }}
|
||||
>
|
||||
{phase === 'updating'
|
||||
? 'Updating Hermes Workspace...'
|
||||
: phase === 'error'
|
||||
? 'Hermes Workspace update failed'
|
||||
: 'Hermes Workspace update available'}
|
||||
</p>
|
||||
<p
|
||||
className="truncate text-xs"
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
>
|
||||
{phase === 'error'
|
||||
? errorMsg
|
||||
: data.app.dirty
|
||||
? 'Local changes detected. Commit or stash before updating.'
|
||||
: updateRemotes
|
||||
.map(
|
||||
(remote) =>
|
||||
`${remote.label}: ${shortSha(remote.currentHead)} → ${shortSha(remote.remoteHead)}`,
|
||||
)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{(phase === 'idle' || phase === 'error') && !data.app.dirty ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpdate}
|
||||
className="rounded-lg px-4 py-1.5 text-xs font-semibold text-white transition-opacity hover:opacity-90"
|
||||
style={{ background: 'var(--theme-accent)' }}
|
||||
>
|
||||
{phase === 'error' ? 'Retry' : 'Install'}
|
||||
</button>
|
||||
) : null}
|
||||
{!isUpdating ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="rounded-lg p-1.5 transition-colors hover:opacity-80"
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Cancel01Icon}
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{phase === 'idle' || phase === 'error' ? (
|
||||
<div
|
||||
className="flex items-center justify-between border-t px-5 py-2.5"
|
||||
style={{ borderColor: 'var(--theme-border)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<HugeiconsIcon
|
||||
icon={Settings02Icon}
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
/>
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
>
|
||||
Auto-update Workspace when clean
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAutoUpdate}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors duration-200',
|
||||
autoUpdate ? '' : '',
|
||||
)}
|
||||
style={{
|
||||
background: autoUpdate
|
||||
? 'var(--theme-accent)'
|
||||
: 'var(--theme-card2)',
|
||||
}}
|
||||
role="switch"
|
||||
aria-checked={autoUpdate}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none mt-0.5 inline-block size-4 rounded-full bg-white shadow-sm transition-transform duration-200',
|
||||
autoUpdate ? 'translate-x-[17px]' : 'translate-x-0.5',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseNotesModal({
|
||||
notes,
|
||||
onClose,
|
||||
}: {
|
||||
notes: StoredReleaseNotes | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
if (!notes) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[10000] flex items-start justify-center bg-black/45 px-4 pt-[calc(var(--titlebar-h,0px)+1.5rem)] backdrop-blur-sm sm:items-center sm:pt-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 24, scale: 0.96 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="w-full max-w-lg overflow-hidden rounded-2xl shadow-2xl"
|
||||
style={{
|
||||
background: 'var(--theme-card)',
|
||||
border: '1px solid var(--theme-border)',
|
||||
color: 'var(--theme-text)',
|
||||
boxShadow: 'var(--theme-shadow-3)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3 px-5 py-4">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-xl"
|
||||
style={{
|
||||
background:
|
||||
'color-mix(in srgb, var(--theme-accent) 14%, transparent)',
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Tick01Icon}
|
||||
size={20}
|
||||
strokeWidth={2}
|
||||
style={{ color: 'var(--theme-accent)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="text-base font-semibold"
|
||||
style={{ color: 'var(--theme-text)' }}
|
||||
>
|
||||
Hermes updated
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: 'var(--theme-muted)' }}>
|
||||
What changed in this Workspace / Agent update.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 transition-opacity hover:opacity-80"
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
aria-label="Close release notes"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] space-y-4 overflow-auto px-5 pb-5">
|
||||
{notes.sections.map((section) => (
|
||||
<section key={`${section.name}:${section.to ?? section.label}`}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<h3
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: 'var(--theme-text)' }}
|
||||
>
|
||||
{section.label}
|
||||
</h3>
|
||||
<span
|
||||
className="shrink-0 rounded-full px-2 py-0.5 text-[11px]"
|
||||
style={{
|
||||
background: 'var(--theme-card2)',
|
||||
color: 'var(--theme-muted)',
|
||||
}}
|
||||
>
|
||||
{shortSha(section.from)} → {shortSha(section.to)}
|
||||
</span>
|
||||
</div>
|
||||
{section.commits.length ? (
|
||||
<ul className="space-y-1.5">
|
||||
{section.commits.map((commit, index) => (
|
||||
<li
|
||||
key={`${section.name}-${index}-${commit}`}
|
||||
className="rounded-xl px-3 py-2 text-sm"
|
||||
style={{
|
||||
background: 'var(--theme-card2)',
|
||||
color: 'var(--theme-text)',
|
||||
}}
|
||||
>
|
||||
{commit}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
>
|
||||
Updated to the latest available commit.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end border-t px-5 py-3"
|
||||
style={{ borderColor: 'var(--theme-border)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
||||
style={{ background: 'var(--theme-accent)' }}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
468
src/components/update-center-notifier.tsx
Normal file
468
src/components/update-center-notifier.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import {
|
||||
ArrowUp02Icon,
|
||||
Cancel01Icon,
|
||||
Loading03Icon,
|
||||
Tick01Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
|
||||
type ProductId = 'workspace' | 'agent'
|
||||
type ProductUpdateStatus = {
|
||||
id: ProductId
|
||||
label: string
|
||||
installKind: 'git' | 'desktop' | 'docker' | 'unknown'
|
||||
version: string
|
||||
path: string | null
|
||||
repoPath: string | null
|
||||
branch: string | null
|
||||
currentHead: string | null
|
||||
latestHead: string | null
|
||||
updateAvailable: boolean
|
||||
canUpdate: boolean
|
||||
state: 'current' | 'available' | 'blocked' | 'unsupported' | 'error'
|
||||
reason: string | null
|
||||
updateMode: string
|
||||
}
|
||||
|
||||
type UpdateStatus = {
|
||||
ok: true
|
||||
checkedAt: number
|
||||
products: Record<ProductId, ProductUpdateStatus>
|
||||
updateAvailable: boolean
|
||||
pendingReleaseNotes?: Array<ReleaseNoteSection>
|
||||
}
|
||||
|
||||
type ReleaseNoteSection = {
|
||||
product: ProductId
|
||||
label: string
|
||||
from: string | null
|
||||
to: string | null
|
||||
commits: Array<string>
|
||||
}
|
||||
|
||||
type ApplyUpdateResult = {
|
||||
ok: boolean
|
||||
product: ProductId
|
||||
output?: string
|
||||
restartRequired?: boolean
|
||||
status?: ProductUpdateStatus
|
||||
releaseNotes?: Array<ReleaseNoteSection>
|
||||
error?: string
|
||||
}
|
||||
|
||||
type Phase = 'idle' | 'updating' | 'done' | 'error'
|
||||
type Notes = {
|
||||
id: string
|
||||
sections: Array<ReleaseNoteSection>
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000
|
||||
const DISMISS_PREFIX = 'hermes-update-v2-dismissed:'
|
||||
const NOTES_KEY = 'hermes-update-v2-release-notes'
|
||||
const NOTES_SEEN_KEY = 'hermes-update-v2-release-notes-seen'
|
||||
|
||||
function shortSha(value: string | null | undefined): string {
|
||||
return value ? value.slice(0, 7) : 'unknown'
|
||||
}
|
||||
|
||||
function productDismissKey(product: ProductUpdateStatus): string {
|
||||
return `${product.id}:${product.latestHead ?? product.version ?? 'unknown'}`
|
||||
}
|
||||
|
||||
function notesId(sections: Array<ReleaseNoteSection>): string {
|
||||
return sections
|
||||
.map((section) => `${section.product}:${section.from}:${section.to}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
}
|
||||
|
||||
function storeNotes(sections: Array<ReleaseNoteSection>): Notes | null {
|
||||
if (!sections.length) return null
|
||||
const notes = { id: notesId(sections), sections, updatedAt: Date.now() }
|
||||
localStorage.setItem(NOTES_KEY, JSON.stringify(notes))
|
||||
localStorage.removeItem(NOTES_SEEN_KEY)
|
||||
return notes
|
||||
}
|
||||
|
||||
function readNotes(): Notes | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(NOTES_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Notes
|
||||
if (!parsed?.id || !Array.isArray(parsed.sections)) return null
|
||||
if (localStorage.getItem(NOTES_SEEN_KEY) === parsed.id) return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function UpdateCenterNotifier() {
|
||||
const queryClient = useQueryClient()
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set())
|
||||
const [phases, setPhases] = useState<Record<ProductId, Phase>>({
|
||||
workspace: 'idle',
|
||||
agent: 'idle',
|
||||
})
|
||||
const [errors, setErrors] = useState<Record<ProductId, string>>({
|
||||
workspace: '',
|
||||
agent: '',
|
||||
})
|
||||
const [notes, setNotes] = useState<Notes | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const values = new Set<string>()
|
||||
for (const key of Object.keys(localStorage)) {
|
||||
if (key.startsWith(DISMISS_PREFIX))
|
||||
values.add(localStorage.getItem(key) || '')
|
||||
}
|
||||
setDismissed(values)
|
||||
setNotes(readNotes())
|
||||
}, [])
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['update-status-v2'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/update/status')
|
||||
if (!res.ok) return null
|
||||
return res.json() as Promise<UpdateStatus>
|
||||
},
|
||||
refetchInterval: CHECK_INTERVAL_MS,
|
||||
staleTime: CHECK_INTERVAL_MS,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.pendingReleaseNotes?.length) return
|
||||
const stored = storeNotes(data.pendingReleaseNotes)
|
||||
if (stored) setNotes((current) => current ?? stored)
|
||||
}, [data?.pendingReleaseNotes])
|
||||
|
||||
const visibleProducts = useMemo(() => {
|
||||
const products = data ? [data.products.workspace, data.products.agent] : []
|
||||
return products.filter((product) => {
|
||||
if (!product.updateAvailable) return false
|
||||
if (phases[product.id] === 'done') return false
|
||||
return !dismissed.has(productDismissKey(product))
|
||||
})
|
||||
}, [data, dismissed, phases])
|
||||
|
||||
function dismiss(product: ProductUpdateStatus) {
|
||||
const key = productDismissKey(product)
|
||||
localStorage.setItem(`${DISMISS_PREFIX}${product.id}`, key)
|
||||
setDismissed((prev) => new Set([...prev, key]))
|
||||
}
|
||||
|
||||
async function update(product: ProductUpdateStatus) {
|
||||
if (!product.canUpdate) return
|
||||
setPhases((prev) => ({ ...prev, [product.id]: 'updating' }))
|
||||
setErrors((prev) => ({ ...prev, [product.id]: '' }))
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/update/${product.id === 'workspace' ? 'workspace' : 'agent'}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
)
|
||||
const result = (await res.json()) as ApplyUpdateResult
|
||||
if (!res.ok || !result.ok) {
|
||||
setPhases((prev) => ({ ...prev, [product.id]: 'error' }))
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[product.id]: result.error || `${product.label} update failed`,
|
||||
}))
|
||||
return
|
||||
}
|
||||
setPhases((prev) => ({ ...prev, [product.id]: 'done' }))
|
||||
dismiss(product)
|
||||
const stored = result.releaseNotes?.length
|
||||
? storeNotes(result.releaseNotes)
|
||||
: null
|
||||
if (stored) setNotes(stored)
|
||||
await queryClient.invalidateQueries({ queryKey: ['update-status-v2'] })
|
||||
toast(`${product.label} updated. Restart may be required.`, {
|
||||
type: 'success',
|
||||
duration: 7000,
|
||||
})
|
||||
} catch (err) {
|
||||
setPhases((prev) => ({ ...prev, [product.id]: 'error' }))
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[product.id]: err instanceof Error ? err.message : String(err),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function closeNotes() {
|
||||
if (notes) localStorage.setItem(NOTES_SEEN_KEY, notes.id)
|
||||
setNotes(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReleaseNotes notes={notes} onClose={closeNotes} />
|
||||
<div className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-h,0px)+1rem)] z-[9998] flex w-[92vw] max-w-md -translate-x-1/2 flex-col gap-3">
|
||||
<AnimatePresence>
|
||||
{visibleProducts.map((product) => (
|
||||
<UpdateCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
phase={phases[product.id]}
|
||||
error={errors[product.id]}
|
||||
onDismiss={() => dismiss(product)}
|
||||
onUpdate={() => update(product)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function UpdateCard({
|
||||
product,
|
||||
phase,
|
||||
error,
|
||||
onDismiss,
|
||||
onUpdate,
|
||||
}: {
|
||||
product: ProductUpdateStatus
|
||||
phase: Phase
|
||||
error: string
|
||||
onDismiss: () => void
|
||||
onUpdate: () => void
|
||||
}) {
|
||||
const updating = phase === 'updating'
|
||||
const blocked = product.updateAvailable && !product.canUpdate
|
||||
const subtitle =
|
||||
phase === 'error'
|
||||
? error
|
||||
: blocked
|
||||
? product.reason || 'Update requires manual review.'
|
||||
: `${shortSha(product.currentHead)} → ${shortSha(product.latestHead)} · ${product.installKind}`
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -24, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -24, scale: 0.96 }}
|
||||
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="pointer-events-auto overflow-hidden rounded-2xl shadow-2xl"
|
||||
style={{
|
||||
background: 'var(--theme-card)',
|
||||
border: '1px solid var(--theme-border)',
|
||||
color: 'var(--theme-text)',
|
||||
boxShadow: 'var(--theme-shadow-3)',
|
||||
}}
|
||||
>
|
||||
{updating ? (
|
||||
<div
|
||||
className="h-0.5 animate-pulse"
|
||||
style={{ background: 'var(--theme-accent)' }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex items-center gap-3 px-5 py-3.5">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-xl',
|
||||
blocked || phase === 'error' ? 'bg-amber-500/15' : '',
|
||||
)}
|
||||
style={
|
||||
!blocked && phase !== 'error'
|
||||
? {
|
||||
background:
|
||||
'color-mix(in srgb, var(--theme-accent) 14%, transparent)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={
|
||||
updating
|
||||
? Loading03Icon
|
||||
: phase === 'done'
|
||||
? Tick01Icon
|
||||
: ArrowUp02Icon
|
||||
}
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
className={updating ? 'animate-spin' : undefined}
|
||||
style={{
|
||||
color:
|
||||
blocked || phase === 'error'
|
||||
? '#f59e0b'
|
||||
: 'var(--theme-accent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: 'var(--theme-text)' }}
|
||||
>
|
||||
{blocked
|
||||
? `${product.label} update blocked`
|
||||
: `${product.label} update available`}
|
||||
</p>
|
||||
<p
|
||||
className="truncate text-xs"
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{product.canUpdate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpdate}
|
||||
disabled={updating}
|
||||
className="rounded-lg px-4 py-1.5 text-xs font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
style={{ background: 'var(--theme-accent)' }}
|
||||
>
|
||||
{updating ? 'Updating' : 'Update'}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-semibold"
|
||||
style={{
|
||||
background: 'var(--theme-card2)',
|
||||
color: 'var(--theme-muted)',
|
||||
}}
|
||||
>
|
||||
Review required
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="rounded-lg p-1.5 transition-opacity hover:opacity-80"
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
aria-label={`Dismiss ${product.label} update`}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseNotes({
|
||||
notes,
|
||||
onClose,
|
||||
}: {
|
||||
notes: Notes | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
if (!notes) return null
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[10000] flex items-start justify-center bg-black/45 px-4 pt-[calc(var(--titlebar-h,0px)+1.5rem)] backdrop-blur-sm sm:items-center sm:pt-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<motion.div
|
||||
className="w-full max-w-lg overflow-hidden rounded-2xl shadow-2xl"
|
||||
initial={{ opacity: 0, y: 24, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 24, scale: 0.96 }}
|
||||
style={{
|
||||
background: 'var(--theme-card)',
|
||||
border: '1px solid var(--theme-border)',
|
||||
color: 'var(--theme-text)',
|
||||
boxShadow: 'var(--theme-shadow-3)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3 px-5 py-4">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-xl"
|
||||
style={{
|
||||
background:
|
||||
'color-mix(in srgb, var(--theme-accent) 14%, transparent)',
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Tick01Icon}
|
||||
size={20}
|
||||
strokeWidth={2}
|
||||
style={{ color: 'var(--theme-accent)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-base font-semibold">Hermes updated</p>
|
||||
<p className="text-sm" style={{ color: 'var(--theme-muted)' }}>
|
||||
What changed in this update.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 transition-opacity hover:opacity-80"
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[60vh] space-y-4 overflow-auto px-5 pb-5">
|
||||
{notes.sections.map((section) => (
|
||||
<section key={`${section.product}:${section.to}`}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">{section.label}</h3>
|
||||
<span
|
||||
className="shrink-0 rounded-full px-2 py-0.5 text-[11px]"
|
||||
style={{
|
||||
background: 'var(--theme-card2)',
|
||||
color: 'var(--theme-muted)',
|
||||
}}
|
||||
>
|
||||
{shortSha(section.from)} → {shortSha(section.to)}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{(section.commits.length
|
||||
? section.commits
|
||||
: ['Updated to the latest available version.']
|
||||
).map((commit, index) => (
|
||||
<li
|
||||
key={`${section.product}-${index}-${commit}`}
|
||||
className="rounded-xl px-3 py-2 text-sm"
|
||||
style={{ background: 'var(--theme-card2)' }}
|
||||
>
|
||||
{commit}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="flex justify-end border-t px-5 py-3"
|
||||
style={{ borderColor: 'var(--theme-border)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-4 py-2 text-sm font-semibold text-white"
|
||||
style={{ background: 'var(--theme-accent)' }}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
56
src/lib/i18n.test.ts
Normal file
56
src/lib/i18n.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOCALE_LABELS, t, type LocaleId } from './i18n'
|
||||
|
||||
function withLocale<T>(locale: LocaleId, fn: () => T): T {
|
||||
const originalWindow = globalThis.window
|
||||
const originalNavigator = globalThis.navigator
|
||||
const store = new Map<string, string>([['hermes-workspace-locale', locale]])
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {},
|
||||
})
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
},
|
||||
})
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: { language: 'en-US' },
|
||||
})
|
||||
try {
|
||||
return fn()
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: originalWindow,
|
||||
})
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: originalNavigator,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('i18n translations', () => {
|
||||
it('uses Simplified Chinese labels for wired navigation keys', () => {
|
||||
withLocale('zh', () => {
|
||||
expect(t('nav.dashboard')).toBe('仪表板')
|
||||
expect(t('nav.profiles')).toBe('配置文件')
|
||||
})
|
||||
})
|
||||
|
||||
it('uses Russian labels instead of falling back to English', () => {
|
||||
withLocale('ru', () => {
|
||||
expect(t('nav.dashboard')).toBe('Панель')
|
||||
expect(t('settings.language')).toBe('Язык')
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes readable locale labels for contributor-targeted languages', () => {
|
||||
expect(LOCALE_LABELS.zh).toBe('中文')
|
||||
expect(LOCALE_LABELS.ru).toBe('Русский')
|
||||
})
|
||||
})
|
||||
@@ -71,7 +71,6 @@ import { Route as ApiMemoryRouteImport } from './routes/api/memory'
|
||||
import { Route as ApiLocalProvidersRouteImport } from './routes/api/local-providers'
|
||||
import { Route as ApiIntegrationsRouteImport } from './routes/api/integrations'
|
||||
import { Route as ApiHistoryRouteImport } from './routes/api/history'
|
||||
import { Route as ApiHermesAgentUpdateRouteImport } from './routes/api/hermes-agent-update'
|
||||
import { Route as ApiGatewayStatusRouteImport } from './routes/api/gateway-status'
|
||||
import { Route as ApiFilesRouteImport } from './routes/api/files'
|
||||
import { Route as ApiEventsRouteImport } from './routes/api/events'
|
||||
@@ -90,6 +89,9 @@ import { Route as ApiChatEventsRouteImport } from './routes/api/chat-events'
|
||||
import { Route as ApiAuthCheckRouteImport } from './routes/api/auth-check'
|
||||
import { Route as ApiAuthRouteImport } from './routes/api/auth'
|
||||
import { Route as ApiArtifactsRouteImport } from './routes/api/artifacts'
|
||||
import { Route as ApiUpdateWorkspaceRouteImport } from './routes/api/update/workspace'
|
||||
import { Route as ApiUpdateStatusRouteImport } from './routes/api/update/status'
|
||||
import { Route as ApiUpdateAgentRouteImport } from './routes/api/update/agent'
|
||||
import { Route as ApiSwarmMemorySearchRouteImport } from './routes/api/swarm-memory/search'
|
||||
import { Route as ApiSkillsUninstallRouteImport } from './routes/api/skills/uninstall'
|
||||
import { Route as ApiSkillsToggleRouteImport } from './routes/api/skills/toggle'
|
||||
@@ -436,11 +438,6 @@ const ApiHistoryRoute = ApiHistoryRouteImport.update({
|
||||
path: '/api/history',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiHermesAgentUpdateRoute = ApiHermesAgentUpdateRouteImport.update({
|
||||
id: '/api/hermes-agent-update',
|
||||
path: '/api/hermes-agent-update',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiGatewayStatusRoute = ApiGatewayStatusRouteImport.update({
|
||||
id: '/api/gateway-status',
|
||||
path: '/api/gateway-status',
|
||||
@@ -531,6 +528,21 @@ const ApiArtifactsRoute = ApiArtifactsRouteImport.update({
|
||||
path: '/api/artifacts',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiUpdateWorkspaceRoute = ApiUpdateWorkspaceRouteImport.update({
|
||||
id: '/api/update/workspace',
|
||||
path: '/api/update/workspace',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiUpdateStatusRoute = ApiUpdateStatusRouteImport.update({
|
||||
id: '/api/update/status',
|
||||
path: '/api/update/status',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiUpdateAgentRoute = ApiUpdateAgentRouteImport.update({
|
||||
id: '/api/update/agent',
|
||||
path: '/api/update/agent',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiSwarmMemorySearchRoute = ApiSwarmMemorySearchRouteImport.update({
|
||||
id: '/search',
|
||||
path: '/search',
|
||||
@@ -738,7 +750,6 @@ export interface FileRoutesByFullPath {
|
||||
'/api/events': typeof ApiEventsRoute
|
||||
'/api/files': typeof ApiFilesRoute
|
||||
'/api/gateway-status': typeof ApiGatewayStatusRoute
|
||||
'/api/hermes-agent-update': typeof ApiHermesAgentUpdateRoute
|
||||
'/api/history': typeof ApiHistoryRoute
|
||||
'/api/integrations': typeof ApiIntegrationsRoute
|
||||
'/api/local-providers': typeof ApiLocalProvidersRoute
|
||||
@@ -818,6 +829,9 @@ export interface FileRoutesByFullPath {
|
||||
'/api/skills/toggle': typeof ApiSkillsToggleRoute
|
||||
'/api/skills/uninstall': typeof ApiSkillsUninstallRoute
|
||||
'/api/swarm-memory/search': typeof ApiSwarmMemorySearchRoute
|
||||
'/api/update/agent': typeof ApiUpdateAgentRoute
|
||||
'/api/update/status': typeof ApiUpdateStatusRoute
|
||||
'/api/update/workspace': typeof ApiUpdateWorkspaceRoute
|
||||
'/api/sessions/$sessionKey/active-run': typeof ApiSessionsSessionKeyActiveRunRoute
|
||||
'/api/sessions/$sessionKey/status': typeof ApiSessionsSessionKeyStatusRoute
|
||||
}
|
||||
@@ -854,7 +868,6 @@ export interface FileRoutesByTo {
|
||||
'/api/events': typeof ApiEventsRoute
|
||||
'/api/files': typeof ApiFilesRoute
|
||||
'/api/gateway-status': typeof ApiGatewayStatusRoute
|
||||
'/api/hermes-agent-update': typeof ApiHermesAgentUpdateRoute
|
||||
'/api/history': typeof ApiHistoryRoute
|
||||
'/api/integrations': typeof ApiIntegrationsRoute
|
||||
'/api/local-providers': typeof ApiLocalProvidersRoute
|
||||
@@ -934,6 +947,9 @@ export interface FileRoutesByTo {
|
||||
'/api/skills/toggle': typeof ApiSkillsToggleRoute
|
||||
'/api/skills/uninstall': typeof ApiSkillsUninstallRoute
|
||||
'/api/swarm-memory/search': typeof ApiSwarmMemorySearchRoute
|
||||
'/api/update/agent': typeof ApiUpdateAgentRoute
|
||||
'/api/update/status': typeof ApiUpdateStatusRoute
|
||||
'/api/update/workspace': typeof ApiUpdateWorkspaceRoute
|
||||
'/api/sessions/$sessionKey/active-run': typeof ApiSessionsSessionKeyActiveRunRoute
|
||||
'/api/sessions/$sessionKey/status': typeof ApiSessionsSessionKeyStatusRoute
|
||||
}
|
||||
@@ -972,7 +988,6 @@ export interface FileRoutesById {
|
||||
'/api/events': typeof ApiEventsRoute
|
||||
'/api/files': typeof ApiFilesRoute
|
||||
'/api/gateway-status': typeof ApiGatewayStatusRoute
|
||||
'/api/hermes-agent-update': typeof ApiHermesAgentUpdateRoute
|
||||
'/api/history': typeof ApiHistoryRoute
|
||||
'/api/integrations': typeof ApiIntegrationsRoute
|
||||
'/api/local-providers': typeof ApiLocalProvidersRoute
|
||||
@@ -1052,6 +1067,9 @@ export interface FileRoutesById {
|
||||
'/api/skills/toggle': typeof ApiSkillsToggleRoute
|
||||
'/api/skills/uninstall': typeof ApiSkillsUninstallRoute
|
||||
'/api/swarm-memory/search': typeof ApiSwarmMemorySearchRoute
|
||||
'/api/update/agent': typeof ApiUpdateAgentRoute
|
||||
'/api/update/status': typeof ApiUpdateStatusRoute
|
||||
'/api/update/workspace': typeof ApiUpdateWorkspaceRoute
|
||||
'/api/sessions/$sessionKey/active-run': typeof ApiSessionsSessionKeyActiveRunRoute
|
||||
'/api/sessions/$sessionKey/status': typeof ApiSessionsSessionKeyStatusRoute
|
||||
}
|
||||
@@ -1091,7 +1109,6 @@ export interface FileRouteTypes {
|
||||
| '/api/events'
|
||||
| '/api/files'
|
||||
| '/api/gateway-status'
|
||||
| '/api/hermes-agent-update'
|
||||
| '/api/history'
|
||||
| '/api/integrations'
|
||||
| '/api/local-providers'
|
||||
@@ -1171,6 +1188,9 @@ export interface FileRouteTypes {
|
||||
| '/api/skills/toggle'
|
||||
| '/api/skills/uninstall'
|
||||
| '/api/swarm-memory/search'
|
||||
| '/api/update/agent'
|
||||
| '/api/update/status'
|
||||
| '/api/update/workspace'
|
||||
| '/api/sessions/$sessionKey/active-run'
|
||||
| '/api/sessions/$sessionKey/status'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -1207,7 +1227,6 @@ export interface FileRouteTypes {
|
||||
| '/api/events'
|
||||
| '/api/files'
|
||||
| '/api/gateway-status'
|
||||
| '/api/hermes-agent-update'
|
||||
| '/api/history'
|
||||
| '/api/integrations'
|
||||
| '/api/local-providers'
|
||||
@@ -1287,6 +1306,9 @@ export interface FileRouteTypes {
|
||||
| '/api/skills/toggle'
|
||||
| '/api/skills/uninstall'
|
||||
| '/api/swarm-memory/search'
|
||||
| '/api/update/agent'
|
||||
| '/api/update/status'
|
||||
| '/api/update/workspace'
|
||||
| '/api/sessions/$sessionKey/active-run'
|
||||
| '/api/sessions/$sessionKey/status'
|
||||
id:
|
||||
@@ -1324,7 +1346,6 @@ export interface FileRouteTypes {
|
||||
| '/api/events'
|
||||
| '/api/files'
|
||||
| '/api/gateway-status'
|
||||
| '/api/hermes-agent-update'
|
||||
| '/api/history'
|
||||
| '/api/integrations'
|
||||
| '/api/local-providers'
|
||||
@@ -1404,6 +1425,9 @@ export interface FileRouteTypes {
|
||||
| '/api/skills/toggle'
|
||||
| '/api/skills/uninstall'
|
||||
| '/api/swarm-memory/search'
|
||||
| '/api/update/agent'
|
||||
| '/api/update/status'
|
||||
| '/api/update/workspace'
|
||||
| '/api/sessions/$sessionKey/active-run'
|
||||
| '/api/sessions/$sessionKey/status'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -1442,7 +1466,6 @@ export interface RootRouteChildren {
|
||||
ApiEventsRoute: typeof ApiEventsRoute
|
||||
ApiFilesRoute: typeof ApiFilesRoute
|
||||
ApiGatewayStatusRoute: typeof ApiGatewayStatusRoute
|
||||
ApiHermesAgentUpdateRoute: typeof ApiHermesAgentUpdateRoute
|
||||
ApiHistoryRoute: typeof ApiHistoryRoute
|
||||
ApiIntegrationsRoute: typeof ApiIntegrationsRoute
|
||||
ApiLocalProvidersRoute: typeof ApiLocalProvidersRoute
|
||||
@@ -1506,6 +1529,9 @@ export interface RootRouteChildren {
|
||||
ApiProfilesReadRoute: typeof ApiProfilesReadRoute
|
||||
ApiProfilesRenameRoute: typeof ApiProfilesRenameRoute
|
||||
ApiProfilesUpdateRoute: typeof ApiProfilesUpdateRoute
|
||||
ApiUpdateAgentRoute: typeof ApiUpdateAgentRoute
|
||||
ApiUpdateStatusRoute: typeof ApiUpdateStatusRoute
|
||||
ApiUpdateWorkspaceRoute: typeof ApiUpdateWorkspaceRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -1944,13 +1970,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiHistoryRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/hermes-agent-update': {
|
||||
id: '/api/hermes-agent-update'
|
||||
path: '/api/hermes-agent-update'
|
||||
fullPath: '/api/hermes-agent-update'
|
||||
preLoaderRoute: typeof ApiHermesAgentUpdateRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/gateway-status': {
|
||||
id: '/api/gateway-status'
|
||||
path: '/api/gateway-status'
|
||||
@@ -2077,6 +2096,27 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiArtifactsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/update/workspace': {
|
||||
id: '/api/update/workspace'
|
||||
path: '/api/update/workspace'
|
||||
fullPath: '/api/update/workspace'
|
||||
preLoaderRoute: typeof ApiUpdateWorkspaceRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/update/status': {
|
||||
id: '/api/update/status'
|
||||
path: '/api/update/status'
|
||||
fullPath: '/api/update/status'
|
||||
preLoaderRoute: typeof ApiUpdateStatusRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/update/agent': {
|
||||
id: '/api/update/agent'
|
||||
path: '/api/update/agent'
|
||||
fullPath: '/api/update/agent'
|
||||
preLoaderRoute: typeof ApiUpdateAgentRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/swarm-memory/search': {
|
||||
id: '/api/swarm-memory/search'
|
||||
path: '/search'
|
||||
@@ -2468,7 +2508,6 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ApiEventsRoute: ApiEventsRoute,
|
||||
ApiFilesRoute: ApiFilesRoute,
|
||||
ApiGatewayStatusRoute: ApiGatewayStatusRoute,
|
||||
ApiHermesAgentUpdateRoute: ApiHermesAgentUpdateRoute,
|
||||
ApiHistoryRoute: ApiHistoryRoute,
|
||||
ApiIntegrationsRoute: ApiIntegrationsRoute,
|
||||
ApiLocalProvidersRoute: ApiLocalProvidersRoute,
|
||||
@@ -2532,6 +2571,9 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ApiProfilesReadRoute: ApiProfilesReadRoute,
|
||||
ApiProfilesRenameRoute: ApiProfilesRenameRoute,
|
||||
ApiProfilesUpdateRoute: ApiProfilesUpdateRoute,
|
||||
ApiUpdateAgentRoute: ApiUpdateAgentRoute,
|
||||
ApiUpdateStatusRoute: ApiUpdateStatusRoute,
|
||||
ApiUpdateWorkspaceRoute: ApiUpdateWorkspaceRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -15,7 +15,7 @@ import { MobilePromptTrigger } from '@/components/mobile-prompt/MobilePromptTrig
|
||||
import { Toaster } from '@/components/ui/toast'
|
||||
import { OnboardingTour } from '@/components/onboarding/onboarding-tour'
|
||||
import { KeyboardShortcutsModal } from '@/components/keyboard-shortcuts-modal'
|
||||
import { HermesUpdateNotifier } from '@/components/hermes-update-notifier'
|
||||
import { UpdateCenterNotifier } from '@/components/update-center-notifier'
|
||||
import { initializeSettingsAppearance } from '@/hooks/use-settings'
|
||||
import { useApplyChatWidth } from '@/hooks/use-chat-settings'
|
||||
import {
|
||||
@@ -28,7 +28,6 @@ import { LoginScreen } from '@/components/auth/login-screen'
|
||||
import { fetchClaudeAuthStatus, type AuthStatus } from '@/lib/claude-auth'
|
||||
import { getRootSurfaceState } from './-root-layout-state'
|
||||
|
||||
|
||||
const APP_CSP = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
@@ -209,7 +208,9 @@ export const Route = createRootRoute({
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
export function getRootLayoutMode(onboardingComplete: string | null): 'onboarding' | 'workspace' {
|
||||
export function getRootLayoutMode(
|
||||
onboardingComplete: string | null,
|
||||
): 'onboarding' | 'workspace' {
|
||||
return onboardingComplete === 'true' ? 'workspace' : 'onboarding'
|
||||
}
|
||||
|
||||
@@ -218,7 +219,11 @@ export function wrapInlineScript(source: string): string {
|
||||
}
|
||||
|
||||
type ServiceWorkerLike = {
|
||||
getRegistrations: () => Promise<ReadonlyArray<{ unregister: () => boolean | Promise<boolean> | void | Promise<void> }>>
|
||||
getRegistrations: () => Promise<
|
||||
ReadonlyArray<{
|
||||
unregister: () => boolean | Promise<boolean> | void | Promise<void>
|
||||
}>
|
||||
>
|
||||
}
|
||||
|
||||
type CachesLike = {
|
||||
@@ -244,7 +249,9 @@ export async function unregisterServiceWorkers({
|
||||
|
||||
await cachesApi
|
||||
?.keys()
|
||||
.then((names) => Promise.allSettled(names.map((name) => cachesApi.delete(name))))
|
||||
.then((names) =>
|
||||
Promise.allSettled(names.map((name) => cachesApi.delete(name))),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
@@ -276,12 +283,20 @@ function RootLayout() {
|
||||
|
||||
void fetch('/api/connection-status')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((status: { ok?: boolean; chatReady?: boolean; modelConfigured?: boolean } | null) => {
|
||||
if (status?.ok || (status?.chatReady && status?.modelConfigured)) {
|
||||
localStorage.setItem(ONBOARDING_KEY, 'true')
|
||||
syncOnboardingCompletion()
|
||||
}
|
||||
})
|
||||
.then(
|
||||
(
|
||||
status: {
|
||||
ok?: boolean
|
||||
chatReady?: boolean
|
||||
modelConfigured?: boolean
|
||||
} | null,
|
||||
) => {
|
||||
if (status?.ok || (status?.chatReady && status?.modelConfigured)) {
|
||||
localStorage.setItem(ONBOARDING_KEY, 'true')
|
||||
syncOnboardingCompletion()
|
||||
}
|
||||
},
|
||||
)
|
||||
.catch(() => undefined)
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
@@ -300,7 +315,8 @@ function RootLayout() {
|
||||
)
|
||||
|
||||
void unregisterServiceWorkers({
|
||||
serviceWorker: 'serviceWorker' in navigator ? navigator.serviceWorker : undefined,
|
||||
serviceWorker:
|
||||
'serviceWorker' in navigator ? navigator.serviceWorker : undefined,
|
||||
cachesApi: 'caches' in window ? caches : undefined,
|
||||
})
|
||||
|
||||
@@ -352,7 +368,7 @@ function RootLayout() {
|
||||
</WorkspaceShell>
|
||||
<SearchModal />
|
||||
<KeyboardShortcutsModal />
|
||||
<HermesUpdateNotifier />
|
||||
<UpdateCenterNotifier />
|
||||
{rootSurfaceState.showPostOnboardingOverlays ? (
|
||||
<>
|
||||
<MobilePromptTrigger />
|
||||
@@ -384,10 +400,14 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
`),
|
||||
}}
|
||||
/>
|
||||
<script dangerouslySetInnerHTML={{ __html: wrapInlineScript(themeScript) }} />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: wrapInlineScript(themeScript) }}
|
||||
/>
|
||||
<HeadContent />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: wrapInlineScript(themeColorScript) }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: wrapInlineScript(themeColorScript),
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createRemoteStatus,
|
||||
normalizeUpdateTarget,
|
||||
remoteUrlMatchesExpectedRepo,
|
||||
} from './claude-update'
|
||||
import { createRemoteStatus, remoteUrlMatchesExpectedRepo } from './claude-update'
|
||||
|
||||
describe('claude update repo gating', () => {
|
||||
it('matches Claude workspace repo aliases', () => {
|
||||
expect(
|
||||
remoteUrlMatchesExpectedRepo(
|
||||
'https://github.com/example/hermes-workspace.git',
|
||||
['hermes-workspace'],
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
remoteUrlMatchesExpectedRepo(
|
||||
'git@github.com:outsourc-e/hermes-workspace.git',
|
||||
['outsourc-e/hermes-workspace'],
|
||||
),
|
||||
).toBe(true)
|
||||
expect(remoteUrlMatchesExpectedRepo('https://github.com/example/hermes-workspace.git', ['hermes-workspace'])).toBe(true)
|
||||
expect(remoteUrlMatchesExpectedRepo('git@github.com:outsourc-e/hermes-workspace.git', ['outsourc-e/hermes-workspace'])).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks update availability for wrong remote repos even when heads differ', () => {
|
||||
@@ -37,7 +23,7 @@ describe('claude update repo gating', () => {
|
||||
expect(status.error).toContain('expected hermes-workspace')
|
||||
})
|
||||
|
||||
it('marks upstream changes as manual sync only', () => {
|
||||
it('allows update availability only for the expected repo with a newer remote head', () => {
|
||||
const status = createRemoteStatus({
|
||||
name: 'upstream',
|
||||
label: 'Hermes Agent',
|
||||
@@ -49,15 +35,7 @@ describe('claude update repo gating', () => {
|
||||
})
|
||||
|
||||
expect(status.repoMatches).toBe(true)
|
||||
expect(status.autoUpdateSupported).toBe(false)
|
||||
expect(status.updateAvailable).toBe(false)
|
||||
expect(status.error).toContain('manual sync')
|
||||
})
|
||||
|
||||
it('normalizes update targets to safe remote names', () => {
|
||||
expect(normalizeUpdateTarget('origin')).toBe('origin')
|
||||
expect(normalizeUpdateTarget('upstream')).toBe('upstream')
|
||||
expect(normalizeUpdateTarget('all')).toBe('all')
|
||||
expect(normalizeUpdateTarget('$(rm -rf /)')).toBe('origin')
|
||||
expect(status.updateAvailable).toBe(true)
|
||||
expect(status.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,15 +4,8 @@ import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
} from '../../server/rate-limit'
|
||||
|
||||
type RemoteName = 'origin' | 'upstream'
|
||||
type UpdateTarget = RemoteName | 'all'
|
||||
|
||||
type RemoteStatus = {
|
||||
name: RemoteName
|
||||
@@ -24,7 +17,6 @@ type RemoteStatus = {
|
||||
remoteHead: string | null
|
||||
currentHead: string | null
|
||||
updateAvailable: boolean
|
||||
autoUpdateSupported: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
@@ -35,24 +27,6 @@ type RemoteDefinition = {
|
||||
aliases: Array<string>
|
||||
}
|
||||
|
||||
type ReleaseNoteSection = {
|
||||
name: RemoteName
|
||||
label: string
|
||||
from: string | null
|
||||
to: string | null
|
||||
commits: Array<string>
|
||||
}
|
||||
|
||||
type UpdateResult = {
|
||||
ok: boolean
|
||||
updated: Array<RemoteName>
|
||||
skipped: Array<{ name: RemoteName; reason: string }>
|
||||
restartRequired: boolean
|
||||
output: string
|
||||
releaseNotes: Array<ReleaseNoteSection>
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const UPDATE_REMOTE_DEFINITIONS: Array<RemoteDefinition> = [
|
||||
{
|
||||
name: 'origin',
|
||||
@@ -70,59 +44,29 @@ export const UPDATE_REMOTE_DEFINITIONS: Array<RemoteDefinition> = [
|
||||
|
||||
function git(args: string[], timeout = 5000): string | null {
|
||||
try {
|
||||
return (
|
||||
execFileSync('git', args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
timeout,
|
||||
}).trim() || null
|
||||
)
|
||||
return execFileSync('git', args, { cwd: process.cwd(), encoding: 'utf8', timeout }).trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function execOrThrow(
|
||||
command: string,
|
||||
args: string[],
|
||||
timeout = 30_000,
|
||||
): string {
|
||||
return execFileSync(command, args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function gitOrThrow(args: string[], timeout = 30_000): string {
|
||||
return execOrThrow('git', args, timeout)
|
||||
}
|
||||
|
||||
function pkgVersion(): string {
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(process.cwd(), 'package.json'), 'utf8'),
|
||||
) as { version?: string }
|
||||
const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as { version?: string }
|
||||
return pkg.version ?? 'unknown'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export function remoteUrlMatchesExpectedRepo(
|
||||
url: string | null,
|
||||
aliases: Array<string>,
|
||||
): boolean {
|
||||
export function remoteUrlMatchesExpectedRepo(url: string | null, aliases: Array<string>): boolean {
|
||||
if (!url) return false
|
||||
const normalizedUrl = url
|
||||
.toLowerCase()
|
||||
.replace(/^git@github\.com:/, 'github.com/')
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\.git$/, '')
|
||||
return aliases.some((alias) =>
|
||||
normalizedUrl.includes(alias.toLowerCase().replace(/\.git$/, '')),
|
||||
)
|
||||
return aliases.some((alias) => normalizedUrl.includes(alias.toLowerCase().replace(/\.git$/, '')))
|
||||
}
|
||||
|
||||
export function createRemoteStatus(input: {
|
||||
@@ -145,14 +89,6 @@ export function createRemoteStatus(input: {
|
||||
error = 'Unable to read remote HEAD.'
|
||||
}
|
||||
|
||||
const changed = Boolean(
|
||||
repoMatches &&
|
||||
input.currentHead &&
|
||||
input.remoteHead &&
|
||||
input.currentHead !== input.remoteHead,
|
||||
)
|
||||
const autoUpdateSupported = input.name === 'origin'
|
||||
|
||||
return {
|
||||
name: input.name,
|
||||
label: input.label,
|
||||
@@ -162,19 +98,12 @@ export function createRemoteStatus(input: {
|
||||
repoMatches,
|
||||
remoteHead: repoMatches ? input.remoteHead : null,
|
||||
currentHead: input.currentHead,
|
||||
updateAvailable: changed && autoUpdateSupported,
|
||||
autoUpdateSupported,
|
||||
error:
|
||||
!autoUpdateSupported && changed
|
||||
? 'Upstream Hermes Agent changes require a manual sync/rebase; one-click fast-forward is disabled for this remote.'
|
||||
: error,
|
||||
updateAvailable: Boolean(repoMatches && input.currentHead && input.remoteHead && input.currentHead !== input.remoteHead),
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
function remoteStatus(
|
||||
definition: RemoteDefinition,
|
||||
currentHead: string | null,
|
||||
): RemoteStatus {
|
||||
function remoteStatus(definition: RemoteDefinition, currentHead: string | null): RemoteStatus {
|
||||
const url = git(['remote', 'get-url', definition.name])
|
||||
const repoMatches = remoteUrlMatchesExpectedRepo(url, definition.aliases)
|
||||
let remoteHead: string | null = null
|
||||
@@ -198,166 +127,6 @@ function remoteStatus(
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeUpdateTarget(value: unknown): UpdateTarget {
|
||||
return value === 'origin' || value === 'upstream' || value === 'all'
|
||||
? value
|
||||
: 'origin'
|
||||
}
|
||||
|
||||
function selectedDefinitions(target: UpdateTarget): Array<RemoteDefinition> {
|
||||
if (target === 'all') return UPDATE_REMOTE_DEFINITIONS
|
||||
return UPDATE_REMOTE_DEFINITIONS.filter(
|
||||
(definition) => definition.name === target,
|
||||
)
|
||||
}
|
||||
|
||||
async function readJsonBody(
|
||||
request: Request,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return request.json().catch(() => ({})) as Promise<Record<string, unknown>>
|
||||
}
|
||||
|
||||
function readCommitMessages(
|
||||
from: string | null,
|
||||
to: string | null,
|
||||
): Array<string> {
|
||||
if (!from || !to || from === to) return []
|
||||
const raw = git(['log', '--pretty=format:%s (%h)', `${from}..${to}`], 10_000)
|
||||
return (
|
||||
raw
|
||||
?.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 12) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
function applyFastForwardUpdate(target: UpdateTarget): UpdateResult {
|
||||
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']) || 'main'
|
||||
const startHead = git(['rev-parse', 'HEAD'])
|
||||
const currentHead = startHead
|
||||
const dirty = Boolean(git(['status', '--porcelain']))
|
||||
|
||||
if (dirty) {
|
||||
return {
|
||||
ok: false,
|
||||
updated: [],
|
||||
skipped: [],
|
||||
restartRequired: false,
|
||||
output: '',
|
||||
releaseNotes: [],
|
||||
error:
|
||||
'Working tree has local changes. Commit, stash, or discard them before updating.',
|
||||
}
|
||||
}
|
||||
|
||||
const updated: Array<RemoteName> = []
|
||||
const skipped: UpdateResult['skipped'] = []
|
||||
const output: Array<string> = []
|
||||
const releaseNotes: Array<ReleaseNoteSection> = []
|
||||
|
||||
for (const definition of selectedDefinitions(target)) {
|
||||
const status = remoteStatus(definition, currentHead)
|
||||
if (!status.repoMatches) {
|
||||
skipped.push({
|
||||
name: definition.name,
|
||||
reason: status.error || 'Remote does not match expected repo.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!status.updateAvailable) {
|
||||
skipped.push({
|
||||
name: definition.name,
|
||||
reason: status.error || 'Already up to date.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const ref = definition.name === 'origin' ? branch : 'HEAD'
|
||||
const beforeMergeHead = git(['rev-parse', 'HEAD'])
|
||||
output.push(`Fetching ${definition.name}...`)
|
||||
output.push(gitOrThrow(['fetch', definition.name], 60_000))
|
||||
|
||||
if (definition.name !== 'origin') {
|
||||
skipped.push({
|
||||
name: definition.name,
|
||||
reason:
|
||||
'Manual upstream sync required. Hermes Agent upstream is not applied with one-click Workspace updates.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const remoteRef = `${definition.name}/${ref}`
|
||||
try {
|
||||
execFileSync('git', ['merge-base', '--is-ancestor', 'HEAD', remoteRef], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'ignore',
|
||||
})
|
||||
} catch {
|
||||
skipped.push({
|
||||
name: definition.name,
|
||||
reason: `${remoteRef} is not a fast-forward update. Manual rebase/merge required.`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
output.push(`Fast-forwarding from ${remoteRef}...`)
|
||||
output.push(gitOrThrow(['merge', '--ff-only', remoteRef], 60_000))
|
||||
const afterMergeHead = git(['rev-parse', 'HEAD'])
|
||||
updated.push(definition.name)
|
||||
releaseNotes.push({
|
||||
name: definition.name,
|
||||
label: definition.label,
|
||||
from: beforeMergeHead,
|
||||
to: afterMergeHead,
|
||||
commits: readCommitMessages(beforeMergeHead, afterMergeHead),
|
||||
})
|
||||
}
|
||||
|
||||
if (updated.length > 0) {
|
||||
const endHead = git(['rev-parse', 'HEAD'])
|
||||
const changedFiles =
|
||||
startHead && endHead
|
||||
? (git(['diff', '--name-only', startHead, endHead], 10_000)
|
||||
?.split('\n')
|
||||
.filter(Boolean) ?? [])
|
||||
: []
|
||||
const dependenciesChanged = changedFiles.some(
|
||||
(file) => file === 'package.json' || file === 'pnpm-lock.yaml',
|
||||
)
|
||||
const shouldVerifyBuild = changedFiles.some(
|
||||
(file) =>
|
||||
file.startsWith('src/') ||
|
||||
file.startsWith('scripts/') ||
|
||||
file === 'package.json' ||
|
||||
file === 'pnpm-lock.yaml' ||
|
||||
file.startsWith('vite') ||
|
||||
file.startsWith('tsconfig'),
|
||||
)
|
||||
|
||||
if (dependenciesChanged) {
|
||||
output.push('Installing updated dependencies...')
|
||||
output.push(
|
||||
execOrThrow('pnpm', ['install', '--no-frozen-lockfile'], 180_000),
|
||||
)
|
||||
}
|
||||
|
||||
if (shouldVerifyBuild) {
|
||||
output.push('Verifying updated Workspace build...')
|
||||
output.push(execOrThrow('pnpm', ['build'], 240_000))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
updated,
|
||||
skipped,
|
||||
restartRequired: updated.length > 0,
|
||||
output: output.filter(Boolean).join('\n'),
|
||||
releaseNotes,
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/claude-update')({
|
||||
server: {
|
||||
handlers: {
|
||||
@@ -369,9 +138,7 @@ export const Route = createFileRoute('/api/claude-update')({
|
||||
const currentHead = git(['rev-parse', 'HEAD'])
|
||||
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'])
|
||||
const dirty = Boolean(git(['status', '--porcelain']))
|
||||
const remotes = UPDATE_REMOTE_DEFINITIONS.map((definition) =>
|
||||
remoteStatus(definition, currentHead),
|
||||
)
|
||||
const remotes = UPDATE_REMOTE_DEFINITIONS.map((definition) => remoteStatus(definition, currentHead))
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
@@ -388,33 +155,6 @@ export const Route = createFileRoute('/api/claude-update')({
|
||||
manualConfirmRequired: true,
|
||||
})
|
||||
},
|
||||
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`claude-update-post:${ip}`, 5, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readJsonBody(request)
|
||||
const target = normalizeUpdateTarget(body.target)
|
||||
const result = applyFastForwardUpdate(target)
|
||||
return json(result, { status: result.ok ? 200 : 409 })
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, realpathSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { isAuthenticated } from '../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
} from '../../server/rate-limit'
|
||||
|
||||
type AgentReleaseNotes = {
|
||||
name: 'agent'
|
||||
label: 'Hermes Agent'
|
||||
from: string | null
|
||||
to: string | null
|
||||
commits: Array<string>
|
||||
}
|
||||
|
||||
function exec(
|
||||
command: string,
|
||||
args: Array<string>,
|
||||
options: { cwd?: string; timeout?: number } = {},
|
||||
): string | null {
|
||||
try {
|
||||
return (
|
||||
execFileSync(command, args, {
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
encoding: 'utf8',
|
||||
timeout: options.timeout ?? 8_000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim() || null
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function execOrThrow(
|
||||
command: string,
|
||||
args: Array<string>,
|
||||
options: { cwd?: string; timeout?: number } = {},
|
||||
): string {
|
||||
return execFileSync(command, args, {
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
encoding: 'utf8',
|
||||
timeout: options.timeout ?? 300_000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function agentRepoPath(): string | null {
|
||||
const candidates = [
|
||||
process.env.HERMES_AGENT_REPO,
|
||||
join(homedir(), '.hermes', 'hermes-agent'),
|
||||
join(homedir(), 'hermes-agent'),
|
||||
].filter(Boolean) as Array<string>
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const resolved = realpathSync(candidate)
|
||||
if (existsSync(join(resolved, '.git'))) return resolved
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function git(args: Array<string>, cwd: string): string | null {
|
||||
return exec('git', args, { cwd })
|
||||
}
|
||||
|
||||
function remoteUrlMatchesHermesAgent(url: string | null): boolean {
|
||||
if (!url) return false
|
||||
const normalized = url
|
||||
.toLowerCase()
|
||||
.replace(/^git@github\.com:/, 'github.com/')
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\.git$/, '')
|
||||
return normalized.includes('hermes-agent')
|
||||
}
|
||||
|
||||
function readCommitMessages(
|
||||
repoPath: string,
|
||||
from: string | null,
|
||||
to: string | null,
|
||||
): Array<string> {
|
||||
if (!from || !to || from === to) return []
|
||||
const raw = git(
|
||||
['log', '--pretty=format:%s (%h)', `${from}..${to}`],
|
||||
repoPath,
|
||||
)
|
||||
return (
|
||||
raw
|
||||
?.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 12) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
function readStatus() {
|
||||
const versionOutput = exec('hermes', ['--version'], { timeout: 10_000 })
|
||||
const repoPath = agentRepoPath()
|
||||
const hermesPath = exec('which', ['hermes'])
|
||||
const currentHead = repoPath ? git(['rev-parse', 'HEAD'], repoPath) : null
|
||||
const branch = repoPath
|
||||
? git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)
|
||||
: null
|
||||
const dirty = repoPath
|
||||
? Boolean(git(['status', '--porcelain'], repoPath))
|
||||
: false
|
||||
const remoteUrl = repoPath
|
||||
? git(['remote', 'get-url', 'origin'], repoPath)
|
||||
: null
|
||||
const repoMatches = remoteUrlMatchesHermesAgent(remoteUrl)
|
||||
const rawRemote =
|
||||
repoPath && repoMatches
|
||||
? exec('git', ['ls-remote', remoteUrl || 'origin', 'HEAD'], {
|
||||
cwd: repoPath,
|
||||
timeout: 10_000,
|
||||
})
|
||||
: null
|
||||
const remoteHead = rawRemote?.split(/\s+/)[0] ?? null
|
||||
const updateAvailable = Boolean(
|
||||
repoPath &&
|
||||
repoMatches &&
|
||||
currentHead &&
|
||||
remoteHead &&
|
||||
currentHead !== remoteHead,
|
||||
)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
checkedAt: Date.now(),
|
||||
app: {
|
||||
name: 'Hermes Agent',
|
||||
version: versionOutput?.split('\n')[0] ?? 'unknown',
|
||||
path: hermesPath,
|
||||
repoPath,
|
||||
branch,
|
||||
currentHead,
|
||||
dirty,
|
||||
},
|
||||
remote: {
|
||||
label: 'Hermes Agent',
|
||||
url: remoteUrl,
|
||||
repoMatches,
|
||||
currentHead,
|
||||
remoteHead,
|
||||
updateAvailable,
|
||||
canUpdate: Boolean(repoPath && repoMatches && !dirty),
|
||||
error: !repoPath
|
||||
? 'Hermes Agent git checkout was not found. Run `hermes update` manually from your terminal.'
|
||||
: !repoMatches
|
||||
? 'Hermes Agent origin remote does not look like a hermes-agent repository.'
|
||||
: dirty
|
||||
? 'Hermes Agent checkout has local changes. Commit or stash before updating.'
|
||||
: null,
|
||||
},
|
||||
updateAvailable,
|
||||
manualCommand: 'hermes update',
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/hermes-agent-update')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
return json(readStatus())
|
||||
},
|
||||
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
const ip = getClientIp(request)
|
||||
if (!rateLimit(`hermes-agent-update-post:${ip}`, 3, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
|
||||
const before = readStatus()
|
||||
if (!before.remote.canUpdate || !before.app.repoPath) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error:
|
||||
before.remote.error ||
|
||||
'Hermes Agent cannot be safely updated automatically.',
|
||||
},
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execOrThrow('hermes', ['update'], { timeout: 300_000 })
|
||||
const after = readStatus()
|
||||
const notes: AgentReleaseNotes | null = after.app.repoPath
|
||||
? {
|
||||
name: 'agent',
|
||||
label: 'Hermes Agent',
|
||||
from: before.app.currentHead,
|
||||
to: after.app.currentHead,
|
||||
commits: readCommitMessages(
|
||||
after.app.repoPath,
|
||||
before.app.currentHead,
|
||||
after.app.currentHead,
|
||||
),
|
||||
}
|
||||
: null
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
output,
|
||||
restartRequired: true,
|
||||
releaseNotes: notes ? [notes] : [],
|
||||
status: after,
|
||||
})
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
39
src/routes/api/update/agent.ts
Normal file
39
src/routes/api/update/agent.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
} from '../../../server/rate-limit'
|
||||
import { applyAgentUpdate } from '../../../server/update-system'
|
||||
|
||||
export const Route = createFileRoute('/api/update/agent')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
if (!rateLimit(`update-agent:${getClientIp(request)}`, 3, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
try {
|
||||
const result = applyAgentUpdate()
|
||||
return json(result, { status: result.ok ? 200 : 409 })
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
17
src/routes/api/update/status.ts
Normal file
17
src/routes/api/update/status.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import { readUpdateStatus } from '../../../server/update-system'
|
||||
|
||||
export const Route = createFileRoute('/api/update/status')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
return json(readUpdateStatus())
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
39
src/routes/api/update/workspace.ts
Normal file
39
src/routes/api/update/workspace.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||
import {
|
||||
getClientIp,
|
||||
rateLimit,
|
||||
rateLimitResponse,
|
||||
requireJsonContentType,
|
||||
} from '../../../server/rate-limit'
|
||||
import { applyWorkspaceUpdate } from '../../../server/update-system'
|
||||
|
||||
export const Route = createFileRoute('/api/update/workspace')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
if (!isAuthenticated(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
if (csrfCheck) return csrfCheck
|
||||
if (!rateLimit(`update-workspace:${getClientIp(request)}`, 3, 60_000)) {
|
||||
return rateLimitResponse()
|
||||
}
|
||||
try {
|
||||
const result = applyWorkspaceUpdate()
|
||||
return json(result, { status: result.ok ? 200 : 409 })
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildInlineToolRenderPlan, compactInlineToolRenderPlan } from './message-item'
|
||||
import {
|
||||
buildInlineToolRenderPlan,
|
||||
compactInlineToolRenderPlan,
|
||||
detectAssistantCorruptionWarning,
|
||||
} from './message-item'
|
||||
import type { ChatMessage } from '../types'
|
||||
|
||||
describe('buildInlineToolRenderPlan', () => {
|
||||
@@ -145,3 +149,29 @@ describe('compactInlineToolRenderPlan', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectAssistantCorruptionWarning', () => {
|
||||
it('flags assistant messages that begin with raw user role text', () => {
|
||||
const warning = detectAssistantCorruptionWarning(
|
||||
'assistant',
|
||||
'user\nNew reviews are fine...',
|
||||
)
|
||||
|
||||
expect(warning?.kind).toBe('role-prefix')
|
||||
expect(warning?.detail).toContain('Stored role is assistant')
|
||||
})
|
||||
|
||||
it('does not flag real user messages with the same body text', () => {
|
||||
expect(
|
||||
detectAssistantCorruptionWarning('user', 'user\nNew reviews are fine...'),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('flags very large repeated divider loops', () => {
|
||||
const text = `${'normal text\n'.repeat(2000)}${'----------\n'.repeat(25)}`
|
||||
|
||||
expect(detectAssistantCorruptionWarning('assistant', text)?.kind).toBe(
|
||||
'divider-loop',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -196,7 +196,9 @@ export function buildInlineToolRenderPlan(
|
||||
}
|
||||
}
|
||||
|
||||
const trailingSections = toolSections.filter((section) => !usedKeys.has(section.key))
|
||||
const trailingSections = toolSections.filter(
|
||||
(section) => !usedKeys.has(section.key),
|
||||
)
|
||||
for (const section of trailingSections) {
|
||||
plan.push({ kind: 'tool', section })
|
||||
}
|
||||
@@ -382,6 +384,43 @@ function normalizeStreamToolPhase(
|
||||
return 'running'
|
||||
}
|
||||
|
||||
export type AssistantCorruptionWarning = {
|
||||
kind: 'role-prefix' | 'divider-loop'
|
||||
label: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export function detectAssistantCorruptionWarning(
|
||||
role: string,
|
||||
text: string,
|
||||
): AssistantCorruptionWarning | null {
|
||||
if (role !== 'assistant') return null
|
||||
const trimmed = text.trimStart()
|
||||
const roleMatch = /^(user|assistant|system)\s*(?:\n|:)/i.exec(trimmed)
|
||||
if (roleMatch) {
|
||||
return {
|
||||
kind: 'role-prefix',
|
||||
label: 'Assistant output contains raw transcript role text',
|
||||
detail: `Stored role is assistant, but the content begins with "${roleMatch[1]}". Treat this as generated text, not a real ${roleMatch[1]} turn.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (text.length > 20_000) {
|
||||
const dividerMatches =
|
||||
text.match(/(?:^|\n)\s*(?:[-_=*]{8,}|[─━]{8,})\s*(?=\n|$)/g) ?? []
|
||||
if (dividerMatches.length >= 20) {
|
||||
return {
|
||||
kind: 'divider-loop',
|
||||
label: 'Assistant output looks corrupted',
|
||||
detail:
|
||||
'This very large assistant message contains repeated divider-like lines and may be a generation loop.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readExecNotification(message: ChatMessage): ExecNotification | null {
|
||||
const raw = (message as any).__execNotification as
|
||||
| Record<string, unknown>
|
||||
@@ -1615,10 +1654,22 @@ function ToolCallGroup({
|
||||
}, [expandAll])
|
||||
|
||||
if (toolSections.length > 1) {
|
||||
const runningCount = toolSections.filter((section) => section.state === 'input-available' || section.state === 'input-streaming').length
|
||||
const errorCount = toolSections.filter((section) => section.state === 'output-error').length
|
||||
const runningCount = toolSections.filter(
|
||||
(section) =>
|
||||
section.state === 'input-available' ||
|
||||
section.state === 'input-streaming',
|
||||
).length
|
||||
const errorCount = toolSections.filter(
|
||||
(section) => section.state === 'output-error',
|
||||
).length
|
||||
const doneCount = toolSections.length - runningCount - errorCount
|
||||
const labels = Array.from(new Set(toolSections.map((section) => formatToolDisplayLabel(section.type, section.input))))
|
||||
const labels = Array.from(
|
||||
new Set(
|
||||
toolSections.map((section) =>
|
||||
formatToolDisplayLabel(section.type, section.input),
|
||||
),
|
||||
),
|
||||
)
|
||||
const visibleLabels = labels.slice(0, 3).join(', ')
|
||||
const overflowLabel = labels.length > 3 ? ` +${labels.length - 3} more` : ''
|
||||
const statusLabel =
|
||||
@@ -1635,7 +1686,9 @@ function ToolCallGroup({
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[12px] text-primary-600 hover:bg-primary-50/70"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<span className="font-mono font-semibold text-ink">Tool activity</span>
|
||||
<span className="font-mono font-semibold text-ink">
|
||||
Tool activity
|
||||
</span>
|
||||
<span className="rounded-full bg-primary-100 px-2 py-0.5 text-[10px] tabular-nums text-primary-600">
|
||||
{toolSections.length} calls
|
||||
</span>
|
||||
@@ -1788,7 +1841,8 @@ function MessageItemComponent({
|
||||
|
||||
// Simulate streaming is only active while words are still being revealed
|
||||
const displayWordCount = countWords(displayText)
|
||||
const revealComplete = revealedWordCount >= displayWordCount && displayWordCount > 0
|
||||
const revealComplete =
|
||||
revealedWordCount >= displayWordCount && displayWordCount > 0
|
||||
const effectiveIsStreaming =
|
||||
remoteStreamingActive || (_simulateStreaming && !revealComplete)
|
||||
const assistantDisplayText = effectiveIsStreaming ? revealedText : displayText
|
||||
@@ -2422,7 +2476,10 @@ function MessageItemComponent({
|
||||
{compactInlineRenderPlan.map((item, index) =>
|
||||
item.kind === 'tools' ? (
|
||||
<ToolCallGroup
|
||||
key={item.sections.map((section) => section.key).join(':') || `tools-${index}`}
|
||||
key={
|
||||
item.sections.map((section) => section.key).join(':') ||
|
||||
`tools-${index}`
|
||||
}
|
||||
toolSections={item.sections}
|
||||
expandAll={expandAllToolSections}
|
||||
isStreaming={effectiveIsStreaming}
|
||||
@@ -2430,7 +2487,9 @@ function MessageItemComponent({
|
||||
) : item.text.trim().length > 0 ? (
|
||||
<div key={`text-${index}`} className="relative">
|
||||
{extractStandaloneMarkdownFence(item.text) ? (
|
||||
<MarkdownMessageCard content={extractStandaloneMarkdownFence(item.text)!} />
|
||||
<MarkdownMessageCard
|
||||
content={extractStandaloneMarkdownFence(item.text)!}
|
||||
/>
|
||||
) : (
|
||||
<MessageContent
|
||||
markdown
|
||||
@@ -2453,6 +2512,23 @@ function MessageItemComponent({
|
||||
<span className="text-pretty">{displayText}</span>
|
||||
) : hasRevealedText ? (
|
||||
<div className="relative">
|
||||
{assistantCorruptionWarning ? (
|
||||
<div
|
||||
className="mb-3 rounded-xl border px-3 py-2 text-xs"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.45)',
|
||||
background: 'rgba(245, 158, 11, 0.12)',
|
||||
color: 'var(--chat-assistant-foreground)',
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold">
|
||||
{assistantCorruptionWarning.label}
|
||||
</div>
|
||||
<div className="mt-1 opacity-80">
|
||||
{assistantCorruptionWarning.detail}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{standaloneMarkdownDocument ? (
|
||||
<MarkdownMessageCard content={standaloneMarkdownDocument} />
|
||||
) : (
|
||||
@@ -2475,7 +2551,10 @@ function MessageItemComponent({
|
||||
{isUser && isQueued && (
|
||||
<span
|
||||
className="self-end text-[10px]"
|
||||
style={{ color: 'color-mix(in srgb, var(--chat-user-foreground) 60%, transparent)' }}
|
||||
style={{
|
||||
color:
|
||||
'color-mix(in srgb, var(--chat-user-foreground) 60%, transparent)',
|
||||
}}
|
||||
>
|
||||
Sent
|
||||
</span>
|
||||
|
||||
22
src/server/update-system.test.ts
Normal file
22
src/server/update-system.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { remoteUrlMatches } from './update-system'
|
||||
|
||||
describe('update-system helpers', () => {
|
||||
it('matches GitHub URL forms against expected repo aliases', () => {
|
||||
expect(
|
||||
remoteUrlMatches('https://github.com/outsourc-e/hermes-workspace.git', [
|
||||
'outsourc-e/hermes-workspace',
|
||||
]),
|
||||
).toBe(true)
|
||||
expect(
|
||||
remoteUrlMatches('git@github.com:NousResearch/hermes-agent.git', [
|
||||
'hermes-agent',
|
||||
]),
|
||||
).toBe(true)
|
||||
expect(
|
||||
remoteUrlMatches('https://github.com/example/other.git', [
|
||||
'hermes-workspace',
|
||||
]),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
607
src/server/update-system.ts
Normal file
607
src/server/update-system.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
type ProductId = 'workspace' | 'agent'
|
||||
type InstallKind = 'git' | 'desktop' | 'docker' | 'unknown'
|
||||
type UpdateState = 'current' | 'available' | 'blocked' | 'unsupported' | 'error'
|
||||
|
||||
type ReleaseNoteSection = {
|
||||
product: ProductId
|
||||
label: string
|
||||
from: string | null
|
||||
to: string | null
|
||||
commits: Array<string>
|
||||
}
|
||||
|
||||
export type ProductUpdateStatus = {
|
||||
id: ProductId
|
||||
label: string
|
||||
installKind: InstallKind
|
||||
version: string
|
||||
path: string | null
|
||||
repoPath: string | null
|
||||
branch: string | null
|
||||
currentHead: string | null
|
||||
latestHead: string | null
|
||||
updateAvailable: boolean
|
||||
canUpdate: boolean
|
||||
state: UpdateState
|
||||
reason: string | null
|
||||
updateMode:
|
||||
| 'git-ff'
|
||||
| 'hermes-update'
|
||||
| 'desktop-auto-updater'
|
||||
| 'docker-manual'
|
||||
| 'manual'
|
||||
}
|
||||
|
||||
export type UpdateStatus = {
|
||||
ok: true
|
||||
checkedAt: number
|
||||
products: {
|
||||
workspace: ProductUpdateStatus
|
||||
agent: ProductUpdateStatus
|
||||
}
|
||||
updateAvailable: boolean
|
||||
pendingReleaseNotes: Array<ReleaseNoteSection>
|
||||
}
|
||||
|
||||
export type ApplyUpdateResult = {
|
||||
ok: boolean
|
||||
product: ProductId
|
||||
output: string
|
||||
restartRequired: boolean
|
||||
status: ProductUpdateStatus
|
||||
releaseNotes: Array<ReleaseNoteSection>
|
||||
error?: string
|
||||
}
|
||||
|
||||
function pendingNotesPath(): string {
|
||||
return join(process.cwd(), '.runtime', 'pending-update-release-notes.json')
|
||||
}
|
||||
|
||||
function persistPendingReleaseNotes(sections: Array<ReleaseNoteSection>): void {
|
||||
if (!sections.length) return
|
||||
const path = pendingNotesPath()
|
||||
mkdirSync(join(process.cwd(), '.runtime'), { recursive: true })
|
||||
writeFileSync(
|
||||
path,
|
||||
`${JSON.stringify({ sections, updatedAt: Date.now() }, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
function readPendingReleaseNotes(): Array<ReleaseNoteSection> {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(pendingNotesPath(), 'utf8')) as {
|
||||
sections?: Array<ReleaseNoteSection>
|
||||
}
|
||||
return Array.isArray(raw.sections) ? raw.sections : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function exec(
|
||||
command: string,
|
||||
args: Array<string>,
|
||||
options: { cwd?: string; timeout?: number; stdio?: 'pipe' | 'ignore' } = {},
|
||||
): string | null {
|
||||
try {
|
||||
if (options.stdio === 'ignore') {
|
||||
execFileSync(command, args, {
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
timeout: options.timeout ?? 8_000,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
return 'ok'
|
||||
}
|
||||
return (
|
||||
execFileSync(command, args, {
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
encoding: 'utf8',
|
||||
timeout: options.timeout ?? 8_000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim() || null
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function execOrThrow(
|
||||
command: string,
|
||||
args: Array<string>,
|
||||
options: { cwd?: string; timeout?: number } = {},
|
||||
): string {
|
||||
return execFileSync(command, args, {
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
encoding: 'utf8',
|
||||
timeout: options.timeout ?? 300_000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function git(args: Array<string>, cwd: string, timeout = 8_000): string | null {
|
||||
return exec('git', args, { cwd, timeout })
|
||||
}
|
||||
|
||||
function realGitRepoPath(path: string | null | undefined): string | null {
|
||||
if (!path) return null
|
||||
try {
|
||||
const resolved = realpathSync(path)
|
||||
return existsSync(join(resolved, '.git')) ? resolved : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function pkgVersion(repoPath: string): string {
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(repoPath, 'package.json'), 'utf8'),
|
||||
) as { version?: string }
|
||||
return pkg.version ?? 'unknown'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export function remoteUrlMatches(
|
||||
url: string | null,
|
||||
expected: Array<string>,
|
||||
): boolean {
|
||||
if (!url) return false
|
||||
const normalized = url
|
||||
.toLowerCase()
|
||||
.replace(/^git@github\.com:/, 'github.com/')
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\.git$/, '')
|
||||
return expected.some((alias) =>
|
||||
normalized.includes(alias.toLowerCase().replace(/\.git$/, '')),
|
||||
)
|
||||
}
|
||||
|
||||
function remoteHead(repoPath: string, remote = 'origin'): string | null {
|
||||
const url = git(['remote', 'get-url', remote], repoPath)
|
||||
if (!url) return null
|
||||
const raw = exec('git', ['ls-remote', url, 'HEAD'], {
|
||||
cwd: repoPath,
|
||||
timeout: 10_000,
|
||||
})
|
||||
return raw?.split(/\s+/)[0] ?? null
|
||||
}
|
||||
|
||||
function isDirty(repoPath: string): boolean {
|
||||
return Boolean(git(['status', '--porcelain'], repoPath))
|
||||
}
|
||||
|
||||
function canFastForward(repoPath: string, remoteRef: string): boolean {
|
||||
return (
|
||||
exec('git', ['merge-base', '--is-ancestor', 'HEAD', remoteRef], {
|
||||
cwd: repoPath,
|
||||
stdio: 'ignore',
|
||||
}) !== null
|
||||
)
|
||||
}
|
||||
|
||||
function readCommits(
|
||||
repoPath: string,
|
||||
from: string | null,
|
||||
to: string | null,
|
||||
): Array<string> {
|
||||
if (!from || !to || from === to) return []
|
||||
return (
|
||||
git(['log', '--pretty=format:%s (%h)', `${from}..${to}`], repoPath, 10_000)
|
||||
?.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 12) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
function workspaceInstallKind(): InstallKind {
|
||||
if (
|
||||
process.env.HERMES_WORKSPACE_DESKTOP === '1' ||
|
||||
process.env.ELECTRON_RUN_AS_NODE
|
||||
)
|
||||
return 'desktop'
|
||||
if (process.env.HERMES_WORKSPACE_DOCKER === '1' || existsSync('/.dockerenv'))
|
||||
return 'docker'
|
||||
return realGitRepoPath(process.cwd()) ? 'git' : 'unknown'
|
||||
}
|
||||
|
||||
export function readWorkspaceUpdateStatus(
|
||||
repoPath = process.cwd(),
|
||||
): ProductUpdateStatus {
|
||||
const installKind = workspaceInstallKind()
|
||||
const gitRepo = realGitRepoPath(repoPath)
|
||||
const version = gitRepo ? pkgVersion(gitRepo) : 'unknown'
|
||||
|
||||
if (installKind === 'desktop') {
|
||||
return {
|
||||
id: 'workspace',
|
||||
label: 'Hermes Workspace',
|
||||
installKind,
|
||||
version,
|
||||
path: repoPath,
|
||||
repoPath: gitRepo,
|
||||
branch: null,
|
||||
currentHead: null,
|
||||
latestHead: null,
|
||||
updateAvailable: false,
|
||||
canUpdate: false,
|
||||
state: 'unsupported',
|
||||
reason:
|
||||
'Desktop auto-updater manifest is not wired yet. This path is reserved for DMG/EXE packaging.',
|
||||
updateMode: 'desktop-auto-updater',
|
||||
}
|
||||
}
|
||||
|
||||
if (installKind === 'docker') {
|
||||
return {
|
||||
id: 'workspace',
|
||||
label: 'Hermes Workspace',
|
||||
installKind,
|
||||
version,
|
||||
path: repoPath,
|
||||
repoPath: gitRepo,
|
||||
branch: null,
|
||||
currentHead: null,
|
||||
latestHead: null,
|
||||
updateAvailable: false,
|
||||
canUpdate: false,
|
||||
state: 'unsupported',
|
||||
reason:
|
||||
'Docker installs should update by pulling a newer image/tag, not by mutating the running container.',
|
||||
updateMode: 'docker-manual',
|
||||
}
|
||||
}
|
||||
|
||||
if (!gitRepo) {
|
||||
return {
|
||||
id: 'workspace',
|
||||
label: 'Hermes Workspace',
|
||||
installKind: 'unknown',
|
||||
version,
|
||||
path: repoPath,
|
||||
repoPath: null,
|
||||
branch: null,
|
||||
currentHead: null,
|
||||
latestHead: null,
|
||||
updateAvailable: false,
|
||||
canUpdate: false,
|
||||
state: 'unsupported',
|
||||
reason: 'Workspace install type could not be detected.',
|
||||
updateMode: 'manual',
|
||||
}
|
||||
}
|
||||
|
||||
const remoteUrl = git(['remote', 'get-url', 'origin'], gitRepo)
|
||||
const repoMatches = remoteUrlMatches(remoteUrl, [
|
||||
'hermes-workspace',
|
||||
'outsourc-e/hermes-workspace',
|
||||
])
|
||||
if (repoMatches) git(['fetch', 'origin', '--quiet'], gitRepo, 30_000)
|
||||
const currentHead = git(['rev-parse', 'HEAD'], gitRepo)
|
||||
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], gitRepo)
|
||||
const supportedBranch = branch === 'main' || branch === 'master'
|
||||
const latestHead =
|
||||
repoMatches && supportedBranch ? remoteHead(gitRepo, 'origin') : null
|
||||
const dirty = isDirty(gitRepo)
|
||||
const updateAvailable = Boolean(
|
||||
supportedBranch && currentHead && latestHead && currentHead !== latestHead,
|
||||
)
|
||||
const remoteRef = `origin/${branch || 'main'}`
|
||||
const ff = updateAvailable ? canFastForward(gitRepo, remoteRef) : true
|
||||
const canUpdate = Boolean(
|
||||
repoMatches && supportedBranch && updateAvailable && !dirty && ff,
|
||||
)
|
||||
|
||||
return {
|
||||
id: 'workspace',
|
||||
label: 'Hermes Workspace',
|
||||
installKind: 'git',
|
||||
version,
|
||||
path: repoPath,
|
||||
repoPath: gitRepo,
|
||||
branch,
|
||||
currentHead,
|
||||
latestHead,
|
||||
updateAvailable,
|
||||
canUpdate,
|
||||
state: !repoMatches
|
||||
? 'unsupported'
|
||||
: !supportedBranch
|
||||
? 'unsupported'
|
||||
: dirty
|
||||
? 'blocked'
|
||||
: updateAvailable
|
||||
? ff
|
||||
? 'available'
|
||||
: 'blocked'
|
||||
: 'current',
|
||||
reason: !repoMatches
|
||||
? 'Workspace origin remote does not look like hermes-workspace.'
|
||||
: !supportedBranch
|
||||
? 'Workspace one-click updates are only enabled on main/master branches.'
|
||||
: dirty
|
||||
? 'Workspace checkout has local changes. Commit or stash before updating.'
|
||||
: updateAvailable && !ff
|
||||
? 'Workspace update is not a fast-forward. Manual rebase/merge required.'
|
||||
: null,
|
||||
updateMode: 'git-ff',
|
||||
}
|
||||
}
|
||||
|
||||
function agentRepoPath(): string | null {
|
||||
const candidates = [
|
||||
process.env.HERMES_AGENT_REPO,
|
||||
join(homedir(), '.hermes', 'hermes-agent'),
|
||||
join(homedir(), 'hermes-agent'),
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
const repo = realGitRepoPath(candidate)
|
||||
if (repo) return repo
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function readAgentUpdateStatus(): ProductUpdateStatus {
|
||||
const version =
|
||||
exec('hermes', ['--version'], { timeout: 10_000 })?.split('\n')[0] ??
|
||||
'unknown'
|
||||
const path = exec('which', ['hermes'])
|
||||
const repoPath = agentRepoPath()
|
||||
|
||||
if (!repoPath) {
|
||||
return {
|
||||
id: 'agent',
|
||||
label: 'Hermes Agent',
|
||||
installKind: 'unknown',
|
||||
version,
|
||||
path,
|
||||
repoPath: null,
|
||||
branch: null,
|
||||
currentHead: null,
|
||||
latestHead: null,
|
||||
updateAvailable: false,
|
||||
canUpdate: false,
|
||||
state: 'unsupported',
|
||||
reason:
|
||||
'Hermes Agent git checkout was not found. Bundled desktop installs will update through the app updater.',
|
||||
updateMode: 'manual',
|
||||
}
|
||||
}
|
||||
|
||||
const remoteUrl = git(['remote', 'get-url', 'origin'], repoPath)
|
||||
const repoMatches = remoteUrlMatches(remoteUrl, [
|
||||
'hermes-agent',
|
||||
'outsourc-e/hermes-agent',
|
||||
'NousResearch/hermes-agent',
|
||||
])
|
||||
if (repoMatches) git(['fetch', 'origin', '--quiet'], repoPath, 30_000)
|
||||
const currentHead = git(['rev-parse', 'HEAD'], repoPath)
|
||||
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)
|
||||
const latestHead = repoMatches ? remoteHead(repoPath, 'origin') : null
|
||||
const dirty = isDirty(repoPath)
|
||||
const updateAvailable = Boolean(
|
||||
currentHead && latestHead && currentHead !== latestHead,
|
||||
)
|
||||
const canUpdate = Boolean(repoMatches && updateAvailable && !dirty)
|
||||
|
||||
return {
|
||||
id: 'agent',
|
||||
label: 'Hermes Agent',
|
||||
installKind: 'git',
|
||||
version,
|
||||
path,
|
||||
repoPath,
|
||||
branch,
|
||||
currentHead,
|
||||
latestHead,
|
||||
updateAvailable,
|
||||
canUpdate,
|
||||
state: !repoMatches
|
||||
? 'unsupported'
|
||||
: dirty
|
||||
? 'blocked'
|
||||
: updateAvailable
|
||||
? 'available'
|
||||
: 'current',
|
||||
reason: !repoMatches
|
||||
? 'Hermes Agent origin remote does not look like hermes-agent.'
|
||||
: dirty
|
||||
? 'Hermes Agent checkout has local changes. Commit or stash before updating.'
|
||||
: null,
|
||||
updateMode: 'hermes-update',
|
||||
}
|
||||
}
|
||||
|
||||
export function readUpdateStatus(): UpdateStatus {
|
||||
const workspace = readWorkspaceUpdateStatus()
|
||||
const agent = readAgentUpdateStatus()
|
||||
return {
|
||||
ok: true,
|
||||
checkedAt: Date.now(),
|
||||
products: { workspace, agent },
|
||||
updateAvailable: workspace.updateAvailable || agent.updateAvailable,
|
||||
pendingReleaseNotes: readPendingReleaseNotes(),
|
||||
}
|
||||
}
|
||||
|
||||
export function applyWorkspaceUpdate(): ApplyUpdateResult {
|
||||
const before = readWorkspaceUpdateStatus()
|
||||
if (!before.canUpdate || !before.repoPath || !before.branch) {
|
||||
return {
|
||||
ok: false,
|
||||
product: 'workspace',
|
||||
output: '',
|
||||
restartRequired: false,
|
||||
status: before,
|
||||
releaseNotes: [],
|
||||
error: before.reason || 'Workspace update is not available.',
|
||||
}
|
||||
}
|
||||
const output: Array<string> = []
|
||||
output.push(
|
||||
execOrThrow('git', ['fetch', 'origin'], {
|
||||
cwd: before.repoPath,
|
||||
timeout: 60_000,
|
||||
}),
|
||||
)
|
||||
const remoteRef = `origin/${before.branch}`
|
||||
if (!canFastForward(before.repoPath, remoteRef)) {
|
||||
const status = readWorkspaceUpdateStatus()
|
||||
return {
|
||||
ok: false,
|
||||
product: 'workspace',
|
||||
output: output.filter(Boolean).join('\n'),
|
||||
restartRequired: false,
|
||||
status,
|
||||
releaseNotes: [],
|
||||
error: `${remoteRef} is not a fast-forward update.`,
|
||||
}
|
||||
}
|
||||
output.push(
|
||||
execOrThrow('git', ['merge', '--ff-only', remoteRef], {
|
||||
cwd: before.repoPath,
|
||||
timeout: 60_000,
|
||||
}),
|
||||
)
|
||||
const after = readWorkspaceUpdateStatus()
|
||||
const changedFiles =
|
||||
before.currentHead && after.currentHead
|
||||
? (git(
|
||||
['diff', '--name-only', before.currentHead, after.currentHead],
|
||||
before.repoPath,
|
||||
10_000,
|
||||
)
|
||||
?.split('\n')
|
||||
.filter(Boolean) ?? [])
|
||||
: []
|
||||
if (
|
||||
changedFiles.some(
|
||||
(file) => file === 'package.json' || file === 'pnpm-lock.yaml',
|
||||
)
|
||||
) {
|
||||
output.push(
|
||||
execOrThrow('pnpm', ['install', '--no-frozen-lockfile'], {
|
||||
cwd: before.repoPath,
|
||||
timeout: 180_000,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (
|
||||
changedFiles.some(
|
||||
(file) =>
|
||||
file.startsWith('src/') ||
|
||||
file === 'package.json' ||
|
||||
file === 'pnpm-lock.yaml' ||
|
||||
file.startsWith('vite') ||
|
||||
file.startsWith('tsconfig'),
|
||||
)
|
||||
) {
|
||||
output.push(
|
||||
execOrThrow('pnpm', ['build'], {
|
||||
cwd: before.repoPath,
|
||||
timeout: 240_000,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const releaseNotes = [
|
||||
{
|
||||
product: 'workspace' as const,
|
||||
label: 'Hermes Workspace',
|
||||
from: before.currentHead,
|
||||
to: after.currentHead,
|
||||
commits: readCommits(
|
||||
before.repoPath,
|
||||
before.currentHead,
|
||||
after.currentHead,
|
||||
),
|
||||
},
|
||||
]
|
||||
persistPendingReleaseNotes(releaseNotes)
|
||||
return {
|
||||
ok: true,
|
||||
product: 'workspace',
|
||||
output: output.filter(Boolean).join('\n'),
|
||||
restartRequired: before.currentHead !== after.currentHead,
|
||||
status: after,
|
||||
releaseNotes,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAgentUpdate(): ApplyUpdateResult {
|
||||
const before = readAgentUpdateStatus()
|
||||
if (!before.canUpdate || !before.repoPath) {
|
||||
return {
|
||||
ok: false,
|
||||
product: 'agent',
|
||||
output: '',
|
||||
restartRequired: false,
|
||||
status: before,
|
||||
releaseNotes: [],
|
||||
error: before.reason || 'Hermes Agent update is not available.',
|
||||
}
|
||||
}
|
||||
|
||||
const output: Array<string> = []
|
||||
output.push(
|
||||
execOrThrow('git', ['fetch', 'origin'], {
|
||||
cwd: before.repoPath,
|
||||
timeout: 60_000,
|
||||
}),
|
||||
)
|
||||
const remoteRef = `origin/${before.branch || 'main'}`
|
||||
if (!canFastForward(before.repoPath, remoteRef)) {
|
||||
const status = readAgentUpdateStatus()
|
||||
return {
|
||||
ok: false,
|
||||
product: 'agent',
|
||||
output: output.filter(Boolean).join('\n'),
|
||||
restartRequired: false,
|
||||
status,
|
||||
releaseNotes: [],
|
||||
error: `${remoteRef} is not a fast-forward update.`,
|
||||
}
|
||||
}
|
||||
output.push(
|
||||
execOrThrow('git', ['merge', '--ff-only', remoteRef], {
|
||||
cwd: before.repoPath,
|
||||
timeout: 60_000,
|
||||
}),
|
||||
)
|
||||
|
||||
const after = readAgentUpdateStatus()
|
||||
const releaseNotes = [
|
||||
{
|
||||
product: 'agent' as const,
|
||||
label: 'Hermes Agent',
|
||||
from: before.currentHead,
|
||||
to: after.currentHead,
|
||||
commits: readCommits(
|
||||
before.repoPath,
|
||||
before.currentHead,
|
||||
after.currentHead,
|
||||
),
|
||||
},
|
||||
]
|
||||
persistPendingReleaseNotes(releaseNotes)
|
||||
return {
|
||||
ok: true,
|
||||
product: 'agent',
|
||||
output: output.filter(Boolean).join('\n'),
|
||||
restartRequired: before.currentHead !== after.currentHead,
|
||||
status: after,
|
||||
releaseNotes,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user