Add in-app shell prompt picker: install Starship + Nerd Font on demand

Lets a user pick one of three Starship presets (Nerd Font Symbols,
Pastel Powerline, Tokyo Night) from the Terminal page and install
Starship + a Nerd Font on the active pane's SSH host with one click,
instead of running a script by hand. Idempotent on the host side, and
available to all authenticated users like the rest of the SSH/Docker
tooling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019hu9pZvJY4BgmcQeAw2ugk
This commit is contained in:
Claude 2026-06-21 09:10:30 +00:00
parent 5ed6bb591f
commit a964591431
No known key found for this signature in database
5 changed files with 273 additions and 2 deletions

View file

@ -0,0 +1,31 @@
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { withSshClient } from '../ssh/docker.js'
import { PROMPT_PRESETS, checkPromptStatus, installPrompt, isValidPresetId } from '../ssh/shellPrompt.js'
/**
* Shell prompt setup for SSH-managed hosts: lets the Terminal page show a
* preset picker and install Starship + a Nerd Font on demand, instead of
* requiring the user to run a script by hand on every host.
*/
export async function shellPromptRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate)
app.get('/api/ssh/:integrationId/shell-prompt', async (req, reply) => {
const integrationId = Number((req.params as { integrationId: string }).integrationId)
const result = await withSshClient(integrationId, (client) => checkPromptStatus(client))
if (!result.ok) return reply.code(502).send({ error: result.error })
return { presets: PROMPT_PRESETS, status: result.value }
})
app.post('/api/ssh/:integrationId/shell-prompt', async (req, reply) => {
const integrationId = Number((req.params as { integrationId: string }).integrationId)
const parsed = z.object({ presetId: z.string() }).safeParse(req.body ?? {})
if (!parsed.success || !isValidPresetId(parsed.data.presetId)) {
return reply.code(400).send({ error: 'Invalid preset' })
}
const result = await withSshClient(integrationId, (client) => installPrompt(client, parsed.data.presetId))
if (!result.ok) return reply.code(502).send({ error: result.error })
return { ok: true, log: result.value.log }
})
}

View file

@ -13,6 +13,7 @@ import { tunnelRoutes } from './routes/tunnels.js'
import { fileRoutes } from './routes/files.js' import { fileRoutes } from './routes/files.js'
import { dockerRoutes, dockerExecRoutes } from './routes/docker.js' import { dockerRoutes, dockerExecRoutes } from './routes/docker.js'
import { dockerSshRoutes, dockerSshExecRoutes } from './routes/dockerSsh.js' import { dockerSshRoutes, dockerSshExecRoutes } from './routes/dockerSsh.js'
import { shellPromptRoutes } from './routes/shellPrompt.js'
import { agentIngestRoutes, agentRoutes } from './routes/agents.js' import { agentIngestRoutes, agentRoutes } from './routes/agents.js'
import { guacamoleRoutes } from './routes/guacamole.js' import { guacamoleRoutes } from './routes/guacamole.js'
import { metricsRoutes } from './routes/metrics.js' import { metricsRoutes } from './routes/metrics.js'
@ -94,6 +95,7 @@ await app.register(dockerRoutes)
await app.register(dockerExecRoutes) await app.register(dockerExecRoutes)
await app.register(dockerSshRoutes) await app.register(dockerSshRoutes)
await app.register(dockerSshExecRoutes) await app.register(dockerSshExecRoutes)
await app.register(shellPromptRoutes)
await app.register(agentIngestRoutes) await app.register(agentIngestRoutes)
await app.register(agentRoutes) await app.register(agentRoutes)
await app.register(guacamoleRoutes) await app.register(guacamoleRoutes)

View file

