P4.2: HTML index at / — the install-page seed

GET / now serves a dense server-rendered console: each app with its
channels' current releases and every release's artifacts as direct
/download/<sha256> links. Read-only off the per-request db.json reload;
text escaped for HTML. A browser hitting the server root sees the store
instead of a 404.
This commit is contained in:
agra
2026-06-12 07:26:01 +03:00
parent 886b48630b
commit cf39589798
3 changed files with 129 additions and 1 deletions

View File

@@ -45,7 +45,7 @@ emit_human :: (s: string, json_mode: bool) {
if json_mode { eputs(s); } else { out(s); }
}
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store read-only over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n routes: /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store read-only over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n routes: / (HTML index), /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
// True if `name` appears as a token in `args`.
has_flag :: (args: []string, name: string) -> bool {

View File

@@ -5,6 +5,7 @@
// Serves the state the CLI publishes — db.json metadata and the
// content-addressed objects — over HTTP (src/server/http.sx):
//
// GET / HTML index: apps, channels, releases, links
// GET /healthz {"status":"ok"} — no store access
// GET /api/apps {"apps":[...]} every app in the store
// GET /api/apps/<slug> {"app":..,"releases":[..],"channels":[..]}
@@ -180,6 +181,103 @@ respond_render :: (client: s32, buf: string, n: s64, overflowed: bool) {
http.respond(client, 200, "application/json", "", string.{ ptr = buf.ptr, len = n });
}
// ── HTML index (the install-page seed, subplan 04 Slice 5) ───────────
// Escape &, <, >, " for HTML text/attribute positions. Returns `s`
// unchanged (no copy) when nothing needs escaping.
html_escape :: (s: string) -> string {
acc := "";
start := 0;
i := 0;
while i < s.len {
c := s[i];
rep := "";
if c == 38 { rep = "&amp;"; } // &
if c == 60 { rep = "&lt;"; } // <
if c == 62 { rep = "&gt;"; } // >
if c == 34 { rep = "&quot;"; } // "
if rep.len > 0 {
if i > start { acc = concat(acc, string.{ ptr = @s[start], len = i - start }); }
acc = concat(acc, rep);
start = i + 1;
}
i += 1;
}
if start == 0 { return s; }
if start < s.len { acc = concat(acc, string.{ ptr = @s[start], len = s.len - start }); }
return acc;
}
// First 12 hex chars of a digest, for display next to the full-link.
short_sha :: (sha: string) -> string {
if sha.len < 12 { return sha; }
return string.{ ptr = sha.ptr, len = 12 };
}
INDEX_HEAD :: "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>dist</title><style>body{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#101216;color:#d6dae1;margin:2rem auto;max-width:60rem;padding:0 1rem}h1{font-size:1.1rem;letter-spacing:.04em}h2{font-size:1rem;margin-top:2rem;border-bottom:1px solid #2a2f3a;padding-bottom:.3rem}table{border-collapse:collapse;width:100%;font-size:.85rem}td,th{text-align:left;padding:.3rem .8rem .3rem 0;border-bottom:1px solid #1d212a;vertical-align:top}th{color:#8b93a3;font-weight:normal}a{color:#7ab7ff;text-decoration:none}a:hover{text-decoration:underline}small,.dim{color:#737b8c}</style></head><body><h1>dist — release console</h1>";
INDEX_FOOT :: "<p class=\"dim\"><small>API: <a href=\"/api/apps\">/api/apps</a> &middot; <a href=\"/healthz\">/healthz</a></small></p></body></html>";
// Server-rendered index: every app with its channels and its releases'
// artifacts as direct /download links. Dense and read-only, per the
// product shape — the full admin UI is subplan 06.
render_index :: (repo: *Repo) -> string {
page := INDEX_HEAD;
if repo.apps.len == 0 {
page = concat(page, "<p class=\"dim\">nothing published yet</p>");
}
a := 0;
while a < repo.apps.len {
app := repo.apps.items[a];
page = concat(page, concat("<h2>", concat(html_escape(app.slug), concat(" <small>", concat(html_escape(app.display_name), "</small></h2>")))));
// Channels: name -> current release pointer.
page = concat(page, "<table><tr><th>channel</th><th>current release</th></tr>");
c := 0;
while c < repo.channels.len {
ch := repo.channels.items[c];
if ch.app_id == app.id {
page = concat(page, concat("<tr><td>", concat(html_escape(ch.name), concat("</td><td>", concat(html_escape(ch.current_release_id), "</td></tr>")))));
}
c += 1;
}
page = concat(page, "</table>");
// Releases with their artifacts as download links.
page = concat(page, "<table><tr><th>release</th><th>platform</th><th>artifact</th><th>size</th><th>sha256</th></tr>");
r := 0;
while r < repo.releases.len {
rel := repo.releases.items[r];
if rel.app_id == app.id {
k := 0;
while k < repo.artifacts.len {
art := repo.artifacts.items[k];
if art.release_id == rel.id {
row := concat("<tr><td>", concat(html_escape(rel.version), concat(" <small>", concat(html_escape(rel.id), "</small></td><td>"))));
row = concat(row, concat(db.platform_str(art.platform), "</td><td>"));
row = concat(row, concat("<a href=\"/download/", concat(art.sha256, concat("\">", concat(html_escape(art.filename), "</a></td><td>")))));
row = concat(row, concat(int_to_string(art.size_bytes), "</td><td><small>"));
row = concat(row, concat(short_sha(art.sha256), "&hellip;</small></td></tr>"));
page = concat(page, row);
}
k += 1;
}
}
r += 1;
}
page = concat(page, "</table>");
a += 1;
}
return concat(page, INDEX_FOOT);
}
handle_index :: (client: s32, store_dir: string) {
rq := load_or_503(client, store_dir);
if rq == null { return; }
repo := rq!;
http.respond(client, 200, "text/html; charset=utf-8", "", render_index(@repo));
}
// ── routes ────────────────────────────────────────────────────────────
handle_apps_index :: (client: s32, store_dir: string) {
@@ -235,6 +333,10 @@ route :: (client: s32, store_dir: string, method: string, path: string) -> s64 {
"every distd route is GET for now (writes go through the dist CLI)");
return 405;
}
if path == "/" {
handle_index(client, store_dir);
return 200;
}
if path == "/healthz" {
http.respond(client, 200, "application/json", "", "{\"status\":\"ok\"}");
return 200;

View File

@@ -5,6 +5,8 @@
// `build/dist server run` on a test port in the background, waits for
// /healthz, and asserts every route against curl:
//
// * / → HTML index naming the app, with a
// /download/<sha> link
// * /healthz → {"status":"ok"}
// * /api/apps → the published app is listed
// * /api/apps/<slug> → app + its releases + channels
@@ -48,6 +50,24 @@ write_file :: (path: string, body: string) {
process.run(cmd);
}
// True iff `needle` occurs in `hay` (plain scan; bodies are small).
contains :: (hay: string, needle: string) -> bool {
if needle.len == 0 { return true; }
if needle.len > hay.len { return false; }
i := 0;
while i + needle.len <= hay.len {
j := 0;
ok := true;
while j < needle.len {
if hay[i + j] != needle[j] { ok = false; break; }
j += 1;
}
if ok { return true; }
i += 1;
}
return false;
}
// GET `path` and return the body (curl; 2s timeout so a dead server fails
// the test instead of hanging it).
fetch :: (path: string) -> string {
@@ -113,6 +133,12 @@ main :: () -> s32 {
print(" server up\n");
// ── routes ────────────────────────────────────────────────────────
process.assert(fetch_code("/") == "200", "/ serves the HTML index");
idx := fetch("/");
process.assert(contains(idx, "<title>dist</title>"), "index is the dist HTML page");
process.assert(contains(idx, "acme-app"), "index names the published app");
process.assert(contains(idx, "/download/"), "index links artifact downloads");
hz := parse_body(fetch("/healthz"), "/healthz", xx arena);
process.assert(get_str(hz, "status") == "ok", "/healthz status ok");