Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/bootstrap-installer/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useStore } from '@nanostores/react'
import { useEffect } from 'react'
import { $route, $bootstrap, initialize } from './store'
import Welcome from './routes/welcome'

import Failure from './routes/failure'
import Progress from './routes/progress'
import Success from './routes/success'
import Failure from './routes/failure'
import Welcome from './routes/welcome'
import { $bootstrap, $route, initialize } from './store'

/*
* App shell — Hermes Setup.
Expand Down
4 changes: 3 additions & 1 deletion apps/bootstrap-installer/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import './styles.css'

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'

import App from './app.tsx'
import './styles.css'
import { watchTheme } from './theme'

// Follow the OS light/dark appearance. theme.ts paints the first frame on
Expand Down
13 changes: 7 additions & 6 deletions apps/bootstrap-installer/src/routes/failure.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { type CSSProperties } from 'react'
import { useStore } from '@nanostores/react'
import { FileText, RefreshCw } from 'lucide-react'
import { type CSSProperties } from 'react'

import { Button } from '../components/button'
import {
$logPath,
$mode,
type BootstrapStateModel,
openLogDir,
startInstall,
startUpdate,
type BootstrapStateModel
startUpdate
} from '../store'
import { RefreshCw, FileText } from 'lucide-react'

interface FailureProps {
bootstrap: BootstrapStateModel
Expand Down Expand Up @@ -55,11 +56,11 @@ export default function Failure({ bootstrap }: FailureProps) {
</div>

<div className="flex items-center gap-3">
<Button onClick={() => void (isUpdate ? startUpdate() : startInstall())} className="gap-1.5">
<Button className="gap-1.5" onClick={() => void (isUpdate ? startUpdate() : startInstall())}>
<RefreshCw />
{isUpdate ? 'Retry update' : 'Retry install'}
</Button>
<Button variant="text" onClick={() => void openLogDir()} className="gap-1.5">
<Button className="gap-1.5" onClick={() => void openLogDir()} variant="text">
<FileText />
Open logs
</Button>
Expand Down
53 changes: 34 additions & 19 deletions apps/bootstrap-installer/src/routes/progress.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { useEffect, useRef, useState } from 'react'
import { useStore } from '@nanostores/react'
import clsx from 'clsx'
import { Check, ChevronRight, FileText, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'

import { BrandMark } from '../components/brand-mark'
import { Button } from '../components/button'
import { Loader } from '../components/loader'
import {
cancelInstall,
$mode,
$progress,
type BootstrapStateModel,
cancelInstall,
type StageState
} from '../store'
import { Check, X, ChevronRight, FileText } from 'lucide-react'
import clsx from 'clsx'
import { BrandMark } from '../components/brand-mark'
import { Loader } from '../components/loader'

interface ProgressProps {
bootstrap: BootstrapStateModel
Expand Down Expand Up @@ -42,15 +43,19 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
if (bootstrap.status !== 'running') {
return
}

const id = window.setInterval(() => setNow(Date.now()), 1000)

return () => window.clearInterval(id)
}, [bootstrap.status])

const isUpdate = mode === 'update'
const title = bootstrap.status === 'completed' ? 'Done' : isUpdate ? 'Updating Hermes' : 'Setting up Hermes Agent'

const description = isUpdate
? 'Hermes is updating to the latest version — this only takes a moment.'
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. Subsequent launches will skip this step.'

const pct = Math.round(progress.fraction * 100)

return (
Expand Down Expand Up @@ -90,22 +95,25 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
<ol className="space-y-0.5">
{bootstrap.stageOrder.map((name) => {
const rec = bootstrap.stages[name]
if (!rec) return null

if (!rec) {return null}

const meta =
rec.state === 'running' && rec.startedAt != null
? formatElapsed(now - rec.startedAt)
: rec.durationMs != null && rec.state !== 'failed'
? formatDuration(rec.durationMs)
: null

return (
<li
key={name}
className={clsx(
'flex items-center gap-2.5 px-3 py-1.5 text-sm',
rec.state === 'running'
? 'font-medium text-foreground'
: 'text-muted-foreground'
)}
key={name}
>
{rec.state === 'running' && <Loader className="-ml-2 size-6 shrink-0" />}
<span className="flex-1 truncate">{rec.info.title}</span>
Expand All @@ -126,11 +134,11 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[10.5px] leading-relaxed">
{bootstrap.logs.map((entry, idx) => (
<div
key={idx}
className={clsx(
'whitespace-pre-wrap',
entry.stream === 'stderr' ? 'text-foreground/45' : 'text-foreground/70'
)}
key={idx}
>
{entry.line}
</div>
Expand All @@ -143,17 +151,17 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {

<div className="flex shrink-0 items-center justify-between border-t border-(--stroke-nous) px-6 py-3">
<button
type="button"
onClick={() => setShowLogs((v) => !v)}
className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setShowLogs((v) => !v)}
type="button"
>
<FileText size={14} />
{showLogs ? 'Hide details' : 'Show details'}
<ChevronRight size={12} className={clsx('transition-transform', showLogs && 'rotate-90')} />
<ChevronRight className={clsx('transition-transform', showLogs && 'rotate-90')} size={12} />
</button>

{bootstrap.status === 'running' && (
<Button variant="outline" size="sm" onClick={() => void cancelInstall()}>
<Button onClick={() => void cancelInstall()} size="sm" variant="outline">
Cancel
</Button>
)}
Expand All @@ -167,29 +175,36 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
// spinner on the left; pending stays icon-less.
function StateIcon({ state }: { state: StageState | null }) {
if (state === 'succeeded') {
return <Check size={13} className="shrink-0 text-muted-foreground" />
return <Check className="shrink-0 text-muted-foreground" size={13} />
}

if (state === 'skipped') {
return <Check size={13} className="shrink-0 text-muted-foreground/50" />
return <Check className="shrink-0 text-muted-foreground/50" size={13} />
}

if (state === 'failed') {
return <X size={13} className="shrink-0 text-destructive" />
return <X className="shrink-0 text-destructive" size={13} />
}

return null
}

function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
if (ms < 1000) {return `${ms}ms`}

if (ms < 60000) {return `${(ms / 1000).toFixed(1)}s`}
const m = Math.floor(ms / 60000)
const s = Math.round((ms % 60000) / 1000)

return `${m}m ${s}s`
}

// Live elapsed for a running stage: bare seconds under a minute, then m:ss.
function formatElapsed(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000))
if (s < 60) return `${s}s`

if (s < 60) {return `${s}s`}
const m = Math.floor(s / 60)

return `${m}:${String(s - m * 60).padStart(2, '0')}`
}
8 changes: 5 additions & 3 deletions apps/bootstrap-installer/src/routes/success.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { AlertCircle } from 'lucide-react'
import { useState } from 'react'
import { type CSSProperties } from 'react'

import { HackeryButton } from '../components/hackery-button'
import { launchHermesDesktop } from '../store'
import { AlertCircle } from 'lucide-react'

/*
* Success screen. HERMES AGENT wordmark stays as the visual anchor
Expand All @@ -22,6 +23,7 @@ export default function Success() {
async function handleLaunch() {
setError(null)
setLaunching(true)

try {
await launchHermesDesktop()
// On success the installer exits — control never returns here.
Expand Down Expand Up @@ -65,8 +67,8 @@ export default function Success() {
/>

{error && (
<div role="alert" className="flex max-w-2xl items-start gap-2 text-sm">
<AlertCircle size={16} className="mt-0.5 shrink-0 text-destructive" />
<div className="flex max-w-2xl items-start gap-2 text-sm" role="alert">
<AlertCircle className="mt-0.5 shrink-0 text-destructive" size={16} />
<div className="min-w-0">
<div className="font-medium text-destructive">Couldn&rsquo;t launch the desktop app</div>
<div className="mt-0.5 text-muted-foreground">{error}</div>
Expand Down
1 change: 1 addition & 0 deletions apps/bootstrap-installer/src/routes/welcome.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type CSSProperties } from 'react'

import { HackeryButton } from '../components/hackery-button'
import { startInstall } from '../store'

Expand Down
Loading
Loading