@ -0,0 +1,131 @@
import type { Client } from 'ssh2'
import { execCommand } from './metrics/common.js'
/**
* Installs/configures a Starship shell prompt + the Nerd Font icons it needs
* on a remote SSH host, on demand from the Terminal page. Idempotent: each
* step checks first and skips work that's already done, so re-running (e.g.
* switching presets) doesn't re-download anything unnecessary.
*/
export interface PromptPreset {
id: string
name: string
description: string
/** Name passed to `starship preset <name>`. */
starshipPreset: string
}
export const PROMPT_PRESETS: PromptPreset[] = [
{
id: 'nerd-font-symbols',
name: 'Nerd Font Symbols',
description: 'Classic Starship look — OS icon, git branch/status, language runtime icons.',
starshipPreset: 'nerd-font-symbols',
},
{
id: 'pastel-powerline',
name: 'Pastel Powerline',
description: 'Powerline-style segments with soft pastel colors and icons.',
starshipPreset: 'pastel-powerline',
},
{
id: 'tokyo-night',
name: 'Tokyo Night',
description: 'Tokyo Night colorway with a clean icon-led layout.',
starshipPreset: 'tokyo-night',
},
]
export function isValidPresetId(id: string): boolean {
return PROMPT_PRESETS.some((p) => p.id === id)
}
const NERD_FONT_VERSION = 'v3.4.0'
const NERD_FONT_NAME = 'JetBrainsMono'
const NERD_FONT_DIR = '.local/share/fonts/ArchNestNerdFont'
export interface PromptStatus {
starshipInstalled: boolean
starshipVersion: string | null
fontInstalled: boolean
configuredPreset: string | null
}
export async function checkPromptStatus(client: Client): Promise<PromptStatus> {
const { stdout: versionOut, code: versionCode } = await execCommand(
client,
'starship --version 2>/dev/null',
)
const starshipInstalled = versionCode === 0
const starshipVersion = starshipInstalled ? versionOut.split('\n')[0]?.trim() || null : null
const { code: fontCode } = await execCommand(
client,
`test -d "\${HOME}/${NERD_FONT_DIR}" && [ -n "$(ls -A "\${HOME}/${NERD_FONT_DIR}" 2>/dev/null)" ]`,
)
const fontInstalled = fontCode === 0
const { stdout: presetOut, code: presetCode } = await execCommand(
client,
'grep -o "ArchNest preset: [a-z-]*" "${HOME}/.config/starship.toml" 2>/dev/null | head -1',
)
const configuredPreset = presetCode === 0 ? presetOut.replace('ArchNest preset:', '').trim() || null : null
return { starshipInstalled, starshipVersion, fontInstalled, configuredPreset }
}
/**
* Idempotently installs Starship + the Nerd Font (if either is missing), writes
* a starship.toml for the chosen preset, and wires the init line into
* .bashrc/.zshrc if either rc file exists and doesn't already have it.
*/
export async function installPrompt(client: Client, presetId: string): Promise<{ log: string }> {
const preset = PROMPT_PRESETS.find((p) => p.id === presetId)
if (!preset) throw new Error('Unknown preset')
const script = `
set -e
LOG() { printf '%s\\n' "$1"; }
if command -v starship >/dev/null 2>&1; then
LOG "starship already installed, skipping install"
else
LOG "installing starship..."
curl -fsSL https://starship.rs/install.sh | sh -s -- -y >/dev/null 2>&1
fi
FONT_DIR="\${HOME}/${NERD_FONT_DIR}"
if [ -d "$FONT_DIR" ] && [ -n "$(ls -A "$FONT_DIR" 2>/dev/null)" ]; then
LOG "nerd font already installed, skipping download"
else
LOG "installing ${NERD_FONT_NAME} Nerd Font..."
mkdir -p "$FONT_DIR"
TMP_TAR="$(mktemp)"
curl -fsSL -o "$TMP_TAR" "https://github.com/ryanoasis/nerd-fonts/releases/download/${NERD_FONT_VERSION}/${NERD_FONT_NAME}.tar.xz"
tar -xJf "$TMP_TAR" -C "$FONT_DIR"
rm -f "$TMP_TAR"
command -v fc-cache >/dev/null 2>&1 && fc-cache -f "$FONT_DIR" >/dev/null 2>&1 || true
fi
LOG "writing starship config for preset: ${preset.starshipPreset}"
mkdir -p "\${HOME}/.config"
starship preset ${preset.starshipPreset} -o "\${HOME}/.config/starship.toml"
printf '\\n# ArchNest preset: ${preset.id}\\n' >> "\${HOME}/.config/starship.toml"
add_init_line() {
local rcfile="$1" line="$2"
[ -f "$rcfile" ] || return 0
grep -qF "$line" "$rcfile" || printf '\\n# Added by ArchNest\\n%s\\n' "$line" >> "$rcfile"
}
add_init_line "\${HOME}/.bashrc" 'eval "$(starship init bash)"'
add_init_line "\${HOME}/.zshrc" 'eval "$(starship init zsh)"'
LOG "done"
`.trim()
const { stdout, stderr, code } = await execCommand(client, script, 180000)
if (code !== 0) {
throw new Error(stderr.trim() || stdout.trim() || `Install script exited with code ${code}`)
}
return { log: stdout.trim() }
}

View file

