Эх сурвалжийг харах

usm-web: USM repository browser + serving frontend (Statum + Spry)

clanker 1 долоо хоног өмнө
commit
a9e0c34357

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+builddir/
+*.sqlite*
+web-config.json
+*.image.tar.xz
+.usm-deploy/
+.kilo/

+ 16 - 0
.usmignore

@@ -0,0 +1,16 @@
+# Build trees at any depth (directory-only: the directory and all beneath it)
+builddir*/
+
+# Database artifacts by basename at any depth (none today, kept for safety)
+*.sqlite*
+db.sqlite
+
+# Static keys are never packaged (mount web-config.json at runtime instead)
+web-config.json
+
+# Local agent session state (directory-only)
+.kilo/
+
+# Deploy artifacts and USM deploy working state
+*.image.tar.xz
+.usm-deploy/

+ 58 - 0
MANIFEST.usm

@@ -0,0 +1,58 @@
+{
+  "name": "usm-web",
+  "version": "0.1",
+  "summary": "USM repository browser and serving frontend (Statum + Spry)",
+  "licences": [],
+  "flags": ["ninjaStyleProgress"],
+  "provides": {
+    "bin:usm-web": "as-expected"
+  },
+  "depends": {
+    "runtime": [
+      "lib:libglib-2.0.so.0",
+      "lib:libgobject-2.0.so.0",
+      "lib:libgio-2.0.so.0",
+      "lib:libjson-glib-1.0.so.0",
+      "lib:libxml2.so.2",
+      "lib:libsodium.so.26",
+      "lib:libarchive.so.13",
+      "lib:libmicrohttpd.so.12",
+      "lib:libinvercargill-1.so",
+      "lib:libinvercargill-json.so",
+      "lib:libinversion-0.1.so",
+      "lib:libastralis-0.1.so",
+      "lib:libstatum-0.1.so",
+      "lib:libusm-0.1.so",
+      "lib:libc.so.6"
+    ],
+    "build": [
+      "bin:valac",
+      "bin:meson",
+      "bin:ninja",
+      "bin:python3",
+      "bin:statum-mkpstm",
+      "bin:statum-mkres",
+      "bin:statum-genkeys",
+      "pc:glib-2.0.pc",
+      "pc:gobject-2.0.pc",
+      "pc:gio-2.0.pc",
+      "pc:json-glib-1.0.pc",
+      "pc:libxml-2.0.pc",
+      "pc:libarchive.pc",
+      "pc:invercargill-1.pc",
+      "pc:invercargill-json.pc",
+      "pc:inversion-0.1.pc",
+      "pc:astralis-0.1.pc",
+      "pc:statum-0.1.pc",
+      "pc:usm-0.1.pc",
+      "vapi:usm-0.1.vapi"
+    ],
+    "manage": [
+      "bin:bash"
+    ]
+  },
+  "execs": {
+    "build": "usm-scripts/build.sh",
+    "install": "usm-scripts/install.sh"
+  }
+}

+ 146 - 0
README.md

