Server Creator API
Assemble ReHLDS, ReGameDLL, Metamod, AMX Mod X, non-Steam auth, anti-cheat and bots into a single archive — validated before a byte is transferred.
Overview #
Offer "create a CS 1.6 server" in your panel without scraping anything. You choose components, we mirror each one from its official release channel, validate that the combination can actually run, generate the config files that must reference the exact stack you picked, and stream you one archive.
| GET | /v1/server-builder/meta | Catalog: components, layers, versions, rules |
| GET | /v1/server-builder/components/{slug} | One component + full per-version file manifest |
| GET | /v1/server-builder/resolve | Validate a stack, preview every file — no download |
| GET | /v1/server-builder/file | Read one file out of a build before downloading |
| GET | /v1/server-builder/download | Stream the finished archive (ZIP) |
Unzip it over the folder that contains your vanilla Steam cstrike
directory — not inside it. ReHLDS ships engine files (hlds_linux, engine_i486.so) that
live next to cstrike/. Extracting one level too deep produces a server that will not boot.
Every path in resolve is relative to that root, which makes it easy to verify.
Builds are Linux-only; Windows binaries are stripped from every mirror.
Layers, versions and rules #
Three concepts explain every validation error you can get.
Layers
Every component sits in a layer. Five layers are exclusive — you cannot run two engines or two Metamods — and the rest accept any number of components.
| Layer | Exclusive | What it is |
|---|---|---|
engine | yes | The HLDS engine itself (ReHLDS) |
gamedll | yes | The Counter-Strike game library (ReGameDLL_CS) |
metamod | yes | The plugin loader everything else hangs off |
amxmodx | yes | The AMX Mod X line (1.10 or 1.9) |
auth | yes | Non-Steam auth provider (Reunion, dProto) |
module | no | AMX Mod X modules (ReAPI, CS module pack) |
anticheat | no | ReAuthCheck, ReChecker, ReSemiclip |
voice | no | Revoice, VoiceTranscoder |
bots | no | YaPB, PODBot mm |
csb | no | Our own extras (auto-listing) |
content | no | Map packs and other content |
Selecting components and versions
components is a comma-separated list of slugs. Pin a version with slug@version;
leave it off for that component's latest mirrored version.
components=rehlds,regamedll,metamod-r,amxmodx # all latest
[email protected],metamod-r,amxmodx # engine pinned
components= # omit entirely -> our recommended stackBecause we mirror from each project's official release channel, "latest" really is upstream's latest — not a snapshot we took months ago. Duplicated slugs are collapsed silently; order does not matter.
Rules
Components declare requires, conflicts and recommends relationships
against each other; they ship in meta and in each component's detail so you can grey out
impossible choices in your UI instead of discovering them at build time.
A requires pointing at a component in an exclusive layer is satisfied by any
component of that layer. "AMX Mod X requires metamod-r" really means "requires a Metamod", so a
user who picked Metamod-P is not told to install a second one. recommends is advisory and never
blocks a build.
Catalog #
/v1/server-builder/meta
server:read
auth optional
Everything you need to render a component picker: each component with its layer, description, license, homepage, mirrored versions and rules — plus the layer definitions themselves. Components with no mirrored version are omitted, so anything you see here is buildable right now.
Parameters
None. This endpoint takes no parameters.
Response fields
| Field | Type | Notes |
|---|---|---|
components[].is_default |
bool | Part of our recommended stack. Pre-tick these in your UI — they are exactly what you get when components is omitted. |
components[].selectable |
bool | false means this component is never a choice: it is pulled into the build automatically by a bundles rule on something else. Do not render a checkbox for it — the AMX Mod X Counter-Strike module pack arrives this way with whichever AMX Mod X line is picked. |
components[].sort_order |
integer | Our display order (ascending). Also the load order used inside the build. |
components[].rules |
object[] | null | null when the component has no rules. Each entry is { rule, component, note } where rule is requires, conflicts, recommends or bundles. A bundles entry is informational: selecting the subject silently adds the other component, so you neither have to request it nor offer it. note is human-readable — show it as the reason a checkbox is disabled. |
components[].versions |
object[] | Mirrored versions, newest first. is_latest marks the one you get when you do not pin. size_bytes and file_count are the mirrored archive's, not the final build's. |
layers |
object[] | Layer key, display label, and whether it is exclusive. Drive your radio-vs-checkbox choice from exclusive. |
A component that exists in our catalog but has never mirrored successfully is
simply absent here, yet still known to resolve — which is why asking for it by name
returns unavailable rather than unknown.
curl "https://api.counter-strike-boost.com/v1/server-builder/meta"const res = await fetch(
"https://api.counter-strike-boost.com/v1/server-builder/meta"
);
const data = await res.json();
if (!data.status) {
throw new Error(data.error); // stable machine-readable code
}<?php
$body = file_get_contents(
"https://api.counter-strike-boost.com/v1/server-builder/meta"
);
$data = json_decode($body, true);
if (empty($data['status'])) {
throw new RuntimeException($data['error'] ?? 'request_failed');
}import requests
r = requests.get(
"https://api.counter-strike-boost.com/v1/server-builder/meta",
timeout=30,
)
r.raise_for_status()
data = r.json()components[] trimmed to two of twelve entries
{
"archive_root": "HLDS root (the folder containing cstrike/)",
"components": [
{
"description": "Reverse-engineered, bug-fixed and hardened HLDS engine. Drop-in replacement for the stock Valve engine — faster, far fewer crashes, and the base every modern CS 1.6 stack is built on.",
"detail_url": "https://api.counter-strike-boost.com/v1/server-builder/components/rehlds",
"homepage": "https://github.com/rehlds/ReHLDS",
"is_default": true,
"layer": "engine",
"license": "GPL-3.0",
"name": "ReHLDS",
"rules": null,
"selectable": true,
"slug": "rehlds",
"sort_order": 10,
"versions": [
{
"file_count": 8,
"is_latest": true,
"released_at": "2026-05-18 13:36:52",
"size_bytes": 3112930,
"version": "3.15.0.896"
}
]
},
{
"description": "AMX Mod X module exposing the ReHLDS and ReGameDLL APIs to plugins.",
"detail_url": "https://api.counter-strike-boost.com/v1/server-builder/components/reapi",
"homepage": "https://github.com/rehlds/ReAPI",
"is_default": true,
"layer": "module",
"license": "GPL-3.0",
"name": "ReAPI",
"rules": [
{ "component": "rehlds", "note": "ReAPI exposes the ReHLDS API — it will not load on a stock engine.", "rule": "requires" },
{ "component": "regamedll", "note": "ReAPI exposes the ReGameDLL API.", "rule": "requires" },
{ "component": "amxmodx", "note": "ReAPI is an AMX Mod X module.", "rule": "requires" }
],
"selectable": true,
"slug": "reapi",
"sort_order": 60,
"versions": [
{
"file_count": 1,
"is_latest": true,
"released_at": "2026-05-18 13:52:56",
"size_bytes": 403612,
"version": "5.29.0.358"
}
]
}
],
"docs": "https://counter-strike-boost.com/developers",
"layers": [
{ "exclusive": true, "key": "engine", "label": "Engine" },
{ "exclusive": true, "key": "gamedll", "label": "Game library" },
{ "exclusive": false, "key": "module", "label": "Modules" }
],
"page_url": "https://counter-strike-boost.com/addons/server-builder",
"platform": "linux",
"status": true
}Component detail #
/v1/server-builder/components/{slug}
server:read
auth optional
The same component record as in the catalog, but with the complete file manifest for every mirrored version — exact paths, byte sizes and the executable bit. Use it to show what a component actually installs, or to diff two versions.
Parameters
slug
string
path
required
Component slug, e.g. reapi.
Response fields
| Field | Type | Notes |
|---|---|---|
versions[].files[].path |
string | Path relative to the HLDS root — already remapped to where the file belongs in a real server, not where upstream's archive happened to put it. |
versions[].files[].executable |
bool | The file must be chmod +x after extraction. We set it in the archive even when upstream forgot to. |
versions[].size_bytes |
integer | Size of the mirrored upstream archive. Sum the files[].size values for the extracted footprint. |
Manifests can be large — the AMX Mod X base is over 900 files. Request this per component on demand rather than eagerly for the whole catalog.
curl "https://api.counter-strike-boost.com/v1/server-builder/components/reapi"const res = await fetch(
"https://api.counter-strike-boost.com/v1/server-builder/components/reapi"
);
const data = await res.json();
if (!data.status) {
throw new Error(data.error); // stable machine-readable code
}<?php
$body = file_get_contents(
"https://api.counter-strike-boost.com/v1/server-builder/components/reapi"
);
$data = json_decode($body, true);
if (empty($data['status'])) {
throw new RuntimeException($data['error'] ?? 'request_failed');
}import requests
r = requests.get(
"https://api.counter-strike-boost.com/v1/server-builder/components/reapi",
timeout=30,
)
r.raise_for_status()
data = r.json(){
"component": {
"description": "AMX Mod X module exposing the ReHLDS and ReGameDLL APIs to plugins. Required by most modern CS 1.6 plugins.",
"homepage": "https://github.com/rehlds/ReAPI",
"is_default": true,
"layer": "module",
"license": "GPL-3.0",
"name": "ReAPI",
"rules": [
{ "component": "rehlds", "note": "ReAPI exposes the ReHLDS API — it will not load on a stock engine.", "rule": "requires" },
{ "component": "regamedll", "note": "ReAPI exposes the ReGameDLL API.", "rule": "requires" },
{ "component": "amxmodx", "note": "ReAPI is an AMX Mod X module.", "rule": "requires" }
],
"slug": "reapi",
"versions": [
{
"file_count": 1,
"files": [
{
"executable": true,
"path": "cstrike/addons/amxmodx/modules/reapi_amxx_i386.so",
"size": 569620
}
],
"is_latest": true,
"released_at": "2026-05-18 13:52:56",
"size_bytes": 403612,
"version": "5.29.0.358"
}
]
},
"status": true
}{
"error": "not_found",
"status": false
}Resolve a stack (dry run) #
/v1/server-builder/resolve
server:read
auth optional
The single most useful endpoint here. Runs exactly the same validation and assembly as the download, then returns the resulting file list instead of the bytes — so you can show a user precisely what they are about to get, and catch an impossible stack before moving 100 MB. Call it on every selection change.
Parameters
components
string
default recommended stack
Comma-separated slugs, optionally slug@version. Omit entirely to get our recommended stack (every is_default component at latest).
plugins
string
optional
Comma-separated slugs from the plugin directory. Each is compiled and folded into the same archive, already registered in plugins.ini. An unknown slug is skipped with a note, not an error.
optimized
boolean
default false
Accepts 1, true, yes or on. Ships configs tuned by us — rates, round flow, rcon hardening, vote thresholds, and per-component cvars that only appear when that component is in the build. Leave it off and every config file is byte-for-byte as its project ships it.
Response fields
| Field | Type | Notes |
|---|---|---|
build_hash |
string | SHA-256 fingerprint of the whole selection — components, versions, plugins, optimize flag and the current config overlay. Identical inputs give an identical hash and a cache hit. Use it as your own cache key; when it changes, the archive really did change. |
download_url |
string | The exact download URL for this selection, built from your query string. Hand it to the user rather than assembling it yourself. |
file_count / total_bytes |
integer | Uncompressed totals. Show total_bytes before a download so nobody is surprised by 100 MB. |
files[].from |
string | Which producer emitted the file: a component slug, csb, csb-optimized, or plugin:{slug}. |
files[].origin |
enum | upstream — untouched, exactly as the project ships it. generated — we wrote it (it must reference your exact stack). edited — an upstream config we replaced because optimized=1. plugin — came from a directory plugin. Great material for a "what did you change?" view. |
notes |
string[] | null | Non-fatal remarks, e.g. "plugin not found, skipped: foo". null when there is nothing to say. Check it — a typo'd plugin slug reports here, not as an error. |
components |
object[] | What actually got selected, with resolved versions and licenses. Render this as the attribution/licence list for the build. |
Entries come out of an unordered map, so the order differs between two identical calls. Sort by
path before displaying or diffing.
Only two files are always generated rather than copied: liblist.gam and
cstrike/addons/metamod/plugins.ini. Both must name the exact components you chose, so they cannot
come from upstream. With optimized=1 a handful of config files additionally show up as
edited.
curl "https://api.counter-strike-boost.com/v1/server-builder/resolve?components=rehlds,regamedll,metamod-r,amxmodx&optimized=1"const res = await fetch(
"https://api.counter-strike-boost.com/v1/server-builder/resolve?components=rehlds,regamedll,metamod-r,amxmodx&optimized=1"
);
const data = await res.json();
if (!data.status) {
throw new Error(data.error); // stable machine-readable code
}<?php
$body = file_get_contents(
"https://api.counter-strike-boost.com/v1/server-builder/resolve?components=rehlds,regamedll,metamod-r,amxmodx&optimized=1"
);
$data = json_decode($body, true);
if (empty($data['status'])) {
throw new RuntimeException($data['error'] ?? 'request_failed');
}import requests
r = requests.get(
"https://api.counter-strike-boost.com/v1/server-builder/resolve?components=rehlds,regamedll,metamod-r,amxmodx&optimized=1",
timeout=30,
)
r.raise_for_status()
data = r.json()files[] trimmed to two of 1021 entries
{
"build_hash": "91a44f56e411ef19a20d94a4ceadcd3bb66e8aac30e053d406496df505ea65fe",
"components": [
{ "homepage": "https://github.com/rehlds/ReHLDS", "layer": "engine", "license": "GPL-3.0", "name": "ReHLDS", "slug": "rehlds", "version": "3.15.0.896" },
{ "homepage": "https://github.com/rehlds/ReGameDLL_CS", "layer": "gamedll", "license": "GPL-3.0", "name": "ReGameDLL_CS", "slug": "regamedll", "version": "5.30.0.814" },
{ "homepage": "https://github.com/rehlds/Metamod-R", "layer": "metamod", "license": "GPL-3.0", "name": "Metamod-R", "slug": "metamod-r", "version": "1.3.0.149" },
{ "homepage": "https://www.amxmodx.org", "layer": "amxmodx", "license": "GPL-3.0", "name": "AMX Mod X 1.10", "slug": "amxmodx", "version": "1.10.0-git5479" }
],
"download_url": "https://api.counter-strike-boost.com/v1/server-builder/download?components=rehlds,regamedll,metamod-r,amxmodx&optimized=1",
"file_count": 1021,
"files": [
{
"executable": false,
"from": "amxmodx",
"origin": "upstream",
"path": "cstrike/addons/amxmodx/data/gamedata/common.games/entities.games/cstrike/offsets-cdeadhev.txt",
"size": 821
},
{
"executable": false,
"from": "csb",
"origin": "generated",
"path": "cstrike/addons/metamod/plugins.ini",
"size": 334
}
],
"notes": null,
"optimized": true,
"status": true,
"total_bytes": 20478882
}Preview one file #
/v1/server-builder/file
server:read
auth optional
Read a single text file out of a build without downloading the archive. Built for
"show me what server.cfg will look like" — especially useful for showing what
optimized=1 actually changed.
Parameters
path
string
required
Exact path as it appears in resolve's files[].path, e.g. cstrike/addons/metamod/plugins.ini.
components
string
default recommended stack
Same as resolve. The file is resolved against this selection, so pass the same one.
plugins
string
optional
Same as resolve.
optimized
boolean
default false
Same as resolve. Flipping it is exactly how you diff stock against tuned.
Response fields
| Field | Type | Notes |
|---|---|---|
binary |
bool | true when the file is not displayable text (a .so, a bot graph). content is then an empty string — do not try to render it. |
truncated |
bool | true when the file exceeded 512 KB and content holds only the first 512 KB. size is still the real size. |
origin |
enum | Same vocabulary as resolve. generated and edited are the interesting ones — those are the files we are responsible for. |
Errors
| Status | error | When |
|---|---|---|
| 400 | missing_path |
The path parameter was absent or empty. |
| 404 | not_found |
That path is not in this selection's build. Note the wording: the same path may well exist for a different components set. |
| 422 | invalid_selection |
The selection itself is impossible — see selection errors. |
path is not a filesystem lookup. It is matched against the assembled
entry map for your selection, so the only readable files are ones that selection legitimately produces —
a traversal attempt is simply not a key in that map.
curl "https://api.counter-strike-boost.com/v1/server-builder/file?path=cstrike/addons/metamod/plugins.ini&components=rehlds,regamedll,metamod-r,amxmodx&optimized=1"const res = await fetch(
"https://api.counter-strike-boost.com/v1/server-builder/file?path=cstrike/addons/metamod/plugins.ini&components=rehlds,regamedll,metamod-r,amxmodx&optimized=1"
);
const data = await res.json();
if (!data.status) {
throw new Error(data.error); // stable machine-readable code
}<?php
$body = file_get_contents(
"https://api.counter-strike-boost.com/v1/server-builder/file?path=cstrike/addons/metamod/plugins.ini&components=rehlds,regamedll,metamod-r,amxmodx&optimized=1"
);
$data = json_decode($body, true);
if (empty($data['status'])) {
throw new RuntimeException($data['error'] ?? 'request_failed');
}import requests
r = requests.get(
"https://api.counter-strike-boost.com/v1/server-builder/file?path=cstrike/addons/metamod/plugins.ini&components=rehlds,regamedll,metamod-r,amxmodx&optimized=1",
timeout=30,
)
r.raise_for_status()
data = r.json(){
"file": {
"binary": false,
"content": "; Metamod plugin list — generated by the CSB Server Creator\r\n; https://counter-strike-boost.com/addons/server-builder\r\n;\r\n; Load order matters: the auth provider runs before AMX Mod X so that\r\n; every client already has its final SteamID when admins are looked up.\r\n\r\n; AMX Mod X 1.10\r\nlinux addons/amxmodx/dlls/amxmodx_mm_i386.so\r\n",
"executable": false,
"from": "csb",
"origin": "generated",
"path": "cstrike/addons/metamod/plugins.ini",
"size": 334,
"truncated": false
},
"status": true
}{
"error": "missing_path",
"status": false
}Download the archive #
/v1/server-builder/download
server:read
auth optional
Streams the finished server as a ZIP. Same parameters and same validation as
resolve — if resolve returned 200,
this returns bytes.
Parameters
components
string
default recommended stack
Identical to resolve.
plugins
string
optional
Identical to resolve.
optimized
boolean
default false
Identical to resolve.
Errors
| Status | error | When |
|---|---|---|
| 422 | invalid_selection |
Impossible stack — see below. Nothing is transferred. |
| 422 | empty_selection |
Every requested component resolved to nothing. |
| 429 | rate_limited |
Hourly build budget exhausted. retry_after is 600. |
| 500 | build_failed |
Assembly failed on our side. Transient — retry once, then report it. |
| 503 | builder_unavailable |
The build cache volume is not configured on this instance. Operator problem, not yours. |
The filename carries the build hash — cs16-server-{first 12 of build_hash}.zip — and
the full hash is in X-CSB-Build-Hash. Compare it against the build_hash from
resolve to confirm you got the build you previewed.
Be patient on a cold build. Identical selections are served from a cache and come back
immediately; a first-time selection assembles hundreds of files and may run the compiler, so allow up to
10 minutes before timing out. Accept-Ranges: bytes means an interrupted download can be resumed
rather than restarted.
Editing a config in our admin panel changes build_hash, which invalidates the cached
archive — you can never be served a build that embeds a config we have since changed.
# -OJ writes the file under the name the server sends
curl -OJ "https://api.counter-strike-boost.com/v1/server-builder/download?components=rehlds,regamedll,metamod-r,amxmodx&optimized=1"const res = await fetch(
"https://api.counter-strike-boost.com/v1/server-builder/download?components=rehlds,regamedll,metamod-r,amxmodx&optimized=1"
);
if (!res.ok) {
const err = await res.json(); // { status: false, error: "..." }
throw new Error(err.error);
}
await writeFile("cs16-server.zip", Buffer.from(await res.arrayBuffer()));<?php
$ch = curl_init("https://api.counter-strike-boost.com/v1/server-builder/download?components=rehlds,regamedll,metamod-r,amxmodx&optimized=1");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
$err = json_decode($body, true);
throw new RuntimeException($err['error'] ?? 'request_failed');
}
file_put_contents("cs16-server.zip", $body);import requests
r = requests.get(
"https://api.counter-strike-boost.com/v1/server-builder/download?components=rehlds,regamedll,metamod-r,amxmodx&optimized=1",
timeout=600, # a cold server build can take minutes
)
r.raise_for_status()
with open("cs16-server.zip", "wb") as f:
f.write(r.content)The response body is the ZIP. Real headers from a build:
content-type | application/zip |
content-disposition | attachment; filename="cs16-server-0aeb1ead3157.zip" |
content-length | 1340093 |
cache-control | public, max-age=3600 |
x-csb-build-hash | 0aeb1ead3157492ff13ec5b65f66b79268af5fdbc416f569ffb881de2d4343d8 |
x-content-type-options | nosniff |
accept-ranges | bytes |
Selection errors #
An impossible stack returns 422 invalid_selection with a machine-readable body, so
your UI can point at the exact component that is wrong instead of just failing. Up to five arrays can be
present; only the non-empty ones are included, and docs always is.
| Field | Meaning |
|---|---|
unknown | string[] — no such component slug. The caller's typo. Never retry. |
unavailable | {component, name, reason}[] — a real component we have not mirrored yet, or a pinned version that is not mirrored. Our catalog, not your bug — worth retrying later. |
conflicts | {component, with, reason}[] — two components that cannot coexist. reason is written for end users; show it verbatim. |
missing_requires | {component, requires, reason}[] — a dependency was not selected. Offer to add requires and rebuild. |
layer_conflicts | {layer, components}[] — two components in the same exclusive layer. components is a comma-separated string, not an array. |
The distinction between unknown and unavailable is deliberate: one is a typo, the other is
our mirror catching up, and only the second is worth trying again tomorrow.
GET /v1/server-builder/resolve?components=quake{
"docs": "https://counter-strike-boost.com/developers",
"error": "invalid_selection",
"status": false,
"unknown": ["quake"]
}GET /v1/server-builder/resolve?components=rehlds,dproto{
"docs": "https://counter-strike-boost.com/developers",
"error": "invalid_selection",
"status": false,
"unavailable": [
{
"component": "dproto",
"name": "dProto",
"reason": "no mirrored version available yet"
}
]
}GET /v1/server-builder/resolve?components=rehlds,metamod-r,amxmodx,amxmodx-19{
"conflicts": [
{
"component": "amxmodx",
"reason": "Pick one AMX Mod X line.",
"with": "amxmodx-19"
}
],
"docs": "https://counter-strike-boost.com/developers",
"error": "invalid_selection",
"layer_conflicts": [
{
"components": "amxmodx, amxmodx-19",
"layer": "amxmodx"
}
],
"status": false
}GET /v1/server-builder/resolve?components=reapi,metamod-r{
"docs": "https://counter-strike-boost.com/developers",
"error": "invalid_selection",
"missing_requires": [
{ "component": "reapi", "requires": "rehlds", "reason": "ReAPI exposes the ReHLDS API — it will not load on a stock engine." },
{ "component": "reapi", "requires": "regamedll", "reason": "ReAPI exposes the ReGameDLL API." },
{ "component": "reapi", "requires": "amxmodx", "reason": "ReAPI is an AMX Mod X module." }
],
"status": false
}Recipe: build a server from a panel #
Validate, show the user the cost, then download. Never skip the resolve step —
it is free, it is the same validation, and it turns a failed 100 MB download into an actionable
message.
API=https://api.counter-strike-boost.com
STACK="rehlds,regamedll,metamod-r,amxmodx,reapi,reunion"
# 1. what can the user pick?
curl -s "$API/v1/server-builder/meta" | jq '.components[] | {slug, layer, is_default}'
# 2. dry run — validates, and prints the size before anything is transferred
curl -s "$API/v1/server-builder/resolve?components=$STACK&optimized=1" \
| jq '{build_hash, file_count, total_bytes, notes}'
# 3. build it (omit ?components= entirely for our recommended stack)
curl -OJ "$API/v1/server-builder/download?components=$STACK&optimized=1"
# 4. unzip over the folder CONTAINING cstrike/ — not inside it
unzip -o cs16-server-*.zip -d /opt/hlds/const API = 'https://api.counter-strike-boost.com';
const qs = new URLSearchParams({
components: 'rehlds,regamedll,metamod-r,amxmodx,reapi,reunion',
optimized: '1',
});
// 1. Dry run first — same validation as the download, zero bytes transferred.
const res = await fetch(`${API}/v1/server-builder/resolve?${qs}`);
const body = await res.json();
if (!res.ok) {
// 422 invalid_selection: point at the exact component that is wrong.
for (const c of body.conflicts ?? []) ui.error(`${c.component} ✕ ${c.with}: ${c.reason}`);
for (const m of body.missing_requires ?? []) ui.error(`${m.component} needs ${m.requires}: ${m.reason}`);
for (const l of body.layer_conflicts ?? []) ui.error(`only one ${l.layer}: ${l.components}`);
for (const u of body.unknown ?? []) ui.error(`no such component: ${u}`);
for (const u of body.unavailable ?? []) ui.warn (`${u.name}: ${u.reason}`);
return;
}
// Non-fatal remarks live here — a typo'd plugin slug shows up as a note.
(body.notes ?? []).forEach(n => ui.warn(n));
ui.confirm(`${body.file_count} files, ${(body.total_bytes / 1e6).toFixed(1)} MB`);
// 2. Same selection -> same build_hash -> cache hit. Cold builds take a while.
const zip = await fetch(body.download_url, { signal: AbortSignal.timeout(600_000) });
if (zip.headers.get('x-csb-build-hash') !== body.build_hash) {
throw new Error('build changed between preview and download');
}
await writeFile('cs16-server.zip', Buffer.from(await zip.arrayBuffer()));Cache by build_hash. If the hash you get from resolve matches
one you already downloaded, you can skip the download entirely — the archive is byte-identical.









