steam-backlog-enforcer/web/mock/http.ts
Krzysztof kuhy Rudnicki 1bdf360193 pick keeps already-picked games; fast install reports progress
pick and pick-manual no longer uninstall games that are already allowed:
the outgoing assignment is kept as a released pick (installed, no lock)
until 'pick --replace', done/check, or abandon-pick. The allowed set is
capped at max_manual_picks + 1; a pick over it is refused before the
candidate list / confirmation, naming the games. status and the web
Picks card list kept games with an Abandon button.

Fast install now tracks the installer log from the spawn offset: web
jobs drive the progress bar (per-depot %, phase) and echo a line every
30 s; daemon-started runs are exposed via ctl status / /api/daemon and
shown on the Dashboard.

Also: web deps bumped for dependency freshness (react-query 5.104.5,
typescript-eslint 8.72.0, vite 8.3.5, node 24.22.1 in CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUaX4zAa6H4sMu3jd6RFdb
2026-10-23 20:14:12 +02:00

82 lines
3.3 KiB
TypeScript

// Small HTTP helpers shared by the mock route handlers.
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { CommandSpec, ParamSpec } from '../src/api/contract.ts'
import type { ApiErrorCode } from '../src/api/jobContract.ts'
import { nameOf } from './games.ts'
import { gameName, world } from './world.ts'
export class HttpError extends Error {
readonly status: number
readonly code: ApiErrorCode
constructor(status: number, code: ApiErrorCode, message: string) {
super(message)
this.status = status
this.code = code
}
}
export function send(res: ServerResponse, status: number, body?: unknown): void {
res.statusCode = status
if (body === undefined) {
res.end()
return
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(body))
}
export async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
const text = Buffer.concat(chunks).toString('utf8')
if (!text) return {}
try {
return JSON.parse(text) as Record<string, unknown>
} catch {
throw new HttpError(400, 'invalid_params', 'Body is not valid JSON.')
}
}
/** Mirror of `_friction.phrase_matches`: only surrounding whitespace is forgiven. */
export function phraseMatches(expected: string, typed: unknown): boolean {
return typeof typed === 'string' && typed.trim() === expected
}
export function fillPhrase(template: string, params: Record<string, number | string>, extra: Record<string, string> = {}): string {
const fields: Record<string, string> = {
count: String(world.installed.filter((g) => !g.assigned && !g.protected).length),
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
...extra,
}
if (params.app_id !== undefined) fields.game_name = gameName(Number(params.app_id))
return template.replace(/\{(\w+)\}/g, (_, key: string) => fields[key] ?? `{${key}}`)
}
function checkParam(p: ParamSpec, raw: unknown): number | string | undefined {
if (raw === undefined || raw === '') {
if (p.required && p.default === undefined) throw new HttpError(400, 'invalid_params', `${p.label} is required.`)
return p.default
}
if (p.type === 'string') return String(raw)
const n = Number(raw)
if (p.type === 'bool' && n !== 0 && n !== 1) throw new HttpError(400, 'invalid_params', `${p.label} must be true or false.`)
if (!Number.isInteger(n)) throw new HttpError(400, 'invalid_params', `${p.label} must be a whole number.`)
if (p.min !== undefined && n < p.min) throw new HttpError(400, 'invalid_params', `${p.label} must be at least ${p.min}.`)
if (p.max !== undefined && n > p.max) throw new HttpError(400, 'invalid_params', `${p.label} must be at most ${p.max}.`)
if (p.type === 'app_id' && nameOf(n) === null) {
throw new HttpError(400, 'invalid_params', `App ${n} is not in your library.`)
}
return n
}
export function validateParams(spec: CommandSpec, raw: unknown): Record<string, number | string> {
const input = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
const out: Record<string, number | string> = {}
for (const p of spec.params) {
const v = checkParam(p, input[p.name])
if (v !== undefined) out[p.name] = v
}
return out
}