@@ -0,0 +1,146 @@
+# usm-web
+
+A Statum + Spry application that browses **and serves** a USM repository:
+human-facing pages for exploring packages, plus the exact HTTP endpoints a
+real USM client (`usm repository list` / `verify` / `install`) expects to
+fetch from a repository host.
+
+## What it serves
+
+| Endpoint | Purpose |
+|---|---|
+| `GET /` | Repo overview: name/summary, signing-key fingerprint, package count and total size, add-repository + install-USM instructions, package table with server-side search |
+| `GET /package/{file}` | Package detail: manifest fields read out of the `.usmc` (licences, flags, provides/depends tables), sha512, on-disk size/mtime, download button |
+| `GET /PACKAGES.usml` | The repository listing streamed **verbatim** (`application/json`) |
+| `GET /repo.usmr` | A rewritten copy of the `.usmr` with its embedded `url` replaced by the derived base |
+| `GET /<file>.usmc` | Package archives streamed from `<repo>/public/` (`application/octet-stream`, exact content-length) |
+| `GET /install-usm.sh` | The configured installer script (`installer.path` mode only) |
+
+The derived base is `base_url` from the config when set, else
+`X-Forwarded-Proto`/`X-Forwarded-Host`, else the request's own scheme/host —
+so clients behind a reverse proxy resolve against the public name.
+
+## Configuration
+
+`web-config.json` (in the app's working directory, or `ASTRALIS_CONFIG_PATH`)
+carries the static Statum keys plus an optional `"usm-web"` section:
+
+```json
+{
+  "statum": { "…": "static keys from spry keys / statum-genkeys" },
+  "usm-web": {
+    "installer": { "path": "install-usm.sh" },
+    "base_url": "https://repo.example.com/",
+    "name": "My repository"
+  }
+}
+```
+
+| Key | Meaning |
+|---|---|
+| `installer.url` | External installer location; the homepage links out and shows `curl -fsSL <url> \| sh` |
+| `installer.path` | Installer script this app serves at `/install-usm.sh`; relative paths resolve against the config file's directory |
+| `base_url` | Optional override of the request-derived repository base (normalised to a trailing slash) |
+| `name` | Optional display-name override for the `.usmr` name |
+
+`installer` takes **exactly one** of `url`/`path`; providing both (or neither,
+when the block should appear) is a configuration error. Omit the section
+entirely to hide the install-USM card.
+
+The repository root is selected by (in order) the `--repo <dir>` argument,
+the `USM_WEB_REPO_DIR` environment variable, or the `/repo` default, and is
+expected to look like a published repository:
+
+```
+/repo
+├── web-stack.usmr
+├── keys/            (not used by usm-web; signing is upstream)
+└── public/
+    ├── PACKAGES.usml
+    └── <name>-<version>.usmc
+```
+
+Both the `.usmr` and `PACKAGES.usml` are re-read when their mtimes change, so
+a rebuilt repository is picked up without a restart. There is **no database
+and no authentication**; all Statum state is stateless PAGE slots.
+
+## Development
+
+```bash
+spry dev
+```
+
+Builds, runs the app on port 8080 (`--port N` to change) and watches
+`src/`, `meson.build` and `web-config.json`: saving a file rebuilds
+incrementally — page HTML and resources recompile via the meson codegen
+targets — and restarts the app when the build succeeds.
+
+The environment below is required for the underlying meson builds (the
+`spry dev` child processes set it for you; export it in shells where you
+build or run manually):
+
+```bash
+export WS_PREFIX="$HOME/.local"
+export PKG_CONFIG_PATH="$WS_PREFIX/lib64/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
+export XDG_DATA_DIRS="$WS_PREFIX/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
+export LD_LIBRARY_PATH="$WS_PREFIX/lib64${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+
+meson setup builddir
+ninja -C builddir
+./builddir/usm-web 8080 --repo ../web-stack-repo
+```
+
+Repo parsing uses libusm (`Usm.Repository`, `Usm.RepositoryListing`,
+`Usm.Manifest.from_package`) — no hand-rolled usml/usmr mappers.
+
+## Layout
+
+| Path | Purpose |
+|---|---|
+| `src/main.vala` | application wiring: modules, config, pages, action, endpoints |
+| `src/UsmWebConfig.vala` | the `"usm-web"` config section (installer url\|path, base_url, name) |
+| `src/RepositoryService.vala` | libusm-backed read side with mtime-based reload |
+| `src/DerivedBase.vala` | base-URI derivation from config/forwarded headers |
+| `src/PackagesState.vala` | shared `packages` slot-state builder + size/fingerprint formatting |
+| `src/entrypoints/` | `HomeEntrypoint` (/) and `PackageDetailEntrypoint` (/package/{file}) |
+| `src/actions/` | `SearchPackagesAction` — server-side table filtering |
+| `src/endpoints/` | the USM-facing endpoints listed above |
+| `src/pages/*.html` | Statum pages, compiled by `statum-mkpstm` at build time |
+| `src/Static/main.css` | stylesheet embedded by `statum-mkres` |
+| `MANIFEST.usm` | USM package manifest: `provides bin:usm-web`, build/runtime/manage deps |
+| `usm-scripts/` | USM build + install scripts (meson staging) |
+| `web-config.json` | static keys and the `usm-web` section (gitignored, 0600) |
+
+Registrations live between `// spry:*-begin/end` markers in `src/main.vala`,
+`# spry:*-begin/end` markers in `meson.build` and `<!-- spry:nav-begin/end -->`
+markers in `src/pages/main.html`; the `spry add` commands edit only those
+blocks and are idempotent.
+
+## Deployment (USM)
+
+```bash
+spry deploy --verbose \
+  --repository ../web-stack-repo/web-stack.usmr \
+  --installer-url file:///tmp/kilo/usm-shim/install-usm-full.sh
+```
+
+This packages the application and builds a single-stage container image
+through USM (`usm manifest deploy`): the `MANIFEST.usm` + `usm-scripts/`
+drive the in-container build and install, the Web-Stack resolves from the
+given repository (including the `usm` package that provides the libusm
+`pc:`/`vapi:` refs this app builds against), and the system package manager
+provides the platform libraries. The result is
+`usm-web-<version>.image.tar.xz`, loadable with `podman load -i`.
+
+`web-config.json` is never packaged (`.usmignore`), so keys are never baked
+into the image. Bind-mount the repository read-only at `/repo` and, when you
+want stable keys or an installer section, mount a config:
+
+```bash
+podman load -i usm-web-0.1.image.tar.xz
+podman run -d -p 8080:8080 \
+    -v /path/to/web-stack-repo:/repo:ro,Z \
+    -v $PWD/web-config.json:/run/web-config.json:ro \
+    -e ASTRALIS_CONFIG_PATH=/run/web-config.json \
+    localhost/usm-web:0.1
+```

+ 90 - 0
meson.build

@@ -0,0 +1,90 @@
+project('usm-web', ['c', 'vala'],
+  version: '0.1',
+)
+
+glib_dep = dependency('glib-2.0')
+gobject_dep = dependency('gobject-2.0')
+gio_dep = dependency('gio-2.0')
+json_glib_dep = dependency('json-glib-1.0')
+libxml_dep = dependency('libxml-2.0')
+libarchive_dep = dependency('libarchive')
+invercargill_dep = dependency('invercargill-1')
+invercargill_json_dep = dependency('invercargill-json')
+inversion_dep = dependency('inversion-0.1')
+astralis_dep = dependency('astralis-0.1')
+statum_dep = dependency('statum-0.1')
+usm_dep = dependency('usm-0.1')
+
+# Statum build tools — statum-mkpstm compiles pages, statum-mkres embeds
+# resources. Resolved from PATH at setup time.
+statum_mkpstm = find_program('statum-mkpstm')
+statum_mkres = find_program('statum-mkres')
+
+# spry:resources-begin
+main_css_resource = custom_target('main-css-resource',
+    input: 'src/Static/main.css',
+    output: 'MainCssResource.vala',
+    command: [statum_mkres, '-o', '@OUTPUT@', '-n', 'main.css',
+              '--class-name', 'MainCssResource', '--ns', 'UsmWeb',
+              '-c', 'text/css', '@INPUT@']
+)
+# spry:resources-end
+
+# spry:pages-begin
+home_page = custom_target('home-page',
+    input: 'src/pages/home.html',
+    output: 'HomePage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'HomePage', '--ns', 'UsmWeb', '@INPUT@'],
+    depend_files: files('src/pages/main.html')
+)
+package_detail_page = custom_target('package-detail-page',
+    input: 'src/pages/package.html',
+    output: 'PackageDetailPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'PackageDetailPage', '--ns', 'UsmWeb', '@INPUT@'],
+    depend_files: files('src/pages/main.html')
+)
+# spry:pages-end
+
+app_sources = files(
+    # spry:sources-begin
+    'src/main.vala',
+    'src/UsmWebConfig.vala',
+    'src/RepositoryService.vala',
+    'src/DerivedBase.vala',
+    'src/PackagesState.vala',
+    'src/entrypoints/HomeEntrypoint.vala',
+    'src/entrypoints/PackageDetailEntrypoint.vala',
+    'src/actions/SearchPackagesAction.vala',
+    'src/endpoints/PackagesListingEndpoint.vala',
+    'src/endpoints/RepositoryDefinitionEndpoint.vala',
+    'src/endpoints/PackageDownloadEndpoint.vala',
+    'src/endpoints/InstallerScriptEndpoint.vala',
+    # spry:sources-end
+)
+
+app_generated = [
+    # spry:generated-begin
+    main_css_resource,
+    home_page,
+    package_detail_page,
+    # spry:generated-end
+]
+
+executable('usm-web',
+    app_sources + app_generated,
+    dependencies: [
+        glib_dep,
+        gobject_dep,
+        gio_dep,
+        json_glib_dep,
+        libxml_dep,
+        libarchive_dep,
+        invercargill_dep,
+        invercargill_json_dep,
+        inversion_dep,
+        astralis_dep,
+        statum_dep,
+        usm_dep,
+    ],
+    install: true
+)

+ 29 - 0
src/DerivedBase.vala

@@ -0,0 +1,29 @@
+using Astralis;
+
+namespace UsmWeb {
+
+    /**
+     * Derives the repository base URI other USM clients should point at.
+     *
+     * Precedence: the configured `base_url` override, then the reverse-proxy
+     * headers (`X-Forwarded-Proto`/`X-Forwarded-Host`), then the plain
+     * request (`http` + the `Host` header). The result always carries a
+     * trailing slash because the USM client appends `PACKAGES.usml` and the
+     * package filenames directly to it.
+     */
+    public class DerivedBase : Object {
+
+        public static string derive(HttpRequest request, UsmWebConfig config) {
+            if (config.base_url != null) {
+                return (!)config.base_url;
+            }
+
+            var proto = (request.get_header("X-Forwarded-Proto") ?? "http").strip();
+            if (proto.length == 0) {
+                proto = "http";
+            }
+            var host = (request.get_header("X-Forwarded-Host") ?? request.get_header("Host") ?? "localhost").strip();
+            return @"$proto://$host/";
+        }
+    }
+}

+ 162 - 0
src/PackagesState.vala

@@ -0,0 +1,162 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillJson;
+using Statum;
+using Usm;
+
+namespace UsmWeb {
+
+    /** One package row in the homepage table. */
+    public class PackageRow : Object {
+        public string name { get; set; default = ""; }
+        public string version { get; set; default = ""; }
+        public string summary { get; set; default = ""; }
+        public string size { get; set; default = ""; }
+        public string file { get; set; default = ""; }
+    }
+
+    /**
+     * Authors the `packages` PAGE-slot state shared by the homepage
+     * entrypoint and the search action: repository overview, the
+     * add-repository / install-USM instruction blocks, and the (possibly
+     * query-filtered) package table. The list is authored as a JSON array
+     * because collection-typed GObject properties are write-side only.
+     */
+    public class PackagesState : Object {
+
+        public const string SLOT_TYPE = "packages";
+
+        /**
+         * Builds the slot state: `repo` fields + instruction blocks + rows
+         * filtered by the case-insensitive `query` substring over name and
+         * summary (an empty query keeps every entry).
+         */
+        public static State build(RepositoryService repositories, UsmWebConfig config,
+                ActionRegistry action_registry, string base_uri, string query) throws Error {
+            var repo = repositories.repo();
+            var entries = repositories.entries();
+
+            var rows = new Vector<PackageRow>();
+            int64 total_bytes = 0;
+            uint matched = 0;
+            foreach (var entry in entries) {
+                var path = repositories.package_location(Path.get_basename(entry.path));
+                int64 size = file_size(path);
+                total_bytes += size;
+
+                if (!matches(entry, query)) {
+                    continue;
+                }
+                matched++;
+                rows.add(new PackageRow() {
+                    name = entry.manifest.name,
+                    version = entry.manifest.version.to_string(),
+                    summary = entry.manifest.summary,
+                    size = format_size(size),
+                    file = Path.get_basename(entry.path)
+                });
+            }
+
+            var dict = new PropertyDictionary();
+            dict.set_native<string>("repo_name", config.title_for(repo.name));
+            dict.set_native<string>("repo_summary", repo.summary);
+            dict.set_native<string>("key_fingerprint", fingerprint(repo.key.to_hex()));
+            dict.set_native<int>("package_count", (int) entries.count());
+            dict.set_native<string>("total_size", format_size(total_bytes));
+            dict.set_native<string>("base_uri", base_uri);
+            dict.set_native<string>("repos_d_snippet",
+                "curl -fsSL -o /etc/usm/repos.d/%s.usmr %srepo.usmr".printf(repo.name, base_uri));
+            dict.set_native<string>("query", query);
+            dict.set_native<string>("result_note", result_note(query, matched, entries.count()));
+            dict.set_native<string>("installer_mode", config.installer.to_string());
+            dict.set_native<string>("installer_href", installer_href(config, base_uri));
+            dict.set_native<string>("installer_command", installer_command(config, base_uri));
+            dict["search"] = action_registry.author<SearchPackagesAction>().to_element();
+            dict["packages"] = list(rows);
+
+            return new State() {
+                type_name = SLOT_TYPE,
+                public_data = dict,
+                private_data = new PropertyDictionary()
+            };
+        }
+
+        /** Case-insensitive substring match over name and summary. */
+        public static bool matches(RepositoryListingEntry entry, string query) {
+            if (query.length == 0) {
+                return true;
+            }
+            var needle = query.casefold();
+            return entry.manifest.name.casefold().contains(needle)
+                || entry.manifest.summary.casefold().contains(needle);
+        }
+
+        /** Builds the rows' JSON array (the {@link StateJson.list} pattern). */
+        public static JsonElement list(Lot<PackageRow> rows) throws Error {
+            var arr = new JsonArray();
+            foreach (var row in rows) {
+                arr.add(new JsonElement.from_properties(GObjectMapping.to_properties(row)));
+            }
+            return new JsonElement.from_elements(arr);
+        }
+
+        /** Groups a hex digest into space-separated 16-character chunks. */
+        public static string fingerprint(string hex) {
+            var builder = new StringBuilder();
+            for (int i = 0; i < hex.length; i += 16) {
+                if (i > 0) {
+                    builder.append_c(' ');
+                }
+                builder.append(hex[i:int.min(i + 16, hex.length)]);
+            }
+            return builder.str;
+        }
+
+        /** Human-readable byte size (KiB/MiB/GiB). */
+        public static string format_size(int64 bytes) {
+            if (bytes >= 1073741824) {
+                return "%.2f GiB".printf(bytes / 1073741824.0);
+            }
+            if (bytes >= 1048576) {
+                return "%.1f MiB".printf(bytes / 1048576.0);
+            }
+            if (bytes >= 1024) {
+                return "%.1f KiB".printf(bytes / 1024.0);
+            }
+            return "%lld B".printf(bytes);
+        }
+
+        /** The link the install card points at (external URL or this app). */
+        private static string installer_href(UsmWebConfig config, string base_uri) {
+            if (config.installer == InstallerSource.URL) {
+                return (!)config.installer_url;
+            }
+            return base_uri + "install-usm.sh";
+        }
+
+        /** The copy-paste one-liner the install card shows. */
+        private static string installer_command(UsmWebConfig config, string base_uri) {
+            return "curl -fsSL %s | sh".printf(installer_href(config, base_uri));
+        }
+
+        private static string result_note(string query, uint matched, uint total) {
+            if (query.length == 0) {
+                return "";
+            }
+            if (matched == 0) {
+                return "No packages match \"%s\".".printf(query);
+            }
+            return "Showing %u of %u packages.".printf(matched, total);
+        }
+
+        private static int64 file_size(string path) {
+            try {
+                var info = File.new_for_path(path).query_info(
+                    FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE);
+                return info.get_size();
+            } catch (Error e) {
+                return 0;
+            }
+        }
+    }
+}

+ 131 - 0
src/RepositoryService.vala

@@ -0,0 +1,131 @@
+using Invercargill;
+using Usm;
+
+namespace UsmWeb {
+
+    /**
+     * Read-side access to the on-disk USM repository this app browses and
+     * serves, built entirely on libusm: {@link Usm.Repository.from_file} for
+     * the `.usmr`, {@link Usm.RepositoryListing.from_stream} for
+     * `public/PACKAGES.usml`.
+     *
+     * The repository root comes from {@link UsmWebConfig.repo_dir} (the
+     * `--repo` argument, `USM_WEB_REPO_DIR` or the `/repo` default) and is
+     * expected to hold one `.usmr` beside a `public/` directory. Both files
+     * are re-read when their mtimes change, so a rebuilt repository is
+     * picked up without a restart.
+     */
+    public class RepositoryService : Object {
+
+        private UsmWebConfig config = Inversion.inject<UsmWebConfig>();
+
+        private Repository? repository;
+        private RepositoryListing? listing;
+        private string? usmr_path;
+        private int64 usmr_mtime;
+        private int64 listing_mtime;
+
+        /**
+         * Reloads the `.usmr` and listing when either file changed on disk
+         * (or was never loaded); a first failure throws, later failures keep
+         * the previously loaded snapshot so an in-flight rebuild cannot take
+         * the running app down.
+         */
+        public void ensure_loaded() throws Error {
+            var path = find_usmr();
+            if (path == null) {
+                throw new UsmWebError.INVALID_CONFIGURATION(
+                    @"No *.usmr found in \"$(config.repo_dir)\" — is the repository mounted?");
+            }
+
+            int64 current_usmr_mtime = file_mtime((!)path);
+            int64 current_listing_mtime = file_mtime(listing_path());
+            if (repository != null && listing != null
+                    && current_usmr_mtime == usmr_mtime
+                    && current_listing_mtime == listing_mtime) {
+                return;
+            }
+
+            if (repository == null || current_usmr_mtime != usmr_mtime) {
+                repository = new Repository.from_file((!)path);
+                usmr_path = (!)path;
+                usmr_mtime = current_usmr_mtime;
+            }
+
+            if (current_listing_mtime != listing_mtime || listing == null) {
+                var file = File.new_for_path(listing_path());
+                listing = new RepositoryListing.from_stream(new DataInputStream(file.read()));
+                listing_mtime = current_listing_mtime;
+            }
+        }
+
+        /** The loaded repository metadata (name, summary, signing key). */
+        public Repository repo() throws Error {
+            ensure_loaded();
+            return (!)repository;
+        }
+
+        /** The loaded `PACKAGES.usml` entries, in listing order. */
+        public Enumerable<RepositoryListingEntry> entries() throws Error {
+            ensure_loaded();
+            return ((!)listing).entries;
+        }
+
+        /** The listing entry whose package file is `file`, or null. */
+        public RepositoryListingEntry? find_entry(string file) throws Error {
+            foreach (var entry in entries()) {
+                if (Path.get_basename(entry.path) == file) {
+                    return entry;
+                }
+            }
+            return null;
+        }
+
+        /** The absolute path of the `.usmr` this app serves a rewritten copy of. */
+        public string usmr_location() throws Error {
+            ensure_loaded();
+            return (!)usmr_path;
+        }
+
+        /** The absolute path of the verbatim-served `PACKAGES.usml`. */
+        public string listing_location() {
+            return listing_path();
+        }
+
+        /** The absolute path of a package archive inside `public/`. */
+        public string package_location(string file) {
+            return Path.build_filename(config.repo_dir, "public", file);
+        }
+
+        private string listing_path() {
+            return Path.build_filename(config.repo_dir, "public", "PACKAGES.usml");
+        }
+
+        private string? find_usmr() {
+            try {
+                var dir = Dir.open(config.repo_dir);
+                string? name = null;
+                string? found = null;
+                while ((name = dir.read_name()) != null) {
+                    if (name.has_suffix(".usmr")) {
+                        found = name;
+                        break;
+                    }
+                }
+                return found == null ? null : Path.build_filename(config.repo_dir, (!)found);
+            } catch (Error e) {
+                return null;
+            }
+        }
+
+        private static int64 file_mtime(string path) {
+            try {
+                var info = File.new_for_path(path).query_info(
+                    FileAttribute.TIME_MODIFIED, FileQueryInfoFlags.NONE);
+                return (int64) info.get_attribute_uint64(FileAttribute.TIME_MODIFIED);
+            } catch (Error e) {
+                return -1;
+            }
+        }
+    }
+}

+ 212 - 0
src/Static/main.css

@@ -0,0 +1,212 @@
+:root {
+    --ink: #1c2733;
+    --ink-soft: #51606f;
+    --line: #dde4ea;
+    --accent: #0f6f5c;
+    --accent-soft: #e7f3f0;
+    --bg: #f5f7f9;
+    --card: #ffffff;
+    --mono: ui-monospace, "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
+}
+
+* { box-sizing: border-box; }
+
+body {
+    margin: 0;
+    font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
+    color: var(--ink);
+    background: var(--bg);
+    line-height: 1.55;
+}
+
+.wrap {
+    max-width: 56rem;
+    margin: 0 auto;
+    padding: 0 1.25rem;
+}
+
+.site-header {
+    background: var(--card);
+    border-bottom: 1px solid var(--line);
+}
+
+.site-header-inner {
+    display: flex;
+    align-items: baseline;
+    gap: 1rem;
+    padding-top: 1rem;
+    padding-bottom: 1rem;
+    flex-wrap: wrap;
+}
+
+.brand {
+    font-weight: 700;
+    font-size: 1.1rem;
+    color: var(--ink);
+    text-decoration: none;
+    letter-spacing: 0.02em;
+}
+
+.brand-mark {
+    color: var(--accent);
+    margin-right: 0.45ch;
+    font-size: 0.9em;
+}
+
+.tagline { color: var(--ink-soft); font-size: 0.9rem; }
+
+main.wrap { padding-top: 1.5rem; padding-bottom: 3rem; }
+
+.site-footer {
+    border-top: 1px solid var(--line);
+    color: var(--ink-soft);
+    font-size: 0.85rem;
+    padding: 1rem 0 1.5rem;
+    background: var(--card);
+}
+
+.card {
+    background: var(--card);
+    border: 1px solid var(--line);
+    border-radius: 10px;
+    padding: 1.25rem 1.5rem;
+    margin-bottom: 1.25rem;
+}
+
+.card h1 { margin: 0 0 0.25rem; font-size: 1.6rem; }
+.card h2 { margin: 0 0 0.75rem; font-size: 1.1rem; }
+.summary { color: var(--ink-soft); margin: 0.25rem 0 0; }
+
+.stats {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    gap: 0.75rem 1.5rem;
+    margin: 1.25rem 0 0;
+    padding: 0;
+}
+
+.stats .stat-wide { grid-column: 1 / -1; }
+.stats dt {
+    font-size: 0.75rem;
+    text-transform: uppercase;
+    letter-spacing: 0.06em;
+    color: var(--ink-soft);
+}
+.stats dd { margin: 0.1rem 0 0; font-weight: 600; }
+
+.mono, code { font-family: var(--mono); font-size: 0.9em; }
+
+.fingerprint { font-weight: 400; word-break: break-all; }
+
+.snippet {
+    background: #10241f;
+    color: #d7ebe5;
+    border-radius: 8px;
+    padding: 0.75rem 1rem;
+    overflow-x: auto;
+    margin: 0.75rem 0;
+}
+
+.snippet code { color: inherit; }
+
+.hint { color: var(--ink-soft); font-size: 0.9rem; margin: 0.4rem 0 0; }
+.steps { margin: 0.75rem 0 0; padding-left: 1.25rem; }
+.steps li { margin-bottom: 0.35rem; }
+
+.search { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
+.search input[type="search"] {
+    flex: 1 1 16ch;
+    max-width: 26rem;
+    padding: 0.45rem 0.65rem;
+    border: 1px solid var(--line);
+    border-radius: 6px;
+    font: inherit;
+}
+
+button, .button {
+    font: inherit;
+    font-weight: 600;
+    border: none;
+    border-radius: 6px;
+    background: var(--accent);
+    color: #fff;
+    padding: 0.45rem 0.9rem;
+    cursor: pointer;
+    text-decoration: none;
+    white-space: nowrap;
+}
+
+.button span { font-weight: 400; opacity: 0.85; margin-left: 0.4ch; }
+.button:hover, button:hover { filter: brightness(1.1); }
+
+.result-note { color: var(--ink-soft); font-size: 0.9rem; min-height: 1.3em; margin: 0.25rem 0 0.75rem; }
+
+.table-scroll { overflow-x: auto; }
+
+table {
+    border-collapse: collapse;
+    width: 100%;
+    font-size: 0.92rem;
+}
+
+th, td {
+    text-align: left;
+    padding: 0.5rem 0.75rem;
+    border-bottom: 1px solid var(--line);
+    vertical-align: top;
+}
+
+th {
+    font-size: 0.75rem;
+    text-transform: uppercase;
+    letter-spacing: 0.06em;
+    color: var(--ink-soft);
+    border-bottom: 2px solid var(--line);
+}
+
+td.num, th.num { text-align: right; white-space: nowrap; }
+td.empty { text-align: center; color: var(--ink-soft); padding: 1.25rem; }
+
+a { color: var(--accent); }
+a:hover { text-decoration: none; }
+
+.download {
+    text-decoration: none;
+    border: 1px solid var(--line);
+    border-radius: 6px;
+    padding: 0.1rem 0.55rem;
+    background: var(--accent-soft);
+}
+
+.breadcrumb { margin: 0 0 0.9rem; font-size: 0.9rem; }
+
+.detail-head {
+    display: flex;
+    justify-content: space-between;
+    align-items: flex-start;
+    gap: 1.5rem;
+    flex-wrap: wrap;
+}
+
+.version { color: var(--accent); font-weight: 600; margin: 0; }
+
+.chips { margin-top: 1rem; display: flex; gap: 0.4rem; flex-wrap: wrap; }
+.chip {
+    background: var(--accent-soft);
+    color: var(--accent);
+    border-radius: 999px;
+    padding: 0.15rem 0.7rem;
+    font-size: 0.8rem;
+}
+
+.muted { color: var(--ink-soft); }
+
+.error-card { border-color: #e3b7b7; }
+.error-card h1 { color: #a33; }
+
+[hidden] { display: none; }
+
+@media (max-width: 40rem) {
+    .stats { grid-template-columns: 1fr 1fr; }
+    .detail-head { flex-direction: column; }
+}

+ 164 - 0
src/UsmWebConfig.vala

@@ -0,0 +1,164 @@
+using Astralis;
+using Json;
+
+namespace UsmWeb {
+
+    /** Errors raised by a malformed `"usm-web"` configuration section. */
+    public errordomain UsmWebError {
+        /** The section (or its `installer` object) is structurally invalid */
+        INVALID_CONFIGURATION,
+    }
+
+    /** How the "Install USM" instructions source the installer script. */
+    public enum InstallerSource {
+        /** No installer configured; the homepage omits the install block. */
+        NONE,
+        /** The installer lives at an external URL; instructions link out. */
+        URL,
+        /** The installer is a local file this app serves at /install-usm.sh. */
+        PATH;
+
+        /** The wire/display name used as the homepage's `installer_mode`. */
+        public string to_string() {
+            switch (this) {
+                case InstallerSource.NONE:
+                    return "none";
+                case InstallerSource.URL:
+                    return "url";
+                case InstallerSource.PATH:
+                    return "path";
+                default:
+                    assert_not_reached();
+            }
+        }
+    }
+
+    /**
+     * The `"usm-web"` section of web-config.json.
+     *
+     * ```json
+     * {
+     *   "usm-web": {
+     *     "installer": { "path": "install-usm.sh" },
+     *     "base_url": "https://repo.example.com/",
+     *     "name": "My repository"
+     *   }
+     * }
+     * ```
+     *
+     * `installer` carries exactly one of `url` (an external location the
+     * homepage links to) or `path` (a file the app serves itself at
+     * {@link InstallerScriptEndpoint}); a relative `path` resolves against
+     * the directory of the config file that declared it. `base_url` overrides
+     * the request-derived repository base (normalised to a trailing slash)
+     * and `name` overrides the display name taken from the `.usmr`.
+     */
+    public class UsmWebConfig : GLib.Object {
+
+        /** The configured installer source, or {@link InstallerSource.NONE}. */
+        public InstallerSource installer { get; private set; default = InstallerSource.NONE; }
+
+        /** The external installer URL (`url` mode), or null. */
+        public string? installer_url { get; private set; }
+
+        /** The absolute installer script path (`path` mode), or null. */
+        public string? installer_path { get; private set; }
+
+        /** The base-URL override (trailing slash), or null to derive per request. */
+        public string? base_url { get; private set; }
+
+        /** The repository display-name override, or null to use the `.usmr` name. */
+        public string? display_name { get; private set; }
+
+        /** The USM repository root (contains the `.usmr` and `public/`). */
+        public string repo_dir { get; private set; }
+
+        /**
+         * Reads the section from the loaded {@link WebConfig}, re-parsing the
+         * contributing files with json-glib so the nested `installer` object
+         * is readable and a relative `path` resolves against the config file
+         * that declared it (later files win, mirroring WebConfig layering).
+         */
+        public static UsmWebConfig load(WebConfig config, string repo_dir) throws Error {
+            var result = new UsmWebConfig();
+            result.repo_dir = repo_dir;
+
+            string? declared_path = null;
+            string declaring_dir = Environment.get_current_dir();
+            foreach (var file in config.get_loaded_files()) {
+                var section = read_section(file);
+                if (section == null) {
+                    continue;
+                }
+                var obj = (!)section;
+
+                result.display_name = read_string_member(obj, "name") ?? result.display_name;
+
+                var base_url = read_string_member(obj, "base_url");
+                if (base_url != null) {
+                    result.base_url = normalise_base((!)base_url);
+                }
+
+                result.installer_url = read_string_member(obj, "installer", "url") ?? result.installer_url;
+                declared_path = read_string_member(obj, "installer", "path") ?? declared_path;
+                if (declared_path != null) {
+                    declaring_dir = GLib.Path.get_dirname((!)file);
+                }
+            }
+
+            if (result.installer_url != null && declared_path != null) {
+                throw new UsmWebError.INVALID_CONFIGURATION(
+                    "usm-web.installer: provide exactly one of \"url\" or \"path\", not both");
+            }
+            if (result.installer_url != null) {
+                result.installer = InstallerSource.URL;
+            } else if (declared_path != null) {
+                var path = (!)declared_path;
+                result.installer_path = GLib.Path.is_absolute(path)
+                    ? path : GLib.Path.build_filename(declaring_dir, path);
+                result.installer = InstallerSource.PATH;
+            }
+
+            return result;
+        }
+
+        /** The homepage title: the `name` override or the `.usmr` name. */
+        public string title_for(string repository_name) {
+            return display_name ?? repository_name;
+        }
+
+        private static string normalise_base(string base_url) {
+            return base_url.has_suffix("/") ? base_url : base_url + "/";
+        }
+
+        /** The root object of `path`'s `"usm-web"` member, or null when absent. */
+        private static Json.Object? read_section(string path) {
+            try {
+                var parser = new Parser();
+                parser.load_from_file(path);
+                var root = parser.get_root().get_object();
+                return root.has_member("usm-web") ? root.get_member("usm-web").get_object() : null;
+            } catch (Error e) {
+                return null;
+            }
+        }
+
+        private static string? read_string_member(Json.Object obj, string member, string? nested = null) {
+            if (!obj.has_member(member)) {
+                return null;
+            }
+            var node = obj.get_member(member);
+            if (nested != null) {
+                if (node.get_node_type() != NodeType.OBJECT) {
+                    return null;
+                }
+                var inner = node.get_object();
+                if (!inner.has_member(nested)) {
+                    return null;
+                }
+                node = inner.get_member(nested);
+            }
+            return node.get_node_type() == NodeType.VALUE ? node.get_string() : null;
+        }
+    }
+}

+ 44 - 0
src/actions/SearchPackagesAction.vala

@@ -0,0 +1,44 @@
+using Invercargill;
+using Statum;
+
+namespace UsmWeb {
+
+    /**
+     * Server-side search: filters the {@link PackagesState.SLOT_TYPE} PAGE
+     * slot's package table by the `query` form field (case-insensitive
+     * substring over name and summary).
+     *
+     * Collection-typed slot fields cannot be read back from held state, so
+     * the filtered rows are rebuilt from the repository (the documented
+     * rebuild-from-source pattern) and written as one slot update keyed by
+     * the held slot's key.
+     */
+    public class SearchPackagesAction : StatumAction {
+
+        protected RepositoryService repositories = Inversion.inject<RepositoryService>();
+        protected UsmWebConfig config = Inversion.inject<UsmWebConfig>();
+        protected Astralis.HttpContext http_context = Inversion.inject<Astralis.HttpContext>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            string query = "";
+            if (request.form != null) {
+                query = (request.form.get_field("query") ?? "").strip();
+            }
+
+            HeldSlot held;
+            if (!request.held.try_get(PackagesState.SLOT_TYPE, out held) || held == null) {
+                return directives().error("No \"packages\" slot held — reload the page");
+            }
+
+            var base_uri = DerivedBase.derive(http_context.request, config);
+            State state;
+            try {
+                state = PackagesState.build(repositories, config, action_registry, base_uri, query);
+            } catch (Error e) {
+                return directives().notify("error", @"Repository load failed: $(e.message)");
+            }
+
+            return directives().update(((!)held).key, state);
+        }
+    }
+}

+ 34 - 0
src/endpoints/InstallerScriptEndpoint.vala

@@ -0,0 +1,34 @@
+using Astralis;
+
+namespace UsmWeb {
+
+    /**
+     * `GET /install-usm.sh` — serves the configured installer script in
+     * `path` mode so a fresh machine can bootstrap USM straight from this
+     * app: `curl -fsSL <base>/install-usm.sh | sh`. Only registered when
+     * {@link UsmWebConfig.installer} is {@link InstallerSource.PATH}.
+     */
+    public class InstallerScriptEndpoint : Object, Endpoint {
+
+        private UsmWebConfig config = Inversion.inject<UsmWebConfig>();
+
+        public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error {
+            var path = config.installer_path;
+            if (path == null) {
+                return new HttpStringResult("No installer configured", StatusCode.NOT_FOUND);
+            }
+
+            var file = File.new_for_path((!)path);
+            if (!file.query_exists()) {
+                return new HttpStringResult("Configured installer script is missing",
+                    StatusCode.INTERNAL_SERVER_ERROR);
+            }
+
+            var info = yield file.query_info_async(
+                FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE);
+            var result = new HttpStreamResult(yield file.read_async(), info.get_size());
+            result.set_header("Content-Type", "text/x-shellscript");
+            return result;
+        }
+    }
+}

+ 40 - 0
src/endpoints/PackageDownloadEndpoint.vala

@@ -0,0 +1,40 @@
+using Astralis;
+
+namespace UsmWeb {
+
+    /**
+     * `GET /{file}.usmc` — streams a package archive from the repository's
+     * `public/` directory with an exact content-length, exactly what a USM
+     * client fetching through this app expects (the bytes match the
+     * listing's sha512).
+     *
+     * The route pattern matches any single segment; requests that do not
+     * name a `.usmc` inside `public/` answer 404.
+     */
+    public class PackageDownloadEndpoint : Object, Endpoint {
+
+        private RepositoryService repositories = Inversion.inject<RepositoryService>();
+
+        public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error {
+            string file = "";
+            route_context.mapped_parameters.try_get("file", out file);
+
+            if (!file.has_suffix(".usmc") || file.contains("/") || file.contains("..")) {
+                return new HttpStringResult("Not Found", StatusCode.NOT_FOUND);
+            }
+
+            var path = repositories.package_location(file);
+            var handle = File.new_for_path(path);
+            if (!handle.query_exists()) {
+                return new HttpStringResult("No such package", StatusCode.NOT_FOUND);
+            }
+
+            var info = yield handle.query_info_async(
+                FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE);
+            var result = new HttpStreamResult(yield handle.read_async(), info.get_size());
+            result.set_header("Content-Type", "application/octet-stream");
+            result.set_flag(HttpResultFlag.DO_NOT_COMPRESS);
+            return result;
+        }
+    }
+}

+ 28 - 0
src/endpoints/PackagesListingEndpoint.vala

@@ -0,0 +1,28 @@
+using Astralis;
+
+namespace UsmWeb {
+
+    /**
+     * `GET /PACKAGES.usml` — streams the repository listing verbatim so USM
+     * clients resolve against exactly the bytes on disk (signature and
+     * checksums stay valid).
+     */
+    public class PackagesListingEndpoint : Object, Endpoint {
+
+        private RepositoryService repositories = Inversion.inject<RepositoryService>();
+
+        public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error {
+            var path = repositories.listing_location();
+            var file = File.new_for_path(path);
+            if (!file.query_exists()) {
+                return new HttpStringResult("PACKAGES.usml not found", StatusCode.NOT_FOUND);
+            }
+
+            var info = yield file.query_info_async(
+                FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE);
+            var result = new HttpStreamResult(yield file.read_async(), info.get_size());
+            result.set_header("Content-Type", "application/json");
+            return result;
+        }
+    }
+}

+ 38 - 0
src/endpoints/RepositoryDefinitionEndpoint.vala

@@ -0,0 +1,38 @@
+using Astralis;
+using Json;
+
+namespace UsmWeb {
+
+    /**
+     * `GET /repo.usmr` — serves a rewritten copy of the on-disk `.usmr` with
+     * its embedded `url` replaced by the derived base ({@link DerivedBase}),
+     * so a client that drops this file into its repos.d resolves packages
+     * from this app no matter which host it was downloaded through.
+     */
+    public class RepositoryDefinitionEndpoint : GLib.Object, Endpoint {
+
+        private RepositoryService repositories = Inversion.inject<RepositoryService>();
+        private UsmWebConfig config = Inversion.inject<UsmWebConfig>();
+
+        public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error {
+            string contents;
+            try {
+                FileUtils.get_contents(repositories.usmr_location(), out contents);
+            } catch (Error e) {
+                return new HttpStringResult(@"Repository definition unavailable: $(e.message)",
+                    StatusCode.INTERNAL_SERVER_ERROR);
+            }
+
+            var base_uri = DerivedBase.derive(http_context.request, config);
+
+            var parser = new Parser();
+            parser.load_from_data(contents);
+            var root = parser.get_root();
+            root.get_object().set_string_member("url", base_uri);
+
+            var result = new HttpStringResult(Json.to_string(root, false));
+            result.set_header("Content-Type", "application/json");
+            return result;
+        }
+    }
+}

+ 39 - 0
src/entrypoints/HomeEntrypoint.vala

@@ -0,0 +1,39 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Statum;
+
+namespace UsmWeb {
+
+    /**
+     * Entrypoint for /: hydrates the {@link PackagesState.SLOT_TYPE} PAGE
+     * slot with the repository overview, instruction blocks and the full
+     * package table. When the repository cannot be read the page degrades to
+     * an error card instead of a failed hydration.
+     */
+    public class HomeEntrypoint : StatumEntrypoint {
+
+        protected RepositoryService repositories = Inversion.inject<RepositoryService>();
+        protected UsmWebConfig config = Inversion.inject<UsmWebConfig>();
+        protected Astralis.HttpContext http_context = Inversion.inject<Astralis.HttpContext>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var base_uri = DerivedBase.derive(http_context.request, config);
+
+            State state;
+            try {
+                state = PackagesState.build(repositories, config, action_registry, base_uri, "");
+            } catch (Error e) {
+                warning("[usm-web] Repository load failed: %s\n", e.message);
+                var dict = new PropertyDictionary();
+                dict.set_native<string>("load_error", e.message);
+                state = new State() {
+                    type_name = PackagesState.SLOT_TYPE,
+                    public_data = dict,
+                    private_data = new PropertyDictionary()
+                };
+            }
+
+            return directives().set(state_service.new_slot(Scope.PAGE, state));
+        }
+    }
+}

+ 124 - 0
src/entrypoints/PackageDetailEntrypoint.vala

@@ -0,0 +1,124 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillJson;
+using Statum;
+using Usm;
+
+namespace UsmWeb {
+
+    /** One `provides` row: the resource ref plus its declared path. */
+    public class ProvideRow : Object {
+        public string ref_name { get; set; default = ""; }
+        public string path { get; set; default = ""; }
+    }
+
+    /** One `depends` row: the lifecycle phase plus the required ref. */
+    public class DependRow : Object {
+        public string phase { get; set; default = ""; }
+        public string ref_name { get; set; default = ""; }
+    }
+
+    /** One licence row of the detail view. */
+    public class LicenceRow : Object {
+        public string name { get; set; default = ""; }
+        public string category { get; set; default = ""; }
+    }
+
+    /**
+     * Entrypoint for /package/{file}: hydrates the `package` PAGE slot from
+     * the `PACKAGES.usml` entry plus the manifest read out of the `.usmc`
+     * itself ({@link Usm.Manifest.from_package}), the listing's sha512 and
+     * the archive's on-disk size/mtime. Unknown files redirect home.
+     */
+    public class PackageDetailEntrypoint : StatumEntrypoint {
+
+        protected RepositoryService repositories = Inversion.inject<RepositoryService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var file = request.route("file") ?? "";
+            var entry = repositories.find_entry(file);
+            if (entry == null) {
+                return directives().navigate("/");
+            }
+
+            var path = repositories.package_location(file);
+            var manifest = new Manifest.from_package(path);
+            var info = file_info(path);
+
+            var dict = new PropertyDictionary();
+            dict.set_native<string>("name", manifest.name);
+            dict.set_native<string>("version", manifest.version.to_string());
+            dict.set_native<string>("summary", manifest.summary);
+            dict.set_native<string>("file", file);
+            dict.set_native<string>("download_href", "/" + file);
+            dict.set_native<string>("size", PackagesState.format_size(info.size));
+            dict.set_native<string>("modified", format_mtime(info.modified));
+            dict.set_native<string>("sha512", ((!)entry).sha512sum.to_hex());
+
+            var flags = new JsonArray();
+            foreach (var flag in manifest.flags) {
+                flags.add(new JsonElement.from_string("\"%s\"".printf(flag.to_string())));
+            }
+            dict["flags"] = new JsonElement.from_elements(flags);
+
+            var licences = new JsonArray();
+            foreach (var licence in manifest.licences) {
+                var row = new LicenceRow() { name = licence.name, category = licence.category.to_string() };
+                licences.add(new JsonElement.from_properties(GObjectMapping.to_properties(row)));
+            }
+            dict["licences"] = new JsonElement.from_elements(licences);
+
+            var provides = new JsonArray();
+            foreach (var pair in manifest.provides) {
+                var row = new ProvideRow() {
+                    ref_name = pair.key.to_string(),
+                    path = pair.value.path ?? ""
+                };
+                provides.add(new JsonElement.from_properties(GObjectMapping.to_properties(row)));
+            }
+            dict["provides"] = new JsonElement.from_elements(provides);
+
+            var depends = new JsonArray();
+            add_phase(depends, "manage", manifest.dependencies.manage);
+            add_phase(depends, "build", manifest.dependencies.build);
+            add_phase(depends, "runtime", manifest.dependencies.runtime);
+            dict["depends"] = new JsonElement.from_elements(depends);
+
+            var state = new State() {
+                type_name = "package",
+                public_data = dict,
+                private_data = new PropertyDictionary()
+            };
+            return directives().set(state_service.new_slot(Scope.PAGE, state));
+        }
+
+        private static void add_phase(JsonArray depends, string phase, DependencyPhase? dependency_phase) throws GLib.Error {
+            if (dependency_phase == null) {
+                return;
+            }
+            foreach (var resource in ((!)dependency_phase).ordered_all_refs()) {
+                var row = new DependRow() { phase = phase, ref_name = resource.to_string() };
+                depends.add(new JsonElement.from_properties(GObjectMapping.to_properties(row)));
+            }
+        }
+
+        private static FileStat file_info(string path) throws Error {
+            var info = File.new_for_path(path).query_info(
+                FileAttribute.STANDARD_SIZE + "," + FileAttribute.TIME_MODIFIED,
+                FileQueryInfoFlags.NONE);
+            int64 size = info.get_size();
+            int64 modified = (int64) info.get_attribute_uint64(FileAttribute.TIME_MODIFIED);
+            return new FileStat() { size = size, modified = modified };
+        }
+
+        private static string format_mtime(int64 unix_seconds) {
+            var dt = new DateTime.from_unix_local(unix_seconds);
+            return dt.format("%Y-%m-%d %H:%M");
+        }
+
+        private class FileStat : Object {
+            public int64 size { get; set; }
+            public int64 modified { get; set; }
+        }
+    }
+}

+ 65 - 0
src/main.vala

@@ -0,0 +1,65 @@
+using Astralis;
+using Inversion;
+using Statum;
+using UsmWeb;
+
+int main(string[] args) {
+    int port = 8080;
+    string? repo_dir = null;
+
+    for (int i = 1; i < args.length; i++) {
+        var arg = args[i];
+        if (arg == "--repo" && i + 1 < args.length) {
+            repo_dir = args[++i];
+        } else if (arg.has_prefix("--repo=")) {
+            repo_dir = arg.substring("--repo=".length);
+        } else if (arg.has_prefix("--port=")) {
+            port = int.parse(arg.substring("--port=".length));
+        } else {
+            port = int.parse(arg);
+        }
+    }
+
+    repo_dir = repo_dir ?? Environment.get_variable("USM_WEB_REPO_DIR") ?? "/repo";
+
+    try {
+        var application = new WebApplication(port);
+        application.use_compression();
+        application.add_module<StatumModule>();
+
+        var web_config = application.container.create_transient_scope().resolve<WebConfig>();
+        var config = UsmWebConfig.load(web_config, (!)repo_dir);
+        application.add_singleton<UsmWebConfig>(() => config);
+        application.add_singleton<RepositoryService>();
+
+        var statum = application.configure_with<StatumConfigurator>();
+
+        // spry:pages-begin
+        statum.add_page<HomePage, HomeEntrypoint>();
+        statum.add_page<PackageDetailPage, PackageDetailEntrypoint>();
+        // spry:pages-end
+
+        // spry:actions-begin
+        statum.action<SearchPackagesAction>();
+        // spry:actions-end
+
+        // spry:resources-begin
+        statum.add_resource<MainCssResource>();
+        // spry:resources-end
+
+        // USM-serving endpoints. The /{file} pattern matches any single
+        // segment, so it must be registered after every exact route.
+        application.add_endpoint<PackagesListingEndpoint>(new EndpointRoute("/PACKAGES.usml"));
+        application.add_endpoint<RepositoryDefinitionEndpoint>(new EndpointRoute("/repo.usmr"));
+        if (config.installer == InstallerSource.PATH) {
+            application.add_endpoint<InstallerScriptEndpoint>(new EndpointRoute("/install-usm.sh"));
+        }
+        application.add_endpoint<PackageDownloadEndpoint>(new EndpointRoute("/{file}"));
+
+        application.run();
+        return 0;
+    } catch (Error e) {
+        printerr("[usm-web] Error: %s\n", e.message);
+        return 1;
+    }
+}

+ 86 - 0
src/pages/home.html

@@ -0,0 +1,86 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <div stm-if="packages != null && packages.load_error != null && packages.load_error != ''"
+         class="card error-card">
+        <h1>Repository unavailable</h1>
+        <p stm-text="packages.load_error">Loading…</p>
+    </div>
+
+    <div stm-if="packages != null && (packages.load_error == null || packages.load_error == '')">
+        <section class="card hero">
+            <h1 stm-text="packages.repo_name">Repository</h1>
+            <p class="summary" stm-text="packages.repo_summary">Loading…</p>
+            <dl class="stats">
+                <div><dt>Packages</dt><dd stm-text="packages.package_count">—</dd></div>
+                <div><dt>Total size</dt><dd stm-text="packages.total_size">—</dd></div>
+                <div class="stat-wide"><dt>Signing key fingerprint</dt>
+                    <dd class="mono fingerprint" stm-text="packages.key_fingerprint">—</dd></div>
+            </dl>
+        </section>
+
+        <section class="card" stm-if="packages.installer_mode == 'url' || packages.installer_mode == 'path'">
+            <h2>Install USM</h2>
+            <p>New machine? Bootstrap the USM package manager first:</p>
+            <pre class="mono snippet"><code stm-text="packages.installer_command"></code></pre>
+            <p class="hint">Installer script: <a stm-attribute.href="packages.installer_href"
+               stm-text="packages.installer_href">installer</a></p>
+        </section>
+
+        <section class="card">
+            <h2>Add this repository</h2>
+            <p>Point USM at this host — the repository URI is
+                <code class="mono" stm-text="packages.base_uri">…</code></p>
+            <ol class="steps">
+                <li>Download the signed repository definition:
+                    <a class="mono" stm-attribute.href="packages.base_uri + 'repo.usmr'"
+                       stm-text="packages.base_uri + 'repo.usmr'">repo.usmr</a></li>
+                <li>Drop it into your repos.d:</li>
+            </ol>
+            <pre class="mono snippet"><code stm-text="packages.repos_d_snippet"></code></pre>
+            <p class="hint">Then <code class="mono">usm repository list</code> should show
+                <span class="mono" stm-text="packages.repo_name">the repository</span>.</p>
+        </section>
+
+        <section class="card">
+            <h2>Packages</h2>
+            <form class="search" stm-action="packages.search">
+                <input type="search" name="query" placeholder="Search name or summary…"
+                       stm-attribute.value="packages.query">
+                <button type="submit">Search</button>
+            </form>
+            <p class="result-note" stm-text="packages.result_note"></p>
+            <div class="table-scroll">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>Name</th>
+                            <th>Version</th>
+                            <th>Summary</th>
+                            <th class="num">Size</th>
+                            <th class="num">Download</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr stm-for-pkg-in="packages.packages" stm-key="pkg.file">
+                            <td><a stm-attribute.href="'/package/' + pkg.file" stm-text="pkg.name">pkg</a></td>
+                            <td class="mono" stm-text="pkg.version">0.0</td>
+                            <td stm-text="pkg.summary">summary</td>
+                            <td class="num mono" stm-text="pkg.size">0 B</td>
+                            <td class="num"><a class="download" title="Download .usmc"
+                               stm-attribute.href="'/' + pkg.file">⬇</a></td>
+                        </tr>
+                        <tr stm-if="packages.packages.length == 0">
+                            <td colspan="5" class="empty">No packages match your search.</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </section>
+    </div>
+</body>
+</html>

+ 29 - 0
src/pages/main.html

@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <pstm-name>main</pstm-name>
+    <pstm-title>usm-web</pstm-title>
+    <link rel="stylesheet" pstm-res-href="main.css">
+    <script pstm-res-src="statum.js"></script>
+</head>
+<body>
+    <header class="site-header">
+        <div class="wrap site-header-inner">
+            <a class="brand" href="/"><span class="brand-mark">▲</span>usm-web</a>
+            <span class="tagline">USM repository browser &amp; serving frontend</span>
+        </div>
+    </header>
+    <main class="wrap">
+        <div stm-preloader>Loading…</div>
+        <div stm-content hidden>
+            <pstm-content></pstm-content>
+        </div>
+    </main>
+    <footer class="site-footer">
+        <div class="wrap">Served by <strong>usm-web</strong> — browse the table above, point USM at the
+            repository URI, or fetch packages straight from this host.</div>
+    </footer>
+</body>
+</html>

+ 80 - 0
src/pages/package.html

@@ -0,0 +1,80 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/package/{file}</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <div stm-if="package != null">
+        <p class="breadcrumb"><a href="/">← All packages</a></p>
+
+        <section class="card hero">
+            <div class="detail-head">
+                <div>
+                    <h1 stm-text="package.name">package</h1>
+                    <p class="version mono" stm-text="package.version">0.0</p>
+                    <p class="summary" stm-text="package.summary">summary</p>
+                </div>
+                <a class="button" stm-attribute.href="package.download_href">Download
+                    <span stm-text="package.size">0 B</span></a>
+            </div>
+            <dl class="stats">
+                <div class="stat-wide"><dt>SHA-512</dt>
+                    <dd class="mono fingerprint" stm-text="package.sha512">—</dd></div>
+                <div><dt>Archive</dt><dd class="mono" stm-text="package.file">file.usmc</dd></div>
+                <div><dt>On-disk size</dt><dd class="mono" stm-text="package.size">—</dd></div>
+                <div><dt>Modified</dt><dd class="mono" stm-text="package.modified">—</dd></div>
+            </dl>
+            <div class="chips" stm-if="package.flags.length > 0">
+                <span class="chip mono" stm-for-flag-in="package.flags" stm-text="flag">flag</span>
+            </div>
+        </section>
+
+        <section class="card" stm-if="package.licences.length > 0">
+            <h2>Licences</h2>
+            <div class="table-scroll">
+                <table>
+                    <thead><tr><th>Name</th><th>Category</th></tr></thead>
+                    <tbody>
+                        <tr stm-for-licence-in="package.licences">
+                            <td stm-text="licence.name">name</td>
+                            <td stm-text="licence.category">category</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </section>
+
+        <section class="card">
+            <h2>Provides</h2>
+            <div class="table-scroll">
+                <table>
+                    <thead><tr><th>Resource</th><th>Path</th></tr></thead>
+                    <tbody>
+                        <tr stm-for-provide-in="package.provides">
+                            <td class="mono" stm-text="provide.ref_name">ref</td>
+                            <td class="mono" stm-if="provide.path != ''" stm-text="provide.path">path</td>
+                            <td class="muted" stm-else>as expected</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </section>
+
+        <section class="card" stm-if="package.depends.length > 0">
+            <h2>Depends</h2>
+            <div class="table-scroll">
+                <table>
+                    <thead><tr><th>Phase</th><th>Requires</th></tr></thead>
+                    <tbody>
+                        <tr stm-for-depend-in="package.depends">
+                            <td stm-text="depend.phase">phase</td>
+                            <td class="mono" stm-text="depend.ref_name">ref</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </section>
+    </div>
+</body>
+</html>

+ 10 - 0
usm-scripts/build.sh

@@ -0,0 +1,10 @@
+#!/bin/bash
+set -e
+
+src_dir=$(pwd)
+build_dir=$1
+
+cd ${build_dir}
+
+meson setup ${src_dir} --prefix=${PREFIX} --libdir=${LIBDIR} --bindir=${BINDIR} --includedir=${INCLUDEDIR}
+ninja

+ 10 - 0
usm-scripts/install.sh

@@ -0,0 +1,10 @@
+#!/bin/bash
+set -e
+
+src_dir=$(pwd)
+build_dir=$1
+install_dir=$2
+
+cd ${build_dir}
+
+meson install --destdir ${install_dir}