feat(desktop): lead onboarding with Nous Portal + fix fresh-install detection (#34970)

- Feature Nous Portal as the primary onboarding card (Recommended tag,
  app logo, single pitch line); collapse other OAuth providers behind an
  "Other providers" disclosure whose open/closed state persists.
- Surface OpenRouter as a one-click API-key option inside the disclosure;
  move "I have an API key" to a quiet bottom-right link.
- Treat "no provider configured" as a normal onboarding state, not a red
  error banner (provider-setup-errors copy match).
- Fix setup.runtime_check: it reported ready when the resolved runtime had
  an empty credential or only implicit Bedrock/IAM, so fresh installs never
  saw onboarding. Now requires a usable credential.
- Auto-wire Windows fonts for WSL2 users so the renderer renders real
  Segoe UI instead of the DejaVu fallback; make WSL detection env-independent
  via the /proc kernel marker.
This commit is contained in:
brooklyn! 2026-05-29 17:00:45 -05:00 committed by GitHub
parent 696037587f
commit de8fed32fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 361 additions and 42 deletions

View file

@ -1,6 +1,15 @@
function isWslEnvironment(env = process.env, platform = process.platform) {
const fs = require('node:fs')
function isWslEnvironment(env = process.env, platform = process.platform, kernelRelease = null) {
if (platform !== 'linux') return false
return Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP)
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true
try {
const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8')
return /microsoft|wsl/i.test(release)
} catch {
return false
}
}
function isWindowsBinaryPathInWsl(filePath, options = {}) {

View file

@ -8,7 +8,8 @@ const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment }
test('isWslEnvironment detects WSL2 env vars on linux', () => {
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
assert.equal(isWslEnvironment({ WSL_INTEROP: '/run/WSL/123_interop' }, 'linux'), true)
assert.equal(isWslEnvironment({}, 'linux'), false)
assert.equal(isWslEnvironment({}, 'linux', '6.6.87.2-microsoft-standard-WSL2'), true)
assert.equal(isWslEnvironment({}, 'linux', '6.6.87-generic'), false)
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'darwin'), false)
})

View file

