Quellcode durchsuchen

feat: multi-repository support (scan or explicit list), repo-scoped routing, cross-repo search, per-repo instructions

clanker vor 1 Woche
Ursprung
Commit
8f7ce85672

+ 55 - 33
README.md

@@ -1,7 +1,8 @@
 # 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
+A Statum + Spry application that browses **and serves** any number of USM
+repositories: human-facing pages for exploring packages (an all-repository
+overview plus per-repository browse views), plus the exact HTTP endpoints a
 real USM client (`usm repository list` / `verify` / `install`) expects to
 fetch from a repository host.
 
@@ -9,16 +10,19 @@ fetch from a repository host.
 
 | 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) |
+| `GET /` | Overview: every repository (name, summary, package count, total size), per-repo add-repository instructions, the global install-USM block, and a combined package table with repo badges + server-side search across all repositories |
+| `GET /repo/{name}` | Single-repo browse: that repository's overview card, instructions and package table, search scoped to it |
+| `GET /repo/{name}/package/{file}` | Package detail: manifest fields read out of the `.usmc` (licences, flags, provides/depends tables), sha512, on-disk size/mtime, download button |
+| `GET /repo/{name}/PACKAGES.usml` | That repository's listing streamed **verbatim** (`application/json`) |
+| `GET /repo/{name}/repo.usmr` | A rewritten copy of the `.usmr` with its embedded `url` replaced by the derived base + `repo/{name}/` |
+| `GET /repo/{name}/{file}.usmc` | Package archives streamed from that repository's `public/` (`application/octet-stream`, exact content-length) |
+| `GET /install-usm.sh` | The configured installer script (`installer.path` mode only), served by the root-level catch-all |
 
 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.
+so clients behind a reverse proxy resolve against the public name. Each
+repository is addressed by the `name` in its `.usmr`, which is also its
+routing segment.
 
 ## Configuration
 
@@ -30,41 +34,50 @@ carries the static Statum keys plus an optional `"usm-web"` section:
   "statum": { "…": "static keys from spry keys / statum-genkeys" },
   "usm-web": {
     "repo": "/repo",
+    "repositories": ["web-stack", "extra"],
     "installer": { "path": "install-usm.sh" },
     "base_url": "https://repo.example.com/",
-    "name": "My repository"
+    "name": "My repositories"
   }
 }
 ```
 
 | Key | Meaning |
 |---|---|
-| `repo` | USM repository root (the directory holding the `.usmr` and `public/`); relative paths resolve against the config file's directory |
+| `repo` | The parent directory holding the repositories (each a subdirectory with a `.usmr` and `public/`); relative paths resolve against the config file's directory |
+| `repositories` | Optional explicit list of repository subdirectories to serve (in that order); when absent or empty every subdirectory containing a `.usmr` is discovered by scanning |
 | `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 |
+| `name` | Optional overview-title override |
 
 `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 repository parent is selected by (in order) the `--repo <dir>` argument,
 the `USM_WEB_REPO_DIR` environment variable, the config's `repo` key, or the
-`/repo` default, and is expected to look like a published repository:
+`/repo` default, and is expected to look like:
 
 ```
 /repo
-├── web-stack.usmr
-├── keys/            (not used by usm-web; signing is upstream)
-└── public/
-    ├── PACKAGES.usml
-    └── <name>-<version>.usmc
+├── web-stack/
+│   ├── web-stack.usmr
+│   ├── keys/            (not used by usm-web; signing is upstream)
+│   └── public/
+│       ├── PACKAGES.usml
+│       └── <name>-<version>.usmc
+└── extra/
+    └── …
 ```
 
-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.
+A legacy single-repository layout with the `.usmr` directly inside the
+configured directory is still served as one repository. Discovery re-runs on
+every request, so repositories added or removed on disk are picked up without
+a restart, and each repository's `.usmr` and `PACKAGES.usml` re-read when
+their mtimes change — one repository can be rebuilt without disturbing the
+others. There is **no database and no authentication**; all Statum state is
+stateless PAGE slots.
 
 ## Development
 
@@ -89,23 +102,25 @@ 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
+./builddir/usm-web 8080 --repo ../repos
 ```
 
-Repo parsing uses libusm (`Usm.Repository`, `Usm.RepositoryListing`,
-`Usm.Manifest.from_package`) — no hand-rolled usml/usmr mappers.
+`--repo` points at the parent directory holding the repositories (see the
+layout above). 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/UsmWebConfig.vala` | the `"usm-web"` config section (repo parent, repositories, installer url\|path, base_url, name) |
+| `src/RepositoryService.vala` | multi-repository discovery + libusm-backed read side with per-repo mtime 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/PackagesState.vala` | shared `packages` slot-state builder (overview + repo-scoped) and size/fingerprint formatting |
+| `src/entrypoints/` | `HomeEntrypoint` (/), `RepoBrowseEntrypoint` (/repo/{name}) and `PackageDetailEntrypoint` (/repo/{name}/package/{file}) |
+| `src/actions/` | `SearchPackagesAction` — server-side table filtering (all repositories or one) |
 | `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` |
@@ -120,10 +135,14 @@ blocks and are idempotent.
 
 ## Deployment (USM)
 
+USM itself installs from the canonical installer URL
+(`https://packages.astrologue.nz/install-usm.sh`); pass
+`--installer-url file://…` to carry a locally built installer into the
+image instead.
+
 ```bash
 spry deploy --verbose \
-  --repository ../web-stack-repo/web-stack.usmr \
-  --installer-url file:///tmp/kilo/usm-shim/install-usm-full.sh
+  --repository ../web-stack-repo/web-stack.usmr
 ```
 
 `--system {fedora,debian,ubuntu,alpine,gentoo}` is shorthand for the