@ -193,6 +193,16 @@ export const api = {
`/agents/docker/hosts/${encodeURIComponent(hostId)}/containers/${encodeURIComponent(containerId)}`, `/agents/docker/hosts/${encodeURIComponent(hostId)}/containers/${encodeURIComponent(containerId)}`,
), ),
// Shell prompt setup on an SSH host: install Starship + a Nerd Font on demand
// from the Terminal page, instead of requiring a manual script run.
getShellPromptStatus: (integrationId: number) =>
apiFetch<{ presets: PromptPreset[]; status: PromptStatus }>(`/ssh/${integrationId}/shell-prompt`),
installShellPrompt: (integrationId: number, presetId: string) =>
apiFetch<{ ok: boolean; log: string }>(`/ssh/${integrationId}/shell-prompt`, {
method: 'POST',
body: JSON.stringify({ presetId }),
}),
getHostMetrics: (integrationId: number) => apiFetch<HostMetrics>(`/integrations/${integrationId}/metrics`), getHostMetrics: (integrationId: number) => apiFetch<HostMetrics>(`/integrations/${integrationId}/metrics`),
startTransfer: (data: { sourceIntegrationId: number; destIntegrationId: number; sourcePaths: string[]; destPath: string; move?: boolean }) => startTransfer: (data: { sourceIntegrationId: number; destIntegrationId: number; sourcePaths: string[]; destPath: string; move?: boolean }) =>
@ -209,6 +219,20 @@ export const api = {
}), }),
} }
export interface PromptPreset {
id: string
name: string
description: string
starshipPreset: string
}
export interface PromptStatus {
starshipInstalled: boolean
starshipVersion: string | null
fontInstalled: boolean
configuredPreset: string | null
}
export interface DataExport { export interface DataExport {
version: number version: number
exportedAt?: string exportedAt?: string

View file

@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash } from 'lucide-react' import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash, Sparkles } from 'lucide-react'
import { api, type Integration } from '../lib/api' import { api, type Integration, type PromptPreset, type PromptStatus } from '../lib/api'
import { useTerminalSessions } from '../lib/TerminalSessionContext' import { useTerminalSessions } from '../lib/TerminalSessionContext'
import { TERM_THEMES } from '../lib/terminalPrefs' import { TERM_THEMES } from '../lib/terminalPrefs'
@ -174,6 +174,9 @@ export default function Terminal() {
))} ))}
</select> </select>
</label> </label>
<div className="ml-auto border-l border-white/10 pl-4">
<ShellPromptControl hostId={activePaneHostId} />
</div>
</div> </div>
)} )}
@ -260,3 +263,83 @@ function TerminalPane({
</div> </div>
) )
} }
/**
* Lets a user pick one of a few Starship prompt looks and install it (Starship
* + a Nerd Font, if not already present) on the active pane's SSH host with
* one click, instead of running a script by hand.
*/
function ShellPromptControl({ hostId }: { hostId: number | null }) {
const [presets, setPresets] = useState<PromptPreset[]>([])
const [status, setStatus] = useState<PromptStatus | null>(null)
const [selected, setSelected] = useState('')
const [installing, setInstalling] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setStatus(null)
setMessage(null)
setError(null)
if (hostId === null) return
api
.getShellPromptStatus(hostId)
.then(({ presets, status }) => {
setPresets(presets)
setStatus(status)
setSelected(status.configuredPreset ?? presets[0]?.id ?? '')
})
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to check host'))
}, [hostId])
if (hostId === null) {
return <span style={{ color: TEXT_SECONDARY }}>Connect a host to set up its shell prompt</span>
}
async function handleInstall() {
if (!selected) return
setInstalling(true)
setError(null)
setMessage(null)
try {
await api.installShellPrompt(hostId!, selected)
setMessage('Installed — open a new terminal session to this host to see it.')
const { status } = await api.getShellPromptStatus(hostId!)
setStatus(status)
} catch (err) {
setError(err instanceof Error ? err.message : 'Install failed')
} finally {
setInstalling(false)
}
}
return (
<div className="flex items-center gap-2">
<Sparkles size={13} style={{ color: GOLD }} />
<label className="flex items-center gap-2">
Shell Prompt
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
className="rounded-md border border-white/10 bg-transparent px-2 py-1"
>
{presets.map((p) => (
<option key={p.id} value={p.id} title={p.description}>
{p.name}
</option>
))}
</select>
</label>
<button
onClick={handleInstall}
disabled={installing || !selected}
className="rounded-md px-2.5 py-1 text-xs font-medium"
style={{ backgroundColor: GOLD, color: '#0A0A0C', opacity: installing ? 0.6 : 1 }}
>
{installing ? 'Installing…' : status?.configuredPreset === selected ? 'Reinstall' : 'Install'}
</button>
{message && <span style={{ color: '#2ECC71' }}>{message}</span>}
{error && <span style={{ color: '#E74C3C' }}>{error}</span>}
</div>
)
}