/** * System Tool Status Handlers * * Reports installation status for each managed tool (git, claude, * opencode, chrome). The corresponding recipe is bundled so the * frontend can render the install button state and manual fallback * without a second round-trip. */ import { t } from 'elysia'; import { createRouter } from '$shared/utils/ws-server'; import { debug } from '$shared/utils/logger'; import { getToolStatus, resolveRecipe, type ToolId } from '$backend/engine/install-recipes'; import { getActiveSessionForTool } from '$backend/engine/install-runner'; import { checkToolUpdate } from '$backend/engine/version-check'; const TOOL_UNION = t.Union([ t.Literal('git'), t.Literal('claude'), t.Literal('opencode'), t.Literal('copilot'), t.Literal('codex'), t.Literal('qwen'), t.Literal('pi'), t.Literal('cline'), t.Literal('cursor'), t.Literal('chrome'), ]); const RECIPE_SCHEMA = t.Object({ tool: TOOL_UNION, autoInstallable: t.Boolean(), unavailableReason: t.Optional(t.String()), displayCommand: t.Optional(t.String()), missingPrereqs: t.Array(TOOL_UNION), manualInstructions: t.Array(t.Object({ label: t.String(), command: t.String(), docs: t.Optional(t.String()) })), pendingCurlDownload: t.Optional(t.Object({ version: t.String(), url: t.String(), sha256: t.String(), archKey: t.String() })) }); const STATUS_SCHEMA = t.Object({ tool: TOOL_UNION, installed: t.Boolean(), version: t.Union([t.String(), t.Null()]), source: t.Union([t.String(), t.Null()]), requiredVersion: t.Optional(t.Union([t.String(), t.Null()])), needsUpdate: t.Optional(t.Boolean()) }); const ACTIVE_SESSION_SCHEMA = t.Union([ t.Null(), t.Object({ sessionId: t.String(), tool: TOOL_UNION, status: t.Union([ t.Literal('running'), t.Literal('success'), t.Literal('failed'), t.Literal('cancelled') ]), exitCode: t.Union([t.Number(), t.Null()]), startedAt: t.Number(), endedAt: t.Union([t.Number(), t.Null()]), totalLines: t.Number(), recentLines: t.Array(t.String()), displayCommand: t.String() }) ]); function toRecipeDTO(tool: ToolId, recipe: Awaited>) { return { tool, autoInstallable: recipe.autoInstallable, unavailableReason: recipe.unavailableReason, displayCommand: recipe.displayCommand, missingPrereqs: recipe.missingPrereqs, manualInstructions: recipe.manualInstructions, pendingCurlDownload: recipe.pendingCurlDownload }; } export const stackStatusHandler = createRouter() .http('stack:check-update', { data: t.Object({ tool: TOOL_UNION, installedVersion: t.Union([t.String(), t.Null()]) }), response: t.Object({ latestVersion: t.Union([t.String(), t.Null()]), hasUpdate: t.Union([t.Boolean(), t.Null()]) }) }, async ({ data }) => { debug.log('path', `stack:check-update for ${data.tool}`); return checkToolUpdate(data.tool, data.installedVersion); }) .http('stack:status', { data: t.Object({ tool: TOOL_UNION }), response: t.Object({ status: STATUS_SCHEMA, recipe: RECIPE_SCHEMA, activeSession: ACTIVE_SESSION_SCHEMA }) }, async ({ data }) => { debug.log('path', `stack:status for ${data.tool}`); const [status, recipe] = await Promise.all([ getToolStatus(data.tool), resolveRecipe(data.tool) ]); return { status, recipe: toRecipeDTO(data.tool, recipe), activeSession: getActiveSessionForTool(data.tool) }; }) .http('stack:status-all', { data: t.Object({}), response: t.Object({ tools: t.Array(t.Object({ status: STATUS_SCHEMA, recipe: RECIPE_SCHEMA, activeSession: ACTIVE_SESSION_SCHEMA })) }) }, async () => { debug.log('path', 'stack:status-all'); const ids = ['git', 'claude', 'opencode', 'copilot', 'codex', 'qwen', 'pi', 'cline', 'cursor', 'chrome'] as const; const tools = await Promise.all(ids.map(async (id) => { const [status, recipe] = await Promise.all([ getToolStatus(id), resolveRecipe(id) ]); return { status, recipe: toRecipeDTO(id, recipe), activeSession: getActiveSessionForTool(id) }; })); return { tools }; });