@ -490,6 +490,44 @@ function openExternalUrl(rawUrl) {
return true
}
function ensureWslWindowsFonts() {
if (!IS_WSL) return
const fontsDir = ['/mnt/c/Windows/Fonts', '/mnt/c/windows/fonts'].find(candidate => {
try {
return fs.statSync(candidate).isDirectory()
} catch {
return false
}
})
if (!fontsDir) return
try {
const confDir = path.join(app.getPath('home'), '.config', 'fontconfig', 'conf.d')
const confPath = path.join(confDir, '99-hermes-wsl-windows-fonts.conf')
let existing = ''
try {
existing = fs.readFileSync(confPath, 'utf8')
} catch {
existing = ''
}
if (existing.includes(fontsDir)) return
fs.mkdirSync(confDir, { recursive: true })
fs.writeFileSync(
confPath,
`<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>${fontsDir}</dir>\n</fontconfig>\n`
)
rememberLog(`[fonts] wired WSL Windows fonts for renderer: ${fontsDir}`)
const cache = spawn('fc-cache', ['-f', fontsDir], { detached: true, stdio: 'ignore' })
cache.on('error', () => undefined)
cache.unref()
} catch (error) {
rememberLog(`[fonts] WSL font setup skipped: ${error.message}`)
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
@ -1341,9 +1379,7 @@ function resolveHermesBackend(dashboardArgs) {
shell: false
}
}
rememberLog(
`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`
)
rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`)
}
// 6. Nothing usable yet -- signal the bootstrap runner that we need to
@ -3296,6 +3332,7 @@ app.whenReady().then(() => {
Menu.setApplicationMenu(null)
}
installMediaPermissions()
ensureWslWindowsFonts()
createWindow()
app.on('activate', () => {

View file

@ -0,0 +1,70 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { OAuthProvider } from '@/types/hermes'
import { $desktopOnboarding, type DesktopOnboardingState, type OnboardingContext } from '@/store/onboarding'
import { Picker } from './desktop-onboarding-overlay'
function provider(id: string, name = id): OAuthProvider {
return {
cli_command: `hermes login ${id}`,
docs_url: `https://example.com/${id}`,
flow: 'pkce',
id,
name,
status: { logged_in: false }
}
}
function setProviders(providers: OAuthProvider[]) {
$desktopOnboarding.set({
configured: false,
flow: { status: 'idle' },
mode: 'oauth',
providers,
reason: null,
requested: false
} satisfies DesktopOnboardingState)
}
const ctx: OnboardingContext = { requestGateway: async () => undefined as never }
afterEach(() => {
cleanup()
$desktopOnboarding.set({
configured: null,
flow: { status: 'idle' },
mode: 'oauth',
providers: null,
reason: null,
requested: false
})
})
describe('onboarding Picker', () => {
it('features Nous Portal and hides other providers behind a disclosure', () => {
setProviders([provider('anthropic', 'Anthropic Claude'), provider('nous', 'Nous Portal')])
render(<Picker ctx={ctx} />)
expect(screen.getByText('Nous Portal')).toBeTruthy()
expect(screen.getByText('Recommended')).toBeTruthy()
expect(screen.queryByText('Anthropic Claude')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Other providers' }))
expect(screen.getByText('Anthropic Claude')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Collapse' })).toBeTruthy()
})
it('shows every provider directly when Nous Portal is absent', () => {
setProviders([provider('anthropic', 'Anthropic Claude'), provider('openai-codex', 'OpenAI Codex / ChatGPT')])
render(<Picker ctx={ctx} />)
expect(screen.getByText('Anthropic Claude')).toBeTruthy()
expect(screen.getByText('OpenAI Codex / ChatGPT')).toBeTruthy()
expect(screen.queryByText('Other sign-in options')).toBeNull()
expect(screen.queryByText('Recommended')).toBeNull()
})
})

View file

@ -4,7 +4,17 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { ModelPickerDialog } from '@/components/model-picker'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Check, ChevronLeft, ChevronRight, ExternalLink, KeyRound, Loader2, Sparkles } from '@/lib/icons'
import {
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ExternalLink,
KeyRound,
Loader2,
Sparkles,
Terminal
} from '@/lib/icons'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { cn } from '@/lib/utils'
import { $desktopBoot, type DesktopBootState } from '@/store/boot'
@ -99,9 +109,9 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
}
const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
pkce: 'Opens your browser to sign in, then continues here.',
device_code: 'Opens a verification page in your browser. Hermes connects automatically.',
external: 'Sign in once in your terminal, then come back to chat.'
pkce: 'Opens your browser to sign in, then continues here',
device_code: 'Opens a verification page in your browser — Hermes connects automatically',
external: 'Sign in once in your terminal, then come back to chat'
}
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
@ -147,7 +157,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<Header />
<div className="grid gap-5 p-6">
<div className="grid gap-3 p-5">
{reason ? <ReasonNotice reason={reason} /> : null}
{ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
</div>
@ -209,13 +219,13 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
function Header() {
return (
<div className="border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) px-6 py-5">
<div className="border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) px-5 py-4">
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)">
<Sparkles className="size-5" />
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Welcome to Hermes</h2>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Let's get you setup with Hermes Agent</h2>
<p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
Connect a model provider to start chatting. Most options take one click.
</p>
@ -225,8 +235,31 @@ function Header() {
)
}
function Picker({ ctx }: { ctx: OnboardingContext }) {
const FEATURED_ID = 'nous'
const FEATURED_PITCH = 'One subscription, 300+ frontier models — the recommended way to run Hermes'
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
const readShowAll = () => {
try {
return window.localStorage.getItem(SHOW_ALL_KEY) === '1'
} catch {
return false
}
}
const persistShowAll = (value: boolean) => {
try {
window.localStorage.setItem(SHOW_ALL_KEY, value ? '1' : '0')
} catch {
// localStorage unavailable — degrade silently.
}
return value
}
export function Picker({ ctx }: { ctx: OnboardingContext }) {
const { mode, providers } = useStore($desktopOnboarding)
const [showAll, setShowAll] = useState(readShowAll)
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
const hasOauth = ordered.length > 0
@ -234,42 +267,123 @@ function Picker({ ctx }: { ctx: OnboardingContext }) {
return <ApiKeyForm canGoBack={hasOauth} ctx={ctx} />
}
if (providers === null) {
return <Status>Looking up providers...</Status>
}
const select = (p: OAuthProvider) => void startProviderOAuth(p, ctx)
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
// Collapse the secondary providers behind a disclosure only when Nous
// Portal is present to anchor the choice — otherwise show the full list.
const collapsible = Boolean(featured) && rest.length > 0
const showRest = !collapsible || showAll
return (
<div className="grid gap-3">
{providers === null ? (
<Status>Looking up providers...</Status>
) : (
ordered.map(provider => (
<ProviderRow key={provider.id} onSelect={p => void startProviderOAuth(p, ctx)} provider={provider} />
))
)}
<FooterLink onClick={() => setOnboardingMode('apikey')}>I have an API key</FooterLink>
<div className="grid gap-2">
{featured ? <FeaturedProviderRow onSelect={select} provider={featured} /> : null}
{showRest ? (
<>
{rest.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
<KeyProviderRow onClick={() => setOnboardingMode('apikey')} />
</>
) : null}
{collapsible ? (
<button
className="flex items-center justify-center gap-1.5 pt-1 text-xs font-medium text-muted-foreground transition hover:text-foreground"
onClick={() => setShowAll(persistShowAll(!showAll))}
type="button"
>
{showAll ? 'Collapse' : 'Other providers'}
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
</button>
) : null}
<div className="flex justify-end pt-1">
<button
className="text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={() => setOnboardingMode('apikey')}
type="button"
>
I have an API key
</button>
</div>
</div>
)
}
function FooterLink({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
function FeaturedProviderRow({
onSelect,
provider
}: {
onSelect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
const loggedIn = provider.status?.logged_in
return (
<div className="pt-2 text-center">
<button
className="text-sm font-semibold text-foreground underline-offset-4 decoration-current/20 hover:underline"
onClick={onClick}
type="button"
>
{children}
</button>
</div>
<button
className={cn(
'group flex w-full items-center justify-between gap-4 rounded-2xl border-2 border-primary/50 bg-primary/5 p-4 text-left transition hover:border-primary hover:bg-primary/10',
loggedIn && 'border-primary'
)}
onClick={() => onSelect(provider)}
type="button"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<img alt="" className="size-5 shrink-0 rounded" src="/apple-touch-icon.png" />
<span className="text-base font-semibold">{providerTitle(provider)}</span>
{loggedIn ? (
<ConnectedTag />
) : (
<span className="inline-flex items-center gap-1.5 bg-primary px-2 py-0.5 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-primary-foreground">
<span aria-hidden="true" className="dither inline-block size-2 shrink-0" />
Recommended
</span>
)}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FEATURED_PITCH}</p>
</div>
<ChevronRight className="size-5 shrink-0 text-primary transition group-hover:translate-x-0.5" />
</button>
)
}
function ConnectedTag() {
return (
<span className="inline-flex items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Check className="size-3" />
Connected
</span>
)
}
function KeyProviderRow({ onClick }: { onClick: () => void }) {
return (
<button
className="group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40"
onClick={onClick}
type="button"
>
<div className="min-w-0">
<span className="text-sm font-semibold">OpenRouter</span>
<p className="mt-1 text-xs leading-5 text-muted-foreground">One key, hundreds of models a solid default</p>
</div>
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
</button>
)
}
function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void; provider: OAuthProvider }) {
const loggedIn = provider.status?.logged_in
const Trail = provider.flow === 'external' ? ExternalLink : ChevronRight
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
return (
<button
className={cn(
'group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-4 text-left transition hover:border-primary/40 hover:bg-accent/40',
'group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40',
loggedIn && 'border-primary/30'
)}
onClick={() => onSelect(provider)}
@ -278,12 +392,7 @@ function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvide
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{providerTitle(provider)}</span>
{loggedIn ? (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Check className="size-3" />
Connected
</span>
) : null}
{loggedIn ? <ConnectedTag /> : null}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FLOW_SUBTITLES[provider.flow]}</p>
</div>

View file

@ -8,6 +8,7 @@ describe('isProviderSetupErrorMessage', () => {
true
)
expect(isProviderSetupErrorMessage('No inference provider is configured.')).toBe(true)
expect(isProviderSetupErrorMessage('No Hermes provider is configured.')).toBe(true)
expect(isProviderSetupErrorMessage('set an API key (OPENROUTER_API_KEY) in ~/.hermes/.env')).toBe(true)
})

View file

@ -1,5 +1,5 @@
const PROVIDER_SETUP_ERROR_RE =
/No inference provider(?: is)? configured|no_provider_configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i
/No (?:inference|Hermes) provider(?: is)? configured|no_provider_configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i
export function isProviderSetupErrorMessage(message: null | string | undefined): boolean {
const text = message?.trim()

View file

@ -1615,6 +1615,57 @@ def test_setup_status_reports_provider_config(monkeypatch):
assert resp["result"]["provider_configured"] is False
def test_setup_runtime_check_rejects_empty_runtime_key(monkeypatch):
monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: True)
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda requested=None: {
"provider": "openrouter",
"api_key": "",
"source": "env/config",
},
)
resp = server.handle_request({"id": "1", "method": "setup.runtime_check", "params": {}})
assert resp["result"]["ok"] is False
assert resp["result"]["provider"] == "openrouter"
def test_setup_runtime_check_allows_no_key_custom_runtime(monkeypatch):
monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: True)
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda requested=None: {
"provider": "custom",
"api_key": "no-key-required",
"source": "env/config",
},
)
resp = server.handle_request({"id": "1", "method": "setup.runtime_check", "params": {}})
assert resp["result"]["ok"] is True
assert resp["result"]["provider"] == "custom"
def test_setup_runtime_check_rejects_implicit_bedrock_when_unconfigured(monkeypatch):
monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: False)
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda requested=None: {
"provider": "bedrock",
"api_key": "aws-sdk",
"source": "iam-role",
},
)
resp = server.handle_request({"id": "1", "method": "setup.runtime_check", "params": {}})
assert resp["result"]["ok"] is False
assert resp["result"]["provider"] == "bedrock"
def test_complete_slash_includes_provider_alias():
resp = server.handle_request(
{"id": "1", "method": "complete.slash", "params": {"text": "/pro"}}

View file

@ -5263,8 +5263,49 @@ def _(rid, params: dict) -> dict:
"""
try:
from hermes_cli.runtime_provider import resolve_runtime_provider
from hermes_cli.auth import has_usable_secret
from hermes_cli.main import _has_any_provider_configured
runtime = resolve_runtime_provider(requested=None)
provider_configured = bool(_has_any_provider_configured())
provider = runtime.get("provider") or "provider"
source = str(runtime.get("source") or "")
if not provider_configured and provider == "bedrock" and source in {
"iam-role",
"aws-sdk-default-chain",
}:
return _ok(
rid,
{
"ok": False,
"provider": provider,
"model": runtime.get("model"),
"source": source,
"error": "No Hermes provider is configured.",
},
)
api_key = runtime.get("api_key")
api_key_text = "" if callable(api_key) else str(api_key or "").strip()
credential_ok = (
callable(api_key)
or api_key_text in {"aws-sdk", "no-key-required"}
or has_usable_secret(api_key_text)
or bool(runtime.get("command"))
)
if not credential_ok:
return _ok(
rid,
{
"ok": False,
"provider": provider,
"model": runtime.get("model"),
"source": runtime.get("source"),
"error": f"No usable credentials found for {provider}.",
},
)
return _ok(
rid,
{