@@ -148,8 +167,11 @@ 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 /path/to/repos-parent:/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
 ```
+
+`/repo` inside the container is the repositories **parent** directory: every
+subdirectory holding a `.usmr` is served at `/repo/<name>/…`.

+ 8 - 0
meson.build

@@ -37,6 +37,12 @@ home_page = custom_target('home-page',
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'HomePage', '--ns', 'UsmWeb', '@INPUT@'],
     depend_files: files('src/pages/main.html')
 )
+repo_browse_page = custom_target('repo-browse-page',
+    input: 'src/pages/repo.html',
+    output: 'RepoBrowsePage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'RepoBrowsePage', '--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',
@@ -53,6 +59,7 @@ app_sources = files(
     'src/DerivedBase.vala',
     'src/PackagesState.vala',
     'src/entrypoints/HomeEntrypoint.vala',
+    'src/entrypoints/RepoBrowseEntrypoint.vala',
     'src/entrypoints/PackageDetailEntrypoint.vala',
     'src/actions/SearchPackagesAction.vala',
     'src/endpoints/PackagesListingEndpoint.vala',
@@ -66,6 +73,7 @@ app_generated = [
     # spry:generated-begin
     main_css_resource,
     home_page,
+    repo_browse_page,
     package_detail_page,
     # spry:generated-end
 ]

+ 105 - 34
src/PackagesState.vala

@@ -6,20 +6,38 @@ using Usm;
 
 namespace UsmWeb {
 
-    /** One package row in the homepage table. */
+    /** One package row in the overview and single-repo tables. */
     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 = ""; }
+        public string repo { 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
+     * One repository summary in the overview: the stats card fields plus the
+     * per-repository add-repository instruction fields ({@link base_uri},
+     * {@link usmr_href}, {@link repos_d_snippet}).
+     */
+    public class RepoSummary : Object {
+        public string name { get; set; default = ""; }
+        public string summary { get; set; default = ""; }
+        public int package_count { get; set; }
+        public string total_size { get; set; default = ""; }
+        public string href { get; set; default = ""; }
+        public string key_fingerprint { get; set; default = ""; }
+        public string base_uri { get; set; default = ""; }
+        public string usmr_href { get; set; default = ""; }
+        public string repos_d_snippet { get; set; default = ""; }
+    }
+
+    /**
+     * Authors the `packages` PAGE-slot state shared by the overview and
+     * single-repo entrypoints and the search action: repository summaries,
+     * the add-repository / install-USM instruction blocks, and the (possibly
+     * query-filtered) package table. The lists are authored as JSON arrays
      * because collection-typed GObject properties are write-side only.
      */
     public class PackagesState : Object {
@@ -27,52 +45,96 @@ namespace UsmWeb {
         public const string SLOT_TYPE = "packages";
 
         /**
-         * Builds the slot state: `repo` fields + instruction blocks + rows
+         * Builds the slot state: a repo-scoped view when `repo_name` names a
+         * repository (its overview card, instruction block and table rows),
+         * otherwise the all-repositories overview (summary cards with
+         * per-repository add instructions, and one combined table). Rows are
          * 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>();
+                ActionRegistry action_registry, string base_uri, string query, string? repo_name = null) throws Error {
+            var repo_rows = new Vector<RepoSummary>();
+            var package_rows = new Vector<PackageRow>();
             int64 total_bytes = 0;
+            uint total_count = 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)) {
+            foreach (var entry in repositories.entries()) {
+                var repo = entry.repo();
+                if (repo_name != null && repo.name != repo_name) {
                     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)
+
+                int64 repo_bytes = 0;
+                uint repo_count = 0;
+                var rows = new Vector<PackageRow>();
+                foreach (var listing_entry in entry.listing_entries()) {
+                    var file = Path.get_basename(listing_entry.path);
+                    int64 size = file_size(entry.package_location(file));
+                    repo_bytes += size;
+                    repo_count++;
+
+                    if (!matches(listing_entry, query)) {
+                        continue;
+                    }
+                    rows.add(new PackageRow() {
+                        name = listing_entry.manifest.name,
+                        version = listing_entry.manifest.version.to_string(),
+                        summary = listing_entry.manifest.summary,
+                        size = format_size(size),
+                        file = file,
+                        repo = repo.name
+                    });
+                }
+                matched += rows.length;
+                package_rows.add_all(rows);
+
+                repo_rows.add(new RepoSummary() {
+                    name = repo.name,
+                    summary = repo.summary,
+                    package_count = (int) repo_count,
+                    total_size = format_size(repo_bytes),
+                    href = "/repo/" + repo.name,
+                    key_fingerprint = fingerprint(repo.key.to_hex()),
+                    base_uri = base_uri + "repo/" + repo.name + "/",
+                    usmr_href = base_uri + "repo/" + repo.name + "/repo.usmr",
+                    repos_d_snippet = "curl -fsSL -o /etc/usm/repos.d/%s.usmr %srepo/%s/repo.usmr"
+                        .printf(repo.name, base_uri, repo.name)
                 });
+                total_bytes += repo_bytes;
+                total_count += repo_count;
+            }
+
+            if (repo_name != null && repo_rows.length == 0) {
+                throw new UsmWebError.INVALID_CONFIGURATION(@"No repository named \"$repo_name\"");
             }
 
             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));
+            if (repo_name == null) {
+                dict.set_native<string>("site_name", config.display_name ?? "Repositories");
+                dict.set_native<int>("repo_count", (int) repo_rows.length);
+                dict.set_native<int>("package_count", (int) total_count);
+                dict.set_native<string>("total_size", format_size(total_bytes));
+                dict["repos"] = repo_list(repo_rows);
+            } else {
+                var summary = repo_rows.first();
+                dict.set_native<string>("repo_name", summary.name);
+                dict.set_native<string>("repo_summary", summary.summary);
+                dict.set_native<string>("key_fingerprint", summary.key_fingerprint);
+                dict.set_native<int>("package_count", summary.package_count);
+                dict.set_native<string>("total_size", summary.total_size);
+                dict.set_native<string>("base_uri", summary.base_uri);
+                dict.set_native<string>("usmr_href", summary.usmr_href);
+                dict.set_native<string>("repos_d_snippet", summary.repos_d_snippet);
+            }
             dict.set_native<string>("query", query);
-            dict.set_native<string>("result_note", result_note(query, matched, entries.count()));
+            dict.set_native<string>("result_note", result_note(query, matched, total_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);
+            dict["packages"] = list(package_rows);
 
             return new State() {
                 type_name = SLOT_TYPE,
@@ -91,7 +153,7 @@ namespace UsmWeb {
                 || entry.manifest.summary.casefold().contains(needle);
         }
 
-        /** Builds the rows' JSON array (the {@link StateJson.list} pattern). */
+        /** Builds the package 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) {
@@ -100,6 +162,15 @@ namespace UsmWeb {
             return new JsonElement.from_elements(arr);
         }
 
+        /** Builds the repository summaries' JSON array. */
+        public static JsonElement repo_list(Lot<RepoSummary> 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();

+ 255 - 54
src/RepositoryService.vala

@@ -1,59 +1,64 @@
 using Invercargill;
+using Invercargill.DataStructures;
 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`.
