In an Aspire workspace: start and stop the AppHost and see its resources in the status line and a pane

[!CAUTION] This is 'Slopllegiance'. it is a functional prototype Allegiance-style game written by Claude under supervision. It is not a finished game, expect bugs, and expect the code to be rough and unpolished - no pride in ownership here - this is slop. If this demonstrates anything it's that
- Allegiance can be re-platformed as a modern crossplatform game, and
- SOTA LLMs are capable of getting quite far in generating functional game code.
A 3D multiplayer space-combat game: a Godot client rendering and predicting flight, and a standalone .NET sim server that runs the authoritative 20 Hz simulation and hosts the lobby. Clients connect directly to a server by ip:port and download everything they need (world, content defs, live state) from it — there is no database to stand up.
client/ Godot 4.7 (C#/.NET 10) — rendering, input, client-side prediction
server/ .NET 10 console — authoritative 20 Hz sim + lobby host (the only gameplay authority)
public-lobby/ .NET 10 web — PUBLIC LOBBY: player accounts + game-server registry + WebRTC signaling + match ladder (Postgres/Orleans)
shared/ deterministic FlightModel + content defs, compiled into BOTH client and server
tools/ simbot (load bot swarm), asteroid-gen (mesh catalog)
tests/ ~20 test suites (FlightModel determinism/golden, Crypto, …) + factions/tests
The client only ever predicts; the server is the single source of truth and reconciles clients against its authoritative snapshots. The shared/ project is referenced (not copied) by both sides so their physics and content stay bit-identical.
dotnet --version ≥ 10) — newer SDKs work too.dotnet tool install -g Aspire.Cli or curl -sSL https://aspire.dev/install.sh | bash. Orchestrates the local stack (aspire run).GODOT env var, the godot.executablePath user secret, PATH, or standard install locations; the Aspire dashboard prompts for the path (and offers to save it to user secrets) if none of those resolve.pwsh) — only needed now for scripts/export-clients.ps1 and the tools/*.ps1 helpers. Preinstalled on Windows; on macOS/Linux install it (brew install powershell or your package manager's apt/dnf package).From the repo root:
aspire run
This brings up a Postgres container, applies the lobby's migrations, then the public lobby (http://localhost:8091) and the sim server (ws://localhost:8090/game), and opens the Aspire dashboard. In the dashboard, click Start on the client resource to build and launch the Godot client (it opens the local lobby's server browser; parameter client-mode = direct dials localhost:8090 instead).
Pick a side, ready up, and the match starts.
Against the hosted lobby instead. To publish a server to, or browse, the real public lobby (https://stellarlobby.wivuu.com) without the local stack, use the scripts: scripts/run-server.ps1 (publishes under your hostname; -Local = private) and scripts/run-client.ps1 (hosted server browser; -Local = direct localhost:8090). See scripts/README.md.
Configuration. The root .env (see .env.example) is read by both docker compose and the AppHost: every KEY=VALUE becomes an AppHost parameter (kebab-cased, e.g. SIM_AUTOSTART=1 → parameter sim-autostart). Anything left unresolved is prompted for in the dashboard and can be saved to user secrets. Local defaults turn on the lobby's dev login and register the server under your machine's hostname; it stays unlisted (direct-connect only) until you approve its device code — aspire resource lobby approve-device-code --user-code XXXX-XXXX (or the button on the lobby resource) — after which it is Verified and clients join through the lobby browser. The local stack always talks to the local lobby; the lobby-public-url/public-lobby parameters only feed Railway deploys. See Public lobby & NAT traversal. Accounts, Verified servers, the ladder and how to deploy them: docs/LOBBY-ACCOUNTS-AND-RANKING.md.
Accounts. On first launch the client asks you to sign in: it shows a short code and opens the lobby's approval page in your browser (passkeys always work; Google/GitHub/Steam when the lobby has them configured). Signed in, you see the public server list with VERIFIED (authenticated, operator shown, results count on the ladder) and UNVERIFIED badges; joining a Verified server uses a single-use join token under your account name. Continue without account (or --anonymous) keeps direct-by-address joins only. The web pages (/ladder, /players/<name>, /me) live on the lobby.
Solo testing tip: set SIM_AUTOSTART=1 (parameter sim-autostart) to skip the ready-up gate and start a perpetual match immediately.
See QUICKSTART.md for a step-by-step walkthrough and CONTRIBUTING.md for project layout, building, and tests.
.vscode/tasks.json wires up the Aspire workflow as VS Code tasks. Run them with Cmd/Ctrl+Shift+P → "Tasks: Run Task":
| Task | Command | What it does | ||
|---|---|---|---|---|
| Aspire: run | aspire run | Start the whole local stack (Postgres, lobby, sim server) with the dashboard, in a dedicated background panel. | ||
| Aspire: launch client | `aspire resource client launch --mode <direct\ | lobby\ | autofly>` | Build and launch an extra/custom Godot client (prompts for the mode). |
| Run server (public lobby) | scripts/run-server.ps1 | Build (Release) + run the sim server published to the hosted public lobby. | ||
| Run client (public lobby) | scripts/run-client.ps1 | Rebuild + launch a Godot client on the hosted lobby's server browser. | ||
| Export clients (all platforms) | scripts/export-clients.ps1 | Export macOS/Windows/Linux builds (macOS .app only when run on macOS). | ||
| Package client (this OS) | scripts/package-clients.ps1 -Version <x.y.z> | Build this OS's release package: Game Launcher + client as a Velopack installer with auto-update (docs/RELEASING.md). -FakeGame skips the Godot export. | ||
| Godot: import assets (if needed) | tools/godot-import.ps1 | Import GLB assets. Runs automatically on folder-open; a no-op unless something needs importing. | ||
| Godot: reimport assets (force) | tools/godot-import.ps1 -Force | Force a full reimport after editing a .glb. | ||
| Asteroid-gen: build catalog | tools/asteroid-gen/build.ps1 | Regenerate the asteroid mesh catalog (Docker). |
The same commands run from a terminal — the tasks are just a convenient front-end. The dashboard's own Start/Launch client buttons on the client resource cover the common case (a single client) without going through VS Code at all.
Godot path (configurable, not committed). Aspire auto-detects Godot from GODOT, the godot.executablePath user secret, PATH, or standard install locations, so most setups need no configuration; if none resolve, the dashboard prompts for the path and offers to save it to user secrets (dotnet user-secrets set godot.executablePath "/path/to/Godot" --id stellarallegiance, outside the repo — never committed, survives git clean).
PowerShell 7+ is still needed for scripts/export-clients.ps1, scripts/package-clients.ps1 and the tools/*.ps1 helpers (Godot import/export, asteroid-gen). It's preinstalled on Windows; on macOS/Linux install it with brew install powershell or your package manager.
GLB assets are one file each. Only the .glb is committed (it embeds its own textures); Godot's .import/extracted-.png artifacts are gitignored and regenerated by the import task. A fresh clone imports automatically on first open; for headless/CI/export builds run tools/godot-import.ps1 (or -Force) first, or res:// asset loads fall back to placeholders.
dotnet build shared/Shared.csproj # deterministic core + content defs
dotnet build server/SimServer.csproj -c Release
dotnet build client/stellarallegiance.csproj # or just open client/ in Godot-mono
dotnet run --project server -c Release -- --port 8090 # run the server directly
dotnet run --project server -- [flags] (also settable via env in docker compose, see .env.example) — the same flags for a raw/perf-sensitive run outside Aspire:
| Flag | Env | AppHost parameter | Effect |
|---|---|---|---|
--port N | SIM_PORT | sim-port | Listen port (default 8090). |
--secret PW | SIM_SECRET | sim-secret | Require a shared-secret password in every client Hello (open if unset). |
--autostart | SIM_AUTOSTART=1 | sim-autostart | Skip the lobby ready-up; run a perpetual match (bots/benchmarking). |
--seed N | — | — | World generation seed. |
The client reads SIM_SECRET (to send the password) and PILOT_NAME (lobby name) from the environment; SIM_URI is a dev override that connects to a full ws://…/game URL directly. Under aspire run/aspire start, set the corresponding .env key (or override the parameter in the dashboard) instead of passing flags directly.
A player can reach a server two ways:
ip:port on the connect screen (or --host). A plain WebSocket join; works on a LAN or against a public/port-forwarded server.public-lobby/ service is a small registry + WebRTC signaling relay. A game server that sets SIM_PUBLIC_NAME registers itself there and clients browse the list. Discovery is direct-first: on register, the lobby probes the server's port and, if it's reachable, advertises a direct host:port so clients connect straight to it over WebSocket (no traffic through the lobby). A server that isn't reachable (NAT, no port-forward) falls back to a WebRTC DataChannel (P2P hole-punching via public STUN), with only the SDP handshake relayed through the lobby. The same binary protocol rides both transports.aspire resource lobby approve-device-code --user-code XXXX-XXXX); the resulting credential persists to SIM_AUTH_FILE (apphost/.local/server/ under Aspire) so later restarts re-list silently. See public-lobby/README.md's "Identity: device codes…" and "Listings: Verified vs Unverified".There is no TURN relay — the lobby never carries game traffic. The trade-off is that a client behind a symmetric NAT can't reach a NAT'd server; it can always join direct servers, so hosting on a public/forwarded port gives the widest reach. All of this is automatic: forward a port and your server is listed as directly joinable; don't, and it's listed as WebRTC/STUN.
| Flag/Env | Where | Effect |
|---|---|---|
SIM_PUBLIC_NAME | server | 3-50 char name; gates public-lobby registration (unset = private). |
PUBLIC_LOBBY | server + client | Lobby base — host:port or https://domain (default https://stellarlobby.wivuu.com). Client also takes --lobby. |
SIM_PUBLIC_PORT | server | Public-facing port the lobby probes/advertises (default = listen port). |
SIM_PUBLIC_ENDPOINT | server | Optional address the server asserts as reachable — host:port (container NAT / proxy) or https://domain (a PaaS edge); advertised only if it answers /health. Auto-derives from RAILWAY_PUBLIC_DOMAIN on Railway. |
SIM_AUTH_FILE | server | Path to the persisted Game Server credential (device-code auth); default beside the sim-cache dir. Delete it to re-pair under a different Operator. |
SHARE_PORT | public-lobby | Listen port (default 8091; a PaaS PORT overrides it). |
STUN_URL | public-lobby | Public STUN URL(s) for the WebRTC fallback, comma-separated for redundancy (default Cloudflare's). |
To host the lobby yourself — the single required port, the reachability probe, and production hardening — see public-lobby/README.md.
cp .env.example .env # optionally set SIM_SECRET / SIM_AUTOSTART / SIM_PUBLIC_NAME
docker compose up --build # sim-server (ws://localhost:8090/game) + public-lobby (:8091)
Released images are published to GHCR — no checkout or build needed. The image keeps itself updated to the latest release once it has no players connected; set SIM_AUTO_UPDATE=warn (log only) or off to turn that down (see docs/DEPLOY.md → Server auto-update).
docker run -p 8090:8090 \
-e SIM_PUBLIC_NAME="My Server" \
-v sa-server-data:/data \
ghcr.io/wivuu/stellarallegiance-sim:latest
An update relaunches inside this same container, so no --rm — the volume keeps the paired lobby credential in /data across a manual restart.
Deploy from the Aspire dashboard — Deploy to Railway on the lobby or server resource — or from the CLI: aspire do deploy-lobby / aspire do deploy-server. See docs/DEPLOY.md for production (TLS termination, single-service deploy, and the one-time manual Railway steps for the lobby + a game server).
README.md), flight-model reference (ship_movement/), completed handoffs (archive/). Historical build-order notes are archived under docs/archive/.The 3D ship and station models are converted from Allegiance, originally developed by Microsoft and open-sourced by the FreeAllegiance project under the MIT license (copyright Microsoft Corporation). See NOTICE for the full license text.
hooks/register.tsx 350 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register, Timer } from 'claude-code'
3
4import type { AspireBusy, AspireSnapshot, AspireWorkspace } from '../types'
5import {
6 appHostFromConfig,
7 conditionOf,
8 describeState,
9 isSettling,
10 lastLine,
11 overallCondition,
12 parentOf,
13 parseDescribe,
14 parseJson,
15 parsePs,
16 pickAppHost,
17 relativeTo,
18 report,
19 statusLine,
20} from './aspire'
21
22const PANE = 'aspire'
23const IDLE_CHECK_MS = 15_000
24const SETTLING_CHECK_MS = 3_000
25const START_TIMEOUT_MS = 5 * 60_000
26const STOP_TIMEOUT_MS = 2 * 60_000
27const CHECK_TIMEOUT_MS = 20_000
28
29const COMMANDS = [
30 { name: 'aspire-start', description: 'Start this workspace’s Aspire AppHost in the background' },
31 { name: 'aspire-stop', description: 'Stop this workspace’s running Aspire AppHost' },
32 { name: 'aspire-status', description: 'Show the Aspire AppHost’s resources and open the Aspire pane' },
33]
34
35const COLOR = { up: 'success', pending: 'warning', down: 'error', done: 'inactive' } as const
36
37const workspace = atom({ plugin: 'aspire-controls', key: 'workspace' } as const, null)
38const snapshot = atom({ plugin: 'aspire-controls', key: 'snapshot' } as const, null)
39const busy = atom({ plugin: 'aspire-controls', key: 'busy' } as const, null)
40
41// The module's own: a reload starts them over, and session.start sets them again.
42let bin = 'aspire'
43let checking: Promise<void> | undefined
44let timer: Timer | undefined
45
46const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
47
48const failure = (ran: ProcessRunResult) =>
49 lastLine(ran.stderr) || lastLine(ran.stdout) || `exit ${ran.exitCode}`
50
51async function aspire($: EngineInterface, args: string[], timeoutMs: number): Promise<ProcessRunResult> {
52 const root = (await read($, workspace))?.root
53
54 return $.process.run([bin, ...args, '--nologo', '--non-interactive'], { cwd: root, timeoutMs })
55}
56
57/** The nearest folder at or above `cwd`, short of the home folder, holding an Aspire config. */
58async function findWorkspace($: EngineInterface, cwd: string): Promise<AspireWorkspace | null> {
59 const home = await $.env.get('HOME')
60 for (let dir = cwd; dir !== home; dir = parentOf(dir)) {
61 const configs = [
62 { path: `${dir}/aspire.config.json`, dir },
63 { path: `${dir}/.aspire/settings.json`, dir: `${dir}/.aspire` },
64 ]
65 for (const config of configs) {
66 if (await $.fs.exists(config.path)) {
67 const text = await $.fs.read(config.path).catch(() => '')
68 return { root: dir, appHostPath: appHostFromConfig(typeof text === 'string' ? text : '', config.dir) }
69 }
70 }
71 if (dir === '/') {
72 break
73 }
74 }
75
76 return null
77}
78
79async function setBusy($: EngineInterface, doing: AspireBusy) {
80 await update($, busy, () => doing)
81}
82
83function schedule($: EngineInterface, ms: number) {
84 timer?.cancel()
85 timer = $.clock.after(ms, () => void check($))
86}
87
88/** Asks `aspire ps`, and `aspire describe` when the workspace's AppHost runs; one check at a time. */
89function check($: EngineInterface): Promise<void> {
90 checking ??= checkNow($).finally(() => {
91 checking = undefined
92 })
93
94 return checking
95}
96
97async function checkNow($: EngineInterface) {
98 const found = await read($, workspace)
99 if (found === null) {
100 return
101 }
102
103 let next: AspireSnapshot
104 try {
105 const ps = await aspire($, ['ps', '--format', 'Json'], CHECK_TIMEOUT_MS)
106 if (ps.exitCode !== 0) {
107 throw new Error(`aspire ps: ${failure(ps)}`)
108 }
109 const host = pickAppHost(parsePs(ps.stdout), found)
110 const described =
111 host === undefined
112 ? undefined
113 : await aspire($, ['describe', '--format', 'Json', '--apphost', host.appHostPath], CHECK_TIMEOUT_MS)
114 next = {
115 appHostPath: host?.appHostPath ?? null,
116 dashboardUrl: host?.dashboardUrl ?? null,
117 resources: described?.exitCode === 0 ? parseDescribe(described.stdout) : [],
118 error: null,
119 }
120 } catch (error) {
121 const previous = await read($, snapshot)
122 next = { appHostPath: null, dashboardUrl: null, resources: [], ...previous, error: messageOf(error) }
123 }
124
125 await update($, snapshot, () => next)
126 schedule($, isSettling(next, await read($, busy)) ? SETTLING_CHECK_MS : IDLE_CHECK_MS)
127}
128
129/** How a start or stop went: a toast, and a dim transcript line the model does not read. */
130function tell($: EngineInterface, text: string) {
131 $.ui.toast(text, { timeoutMs: 6000 })
132 $.ui.log(text)
133}
134
135async function start($: EngineInterface) {
136 const found = await read($, workspace)
137 const doing = await read($, busy)
138 if (found === null) {
139 return
140 }
141 if (doing !== null) {
142 tell($, `Aspire is already ${doing}; try again once it is done.`)
143 return
144 }
145
146 await setBusy($, 'starting')
147 try {
148 await check($)
149 if ((await read($, snapshot))?.appHostPath) {
150 tell($, 'Aspire is already running.')
151 return
152 }
153 const appHost = found.appHostPath === null ? [] : ['--apphost', found.appHostPath]
154 const ran = await aspire($, ['start', '--format', 'Json', ...appHost], START_TIMEOUT_MS)
155 if (ran.exitCode !== 0) {
156 tell($, `Aspire failed to start: ${failure(ran)}`)
157 return
158 }
159 const started = parseJson(ran.stdout) as { dashboardUrl?: unknown } | undefined
160 const dashboard = typeof started?.dashboardUrl === 'string' ? ` Dashboard: ${started.dashboardUrl}` : ''
161 tell($, `Aspire started.${dashboard}`)
162 } catch (error) {
163 tell($, `Aspire failed to start: ${messageOf(error)}`)
164 } finally {
165 await setBusy($, null)
166 await check($)
167 }
168}
169
170async function stop($: EngineInterface) {
171 const doing = await read($, busy)
172 if ((await read($, workspace)) === null) {
173 return
174 }
175 if (doing !== null) {
176 tell($, `Aspire is already ${doing}; try again once it is done.`)
177 return
178 }
179
180 await setBusy($, 'stopping')
181 try {
182 await check($)
183 const appHostPath = (await read($, snapshot))?.appHostPath
184 if (!appHostPath) {
185 tell($, 'Aspire is not running.')
186 return
187 }
188 const ran = await aspire($, ['stop', '--apphost', appHostPath], STOP_TIMEOUT_MS)
189 tell($, ran.exitCode === 0 ? 'Aspire stopped.' : `Aspire failed to stop: ${failure(ran)}`)
190 } catch (error) {
191 tell($, `Aspire failed to stop: ${messageOf(error)}`)
192 } finally {
193 await setBusy($, null)
194 await check($)
195 }
196}
197
198/** Starts or stops off the hook that asked, so the prompt stays free while the CLI works. */
199function launch($: EngineInterface, action: 'start' | 'stop') {
200 $.clock.after(0, () => void (action === 'start' ? start($) : stop($)))
201}
202
203export const register: Register = on => {
204 on('session.start', async ($, e, next) => {
205 timer?.cancel()
206 const found = await findWorkspace($, e.cwd)
207 await update($, workspace, () => found)
208 await update($, busy, () => null)
209 // The row under the prompt shows the state now; clear the status line an older load may have left.
210 $.ui.status(undefined)
211 if (found === null) {
212 return next(e)
213 }
214
215 const home = await $.env.get('HOME')
216 const installed = home === undefined ? undefined : `${home}/.aspire/bin/aspire`
217 bin = installed !== undefined && (await $.fs.exists(installed)) ? installed : 'aspire'
218 for (const command of COMMANDS) {
219 await $.command.register(command)
220 }
221 void check($)
222
223 return next(e)
224 })
225
226 on('command.run', { command: 'aspire-start' }, async $ => {
227 launch($, 'start')
228 return { text: 'Starting Aspire in the background; a toast says when it is up.' }
229 })
230
231 on('command.run', { command: 'aspire-stop' }, async $ => {
232 launch($, 'stop')
233 return { text: 'Stopping Aspire in the background; a toast says when it is down.' }
234 })
235
236 on('command.run', { command: 'aspire-status' }, async $ => {
237 const found = await read($, workspace)
238 if (found === null) {
239 return { text: 'No aspire.config.json in this workspace.' }
240 }
241 await check($)
242 await $.ui.open({ id: PANE, title: 'Aspire' })
243
244 return { text: report(await read($, snapshot), await read($, busy), found) }
245 })
246
247 // Aspire started or stopped from Bash, by the person or the model, shows without waiting for the next check.
248 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
249 const ran = await next(e)
250 if (/\baspire\s+(start|stop|run|resource)\b/.test(e.command)) {
251 void check($)
252 }
253
254 return ran
255 })
256
257 // The hint line under the prompt: the engine's own line, then the AppHost's state and a Start or Stop button.
258 // Buttons only where a click reaches them: the fullscreen terminal and the desktop app, not the terminal's
259 // main screen, which gets the slash command's name instead.
260 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
261 const engine = await next(e)
262 const found = await read($, workspace)
263 if (found === null) {
264 return engine
265 }
266
267 const { Box, Button, Text } = $.ui.resolve(e)
268 const [current, doing] = await Promise.all([read($, snapshot), read($, busy)])
269 const isRunning = Boolean(current?.appHostPath)
270 const isClickable = e.viewport?.isFullscreen !== false
271
272 return (
273 <Box>
274 <Box flexGrow={1} flexShrink={1}>
275 {engine}
276 </Box>
277 <Box gap={1} flexShrink={0}>
278 <Text color={COLOR[overallCondition(current, doing)]}>●</Text>
279 <Text dimColor>{statusLine(current, doing)}</Text>
280 {isClickable && doing === null && !isRunning && (
281 <Button key="start" label="Start" variant="primary" onPress={() => launch($, 'start')} />
282 )}
283 {isClickable && doing === null && isRunning && (
284 <Button key="stop" label="Stop" onPress={() => launch($, 'stop')} />
285 )}
286 {isClickable && (
287 <Button key="details" label="Details" dimColor onPress={() => void $.ui.open({ id: PANE, title: 'Aspire' })} />
288 )}
289 {!isClickable && doing === null && <Text dimColor>{isRunning ? '/aspire-stop' : '/aspire-start'}</Text>}
290 </Box>
291 </Box>
292 )
293 })
294
295 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
296 const { Box, Button, Link, Text } = $.ui.resolve(e)
297 const [found, current, doing] = await Promise.all([read($, workspace), read($, snapshot), read($, busy)])
298 if (found === null) {
299 return <Text dimColor>No aspire.config.json in this workspace.</Text>
300 }
301
302 const isRunning = Boolean(current?.appHostPath)
303 const resources = current?.resources ?? []
304 const nameWidth = Math.max(8, ...resources.map(resource => resource.name.length)) + 1
305 const appHost = current?.appHostPath ?? found.appHostPath
306
307 return (
308 <Box flexDirection="column">
309 <Text bold>{statusLine(current, doing)}</Text>
310 {appHost !== null && <Text dimColor>{relativeTo(found.root, appHost)}</Text>}
311 {current?.dashboardUrl && (
312 <Box gap={1}>
313 <Text dimColor>Dashboard</Text>
314 <Link href={current.dashboardUrl} label="open in browser" />
315 </Box>
316 )}
317 {current?.error && <Text color="error">{current.error}</Text>}
318 {resources.length > 0 && (
319 <Box flexDirection="column" marginTop={1}>
320 {resources.map(resource => {
321 const condition = conditionOf(resource)
322 return (
323 <Box key={`resource-${resource.name}`} gap={1}>
324 <Text color={COLOR[condition]}>●</Text>
325 <Box width={nameWidth} flexShrink={0}>
326 <Text dimColor={condition === 'done'} wrap="truncate-end">
327 {resource.name}
328 </Text>
329 </Box>
330 <Text dimColor>{describeState(resource)}</Text>
331 {resource.urls[0] !== undefined && <Link href={resource.urls[0]} />}
332 </Box>
333 )
334 })}
335 </Box>
336 )}
337 <Box gap={1} marginTop={1}>
338 {doing === null && !isRunning && (
339 <Button key="start" label="Start" hotkey="s" variant="primary" onPress={() => launch($, 'start')} />
340 )}
341 {doing === null && isRunning && (
342 <Button key="stop" label="Stop" hotkey="x" onPress={() => launch($, 'stop')} />
343 )}
344 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void check($)} />
345 </Box>
346 </Box>
347 )
348 })
349}
350hooks/aspire.ts 221 lines1// Reading the Aspire CLI's output and summing it up. Pure: nothing here calls the engine.
2
3import type { AspireBusy, AspireResource, AspireSnapshot, AspireWorkspace } from '../types'
4
5/** One AppHost `aspire ps` lists as running, anywhere on the machine. */
6export type RunningAppHost = { appHostPath: string; dashboardUrl: string | null }
7
8/** How a resource is doing: `done` is a one-shot that finished well (an installer). */
9export type Condition = 'up' | 'pending' | 'down' | 'done'
10
11const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g
12const PENDING = new Set(['Starting', 'Waiting', 'NotStarted', 'Building', 'Stopping', 'Unknown'])
13
14export const stripAnsi = (text: string): string => text.replace(ANSI, '')
15
16/** The last non-empty line of CLI output, plain and short: what a failure toast says. */
17export function lastLine(text: string): string {
18 const lines = stripAnsi(text)
19 .split('\n')
20 .map(line => line.trim())
21 .filter(line => line !== '')
22 const line = lines[lines.length - 1] ?? ''
23
24 return line.length > 160 ? `${line.slice(0, 157)}...` : line
25}
26
27/** The JSON in CLI output that may carry progress lines around it. */
28export function parseJson(text: string): unknown {
29 const clean = stripAnsi(text)
30 const start = clean.search(/^\s*[[{]/m)
31 const end = Math.max(clean.lastIndexOf('}'), clean.lastIndexOf(']'))
32 if (start < 0 || end < start) {
33 return undefined
34 }
35 try {
36 return JSON.parse(clean.slice(start, end + 1))
37 } catch {
38 return undefined
39 }
40}
41
42const isRecord = (value: unknown): value is Record<string, unknown> =>
43 typeof value === 'object' && value !== null && !Array.isArray(value)
44
45const stringOr = (value: unknown, fallback: string | null) =>
46 typeof value === 'string' ? value : fallback
47
48export function parsePs(stdout: string): RunningAppHost[] {
49 const list = parseJson(stdout)
50
51 return Array.isArray(list)
52 ? list.filter(isRecord).flatMap(item =>
53 typeof item.appHostPath === 'string'
54 ? [{ appHostPath: item.appHostPath, dashboardUrl: stringOr(item.dashboardUrl, null) }]
55 : [],
56 )
57 : []
58}
59
60export function parseDescribe(stdout: string): AspireResource[] {
61 const value = parseJson(stdout)
62 const list = isRecord(value) && Array.isArray(value.resources) ? value.resources : []
63
64 return list.filter(isRecord).map(item => ({
65 name: stringOr(item.displayName, null) ?? stringOr(item.name, '?') ?? '?',
66 type: stringOr(item.resourceType, '') ?? '',
67 state: stringOr(item.state, 'Unknown') ?? 'Unknown',
68 health: stringOr(item.healthStatus, null),
69 exitCode: typeof item.exitCode === 'number' ? item.exitCode : null,
70 urls: (Array.isArray(item.urls) ? item.urls : [])
71 .filter(isRecord)
72 .flatMap(url => (typeof url.url === 'string' ? [url.url] : [])),
73 }))
74}
75
76/** The workspace's AppHost among the running ones: the one its config names, else one under its root. */
77export function pickAppHost(
78 running: readonly RunningAppHost[],
79 workspace: AspireWorkspace,
80): RunningAppHost | undefined {
81 return (
82 running.find(host => host.appHostPath === workspace.appHostPath) ??
83 running.find(host => host.appHostPath.startsWith(`${workspace.root}/`))
84 )
85}
86
87export function conditionOf(resource: AspireResource): Condition {
88 const { state, health, exitCode } = resource
89 if (state === 'Running') {
90 return health === null || health === 'Healthy' ? 'up' : 'down'
91 }
92 if (state === 'Finished' && (exitCode === 0 || exitCode === null)) {
93 return 'done'
94 }
95
96 return PENDING.has(state) ? 'pending' : 'down'
97}
98
99/** `Running · Healthy`, `Exited (1)`: a resource's state in a few words. */
100export function describeState(resource: AspireResource): string {
101 const exit = resource.exitCode !== null && resource.state !== 'Running' ? ` (exit ${resource.exitCode})` : ''
102 const health = resource.health !== null && resource.state === 'Running' ? ` · ${resource.health}` : ''
103
104 return `${resource.state}${health}${exit}`
105}
106
107/** Whether something is still on its way, so the next check comes sooner. */
108export function isSettling(snapshot: AspireSnapshot | null, busy: AspireBusy): boolean {
109 return busy !== null || (snapshot?.resources.some(resource => conditionOf(resource) === 'pending') ?? false)
110}
111
112/** One color for the whole AppHost: stopped reads as `done`, the dim gray. */
113export function overallCondition(snapshot: AspireSnapshot | null, busy: AspireBusy): Condition {
114 if (busy !== null || snapshot === null) {
115 return 'pending'
116 }
117 if (snapshot.appHostPath === null) {
118 return snapshot.error === null ? 'done' : 'down'
119 }
120 const conditions = snapshot.resources.map(conditionOf)
121
122 return conditions.includes('down') ? 'down' : conditions.includes('pending') ? 'pending' : 'up'
123}
124
125/** The AppHost's state in one line, as the row under the prompt shows it. */
126export function statusLine(snapshot: AspireSnapshot | null, busy: AspireBusy): string {
127 if (busy !== null) {
128 return `Aspire: ${busy}…`
129 }
130 if (snapshot === null) {
131 return 'Aspire: checking…'
132 }
133 if (snapshot.appHostPath === null) {
134 return snapshot.error === null ? 'Aspire: stopped' : 'Aspire: unknown (aspire ps failed)'
135 }
136
137 const services = snapshot.resources.filter(resource => conditionOf(resource) !== 'done')
138 const up = services.filter(resource => conditionOf(resource) === 'up')
139 const down = services.filter(resource => conditionOf(resource) === 'down')
140 const pending = services.filter(resource => conditionOf(resource) === 'pending')
141 const line = `Aspire: running · ${up.length}/${services.length} up`
142
143 if (down.length > 0) {
144 return `${line} · ${down.map(resource => `${resource.name} ${resource.health ?? resource.state}`).join(', ')}`
145 }
146 if (pending.length > 0) {
147 return `${line} · ${pending.map(resource => resource.name).join(', ')} starting`
148 }
149
150 return line
151}
152
153/** What `/aspire-status` prints, which the model reads too. */
154export function report(
155 snapshot: AspireSnapshot | null,
156 busy: AspireBusy,
157 workspace: AspireWorkspace,
158): string {
159 const lines = [statusLine(snapshot, busy)]
160 if (snapshot?.error) {
161 lines.push(`Last check failed: ${snapshot.error}`)
162 }
163 if (snapshot?.appHostPath) {
164 lines.push(`AppHost: ${relativeTo(workspace.root, snapshot.appHostPath)}`)
165 }
166 if (snapshot?.dashboardUrl) {
167 lines.push(`Dashboard: ${snapshot.dashboardUrl}`)
168 }
169 for (const resource of snapshot?.resources ?? []) {
170 const url = resource.urls[0] === undefined ? '' : ` ${resource.urls[0]}`
171 lines.push(` ${resource.name}: ${describeState(resource)}${url}`)
172 }
173
174 return lines.join('\n')
175}
176
177export function relativeTo(root: string, path: string): string {
178 return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
179}
180
181/** `a/b/../c/./d` to `a/c/d`. */
182export function normalize(path: string): string {
183 const parts: string[] = []
184 for (const part of path.split('/')) {
185 if (part === '..') {
186 parts.pop()
187 } else if (part !== '.' && part !== '') {
188 parts.push(part)
189 }
190 }
191
192 return `${path.startsWith('/') ? '/' : ''}${parts.join('/')}`
193}
194
195export function parentOf(dir: string): string {
196 const cut = dir.lastIndexOf('/')
197
198 return cut <= 0 ? '/' : dir.slice(0, cut)
199}
200
201/** The AppHost a config names, absolute: `aspire.config.json`'s `appHost.path`, or `.aspire/settings.json`'s `appHostPath`. */
202export function appHostFromConfig(text: string, configDir: string): string | null {
203 let config: unknown
204 try {
205 config = JSON.parse(text)
206 } catch {
207 return null
208 }
209 if (!isRecord(config)) {
210 return null
211 }
212 const path =
213 isRecord(config.appHost) && typeof config.appHost.path === 'string'
214 ? config.appHost.path
215 : typeof config.appHostPath === 'string'
216 ? config.appHostPath
217 : null
218
219 return path === null ? null : normalize(path.startsWith('/') ? path : `${configDir}/${path}`)
220}
221types/index.d.ts 41 lines1/** One resource of a running AppHost, as `aspire describe` reports it. */
2export type AspireResource = {
3 name: string
4 type: string
5 /** Running, Starting, Waiting, Finished, Exited, FailedToStart, ... */
6 state: string
7 /** Healthy, Unhealthy, Degraded; null when the resource has no health checks. */
8 health: string | null
9 exitCode: number | null
10 urls: string[]
11}
12
13/** The Aspire workspace the session runs in: where its config lives and the AppHost it names. */
14export type AspireWorkspace = {
15 root: string
16 /** Absolute; null when the config names no AppHost. */
17 appHostPath: string | null
18}
19
20/** What the last check of `aspire ps` and `aspire describe` found. */
21export type AspireSnapshot = {
22 /** The workspace's running AppHost; null when none runs. */
23 appHostPath: string | null
24 dashboardUrl: string | null
25 resources: AspireResource[]
26 /** Why the last check failed; null when it did not. */
27 error: string | null
28}
29
30export type AspireBusy = 'starting' | 'stopping' | null
31
32declare module 'claude-code' {
33 interface PluginState {
34 'aspire-controls': {
35 workspace: AspireWorkspace | null
36 snapshot: AspireSnapshot | null
37 busy: AspireBusy
38 }
39 }
40}
41