+     * One on-disk USM repository discovered under the configured parent
+     * directory: a subdirectory holding a `.usmr` beside a `public/`
+     * directory, built on libusm via {@link Usm.Repository.from_file} and
+     * {@link Usm.RepositoryListing.from_stream}.
      *
-     * 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.
+     * Each entry tracks its `.usmr` and `public/PACKAGES.usml` mtimes
+     * independently, so one repository can be rebuilt and re-read without
+     * disturbing the others.
      */
-    public class RepositoryService : Object {
+    public class RepositoryEntry : Object {
 
-        private UsmWebConfig config = Inversion.inject<UsmWebConfig>();
+        /** The repository's directory (absolute). */
+        public string dir_path { get; private set; }
+
+        /** The absolute path of the `.usmr` inside {@link dir_path}. */
+        public string usmr_path { get; private set; }
 
         private Repository? repository;
         private RepositoryListing? listing;
-        private string? usmr_path;
         private int64 usmr_mtime;
         private int64 listing_mtime;
 
+        public RepositoryEntry(string dir_path, string usmr_path) {
+            this.dir_path = dir_path;
+            this.usmr_path = usmr_path;
+        }
+
+        /** Whether both the `.usmr` and the listing are currently loaded. */
+        public bool is_loaded {
+            get { return repository != null && listing != null; }
+        }
+
         /**
          * 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.
+         * (or was never loaded); a failure throws while keeping the
+         * previously loaded snapshot, so an in-flight rebuild cannot lose
+         * what is already held.
          */
         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
+            int64 current_usmr_mtime = file_mtime(usmr_path);
+            int64 current_listing_mtime = file_mtime(listing_location());
+            if (is_loaded
                     && 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;
+                repository = new Repository.from_file(usmr_path);
                 usmr_mtime = current_usmr_mtime;
             }
 
-            if (current_listing_mtime != listing_mtime || listing == null) {
-                var file = File.new_for_path(listing_path());
+            if (listing == null || current_listing_mtime != listing_mtime) {
+                var file = File.new_for_path(listing_location());
                 listing = new RepositoryListing.from_stream(new DataInputStream(file.read()));
                 listing_mtime = current_listing_mtime;
             }
@@ -66,14 +71,14 @@ namespace UsmWeb {
         }
 
         /** The loaded `PACKAGES.usml` entries, in listing order. */
-        public Enumerable<RepositoryListingEntry> entries() throws Error {
+        public Enumerable<RepositoryListingEntry> listing_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()) {
+            foreach (var entry in listing_entries()) {
                 if (Path.get_basename(entry.path) == file) {
                     return entry;
                 }
@@ -81,51 +86,247 @@ namespace UsmWeb {
             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();
+            return Path.build_filename(dir_path, "public", "PACKAGES.usml");
         }
 
         /** The absolute path of a package archive inside `public/`. */
         public string package_location(string file) {
-            return Path.build_filename(config.repo_dir, "public", file);
+            return Path.build_filename(dir_path, "public", file);
+        }
+
+        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;
+            }
+        }
+    }
+
+    /**
+     * Read-side access to the on-disk USM repositories this app browses and
+     * serves.
+     *
+     * The repositories live under {@link UsmWebConfig.repo_dir}: every
+     * subdirectory containing a `.usmr` is discovered by scanning, or only
+     * those named by {@link UsmWebConfig.repositories} are used when that
+     * list is configured. A repository is addressed by the name in its
+     * `.usmr`, which is also its routing segment (`/repo/<name>/…`). A
+     * legacy single-repository layout with the `.usmr` directly inside the
+     * configured directory is still served as one repository.
+     *
+     * Discovery re-runs on every {@link ensure_loaded}, so repositories
+     * added or removed on disk are picked up without a restart; each
+     * repository's `.usmr` and `PACKAGES.usml` reload independently when
+     * their mtimes change. A repository that fails to (re)load is skipped
+     * with a warning rather than taking the whole app down.
+     */
+    public class RepositoryService : Object {
+
+        private UsmWebConfig config = Inversion.inject<UsmWebConfig>();
+
+        private Vector<RepositoryEntry> discovered = new Vector<RepositoryEntry>();
+        private Vector<RepositoryEntry> loaded = new Vector<RepositoryEntry>();
+        private Dictionary<string, RepositoryEntry> entries_by_dir = new Dictionary<string, RepositoryEntry>();
+        private string? legacy_dir;
+
+        /**
+         * Discovers the repositories under the configured parent directory
+         * and (re)loads each one. Throws when nothing is configured or
+         * discovered, or when nothing discovered can be loaded; individual
+         * failures only warn and drop that repository until it loads again.
+         */
+        public void ensure_loaded() throws Error {
+            var current = discover();
+
+            var still_current = new Vector<RepositoryEntry>();
+            Error? first_failure = null;
+            foreach (var entry in current) {
+                try {
+                    entry.ensure_loaded();
+                    still_current.add(entry);
+                } catch (Error e) {
+                    warning("[usm-web] Repository at %s failed to load: %s\n", entry.dir_path, e.message);
+                    if (first_failure == null) {
+                        first_failure = e;
+                    }
+                }
+            }
+
+            var unique = new Vector<RepositoryEntry>();
+            var seen_names = new Dictionary<string, RepositoryEntry>();
+            foreach (var entry in still_current) {
+                var name = entry.repo().name;
+                RepositoryEntry? seen = null;
+                seen_names.try_get(name, out seen);
+                if (seen != null) {
+                    warning("[usm-web] \"%s\" is also served by %s — keeping %s\n",
+                        name, entry.dir_path, ((!)seen).dir_path);
+                    continue;
+                }
+                seen_names.set(name, entry);
+                unique.add(entry);
+            }
+
+            discovered.clear();
+            discovered.add_all(current);
+            loaded.clear();
+            loaded.add_all(unique);
+
+            if (loaded.length == 0) {
+                if (first_failure != null) {
+                    throw first_failure;
+                }
+                throw new UsmWebError.INVALID_CONFIGURATION(
+                    @"No repositories found under \"$(config.repo_dir)\" — is the parent directory mounted?");
+            }
         }
 
-        private string listing_path() {
-            return Path.build_filename(config.repo_dir, "public", "PACKAGES.usml");
+        /** The currently loadable repositories, in discovery order. */
+        public Enumerable<RepositoryEntry> entries() throws Error {
+            ensure_loaded();
+            return loaded;
         }
 
-        private string? find_usmr() {
+        /**
+         * The repository whose `.usmr` is named `name`, or null when no
+         * loaded repository carries that name. A discovered repository that
+         * cannot be read surfaces its error (so callers can 500 rather than
+         * 404) unless another repository matches cleanly first.
+         */
+        public RepositoryEntry? find(string name) throws Error {
+            ensure_loaded();
+
+            Error? first_failure = null;
+            foreach (var entry in discovered) {
+                string entry_name;
+                try {
+                    entry_name = entry.repo().name;
+                } catch (Error e) {
+                    if (first_failure == null) {
+                        first_failure = e;
+                    }
+                    continue;
+                }
+                if (entry_name == name) {
+                    return entry;
+                }
+            }
+
+            if (first_failure != null) {
+                throw first_failure;
+            }
+            return null;
+        }
+
+        /**
+         * Discovers repository directories: the configured subdirectories
+         * when {@link UsmWebConfig.repositories} names them, otherwise every
+         * subdirectory holding a `.usmr`, falling back to the legacy
+         * single-repository layout. Already-known directories keep their
+         * entry (and loaded snapshot).
+         */
+        private Vector<RepositoryEntry> discover() throws Error {
+            var result = new Vector<RepositoryEntry>();
+            var dir_names = new Vector<string>();
+
+            if (config.repositories.length > 0) {
+                foreach (var name in config.repositories) {
+                    dir_names.add(name);
+                }
+            } else {
+                try {
+                    var dir = Dir.open(config.repo_dir);
+                    string? child;
+                    while ((child = dir.read_name()) != null) {
+                        dir_names.add((!)child);
+                    }
+                } catch (Error e) {
+                    throw new UsmWebError.INVALID_CONFIGURATION(
+                        @"Cannot read repository parent \"$(config.repo_dir)\": $(e.message)");
+                }
+            }
+
+            foreach (var name in dir_names.order_by<string>(n => n, Operators.comparison<string>())) {
+                var child_path = Path.build_filename(config.repo_dir, name);
+                if (!FileUtils.test(child_path, FileTest.IS_DIR)) {
+                    if (config.repositories.length > 0) {
+                        throw new UsmWebError.INVALID_CONFIGURATION(
+                            @"\"$name\" from the configured repositories list is not a directory under \"$(config.repo_dir)\"");
+                    }
+                    continue;
+                }
+                var usmr_path = find_usmr(child_path);
+                if (usmr_path == null) {
+                    if (config.repositories.length > 0) {
+                        throw new UsmWebError.INVALID_CONFIGURATION(
+                            @"\"$name\" from the configured repositories list has no *.usmr under \"$(config.repo_dir)\"");
+                    }
+                    continue;
+                }
+                result.add(entry_for(child_path, (!)usmr_path));
+            }
+
+            if (result.length == 0) {
+                var legacy_usmr = find_usmr(config.repo_dir);
+                if (legacy_usmr != null) {
+                    if (legacy_dir != config.repo_dir) {
+                        legacy_dir = config.repo_dir;
+                        warning("[usm-web] \"$(config.repo_dir)\" holds a .usmr directly — serving it as a single repository (move it into a subdirectory for the multi-repository layout)\n");
+                    }
+                    result.add(entry_for(config.repo_dir, (!)legacy_usmr));
+                }
+            }
+
+            drop_stale(result);
+            return result;
+        }
+
+        /** Reuses the entry for `dir_path` when discovery finds it again. */
+        private RepositoryEntry entry_for(string dir_path, string usmr_path) {
+            RepositoryEntry? existing = null;
+            entries_by_dir.try_get(dir_path, out existing);
+            if (existing != null) {
+                return (!)existing;
+            }
+            var created = new RepositoryEntry(dir_path, usmr_path);
+            entries_by_dir.set(dir_path, created);
+            return created;
+        }
+
+        /** Forgets entries whose directory is no longer discovered. */
+        private void drop_stale(Vector<RepositoryEntry> current) {
+            var current_dirs = new Dictionary<string, RepositoryEntry>();
+            foreach (var entry in current) {
+                current_dirs.set(entry.dir_path, entry);
+            }
+            foreach (var dir_path in entries_by_dir.keys.to_array()) {
+                if (!current_dirs.has(dir_path)) {
+                    entries_by_dir.remove(dir_path);
+                }
+            }
+        }
+
+        /** The first `*.usmr` directly inside `dir`, or null when there is none. */
+        private static string? find_usmr(string dir) {
             try {
-                var dir = Dir.open(config.repo_dir);
+                var handle = Dir.open(dir);
                 string? name = null;
                 string? found = null;
-                while ((name = dir.read_name()) != null) {
+                while ((name = handle.read_name()) != null) {
                     if (name.has_suffix(".usmr")) {
                         found = name;
                         break;
                     }
                 }
-                return found == null ? null : Path.build_filename(config.repo_dir, (!)found);
+                return found == null ? null : Path.build_filename(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;
-            }
-        }
     }
 }

+ 18 - 0
src/Static/main.css

@@ -180,6 +180,24 @@ a:hover { text-decoration: none; }
 
 .breadcrumb { margin: 0 0 0.9rem; font-size: 0.9rem; }
 
+.repo-block { padding-top: 1.1rem; border-top: 1px solid var(--line); margin-top: 1.1rem; }
+.repo-block:first-of-type { border-top: none; padding-top: 0; margin-top: 0; }
+.repo-block h3 { margin: 0 0 0.25rem; font-size: 1rem; }
+.repo-block .summary { margin-top: 0; }
+
+.badge {
+    display: inline-block;
+    text-decoration: none;
+    border: 1px solid var(--line);
+    border-radius: 999px;
+    padding: 0.05rem 0.6rem;
+    background: var(--accent-soft);
+    color: var(--accent);
+    font-size: 0.78rem;
+    font-weight: 600;
+    white-space: nowrap;
+}
+
 .detail-head {
     display: flex;
     justify-content: space-between;

+ 61 - 18
src/UsmWebConfig.vala

@@ -40,25 +40,32 @@ namespace UsmWeb {
      * {
      *   "usm-web": {
      *     "repo": "/repo",
+     *     "repositories": ["web-stack", "extra"],
      *     "installer": { "path": "install-usm.sh" },
      *     "base_url": "https://repo.example.com/",
-     *     "name": "My repository"
+     *     "name": "My repositories"
      *   }
      * }
      * ```
      *
-     * `repo` is the USM repository root (the directory containing the
-     * `.usmr` and `public/`); a relative value resolves against the
-     * directory of the config file that declared it. It applies only when
-     * neither the `--repo` argument nor `USM_WEB_REPO_DIR` is given
-     * (precedence: argument > environment > config > `/repo`).
+     * `repo` is the parent directory holding the repositories this app
+     * serves: each is a subdirectory containing a `.usmr` and a `public/`
+     * directory. A relative value resolves against the directory of the
+     * config file that declared it. It applies only when neither the
+     * `--repo` argument nor `USM_WEB_REPO_DIR` is given (precedence:
+     * argument > environment > config > `/repo`).
+     *
+     * `repositories` optionally names the subdirectories to serve (in that
+     * order); when absent (or empty) every subdirectory containing a `.usmr`
+     * is discovered by scanning. A single-repository layout with the `.usmr`
+     * directly inside `repo` is still served as one 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`.
+     * and `name` overrides the overview title.
      */
     public class UsmWebConfig : GLib.Object {
 
@@ -74,19 +81,30 @@ namespace UsmWeb {
         /** 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. */
+        /** The overview-title override, or null for the default heading. */
         public string? display_name { get; private set; }
 
-        /** The USM repository root (contains the `.usmr` and `public/`). */
+        /**
+         * The parent directory holding the served repositories (each a
+         * subdirectory with a `.usmr` and `public/`).
+         */
         public string repo_dir { get; private set; default = "/repo"; }
 
+        /**
+         * The explicitly listed repository subdirectories under
+         * {@link repo_dir}; empty means scan for every subdirectory holding
+         * a `.usmr`.
+         */
+        public string[] repositories { get; private set; default = new string[0]; }
+
         /**
          * 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`/`repo` resolves against the
-         * config file that declared it (later files win, mirroring WebConfig
-         * layering). {@link repo_dir} wins when given; otherwise the config's
-         * `repo` applies, falling back to `/repo`.
+         * and the `repositories` array are readable and a relative
+         * `path`/`repo` resolves against the config file that declared it
+         * (later files win, mirroring WebConfig layering). {@link repo_dir}
+         * wins when given; otherwise the config's `repo` applies, falling
+         * back to `/repo`.
          */
         public static UsmWebConfig load(WebConfig config, string? repo_dir = null) throws Error {
             var result = new UsmWebConfig();
@@ -95,6 +113,7 @@ namespace UsmWeb {
             string declaring_dir = Environment.get_current_dir();
             string? declared_repo = null;
             string repo_declaring_dir = declaring_dir;
+            string[]? declared_repositories = null;
             foreach (var file in config.get_loaded_files()) {
                 var section = read_section(file);
                 if (section == null) {
@@ -115,6 +134,11 @@ namespace UsmWeb {
                     repo_declaring_dir = GLib.Path.get_dirname((!)file);
                 }
 
+                var repositories = read_string_array_member(obj, "repositories");
+                if (repositories != null) {
+                    declared_repositories = repositories;
+                }
+
                 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) {
@@ -131,6 +155,10 @@ namespace UsmWeb {
                     ? repo : GLib.Path.build_filename(repo_declaring_dir, repo);
             }
 
+            if (declared_repositories != null) {
+                result.repositories = (!)declared_repositories;
+            }
+
             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");
@@ -147,11 +175,6 @@ namespace UsmWeb {
             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 + "/";
         }
@@ -185,5 +208,25 @@ namespace UsmWeb {
             }
             return node.get_node_type() == NodeType.VALUE ? node.get_string() : null;
         }
+
+        /** The string values of `member` (when it is a JSON array), or null. */
+        private static string[]? read_string_array_member(Json.Object obj, string member) {
+            if (!obj.has_member(member)) {
+                return null;
+            }
+            var node = obj.get_member(member);
+            if (node.get_node_type() != NodeType.ARRAY) {
+                return null;
+            }
+            var array = node.get_array();
+            var values = new string[0];
+            for (var i = 0; i < array.get_length(); i++) {
+                var element = array.get_element(i);
+                if (element.get_node_type() == NodeType.VALUE) {
+                    values += element.get_string();
+                }
+            }
+            return values;
+        }
     }
 }

+ 8 - 3
src/actions/SearchPackagesAction.vala

@@ -6,10 +6,12 @@ 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).
+     * substring over name and summary). A `repo` form field scopes the
+     * rebuild to that repository's browse view; without it the filtered
+     * table is rebuilt across **all** repositories.
      *
      * Collection-typed slot fields cannot be read back from held state, so
-     * the filtered rows are rebuilt from the repository (the documented
+     * the filtered rows are rebuilt from the repositories (the documented
      * rebuild-from-source pattern) and written as one slot update keyed by
      * the held slot's key.
      */
@@ -21,8 +23,10 @@ namespace UsmWeb {
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             string query = "";
+            string repo = "";
             if (request.form != null) {
                 query = (request.form.get_field("query") ?? "").strip();
+                repo = (request.form.get_field("repo") ?? "").strip();
             }
 
             HeldSlot held;
@@ -33,7 +37,8 @@ namespace UsmWeb {
             var base_uri = DerivedBase.derive(http_context.request, config);
             State state;
             try {
-                state = PackagesState.build(repositories, config, action_registry, base_uri, query);
+                state = PackagesState.build(repositories, config, action_registry, base_uri, query,
+                    repo.length > 0 ? repo : null);
             } catch (Error e) {
                 return directives().notify("error", @"Repository load failed: $(e.message)");
             }

+ 16 - 7
src/endpoints/InstallerScriptEndpoint.vala

@@ -3,9 +3,12 @@ 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
+     * `GET /{file}` — the root-level catch-all, serving **only**
+     * `/install-usm.sh` (the configured installer in `path` mode) so a fresh
+     * machine can bootstrap USM straight from this app: `curl -fsSL
+     * <base>/install-usm.sh | sh`. Any other single-segment request 404s —
+     * listings, downloads and rewritten definitions all live under
+     * `/repo/<name>/…`. Only registered (last) when
      * {@link UsmWebConfig.installer} is {@link InstallerSource.PATH}.
      */
     public class InstallerScriptEndpoint : Object, Endpoint {
@@ -13,20 +16,26 @@ namespace UsmWeb {
         private UsmWebConfig config = Inversion.inject<UsmWebConfig>();
 
         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 != "install-usm.sh") {
+                return new HttpStringResult("Not Found", StatusCode.NOT_FOUND);
+            }
+
             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()) {
+            var installer = File.new_for_path((!)path);
+            if (!installer.query_exists()) {
                 return new HttpStringResult("Configured installer script is missing",
                     StatusCode.INTERNAL_SERVER_ERROR);
             }
 
-            var info = yield file.query_info_async(
+            var info = yield installer.query_info_async(
                 FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE);
-            var result = new HttpStreamResult(yield file.read_async(), info.get_size());
+            var result = new HttpStreamResult(yield installer.read_async(), info.get_size());
             result.set_header("Content-Type", "text/x-shellscript");
             return result;
         }

+ 22 - 7
src/endpoints/PackageDownloadEndpoint.vala

@@ -3,27 +3,42 @@ 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).
+     * `GET /repo/{name}/{file}.usmc` — streams a package archive from that
+     * 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.
+     * The route pattern matches any third segment, so it must be registered
+     * after the exact `/repo/{name}/PACKAGES.usml` and
+     * `/repo/{name}/repo.usmr` routes; 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 name = "";
             string file = "";
+            route_context.mapped_parameters.try_get("name", out name);
             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);
+            RepositoryEntry? entry;
+            try {
+                entry = repositories.find(name);
+            } catch (Error e) {
+                return new HttpStringResult(@"Package download unavailable: $(e.message)",
+                    StatusCode.INTERNAL_SERVER_ERROR);
+            }
+            if (entry == null) {
+                return new HttpStringResult(@"No repository named \"$name\"", StatusCode.NOT_FOUND);
+            }
+
+            var path = ((!)entry).package_location(file);
             var handle = File.new_for_path(path);
             if (!handle.query_exists()) {
                 return new HttpStringResult("No such package", StatusCode.NOT_FOUND);

+ 18 - 4
src/endpoints/PackagesListingEndpoint.vala

@@ -3,16 +3,30 @@ 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).
+     * `GET /repo/{name}/PACKAGES.usml` — streams that repository's 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();
+            string name = "";
+            route_context.mapped_parameters.try_get("name", out name);
+
+            RepositoryEntry? entry;
+            try {
+                entry = repositories.find(name);
+            } catch (Error e) {
+                return new HttpStringResult(@"Repository listing unavailable: $(e.message)",
+                    StatusCode.INTERNAL_SERVER_ERROR);
+            }
+            if (entry == null) {
+                return new HttpStringResult(@"No repository named \"$name\"", StatusCode.NOT_FOUND);
+            }
+
+            var path = ((!)entry).listing_location();
             var file = File.new_for_path(path);
             if (!file.query_exists()) {
                 return new HttpStringResult("PACKAGES.usml not found", StatusCode.NOT_FOUND);

+ 22 - 6
src/endpoints/RepositoryDefinitionEndpoint.vala

@@ -4,10 +4,11 @@ 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.
+     * `GET /repo/{name}/repo.usmr` — serves a rewritten copy of that
+     * repository's on-disk `.usmr` with its embedded `url` replaced by the
+     * derived base ({@link DerivedBase}) plus `repo/<name>/`, 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 {
 
@@ -15,20 +16,35 @@ namespace UsmWeb {
         private UsmWebConfig config = Inversion.inject<UsmWebConfig>();
 
         public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error {
+            string name = "";
+            route_context.mapped_parameters.try_get("name", out name);
+
+            RepositoryEntry? entry;
+            try {
+                entry = repositories.find(name);
+            } catch (Error e) {
+                return new HttpStringResult(@"Repository definition unavailable: $(e.message)",
+                    StatusCode.INTERNAL_SERVER_ERROR);
+            }
+            if (entry == null) {
+                return new HttpStringResult(@"No repository named \"$name\"", StatusCode.NOT_FOUND);
+            }
+
             string contents;
             try {
-                FileUtils.get_contents(repositories.usmr_location(), out contents);
+                FileUtils.get_contents(((!)entry).usmr_path, out contents);
             } catch (Error e) {
                 return new HttpStringResult(@"Repository definition unavailable: $(e.message)",
                     StatusCode.INTERNAL_SERVER_ERROR);
             }
 
+            var repo = ((!)entry).repo();
             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);
+            root.get_object().set_string_member("url", base_uri + "repo/" + repo.name + "/");
 
             var result = new HttpStringResult(Json.to_string(root, false));
             result.set_header("Content-Type", "application/json");

+ 4 - 3
src/entrypoints/HomeEntrypoint.vala

@@ -6,9 +6,10 @@ 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.
+     * slot with the all-repository overview — summary cards with per-repo
+     * add-repository instructions, the global install-USM block, and the
+     * combined package table. When no repository can be read the page
+     * degrades to an error card instead of a failed hydration.
      */
     public class HomeEntrypoint : StatumEntrypoint {
 

+ 19 - 8
src/entrypoints/PackageDetailEntrypoint.vala

@@ -25,23 +25,33 @@ namespace UsmWeb {
     }
 
     /**
-     * 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.
+     * Entrypoint for /repo/{name}/package/{file}: hydrates the `package`
+     * PAGE slot from that repository's `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
+     * repositories redirect to the overview; unknown files redirect to the
+     * repository's browse page.
      */
     public class PackageDetailEntrypoint : StatumEntrypoint {
 
         protected RepositoryService repositories = Inversion.inject<RepositoryService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
+            var name = request.route("name") ?? "";
             var file = request.route("file") ?? "";
-            var entry = repositories.find_entry(file);
+
+            var entry = repositories.find(name);
             if (entry == null) {
                 return directives().navigate("/");
             }
 
-            var path = repositories.package_location(file);
+            var listing_entry = ((!)entry).find_entry(file);
+            if (listing_entry == null) {
+                return directives().navigate(@"/repo/$name");
+            }
+
+            var repo = ((!)entry).repo();
+            var path = ((!)entry).package_location(file);
             var manifest = new Manifest.from_package(path);
             var info = file_info(path);
 
@@ -49,11 +59,12 @@ namespace UsmWeb {
             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>("repo", repo.name);
             dict.set_native<string>("file", file);
-            dict.set_native<string>("download_href", "/" + file);
+            dict.set_native<string>("download_href", @"/repo/$name/$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());
+            dict.set_native<string>("sha512", ((!)listing_entry).sha512sum.to_hex());
 
             var flags = new JsonArray();
             foreach (var flag in manifest.flags) {

+ 44 - 0
src/entrypoints/RepoBrowseEntrypoint.vala

@@ -0,0 +1,44 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Statum;
+
+namespace UsmWeb {
+
+    /**
+     * Entrypoint for /repo/{name}: hydrates the
+     * {@link PackagesState.SLOT_TYPE} PAGE slot scoped to one repository —
+     * its overview card, add-repository instructions and package table.
+     * Unknown repositories redirect to the overview; a load failure degrades
+     * to an error card instead of a failed hydration.
+     */
+    public class RepoBrowseEntrypoint : 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 name = request.route("name") ?? "";
+            var base_uri = DerivedBase.derive(http_context.request, config);
+
+            State state;
+            try {
+                if (repositories.find(name) == null) {
+                    return directives().navigate("/");
+                }
+                state = PackagesState.build(repositories, config, action_registry, base_uri, "", name);
+            } 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));
+        }
+    }
+}

+ 9 - 6
src/main.vala

@@ -38,6 +38,7 @@ int main(string[] args) {
 
         // spry:pages-begin
         statum.add_page<HomePage, HomeEntrypoint>();
+        statum.add_page<RepoBrowsePage, RepoBrowseEntrypoint>();
         statum.add_page<PackageDetailPage, PackageDetailEntrypoint>();
         // spry:pages-end
 
@@ -49,14 +50,16 @@ int main(string[] args) {
         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"));
+        // USM-serving endpoints. The exact /repo/{name}/PACKAGES.usml and
+        // /repo/{name}/repo.usmr routes must precede /repo/{name}/{file}
+        // (which also matches them), and the /{file} catch-all — serving
+        // only /install-usm.sh — must come last.
+        application.add_endpoint<PackagesListingEndpoint>(new EndpointRoute("/repo/{name}/PACKAGES.usml"));
+        application.add_endpoint<RepositoryDefinitionEndpoint>(new EndpointRoute("/repo/{name}/repo.usmr"));
+        application.add_endpoint<PackageDownloadEndpoint>(new EndpointRoute("/repo/{name}/{file}"));
         if (config.installer == InstallerSource.PATH) {
-            application.add_endpoint<InstallerScriptEndpoint>(new EndpointRoute("/install-usm.sh"));
+            application.add_endpoint<InstallerScriptEndpoint>(new EndpointRoute("/{file}"));
         }
-        application.add_endpoint<PackageDownloadEndpoint>(new EndpointRoute("/{file}"));
 
         application.run();
         return 0;

+ 31 - 22
src/pages/home.html

@@ -7,19 +7,18 @@
 <body>
     <div stm-if="packages != null && packages.load_error != null && packages.load_error != ''"
          class="card error-card">
-        <h1>Repository unavailable</h1>
+        <h1>Repositories 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>
+            <h1 stm-text="packages.site_name">Repositories</h1>
+            <p class="summary">Every USM repository served by this host.</p>
             <dl class="stats">
+                <div><dt>Repositories</dt><dd stm-text="packages.repo_count">—</dd></div>
                 <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>
 
@@ -32,24 +31,30 @@
         </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>
+            <h2>Add a repository</h2>
+            <div class="repo-block" stm-for-repo-in="packages.repos" stm-key="repo.name">
+                <h3 stm-text="repo.name">repository</h3>
+                <p class="summary" stm-text="repo.summary">summary</p>
+                <p>Point USM at this repository — its URI is
+                    <code class="mono" stm-text="repo.base_uri">…</code></p>
+                <ol class="steps">
+                    <li>Download the signed repository definition:
+                        <a class="mono" stm-attribute.href="repo.usmr_href"
+                           stm-text="repo.usmr_href">repo.usmr</a></li>
+                    <li>Drop it into your repos.d:</li>
+                </ol>
+                <pre class="mono snippet"><code stm-text="repo.repos_d_snippet"></code></pre>
+                <p class="hint">Signing-key fingerprint:
+                    <span class="mono fingerprint" stm-text="repo.key_fingerprint">—</span></p>
+                <p class="hint">Browse the repository's
+                    <a stm-attribute.href="repo.href">packages</a>.</p>
+            </div>
         </section>
 
         <section class="card">
             <h2>Packages</h2>
             <form class="search" stm-action="packages.search">
-                <input type="search" name="query" placeholder="Search name or summary…"
+                <input type="search" name="query" placeholder="Search all repositories…"
                        stm-attribute.value="packages.query">
                 <button type="submit">Search</button>
             </form>
@@ -58,6 +63,7 @@
                 <table>
                     <thead>
                         <tr>
+                            <th>Repository</th>
                             <th>Name</th>
                             <th>Version</th>
                             <th>Summary</th>
@@ -66,16 +72,19 @@
                         </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>
+                        <tr stm-for-pkg-in="packages.packages" stm-key="pkg.repo + '/' + pkg.file">
+                            <td><a class="badge" stm-attribute.href="'/repo/' + pkg.repo"
+                               stm-text="pkg.repo">repo</a></td>
+                            <td><a stm-attribute.href="'/repo/' + pkg.repo + '/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>
+                               stm-attribute.href="'/repo/' + pkg.repo + '/' + pkg.file">⬇</a></td>
                         </tr>
                         <tr stm-if="packages.packages.length == 0">
-                            <td colspan="5" class="empty">No packages match your search.</td>
+                            <td colspan="6" class="empty">No packages match your search.</td>
                         </tr>
                     </tbody>
                 </table>

+ 3 - 2
src/pages/package.html

@@ -1,12 +1,13 @@
 <!DOCTYPE html>
 <html lang="en">
 <head>
-    <pstm-uri>/package/{file}</pstm-uri>
+    <pstm-uri>/repo/{name}/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>
+        <p class="breadcrumb"><a href="/">← All repositories</a>&nbsp;/&nbsp;<a
+            stm-attribute.href="'/repo/' + package.repo" stm-text="package.repo">repository</a></p>
 
         <section class="card hero">
             <div class="detail-head">

+ 90 - 0
src/pages/repo.html

@@ -0,0 +1,90 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/repo/{name}</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 == '')">
+        <p class="breadcrumb"><a href="/">← All repositories</a></p>
+
+        <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 repository — its 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.usmr_href"
+                       stm-text="packages.usmr_href">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="hidden" name="repo" stm-attribute.value="packages.repo_name">
+                <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="'/repo/' + pkg.repo + '/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="'/repo/' + pkg.repo + '/' + 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>