diff --git a/.github/workflows/php-analysis.yml b/.github/workflows/php-analysis.yml index 5666fb9..fed0461 100644 --- a/.github/workflows/php-analysis.yml +++ b/.github/workflows/php-analysis.yml @@ -7,8 +7,14 @@ name: PHP Analysis # • PHPStan — type/static analysis (non-blocking until a baseline lands) "on": + # `push` is limited to main — the same shape ci.yml uses. Listing master here + # too made every master->main PR run this workflow TWICE: the push event fires + # for refs/heads/master and the pull_request event for refs/pull/N/merge, and + # because the concurrency group is keyed on the ref, the two never collide. + # That is where the duplicate "composer audit", "PHPStan" and "Semgrep" checks + # on a PR came from. Changes to master still get analysed — through the PR. push: - branches: [main, master] + branches: [main] pull_request: branches: [main, master] schedule: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4dd44fe..a6502d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,9 +48,9 @@ jobs: # macOS universal binary is assembled with llvm-lipo. This needs only ONE # self-hosted Zig toolchain (Linux) and no macOS runner. - # ── Linux: .deb (amd64) ──────────────────────────────────────────────────── + # ── Linux: portable tarball (default install) + .deb (system-wide) ──────── build-linux: - name: Build Linux .deb + name: Build Linux tarball + .deb needs: test runs-on: ubuntu-22.04 steps: @@ -63,8 +63,16 @@ jobs: with: { php-version: "8.4", tools: composer } - name: Bundle (linux) run: VERSION="${RELEASE_VERSION:-${GITHUB_REF_NAME#v}}" ./tools/bundle.sh linux + # TWO artifacts. The tarball is the DEFAULT install path — user-local, + # no root (tools/install.sh). The .deb is for multi-user machines and CI + # images where a system-wide install and apt-managed PHP are the point. + - uses: actions/upload-artifact@v5 + with: { name: linux-tarball, path: dist/*linux*.tar.gz } - uses: actions/upload-artifact@v5 with: { name: linux-deb, path: dist/*.deb } + # Published alongside the assets so `curl | sh` works without a checkout. + - uses: actions/upload-artifact@v5 + with: { name: installer, path: tools/install.sh } # ── Windows: .zip (x86_64, cross-compiled) ──────────────────────────────── build-windows: @@ -135,9 +143,11 @@ jobs: # explicitly; on a tag push this matches GITHUB_REF_NAME anyway. tag_name: v${{ steps.notes.outputs.version }} files: | + artifacts/linux-tarball/*.tar.gz artifacts/linux-deb/*.deb artifacts/windows-zip/*.zip artifacts/macos-app/*.tar.gz + artifacts/installer/install.sh # Curated section (if present) goes first; GitHub appends the # auto-generated "What's Changed" / contributors below it. body_path: ${{ steps.notes.outputs.has_notes == 'true' && 'release-body.md' || '' }} diff --git a/.gitignore b/.gitignore index 556aca6..c8afb68 100644 --- a/.gitignore +++ b/.gitignore @@ -17,13 +17,14 @@ tools/zig-out/ # ...but publish the curated, reader-facing guides (docs/ai-context stays ignored): !/docs/guides/ +/tmp/ # ── COMPOSER ────────────────────────────────────────────────── /vendor/ #(Uncomment if you want to ignore lock file for the library) composer.lock *.bak - +/var/ # ── TESTING & COVERAGE ──────────────────────────────────────── /.phpunit.cache/ @@ -84,3 +85,5 @@ ehthumbs.db *.swo *~ **/.zig-cache + +/userdata/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dadde4..778e89f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,250 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.2] - 2026-08-17 + +Fixes a class of failure that made installing or upgrading on a machine with an +existing install appear to do nothing. A machine can hold BOTH a system install +(`.deb` → `/opt/hkm-kernel` + `/usr/bin`) and a user install (tarball → +`~/.local`); the CLI did not model that, and every symptom below followed from +the same gap. + +**If you are upgrading from 1.3.1 or earlier, the old launcher cannot install +the user scope.** Install it from the release instead — the fixed `hkm upgrade` +takes over from there: + +```sh +curl -fsSL https://github.com/AlfaCode-Team/hkm-kernel/releases/latest/download/install.sh | sh +hkm version # shows every install and which one your PATH runs +``` + +### Added +- **`hkm version` reports every install on the machine**, not just the launcher's + own compile-time stamp: the kernel version in each scope (read from that + kernel's `composer.json`), the launcher serving it and the version IT was built + as, and an arrow on the one this invocation resolves. It also names the states + that make a later "my upgrade did nothing" report inevitable — another `hkm` + earlier on `PATH`, a kernel with no `vendor/`, a stale config pin. `hkm + --version` is unchanged and still prints one line for scripts. +- **`hkm upgrade --user` / `--system`** to force a scope. Without either, the + target is chosen from privilege — root → system, otherwise → user — so + `sudo hkm upgrade` and `hkm upgrade` are two predictable commands rather than + one command whose target depends on machine state. +- **`hkm-config unset `**, for clearing a stale `HKM_KERNEL_HOME`. +- `hkm doctor` gained an **Installs** table: both scopes, their versions and + whether each has resolved dependencies. + +### Changed +- **Kernel resolution ranks sources by how specific they are to the invocation** + (`tools/src/lib/kernel.zig`): an exported `HKM_CLI_PATH` / `HKM_KERNEL_HOME`, + then self-location relative to the launcher's own binary, then a + `config.env` pin, then `/opt/hkm-kernel`. The pin was previously checked + first. It still applies wherever self-location genuinely fails — a custom + prefix — but no longer overrides an install sitting next to the binary. + A launcher in a system bin directory (`/usr/bin`) claims `/opt/hkm-kernel` at + the self-location step, since no relative probe can reach it from there. +- **`hkm upgrade --user` installs to `~/.local/lib/hkm-kernel`**, matching + `install.sh`, instead of `~/.local/share/hkm/kernel`. The old path sits outside + every self-location probe, so it could only ever be reached through a + machine-wide pin — which is what created the cross-scope hijack below. An + install left at the old location is detected and reported, not silently used. +- **`install.sh` removes a redundant or superseded `HKM_KERNEL_HOME` pin** + rather than repointing it. A repointed pin is still read by every launcher on + the machine; no pin lets each one find its own kernel. A pin aimed at a genuine + custom layout is reported and left alone. It also lists the installs already + present with their versions, and prints `Version: old -> new` when it finishes. +- **`hkm-config check` no longer pins `HKM_KERNEL_HOME` for a self-locating + layout** — writing one on behalf of whichever install ran it last is how the + shared pin came to exist. It removes one that has become redundant. +- `hkm upgrade --local` obeys the same scope rule (non-root installs to the user + scope, creating it if absent) and installs the launcher into that scope's `bin` + directory rather than always `/usr/bin`. +- The scaffolded `kernel-autoload.php` tries `~/.local/lib/hkm-kernel` before + `/opt/hkm-kernel`, so a project run under PHP-FPM or systemd resolves the + kernel its owner actually manages. The pre-1.4 user path is still tried. + +### Fixed +- **One install silently ran the other's kernel.** `~/.config/hkm/config.env` is + read by every `hkm` on the machine, and `HKM_KERNEL_HOME` was checked before + self-location — so whichever installer wrote that pin last redirected the other + install too. A `.deb` launcher would report its own version while running a + kernel out of the user's home, and upgrading either scope could not move the + number on screen. +- **`hkm upgrade` could not update a user install on Linux.** It only ever + fetched the `.deb` and shelled out to `sudo apt-get`, despite the user-local + tarball being the documented default since 1.3.1. Because `PATH` usually + resolves `~/.local/bin` before `/usr/bin`, the command reported success and the + very next invocation ran the old launcher unchanged. The user scope now + installs from the tarball via its own `install.sh`, with no `sudo` anywhere in + that path. +- **Upgrade decisions used the wrong version.** `hkm upgrade` compared the + LAUNCHER's compile-time stamp against the latest release tag, then went on to + replace a KERNEL somewhere else — two numbers that differ exactly when the + launcher on `PATH` belongs to the other scope. Versions are now read from the + kernel being replaced, and the command names the other scope when it is also + behind instead of reporting an unqualified "you are on the latest version". +- **A `--local` install could never report what it was.** It copied the + checkout's `composer.json`, which carries no `version` field by design, so + `hkm version` read "unstamped" forever and the next upgrade had nothing to + compare. The `git describe` version is now recorded as semver build metadata + (`1.3.1-2-g34abb2c` → `1.3.1+2.g34abb2c`), which Composer accepts and which + semver excludes from precedence — a change of spelling, not of meaning. A + release build still stamps the exact tag or nothing. +- A `--system` upgrade run without root now says so once, up front, with the + command that works, instead of failing one permission error at a time. The + system path no longer prefixes `sudo` unconditionally, which broke on the + containers and CI images where a system install is most useful and `sudo` is + frequently absent. + +## [1.3.1] - 2026-08-12 + +Supersedes 1.3.0, which was tagged from a commit that never reached `master` +(the branch had advanced remotely between the build and the push). Tags are +immutable in this repository, so 1.3.0 was left in place rather than moved — +it builds, but it predates the `php-io-cli` pin below. **Use 1.3.1.** + +### Added +- **Domain lists.** `domain` / `subdomain` now take either a string or a LIST, + at all three levels — module-wide (`routeDomain` / `routeSubdomain`), group, + and route. A project serving several hosts can pin a group to "these three and + not that one" instead of duplicating the group per host. The domain is still + part of the route KEY, and a route grouped under a host the project does not + serve is still rejected at boot. +- **Plugin env seeding.** Enabling a plugin writes the environment it declares + in `module.json` `config[]` straight into `.env`, in three shapes: a documented + default is written ACTIVE, a required key with no default is written active but + EMPTY (so the boot failure points at a line you can see), and an optional key + with no default is written COMMENTED. Previously that list was discoverable + only from a boot stack trace, one variable per attempt. +- **A user-local install that needs no root.** Linux releases now ship a portable + tarball alongside the `.deb`; `tools/install.sh` unpacks kernel and launcher + entirely inside `$HOME` and writes nothing outside it. Published with the + release assets, so `curl … | sh` works without a checkout. The `.deb` remains + for multi-user machines and CI images. +- **Scaffold support for `@pageflow/admin`** (Pageflow v1.1.0): a three-state + theme provider (`{ theme, resolvedTheme, setTheme, toggle }` with a "system" + default that keeps following the OS), the sidebar CSS variables the shell + consumes, and a globbed `ui/admin/nav.ts` navigation registry. Both scaffold + surfaces now wrap their tree in `AppErrorBoundary`. + +### Fixed +- **`modules/php-io-cli` pinned back to its last loadable commit.** The newer + pointer merged two parallel implementations of unknown-option handling and kept + both, declaring `AbstractCommand::$unknownOptions` twice — a fatal at class + load, so every command built on `AbstractCommand` died, not just the test that + surfaced it. Only the pointer is reverted; which implementation is canonical is + php-io-cli's call. + +### Changed +- `hkm doctor` reports which install is actually in use, and whether a stale + `HKM_KERNEL_HOME` pin in `~/.config/hkm/config.env` is overriding it — the + failure that otherwise presents as "my changes do nothing". + +## [1.2.0] - 2026-08-12 + +### Added +- **Route groups.** `groups[]` in `module.json` / `proj.json` states a `prefix`, + `filters`, `requires`, `name` prefix and `domain` once for every route inside; + groups nest (max depth 16). Module-wide `routePrefix` / `routeFilters` / + `routeRequires` / `routeName` / `routeDomain` / `routeSubdomain` do the same for + a whole file. Expanded at BOOT into ordinary flat routes — zero request-time cost. +- **Domain grouping.** A route may declare the host it answers on + (`"domain": "africavoting.local"`, `"domain": "*.example.com"`, or a bare + `"subdomain": "api"`). The domain is part of the route KEY, so one project can + answer `GET /` differently per host. Ungrouped routes stay global; a bare + subdomain answers on that label of every domain. A declared host is validated + against `proj.json` `"domains"`. +- **Parameter types `path` and `enum(a|b)`, and optional `{id?}`.** `path` is a + traversal-safe catch-all (`any` is unchanged and still has no guard); + `enum` members are `preg_quote`d, so no regex can be injected from JSON. +- `HEAD` requests are served by the `GET` route (`ROUTE_HEAD_FALLBACK`), with the + body stripped. Opt-in `405 Method Not Allowed` + `Allow` + (`ROUTE_METHOD_NOT_ALLOWED`) and trailing-slash policy (`ROUTE_TRAILING_SLASH`). +- **`BOOT_CACHE`** — `Kernel::build()` skips recompiling manifests that are already + current. Under PHP-FPM the boot pipeline previously ran on *every request* + (~2 ms, ~150 KB of writes for ~130 routes); with the cache that becomes ~0.02 ms. + Off by default; clear `var/cache/manifests/` on deploy. +- `route()`, `signed_route()` and `url()` global helpers; `UrlGenerator` bound in + the `CoreContainer`. Absolute URLs follow the route's own domain group. +- `signed` route filter (SecurityFilters) — enforces a `signed_route()` link + declaratively, the URL counterpart to `hmac`. +- Two derived manifests beside `route-manifest.php`: `route-index.php` (the + matcher-ready index) and `route-names.php` (the name index `UrlGenerator` reads). + Both optional at runtime — every consumer falls back to the flat manifest. + +### Fixed +- **Captured route parameters are percent-decoded and re-validated against their + type.** `/files/..%2F..%2Fetc%2Fpasswd` no longer satisfies `{name}`, and + `/users/Jos%C3%A9` now reaches the controller as `José` rather than `Jos%C3%A9`. +- Route patterns are anchored with the `D` modifier — a trailing newline in the + request path no longer satisfies `$`. +- Literal path text is `preg_quote`d, so `/feed.xml/{id}` no longer matches + `/feedXxml/1`. +- Signed-URL verification compares the query byte-for-byte instead of round-tripping + it through `parse_str()`, which rewrote `.`, ` ` and `[` in parameter names and + made some legitimately signed URLs impossible to verify. +- `UrlGenerator` supports a repeated placeholder (`/a/{id}/b/{id}`), which + previously reported the second occurrence as a missing parameter. +- `resolveEssentialModules()` no longer re-reads every `module.json` a second time + during `build()`. + +### Changed +- Route filter stages are resolved once per worker instead of being reconstructed + on every request; filter specs, the handler split and the dependency-graph key + are precompiled into the manifest. +- Dynamic routes are bucketed by their first literal path segment, so a request + tests only the patterns that could match its prefix. +- These now FAIL THE BOOT instead of compiling into a route that silently never + matched: a path not starting with `/`, a duplicated or PCRE-invalid capture name, + a handler without exactly one `@`, a filter alias no `Provider::boot()` + registered, and a route domain absent from `proj.json` `"domains"`. +- `RouteCatalog::publicPaths()` takes an optional `$domain` — the default is + unchanged (shared routes only). + +### Docs +- `docs/Sentinel-Routing-Guide.pdf` — a practical, example-driven routing manual. + +## [1.1.0-beta.1] - 2026-08-07 + +First **installable** pre-release of the 1.1.0 line. `1.1.0-dev.2` and +`1.1.0-dev.3` are withdrawn — see below. + +### Fixed +- **`composer install` aborted on every machine that took `1.1.0-dev.2` or + `-dev.3`.** The build stamps its version into `composer.json`, and + `1.1.0-dev.N` is not a valid Composer version: Composer's `dev` suffix takes + no counter. `composer install` refuses to run at all on an unparseable + version, so the package unpacked and then failed to resolve its dependencies. + The stamper now validates and skips rather than writing something Composer + rejects, and this release is named `-beta.1`, which Composer accepts — so the + version marker the native distribution needs is actually present again. +- The stamper trimmed `v` from both ends of the version, so any version ending + in `v` lost it — `1.1.0-dev` became `1.1.0-de`, the one pre-release form + Composer does accept. + +### Note on upgrading from 1.0.21 +A 1.0.21 client has no pre-release filter: it strips the suffix, sees +`1.1.0 > 1.0.21` and offers this automatically. That filter ships **in** this +release, so the behaviour self-corrects after one upgrade. If you took +`1.1.0-dev.2` or `-dev.3` and the install reported a composer schema error, +upgrading to this release repairs it. + +## [1.1.0-dev.3] - 2026-08-07 + +Re-cut of `1.1.0-dev.2` from `main` rather than `master`, so the artefacts +include the PHPStan work that landed with #107. Contents are otherwise +identical — see `[1.1.0-dev.2]` below for the full list. + +### Fixed +- **`ProcessLocalLock` could not write its own lock table.** The registry was + typed as an anonymous `object{locks: ...}` shape, whose properties PHPStan + treats as read-only, so every write was an error against a type that + described the shape but never named the one class satisfying it. +- PHPStan is green again: the project scaffolding that binds to plugin + contracts is scoped out of analysis here, since those plugins are + deliberately not dependencies of the kernel. It is analysed in a project that + has installed them. + ## [1.1.0-dev.2] - 2026-08-07 Development pre-release. Published so the new tooling can be exercised against diff --git a/README.md b/README.md index 43ebd8c..07b5ffc 100644 --- a/README.md +++ b/README.md @@ -537,29 +537,27 @@ Domain → NOTHING EXTERNAL (zero imports outside Domain/) ## Batteries included (plugins) -Drop-in modules under `plugins/`, activated per project: - -| Plugin | Domain | What you get | -|---|---|---| -| **Auth** | `auth.identity` | JWT / PAT / session issuance + verification, refresh-token rotation, guards | -| **OAuth2** | `oauth.server` | Native OAuth 2.1 + OIDC server (auth code + PKCE, device code, JWKS, introspection) | -| **User** | `user.management` | Central identity store, email verification, transactional outbox, audit log | -| **Tenancy** | `tenancy.routing` | Multi-tenant DB routing, memberships, invitations, per-tenant isolation | -| **Validation** | `validation.rules` | Request validation engine + `AbstractDto` (`rules()`), ~45 built-in rules | -| **Mail** | `mail.delivery` | Native dependency-free mailer — SMTP/Sendmail/`mail()`, DKIM, attachments | -| **Storage** | `storage.local` | `StoragePort` over local disk **or** S3 (Flysystem), signed temp URLs | -| **Session / Cookie** | `session.management` / `http.cookies` | Encrypted sessions, flash, CSRF; queued encrypted cookies | -| **HttpClient** | `http.client` | `HttpClientPort` (cURL) with idempotent-safe retries + coroutine backoff | -| **View / ViteManifest / Pageflow** | frontend | PHP templating, Vite asset resolution, Inertia-style SPA bridge | -| **SecurityFilters** | `http.security_filters` | CORS + secure headers; route-filter aliases `auth`, `throttle`, `hmac`, `shield` | -| **I18n** | `i18n.translation` | File-based translator, pluralization, `Accept-Language` negotiation | - -Each plugin ships its own `README.md` — e.g. [Auth](plugins/Auth/README.md), -[Tenancy](plugins/Tenancy/README.md), [User](plugins/User/README.md), -[OAuth2](plugins/OAuth2/README.md). +The kernel ships **no** plugins and depends on none. Roughly thirty first-party +plugins cover auth, users, tenancy, OAuth2, mail, storage, sessions, validation, +i18n, templating and the SPA bridge — each its own package, owning exactly one +domain. + +```bash +hkm plugins domains # every plugin and the `solves` domain it claims +hkm plugins enable auth # install it, publish its assets, run its migrations +``` + +Then add its `Provider` to the project bootstrap's `withModules([...])`. + +**Each plugin documents itself in its own repository** — `README.md` for what it +is, `CLAUDE.md` for its contract and configuration, and `module.json` as the +authoritative `requires[]` / `exposes[]` / `config[]`. This repository keeps no +plugin catalogue: a static list here is the copy that goes stale, and it did. +Repositories are at `github.com/AlfaCode-Team/hkm-plugin-`. --- + ## Development from source ```bash @@ -608,7 +606,10 @@ Notes: - You can still cut a release manually at any time by pushing a `v*` tag. For deep dives, see the layer guides in [`docs/guides/`](docs/guides/) and the -[CHANGELOG](CHANGELOG.md). +[CHANGELOG](CHANGELOG.md). Those guides cover the kernel (`src/`) and the packages +it runs on (`modules/`); the `Project\` layer is documented in +[hkm-project-layer](https://github.com/AlfaCode-Team/hkm-project-layer), each +plugin in its own repository, and the `hkm` CLI in [`tools/`](tools/README.md). --- diff --git a/composer.json b/composer.json index f1a8630..e229b55 100644 --- a/composer.json +++ b/composer.json @@ -82,7 +82,8 @@ "psr-4": { "AlfacodeTeam\\PhpServicePlatform\\": "src/", "AlfacodeTeam\\PhpServicePlatform\\System\\": "src/System/", - "Project\\": "projects/" + "Project\\": "projects/", + "Plugins\\": "plugins/" }, "files": [ "src/Kernel/Support/helpers.php" diff --git a/docs/guides/00_SENTINEL_OVERVIEW.md b/docs/guides/00_SENTINEL_OVERVIEW.md index 7bde2de..23549e5 100644 --- a/docs/guides/00_SENTINEL_OVERVIEW.md +++ b/docs/guides/00_SENTINEL_OVERVIEW.md @@ -7,7 +7,7 @@ ## What HKM Kernel Is -HKM Kernel is a PHP 8.2+ framework built on the **Gated Demand Architecture (GDA)** pattern. +HKM Kernel is a PHP 8.4+ framework built on the **Gated Demand Architecture (GDA)** pattern. | Principle | Meaning | |---|---| @@ -200,5 +200,4 @@ Always throw the exception type matching the layer. Never let a `\PDOException` | `08_EVENTS.md` | Domain vs Integration events, EventBus, outbox | | `09_SECURITY.md` | SecurityGateway, layers, Identity, tokens | | `10_TESTING.md` | Test patterns, fakes, port doubles, strategies | -| `11_PROJECT.md` | Bootstrap, port adapters, configuration wiring | | `12_WORKER.md` | Worker pipeline, jobs, retry, dead-letter queue | diff --git a/docs/guides/02_MODULE.md b/docs/guides/02_MODULE.md index fa90ca2..789307a 100644 --- a/docs/guides/02_MODULE.md +++ b/docs/guides/02_MODULE.md @@ -38,14 +38,36 @@ "InvoiceServiceContract" // → fully qualified or short class name ], + // ── Module-wide route defaults (all optional) ──────────────────────────── + "routePrefix": "/api/v1", // prepended to every path below + "routeFilters": ["auth"], // merged in FRONT of every route's filters[] + "routeRequires": ["view.rendering"], // added to every route's requires[] + "routeName": "invoice.", // prefixed onto every route's name + "routeDomain": "admin.example.com", // or "routeSubdomain": "admin" + "routes": [ // HTTP routes — compiled into route-manifest.php - // Optional "filters": [...] declares route filters by alias (run by - // RouteFilterStage). String or list; "alias:arg1,arg2" passes args. - { "method": "GET", "path": "/api/invoices", "handler": "InvoiceController@index" }, - { "method": "POST", "path": "/api/invoices", "handler": "InvoiceController@create", "filters": ["auth", "throttle:60,1"] }, - { "method": "GET", "path": "/api/invoices/{id}", "handler": "InvoiceController@show" }, - { "method": "PUT", "path": "/api/invoices/{id}", "handler": "InvoiceController@update" }, - { "method": "DELETE", "path": "/api/invoices/{id}", "handler": "InvoiceController@destroy" } + // method + path + handler are REQUIRED. Everything else is optional. + { "method": "GET", "path": "/invoices", "handler": "InvoiceController@index", + "name": "index" }, + { "method": "POST", "path": "/invoices", "handler": "InvoiceController@create", + "filters": ["throttle:60,1"] }, + { "method": "GET", "path": "/invoices/{id:num}", "handler": "InvoiceController@show" }, + { "method": "DELETE", "path": "/invoices/{id:num}", "handler": "InvoiceController@destroy" } + ], + + // ── Groups: say it ONCE instead of on every route ──────────────────────── + // Expanded at BOOT into ordinary flat routes, so grouping costs nothing at + // request time. Groups may nest (max depth 16). + "groups": [ + { "prefix": "/admin", + "filters": ["shield"], // merged, de-duplicated BY ALIAS — a route's + // throttle:5,1 REPLACES a group's throttle:60,1 + "name": "admin.", // concatenated; an UNNAMED route stays unnamed + "requires": ["audit.trail"], // union + "domain": "admin.example.com", // inner overrides outer + "routes": [ { "method": "GET", "path": "/stats", "handler": "AdminController@stats" } ], + "groups": [ /* … nest further … */ ] + } ], "emits": [ // Integration events this module dispatches @@ -66,6 +88,117 @@ --- +## Route Keys — Complete Reference + +`method`, `path` and `handler` are required. Everything else is optional and +absent by default, so an existing `module.json` compiles byte-identically. + +| Key | Type | Meaning | +|---|---|---| +| `method` | string | Upper-cased for you. `HEAD` is served by the `GET` route automatically. | +| `path` | string | **Must start with `/`** — a relative path could never match, so it fails the boot. | +| `handler` | string | `Full\Class@method`, **exactly one `@`**. | +| `name` | string | Addressable by `route('name')`. Application-wide unique. | +| `filters` | string\|list | Aliases wrapping this route: `auth`, `throttle:60,1`, `shield`, `hmac`, `signed`, `tenant`. | +| `requires` | string\|list | Module domains seeded into **this route's** dependency graph only. | +| `domain` | string | The host this route answers on. Part of the route KEY. | +| `subdomain` | string | A bare label that answers on **every** domain. | +| `faces` | string\|list | Restrict to `admin` / `api` / `project` / `public`. | + +### Path parameters — `{name}`, `{name:type}`, `{name?}` + +| Type | Matches | Rejects | +|---|---|---| +| *(untyped)* | one segment, `[^/]+` | `a/b` | +| `num` | digits | `abc` | +| `alpha` | letters | `draft2` | +| `alphanum` | letters + digits | `a-1` | +| `slug` | letters, digits, `-_` | `my.post` | +| `uuid` | a UUID | anything else | +| `segment` | same as untyped | `a/b` | +| `any` | anything, crosses `/` | — (**no traversal guard**) | +| `path` | crosses `/`, refuses `..` and control chars | `../etc` | +| `enum(a\|b)` | a closed set; members are `preg_quote`d | non-members | + +`{page?}` is optional and takes its leading `/` with it, so `/posts/{page?}` +matches `/posts` as well as `/posts/2`. An omitted value arrives as `''`. + +⚠ Use `path`, not `any`, for anything reaching a filesystem or a `StoragePort`. +`any` is a bare catch-all kept unchanged for compatibility. + +**Captured values are percent-DECODED and then re-validated against their type**, +so `/files/..%2F..%2Fetc%2Fpasswd` does not satisfy `{name}` — the decoded value +does not. The type constrains what the CONTROLLER receives, not merely the wire +bytes. `/users/Jos%C3%A9` reaches the controller as `José`. + +### Group inheritance + +| Key | Inheritance | +|---|---| +| `prefix` | concatenated outward-in | +| `name` | concatenated outward-in; an **unnamed route stays unnamed** | +| `filters` | merged, de-duplicated **by alias** — inner replaces, never doubles | +| `requires` | union | +| `domain` / `subdomain` / `faces` | inner overrides outer | + +Nothing subtracts: a group cannot strip a filter an outer group added. Removal is +the project's prerogative and lives in `proj.json` `routePolicy.disable`. + +### Domain grouping — three rules + +``` +UNGROUPED route GLOBAL. Every domain reaches it. +"subdomain": "api" That LABEL on EVERY domain — api.example.com, + api.example2.com, and any future host with it. +"domain": "host" That host only. +"domain": "*.parent" Any subdomain of that parent. +``` + +The domain is part of the route KEY (`GET@africavoting.local /`), not a +post-match filter — which is why two domains may each declare `GET /` with a +different handler. The compiler groups by the string **verbatim**; the only check +is that a declared HOST appears in the deploying project's `proj.json` +`"domains"`. A bare `subdomain` is never checked (it spans every domain by +design), and a project declaring no `domains` is not checked at all. + +Matching expands the request host into candidates, most specific first: + +``` +organizer.africavoting.local + → organizer.africavoting.local exact host + → *.africavoting.local wildcard on each parent suffix + → *.local + → organizer bare subdomain label + → '' the shared group +``` + +Resolution order is that list, with **static still beating dynamic**: all static +work happens before any dynamic work, so a shared literal `/users/me` is never +swallowed by a group's `/users/{id}`. + +### Boot-time failures + +Each of these used to compile into a route that silently never matched: + +| Message contains | Cause | +|---|---| +| *unknown parameter type* | `{id:number}` — no such type | +| *does not start with `/`* | `"path": "users"` | +| *repeats the capture name* | `/a/{id}/b/{id}` | +| *not a usable capture name* | `{2fa}` — starts with a digit | +| *'Controller@method' format* | no `@`, or two | +| *Duplicate route* | two plugins claim the same key | +| *Duplicate route name* | names are application-wide | +| *requires unknown module domain* | typo, or plugin missing from `withModules()` | +| *this project does not serve* | domain group absent from `proj.json` `domains` | +| *declares filter … which no Provider registered* | plugin missing, or alias misspelled | +| *groups … nest more than 16* | self-referencing `groups[]` | + +> **Worked examples:** see [30_ROUTING_COOKBOOK.md](30_ROUTING_COOKBOOK.md) — 13 recipes, +> each compiled and showing what it actually produces. + +--- + ## ModuleContract — Every Module Implements This ```php diff --git a/docs/guides/07_CONTROLLER.md b/docs/guides/07_CONTROLLER.md index 48ba1dd..a0d29d9 100644 --- a/docs/guides/07_CONTROLLER.md +++ b/docs/guides/07_CONTROLLER.md @@ -253,53 +253,44 @@ public function upload(Request $request): Response --- -## Base Controllers (project layer — `Project\Http\Controllers\`) +## `RequestAware` — the kernel's only controller seam -Two optional base classes live in `projects/Http/Controllers/` (namespace -`Project\`). They are project-layer, NOT kernel, because view rendering and -cookies are plugin concerns — the kernel stays renderer-agnostic. +The kernel is renderer-agnostic and knows nothing about controller base classes. +The single seam between the two is one interface: -| Base | Use for | Coupling | -|---|---|---| -| `ApiController` | JSON endpoints | Pure kernel types (no plugin) | -| `ViewController` | HTML/view endpoints | Injects `ViewRendererContract` (View plugin) | - -`ApiController` helpers: `ok()`, `created()`, `accepted()`, `noContent()`, -`paginated()`, `okOrNotFound()`, `notFound()`, `forbidden()`, `unprocessable()`, -`identity()`. `ViewController` helpers: `view()`, `viewNotFound()`, `redirect()`, -`back()`. - -Both `use InteractsWithCookies` (trait wrapping every public `CookieJar` method: -`cookie()`, `queueCookie()`, `rememberCookie()`, `forgetCookie()`, -`hasQueuedCookie()`, `decryptCookie()`, `cookieJar()`). - -### RequestAware — actions take route params ONLY (no `$request`) +```php +AlfacodeTeam\PhpServicePlatform\Kernel\Http\Contracts\RequestAware + public function setRequest(Request $request): static; +``` -Both bases implement the kernel contract -`AlfacodeTeam\…\Kernel\Http\Contracts\RequestAware` (`setRequest(Request): static`). -`ExecuteStage` detects it and: +`ExecuteStage` checks `instanceof RequestAware` and, when true: - calls `setRequest($request)` with the container-bearing request BEFORE the action, then -- invokes the action as `$method(...$routeParams)` — WITHOUT `$request`. +- invokes the action as `$method(...$routeParams)` — **without** `$request`. Plain controllers (not `RequestAware`) keep the conventional `$method($request, ...$params)` signature — fully backward compatible. ```php -use Project\Http\Controllers\ApiController; - -final class CartController extends ApiController // RequestAware +final class CartController implements RequestAware // route params only { - public function show(string $id): Response // route param only — no $request + public function show(string $id): Response { - $this->queueCookie('last_viewed', $id); // request injected by the kernel - return $this->okOrNotFound($this->cart->find($id)?->toArray()); + return Response::json($this->cart->find($id)?->toArray() ?? [], 200); } } ``` -The raw request is still available inside the action as `$this->request`; any -cookie helper also accepts an explicit `?Request` override. +``` +✗ Adding $request to a RequestAware action — it receives route params only +✗ Coupling the kernel to a controller base class or a view renderer — this + interface is the whole contract +``` + +Optional base classes (`ApiController`, `ViewController`) and their concern +traits are **project layer**, not kernel: view rendering and cookies are plugin +concerns. They are documented in +[hkm-project-layer](https://github.com/AlfaCode-Team/hkm-project-layer/blob/main/Http/Controllers/README.md). --- diff --git a/docs/guides/09_SECURITY.md b/docs/guides/09_SECURITY.md index 527293a..9350d5f 100644 --- a/docs/guides/09_SECURITY.md +++ b/docs/guides/09_SECURITY.md @@ -157,47 +157,44 @@ new CsrfTokenLayer( ); ``` -### Auth plugin layers — `JwtAuthLayer` / `PersonalAccessTokenLayer` - -```php -// Provided by Plugins\Auth (the kernel ships NO JWT code). You add them to -// withSecurity([...]) alongside CsrfTokenLayer. -// JwtAuthLayer — verifies a Bearer JWT (iss/aud/exp, jti deny-list), -// builds Identity from claims (incl. the `tnt` tenant claim). -// PersonalAccessTokenLayer — verifies long-lived personal access tokens. -// Session-based auth is a separate after.load stage (SessionAuthStage), not a gateway layer. -// All signature/token comparisons are timing-safe (hash_equals()). -``` - -### Tenant context on the Identity (`tnt` claim — multi-tenant control plane) - -`Identity.tenantId` carries the authenticated tenant for database-per-tenant -routing. `Plugins\Auth\Security\JwtAuthLayer` reads it from the signed **`tnt`** -claim (legacy `tenant` accepted for BC) and defaults it to **`''` (empty)**: - -```php -$tenant = (string) ($claims['tnt'] ?? $claims['tenant'] ?? ''); -$identity = new Identity(userId: $claims['sub'], tenantId: $tenant, /* … */); -``` - -- **Empty tenant claim ≠ central access.** `AuthService::issueJwt()` mints NO - tenant at login — but `TenantContextStage` routes STRICTLY: with no tenant - claim, the remembered cookie hint and then the Host identifier must still - resolve one, or the request 404s (no unscoped passthrough). Login/picker/public - pages therefore live on a host that is itself assigned to a tenant; - control-plane reads pin the central connection explicitly. -- **Non-empty tenant** is routed to its isolated database by - `Plugins\Tenancy`'s `TenantContextStage` (hooked `after.load`), which rebinds - `DatabasePort` in the request container. Mint a tenant-scoped token ONLY after - the user selects a tenant and membership is verified against the central - `user_tenants` table; re-check membership each request so a revoked seat loses - access before the token expires. -- **Control-plane plugins pin to central.** `Plugins\User` (the global `users` - identity table) and `Plugins\Auth` (`personal_access_tokens`) resolve the - `DatabaseConnectionManagerContract` **default** connection, NOT the per-request - (tenant-rebound) `DatabasePort` — so identity I/O never lands in a tenant DB. - Because the `tnt` claim is signed it cannot be forged, but it is still a hint, - not authority: authorization keys on `(userId, tenantId, role/permission)`. +### Token verification is a plugin's job + +**The kernel ships no JWT, API-key or session token validator, deliberately.** It +defines `SecurityLayerContract` and runs whatever layers a project passes to +`withSecurity([...])`; an auth plugin supplies the verifiers. Which layers exist, +what claims they read and how they are configured is that plugin's documentation, +not the kernel's. + +What the kernel guarantees regardless of the plugin: a layer **never throws** (it +returns a verdict), a missing credential means **anonymous rather than denied** +(public routes keep working), and a denial costs **zero module loading**. + +### Tenant context on the Identity (`tenantId`) + +`Identity.tenantId` is the only multi-tenancy the KERNEL knows about: an +immutable string it carries and hands to whatever runs next. The kernel does not +resolve tenants, own a registry, or know what a tenant database is — an auth +plugin populates the field, and a tenancy plugin acts on it. + +Three rules bind every consumer of that field, and they are kernel-level +guarantees rather than any one plugin's behaviour: + +- **The tenant id is a HINT, not authority.** Whatever set it — a signed claim, a + cookie, a host label — authorization still keys on + `(userId, tenantId, role/permission)`, re-checked against the store that owns + memberships. A signature proves the value survived transit unmodified; it does + not prove the seat still exists. +- **Empty is not "central access".** An absent tenant means *unresolved*, not + *privileged*. Any component that treats a missing tenant as permission to read + a shared/central store must say so explicitly and pin that connection itself. +- **A per-request rebind goes in the request-scoped container.** A plugin that + rebinds `DatabasePort` for a tenant binds it into the `ModuleContainer` + (discarded on `reset()`), never `CoreContainer` and never a static. Under + OpenSwoole a leaked binding means one tenant's request served from another + tenant's database. + +How a tenant is identified, routed, provisioned and revoked belongs to the +Tenancy, Auth and User plugins, and is documented in their repositories. --- @@ -247,7 +244,7 @@ $kernel->withSecurity([ ``` Rate limiting and IP filtering are not added here — a route opts into them with the -SecurityFilters `throttle` / `shield` filters (see `20_FIRST_PARTY_PLUGINS.md`). +SecurityFilters `throttle` / `shield` route filters (see the [SecurityFilters plugin](https://github.com/AlfaCode-Team/hkm-plugin-security-filters)). --- diff --git a/docs/guides/11_PROJECT.md b/docs/guides/11_PROJECT.md deleted file mode 100644 index 436a53c..0000000 --- a/docs/guides/11_PROJECT.md +++ /dev/null @@ -1,266 +0,0 @@ -# HKM Kernel — Project Layer - -> The Project layer contains no business logic. It wires kernel contracts to infrastructure adapters and chooses which business modules are active per project. - ---- - -## Current Project Bootstrap Architecture - -The repository now uses inheritance-safe project bootstrapping: - -- Shared base builder: `app/bootstrap/base.php` (returns an unbuilt `Kernel` builder) -- Per-project bootstrap: `projects/{project}/bootstrap/app.php` (extends base and calls `->build()`) -- Backward-compatible shim: `bootstrap/app.php` delegates to `projects/admin/bootstrap/app.php` -- Runtime selection: entry points resolve `HKM_PROJECT` (default: `admin`) and load `projects/{HKM_PROJECT}/bootstrap/app.php`, falling back to `bootstrap/app.php` - ---- - -## Why This Shape - -The kernel freezes `CoreContainer` when it materializes (the first entry-point call), not in `build()`. Inherited projects must still share the builder, not a built kernel instance — each project finalizes its own ports/modules with its own `->build()`. - -This allows: - -- one shared admin base in `app/` -- many child projects with their own module sets -- identical entry points reused across projects - ---- - -## Builder Semantics (Inheritance-Safe) - -`Kernel` builder methods are additive so child projects can extend base config safely: - -- `withPorts([...])`: merges with existing bindings (later keys override earlier ones) -- `withSecurity([...])`: appends layers (base first, project additions later) -- `withModules([...])`: appends and de-duplicates module class names preserving order - ---- - -## File Layout (As Implemented) - -```text -app/ -├── Infrastructure/ -│ ├── InMemoryCache.php -│ └── PdoDatabase.php -├── bootstrap/ -│ └── base.php -├── api/server.php -├── cli/run.php -├── worker/run.php -└── public_html/index.php - -projects/ -└── admin/ - └── bootstrap/app.php - -bootstrap/ -└── app.php # legacy shim -``` - ---- - -## Base Builder Pattern - -```php -// app/bootstrap/base.php (shared defaults, NO ->build()) -return Kernel::configure() - ->withBasePath(dirname(__DIR__, 2)) - ->withPorts([ - DatabasePort::class => new PdoDatabase(...), - CachePort::class => new InMemoryCache(), - ]) - ->withSecurity([ - new CsrfTokenLayer(exemptPaths: ['/api']), - ]); -``` - ---- - -## Project Bootstrap Pattern - -```php -// projects/admin/bootstrap/app.php -/** @var Kernel $builder */ -$builder = require __DIR__ . '/../../../app/bootstrap/base.php'; - -return $builder - ->withModules([ - TaskModule::class, - ]) - ->build(); -``` - ---- - -## Entry Point Resolution Pattern - -All entry points in `app/` follow this runtime bootstrap selection logic. Note the -fixed order: resolve the project, **load the environment, install the error net, THEN -require the kernel bootstrap** (so a pre-kernel failure is caught and cannot leak): - -```php -$rootPath = dirname(__DIR__, 2); -$domain = EntryHelpers::resolveDomain($rootPath, $host); // HTTP only; null in CLI/worker -$project = (string) (getenv('HKM_PROJECT') ?: 'admin'); // ← legitimate pre-env getenv - -LoadEnvironment::load($rootPath, $domain, $argv); // 1. .env cascade → $_ENV -ErrorGuard::install($rootPath . '/projects/' . $project . '/var/logs/errors.log'); // 2. error net - -$kernel = require EntryHelpers::bootstrapPathFor($rootPath, $project); // 3. kernel -``` - -`HKM_PROJECT` is read with `getenv()` on purpose — it selects which project to boot and -is a genuine OS/server variable evaluated *before* `LoadEnvironment` runs. Everything the -kernel and modules read afterwards must use the `env()` helper, not `getenv()` (see -`app/Bootstrap/Environment/`). - -Applied to: - -- `app/api/server.php` (env + guard installed once per worker in `workerStart`; guard is ini-only) -- `app/cli/run.php` -- `app/worker/run.php` -- `app/public_html/index.php` - ---- - -## Project Routes & Views (Project-Over-Plugin Priority) - -A project can declare its OWN routes and view paths — they take precedence over -plugin resources by default (deterministic, compiled at boot). - -```jsonc -// projects//proj.json (or the flat project-root proj.json) -{ - "name": "shop", - "views": "resources", // project view root (priority 0) - "routes": [ - { "method": "GET", "path": "/", "handler": "Shop\\Http\\HomeController@index" }, - { "method": "GET", "path": "/ping", "handler": "Shop\\Http\\HomeController@ping" } - ] -} -``` - -- Routes: `EntryHelpers::projectRoutes($projectPath)` reads `proj.json` - `routes[]`; the project bootstrap passes them to `Kernel::withRoutes(...)`. - They compile AFTER all plugin routes and OVERRIDE a plugin route with the same - `METHOD path`. They resolve under the synthetic `__project__` scope (no module - graph); the full-class-path controller autowires from the request container. - Keep project controllers thin — orchestrate published plugin contracts. -- Views: project view paths sort to priority `0` (highest). `render('welcome')` - resolves the project copy before any plugin's; `render('plugin::view')` can be - overridden by dropping `{project-views}/plugin/view.php`. - -### Per-route `requires` — project routes opting into plugins - -The `__project__` scope has an EMPTY dependency graph, so a project route loads -NO plugins by default: on-demand modules' `register()` never runs, their published -contracts are unbound, and a `ViewController` (which constructor-injects -`ViewRendererContract`) cannot even be built. To pull a plugin into ONE project -route without making it essential, declare a route-level `requires[]`: - -```jsonc -// proj.json -{ "method": "GET", "path": "/dashboard", - "handler": "Shop\\Http\\DashboardController@index", - "requires": ["view.rendering"] } -``` - -- `CompileRouteManifestStage` validates each `requires[]` entry at BOOT against - the set of domains some module `solves()` — an unknown/typo'd domain fails the - build with a descriptive message (never a request-time 500). -- `LoadStage` reads the matched route's `requires[]` and seeds those domains - (plus their transitive `requires`) into THAT request's graph only, via - `DependencyGraphCalculator::resolve($service, $additional)`. Routes without - `requires[]` stay lean. -- Scope isolation is unchanged: the required plugin's PUBLIC contract resolves in - the project controller, but its `bindInternal` bindings still throw - `ScopeViolationException` cross-scope. - -| Need | Mechanism | -| --- | --- | -| Some project routes need a plugin | route-level `requires[]` in `proj.json` | -| Every request needs a plugin | `withEssentialModules([...])` | -| The endpoint IS the plugin's domain | declare the route in the plugin's `module.json` | - -Project routes also pass `filters[]` through to the compiler; plugin routes MAY -carry `requires[]` too (they normally get deps via their module's `solves` graph). - -### Route policy — DISABLE plugin routes (the third verb) - -A plugin OWNS and declares its routes, but the deploying project is the FINAL -authority: it can veto plugin routes it will not expose — without forking the -plugin. Declared in `proj.json` and wired by the bootstrap via -`Kernel::withRoutePolicy(EntryHelpers::projectRoutePolicy($projectRoot))`: - -```jsonc -// proj.json -"routePolicy": { - "disable": [ - "GET /register", // one plugin route (method + path) - "oauth.server" // a module DOMAIN — every route that module solves() - ] -} -``` - -- Two spec forms: `"METHOD /path"` (one exact plugin route) or a bare module - domain (all of that module's routes — the whole-plugin off switch). -- `CompileRouteManifestStage` applies the policy to plugin routes AFTER they - compile and BEFORE project routes — so a project can disable a plugin route - and re-declare its OWN on the freed `METHOD path` with no duplicate-route - boot failure. -- A spec matching NOTHING fails the build with a descriptive message (same - anti-typo guard as unknown `requires[]` domains). Never a silent no-op. -- Project routes (`withRoutes`) are the project's own and are unaffected. - -| Route verb | Mechanism | Result | -| --- | --- | --- | -| add | project `routes[]` | new project route | -| override | project route on a plugin's `METHOD path` | project controller wins | -| disable | `routePolicy.disable[]` | plugin route dropped (404) | - -See the project-over-plugin resource-resolution model in [16_PLUGINS.md](16_PLUGINS.md). - ---- - -### Global (essential) modules — proj.json `"essentials"` - -Which plugins register on EVERY request is a per-project deployment decision, -declared in `proj.json` — not a bootstrap code edit: - -```jsonc -// proj.json — module DOMAINS (a plugin's solves value) -"essentials": ["tenancy.routing", "auth.identity", "user.management"] -``` - -Wired by the bootstrap via -`Kernel::withEssentialModules(EntryHelpers::projectEssentials($projectRoot))`. -Semantics: - -- `withEssentialModules()` accepts provider class-strings AND module domains; a - domain must name a module already in `withModules()` and resolves to its - provider at `build()` — an unknown domain FAILS the boot (never a silent - no-op essential). -- Essential domains are seeded into every request's dependency graph, so an - essential's transitive `requires[]` load with it; each module still registers - exactly once per request. -- Keep the list SHORT — every essential (and its requires graph) is - per-request `register()` cost. -- Session-cookie apps declare `auth.identity` + `user.management` so Auth's - `SessionAuthStage` resolves the logged-in user on every page; JWT/PAT-only - APIs need neither (token layers run before any module loads). -- Multi-tenant projects declare `tenancy.routing`; single-tenant projects leave - Tenancy out of `withModules` entirely. - ---- - -## Rules For Future Project Work - -- Keep business logic out of `app/`, `bootstrap/`, and project bootstrap files -- Project routes go in `proj.json` routes[] (or `Kernel::withRoutes()`), never in PHP -- Unwanted plugin routes go in `proj.json` routePolicy.disable[] — never fork a plugin to hide an endpoint -- Put only port/adapters/security/module lists in bootstrap wiring -- Add new projects under `projects/{name}/bootstrap/app.php` -- Ensure module classes listed in `withModules()` have valid `module.json` -- Prefer extending `app/bootstrap/base.php` over copy-pasting full kernel wiring diff --git a/docs/guides/13_ANTIPATTERNS.md b/docs/guides/13_ANTIPATTERNS.md index 7e88672..04ddca4 100644 --- a/docs/guides/13_ANTIPATTERNS.md +++ b/docs/guides/13_ANTIPATTERNS.md @@ -208,6 +208,78 @@ $router->post('/api/invoices', [InvoiceController::class, 'create']); } ``` +Need something computed? A project may pass PHP arrays through +`Kernel::withRoutes()` / `withRouteGroups()` from `bootstrap/app.php`. That is the +sanctioned escape hatch — there is still no route DSL, and no route file that +executes at request time. + +--- + +## ANTI-PATTERN 6b — Repeating Yourself Across Routes + +**Wrong — the prefix, the filter and the name stem copy-pasted onto every line:** +```json +{ "method": "GET", "path": "/admin/users", "handler": "…@index", "filters": ["auth"], "name": "admin.users.index" }, +{ "method": "POST", "path": "/admin/users", "handler": "…@store", "filters": ["auth"], "name": "admin.users.store" }, +{ "method": "DELETE", "path": "/admin/users/{id}", "handler": "…@destroy", "filters": ["auth"], "name": "admin.users.destroy" } +``` + +**Correct — a group states it once, and is expanded at BOOT into the same flat routes:** +```json +"groups": [ + { "prefix": "/admin/users", "filters": ["auth"], "name": "admin.users.", + "routes": [ + { "method": "GET", "path": "", "handler": "…@index", "name": "index" }, + { "method": "POST", "path": "", "handler": "…@store", "name": "store" }, + { "method": "DELETE", "path": "/{id}", "handler": "…@destroy", "name": "destroy" } + ] } +] +``` + +Grouping costs nothing at request time — the manifest, the matcher and every +stage only ever see flat routes. + +--- + +## ANTI-PATTERN 6c — `{param:any}` for a File Path + +**Wrong — `any` is a bare catch-all with no traversal guard:** +```json +{ "method": "GET", "path": "/download/{file:any}", "handler": "…@download" } +``` +`/download/../../etc/passwd` matches, and the controller gets `../../etc/passwd`. + +**Correct — `path` is the same catch-all with `..` and control characters refused:** +```json +{ "method": "GET", "path": "/download/{file:path}", "handler": "…@download" } +``` + +`any` is kept byte-for-byte only so existing routes do not regress. Any route that +reaches a filesystem or a `StoragePort` should use `path`. + +Note the router already decodes captured values and **re-validates them against +their type**, so `%2F` cannot smuggle a `/` past `{file}` — but `any` permits a +literal `..` by definition, which is what `path` closes. + +--- + +## ANTI-PATTERN 6d — Hard-coding a Path in a Link + +**Wrong — silently 404s the moment a project moves the page:** +```php +return Response::redirect('/register'); +``` + +**Correct — a name survives an override or a move:** +```php +return Response::redirect(route('auth.register')); +``` + +The whole platform is built on projects overriding and disabling plugin routes. +A project override INHERITS the plugin route's name, so the link keeps working. +An unknown name or a value violating its type throws at the CALL SITE, turning a +mystery 404 into an error where the link was written. + --- ## ANTI-PATTERN 7 — Skipping a Job by Throwing @@ -405,7 +477,12 @@ class Provider implements ModuleContract | Put authorization in a Controller | Move it to the Service | | Dispatch an event inside a `try` block | Move dispatch after the `try/catch` | | Use `static` properties for caching | Use `CachePort` | -| Define routes in PHP | Define them in `module.json` | +| Define routes in PHP | Define them in `module.json` / `proj.json` | +| Copy a prefix or filter onto every route | State it once in a `groups[]` entry | +| Use `{file:any}` for a download path | Use `{file:path}` — `any` has no traversal guard | +| Hard-code `/register` in a link | `route('auth.register')` — names survive overrides | +| Add a route for a host not in `proj.json` `domains` | Register the host, or use `subdomain` / a wildcard | +| Ship to PHP-FPM without `BOOT_CACHE=1` | Every request recompiles every manifest (~2 ms) | | Use `float` for money | Use `Money::of()` with integer cents | | Throw in a job to skip processing | Return `JobResult::skipped($reason)` | | Use `===` for token comparison | Use `hash_equals()` | diff --git a/docs/guides/16_PLUGINS.md b/docs/guides/16_PLUGINS.md index c062e69..70c01bb 100644 --- a/docs/guides/16_PLUGINS.md +++ b/docs/guides/16_PLUGINS.md @@ -1,8 +1,25 @@ # HKM Kernel — Plugins Layer -> The `plugins/` folder is the home for **locally developed business modules** that belong to -> this specific application but are not published as standalone packages. -> Every module here follows identical GDA rules — only the folder and namespace differ. +> **A plugin is a standalone package in its own git repository.** Since 1.1.0 the kernel +> depends on zero plugins and ships none: `plugins/` in the kernel repo is empty. In a +> PROJECT, `plugins/` holds the plugins that project has installed. +> Every plugin follows identical GDA rules — only the folder and namespace differ. + +**Repo:** `github.com/AlfaCode-Team/hkm-plugin-` · **Package:** +`alfacode-team/hkm-plugin-` · **Namespace:** `Plugins\{Name}\` · **Test doubles:** +`AlfaCode-Team/hkm-test-support` + +Slug = lower-cased folder name, except `DevTools→dev-tools`, `HttpClient→http-client`, +`RedisCache→redis-cache`, `SecurityFilters→security-filters`, `SocialAuth→social-auth`, +and the unhyphenated `SiteSEO→siteseo`, `ViteManifest→vitemanifest`, `OAuth2→oauth2`. + +**Managed with `hkm plugins`** — `install`, `enable` (auto-installs), `disable`, +`uninstall`, `versions`, `outdated`, `update`, `lock`, `verify`, `store`, `domains`, +`create`. Installs resolve to a TAG, never a branch; `plugins.lock.json` records remote, +tag, commit and kernel version (commit it, never hand-edit). A global plugin store keyed +`/-` shares one download across projects. `module.json` +`"kernel": "^1.2"` gates install. A dependency is a DOMAIN, not a repo name — 13 of 28 +domains do not match their repo name, so use `hkm plugins domains` rather than guessing. --- @@ -12,7 +29,13 @@ |---|---| | `modules/` | First-party framework packages (`bind-it`, `php-io-cli`, etc.) loaded as Composer path repositories. These are git submodules and may be published to Packagist. | | `projects/` | Project-layer wiring only — bootstrap files, domain resolution, `platform.json`, `projects.json`. No business logic lives here. | -| `plugins/` | Local business modules unique to this application. Full GDA structure. Autoloaded via `Plugins\\` PSR-4 prefix. Never git submodules. | +| `plugins/` | Business modules. Full GDA structure, autoloaded via the `Plugins\\` PSR-4 prefix. In a PROJECT this holds the plugins that project installed (each from its own repo) plus any project-authored ones. In the KERNEL repo it is empty and stays empty. | + +The placement rule, stated once: **the framework holds only code that is for the +framework; `modules/` holds what the framework needs to run; `plugins/` holds +what extends projects.** Every business capability is a plugin, never the kernel. +Port *interfaces* live in `src/Kernel/Ports/` because the kernel defines the +contract; port *implementations* are always plugins. --- @@ -75,7 +98,7 @@ A route entry may also carry `filters[]` (auth, throttle, …) and an optional `requires[]` of extra module domains. A plugin route normally gets its deps via its own `solves` graph, so `requires[]` is rarely needed here — it is the primary mechanism for PROJECT routes (whose `__project__` scope has no graph); see -[11_PROJECT.md](11_PROJECT.md) "Per-route `requires`". Either way, every +the [project-layer docs](https://github.com/AlfaCode-Team/hkm-project-layer/blob/main/docs/PROJECT.md) "Per-route `requires`". Either way, every `requires[]` domain is validated at BOOT — an unknown domain fails the build. --- @@ -86,12 +109,12 @@ Add the `Provider` class to the appropriate project bootstrap: ```php // projects/admin/bootstrap/app.php -use Plugins\Task\Provider as TaskModule; +use Plugins\Invoice\Provider as InvoiceModule; use Plugins\MyOtherModule\Provider as MyOtherModule; return $builder ->withModules([ - TaskModule::class, + InvoiceModule::class, MyOtherModule::class, ]) ->build(); @@ -99,25 +122,23 @@ return $builder --- -## Registered Plugins +## Which Plugins Exist -| Plugin | Namespace | Solves | Routes | -|---|---|---|---| -| Task | `Plugins\Task\` | `task.management` | `GET/POST /api/tasks`, `GET/POST/DELETE /api/tasks/{id}` | +`hkm plugins domains` lists every installed plugin with the `solves` domain +it claims — live and authoritative. This repository keeps no static catalogue; +one would go stale, and it already had. -Infrastructure plugins (port adapters / pipeline stages, no routes) — see -[20_FIRST_PARTY_PLUGINS.md](20_FIRST_PARTY_PLUGINS.md) for the full list and the -module-activation notes (on-demand vs essential): +**A plugin documents itself, in its own repository.** Each one ships a +`README.md` (what it is, how to install it) and a `CLAUDE.md` (its contract, +its `config[]`, and the rules specific to it); some also ship a `docs/` deep +dive. `module.json` is the authoritative source for `requires[]`, `exposes[]`, +`emits[]` and `config[]` — read it there rather than from any summary. -| Plugin | Solves | Provides | Activation | -|---|---|---|---| -| Storage | `storage.local` | `StoragePort` (local + S3) | on-demand | -| HttpClient | `http.client` | `HttpClientPort` (cURL) | on-demand | -| Session | `session.management` | `SessionPort` (file/array/cookie drivers) | essential | -| Cookie | `http.cookies` | `CookieJar` + flush stage | essential | -| RedisCache | `cache.redis` | `CachePort` + `QueuePort` | essential | -| SecurityFilters | `http.security_filters` | global hooks: CORS, SecureHeaders. Route-filter aliases: `auth`, `throttle`, `hmac`, `shield` | hooked + filters | -| Tenancy | `tenancy.routing` | `TenantRegistryContract` + `TenantConnectionResolverContract` + `MembershipServiceContract` + `InvitationServiceContract` (database-per-tenant routing + selection/invitation flows; STRICT: every request must resolve a tenant or 404 — no unscoped passthrough; refresh tokens in `Plugins\Auth`; `requires: ["database.management"]` — route-level `requires[]` carry auth/user/audit for its own endpoints) | essential (declare `"essentials": ["tenancy.routing"]` in proj.json) | +``` +✗ Documenting a plugin's behaviour, API, env vars or wiring in this repository — + the copy in the kernel is the one that goes stale +✗ Inferring a plugin's requires[] from a table anywhere — open its module.json +``` --- @@ -129,7 +150,7 @@ project's `proj.json` `views` into `view-manifest.php`, which the View plugin's renderer consumes. ```jsonc -// plugins/Task/module.json +// {Invoice plugin}/module.json "views": "resources/views" // namespace defaults to "task" "views": { "path": "resources/views", "namespace": "task", "priority": 100, "global": true } // explicit form @@ -161,7 +182,9 @@ The resource-resolution model (project-over-plugin, deterministic at boot) is de ✓ module.json handlers use fully-qualified Plugins\... class strings ✓ Provider registered in projects/{project}/bootstrap/app.php ✗ Do NOT place plugin files under projects/ — that folder is for wiring only -✗ Do NOT add plugins as Composer path repositories — Plugins\ PSR-4 covers autoloading +✗ Do NOT author plugin source in the KERNEL repo's plugins/ — it ships no plugins +✗ Do NOT add a hkm-plugin-* require to the kernel's composer.json +✗ Do NOT hand-edit plugins.lock.json, or guess a plugin's repo from its solves domain ✗ All GDA five-layer access rules apply exactly as for any other module ``` @@ -169,9 +192,22 @@ The resource-resolution model (project-over-plugin, deterministic at boot) is de ## Adding a New Plugin (Checklist) -1. `mkdir -p plugins/{Name}/{API/Contracts,API/Dto,API/IntegrationEvents,Application/Services,Domain/Entities,Domain/ValueObjects,Domain/Events,Infrastructure/Http,Infrastructure/Persistence}` -2. Write `plugins/{Name}/module.json` — set `"type": "module"`, `"solves"`, routes with `Plugins\\{Name}\\...` handlers -3. Implement all layers under `namespace Plugins\{Name}\...` -4. Write `plugins/{Name}/Provider.php` — `namespace Plugins\{Name};` implements `ModuleContract` -5. Add `Plugins\{Name}\Provider::class` to the relevant `projects/*/bootstrap/app.php` -6. Run `composer dump-autoload` if the new namespace isn't picked up automatically +1. `hkm plugins create {name}` scaffolds `plugins/{Name}/` from `templates/plugin/`. + By hand: `mkdir -p plugins/{Name}/{API/Contracts,API/Dto,API/IntegrationEvents,Application/Services,Domain/Entities,Domain/ValueObjects,Domain/Events,Infrastructure/Http,Infrastructure/Persistence}` +2. Write `plugins/{Name}/module.json` — `"type": "module"`, a `"solves"` domain no + other module claims, routes with `Plugins\\{Name}\\...` handlers, and **every + env var the plugin reads** in `config[]` (with a `default` wherever one exists — + that is the value `hkm plugins enable` seeds into the project `.env`). +3. Implement all layers under `namespace Plugins\{Name}\...`, obeying the five + access rules. +4. Write `plugins/{Name}/Provider.php` — `namespace Plugins\{Name};` implements + `ModuleContract`; `solves()`/`requires()`/`exposes()` must mirror `module.json`. +5. Add `Plugins\{Name}\Provider::class` to the relevant + `projects/*/bootstrap/app.php` `withModules([...])`. +6. Run `composer dump-autoload` if the new namespace isn't picked up automatically. +7. Test it with the **Ground** plugin (`PluginGround::for(Provider::class)`) — a + real kernel boot in a temp workspace, not a hand-rolled bootstrap. Gate CI on + `hkm plugin:check`. +8. If it is going to its own repository, give it a `README.md` (install + + capability) and a `CLAUDE.md` (its contract, `config[]` and plugin-specific + rules). Those two files are where the plugin is documented — not in the kernel. diff --git a/docs/guides/17_PHP_IO_CLI.md b/docs/guides/17_PHP_IO_CLI.md index a34cd96..cdde39d 100644 --- a/docs/guides/17_PHP_IO_CLI.md +++ b/docs/guides/17_PHP_IO_CLI.md @@ -219,7 +219,7 @@ Add to your **project's** `composer.json` (not the library's): "php-io-cli": { "commands": [ "App\\Commands\\MigrateCommand", - "Plugins\\Task\\Infrastructure\\Commands\\TaskListCommand" + "Plugins\\Invoice\\Infrastructure\\Commands\\InvoiceListCommand" ] } } diff --git a/docs/guides/19_DATABASE.md b/docs/guides/19_DATABASE.md deleted file mode 100644 index b67d1f1..0000000 --- a/docs/guides/19_DATABASE.md +++ /dev/null @@ -1,355 +0,0 @@ -# 19 — DATABASE MODULE (Multi-Driver Persistence) - -> Enterprise multi-driver implementation of the kernel `DatabasePort`. -> Lives in `plugins/Database/` under the `Plugins\Database\` namespace. -> Solves the `database.management` domain. - ---- - -## WHAT THIS MODULE IS - -The Database module is the **single concrete implementation** of the kernel -`DatabasePort` interface. The kernel defines the port; this module provides a -production-grade adapter that speaks to four database engines through PDO: - -| Engine | Driver key | DSN prefix | -|---|---|---| -| MySQL / MariaDB | `mysql` | `mysql:` | -| PostgreSQL | `pgsql` | `pgsql:` | -| SQLite (file or `:memory:`) | `sqlite` | `sqlite:` | -| SQL Server | `sqlsrv` | `sqlsrv:` | - -Repositories depend on `DatabasePort` only. They never import a driver class or -the adapter — driver selection is an infrastructure concern resolved at boot from -`DB_*` environment variables. - -``` -Repository ──> DatabasePort (kernel interface) - ▲ - │ bound by Plugins\Database\Provider - │ - MultiDriverDatabaseAdapter ──> PDO ──> {MySQL|PostgreSQL|SQLite|SQL Server} -``` - ---- - -## FOLDER STRUCTURE - -``` -plugins/Database/ -├── module.json ← solves database.management, declares DB_* config -├── Provider.php ← wiring only: factory → adapter → DatabasePort -├── API/ -│ └── Contracts/ -│ ├── DatabaseConfigurationContract.php ← driver(), dsn(), pdoOptions(), initStatements() -│ └── DatabaseConnectionManagerContract.php ← named multi-connection registry -├── Infrastructure/ -│ ├── Drivers/ -│ │ ├── DatabaseConfigurationFactory.php ← alias resolution + per-driver defaults -│ │ ├── MySQLConfiguration.php -│ │ ├── PostgreSQLConfiguration.php -│ │ ├── SQLiteConfiguration.php -│ │ └── SqlServerConfiguration.php -│ ├── Persistence/ -│ │ ├── MultiDriverDatabaseAdapter.php ← DatabasePort implementation (direct) -│ │ ├── PooledDatabaseAdapter.php ← DatabasePort implementation (pool-backed, request-scoped) -│ │ ├── ConnectionManager.php ← DatabaseConnectionManagerContract implementation -│ │ └── SavepointGrammar.php ← driver-correct nested-transaction SQL -│ └── Pool/ -│ ├── ConnectionPool.php ← per-worker pool: warmup, validate, evict, stats -│ ├── PoolConfiguration.php ← min/max/timeouts/validate (DB_POOL_*) -│ └── PooledConnection.php ← slot wrapper (lifetime + idle bookkeeping) -└── Exceptions/ - └── ConnectionException.php ← the only exception that escapes the module -``` - ---- - -## THE FIVE ENTERPRISE BEHAVIOURS - -### 1. Lazy connection -The adapter does **not** open a socket in its constructor. PDO is created on the -first query (or explicit `pdo()` / `ping()` call). Booting a module that never -touches the database costs nothing — consistent with GDA "load only what is needed". - -```php -$db = new MultiDriverDatabaseAdapter($config); -$db->isConnected(); // false — no socket yet -$db->query('SELECT 1'); -$db->isConnected(); // true -``` - -### 2. Nested transactions via savepoints -`beginTransaction()` / `commit()` / `rollback()` **nest**. Only the outermost -level drives the real transaction; inner levels use `SAVEPOINT` so a partial -rollback does not abandon the whole unit of work. `SavepointGrammar` emits the -correct dialect (`SAVEPOINT` / `RELEASE` / `ROLLBACK TO` for standard SQL; -`SAVE TRANSACTION` / `ROLLBACK TRANSACTION` for SQL Server). - -```php -$db->transaction(function (MultiDriverDatabaseAdapter $db) { - $db->execute('INSERT ...'); // outer - $db->transaction(fn ($db) => // inner — savepoint - $db->execute('INSERT ...')); -}); // single real COMMIT -``` - -`transaction(callable)` commits on success and rolls back on **any** throwable, -re-throwing the original exception. This is the preferred entry point for service -code that already wraps work in `TransactionManager`. - -### 3. Auto-reconnect -Long-running Swoole workers keep connections for hours. When a statement fails -with a "server has gone away" class error **and no transaction is active**, the -adapter transparently reconnects and retries the statement once. Inside a -transaction it does not retry (the transaction is already invalid) — it surfaces -the error so the caller rolls back. - -### 4. Post-connect init statements -Each driver returns `initStatements()` run immediately after connecting: - -| Driver | Statements | Why | -|---|---|---| -| SQLite | `PRAGMA foreign_keys = ON`, `busy_timeout = 5000`, `journal_mode = WAL`* | FK enforcement is **off by default** in SQLite | -| MySQL | `SET SESSION sql_mode = 'STRICT_ALL_TABLES,…'` | fail on truncation/coercion instead of silent corruption | -| SQL Server | `SET XACT_ABORT ON` | whole-transaction rollback on any runtime error | -| PostgreSQL | — | strict + FK-enforcing by default | - -\* WAL is skipped for `:memory:`. - -### 5. Query observability -Inject an optional PSR-3 `LoggerInterface`. Every statement is timed: -- `logQueries = true` → each query logged at **debug**. -- Any query slower than `slowQueryThresholdMs` (default 200ms) → logged at - **warning**, regardless of the debug flag. - -Set `DB_ENABLE_QUERY_LOG=true` to turn on debug logging through the Provider. - ---- - -## CONFIGURATION (ENV-DRIVEN) - -`DatabaseConfigurationFactory::fromEnvironment()` reads: - -| Variable | Applies to | Default | -|---|---|---| -| `DB_DRIVER` | all (aliases: `mariadb`, `postgres`, `mssql`, `sqlserver`, …) | `sqlite` | -| `DB_HOST` | mysql, pgsql, sqlsrv | driver default | -| `DB_PORT` | mysql, pgsql, sqlsrv | 3306 / 5432 / 1433 | -| `DB_DATABASE` | all (SQLite: file path or `:memory:`) | `:memory:` | -| `DB_USERNAME` / `DB_PASSWORD` | mysql, pgsql, sqlsrv | driver default | -| `DB_CHARSET` | mysql | `utf8mb4` | -| `DB_SSL_MODE` | pgsql (`disable`…`verify-full`) | `prefer` | -| `DB_SSL_VERIFY` / `DB_SSL_CA` | mysql | off | -| `DB_UNIX_SOCKET` | mysql, pgsql | — | -| `DB_ENCRYPT` / `DB_TRUST_SERVER_CERT` | sqlsrv | off | -| `DB_ENABLE_QUERY_LOG` | observability | off | - -Every variable is declared in `module.json` `config[]` — an undeclared variable -read by the module fails boot (GDA rule). - ---- - -## WIRING - -`Provider::register()` performs wiring only — no business logic: - -```php -$container->singleton(DatabaseConfigurationContract::class, fn () => - (new DatabaseConfigurationFactory())->fromEnvironment()); - -$container->bind(DatabasePort::class, fn ($c) => - new MultiDriverDatabaseAdapter( - config: $c->make(DatabaseConfigurationContract::class), - logger: /* optional PSR-3 */, - logQueries: env('DB_ENABLE_QUERY_LOG') === 'true', // env() — never getenv() for .env values - )); - -$container->singleton(DatabaseConnectionManagerContract::class, /* registry */); -``` - -The module is registered in `app/bootstrap/base.php`: - -```php -->withModules([ - Plugins\Database\Provider::class, - Plugins\Commands\Provider::class, -]); -``` - ---- - -## CONNECTION POOLING (OPT-IN, PER WORKER) - -Under OpenSwoole the kernel boots **once per worker** and handles many requests -on that long-lived process. Reconnecting to the database on every request wastes -the TCP/TLS handshake. The pool keeps a bounded set of warm connections and lends -one per request. - -### Topology - -``` -Worker process (app-lifetime) -└── ConnectionPool ← ONE per worker, bound via withPorts (CoreContainer) - ├── idle: [conn, conn, …] ← warm, ready to lend - └── borrowed:{conn, …} ← currently checked out - -Request (request-scoped) -└── PooledDatabaseAdapter (DatabasePort) - └── pins ONE borrowed connection for the whole request, - returns it to the pool on teardown -``` - -`PooledDatabaseAdapter` pins a single connection per request so `lastInsertId()` -and multi-statement transactions stay correct, then `release()`s it on teardown -(`__destruct` is the safety net). Because each request gets its own adapter and -(by default) requests run sequentially per worker, no per-coroutine keying is -needed; when `SWOOLE_COROUTINE=true`, `acquire()` yields the scheduler while -waiting for a free slot. - -### Enabling it - -Set `DB_POOL_ENABLED=true`. The bootstrap (`app/bootstrap/base.php`) builds one -`ConnectionPool` per worker and registers it app-lifetime via `withPorts`; the -module's `Provider` then binds `DatabasePort` to a request-scoped -`PooledDatabaseAdapter`. If no app-lifetime pool is present the Provider falls -back to a container-singleton pool, so the pooled path also works in tests/CLI. - -### Tuning (`DB_POOL_*`) - -| Variable | Default | Meaning | -|---|---|---| -| `DB_POOL_ENABLED` | `false` | Master switch for the pooled DatabasePort | -| `DB_POOL_MIN` | `0` | Connections opened at warm-up and kept hot | -| `DB_POOL_MAX` (alias `DB_POOL_SIZE`) | `10` | Hard ceiling on connections per worker | -| `DB_POOL_ACQUIRE_TIMEOUT_MS` | `3000` | Wait before `poolExhausted` when saturated | -| `DB_POOL_IDLE_TIMEOUT` | `60` | Evict a connection idle longer than this (s) | -| `DB_POOL_MAX_LIFETIME` | `3600` | Recycle a connection older than this (s) | -| `DB_POOL_VALIDATE` | `true` | `ping()` a reused connection before lending | - -A connection that is stale (past idle/lifetime) or fails validation is closed -deterministically (`MultiDriverDatabaseAdapter::close()`) and replaced. A -connection returned mid-transaction is rolled back before re-entering the pool. - -### Observability - -`ConnectionPool::stats()` returns `idle`, `active`, `total`, `max`, `min`, -`waiters`, `closed` — wire it into a health endpoint to watch saturation. - -Sizing rule of thumb: `DB_POOL_MAX × worker_count` must stay under the database -server's `max_connections`. - ---- - -## MULTI-DATABASE (READ REPLICAS / WAREHOUSE) - -`ConnectionManager` implements `DatabaseConnectionManagerContract` for setups -needing more than one connection. Connections are built lazily and cached: - -```php -$manager->register('primary', $primaryConfig); -$manager->register('replica', $replicaConfig); - -$manager->connection('replica')->query('SELECT ...'); // reads -$manager->default()->execute('INSERT ...'); // writes -$manager->close('replica'); // drop one -``` - ---- - -## ERROR HANDLING - -Every `\PDOException` is translated to `Plugins\Database\Exceptions\ConnectionException` -— no vendor exception escapes the module (GDA gateway/repository rule). It carries -structured context for the kernel `ErrorPipeline`: - -```php -try { - $db->query($sql); -} catch (ConnectionException $e) { - $e->driver; // 'mysql' | 'pgsql' | 'sqlite' | 'sqlsrv' - $e->operation; // 'connect' | 'query' | 'execute' | 'transaction.commit' | … - $e->getPrevious(); // original \PDOException -} -``` - -Repositories should catch `ConnectionException` and re-throw a `RepositoryException` -(per [05_REPOSITORY.md](05_REPOSITORY.md)). - ---- - -## TESTING - -The module ships a full unit suite under `tests/Unit/Database/` (85 tests). It uses -**SQLite `:memory:`** as a real connection — no mocking of PDO, so transaction and -savepoint behaviour is genuinely exercised: - -```bash -vendor/bin/phpunit tests/Unit/Database -``` - -Test coverage: -- `Drivers/*ConfigurationTest` — DSN, PDO options, init statements, password redaction -- `Drivers/DatabaseConfigurationFactoryTest` — alias resolution, env parsing, unknown driver -- `Persistence/MultiDriverDatabaseAdapterTest` — CRUD, nested tx/savepoints, `transaction()`, error translation, lazy connect -- `Persistence/ConnectionManagerTest` — named connection registry lifecycle -- `Persistence/QueryLoggingTest` — debug + slow-query logging -- `Exceptions/ConnectionExceptionTest` — structured context - -For repository/service tests, prefer the in-memory adapter or a `DatabasePort` -fake (see [10_TESTING.md](10_TESTING.md)). - ---- - -## CROSS-DRIVER PORTABILITY (UNIFORM API) - -PDO's API and the `:named` placeholder scheme are identical across MySQL, -PostgreSQL and SQLite — but the SQL *text* is not. `DatabasePort` absorbs the -constructs that genuinely differ so repositories never branch on the driver: - -| Need | Use | Never hand-write | -|---|---|---| -| Insert-or-update | `$db->upsert($table, $values, $conflictColumns, $updateColumns)` | `ON DUPLICATE KEY UPDATE` / `ON CONFLICT …` | -| Last insert id | `$db->lastInsertId($sequence = null)` — pass the sequence name on PostgreSQL | `lastInsertId()` assuming MySQL semantics | - -`upsert()` compiles to `INSERT … ON DUPLICATE KEY UPDATE col = VALUES(col)` on -MySQL and `INSERT … ON CONFLICT (cols) DO UPDATE SET col = EXCLUDED.col` on -PostgreSQL/SQLite, quoting identifiers per driver. `$conflictColumns` must have a -matching unique/PK constraint. `$updateColumns`: `null` = all non-conflict -columns, `[]` = do nothing on conflict (insert-if-absent), a subset = only those -(e.g. refresh `role`/`updated_at` but preserve the original `joined_at`). It is -atomic — no UPDATE-then-INSERT race. - -Constructs the port does NOT abstract (keep to the portable subset, or branch on -`$db->driver()` in the rare case you must): string concatenation (`CONCAT` vs -`||`), `SUBSTRING`/`substr`, `bytea`/BLOB streams, and vendor functions. Prefer -computing such values in PHP and binding the result. Full guidance: -[22_DATA_ACCESS_ORM_BLUEPRINT.md](22_DATA_ACCESS_ORM_BLUEPRINT.md). - -## RULES — WHAT NOT TO DO - -``` -✗ Hand-writing ON DUPLICATE KEY / ON CONFLICT — use $db->upsert() (driver-portable) -✗ Importing a driver/adapter class in a repository — depend on DatabasePort only -✗ Reading DB_* env vars anywhere but DatabaseConfigurationFactory -✗ Letting a \PDOException escape the module — always ConnectionException -✗ Putting business logic in Provider — wiring only -✗ float for money columns — integer cents (see Domain/ValueObjects rules) -✗ Opening the connection eagerly in a constructor — it is lazy by design -✗ Catching ConnectionException and swallowing it — translate to RepositoryException -✗ Adding a 5th driver without an initStatements() review and a config test -✗ Making PooledDatabaseAdapter app-lifetime — it MUST be request-scoped (per-request pin) -✗ Making ConnectionPool request-scoped — it MUST be app-lifetime (one per worker) -✗ Holding a borrowed connection across requests without release() — starves the pool -✗ Setting DB_POOL_MAX × workers above the server's max_connections -``` - ---- - -## RELATED CONTEXT - -- [05_REPOSITORY.md](05_REPOSITORY.md) — repository layer rules (DatabasePort only) -- [18_MIGRATIONS.md](18_MIGRATIONS.md) — LetMigrate uses the same `DB_*` variables -- [16_PLUGINS.md](16_PLUGINS.md) — plugins folder convention -- [10_TESTING.md](10_TESTING.md) — port fakes and service tests -``` diff --git a/docs/guides/20_FIRST_PARTY_PLUGINS.md b/docs/guides/20_FIRST_PARTY_PLUGINS.md deleted file mode 100644 index f4ff1fa..0000000 --- a/docs/guides/20_FIRST_PARTY_PLUGINS.md +++ /dev/null @@ -1,586 +0,0 @@ -# First-Party Plugins — Ported / Built Capabilities - -These plugins live under `plugins/` (namespace `Plugins\`) and were added to give -the GDA kernel capabilities it intentionally did not ship with. Each follows the -plugin convention in `16_PLUGINS.md`: a `module.json`, a `Provider`, and the GDA -layer layout. Register a plugin by adding `Plugins\{Name}\Provider::class` to a -project bootstrap (most are already in `app/bootstrap/base.php` or -`projects/admin/bootstrap/app.php`). - -| Plugin | solves | Exposes / provides | -|---|---|---| -| `Authorization` | `authorization.policy` | `AuthorizationServiceContract` (Casbin RBAC/ABAC) | -| `Auth` | `auth.identity` | `AuthServiceContract` + JWT/PAT/session SecurityLayers (asymmetric signing, `jti` revocation, `SessionAuthStage`, `/auth/login\|logout\|me`). **Deep dive: [25_AUTH.md](25_AUTH.md)** | -| `OAuth2` | `oauth.server` | Native OAuth 2.1 + OIDC authorization server (auth-code/PKCE, client-credentials, refresh, password, device; JWKS, introspection/revocation, discovery). Access tokens are platform JWTs. **Deep dive: [26_OAUTH2.md](26_OAUTH2.md)** | -| `SocialAuth` | `auth.social` | `SocialAuthServiceContract` (OAuth1/OAuth2) | -| `SecurityFilters` | `http.security_filters` | global hooks (CORS, SecureHeaders) + route-filter aliases (`auth`, `throttle`, `hmac`, `shield`) | -| `Crypto` | `crypto.services` | `EncryptionPort` + `HashingPort` adapters | -| `Validation` | — (library) | `Validator` rules engine | -| `I18n` | `i18n.translation` | `Translator` — file-based `{APP_LANG_PATH}/{locale}/{group}.php`, dotted `group.key`; `:name`/`:Name`/`:NAME` placeholders (longest-first `strtr`); `choice()` pluralization (`singular\|plural` or ranges `{0}`/`[1,19]`/`[20,*]`); never throws (miss → fallback locale → key). `LocaleStage` (after.load p45) negotiates `Accept-Language` vs `APP_LOCALES` + binds global helpers `__()`/`trans()`/`trans_choice()`/`lang_has()` | -| `Support` | — (library) | `Collection`, `Arr`, `Str`, `Resource`, `collect()` | -| `Mail` | `mail.smtp` | `MailPort` SMTP adapter | -| `Pageflow` | `http.pageflow` | `PageflowResponder` + `PageflowChannel` (Inertia v2 SPA bridge: CSRF, validation/precognition, reactive props, auth, offline) | -| `DevTools` | `dev.tooling` | `make:*`, `module:list/info`, `routes:list`, `project:list` | -| `Storage` | `storage.local` | `StoragePort` — local disk + S3 driver (Flysystem), signed URLs | -| `View` | `view.rendering` | `ViewRendererContract` — PHP template engine (layouts, sections, decorators) | -| `HttpClient` | `http.client` | `HttpClientPort` — cURL client, fluent builder, multipart | -| `Session` | `session.management` | `SessionPort` — file/array/cookie handlers, flash, CSRF, lazy persist | -| `Cookie` | `http.cookies` | `CookieJar` — queued cookies, encrypt/decrypt via `EncryptionPort` | -| `RedisCache` | `cache.redis` | `CachePort` + `QueuePort` — ext-redis, in-memory fallback | -| `Tenancy` | `tenancy.routing` | `TenantRegistryContract` + `TenantConnectionResolverContract` + `MembershipServiceContract` + `InvitationServiceContract` + `TenantHostServiceContract` — database-per-tenant routing + selection/invitation/custom-host flows. (Refresh tokens moved to `Plugins\Auth`.) **Deep dive: [23_TENANCY.md](23_TENANCY.md)** | -| `User` | `user.management` | `UserServiceContract` — GLOBAL central identity (CRUD, credential/email verification, transactional outbox, audit_log). **Deep dive: [24_USER.md](24_USER.md)** | - -Activation: `Storage`, `View`, and `HttpClient` are **on-demand** (a consumer -declares `requires: ["storage.local"]` / `["view.rendering"]` / `["http.client"]`). -`Session`, `Cookie`, and -`RedisCache` are **essential** (registered every request via -`withEssentialModules` in `app/bootstrap/base.php`). `SecurityFilters` runs -`CorsStage` + `SecureHeadersStage` as global hooks and registers the `auth` / -`throttle` / `hmac` / `shield` route-filter aliases (opt in per route via -`"filters": [...]`). See `16_PLUGINS.md` and the SecurityFilters section below for the hook-vs-filter -and module-activation models. - ---- - -## Storage (local + S3) - -`StoragePort` adapter. `STORAGE_DRIVER=local` (default) uses atomic, fsync'd file -writes under `STORAGE_ROOT` (short-write detection guards against silent -disk-full corruption) with HMAC-signed `temporaryUrl()`; `STORAGE_DRIVER=s3` uses -`league/flysystem-aws-s3-v3` (AWS S3 / DigitalOcean Spaces / Cloudflare R2 / MinIO) -with native pre-signed URLs. On-demand: a consuming module declares -`{ "requires": ["storage.local"] }`. - -**S3 credentials:** leave `STORAGE_S3_KEY` empty on EC2/ECS/EKS — `fromConfig()` -then omits static credentials so the AWS default provider chain (IAM -instance/task roles, env, SSO) resolves them. Only set the key/secret for -non-AWS providers or local dev. The adapter is bound as a request-scoped -**singleton**, so the `S3Client` is built once per request, not per resolution. - -**Configuration** is env-driven through `config/storage.php`, read via the -`storage_config()` helper (dotted access; a project copy at -`projects//config/storage.php` overrides the plugin default): - -```php -storage_config('driver'); // 'local' | 's3' -storage_config('local.root'); // STORAGE_ROOT -storage_config('s3.bucket'); // STORAGE_S3_BUCKET -``` - -**Streaming** (large blobs, no full in-memory buffer): - -```php -$path = $storage->store($bytes, 'invoice.pdf', 'invoices/2026', 'private'); -$url = $storage->temporaryUrl($path, 600); - -$storage->storeStream($readable, 'export.csv', 'exports'); // stream → storage -$handle = $storage->readStream('exports/export.csv'); // storage → stream (caller closes) -``` - -Env keys: `STORAGE_DRIVER`, `STORAGE_ROOT`, `STORAGE_URL_BASE`, -`STORAGE_URL_SECRET`, `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_KEY`, -`STORAGE_S3_SECRET`, `STORAGE_S3_ENDPOINT`, `STORAGE_S3_PATH_STYLE`. - -## View (PHP templates) - -`ViewRendererContract` — a PHP template engine ported from CodeIgniter 4 and -rebuilt to GDA rules: **no globals** (view paths, extensions, decorators and the -HTML escaper are all constructor-injected; the engine reads no `config()`/`kernel()`), -**request-scoped** (bound per request, so its mutable template data never leaks -across requests under OpenSwoole), and no file-locator dependency (views resolve -against the injected paths). Supports data binding with optional escaping, -layouts (`$options['layout']` or `extend()`/`section()`), section rendering, -includes and output decorators. On-demand: `{ "requires": ["view.rendering"] }`. - -`Plugins\View\Infrastructure\SidebarManager` ships alongside as a navigation-HTML -builder (instance-scoped icon cache — never `static`). - -Config (env; `VIEW_PATHS` unset → defaults to `/resources/views`): -`VIEW_PATHS` (colon/comma-separated dirs), `VIEW_EXTENSIONS` (default `php`), -`VIEW_SAVE_DATA` (persist data across `render()` calls). - -```php -// Controller injects ViewRendererContract (its module requires "view.rendering"): -return Response::html( - $this->view->setVar('name', $user->name) // pass raw… - ->render('welcome', ['layout' => 'layouts/app']) -); -// Escape ONCE: either pre-escape via setVar(..., 'html') AND echo raw in the -// template, OR pass raw and escape in the template — never both (double-escapes). -``` - -## HttpClient (outbound cURL) - -`HttpClientPort` adapter for Gateways. Dependency-free cURL with an immutable -fluent builder, safe retry/backoff, and manual multipart uploads. Vendor/transport -errors are translated to `GatewayException`. On-demand: `{ "requires": ["http.client"] }`. - -The fluent builder is reachable **through the port** — `HttpClientPort::pending(): -PendingRequestContract` — so a Gateway typed against the kernel contract (never the -concrete adapter) can still use `baseUrl()`, `withToken()`, `asForm()`, `attach()`, -etc. Both `pending()` and the returned `PendingRequestContract` live in the kernel -`Ports` namespace. - -```php -$res = $client->pending()->acceptJson()->withToken($t)->post($url, $payload); -if ($res->ok()) { $data = $res->json(); } -$client->pending()->asMultipart()->attach('file', $bytes, 'a.png')->post($url); -``` - -Hardening / behaviour to rely on: - -- **Retries are idempotent-only by default.** `retry(n)` retries transport failures - AND transient responses (5xx / 429), but ONLY for `GET/HEAD/PUT/DELETE/OPTIONS/TRACE` - — a POST/PATCH is never silently re-executed. Widen deliberately (e.g. an - idempotency-key POST) with `->retryMethods([...])` (builder) or the `retry_methods` - request option. Backoff is coroutine-aware (OpenSwoole/Swoole `Coroutine::usleep`, - else `usleep`) so it never blocks the worker. -- **Header injection is rejected** — CR/LF in any header name/value throws; multipart - field/file names are stripped of CR/LF and `"`. -- **JSON bodies use `JSON_THROW_ON_ERROR`** — an un-encodable payload throws a - `GatewayException`, never ships a silent `{}`. -- **OOM guard** — responses are capped at `HTTP_CLIENT_MAX_RESPONSE_BYTES` - (default 32 MiB) via an aborting cURL progress callback. -- TLS verification on by default; gzip/deflate negotiated transparently; `NOSIGNAL` - set for threaded/Swoole SAPIs. - -Config env: `HTTP_CLIENT_TIMEOUT` (30), `HTTP_CLIENT_CONNECT_TIMEOUT` (10), -`HTTP_CLIENT_RETRY` (0), `HTTP_CLIENT_MAX_RESPONSE_BYTES` (33554432). - -Live demo: `HttpClientController` in `psp-shop` (`/http/get`, `/http/fluent`, -`/http/post`, `/http/error`). - -## Session (essential) - -`SessionPort` adapter with native `\SessionHandlerInterface` handlers -(`SESSION_DRIVER=file|array|cookie`), flash data, CSRF `token()`, `regenerate()`/ -`invalidate()` for fixation defence, and **lazy persistence** — a fresh visitor -who never writes the session gets no file and no cookie (stateless API/bot traffic -stays clean). `StartSessionStage` (hooked `after.load`) opens it before modules -and persists + sets the cookie after, only when `shouldPersist()`. - -> Apps must call `$session->regenerate()` after login (fixation defence). The -> kernel's CSRF layer is double-submit-cookie based and independent of `token()`. - -### Drivers - -| `SESSION_DRIVER` | Storage | Notes | -|---|---|---| -| `file` (default) | one file per session under `var/sessions/` | server-side; `SESSION_PATH` overrides the dir | -| `array` | in-memory (per process) | tests / CLI / stateless contexts | -| `cookie` | **in the session cookie itself** | stateless & horizontally-scalable — no server store | - -### Cookie driver — stateless, encrypted/signed sessions - -`CookieSessionHandler` carries the whole serialized attribute bag inside the -session cookie, so nothing is stored server-side (ideal for multi-node deploys). -Defence in depth, all env-driven: - -- **Protection** — encrypted via `EncryptionPort` when `APP_KEY`/Crypto is present - (confidential + authenticated); otherwise **HMAC-SHA256 signed** with - `SESSION_SIGNING_KEY` (falls back to `APP_KEY`) — readable but tamper-evident, - verified with `hash_equals()`. -- **Timeouts** — `SESSION_LIFETIME` (absolute, never extended by re-saving) and - `SESSION_IDLE_TIMEOUT` (sliding), both enforced server-side on read. -- **Fingerprint binding** — `SESSION_COOKIE_FINGERPRINT=off|ua|ip|ua,ip` ties the - session to a hashed client fingerprint. `ua` survives IP changes (safe for - mobile); `ip`/`ua,ip` are stricter anti-theft. -- **Compression** — `SESSION_COOKIE_COMPRESS` deflates data above N bytes to fit - more under the ~4 KB cookie limit; `SESSION_COOKIE_MAX_BYTES` drops an oversized - cookie (and expires any stale one) rather than emit an invalid `Set-Cookie`. -- **Hard guards** — `SESSION_COOKIE_REQUIRE_AUTH` (default on) fails boot unless - signed or encrypted; `SESSION_COOKIE_REQUIRE_ENCRYPTION` fails boot unless - *encrypted* (blocks the signed-but-readable mode for confidential data). -- **Cookie attributes** — `SESSION_SECURE=auto|true|false`, plus - `SESSION_COOKIE_PATH` / `SESSION_COOKIE_DOMAIN`. -- Binary-safe regardless of `SESSION_SERIALIZATION` (`json` default | `php`). - -> Keep cookie sessions small (ids/flags/CSRF) — they ride on every request and are -> capped at ~4 KB. Use `file` (or a Redis driver) for large session state. - -## Cookie (essential) - -`CookieJar` queues outgoing cookies flushed by `QueuedCookiesStage`; values are -encrypted via `EncryptionPort` (except an exempt list). Read incoming cookies with -`$jar->read($request, $name)` (auto-decrypts; exempt cookies returned raw). -Encryption is only meaningful with `APP_KEY` set — the kernel hard-fails at boot -outside `local`/`testing` when it is missing. - -**Config — `plugins/Cookie/config/cookie.php` (env-driven; project override wins).** -A project may copy it to `projects//config/cookie.php`; `cookie_config()` -resolves the project file first (via `Paths::config()`), else the plugin default. -Every value reads from `.env`: - -| Env | Key | Default | -|---|---|---| -| `COOKIE_LIFETIME` (minutes) | `lifetime` | `120` | -| `COOKIE_PATH` | `path` | `/` | -| `COOKIE_DOMAIN` | `domain` | `null` (bind to issuing host) | -| `COOKIE_SECURE` | `secure` | `true` (set `false` for local http://) | -| `COOKIE_HTTP_ONLY` | `http_only` | `true` | -| `COOKIE_SAME_SITE` | `same_site` | `Lax` | -| `COOKIE_ENCRYPT_EXEMPT` (comma-separated) | `encrypt_exempt` | `[]` | - -`CookieJar::queue()` attributes are nullable — omitted ones fall back to these -defaults, so callers usually pass only name + value. - -**Encryption exemptions (`encrypt_exempt`).** Names listed here are written AND -read as plaintext — `CookieJar` skips both `encryptString()` on flush and -`decryptString()` on `read()` for them. The final list is a base array declared -in `config/cookie.php` MERGED with the comma-separated `COOKIE_ENCRYPT_EXEMPT` -env var (de-duplicated), so deployments can add names without editing code. -Exempt a cookie when its raw value must stay stable and readable as-is: - -- a JS-readable flag (theme, locale) the front-end reads directly; or -- an opaque session/binding cookie a **pre-load security layer** reads raw — e.g. - `CsrfTokenLayer`'s `bindCookie`. Encryption rotates the ciphertext on every - response (random IV), which would break that binding; exempting it keeps the - value byte-stable across requests. See [CSRF guide](21_CSRF.md). - -**Helpers (`plugins/Cookie/Support/helpers.php`, autoloaded):** - -```php -cookie_config(); // full config array (cached per process) -cookie_config('same_site'); // single key -$jar->queue(...cookie('cart', $id, minutes: 30)); // spread into queue() -Response::json($d)->withCookie(...cookie('seen', '1')); // or into withCookie() -``` - -`cookie()` returns a spread-ready attribute array (keys match both -`CookieJar::queue()` and `Response::withCookie()`); `maxAge` is in seconds. - -> `.env` gotcha: an empty value followed by an inline comment (`COOKIE_DOMAIN= # note`) -> resolves to empty — `LoadEnvironment` treats a comment-only value as `''`. Put -> comments on their OWN line to avoid surprises with non-empty values. - -## RedisCache (essential) - -`CachePort` + `QueuePort` on ext-redis (one shared lazy connection). Numbers are -stored raw so `increment()`/`set()`/`get()` interoperate (the rate limiter relies -on this); everything else is serialized. `deletePattern()` uses non-blocking SCAN. -Only binds when `REDIS_HOST` is set (else the in-memory `CachePort` stays). -`REDIS_PERSISTENT=true` enables `pconnect` reuse (FPM only — keep off on Swoole). - ---- - -## Authorization (Casbin) - -Casbin policy engine wrapped for GDA. Policy storage goes through `DatabasePort` -via `DatabasePolicyAdapter` (table `casbin_rule`); the `Enforcer` is an internal -binding and only `AuthorizationServiceContract` is exposed. - -```php -$authz->allows($userId, 'invoice:42', 'edit'); // bool -$authz->assignRole($userId, 'admin', $tenantId); -$authz->grant('admin', 'invoice', 'edit'); -``` - -Model config: `plugins/Authorization/config/rbac_model.conf` (override with -`AUTHZ_MODEL_PATH`). Run the bundled migration to create `casbin_rule`. - -## Auth (JWT + Personal Access Tokens) - -Credential **issuance** is `AuthServiceContract` (`issueJwt`, `createPersonalAccessToken`, -`hashPassword`/`verifyPassword` via `HashingPort`). Credential **verification** is -done by SecurityLayers wired into the kernel `withSecurity([...])` chain: - -- `JwtAuthLayer(secret, algo)` — validates `Authorization: Bearer `. -- `PersonalAccessTokenLayer(databasePort)` — validates DB-backed `` tokens. - -No header → anonymous (public routes still work). Invalid token → `deny(401)`. -PATs are looked up by deterministic `sha256` (passwords use bcrypt via `HashingPort`). - -## SocialAuth (OAuth) - -Ported OAuth providers (GitHub, Google, Facebook, GitLab, Bitbucket, LinkedIn, -Slack, X). A small compat layer (`Socialite/Http`, `Socialite/Support`) lets the -stateful OAuth flow run inside the stateless kernel. OAuth2 drivers work out of -the box; the Twitter OAuth1 driver also needs `league/oauth1-client` + `phpseclib`. - -```php -$social->redirectUrl('github'); // start -$social->userFromCallback('github', $request); // resolve user -``` - -## SecurityFilters (HTTP stages) - -The 0.3 filters rebuilt as `HttpStageContract` stages. CORS + SecureHeaders run as -GLOBAL pipeline hooks (every request); HMAC, auth, Shield and the rate limiter are -exposed as DECLARATIVE route-filter aliases that a route opts into by name. A stage -runs through exactly ONE mechanism — never both (double-registering double-runs it). - -**Global hooks** (registered in `Provider::boot()`, run on every request): - -| Stage | Slot | Config | -|---|---|---| -| `CorsStage` | after.security | `CORS_ALLOWED_ORIGINS/METHODS/HEADERS`, `CORS_ALLOW_CREDENTIALS`, `CORS_MAX_AGE` | -| `SecureHeadersStage` | after.execute | `CONTENT_SECURITY_POLICY`, `HSTS_MAX_AGE` | - -**Route-filter aliases** (registered via `$http->filter(...)`; a route opts in with -`"filters": [...]` in module.json / proj.json): - -| Alias | Stage | Config | -|---|---|---| -| `hmac` | `HmacSignedStage` | `HMAC_PROTECTED_PREFIX`, `REQUEST_SIGNING_SECRET`, `HMAC_MAX_SKEW` | -| `auth` | `RequireAuthStage` | also honours `AUTH_PROTECTED_PATHS` (exact / `prefix/*` / `*` segment) | -| `shield` | `ShieldStage` | `SHIELD_RULES` (`/path=role:admin;/x=perm:y`) | -| `throttle` | `ApiRateLimitStage` | `RATE_LIMIT_PREFIX/MAX/WINDOW` (uses `CachePort`); `"throttle:max,window"` args | - -```jsonc -// require auth + throttle on one route, declaratively -{ "method": "POST", "path": "/api/tasks", "handler": "...@create", - "filters": ["auth", "throttle:60,1"] } -``` - -`RequireAuthStage` enforces when EITHER the route declared the `auth` filter OR the -path is in `AUTH_PROTECTED_PATHS` — the auth layer attaches Identity globally, this -stage decides which routes demand it. See [16_PLUGINS.md](16_PLUGINS.md) for the hook-vs-filter model and `RouteFilterStage` / `FilterRegistry` internals. - -## Crypto (kernel ports) - -Adds two **kernel ports** the framework was missing, with adapters: - -- `EncryptionPort` → `AesEncrypter` — authenticated AES-256-GCM with key rotation. -- `HashingPort` → `PasswordHasher` — bcrypt/argon2 over `password_*`. - -Wired in `app/bootstrap/base.php` from `APP_KEY` / `APP_KEY_PREVIOUS` / -`HASH_BCRYPT_COST`. **Set a real 32-byte `APP_KEY` in production.** - -## Validation - -Dependency-free rules engine that throws the kernel `ValidationException` -(field → messages, the standard 422 shape). Optional `Translator` for localized -messages. - -```php -Validator::make($request->all(), [ - 'email' => 'required|email', - 'age' => 'required|integer|min:18', - 'password' => 'required|min:8|confirmed', -])->validate(); // returns validated data or throws -``` - -Rules: `required, nullable, string, integer, numeric, boolean, array, email, url, -min, max, between, in, regex, same, different, confirmed`. - -## I18n - -File-based `Translator`: `lang/{locale}/{group}.php`, dotted keys, `:placeholder` -substitution, locale→fallback→key resolution, path-traversal guarded. -Config: `APP_LOCALE`, `APP_FALLBACK_LOCALE`, `APP_LANG_PATH`. - -## Support - -`Collection` (fluent, immutable-friendly), `Arr`, `Str`, and `Resource` / -`ResourceCollection` (API transformers). `collect()` helper autoloaded. - -```php -collect($rows)->map(...)->where('active', true)->pluck('id')->all(); -UserResource::collection($users)->toArray(); -``` - -## Mail (SMTP) - -`SmtpMailer` implements `MailPort` over a dependency-free `SmtpTransport` -(STARTTLS/SSL, AUTH LOGIN). Bound only when `SMTP_HOST` is set, so unconfigured -projects are unaffected. Renders PHP-template views or inline HTML. - -## Pageflow (SPA bridge — `http.pageflow`) - -A fork of **Inertia.js v2**, rebranded and wired into the kernel, with -platform-native capabilities Inertia lacks. Server side + the React client both -live in `plugins/Pageflow/` (PHP) and `plugins/Pageflow/ui/` (client). Full usage -guide: `plugins/Pageflow/ui/PAGEFLOW_GUIDE.pdf`. - -### Core protocol - -`PageflowResponder::render($request, $component, $surface, $props = [], -$viteEntry = null, $loadPage = true, $cacheable = false)` returns a JSON page -object for `X-Pageflow` XHR navigations -or an HTML shell on first load (the client boots from the root element's -**`data-page`** attribute — `PageflowPage::mount($appId)` — NOT -`window.initialPage`). Honours partial reloads (`X-Pageflow-Partial-*`). -`PageflowVersionStage` returns `409 + X-Pageflow-Location` on stale assets. -Shared props via `pageflow_share('key', fn($request) => …)`. - -### CSRF - -The responder renders `` into the HTML head (minted from -`APP_KEY` + the session-cookie binding via `CsrfTokenLayer::make`). The client -reads it and sends `X-CSRF-Token` on mutations — **same-origin only** (never -leaked cross-origin). `GET /pageflow/csrf` (throttled) refreshes an expired token -for long-lived tabs; the client's axios interceptor auto-refreshes on a CSRF 403. -The token is intentionally NOT shared as a prop (kept out of JSON / SW cache). - -### Native validation & precognition - -`PageflowValidationStage` turns a kernel `ValidationException` into either a -`422 {errors}` (precognition) or a session-flashed **303 redirect-back** (normal -submit) — controllers just throw via their DTOs; the `errors` shared prop -surfaces them and `useForm` shows them (`preserveState` keeps the form). The -303 `Location` is reduced to a same-origin path (no open redirect). Precognition -(`Precognition: true`) runs validation only; a controller short-circuits with -`pageflow_precognition($request)` → `PageflowResponder::precognitionSuccess()`. -`PageflowPrecognitionStage` flags the request (`precognition` attribute) so a -repo/service can refuse writes. - -### Reactive props (secure server push) - -`PageflowChannel` (CachePort-backed): a Service calls -`$channel->touch("t:{$tenantId}:dashboard", ['orders'])` after commit; the -tenant-scoped `GET /pageflow/stream` SSE endpoint (auth-gated) pushes **stale key -names only — never data**. The client (`useReactiveProps`) reacts with a normal -authorized partial reload. Reconnect-safe via SSE `id:`/`Last-Event-ID`; bounded -lifetime (`PAGEFLOW_STREAM_MAX_SECONDS`). Requires OpenSwoole for real push. - -### Auth projection - -`pageflow_auth` shared prop (via `PageflowAuth`, override with -`pageflow_auth_projection()`) exposes userId/tenant/roles/permissions — -**never tokens**. Client `useAuth()`/`` gate UI (UX only; server stays the -authority). `useFlushOnIdentityChange()` purges prefetch + SW cache on -login/logout/tenant-switch. - -### Offline (opt-in) - -`registerPageflowSW()` + `pageflow-sw.js`: static assets cache-first; page objects -cached **only** when the server opts in (`render(..., cacheable: true)` → -`X-Pageflow-Cache: 1`, or `Cache-Control: public`) — authenticated pages are never -cached by default. `no-store`/`private` always win. - -### Client API (`@pageflow/react`) - -``, `useForm` (+ `resetOnSuccess`/`resetOnError`), `usePage`, ``, -`
`, `usePrecognition`, `useReactiveProps`, `useAuth`, -``, `useDirtyGuard`, `usePoll`, `usePrefetch`, `useRemember`, `Deferred`, -`WhenVisible`, `installCsrfAutoRefresh`, `registerPageflowSW`. Batched deferred -props (N groups → 1 request). CLI `pageflow:types` generates end-to-end `.d.ts`. - -### Endpoints & env - -Routes: `GET /pageflow/csrf` (throttle), `GET /pageflow/stream` (auth + -throttle). Env: `PAGEFLOW_VERSION`, `PAGEFLOW_ROOT_VIEW`, `PAGEFLOW_APP_ID`, -`PAGEFLOW_CSRF_COOKIE`, `PAGEFLOW_CSRF_LIFETIME`, `PAGEFLOW_STREAM_INTERVAL`, -`PAGEFLOW_STREAM_MAX_SECONDS`, `PAGEFLOW_PRECOGNITION_ROLLBACK`. - -## SiteSEO (`seo.management`, on-demand) - -Full SEO toolkit + Project-layer support. `requires: ["http.client"]`. Published -`SeoServiceContract`: `openGraph()`, `schema()`, `sitemap()`, `robots()`, -`pingSitemap()`, `indexNow(host,key,keyLocation,urls,endpoints,dryRun)` -(auto-batches 10k, lazy iterable), `indexNowChunks()`. All outbound HTTP goes -through `Infrastructure/Gateways/SearchEngineGateway` (`HttpClientPort`) — never -raw cURL. The toolkit value classes (`OpenGraph`, `Schema`, `Sitemap*`, -`RobotsTxtEditor`) autoload directly, so building sitemaps / OG / JSON-LD needs -NO module load; only ping + IndexNow do (they hit the network). - -Project-layer helpers (`Project\Support\Seo\`, reusable & DI-free): - -- `RouteCatalog` — public static GET pages from the route manifest (drops - `{param}`, auth-gated, `/api`, SEO endpoints). -- `SitemapGenerator` — small/route-derived `` (≤30k); `toXml()`/`save()`. -- `SitemapStreamWriter` — **enterprise**: streams an `iterable` to split child - files + index at **O(1) memory** (no DOM), 50k split, optional gzip. For - millions of URLs (verified flat memory to 1M+). -- `SitemapUrlProvider` + `SitemapSource` — expand dynamic routes (`/blog/{slug}`) - from the DB with a keyset-cursor generator; `uncoveredDynamicRoutes()` guards - silent omissions. -- `RichGraph` — Schema.org JSON-LD `@graph` for Google rich results (org → - website[SearchAction] → webPage → breadcrumb → content node, linked by `@id`). - Content nodes: article/newsArticle/blogPosting, product (offer+rating+review), - book, course (syllabus), realEstate (lease), pageantEdition/awardEdition/ - contestant (Event+Person), faq. -- `SeoHead` — full ``: title, description, **canonical**, **robots**, - **hreflang**/x-default, plus attached OG + JSON-LD. -- `IndexNowKey` — key/keyLocation value object. - -Controller traits (`Project\Http\Controllers\Concerns\`): `InteractsWithSeo` -(siteBaseUrl, sitemap, openGraph, ogImage, richGraph, robots) and -`InteractsWithGraphSeo` (adds `graph()` + `seoHead()`). - -Background indexing: job `seo.indexnow` (`IndexNowJob`, queue `indexing`, -declared in `module.json` `jobs[]`, bound in `Provider::register()`) submits one -≤10k batch; dispatch by chunking a URL stream and `QueuePort::push()` per batch -(`FileQueue` in `Project\Infrastructure\` is the no-Redis fallback). Index-on- -publish: emit `UrlPublishedIntegrationEvent` after commit → SEO module subscribes -`EnqueueIndexNowListener` (`Provider::boot()`) which enqueues. The EventBus -resolves listeners from the CoreContainer (`has()` bound-only), so the **project -binds the listener with its `QueuePort`** in `bootstrap/app.php`. Env: -`INDEXNOW_KEY` (listener no-ops without it), `INDEXNOW_LIVE`. - -`NOTE` the toolkit had two real bugs fixed during integration: `Schema` now emits -a proper multi-node `@graph` (was serializing only `things[0]`), and the Twitter -card no longer leaks `og:image:*` keys when a structured image is attached. - -## Tenancy (multi-tenant control plane) - -`solves: tenancy.routing`, `requires: ["database.management"]`, **essential**. -Database-per-tenant isolation layered on `plugins/Database`'s `ConnectionManager`. - -Two planes: a **central (control) DB** holds `users`, `tenants`, `user_tenants` -(+ optional invitations/refresh-tokens/audit); each **tenant has its own DB** -containing only business domain (no auth, no `tenant_id` column — the database is -the boundary). User references inside a tenant DB store the central -`users.user_id` ULID as an opaque value (no cross-DB FK). - -Flow: the Auth layer mints a tenant-scoped `Identity` (JWT `tnt` claim → -`Identity.tenantId`) after the user selects a tenant, re-checking `user_tenants` -each request so a revoked membership drops access before the token expires. -`TenantContextStage` (hooked at `after.load`) reads `Identity.tenantId`, asks -`TenantConnectionResolver` for that tenant's `DatabasePort`, and **rebinds -`DatabasePort` in the request container** — every repository then transparently -talks to the tenant DB. - -- **`TenantRegistry`** — cached reads of central `tenants` (DatabasePort-only, - reads the `ConnectionManager` default = central connection). -- **`TenantConnectionResolver`** — `tenant_id → DatabasePort`; registers a named - `tenant:` connection (password decrypted via `EncryptionPort` at connect - time only). **Fail-closed**: unknown/suspended/deleted/unreachable → throw, - never falls back to another tenant or central. Per-tenant **circuit breaker** - (`TENANCY_BREAKER_THRESHOLD`/`TENANCY_BREAKER_COOLDOWN`) isolates one dead - tenant DB from the fleet. -- **Swoole-safe**: tenant `DatabasePort` is bound into the per-request - `ModuleContainer` (discarded on `reset()`); tenant id rides on the immutable - `Request`/`Identity`, never a static or `CoreContainer`. For cross-request - pooling, bind `ConnectionManager` + resolver into the `CoreContainer` in - bootstrap (see the plugin README) and LRU-evict idle tenant connections. -- **CLI**: `tenants:create` (registry row → CREATE DATABASE → template migrate → - activate, with compensating `provisioning` status) and `tenants:migrate` - (resumable, failure-isolated fleet migrator; each tenant DB keeps its own - `let_migrations` table; central `tenants.schema_version` mirrors drift). -- **Tenant template** migrations live in `plugins/Tenancy/database/tenant-template/` - (override via `TENANCY_TEMPLATE_PATH`). Use expand→migrate→contract for - destructive changes and canary waves across the fleet. -- **Tenant-selection flow** (`MembershipServiceContract`, requires `auth.identity`): - `GET /api/me/tenants` lists active seats; `POST /api/tenants/{tenantId}/select` - re-verifies the membership against central `user_tenants` (never trusts a - client-supplied id), mints a tenant-scoped token via the Auth module (`tnt` - claim), and audits `tenant.switch`. `TENANCY_TOKEN_TTL` sets the scoped-token - lifetime. A revoked seat fails selection (`403`, audited `tenant.switch_denied`) - and loses access on an already-issued token via the per-request re-check. -- **Control-plane tables** (central migrations): `tenants`, `user_tenants`, - `tenant_invitations` (email onboarding, hashed token), `audit_log` (append-only). -- **Invitations** (`InvitationServiceContract`): `invite()` returns a one-time - token (hash stored); `accept()` requires the user's verified email to match, - creates/activates the seat (idempotent), audits `member.join`; `revoke()`. -- **Refresh tokens** moved to `Plugins\Auth` (`RefreshTokenServiceContract`, `POST /auth/refresh`) — tenant-agnostic; the tenant seat check stays at tenant-SELECT here. - -Env: `TENANCY_MODE` (`claim` = JWT `tnt` claim, default · `domain` = Host -sub-domain), `TENANCY_BASE_DOMAINS`, `TENANCY_REGISTRY_TTL`, -`TENANCY_BREAKER_THRESHOLD`, `TENANCY_BREAKER_COOLDOWN`, `TENANCY_TEMPLATE_PATH`, -`TENANCY_TOKEN_TTL` / `TENANCY_REFRESH_TTL` / `TENANCY_ACCESS_TTL`. -**Full AI reference: [23_TENANCY.md](23_TENANCY.md)** · human guide: -`plugins/Tenancy/README.md`. - -## DevTools (CLI) - -`make:plugin`, `make:service` (GDA scaffolding), plus introspection that reads -`module.json` as the source of truth: `module:list`, `module:info `, -`routes:list` (with collision detection), `project:list`. - ---- - -## Tests - -Unit tests for the new plugins live under `tests/Unit/Plugins/` (Crypto, -Validation, I18n, Support, Pageflow). Run `vendor/bin/phpunit`. diff --git a/docs/guides/21_CSRF.md b/docs/guides/21_CSRF.md index 4f13e84..a7436fc 100644 --- a/docs/guides/21_CSRF.md +++ b/docs/guides/21_CSRF.md @@ -165,7 +165,7 @@ Two consequences: - **Add the binding cookie to `encrypt_exempt`** (`COOKIE_ENCRYPT_EXEMPT` env or the base list in `plugins/Cookie/config/cookie.php`). It is then stored AND read as plaintext, so its raw value is byte-stable — the cleanest option for - pinning to the session cookie. See [First-party plugins → Cookie](20_FIRST_PARTY_PLUGINS.md). + pinning to the session cookie. See the [Cookie plugin](https://github.com/AlfaCode-Team/hkm-plugin-cookie). - Queue a dedicated binding cookie with `raw: true` and read it back with `$request->cookie(...)` (NOT `$this->cookie(...)`, which tries to decrypt). - Bind to a cookie that is not re-written every response (so its value never diff --git a/docs/guides/22_DATA_ACCESS_ORM_BLUEPRINT.md b/docs/guides/22_DATA_ACCESS_ORM_BLUEPRINT.md index a08229e..df65b89 100644 --- a/docs/guides/22_DATA_ACCESS_ORM_BLUEPRINT.md +++ b/docs/guides/22_DATA_ACCESS_ORM_BLUEPRINT.md @@ -278,5 +278,6 @@ Swoole request isolation. - `docs/guides/05_REPOSITORY.md` — repository layer rules in detail - `docs/guides/18_MIGRATIONS.md` — LetMigrate engine + patterns -- `docs/guides/19_DATABASE.md` — multi-driver Database module + DatabasePort adapter +- `hkm-plugin-database` → `docs/DATABASE.md` — the multi-driver `DatabasePort` adapter + (https://github.com/AlfaCode-Team/hkm-plugin-database) - `docs/guides/03_DOMAIN.md` — entity / value object / reconstitute() patterns diff --git a/docs/guides/23_TENANCY.md b/docs/guides/23_TENANCY.md deleted file mode 100644 index a961a40..0000000 --- a/docs/guides/23_TENANCY.md +++ /dev/null @@ -1,250 +0,0 @@ -# Tenancy Plugin — Multi-Tenant Control Plane - -> AI reference for `Plugins\Tenancy\` (solves `tenancy.routing`, **essential**). -> Database-per-tenant isolation + central control plane on top of -> `plugins/Database`'s `ConnectionManager`. Pairs with [09_SECURITY](09_SECURITY.md), -> [19_DATABASE](19_DATABASE.md), [24_USER](24_USER.md). - ---- - -## WHAT IT DOES - -Maps an incoming request to **one tenant**, then rebinds `DatabasePort` to that -tenant's **isolated database** for the request, so every repository downstream -transparently talks to the right DB. The control-plane tables (tenant registry, -memberships, invitations, hosts, audit) live in the **central** -database and are NEVER tenant-routed. - -``` -Request → identify tenant → resolve isolated DatabasePort → rebind for this request - (claim / host / cookie) (registry + breaker, fail-closed) -``` - -`requires: ["database.management"]` — module-level requires cover ONLY the -always-on `TenantContextStage` path. Everything the selection / admin / -invitation / host ROUTES need (`auth.identity`, `user.management`, -`audit.trail`, `http.pageflow`) is declared per route in `module.json` -`routes[].requires`, so a Tenancy-essential project does not register those -modules on every request. - ---- - -## TENANT IDENTIFICATION — `TENANCY_MODE` - -The pluggable `TenantIdentifier` seam decides WHICH tenant a request belongs to. -`identify(Request): string` returns the tenant id, or `''` when none was -identified — which the stage FAILS CLOSED on (404). It may also throw -`UnknownTenantException` to refuse a host explicitly (same 404). - -| Mode (`TENANCY_MODE`) | Identifier | Tenant source | -|---|---|---| -| `claim` (default, SaaS) | `ClaimTenantIdentifier` | `Identity.tenantId` (the signed JWT `tnt` claim) | -| `domain` (storefront) | `DomainTenantIdentifier` | Host sub-domain under `TENANCY_BASE_DOMAINS` | -| `host` (custom domains) | `HostTenantIdentifier` | FULL hostname via the central `tenant_hosts` registry | - -**STRICT routing — no unscoped passthrough.** Every request must resolve to a -tenant: cookie hint first, then the identifier; both empty ⇒ **404** (`Tenant -not found`). Every host the app serves must therefore be assigned to a tenant -(`tenant:host:add` in host mode; a resolvable label in domain mode). Central -control-plane code never depends on the stage skipping the rebind — it pins the -central connection explicitly via the `ConnectionManager` default. - -**Activation — must be ESSENTIAL, declared by the PROJECT.** `TenantContextStage` -is an always-on `after.load` hook that resolves `TenantIdentifier` + the -connection resolver from the **request container**; those bindings only exist -when `Tenancy::register()` ran, and the stage now FAILS LOUDLY when they are -absent. A multi-tenant project declares `"essentials": ["tenancy.routing"]` in -its `proj.json` (read by `EntryHelpers::projectEssentials()` → -`Kernel::withEssentialModules()`, which also accepts domains and fails the boot -on an unknown one). A single-tenant project leaves Tenancy OUT of `withModules` -entirely — merely dropping it from essentials would make the always-on stage -throw on every request. Essentials resolve through the dependency graph, so -Tenancy's `database.management` requirement loads with it automatically. - -**`domain` mode + session login — cross-subdomain cookie.** Control-plane routes -(`/auth/login`, `/ajx/me/tenants`, `/ajx/tenants/{id}/select`) run on the -apex/central host (`shop.localhost` → `''` → central); tenant-scoped routes run on -`.shop.localhost` (→ that tenant's DB). For the apex login's session to -carry to the tenant sub-domains, set the session cookie's domain to the shared -base: `SESSION_COOKIE_DOMAIN=.shop.localhost` (host-only otherwise = 401 on the -sub-domain). Reserved sub-domains (`TENANCY_RESERVED_SUBDOMAINS`: www, api, admin, -…) resolve to central, never a tenant. - ---- - -## REQUEST ROUTING — `TenantContextStage` (after.load, priority 5) - -`Infrastructure/Http/Stages/TenantContextStage.php`. Runs after the request -container exists, before route filters / `ExecuteStage`. - -1. Resolve the active tenant: **encrypted cookie hint first** (principal-bound), - then the `TenantIdentifier` (see cookie section). Both empty → **404 fail - closed** — there is NO unscoped passthrough to the central `DatabasePort`. -2. `resolver->for($tenantId)` → isolated `DatabasePort` (registry lookup + - per-tenant circuit breaker; **fail-closed**, no silent fallback). -3. `$container->instance(DatabasePort::class, $db)` — rebind for THIS request only. -4. `$request->withAttribute('tenant', $tenantId)` — expose to controllers. -5. `$container->bind('tenant.current', fn() => $tenantId)` — a **plain string - container key** so request-scoped services that never see the `Request` (e.g. - the User `AuditLogger`) can read the active tenant with no Tenancy import. - Use `bind()` (closure), NOT `instance()`: the kernel `ModuleContainer::instance()` - requires an `object`, so binding the bare tenant-id string there throws a - `TypeError` on every host/domain-routed request. -6. On `UnknownTenantException` → 404 (and forget a stale cookie hint); on - `TenantUnavailableException` → 403/410/503; connectivity faults feed the breaker. - -``` -✗ Binding tenant context into CoreContainer — it rides the request + request container only (Swoole-safe) -✗ Reading $_SERVER for the host inside a module — use $request->attribute('tenant') -✗ Silent fallback to central or another tenant on resolution failure — fail closed -``` - -### Tenant cookie (encrypted hint — never authority) - -`TenantContextStage` writes an **encrypted, user-bound** cookie remembering the -active tenant so a returning user keeps their selection without re-running the -picker. Properties: - -- **Encrypted** via the Cookie plugin's `EncryptionPort` (tamper → `read()` returns null). -- **Principal-bound**: stores `{t: tenantId, u: userId}`; honoured only by the - exact principal that minted it — a user's hint never replays onto another user - (or a post-logout guest), while a guest-minted hint (`u` = `''`) keeps working - for guests so public pages retain their selection. Log-in flips the principal - and re-mints. -- **Cookie first**: the remembered selection is consulted BEFORE the identifier; - the identifier only runs when there is no valid hint. Every hint is still - fully re-validated below, so a stale/hostile value can never route to an - unknown tenant. -- **Still revalidated** every request through `resolver->for()` — a hint, exactly - like the `tnt` claim. A stale hint at a deleted tenant is auto-forgotten. - ---- - -## PUBLISHED CONTRACTS (`exposes`) - -| Contract | Role | -|---|---| -| `TenantRegistryContract` | tenant_id → connection coordinates (CachePort-cached) | -| `TenantConnectionResolverContract` | `for($tenantId): DatabasePort` (+ breaker) | -| `MembershipServiceContract` | `myTenants`, `isActiveMember`, `selectTenant` | -| `InvitationServiceContract` | email invite → seat (`invite`, `accept`) | -| `TenantHostRegistryContract` | hostname → tenant_id resolution | -| `TenantHostServiceContract` | `add`/`verify`/`makePrimary`/`remove` custom hosts | - -Internal ports (`Application/Ports/`): `MembershipReader`/`MembershipWriter`, -`InvitationStore`, `TenantHostStore`, `AuditSink` (write), -`AuditReader` (read), `DnsResolver`. - ---- - -## CENTRAL TABLES (control plane — never in a tenant DB) - -| Table | Repository | Notes | -|---|---|---| -| `tenants` | `TenantRegistry` | registry; `db_password_enc` encrypted via `EncryptionPort` | -| `user_tenants` | `MembershipRepository` | M:N user↔tenant + role/status; FK → central `users`/`tenants` | -| `tenant_invitations` | `InvitationRepository` | email onboarding, hashed token | -| `tenant_hosts` | `TenantHostRepository` | PK is **`host_id`** (not `id`); custom domains + DNS verify | -| `audit_log` | write `AuditTrail` / read `AuditLogRepository` | append-only; keyset-paginated reads | - -Migrations: `plugins/Tenancy/database/migrations/`. Tenant template schema (run -per new tenant DB): `plugins/Tenancy/database/tenant-template/` (or -`TENANCY_TEMPLATE_PATH`). See [18_MIGRATIONS](18_MIGRATIONS.md). - ---- - -## AUDIT TRAIL (`audit_log`) - -Shared central table written by BOTH Tenancy and the User plugin. - -- **Write**: `AuditSink::record(action, userId?, tenantId?, meta[], ip?)` → - `AuditTrail` (best-effort — an audit write NEVER breaks the audited action). -- **Read**: `AuditReader` → `AuditLogRepository` — `recent`, `forTenant`, - `forUser`, `byAction` (keyset-paginated by descending id), `find(eventId)`, - `countForTenant`, `purgeOlderThan(cutoff)` (retention/GDPR). LIMIT is clamped + - **inlined as an int** (cannot be bound with emulated prepares off); filter - values stay parameter-bound. - ---- - -## MEMBERSHIP & SELF-SIGNUP ASSIGNMENT - -A new user is assigned to their originating tenant via the **`user.registered`** -integration event (User's transactional outbox, relayed by `user:outbox:relay`): - -``` -self-signup on tenant host → RegisterUserDTO reads request 'tenant' attribute - → UserRegisteredIntegrationEvent carries tenantId (persisted in the outbox) - → Tenancy's AssignTenantMembershipOnUserRegistered listener (subscribed in boot()) - → MembershipWriter::upsertActive(userId, tenantId, 'member') [idempotent] -``` - -- The listener resolves from the **CoreContainer** (no request context) — so the - tenant MUST ride on the event payload, never re-derived at relay time. -- The project binds the listener in the CoreContainer with a central-connection - `MembershipWriter` (the EventBus resolves listeners there). See [08_EVENTS](08_EVENTS.md). -- Assignment is **eventually consistent** (lands when the relay runs) and - **idempotent** (`upsertActive` upserts on `(user_id, tenant_id)`). - ---- - -## CLI COMMANDS (claim mode only — registered in `Provider::boot()`) - -Registered via a deferred closure that builds a scoped `ModuleContainer` -(Database + Crypto + Tenancy) so commands with module-scoped deps resolve. Hidden -in `domain` mode (tenants are provisioned by the project's own tooling there). - -| Command | Purpose | -|---|---| -| `tenant:create` | Provision: registry row → CREATE DATABASE → DB user + grant → template migrations → activate. Interactive wizard (RadioGroup driver picker, masked Password, NumberInput port) when flags are missing. **Compensating rollback** on any failure (DDL isn't transactional on MySQL). | -| `tenant:delete` | Drop the tenant DB user (all hosts), optionally the database (`--drop-database`), and the registry row. Requires confirmation / `--yes`. | -| `tenant:host:add` | Register a hostname (via `TenantHostService`); `--verified` seeds it past DNS, `--primary` makes it canonical. Prompts (tenant Select, host, IP Select) for anything omitted in a terminal. | -| `tenant:migrate` | Run tenant template migrations across the fleet (per-tenant transactional, failure-isolated, resumable). | - -### Tenant DB user provisioning (driver-aware, `ManagesTenantDatabase` trait) - -- **Privileges are scoped to the tenant's database only** — `GRANT ALL ON \`db\`.*` - (MySQL) / database `OWNER` (pgsql) / `db_owner` (sqlsrv). Never global. -- **MySQL accounts are loopback-only by default** — created at `localhost`, - `127.0.0.1`, `::1` (works over socket AND TCP); a non-loopback host pins to that - exact host. **The `'%'` wildcard is never used.** -- Supported: `mysql`/`mariadb`, `pgsql`, `sqlsrv`. `sqlite` is rejected (no - users/CREATE DATABASE — provision file-per-tenant instead). - ---- - -## TENANT SELECTION & TOKENS (HTTP, `/ajx/...`) - -- `GET /ajx/me/tenants` → list my tenants. `POST /ajx/tenants/{id}/select` → - re-verifies membership, mints a `tnt`-scoped access JWT. -- DECOMPOSED (tenancy ≠ authentication): `MembershipService` is control plane - ONLY — `selectTenant()` verifies the seat + audits and returns the verified - `TenantSummary`; it has NO Auth dependency. `TenantController` is the - composition point: it mints the token via `AuthServiceContract` (with - `roles` and the `name` claim read through User's published - `TenantProfileReaderContract`) and builds the `TenantSelection` response. - This also keeps the container graph acyclic (AuthService → UserService → - MembershipService — no cycle back into Auth). -- `POST /ajx/invitations/accept` → join a tenant from an emailed invite. -- Refresh-token rotation is NOT here — it moved to `Plugins\Auth` (`POST /auth/refresh`). Tenancy re-checks the tenant seat only at tenant-SELECT. -- Custom hosts: `GET/POST /ajx/tenant/hosts`, `…/{hostId}/verify|primary`, DELETE. - -The signed `tnt` claim is a **hint, not authority** — authorization still keys on -`(userId, tenantId, role/permission)` and membership is re-checked each request so -a revoked seat loses access before token expiry. - ---- - -## ABSOLUTE RULES - -``` -✓ Control-plane tables (tenants, user_tenants, invitations, hosts, audit_log) are CENTRAL — pin to ConnectionManager default. (refresh_tokens now belongs to Plugins\Auth.) -✓ TenantContextStage rebinds DatabasePort per request ONLY; never into CoreContainer. -✓ Tenant DB users: privileges scoped to their own database; MySQL accounts loopback/host-pinned, never '%'. -✓ Membership assignment travels on the user.registered event payload (outbox), idempotent upsert. -✓ Mint a tenant-scoped token ONLY after verifying membership; re-check every request. -✗ Reading $_SERVER / re-identifying the tenant inside a module — use $request->attribute('tenant'). -✗ Trusting the tnt claim or tenant cookie as authority — both are revalidated hints. -✗ Hand-writing CREATE USER with '@%' or cross-DB privileges in provisioning. -✗ Binding the membership/audit listener WITHOUT the project supplying its central writer in CoreContainer. -``` diff --git a/docs/guides/24_USER.md b/docs/guides/24_USER.md deleted file mode 100644 index 3c949f1..0000000 --- a/docs/guides/24_USER.md +++ /dev/null @@ -1,191 +0,0 @@ -# User Plugin — Central Identity - -> AI reference for `Plugins\User\` (solves `user.management`). -> The GLOBAL central identity store: CRUD, credential verification, email -> verification, transactional outbox. Pairs with [09_SECURITY](09_SECURITY.md) -> (Auth issues tokens over this identity), [23_TENANCY](23_TENANCY.md) (memberships -> link users to tenants), [08_EVENTS](08_EVENTS.md). - ---- - -## WHAT IT DOES - -Owns the **global, central `users` table** — identity is centralized, username -and email are globally unique. Repositories + the outbox are pinned to the -**central** connection (the `ConnectionManager` default), so identity I/O is -NEVER redirected to a tenant DB even when `TenantContextStage` rebinds -`DatabasePort` for the request. - -`requires: ["database.management", "crypto.services", "cache.redis", "view.rendering", "http.client"]` -`exposes: ["Plugins\User\API\Contracts\UserServiceContract"]` (the ONLY cross-module -contract — feedback + settings are internal to the plugin) - -**No `status` column.** The login gate is a verified email: `verifyCredentials` -checks `User::canLogin()` (= `email_verified_at` is set). "Disable" = soft delete. -The old `status` / `auth_provider` / `provider_subject` / `is_platform_admin` / -`last_login_at` columns were removed. - ---- - -## PUBLISHED CONTRACT — `UserServiceContract` - -`Application/Services/UserService.php`. All methods take/return DTOs (`API/DTOs/`) -— never entities or raw arrays across the boundary. - -| Method | Notes | -|---|---| -| `register(RegisterUserDTO): UserDTO` | tx + outbox; emits `user.registered` | -| `list(ListUsersQuery): UserPage` | paginated | -| `find(id): ?UserDTO` | | -| `update(id, UpdateUserDTO): ?UserDTO` | optimistic-locked (`version`); emits `user.updated` | -| `verifyEmail(id, VerifyEmailDTO): ?UserDTO` | | -| `verifyCredentials(identifier, password): ?UserDTO` | timing-safe, rate-limited; rehash-on-login | -| `delete(id): bool` | emits `user.deleted` | - -`RegisterUserDTO::fromRequest()` also reads the request **`tenant`** attribute -(set by Tenancy's `TenantContextStage`) into `$tenantId` — an opaque string that -is forwarded on the `user.registered` event so Tenancy can assign membership. -User stays tenant-agnostic (no Tenancy import). - ---- - -## TENANT PROFILE READS — `TenantProfileReaderContract` (published) - -`TenantProfileProvisioner` now IMPLEMENTS the published -`TenantProfileReaderContract` (`fullName(userId, tenantId): string`) in two -construction modes: **pinned** (a `UserSettingsRepository` already built -against the resolved tenant connection — the listener path) or **resolver** -(the container binding — resolves the tenant DB per call through Tenancy's -`TenantConnectionResolverContract`). Reads are BEST-EFFORT and never throw — -a missing profile / unreachable tenant DB yields `''`. Consumers: Tenancy's -tenant-selection (the JWT `name` claim) and `UserService::find()` (attaches -`UserDTO.fullName` when a membership pins the tenant). `UserDTO` also carries -`avatarUrl` and `permissions`; `UserProfile::fullName()` composes first + last. -`UserServiceContract::find()` gained `bool $isAuth = false` — skips the -self-or-permission check for issuance-time lookups by Auth (request Identity -is still guest during login). - ---- - -## SERVICE PATTERN (mandatory shape) - -Mutating methods follow the kernel transaction+event pattern (see [04_SERVICE](04_SERVICE.md)): - -``` -collector->beginCollection(); transaction->begin(); - try { entity op → flushEvents() → repository.insert() → commit(); } - catch { rollback(); collector->discard(); throw wrap(...); } -collector->release(); // domain events -audit->record('user.…', [...]); // security audit (also persisted to audit_log) -``` - -Integration events are written to the **transactional outbox** inside the tx -(durable), NOT dispatched inline. - ---- - -## EVENTS — TRANSACTIONAL OUTBOX - -`emits: ["user.registered", "user.updated", "user.deleted"]` - -- `flushEvents()` → `toIntegration()` builds the integration event and - `OutboxWriter::write()`s it into `user_outbox` **in the same transaction** as - the user change (atomic, no lost/phantom events). -- `user:outbox:relay` (CLI command, `Infrastructure/Cli/RelayUserOutboxCommand`) - drains pending rows and dispatches a `GenericIntegrationEvent` (carrying the - stored payload array) to the EventBus. Delivery is **at-least-once** → listeners - must be idempotent. -- `UserRegisteredIntegrationEvent` carries `userId, username, email, occurredAt` - **+ `tenantId`** (origin tenant for self-signup; `''` when none). This is how - Tenancy auto-assigns membership — see [23_TENANCY](23_TENANCY.md). - ---- - -## SECURITY AUDIT — `AuditLogger` - -`Infrastructure/Audit/AuditLogger.php`. Records security-relevant actions -(register, update, email verified, login failed/locked-out, password rehash, -delete) — **identifiers + outcomes only, never passwords/hashes/PII**. - -- Writes a structured JSON line (via `error_log`, tagged `source=user_audit`). -- **Also persists to the shared central `audit_log` table** when a `DatabasePort` - is injected: `userId`→`user_id`, `ip`→`ip`, the rest→JSON `meta`, `event_id` - via `Ulid::generate()`. **Best-effort** (try/catch — an audit write must never - break the audited action; the log line is the durable fallback). -- `tenant_id` is stamped from the `'tenant.current'` container key published by - Tenancy's `TenantContextStage` (`has()`-guarded — no Tenancy dependency); `NULL` - for unscoped/CLI requests. -- Reads/queries of `audit_log` are Tenancy's `AuditReader`/`AuditLogRepository`. - ---- - -## DATA - -| Table | Repository | Notes | -|---|---|---| -| `users` | `UserRepository` (central) | ULID `user_id`; unique username/email; `password_hash`, `remember_token` (60/64 char); `version` (optimistic lock); login gate = `email_verified_at` | -| `user_outbox` | `OutboxWriter` / `OutboxRelay` (central) | transactional integration-event outbox | -| `user_feedback` | `FeedbackRepository` (TENANT) | tenant-scoped; `feedback_id` UUID public id; `user_id` = central ULID (soft ref, no FK) | -| `user_profiles` / `user_preferences` / `user_privacy_settings` / `user_notification_preferences` | `UserSettingsRepository` (TENANT) | per-user singletons; one row per `user_id`; portable `upsert` | - -Central schema → `database/migrations/` (`migrate:run`). Tenant schema → -`database/tenant-template/`, applied per-tenant by the **Tenancy** tooling -(`tenant:migrate`), NOT `migrate:run`. - -- Passwords hashed via `crypto.services` (bcrypt, rehash-on-login). Hashes and - remember tokens NEVER cross the API boundary. -- `UserId`/`Ulid` value objects generate the 26-char public id. -- See [05_REPOSITORY](05_REPOSITORY.md), [18_MIGRATIONS](18_MIGRATIONS.md). - ---- - -## ROUTES (`module.json`) - -- HTML (View): `GET /users[...]`, plus demo pages `GET /account/settings`, - `/account/feedback`. -- JSON identity (`/ajx/users...`): `POST /ajx/users` register (`throttle:10,1` — - **anonymous**, not auth-gated), `GET/PUT/PATCH/DELETE /ajx/users/{id}` + - verify-email (`auth`). -- JSON feedback (`auth` + `tenant`): `POST /ajx/feedback` (`throttle:5,1`), - `GET /ajx/feedback`, `GET /ajx/feedback/{id}`, `PATCH /ajx/feedback/{id}`. -- JSON settings (`auth` + `tenant`): `GET/PUT /ajx/{profile,preferences,privacy, - notification-preferences}` (PUT `throttle:30,1`). - ---- - -## TENANT-SCOPED SUB-RESOURCES (feedback & settings) - -Internal capabilities whose data lives in the **tenant** DB (not central): - -- **Repositories take the request `DatabasePort`** (tenant-routed by - `TenantContextStage`), NOT `self::central()`. `user_id` is the central ULID, - carried as a soft reference (no cross-DB FK). -- **Routes declare `["auth", "tenant"]`.** The `tenant` filter (Tenancy plugin) - returns **409** when no tenant is active → these never silently hit central. -- **Self-scoped** — user id from `Identity`, never the body. AuthZ in the service. -- **Internal, not published** — bound `bindInternal`; controllers depend on the - concrete `FeedbackService` / `UserSettingsService`. They return the domain - **entity** and the controller serialises via `entity->toArray()` (no output DTO). -- **Feedback** = full CRUD (`submit`/`find`/`list`/`updateStatus`, forward-only - status, `feedback:manage` for triage); emits `feedback.submitted` **directly** - (single insert, not the outbox). **Settings** = one `UserSettingsService` + - `UserSettingsRepository` for the 4 singletons, idempotent `PUT` via `upsert`, - audited on write. - ---- - -## ABSOLUTE RULES - -``` -✓ users + user_outbox are CENTRAL — pin repositories to the ConnectionManager default, never the request DatabasePort. -✓ Integration events go through the transactional outbox; relayed at-least-once → idempotent listeners. -✓ Audit records identifiers/outcomes ONLY; DB persistence is best-effort and never aborts the action. -✓ Password hashes / remember tokens never appear in a DTO or response. -✓ Writes are optimistic-locked on `version`. -✓ users/feedback/settings split connections: identity = CENTRAL, feedback/settings = TENANT (request DatabasePort). -✗ Importing a Tenancy class from User — User forwards the opaque 'tenant' request attribute only. -✗ Dispatching user identity events inline instead of via the outbox (feedback.submitted is a single insert → direct dispatch is fine). -✗ Returning entities across the PUBLISHED contract (UserServiceContract) — use API/DTOs. (Internal feedback/settings services return entities; their controllers toArray().) -✗ Reading user IDENTITY from a tenant-routed DatabasePort — always central. (Feedback/settings deliberately DO use the tenant connection.) -✗ Applying tenant-template schema with migrate:run — it is per-tenant (tenant:migrate). -``` diff --git a/docs/guides/25_AUTH.md b/docs/guides/25_AUTH.md deleted file mode 100644 index 40ffb26..0000000 --- a/docs/guides/25_AUTH.md +++ /dev/null @@ -1,324 +0,0 @@ -# Auth Plugin — Authentication (tokens + sessions) - -> AI reference for `Plugins\Auth\` (solves `auth.identity`). -> Issues credentials (JWT, personal access tokens) and provides the -> SecurityLayer verifiers the kernel runs before any module loads. Pairs with -> [09_SECURITY](09_SECURITY.md), [24_USER](24_USER.md) (verifies credentials), -> [26_OAUTH2](26_OAUTH2.md) (OAuth2 access tokens are the same JWTs this layer -> verifies). - ---- - -## WHAT IT DOES - -The kernel ships **no** token validator — Auth fills the intended "AuthModule -layer" slot. It splits cleanly: - -- **Issuance** lives in `AuthService` (exposed via `AuthServiceContract`): mint - JWTs, create/revoke personal access tokens (PATs), establish/tear down web - sessions, hash/verify passwords. -- **Verification** lives in `SecurityLayer` classes a project wires into - `Kernel::withSecurity([...])`; the SecurityGateway runs them before any module - loads (deny = zero module cost). - -``` -requires: ["database.management", "crypto.services", "user.management"] -exposes: ["Plugins\Auth\API\Contracts\AuthServiceContract"] -``` -Control-plane tables (`personal_access_tokens`) are pinned to the **central** -connection. The session login flow verifies credentials via `UserServiceContract`. - ---- - -## SECURITY LAYERS (wired in the project bootstrap) - -### `JwtAuthLayer` — stateless Bearer JWT -```php -new JwtAuthLayer( - secret: $hsSecretOrPublicKeyPem, // HS secret, or PEM PUBLIC key for RS/ES/PS - algo: 'RS256', // single pinned algo — never trust the token's `alg` - issuer: env('JWT_ISSUER'), // when set, `iss` MUST match - audience: env('JWT_AUDIENCE'), // when set, `aud` MUST contain it (list-aware, hash_equals) - leeway: 60, // clock-skew tolerance for exp/iat/nbf - revocations: $cachePort, // optional jti deny-list -); -``` -- No `Authorization` header → **allow as guest** (public routes keep working). -- Valid Bearer → `Identity` from `sub`/`tnt`/`roles`/`permissions`. -- Malformed / expired / wrong iss|aud / **revoked `jti`** → `deny(401)`. -- Revocation deny-list **fails OPEN** on a cache outage (token is otherwise valid). - -### `PersonalAccessTokenLayer` — DB-backed `Bearer .` -Hashes (`sha256`) and matches against `personal_access_tokens`; **enforces -`expires_at`** (expired = absent), loads the token's `abilities` into -`Identity.permissions`, and stamps `last_used_at`. Empty `tenantId` (unscoped / -central) — consistent with the JWT layer. - -JWT/JOSE verification is the ONLY auth the kernel delegates here; everything else -(firewall, rate-limit, CSRF) is kernel-native. - ---- - -## PUBLISHED CONTRACT — `AuthServiceContract` - -| Method | Notes | -|---|---| -| `issueJwt(userId, claims, ttl): string` | adds `iat/nbf/exp/jti`, plus `iss/aud` when configured. Asymmetric algos sign with the **private key** (`JWT_PRIVATE_KEY[_FILE]`), optional `kid` | -| `revokeJwt(jti, ttl): void` | deny-lists a `jti` via `CachePort` (key `auth:jwt:revoked:`) so a token dies before expiry | -| `createPersonalAccessToken(userId, name, abilities, ttl): {id, token}` | plaintext returned ONCE; only the hash is stored; optional abilities + expiry | -| `revokePersonalAccessToken(id): void` | | -| `tokensFor(userId): list` | lists a user's PATs (newest first), **no secret material**. GDA replacement for the old `HasApiTokens::tokens()` | -| `guard(Request): Guard` | read-only projection over the request `Identity` — replaces the old `AuthManager`/named guards (see below) | -| `startSession(SessionPort, userId, roles, permissions, tenantId): void` | rotates session id (fixation defence), stores identity | -| `endSession(SessionPort): void` | invalidate + rotate | -| `hashPassword / verifyPassword` | bcrypt/argon2 via `HashingPort`, timing-safe | - ---- - -## GUARD — READ-ONLY IDENTITY PROJECTION (replaces `AuthManager`) - -There is no guard/driver factory. The SecurityGateway chain -(`JwtAuthLayer` → `PersonalAccessTokenLayer` → `SessionAuthStage`) already -resolved WHO authenticated and by WHICH credential. `Plugins\Auth\API\Guard` is a -stateless, allocation-cheap projection over the request `Identity`: - -| Method | Meaning | -|---|---| -| `check()` / `guest()` | authenticated? | -| `id()` / `tenantId()` | user id / tenant ('' = central) | -| `via()` | `'jwt' \| 'api_key' \| 'session' \| 'none'` — the "named guard", derived not chosen | -| `viaToken()` / `viaSession()` | Bearer credential vs stateful session | -| `hasRole()` / `hasPermission()` | RBAC | -| `hasScope(s)` | token scope — matches a bare permission OR OAuth2's `scope:` namespaced form | - -Controllers get it via the `Project\Http\Controllers\Concerns\InteractsWithAuth` -concern: `$this->guard()`, `$this->identity()`, `$this->authId()`, -`$this->tokenCan('write')`. Works even without the Auth module loaded (it reads -the kernel `Identity`). - ---- - -## AUTHMANAGER — NAMED GUARDS + PROVIDERS (config-driven) - -For multi-guard apps (session web + token API + jwt), `AuthManager` manages named -**guards** and user **providers** from `config/auth.php`. GDA-native rework of the -old `__DEV__` AuthManager — no global `auth.` alias, no `kernel()`/`config()` -reach-ins, and the kernel `Identity` stays the principal (guards resolve an -`AuthUserProxy` that **emits** an `Identity`). - -```php -$manager->guard(); // default guard (config defaults.guard) -$manager->guard('api')->user(); // ?Authenticatable (AuthUserProxy) -$manager->guard('jwt')->identity(); // kernel Identity -$manager->provider('users'); // a named UserProvider (ModelUserProvider) -``` - -| Piece | Role | -|---|---| -| `AuthManager` | request-scoped registry; `guard($name)`, `user()`, `check()`, `id()`, `provider($name)`. Bind `setRequest($request)` per use (Request is not container-bound) | -| `UserProvider` / `ModelUserProvider` | resolves users from a store. Default `users` provider is ModelUserProvider over `UserServiceContract` (no ORM). `retrieveByCredentials` does the FULL timing-safe verify (the store hides the hash) | -| `AuthUserProxy` | lightweight current-user; carries id/username/email + security context; `identity(): Identity`. NOT the principal | -| `GuardDriver` (`Infrastructure/Auth/Drivers/*`) | `session` (session store), `jwt`/`token` (rehydrate the SecurityGateway verdict by tokenType), `request` (credential-agnostic) | - -**Driver "scan":** `AuthManager::drivers()` filesystem-scans -`Infrastructure/Auth/Drivers/*.php` for `GuardDriver` implementations, keyed by -`driverName()`, **once per process, cached** (boot-time — a deliberate, -documented exception to the GDA no-runtime-discovery rule; never on the hot path). - -Controllers: `Project\Http\Controllers\Concerns\InteractsWithAuthManager` → -`$this->auth('api')->user()`, `$this->authUser()`. A route using it must declare -`"requires": ["auth.identity"]`. Config lives in `config/auth.php` (project copy -wins), read via `auth_config()`. - ---- - -## HIERARCHICAL SCOPE INHERITANCE - -Scopes/abilities are colon-hierarchical: a held scope satisfies every descendant. -`ScopeInheritance::satisfies($held, $required)` powers `Guard::hasScope()`, -`AuthUserProxy::tokenCan()` and `TokenDTO::can()`. - -```php -Guard::actingAs('u1', ['admin'])->hasScope('admin:users:write'); // true (ancestor) -Guard::actingAs('u1', ['reports'])->hasScope('billing'); // false -// '*' grants everything; 'scope:'-namespaced (OAuth2) and bare (PAT) both match; -// non-colon-boundary prefixes never match ('adm' ≠ 'admin'). -``` - ---- - -## PERSONAL ACCESS TOKENS — self-service (`/auth/tokens`) - -First-party user API keys (`Bearer .`), owner-scoped to the caller's -Identity. Backed by `AuthServiceContract` (hash-only storage). NOT OAuth clients, -NOT used by session login. - -| Route | Action | -|---|---| -| `GET /auth/tokens` | list my tokens (no secrets) | -| `POST /auth/tokens` | mint (plaintext returned ONCE) | -| `DELETE /auth/tokens/{id}` | revoke one of MY tokens (else 404) | - -`AuthServiceContract`: `createPersonalAccessToken`, `revokePersonalAccessToken`, -`tokensFor(userId): list`. `PersonalAccessTokenFactory` + -`PersonalAccessTokenResult` mint the one-time result. `AuthUserProxy` exposes -HasApiTokens (`tokens()/token()/tokenCan()/createToken()`). - ---- - -## REFRESH TOKENS — revocable first-party sessions (`/auth/refresh`) - -Relocated from Tenancy (authentication ≠ tenancy). `RefreshTokenServiceContract`: -`issue/rotate/revoke/revokeAllForUser`. One-time-use rotation with rotation-family -reuse detection (replay/race → burn the family → 401). Only the SHA-256 is stored; -the raw token is returned once. Table `refresh_tokens` (central, `family_id`). - -**Tenant-agnostic:** `tenantId` rides through as a scope hint for the paired -access token's `tnt` claim but is NEVER re-verified on refresh — tenant seat checks -live in the Tenancy `/ajx/tenants/{id}/select` flow. - -- `POST /auth/refresh` `{token}` → new access JWT + rotated refresh token (401 on invalid/reuse). -- `POST /auth/refresh/logout` `{token}` → revoke a single session. - -## TRANSIENT TOKEN — first-party SPA (`/auth/token/refresh`) - -`POST /auth/token/refresh` (auth-filtered). A session-authenticated SPA mints a -short-lived (900s) JWT carrying the session identity's real roles/permissions — -the scoped replacement for Passport's blanket transient token. A Bearer/PAT caller -(non-session) is refused. - -## PASSWORD RESET — `PasswordBroker` - -CachePort-backed, enumeration-safe. `sendResetLink(email)` mints a one-time hashed -token (throttled); `validateToken`; `reset(email, token, newPassword)` sets the -password (via `UserServiceContract::resetPassword`, which also clears remember -tokens) and burns the token. Statuses: `RESET_LINK_SENT` / `PASSWORD_RESET` / -`INVALID_USER` / `INVALID_TOKEN` / `THROTTLED`. - ---- - -## SESSION AUTH (web + AJAX) - -The session is opened at `after.load` (`StartSessionStage`, priority 20) — AFTER -the SecurityGateway — so session auth CANNOT be a SecurityLayer. Instead -`SessionAuthStage` is an `after.load` hook at **priority 22** (after session -start, before the route `auth` filter): - -- A request already carrying a token-derived `Identity` is left untouched (token - wins). -- An anonymous request with a logged-in session gets a `tokenType: 'session'` - Identity rebuilt from the session. -- The same `auth` route filter then protects **both** token and session callers. - -**The session Identity is bound into BOTH the request AND the request-scoped -container.** `OnDemandLoader` binds `Identity::class` at `LoadStage` from the -PRE-auth (guest) request — which runs *before* this `after.load` stage. So -`SessionAuthStage::attach()` rebinds `Identity::class` into `$request->container()` -too, not just the request. Without that rebind the `auth` route filter would pass -(it reads the request) but every **service** — which injects `Identity` from the -container — would still see a guest, so service-layer permission checks -(`requirePermission()`, `isGuest()`) would wrongly fail. Token auth is unaffected: -it attaches its Identity in the SecurityGateway (before `LoadStage`), so the -container already holds the right one. Any stage that *elevates* an Identity -mid-pipeline (adds roles/permissions) must follow the same rule — rebind the -container, not only the request. - -Endpoints (`SessionAuthController`): `POST /auth/login` (verifies via User module, -then `startSession`), `POST /auth/logout`, `GET /auth/me`. CSRF is the kernel's -`CsrfTokenLayer` (these routes are outside `/api`). - -### Post-login redirect ("previous page") - -The Session plugin's `StartSessionStage` records the last eligible page view -(GET + 2xx, HTML navigation OR a Pageflow page object via the `X-Pageflow` -response header; auth/OAuth/API/asset paths exempt, extend with -`SESSION_PREVIOUS_EXEMPT`) under **`StartSessionStage::PREVIOUS_URL`** — the -SINGLE source of truth for the key (value `auth.previous_url`; no duplicate -const anywhere). On successful `POST /auth/login`, first match wins: - -1. an explicit `redirectTo` on the login request (query or body), -2. the recorded previous page — PULLED one-time, so a fulfilled intent never - goes stale, -3. `/`. - -Browser form POSTs get a real 302; AJAX/SPA callers get `redirectTo` in the -JSON payload (alongside `user`) and navigate client-side. BOTH candidates pass -the same open-redirect guard (`safeRedirect()`): relative `/…` paths only — -`//host`, `/\` tricks and absolute URLs are rejected. SocialAuth's web -callback consumes the same key (falls back to `SOCIAL_AUTH_SUCCESS_REDIRECT`). - -### Display identity (username / email / fullName / avatarUrl) - -`Identity` carries best-effort display fields. `AuthService` fills -username/email from the central user store at issuance when the caller didn't -supply them (`displayIdentity()` → `UserServiceContract::find(id, false, -isAuth: true)` — `isAuth` skips the self-or-permission check, since at -issuance the request Identity is still guest). They ride as OIDC claims -(`preferred_username`, `email`, `name`) on JWTs — rebuilt statelessly by -`JwtAuthLayer` — and as session keys (`SESSION_USERNAME/EMAIL/NAME/AVATAR`) -for session logins/recaller resurrection. `name` (first + last) lives in the -TENANT `user_profiles` table, so only tenant-aware flows (tenant selection) -mint it. The `users` constructor dep is a **LAZY closure** (`fn(): -UserServiceContract`): an eager `make()` recurses AuthService → UserService → -MembershipService → AuthService until `max_execution_time`. - -### Remember-me (recaller cookie) - -`POST /auth/login` with `remember=true` issues an encrypted `remember_web` -cookie holding a `userId|token` **recaller** (`Plugins\Auth\Domain\ValueObjects\Recaller` -— a flat pipe string; NEVER unserialized). When a later request has no live -session, `SessionAuthStage::fromRecaller()`: - -1. reads + decrypts the cookie (via the essential `CookieJar`); -2. resolves the user by the token's SHA-256 hash (`UserServiceContract::findByRememberToken`), - rejecting a mismatched owner id or a forged/stale token; -3. re-opens the session (`startSession`, rotating the id) and attaches a - `tokenType: 'session'` Identity; -4. **rotates** the token + cookie (`cycleRememberToken`) so a stolen cookie is a - single-use window. - -Logout clears the stored token (`clearRememberToken`) and expires the cookie, so -outstanding recallers die immediately. The `remember_token` column + index live -on the central `users` table. Backed by `UserServiceContract`: -`findByRememberToken(token)`, `cycleRememberToken(userId): plaintext`, -`clearRememberToken(userId)`. - ---- - -## CLI - -- `auth:tokens:prune [--dry] [--watch=SECONDS]` — delete expired PATs (cron or a - supervised loop for no-cron environments). - ---- - -## CONFIG (env) - -`JWT_SECRET`, `JWT_ALGO` (default HS256), `JWT_ISSUER`, `JWT_AUDIENCE`, -`JWT_PRIVATE_KEY` / `JWT_PRIVATE_KEY_FILE` (asymmetric signing — file form keeps -keys off the process env), `JWT_KID`, `AUTH_PAT_TABLE`, -`AUTH_REFRESH_TTL` (refresh-token lifetime, default 30d), -`AUTH_REFRESH_ACCESS_TTL` (paired access-JWT lifetime, default 900s), -`AUTH_GUARD` / `AUTH_PROVIDER` (AuthManager defaults). Guard/provider maps live in -`config/auth.php` (read via `auth_config()`). - ---- - -## RULES - -``` -✓ Verification = SecurityLayers (gateway); issuance = AuthService. Never mix. -✓ Pin a SINGLE algo in JwtAuthLayer — never let the token's `alg` choose the verifier. -✓ Asymmetric (RS/ES/PS) for any deployment where verifiers must not hold the signing secret. -✓ PATs: store only the hash, return plaintext once, enforce expires_at, load abilities as permissions. -✓ Session login AFTER credential verification; rotate the session id (fixation defence). -✓ Guard is a projection over the request Identity — never a stateful driver/AuthManager, never a global. -✓ Remember-me: store only the token HASH, rotate on every use, match the cookie's owner id, clear on logout. -✓ Refresh tokens live in Auth, not Tenancy. One-time-use rotation; a replay/race burns the whole family. -✓ Scopes are hierarchical — an ancestor satisfies its descendants; never do a bare string-equality scope check. -✗ Re-checking tenant seat membership on refresh — refresh is tenant-agnostic; the seat check is at tenant-SELECT. -✗ A SecurityLayer that THROWS — always return a SecurityVerdict. -✗ Unserializing a recaller/cookie value — the recaller is a flat `id|token` string (object-injection safe). -✗ Trusting a `tnt` claim as authorization — it is a routing hint; authz keys on (userId, tenantId, role/permission). -✗ getenv() for JWT_* — use env() (see 11_PROJECT). -``` diff --git a/docs/guides/26_OAUTH2.md b/docs/guides/26_OAUTH2.md deleted file mode 100644 index 1438d2c..0000000 --- a/docs/guides/26_OAUTH2.md +++ /dev/null @@ -1,118 +0,0 @@ -# OAuth2 Plugin — Authorization Server (OAuth 2.1 + OIDC) - -> AI reference for `Plugins\OAuth2\` (solves `oauth.server`). -> A native, dependency-free OAuth 2.1 + OpenID Connect authorization server. -> Access tokens are JWTs signed with the platform JWT keys, so they are verified -> by [25_AUTH](25_AUTH.md)'s `JwtAuthLayer` with no extra wiring. Pairs with -> [24_USER](24_USER.md) (password grant), [09_SECURITY](09_SECURITY.md). - ---- - -## WHAT IT DOES - -A full authorization server for **third-party / delegated** access (the piece a -first-party Auth module can't provide). Reuses `firebase/php-jwt` (already a -kernel dep) — no new vendor packages, honouring native distribution. - -``` -requires: ["database.management", "crypto.services", "user.management", "view.rendering"] -exposes: ["Plugins\OAuth2\Application\Ports\ClientStore"] -``` -All control-plane tables (`oauth_clients`, `oauth_auth_codes`, -`oauth_refresh_tokens`, `oauth_scopes`, `oauth_device_codes`) are pinned to the -**central** connection. - -> **Placement:** OAuth2 is a CENTRAL/control-plane concern — serve `/oauth/*` on -> the **apex/central host**, never tenant sub-domains. In host-tenancy mode set -> `TENANCY_BASE_DOMAINS` so the apex resolves to central. - ---- - -## GRANTS - -| Grant | Notes | -|---|---| -| `authorization_code` (+ **PKCE**) | exact-match `redirect_uri`; PKCE **mandatory for public clients** (S256/plain); codes random, hashed, 60s, single-use (atomic `consume`) | -| `client_credentials` | confidential clients only; no refresh token; `sub = client_id` | -| `refresh_token` | rotating + **family reuse-detection** (replay burns the family); scope narrowing only | -| `password` | confidential client; verifies via `ResourceOwnerVerifier` (User module); deprecated by OAuth 2.1 | -| `urn:…:device_code` | RFC 8628; `authorization_pending` / `slow_down` (interval-enforced) / `access_denied` / `expired_token`; single redemption | - -Confidential clients ALWAYS authenticate (Basic or body secret, `hash_equals`); -public clients are identified by `client_id` + PKCE only. - ---- - -## ENDPOINTS - -| Method · Path | Purpose | -|---|---| -| `GET/POST /oauth/authorize` | Auth-code consent (session-auth gated; request stored **server-side**, form carries only an opaque `authz_id` — no PKCE/scope round-trip) | -| `POST /oauth/token` | token endpoint (all grants) | -| `POST /oauth/device_authorization` | device-code start (device_code + user_code) | -| `GET/POST /oauth/device` | device user-verification page | -| `GET /oauth/userinfo` | OIDC UserInfo (Bearer; requires `scope:openid`) | -| `POST /oauth/introspect` | RFC 7662 (client-authenticated) | -| `POST /oauth/revoke` | RFC 7009 — refresh family revoke **+ JWT `jti` deny-list** | -| `GET /oauth/jwks` | RFC 7517 JWKS (RSA + EC) | -| `GET /.well-known/oauth-authorization-server` · `/openid-configuration` | RFC 8414 / OIDC discovery | -| `GET /oauth/scopes` | scope catalogue **with descriptions** (`ScopeRegistry` over `ScopeStore::describe()`) — public | -| `GET/POST/PUT/DELETE /oauth/clients` · `/clients/{id}` | **self-service client mgmt** (`auth`-gated, owner-scoped via `owner_id`; secret shown ONCE on create; another owner's client → 404) | -| `GET/DELETE /oauth/authorized-tokens` · `/{id}` | **self-service authorized-apps** — list a user's active grants; delete revokes the whole rotation family (`RefreshTokenStore::findByUser`) | - -The mgmt trio is the GDA-native port of Passport's `Client`/`AuthorizedAccessToken`/ -`Scope` controllers. `ScopeRegistry` also exposes `scopesFor()`/`tokensCan()`/ -`hasScope()` for consent screens. Personal (user) API keys are NOT here — those -are Auth PATs (`/auth/tokens`); `oauth_clients` stores APPLICATIONS, not user keys. - -CSRF: the machine POSTs (`/oauth/token`, `/introspect`, `/revoke`, -`/device_authorization`) MUST be in `CsrfTokenLayer` `exemptPaths` (client-auth, -not cookie-auth); the browser consent forms (`/oauth/authorize`, `/oauth/device`) -stay CSRF-protected. - ---- - -## TOKENS - -- **Access token = JWT** signed with the platform key (`JWT_ALGO`/keys), so the - existing `JwtAuthLayer` validates it. Claims: `iss`, `aud` (the **resource - audience** `OAUTH_TOKEN_AUDIENCE` ∕ `JWT_AUDIENCE`, NOT the client), `azp` - (client), `sub`, `scope`, `jti`, and `permissions` as **`scope:`** - (namespaced so an OAuth scope can NEVER satisfy a first-party - `hasPermission('admin')`). -- **id_token** (OIDC) issued when `openid` is granted — carries `nonce`, - `aud = client_id`, `auth_time`. Refused for a **public client under symmetric - (HS) signing** (unverifiable) — OIDC needs RS/ES/PS keys. -- **Refresh token** = opaque, stored hashed, rotating. - ---- - -## CLI - -`oauth:client:create` (`--public` for PKCE clients; secret shown once), -`oauth:client:list`, `oauth:client:revoke`, `oauth:client:rotate`, -`oauth:prune [--watch=SECONDS]` (expired codes/refresh/device rows). - ---- - -## CONFIG (env) - -`OAUTH_ACCESS_TTL`, `OAUTH_REFRESH_TTL`, `OAUTH_CODE_TTL`, `OAUTH_DEVICE_TTL`, -`OAUTH_DEVICE_INTERVAL`, `OAUTH_TOKEN_AUDIENCE` (defaults to `JWT_AUDIENCE`). -Signing keys come from Auth's `JWT_*` (use **RS256 + key files** for OIDC). - ---- - -## RULES - -``` -✓ Serve /oauth/* on the apex/central host (control-plane); set TENANCY_BASE_DOMAINS in host mode. -✓ Access tokens are platform JWTs — verified by JwtAuthLayer, no OAuth-specific resource-server code. -✓ Scopes ride in `scope` AND namespaced `scope:*` permissions — never bare RBAC names. -✓ redirect_uri EXACT match, validated before any error redirect; PKCE mandatory for public clients. -✓ Refresh rotation with family reuse-detection; auth codes single-use (burned on PKCE/redirect failure). -✓ OIDC (public clients) requires asymmetric signing (RS/ES/PS) + key files. -✗ Putting OAuth scopes into bare `permissions` (collision with first-party authz). -✗ CSRF-protecting the machine token/introspect/revoke endpoints (they are client-authenticated). -✗ A new vendor OAuth package — this server is native on firebase/php-jwt. -``` diff --git a/docs/guides/27_ENTITY_SUPPORT.md b/docs/guides/27_ENTITY_SUPPORT.md deleted file mode 100644 index 60f4ed0..0000000 --- a/docs/guides/27_ENTITY_SUPPORT.md +++ /dev/null @@ -1,217 +0,0 @@ -# 27 — Entity, Casting & Hydration Support (`Project\Support\`) - -> Reusable, DI-free, I/O-free entity-mapping helpers under `projects/Support/`. -> They are the **GDA-compliant decomposition of the legacy `__DEV__/Entity` -> Active Record** — the fat CodeIgniter/Eloquent-style base was split across the -> layers it conflated, and only the genuinely reusable casting / mapping / -> entity-mechanics live here. - -This file is the AI-context summary. The exhaustive, copy-pasteable cookbooks are: - -- `projects/Support/Casting/README.md` — casting engine + hydrator (13 examples) -- `projects/Support/Entity/README.md` — the `Entity` base (18-part cookbook) - ---- - -## Why it exists - -`__DEV__/Entity/Entity.php` was a fat Active Record: magic `__get/__set`, -mutators/accessors, `save()/delete()/restore()`, `performInsert/Update`, -`getRepo_()`, WP-style meta tables, change tracking — all in one base. GDA forbids -ORM/AR in the Domain layer, entities importing infrastructure, and entities -calling their own repository. The responsibilities were therefore split: - -| Old `Entity` responsibility | GDA home | -|---|---| -| Attributes, transitions, change tracking, invariants | **Domain entity** (or the `Entity` base) | -| `save()/delete()/performInsert/Update`/meta tables | **Repository** (`DatabasePort`, tenant-scoped) | -| Type casting + row⇄object mapping | **this Support layer** | -| Mass-assignment + validation | **DTO** at the controller edge (entity keeps a guard as defense-in-depth) | -| `toArray()/jsonSerialize()` | Response **DTO** (entity provides them too) | - ---- - -## Components - -| Namespace | Class | Role | -|---|---|---| -| `Project\Support\Casting` | `DataCaster` | Cast ONE field value, either direction | -| `Project\Support\Casting` | `TypeParser` | Parse a type string into `{nullable, baseType, params}` | -| `Project\Support\Casting` | `CastInterface` / `BaseCast` | Cast contract + identity base | -| `Project\Support\Casting` | `CastException` | Invalid handler / JSON | -| `Project\Support\Casting\Casts` | 11 built-ins | see table below | -| `Project\Support\Hydration` | `DataConverter` | Map a whole DB row ⇄ object | -| `Project\Support\Entity` | `Entity` (abstract) | Enterprise base for domain entities | - ---- - -## DataCaster - -```php -new DataCaster( - ?array $castHandlers = null, // [type => CastInterface::class] merged over defaults - ?array $types = null, // [field => typeString] - ?object $helper = null, // forwarded as 3rd arg to every cast - bool $strict = true, // true: null into a non-nullable type throws -); - -$caster->castAs(mixed $value, string $field, 'get'|'set' $method = 'get'): mixed; -$caster->setTypes(array $types): static; // resets parse cache -``` - -- `'get'` = DataSource → PHP; `'set'` = PHP → DataSource. -- Prefix a type with `?` to pass `null` through. Prefer `?type` over `strict:false`. -- A field absent from `$types` is returned unchanged. - -### Type grammar (`TypeParser`) - -```text -"?"? baseType ( "[" param ( "," param )* "]" )? -``` - -`?json[array]` → nullable JSON decoded as assoc array. `datetime[ms]`, -`datetime[Y-m-d]`, `int-bool`, `csv`, etc. - -### Built-in casts (`Project\Support\Casting\Casts`) - -| Type key(s) | get (DB→PHP) | set (PHP→DB) | -|---|---|---| -| `int` / `integer` | `int` | identity | -| `float` / `double` | `float` | identity | -| `string` | `string` | identity | -| `bool` / `boolean` | `bool` (`filter_var`; `t`/`f` for PG) | identity | -| `int-bool` | `bool` | `int` (0/1) — requires bool input | -| `csv` | `string`→`array` | `array`→`string` | -| `array` | `string`→`array` (native unserialize) | `array`→`string` (`serialize`) | -| `json` | `string`→`stdClass` (or `array` with `[array]`) | value→JSON `string` | -| `object` | `(object)` cast | identity | -| `datetime` | `string`→`DateTimeImmutable` | `DateTimeInterface`→`string` | -| `timestamp` | `int`/`string`→`DateTimeImmutable` | `DateTimeInterface`→`int` | - -> `bool` casts on READ only — use `int-bool` when the column stores `0/1` and the -> WRITE must emit an int. `json[array]` → assoc array; plain `json` → `stdClass`. - -### Custom cast - -Implement `CastInterface` (or extend `BaseCast`) and register via `castHandlers` -(or the entity's `$customCasters`). Custom handlers merge over — and can override — -the defaults. - ---- - -## DataConverter (the Repository hydrator) - -```php -new DataConverter( - array $types, // [column => typeString] - array $castHandlers = [], - ?object $helper = null, - Closure|string $reconstructor = 'reconstitute', // static factory name OR closure - Closure|string $extractor = 'toRawArray', // method name OR closure -); - -$conv->fromDataSource(array $row): array; // row → PHP-typed array -$conv->toDataSource(array $php): array; // PHP → DB-typed array -$conv->reconstruct(string $class, array $row): object; -$conv->extract(object $obj): array; -``` - -Reconstruction order: closure → named static factory → throw (no reflection -back-door). Converters pool `DataCaster` by a hash of `types + castHandlers`. - ---- - -## Entity base (`Project\Support\Entity\Entity`) - -Abstract. Implements `JsonSerializable`, `ArrayAccess`, `Stringable`. All features -are infrastructure-free. - -| Area | API | -|---|---| -| Config | `$primaryKey`, `$casts`, `$customCasters`, `$fillable`, `$guarded`, `$hidden`, `$visible`, `$appends`, `$dates`, `$dateFormat` | -| Mass assignment (secure by default) | `fill()` (honours `$fillable`), `forceFill()` (bypass), `isFillable()` | -| Attribute access | `getAttribute`/`setAttribute`, `getRawAttribute`, `hasAttribute`, `only`, `except`, `get{X}Attribute`/`set{X}Attribute` hooks | -| Typed getters | `getString/getInt/getFloat/getBool/getArray/getDate` | -| Serialization | `toArray`, `toRawArray`, `jsonSerialize`, `toJson`, `__toString`, `makeHidden`/`makeVisible` | -| Change tracking | `syncOriginal`, `isDirty`, `isClean`, `wasChanged`, `getDirty`/`getChanges`, `getOriginal` | -| Identity | `getKey`, `getKeyName`, `exists`, `is`, `isNot` | -| Domain events | `recordEvent` (protected), `hasEvents`, `releaseEvents` | -| Immutability | `seal`, `isSealed` (mutation throws `LogicException`) | -| Lifecycle | `make`, `reconstitute` (records no events), `replicate` (drops PK), `__clone` resets tracking | - -### Security - -- **Mass assignment denied by default** (`$guarded = ['*']`): `fill()` only writes - `$fillable` keys, so over-posting can't set `id`/`is_admin`. Defense-in-depth — - the DTO at the controller edge is still the primary validator. -- **`__debugInfo()` redacts `$hidden`** as `********` — secrets never reach - `var_dump()`, logs or stack traces. -- **`seal()`** yields a read-only snapshot; any write throws. - ---- - -## Repository usage (NOT Active Record) - -```php -final class InvoiceRepository -{ - private DataConverter $converter; - - public function __construct( - private readonly DatabasePort $db, - private readonly Identity $identity, - ) { - $this->converter = new DataConverter( - types: ['id' => 'int', 'paid' => 'bool', 'meta' => 'json[array]'], - reconstructor: 'reconstitute', - extractor: 'toRawArray', - ); - } - - public function find(string $id): Invoice - { - $row = $this->db->queryOne( - 'SELECT * FROM invoices WHERE id = :id AND tenant_id = :t', - ['id' => $id, 't' => $this->identity->tenantId], - ) ?? throw new RepositoryException("Invoice [{$id}] not found", layer: 'repository.invoice'); - - return $this->converter->reconstruct(Invoice::class, $row); - } - - public function save(Invoice $invoice): void - { - $this->db->upsert('invoices', $this->converter->extract($invoice), ['id']); - $invoice->syncOriginal(); - } -} -``` - -The Service flushes domain events inside the transaction: - -```php -$invoice->pay(); -foreach ($invoice->releaseEvents() as $event) { - $this->collector->collect($event); // buffered in-tx, discarded on rollback -} -$this->repository->save($invoice); -``` - ---- - -## Rules - -``` -✓ Entities carry data + invariants + events; Repositories carry persistence (DatabasePort). -✓ Hydrate with Entity::reconstitute($row) or DataConverter::reconstruct(); persist with toRawArray()/extract() + $db->upsert(). -✓ Casts are static + stateless (OpenSwoole-safe); DataConverter pools casters by types-hash. -✓ Mark nullable columns ?type; mass assignment is deny-by-default. -✗ save()/delete()/find()/getRepo_() on an entity — that is the Repository's job. -✗ app()/kernel()/config() or a DB query inside an entity — entities never do I/O. -✗ reconstruct() writing private props by reflection — give the entity a static reconstitute()/toRawArray(). -✗ strict:false instead of a nullable ?type. ✗ float for money — custom MoneyCast over integer cents. -``` - -Relationship to the gold standard: a `final` entity with a private constructor and -fully-encapsulated typed state is still preferred for small, well-defined -aggregates. Extend `Entity` when a flexible, meta-driven attribute bag earns its -keep. See also `03_DOMAIN.md`, `05_REPOSITORY.md`, `22_DATA_ACCESS_ORM_BLUEPRINT.md`. diff --git a/docs/guides/30_ROUTING_COOKBOOK.md b/docs/guides/30_ROUTING_COOKBOOK.md new file mode 100644 index 0000000..51fc4a5 --- /dev/null +++ b/docs/guides/30_ROUTING_COOKBOOK.md @@ -0,0 +1,408 @@ +# Routing Cookbook — Worked Examples + +Every recipe here was compiled through `CompileRouteManifestStage` and the output +below is what it actually produced. Copy, adjust the handler, done. + +Recipes live in `module.json` (a plugin) or `proj.json` (a project) — the shape is +identical. See [02_MODULE.md](02_MODULE.md) for the complete key reference and +the [project-layer docs](https://github.com/AlfaCode-Team/hkm-project-layer/blob/main/docs/PROJECT.md) for project-side wiring. + +--- + +## 1. A CRUD resource + +One group carries the prefix and the name stem, so no line repeats them. + +```jsonc +"groups": [ + { "prefix": "/invoices", "name": "invoice.", + "routes": [ + { "method": "GET", "path": "", "handler": "InvoiceController@index", "name": "index" }, + { "method": "POST", "path": "", "handler": "InvoiceController@store", "name": "store" }, + { "method": "GET", "path": "/{id:num}", "handler": "InvoiceController@show", "name": "show" }, + { "method": "PUT", "path": "/{id:num}", "handler": "InvoiceController@update", "name": "update" }, + { "method": "DELETE", "path": "/{id:num}", "handler": "InvoiceController@destroy", "name": "destroy" } + ] } +] +``` + +**Compiles to:** + +``` +GET /invoices name=invoice.index +POST /invoices name=invoice.store +GET /invoices/{id:num} name=invoice.show +PUT /invoices/{id:num} name=invoice.update +DELETE /invoices/{id:num} name=invoice.destroy +``` + +`"path": ""` is legal inside a prefixed group — the prefix supplies the path. +`{id:num}` means `/invoices/abc` 404s at the router, never reaching the controller. + +--- + +## 2. An admin area behind auth + +Groups nest. The inner group adds a throttle without repeating `auth`. + +```jsonc +"groups": [ + { "prefix": "/admin", "filters": ["auth"], "name": "admin.", + "routes": [ + { "method": "GET", "path": "/", "handler": "AdminController@home", "name": "home" } + ], + "groups": [ + { "prefix": "/users", "filters": ["throttle:30,1"], "name": "users.", + "routes": [ + { "method": "GET", "path": "", "handler": "UserAdminController@index", "name": "index" }, + { "method": "DELETE", "path": "/{id:uuid}", "handler": "UserAdminController@destroy", "name": "destroy" } + ] } + ] } +] +``` + +**Compiles to:** + +``` +GET /admin/ [auth] name=admin.home +GET /admin/users [auth throttle:30,1] name=admin.users.index +DELETE /admin/users/{id:uuid} [auth throttle:30,1] name=admin.users.destroy +``` + +Filters accumulate outward-in; names concatenate the same way. + +--- + +## 3. A versioned API, rate limited by default + +Module-wide defaults apply to every route in the file — no group needed. + +```jsonc +{ + "routePrefix": "/api/v2", + "routeFilters": ["throttle:60,1"], + + "routes": [ + { "method": "GET", "path": "/ping", "handler": "ApiController@ping" }, + { "method": "POST", "path": "/import", "handler": "ApiController@import", + "filters": ["auth", "throttle:5,1"] } + ] +} +``` + +**Compiles to:** + +``` +GET /api/v2/ping [throttle:60,1] +POST /api/v2/import [auth throttle:5,1] +``` + +Note `/import`: its `throttle:5,1` **replaced** the default `throttle:60,1` rather +than running the stage twice with two different budgets. De-duplication is by +**alias**, so a route always wins over the default it names. + +--- + +## 4. A safe file download + +```jsonc +{ "method": "GET", "path": "/download/{file:path}", "handler": "FileController@download" } +``` + +**Behaviour:** + +``` +GET /download/reports/q1.pdf -> FileController@download ($file = 'reports/q1.pdf') +GET /download/../../etc/passwd -> 404 +GET /download/a/..%2Fb -> 404 +``` + +`path` crosses `/` like `any`, but refuses `..` and control characters. The encoded +attempt fails too, because captured values are decoded and **re-validated** before +the controller sees them. + +> Using `{file:any}` here would match all three — `any` has **no traversal guard** +> and is kept unchanged only so existing routes do not regress. + +--- + +## 5. Optional pagination and a closed set + +```jsonc +{ "method": "GET", "path": "/posts/{page:num?}", + "handler": "PostController@index", "name": "post.index" }, + +{ "method": "GET", "path": "/posts/status/{s:enum(draft|published)}", + "handler": "PostController@byStatus" } +``` + +**Behaviour:** + +``` +GET /posts -> PostController@index ($page = '') +GET /posts/2 -> PostController@index ($page = '2') +GET /posts/two -> 404 the type still applies +GET /posts/status/draft -> PostController@byStatus +GET /posts/status/deleted -> 404 not a member of the enum +``` + +The optional parameter takes its leading `/` with it, so one route serves both +`/posts` and `/posts/2`. `enum` members are `preg_quote`d — no regex can be +injected from JSON. + +--- + +## 6. Two brands and a portal on one project + +The case `faces` cannot express: the same path, a different handler per host. + +```jsonc +{ + "domains": ["hkmvote.local", "africavoting.local", "organizer.africavoting.local"], + + "groups": [ + { "domain": "hkmvote.local", "name": "vote.", + "routes": [ { "method": "GET", "path": "/", "handler": "VoteHome@index", "name": "home" } ] }, + + { "domain": "africavoting.local", "name": "africa.", + "routes": [ { "method": "GET", "path": "/", "handler": "AfricaHome@index", "name": "home" } ] }, + + { "domain": "organizer.africavoting.local", "prefix": "/dashboard", + "filters": ["auth"], "name": "organizer.", + "routes": [ { "method": "GET", "path": "", "handler": "Organizer@index", "name": "home" } ] }, + + { "domain": "*.africavoting.local", + "routes": [ { "method": "GET", "path": "/", "handler": "TenantHome@index" } ] } + ], + + "routes": [ + { "method": "GET", "path": "/health", "handler": "HealthController@show" } + ] +} +``` + +**Compiles to:** + +``` +GET /health ← ungrouped: GLOBAL +GET@hkmvote.local / name=vote.home +GET@africavoting.local / name=africa.home +GET@organizer.africavoting.local /dashboard [auth] name=organizer.home +GET@*.africavoting.local / +``` + +**Behaviour:** + +``` +GET / @ hkmvote.local -> VoteHome@index +GET / @ africavoting.local -> AfricaHome@index +GET / @ news.africavoting.local -> TenantHome@index (wildcard) +GET /dashboard @ organizer.africavoting.local -> Organizer@index (exact beats wildcard) +GET /health @ hkmvote.local -> HealthController@show +GET /health @ anything.example -> HealthController@show +``` + +Note the two `home` names had to become `vote.home` and `africa.home` — names are +one flat, application-wide namespace, and a group `name` prefix is the fix. + +--- + +## 7. An `api.` subdomain that serves every domain + +A bare `subdomain` belongs to no single host, which is the point. + +```jsonc +"groups": [ + { "subdomain": "api", "prefix": "/v1", "filters": ["throttle:120,1"], + "routes": [ { "method": "GET", "path": "/ping", "handler": "ApiController@ping" } ] } +] +``` + +**Compiles to** `GET@api /v1/ping [throttle:120,1]`, and: + +``` +GET /v1/ping @ api.example.com -> ApiController@ping +GET /v1/ping @ api.example2.com -> ApiController@ping +GET /v1/ping @ api.brand-new.test -> ApiController@ping ← host never registered +GET /v1/ping @ www.example.com -> 404 +``` + +Because it answers on every domain, a bare subdomain is **never** validated +against `proj.json` `"domains"` — there is no single host to check it against. + +--- + +## 8. Override a plugin page, veto another + +```jsonc +{ + "routePolicy": { + "disable": [ + "GET /register", // drop the plugin's page — the key is now free + "oauth.server" // or drop EVERY route that module solves() + ] + }, + + "routes": [ + { "method": "GET", "path": "/register", "handler": "Shop\\SignupController@show" } + ] +} +``` + +Disable runs on plugin routes **before** project routes compile, so vetoing and +re-declaring the same key is not a duplicate-route failure. A project route that +overrides a plugin route **inherits its name** unless it declares one, so every +`route('auth.register')` in the plugin's own views keeps working. + +> A disable spec matching nothing FAILS the boot — a silently-ignored disable +> would leave an endpoint exposed that you believed was gone. + +--- + +## 9. A project page that needs exactly one plugin + +Project routes run under the synthetic `__project__` scope, whose dependency graph +is **empty** — they load no plugins at all. + +```jsonc +{ "method": "GET", "path": "/dashboard", + "handler": "Shop\\DashboardController@index", + "requires": ["view.rendering"] } +``` + +Without `requires`, the View plugin's contract is unbound and the controller +cannot render. This is the per-route alternative to making a plugin *essential*: + +| Need | Mechanism | +|---|---| +| Stateless, every request | `withPorts([...])` — an app-lifetime port | +| Some routes need a plugin | `"requires"` on the route | +| Every request needs it | `"essentials"` in `proj.json` | + +Requiring a plugin grants its **published contracts** only — `bindInternal()` +bindings still throw `ScopeViolationException` across scopes. + +--- + +## 10. Restrict a route to one face + +```jsonc +{ "method": "GET", "path": "/ops", "handler": "OpsController@index", "faces": ["admin"] } +``` + +Invisible on any other face, and a mismatch 404s rather than 403s — a route the +caller cannot reach here should not advertise that it exists elsewhere. Requires +the entry point to set `route_face`; with nothing set, the restriction is inert +rather than silently 404ing everywhere. + +Use `faces` for the coarse admin/api/project/public split, and a **domain group** +when you need the same path to resolve differently per host (recipe 6). + +--- + +## 11. An email verification link + +```php +// Minting — in the service that sends the mail +$link = signed_route('email.verify', ['id' => $user->id()], expiresIn: 3600); +``` + +```jsonc +// The route enforces the signature declaratively +{ "method": "GET", "path": "/verify/{id:num}", "handler": "VerifyController@confirm", + "name": "email.verify", "filters": ["signed"] } +``` + +The HMAC covers the path and query — never the host — so a proxy that rewrites +`Host` cannot invalidate the link. `expires` is inside the signature, so the +deadline cannot be extended by editing the URL. With an empty `APP_KEY`, +`signed_route()` throws rather than emitting a forgeable link. + +--- + +## 12. A plugin publishing its own filter + +```php +// Provider::boot() — the alias registry is shared, not owned by SecurityFilters +public function boot( + HttpPipeline $http, CliPipeline $cli, + WorkerPipeline $worker, EventBus $events, +): void { + $http->filter('json', RequireJsonStage::class); +} +``` + +```jsonc +{ "method": "POST", "path": "/api/import", "handler": "…@import", "filters": ["json"] } +``` + +```php +final class RequireJsonStage implements HttpStageContract +{ + public function handle(Request $request, callable $next): Response + { + // "json:strict" → ['strict'] + $args = $request->attribute('filter_args')['json'] ?? []; + + if (!$request->expectsJson()) { + return Response::json(['error' => ['code' => 'not_acceptable']], 406); + } + + return $next($request); // before → on the way in, after → on the way out + } +} +``` + +> A stage is a global hook **or** a route filter — never both. Registered twice, +> it runs twice per request. + +--- + +## 13. Reading route data in a controller + +```php +final class ReportController extends ApiController // RequestAware +{ + // Actions take route params ONLY — the Request arrives via $this->request. + public function show(string $year, string $slug): Response + { + $this->request->attribute('route_entry'); // the compiled route entry + $this->request->attribute('active_filters'); // ['auth', 'throttle'] + + return $this->ok(['year' => $year, 'slug' => $slug]); + } +} +``` + +A plain controller keeps `show(Request $request, string $year, string $slug)`. + +--- + +## When it will not compile + +Each of these stops the build with the route that caused it. Verified messages: + +``` +{id:number} declares path [/u/{id:number}] with unknown parameter type [number] on {id} +"path": "users" declares path [users] which does not start with '/' +/a/{id}/b/{id} Route parameter {id} repeats the capture name [id] +/{2fa} Route parameter {2fa} is not a usable capture name +"handler": "C" has handler [C] — it must be in 'Controller@method' format (exactly one "@") +domain not served Registered domains (proj.json "domains"): … Add it there, use a wildcard … +``` + +Every one previously compiled into a route that silently never matched — a 404 +that reads like a missing controller rather than a typo. + +--- + +## Production checklist + +```bash +BOOT_CACHE=1 # build() otherwise recompiles every manifest per FPM request +APP_KEY=… # signed URLs fail closed without it +APP_URL=https://… # base for absolute URLs +ROUTE_VERIFY_HANDLERS=1 # CI only — verifies every handler class + method exists +``` + +And clear `var/cache/manifests/` on deploy. diff --git a/docs/guides/Kernel-Guide_EN-FR.src.html b/docs/guides/Kernel-Guide_EN-FR.src.html index 6c74214..cb502d3 100644 --- a/docs/guides/Kernel-Guide_EN-FR.src.html +++ b/docs/guides/Kernel-Guide_EN-FR.src.html @@ -67,7 +67,7 @@

The Kernel Guide

How the Gated Demand Architecture kernel works, how to use it,
and why it differs from other frameworks
· Guide bilingue — English & Français ·

- PHP 8.2+Gated Demand ArchitectureContributorsApp BuildersStep-by-stepEN / FR + PHP 8.4+Gated Demand ArchitectureContributorsApp BuildersStep-by-stepEN / FR
Package: alfacode-team/php-service-platform  ·  Namespace: AlfacodeTeam\PhpServicePlatform\Kernel\
@@ -243,7 +243,7 @@

4.3 Entry points

$kernel->http()->handle($request)->send();

4.4 TUTORIAL — Build a module from scratch (8 steps)

-

We rebuild the real Task plugin. It lives in plugins/Task/ under the Plugins\Task\ namespace and owns one domain: task.management.

+

We build a worked example plugin, Task, under the Plugins\Task\ namespace, owning one domain: task.management. Note: first-party plugins are not part of the kernel repository — each lives in its own repo (AlfaCode-Team/hkm-plugin-<slug>) and is installed into a project with hkm plugins install. Scaffold your own with hkm plugins create Task.

Step 1 — Declare the module in module.json (single source of truth)

plugins/Task/module.json
@@ -524,9 +524,14 @@

Adding a boot validation stage

5.6 Security gateway internals

-
Request → FirewallLayer → RateLimiterLayer → CsrfTokenLayer → [Auth layer] → pipeline
-              ↓ deny(403)      ↓ deny(429)        ↓ deny(403)      ↓ deny(401)
-          ZERO module cost at every denial
+
Request → CsrfTokenLayer → [Auth plugin: JwtAuthLayer] → [PersonalAccessTokenLayer] → pipeline
+               ↓ deny(403)            ↓ deny(401)                    ↓ deny(401)
+           ZERO module cost at every denial
+

The kernel ships exactly one layer: CsrfTokenLayer. There is no kernel +FirewallLayer and no kernel RateLimiterLayer. Token authentication comes from the +Auth plugin; rate limiting (throttle) and IP filtering (shield) are +SecurityFilters route filters, which run inside the pipeline once a route opts in — they need a +CachePort and config, so they cannot be pre-module layers.

A layer implements check(Request): SecurityVerdict and never throws — it returns allow() or deny(code, reason). Layers are ordered cheapest-first so the cheapest rejection happens earliest. Authorization (role/permission checks) belongs in the Service layer, not the gateway. The kernel ships no JWT validator — token auth is provided by a project's Auth module as a layer hook registered in boot().

6. The Five Access Rules & Exception Hierarchy

@@ -723,7 +728,7 @@

4.3 Points d'entrée

$kernel->requestTeardown() // après chaque requête sous OpenSwoole

4.4 TUTORIEL — créer un module de zéro (8 étapes)

-

Nous reconstruisons le plugin réel Task, dans plugins/Task/ sous l'espace de noms Plugins\Task\, possédant le domaine task.management.

+

Nous construisons un plugin d'exemple, Task, sous l'espace de noms Plugins\Task\, possédant le domaine task.management. Note : les plugins de première partie ne font pas partie du dépôt du kernel — chacun possède son propre dépôt (AlfaCode-Team/hkm-plugin-<slug>) et s'installe dans un projet avec hkm plugins install. Créez le vôtre avec hkm plugins create Task.

Étape 1 — Déclarer dans module.json (source unique de vérité)

{
@@ -926,9 +931,15 @@ 

Ajouter une étape de validation au démarrage

5.6 Intérieur de la passerelle de sécurité

-
Requête → FirewallLayer → RateLimiterLayer → CsrfTokenLayer → [couche Auth] → pipeline
-               ↓ deny(403)     ↓ deny(429)        ↓ deny(403)      ↓ deny(401)
-           COÛT MODULE NUL à chaque refus
+
Requête → CsrfTokenLayer → [plugin Auth : JwtAuthLayer] → [PersonalAccessTokenLayer] → pipeline
+                ↓ deny(403)              ↓ deny(401)                     ↓ deny(401)
+            COÛT MODULE NUL à chaque refus
+

Le kernel ne fournit qu'une seule couche : CsrfTokenLayer. Il n'existe pas de +FirewallLayer ni de RateLimiterLayer dans le kernel. L'authentification par jeton provient +du plugin Auth ; la limitation de débit (throttle) et le filtrage d'IP +(shield) sont des filtres de route SecurityFilters, exécutés dans le pipeline lorsqu'une +route les déclare — ils nécessitent un CachePort et de la configuration, donc ils ne peuvent pas être des +couches pré-module.

Une couche implémente check(Request): SecurityVerdict et ne lève jamais. L'autorisation (rôles/permissions) appartient à la couche Service. Le kernel ne fournit aucun validateur JWT — l'auth par jeton vient du module Auth du projet, sous forme de hook enregistré dans boot().

6. Les cinq règles d'accès et la hiérarchie d'exceptions

diff --git a/docs/guides/README.md b/docs/guides/README.md index ffdfb40..79ae69e 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -1,10 +1,14 @@ # HKM Kernel — Guides -Layer-by-layer guides to the **Gated Demand Architecture (GDA)** kernel and its first-party -plugins. Start with the overview, then dive into the layer you're working in. +Layer-by-layer guides to the **Gated Demand Architecture (GDA)** kernel. Start +with the overview, then dive into the layer you're working in. -> New here? Read the [project README](../../README.md) first for the big picture, install -> steps, and a full end-to-end feature walkthrough. +> **Scope.** These guides cover the kernel (`src/`) and the first-party packages +> it runs on (`modules/`) — and nothing else. Everything outside that documents +> itself; see [Not documented here](#not-documented-here) at the bottom. + +> New here? Read the [project README](../../README.md) first for the big picture, +> install steps, and a full end-to-end feature walkthrough. ## Architecture & lifecycle @@ -13,7 +17,7 @@ plugins. Start with the overview, then dive into the layer you're working in. | [00 · Overview](00_SENTINEL_OVERVIEW.md) | Full architecture + the request lifecycle | | [01 · Kernel](01_KERNEL.md) | Boot pipeline, materialization, the fluent builder | | [02 · Module](02_MODULE.md) | Module contract, `module.json`, on-demand loading | -| [11 · Project](11_PROJECT.md) | Project layer — wiring, domain resolution, bootstrap | +| [16 · Plugins](16_PLUGINS.md) | How the kernel loads a module from `plugins/` | ## The layers @@ -23,40 +27,55 @@ plugins. Start with the overview, then dive into the layer you're working in. | [04 · Service](04_SERVICE.md) | Transaction + event orchestration (the mandatory shape) | | [05 · Repository](05_REPOSITORY.md) | `DatabasePort` only; translate every `\PDOException` | | [06 · Gateway](06_GATEWAY.md) | Vendor SDKs only; translate vendor exceptions | -| [07 · Controller](07_CONTROLLER.md) | ≤3-line actions, DTO validation, base controllers | +| [07 · Controller](07_CONTROLLER.md) | ≤3-line actions, DTO validation, `RequestAware` | | [08 · Events](08_EVENTS.md) | Domain vs. integration events, the EventBus | ## Cross-cutting | Guide | Topic | |---|---| -| [09 · Security](09_SECURITY.md) | SecurityGateway, Identity, layers | +| [09 · Security](09_SECURITY.md) | SecurityGateway, `SecurityVerdict`, `Identity` | | [21 · CSRF](21_CSRF.md) | `CsrfTokenLayer` — HMAC-token CSRF | | [10 · Testing](10_TESTING.md) | Port fakes, service tests | | [12 · Worker](12_WORKER.md) | Worker pipeline, jobs, retry strategies | | [13 · Anti-patterns](13_ANTIPATTERNS.md) | Wrong/correct code pairs | -| [15 · Error handling](15_ERROR_HANDLING.md) | ErrorGuard + ErrorPipeline, notifiers | +| [15 · Error handling](15_ERROR_HANDLING.md) | ErrorPipeline, classifier, notifiers | +| [30 · Routing cookbook](30_ROUTING_COOKBOOK.md) | 13 worked recipes, each compiled with its real output | ## CLI & data | Guide | Topic | |---|---| | [14 · CLI](14_CLI.md) | CLI pipeline, `AbstractCommand` | -| [17 · php-io-cli](17_PHP_IO_CLI.md) | The interactive terminal component library | -| [18 · Migrations](18_MIGRATIONS.md) | LetMigrate engine, migrations, seeders | -| [19 · Database](19_DATABASE.md) | Multi-driver `DatabasePort`, connections | -| [22 · Data access blueprint](22_DATA_ACCESS_ORM_BLUEPRINT.md) | Repository/hydrator/entity mapping, portable SQL | -| [27 · Entity support](27_ENTITY_SUPPORT.md) | Casting engine, hydrator, the Entity base | +| [22 · Data access blueprint](22_DATA_ACCESS_ORM_BLUEPRINT.md) | Repository/hydrator mapping, portable SQL, no vendor ORM | + +## `modules/` — the packages the kernel runs on + +| Guide | Package | +|---|---| +| [17 · php-io-cli](17_PHP_IO_CLI.md) | `alfacode-team/php-io-cli` — the interactive terminal component library | +| [18 · Migrations](18_MIGRATIONS.md) | `alfacode-team/let-migrate` — schema engine, migrations, seeders | + +The other three (`phpshots/bind-it`, `phpshots/common-type-alias`, +`alfacode-team/http`) document themselves in their own submodules. -## Plugins +## Operations -| Guide | Plugin | +| Guide | Topic | |---|---| -| [16 · Plugins](16_PLUGINS.md) | The `plugins/` convention + local-module checklist | -| [20 · First-party plugins](20_FIRST_PARTY_PLUGINS.md) | The bundled plugin catalogue | -| [23 · Tenancy](23_TENANCY.md) | Multi-tenant routing, membership, invitations | -| [24 · User](24_USER.md) | Central identity store, outbox, audit log | -| [25 · Auth](25_AUTH.md) | JWT / PAT / session issuance + verification | -| [26 · OAuth2](26_OAUTH2.md) | OAuth 2.1 + OIDC authorization server | - -Each first-party plugin also ships its own README under `plugins//`. +| [Safe deployments](SAFE_DEPLOYMENTS_GUIDE.md) | Release and rollback runbooks | + +## Not documented here + +| Subject | Where | +|---|---| +| The `Project\` layer (`projects/`) | [hkm-project-layer](https://github.com/AlfaCode-Team/hkm-project-layer) | +| Any plugin — behaviour, API, config | that plugin's own repository: `README.md`, `CLAUDE.md`, and `module.json` as the authority | +| Which plugin claims a `solves` domain | `hkm plugins domains` — live, and never stale | +| The `hkm` CLI, bundling, installers | [`tools/README.md`](../../tools/README.md), [`tools/docs/hkm-cli-usage.md`](../../tools/docs/hkm-cli-usage.md) | +| Per-project frontend, `hkm ui`, surfaces | [`tools/src/templates/frontend/docs/HOW_IT_WORKS.md`](../../tools/src/templates/frontend/docs/HOW_IT_WORKS.md) | + +This is deliberate. A copy of someone else's documentation living in the kernel +is the copy that goes stale, and it did: the catalogue this repo used to carry +recorded a plugin's `solves` domain wrongly, claimed another had no dependencies, +and understated four plugins' `requires[]`. The manifest is the authority. diff --git a/modules/http b/modules/http index 5bc998a..e60823d 160000 --- a/modules/http +++ b/modules/http @@ -1 +1 @@ -Subproject commit 5bc998ac4a9575a560a027073b8e2792bfff27ae +Subproject commit e60823d6479882fac66588ffbf73da96cd5210ee diff --git a/projects/Bootstrap/EntryHelpers.php b/projects/Bootstrap/EntryHelpers.php index 0d0bdbf..6849f10 100644 --- a/projects/Bootstrap/EntryHelpers.php +++ b/projects/Bootstrap/EntryHelpers.php @@ -120,13 +120,13 @@ public static function projectRoutes(string $projectPath): array 'handler' => (string) $route['handler'], ]; // Optional per-route declarations passed through to the route-manifest - // compiler: filters[] (auth, throttle, …) and requires[] (plugin - // domains to seed into this route's dependency graph). - if (isset($route['filters'])) { - $entry['filters'] = $route['filters']; - } - if (isset($route['requires'])) { - $entry['requires'] = $route['requires']; + // compiler: filters[] (auth, throttle, …), requires[] (plugin domains + // to seed into this route's dependency graph), name, faces[], and + // site/domain (the host group this route belongs to). + foreach (['filters', 'requires', 'name', 'faces', 'domain', 'subdomain'] as $key) { + if (isset($route[$key])) { + $entry[$key] = $route[$key]; + } } $routes[] = $entry; } @@ -134,6 +134,84 @@ public static function projectRoutes(string $projectPath): array return $routes; } + /** + * Read the project's route GROUPS and source-wide route defaults from + * /proj.json, for Kernel::withRouteGroups(). + * + * A group says once what would otherwise be repeated on every route inside + * it — a path prefix, filters, requires, a name prefix, and the DOMAIN the + * routes answer on. Groups may nest. The whole structure is expanded at boot + * by CompileRouteManifestStage into ordinary flat routes, so nothing about + * grouping survives into a request. + * + * "routePrefix": "/app", + * "groups": [ + * { "subdomain": "organizer", "prefix": "/dashboard", "filters": ["auth"], + * "name": "organizer.", "routes": [ … ] }, + * { "domain": "africavoting.local", "routes": [ … ] } + * ] + * + * Passed through as-is: the compiler validates every part with a descriptive + * boot error, which is a far better place to report a typo than here. + * + * @return array + */ + public static function projectRouteGroups(string $projectPath): array + { + $file = rtrim($projectPath, '/') . '/proj.json'; + if (!is_file($file)) { + return []; + } + + $data = json_decode((string) file_get_contents($file), true); + if (!is_array($data)) { + return []; + } + + $source = []; + foreach (['groups', 'routePrefix', 'routeFilters', 'routeRequires', 'routeName', 'routeDomain', 'routeSubdomain', 'routeFaces'] as $key) { + if (isset($data[$key])) { + $source[$key] = $data[$key]; + } + } + + return $source; + } + + + /** + * Read the hosts this project serves from /proj.json "domains". + * + * The same list DomainResolver matches an incoming Host against to build a + * DomainContext. Passed to Kernel::withProjectDomains(), where the route + * compiler uses it to reject a route grouped under a host this project does + * not serve — such a route could never be reached, because the request would + * have gone to a different project entirely. + * + * @return list + */ + public static function projectDomains(string $projectPath): array + { + $file = rtrim($projectPath, '/') . '/proj.json'; + if (!is_file($file)) { + return []; + } + + $data = json_decode((string) file_get_contents($file), true); + if (!is_array($data) || !is_array($data['domains'] ?? null)) { + return []; + } + + $domains = []; + foreach ($data['domains'] as $domain) { + if (is_string($domain) && trim($domain) !== '') { + $domains[] = strtolower(trim($domain)); + } + } + + return $domains; + } + /** * Read the project's GLOBAL (essential) modules from /proj.json * under "essentials": [ ... ]. Each entry is a module DOMAIN (a plugin's diff --git a/projects/Infrastructure/PdoDatabase.php b/projects/Infrastructure/PdoDatabase.php index 54c435b..f0a16a5 100644 --- a/projects/Infrastructure/PdoDatabase.php +++ b/projects/Infrastructure/PdoDatabase.php @@ -31,10 +31,11 @@ public function __construct( ?string $password = null, ) { $this->pdo = new PDO($dsn, $username, $password, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_EMULATE_PREPARES => false, ]); + } public function query(string $sql, array $params = []): array @@ -65,28 +66,32 @@ public function upsert(string $table, array $values, array $conflictColumns, ?ar return 0; } - $columns = array_keys($values); + $columns = array_keys($values); $updateColumns ??= array_values(array_diff($columns, $conflictColumns)); - $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); - $quote = static fn (string $i): string => $driver === 'mysql' + $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $quote = static fn(string $i): string => $driver === 'mysql' ? '`' . str_replace('`', '', $i) . '`' : '"' . str_replace('"', '', $i) . '"'; - $cols = implode(', ', array_map($quote, $columns)); - $binds = implode(', ', array_map(static fn (string $c): string => ':' . $c, $columns)); + $cols = implode(', ', array_map($quote, $columns)); + $binds = implode(', ', array_map(static fn(string $c): string => ':' . $c, $columns)); $insert = "INSERT INTO {$quote($table)} ({$cols}) VALUES ({$binds})"; if ($driver === 'mysql') { $sql = $updateColumns === [] ? "{$insert} ON DUPLICATE KEY UPDATE {$quote($conflictColumns[0] ?? $columns[0])} = {$quote($conflictColumns[0] ?? $columns[0])}" : "{$insert} ON DUPLICATE KEY UPDATE " . implode(', ', array_map( - static fn (string $c): string => "{$quote($c)} = VALUES({$quote($c)})", $updateColumns)); + static fn(string $c): string => "{$quote($c)} = VALUES({$quote($c)})", + $updateColumns + )); } else { $target = implode(', ', array_map($quote, $conflictColumns)); $sql = $updateColumns === [] ? "{$insert} ON CONFLICT ({$target}) DO NOTHING" : "{$insert} ON CONFLICT ({$target}) DO UPDATE SET " . implode(', ', array_map( - static fn (string $c): string => "{$quote($c)} = EXCLUDED.{$quote($c)}", $updateColumns)); + static fn(string $c): string => "{$quote($c)} = EXCLUDED.{$quote($c)}", + $updateColumns + )); } return $this->execute($sql, $values); diff --git a/projects/README.md b/projects/README.md new file mode 100644 index 0000000..e578ccc --- /dev/null +++ b/projects/README.md @@ -0,0 +1,8 @@ +# `projects/` — the Project Layer + +The documentation for this layer lives in its own repository: +**[hkm-project-layer](https://github.com/AlfaCode-Team/hkm-project-layer)**. + +This repository's documentation covers the kernel (`src/`) and the packages it +runs on (`modules/`) — and nothing else. The directory layout there mirrors this +one, so a path here maps to the same path in that repo. diff --git a/projects/Support/Casting/README.md b/projects/Support/Casting/README.md deleted file mode 100644 index 3cac873..0000000 --- a/projects/Support/Casting/README.md +++ /dev/null @@ -1,481 +0,0 @@ -# Casting & Hydration (`Project\Support\Casting` + `Project\Support\Hydration`) - -Dependency-free, DI-free type-casting and row-hydration utilities, ported from -the legacy `__DEV__/DataCaster`, `__DEV__/DataConverter` and the `__DEV__/Entity` -Active-Record base class — refactored to obey the GDA layer rules. - -- **Namespaces:** `Project\Support\Casting`, `Project\Support\Casting\Casts`, - `Project\Support\Hydration` -- **Autoload:** `Project\` → `projects/` (PSR-4, already in `composer.json`) -- **No dependencies:** no Carbon, no `BaseConnection`, no WordPress helpers, no - container, no globals — pure value transformation, safe to use from any layer. - ---- - -## Table of contents - -1. [Why the old `Entity` was decomposed](#why-the-old-entity-was-decomposed) -2. [What was dropped / changed in the port](#what-was-dropped--changed-in-the-port) -3. [Components at a glance](#components-at-a-glance) -4. [`DataCaster` — the engine](#datacaster--the-engine) -5. [Type-string grammar (`TypeParser`)](#type-string-grammar-typeparser) -6. [Built-in casts](#built-in-casts) -7. [Custom casts](#custom-casts) -8. [`DataConverter` — the hydrator](#dataconverter--the-hydrator) -9. [Cookbook — exhaustive examples](#cookbook--exhaustive-examples) -10. [Design notes & caveats](#design-notes--caveats) - ---- - -## Why the old `Entity` was decomposed - -`__DEV__/Entity/Entity.php` is a CodeIgniter/Eloquent-style **fat Active Record**: -magic `__get/__set`, mutators/accessors, `save()/delete()/restore()`, -`performInsert/Update`, `getRepo_()`, WP-style meta tables and change tracking — -all in one base class. GDA explicitly forbids this: - -> ✗ Eloquent, Active Record, or any ORM in the Domain layer -> ✗ Domain importing anything external · ✗ an entity calling its own repository - -So the single class is split across the layers it was conflating: - -| Old `Entity` responsibility | GDA home | -| --- | --- | -| Attributes, state transitions, change tracking, invariants | **Domain entity** (`final`, private ctor, `create()`/`reconstitute()`, `releaseEvents()`) — or the [`Entity` base](../Entity/README.md) | -| `save()` / `delete()` / `performInsert/Update` / meta tables | **Repository** (`DatabasePort` only, tenant-scoped SQL) | -| Type casting on read/write, DB-row ⇄ object mapping | **this Support layer** (`DataCaster` + `DataConverter`) | -| Mass-assignment (`fillable`/`guarded`), validation | **DTO** (`fromRequest()` validation) at the controller edge | -| `toArray()` / `jsonSerialize()` | Response **DTO** `toArray()` | - -The casting/mapping concern is the only genuinely reusable, rule-compliant piece, -so it lives here. The rest is per-domain code that belongs in each plugin. - -## What was dropped / changed in the port - -- **No Carbon / `BaseConnection`** — `DatetimeCast` and `TimestampCast` use - `\DateTimeImmutable` (the framework's Domain date type). -- **No WP `maybe_serialize`** — `ArrayCast` uses native `serialize()` with - `allowed_classes => false` on read. -- **Dropped legacy-VO casts** (`StatusCast`, `ProjectStatusCast`, `URICast`) — - they coupled to `HKM\lib\Common\ValueObjects\*` which is not part of this - framework. Re-add them per-domain as custom handlers (see below). -- **`DataConverter` no longer touches `Entity` or `kernel()`** — it reconstructs - via an explicit static factory (`reconstitute` by default) or a closure, with - no reflection back-door. - ---- - -## Components at a glance - -| Class | Role | -| --- | --- | -| `Casting\DataCaster` | Casts one field value, either direction (`get`/`set`) | -| `Casting\TypeParser` | Parses a type string into `{nullable, baseType, params}` | -| `Casting\CastInterface` | The contract every cast implements (`get`/`set`) | -| `Casting\BaseCast` | Identity cast; subclass and override one direction | -| `Casting\CastException` | Thrown on invalid handler / JSON | -| `Casting\Casts\*` | The 11 built-in casts | -| `Hydration\DataConverter` | Maps a whole DB row ⇄ object (uses `DataCaster`) | - ---- - -## `DataCaster` — the engine - -```php -use Project\Support\Casting\DataCaster; - -new DataCaster( - ?array $castHandlers = null, // custom [type => CastInterface::class], merged over defaults - ?array $types = null, // [field => typeString] - ?object $helper = null, // passed as 3rd arg to every cast (e.g. a connection) - bool $strict = true, // true: passing null to a non-nullable type throws -); -``` - -| Method | Returns | Notes | -| --- | --- | --- | -| `setTypes(array $types)` | `static` | Replace the field→type map (clears the parse cache) | -| `castAs(mixed $value, string $field, 'get'\|'set' $method='get')` | `mixed` | Cast `$value` for `$field`; unknown field → returned unchanged | - -Direction: - -- `'get'` = **DataSource → PHP** (reading a DB row) -- `'set'` = **PHP → DataSource** (writing to the DB) - -Nullability & strictness: - -- Prefix a type with `?` to let `null` pass through untouched in either direction. -- `strict: true` (default): passing `null` to a **non**-nullable type throws - `InvalidArgumentException`. -- A field with no entry in `$types` is returned verbatim (no-op). - -```php -$caster = new DataCaster(types: [ - 'id' => 'int', - 'price' => 'float', - 'active' => 'bool', - 'tags' => 'csv', - 'meta' => '?json[array]', - 'created' => 'datetime', -], strict: false); - -$caster->castAs('42', 'id'); // 42 (get) -$caster->castAs('a,b', 'tags'); // ['a', 'b'] (get) -$caster->castAs(null, 'meta'); // null (nullable) -$caster->castAs($dateTime, 'created', 'set');// 'YYYY-mm-dd H:i:s' (set) -$caster->castAs('whatever', 'unknown'); // 'whatever' (no type → no-op) -``` - ---- - -## Type-string grammar (`TypeParser`) - -```text -"?"? baseType ( "[" param ( "," param )* "]" )? -``` - -| Input | nullable | baseType | params | -| --- | --- | --- | --- | -| `int` | `false` | `int` | `[]` | -| `?string` | `true` | `string` | `[]` | -| `json[array]` | `false` | `json` | `['array']` | -| `?datetime[ms]` | `true` | `datetime` | `['ms']` | -| `datetime[Y-m-d]` | `false` | `datetime` | `['Y-m-d']` | - -```php -use Project\Support\Casting\TypeParser; - -TypeParser::parse('?json[array]'); -// ['nullable' => true, 'baseType' => 'json', 'params' => ['array']] -``` - -You rarely call this directly — `DataCaster` uses it internally and caches the -result per field. - ---- - -## Built-in casts - -All live in `Project\Support\Casting\Casts`. "get" = DB→PHP, "set" = PHP→DB. -Casts with an identity "set" (BaseCast default) store the value unchanged. - -| Type key(s) | Class | get (DB → PHP) | set (PHP → DB) | -| --- | --- | --- | --- | -| `int`, `integer` | `IntegerCast` | `int` | _identity_ | -| `float`, `double` | `FloatCast` | `float` | _identity_ | -| `string` | `StringCast` | `string` | _identity_ | -| `bool`, `boolean` | `BooleanCast` | `bool` (`filter_var`; `t`/`f` for PG) | _identity_ | -| `int-bool` | `IntBoolCast` | `bool` | `int` (0/1) — requires bool input | -| `csv` | `CSVCast` | `string` → `array` (split `,`) | `array` → `string` (join `,`) | -| `array` | `ArrayCast` | `string` → `array` (native unserialize) | `array` → `string` (`serialize`) | -| `json` | `JsonCast` | `string` → `stdClass` (or `array` w/ `[array]`) | value → JSON `string` | -| `object` | `ObjectCast` | `(object)` cast | _identity_ | -| `datetime` | `DatetimeCast` | `string` → `DateTimeImmutable` | `DateTimeInterface` → `string` | -| `timestamp` | `TimestampCast` | `int`/`string` → `DateTimeImmutable` | `DateTimeInterface` → `int` | - -Notes: - -- **`bool` vs `int-bool`** — `bool` only transforms on read; if your column - stores `0/1` and you want the **write** to emit an int, use `int-bool`. -- **`json[array]`** decodes objects as associative arrays; plain `json` yields a - `stdClass`. -- **`datetime` format param** — `''`→`Y-m-d H:i:s`, `ms`→`…H:i:s.v`, - `us`→`…H:i:s.u`, or any literal PHP date format (e.g. `datetime[Y-m-d]`). - ---- - -## Custom casts - -Implement `CastInterface` (or extend `BaseCast` to inherit identity behaviour for -the direction you don't need) and register it by type key: - -```php -use Project\Support\Casting\CastInterface; - -final class MoneyCast implements CastInterface -{ - public static function get(mixed $value, array $params = [], ?object $helper = null): Money - { - return Money::ofCents((int) $value); // DB int cents → Money VO - } - - public static function set(mixed $value, array $params = [], ?object $helper = null): int - { - return $value instanceof Money ? $value->cents() : (int) $value; - } -} - -$caster = new DataCaster( - castHandlers: ['money' => MoneyCast::class], - types: ['total' => 'money'], -); - -$caster->castAs(1999, 'total'); // Money(19.99) -$caster->castAs(Money::of(19.99), 'total', 'set'); // 1999 -``` - -Custom handlers are **merged over** the defaults, so you can also override a -built-in type key with your own implementation. - ---- - -## `DataConverter` — the hydrator - -Maps a whole row, both directions, running every field through a pooled -`DataCaster`. This is what a Repository uses instead of the old -`Entity::find()/save()`. - -```php -use Project\Support\Hydration\DataConverter; - -new DataConverter( - array $types, // [column => typeString] - array $castHandlers = [], // custom casts - ?object $helper = null, - Closure|string $reconstructor = 'reconstitute', // static factory name OR closure - Closure|string $extractor = 'toRawArray', // method name OR closure -); -``` - -| Method | Returns | Notes | -| --- | --- | --- | -| `fromDataSource(array $row)` | `array` | Row → PHP-typed array (`get` on each known field) | -| `toDataSource(array $php)` | `array` | PHP array → DB-typed array (`set` on each known field) | -| `reconstruct(string $class, array $row)` | `object` | Hydrate an object from a raw row | -| `extract(object $obj)` | `array` | Object → DB-typed column array | - -Reconstruction resolves in this order: - -1. a **`Closure`** reconstructor → `$closure($phpData)` -2. a **static factory** named by the string (default `reconstitute`) → - `Class::reconstitute($phpData)` -3. otherwise throws `RuntimeException` (no reflection back-door). - -Extraction resolves: a **`Closure`** → a **method name** (default `toRawArray`) → -fallback to public state via `(array) $object` (private/protected keys dropped). - ---- - -## Cookbook — exhaustive examples - -### 1. Standalone caster, both directions - -```php -$c = new DataCaster(types: ['n' => 'int', 'on' => 'bool'], strict: false); -$c->castAs('7', 'n'); // 7 -$c->castAs('true', 'on'); // true -$c->castAs(7, 'n', 'set'); // 7 (IntegerCast set is identity) -``` - -### 2. Every built-in type - -```php -$c = new DataCaster(strict: false, types: [ - 'i' => 'int', 'f' => 'float', 's' => 'string', 'b' => 'bool', - 'ib' => 'int-bool','csv'=> 'csv', 'arr'=> 'array', 'j' => 'json', - 'ja' => 'json[array]', 'o' => 'object', 'dt' => 'datetime', 'ts' => 'timestamp', -]); - -$c->castAs('5', 'i'); // 5 -$c->castAs('9.95', 'f'); // 9.95 -$c->castAs(123, 's'); // '123' -$c->castAs('1', 'b'); // true -$c->castAs(true, 'ib', 'set'); // 1 -$c->castAs('a,b,c', 'csv'); // ['a','b','c'] -$c->castAs(['x' => 1], 'arr', 'set'); // 'a:1:{s:1:"x";i:1;}' (serialized) -$c->castAs('{"k":1}', 'j'); // stdClass { k: 1 } -$c->castAs('{"k":1}', 'ja'); // ['k' => 1] -$c->castAs('2024-01-02 03:04:05','dt');// DateTimeImmutable -$c->castAs('1700000000', 'ts'); // DateTimeImmutable @1700000000 -``` - -### 3. Nullable vs. strict - -```php -$strict = new DataCaster(types: ['x' => 'int']); // strict: true (default) -$strict->castAs(null, 'x'); // ❌ InvalidArgumentException (not nullable) - -$nullable = new DataCaster(types: ['x' => '?int']); -$nullable->castAs(null, 'x'); // null (passes through) - -$lenient = new DataCaster(types: ['x' => 'int'], strict: false); -// strict:false stops the null guard from throwing, but the handler may still -// reject null — always prefer the explicit `?int` for nullable columns. -``` - -### 4. Datetime formats - -```php -$c = new DataCaster(strict: false, types: [ - 'a' => 'datetime', // Y-m-d H:i:s - 'b' => 'datetime[ms]', // Y-m-d H:i:s.v - 'c' => 'datetime[Y-m-d]', // literal format -]); -$dt = new DateTimeImmutable('2024-12-25 10:30:00'); -$c->castAs($dt, 'a', 'set'); // '2024-12-25 10:30:00' -$c->castAs($dt, 'c', 'set'); // '2024-12-25' -$c->castAs('2024-12-25', 'c'); // DateTimeImmutable (parsed with that format) -``` - -### 5. Custom value-object cast - -```php -final class MoneyCast implements \Project\Support\Casting\CastInterface { - public static function get(mixed $v, array $p = [], ?object $h = null): Money { return Money::ofCents((int) $v); } - public static function set(mixed $v, array $p = [], ?object $h = null): int { return $v instanceof Money ? $v->cents() : (int) $v; } -} -$c = new DataCaster(castHandlers: ['money' => MoneyCast::class], types: ['total' => 'money']); -$c->castAs(2500, 'total'); // Money(25.00) -$c->castAs(Money::of(25), 'total', 'set'); // 2500 -``` - -### 6. Passing a helper to casts - -```php -// The 3rd ctor arg is forwarded to every cast as $helper — e.g. a connection, -// a clock, or any context object a custom cast needs. -$c = new DataCaster( - castHandlers: ['tzdate' => TimezoneDateCast::class], - types: ['at' => 'tzdate'], - helper: $clock, // TimezoneDateCast::get($v, $p, $clock) -); -``` - -### 7. Reusing a caster with `setTypes` - -```php -$c = new DataCaster(strict: false); -$c->setTypes(['a' => 'int'])->castAs('1', 'a'); // 1 -$c->setTypes(['a' => 'bool'])->castAs('1', 'a'); // true (parse cache reset) -``` - -### 8. Hydrating a single row - -```php -use Project\Support\Hydration\DataConverter; - -$conv = new DataConverter( - types: ['id' => 'int', 'paid' => 'bool', 'meta' => 'json[array]'], -); -$invoice = $conv->reconstruct(Invoice::class, [ - 'id' => '7', 'paid' => '1', 'meta' => '{"k":1}', -]); -// Invoice::reconstitute(['id'=>7, 'paid'=>true, 'meta'=>['k'=>1]]) -``` - -### 9. Hydrating with a closure (no static factory) - -```php -$conv = new DataConverter( - types: ['id' => 'int'], - reconstructor: fn(array $d) => new Dto($d['id']), - extractor: fn(Dto $o) => ['id' => $o->id], -); -$dto = $conv->reconstruct(Dto::class, ['id' => '3']); // Dto(3) -$row = $conv->extract($dto); // ['id' => 3] -``` - -### 10. Extracting an object to a DB row - -```php -$conv = new DataConverter( - types: ['id' => 'int', 'paid' => 'bool', 'meta' => 'json[array]'], - extractor: 'toRawArray', -); -$columns = $conv->extract($invoice); // ['id'=>3, 'paid'=>false, 'meta'=>'{"a":2}'] -$db->upsert('invoices', $columns, ['id']); -``` - -### 11. Mapping arrays directly (no objects) - -```php -$conv = new DataConverter(types: ['id' => 'int', 'active' => 'bool']); -$php = $conv->fromDataSource(['id' => '9', 'active' => '0', 'name' => 'ada']); -// ['id' => 9, 'active' => false, 'name' => 'ada'] (untyped keys pass through) -$store = $conv->toDataSource(['id' => 9, 'active' => false]); -// ['id' => 9, 'active' => false] -``` - -### 12. Full Repository CRUD (the GDA replacement for `Entity::find/save`) - -```php -use Project\Support\Hydration\DataConverter; - -final class InvoiceRepository -{ - private DataConverter $converter; - - public function __construct( - private readonly DatabasePort $db, - private readonly Identity $identity, - ) { - $this->converter = new DataConverter( - types: ['id' => 'int', 'paid' => 'bool', 'meta' => 'json[array]'], - reconstructor: 'reconstitute', // public static Invoice::reconstitute(array): self - extractor: 'toRawArray', // public Invoice::toRawArray(): array - ); - } - - public function find(string $id): Invoice - { - $row = $this->db->queryOne( - 'SELECT * FROM invoices WHERE id = :id AND tenant_id = :t', - ['id' => $id, 't' => $this->identity->tenantId], - ) ?? throw new RepositoryException("Invoice [{$id}] not found", layer: 'repository.invoice'); - - return $this->converter->reconstruct(Invoice::class, $row); - } - - /** @return Invoice[] */ - public function all(): array - { - $rows = $this->db->query( - 'SELECT * FROM invoices WHERE tenant_id = :t', - ['t' => $this->identity->tenantId], - ); - - return array_map(fn($r) => $this->converter->reconstruct(Invoice::class, $r), $rows); - } - - public function save(Invoice $invoice): void - { - $this->db->upsert('invoices', $this->converter->extract($invoice), ['id']); - } -} -``` - -The Domain `Invoice` stays pure: a `final` class (or one extending the -[`Entity` base](../Entity/README.md)) with `static reconstitute(array)`, -`toRawArray()`, state-transition methods that record domain events, and zero -infrastructure imports. - -### 13. Overriding a built-in type - -```php -// Replace the default 'json' behaviour project-wide: -$conv = new DataConverter( - types: ['payload' => 'json'], - castHandlers: ['json' => StrictJsonCast::class], // your own implementation wins -); -``` - ---- - -## Design notes & caveats - -- **Casts are static & stateless** — pure transforms, safe to share and call - concurrently (OpenSwoole-safe; no per-request state). -- **`DataConverter` pools `DataCaster` instances** keyed by a hash of - `types + castHandlers`, so many converters with the same shape share one - caster (memory win). The pool holds immutable config, not request data. -- **Prefer `?type` over `strict: false`** for nullable columns — it is explicit - and survives a handler that rejects `null`. -- **`array` cast uses PHP `serialize()`** (unserialize is restricted to - `allowed_classes => false`). Use `json`/`json[array]` if you need portable, - language-agnostic storage. -- **No reflection back-door** — `reconstruct()` requires a static factory or a - closure; it will not write private properties behind the entity's back. Give - your Domain entity a `reconstitute()`/`toRawArray()` (the - [`Entity` base](../Entity/README.md) provides both). -- This layer never does I/O. The DB call belongs to the Repository; the cast - layer only shapes values. diff --git a/projects/Support/Entity/README.md b/projects/Support/Entity/README.md deleted file mode 100644 index 667f240..0000000 --- a/projects/Support/Entity/README.md +++ /dev/null @@ -1,821 +0,0 @@ -# Entity support (`Project\Support\Entity\Entity`) - -An enterprise-grade, **GDA-safe** base class for every domain entity — the -refactored core of the legacy `__DEV__/Entity` Active Record, with all the -persistence/ORM machinery stripped out and a hardened, secure feature set added. - -- **Namespace:** `Project\Support\Entity` -- **Autoload:** `Project\` → `projects/` (PSR-4, already wired in `composer.json`) -- **Pairs with:** [`Project\Support\Casting\DataCaster`](../Casting/README.md) (the `$casts` engine) - and `Project\Support\Hydration\DataConverter` (row ⇄ object mapping) - -It performs **no I/O**, reads **no globals** (`app()`/`kernel()`/`config()`), and -imports only the sibling casting utility — so it is safe to extend from a -plugin's `Domain/` layer without violating the Five Access Rules. - ---- - -## Table of contents - -1. [Why it was decomposed, not relocated](#why-it-was-decomposed-not-relocated) -2. [What it keeps vs. removes](#what-it-keeps-vs-removes) -3. [Quick start](#quick-start) -4. [Configuration properties](#configuration-properties) -5. [Full API reference](#full-api-reference) -6. [Type casting (`$casts`)](#type-casting-casts) -7. [Accessors & mutators](#accessors--mutators) -8. [Security model](#security-model) -9. [Serialization](#serialization) -10. [Change tracking](#change-tracking) -11. [Domain events](#domain-events) -12. [Immutability sealing](#immutability-sealing) -13. [Use in the GDA layers](#use-in-the-gda-layers) -14. [Cookbook — exhaustive examples](#cookbook--exhaustive-examples) -15. [Design notes & caveats](#design-notes--caveats) - ---- - -## Why it was decomposed, not relocated - -`__DEV__/Entity/Entity.php` is a CodeIgniter/Eloquent-style **fat Active Record**: -magic `__get/__set`, mutators/accessors, `save()/delete()/restore()`, -`performInsert/Update`, `getRepo_()`, WP-style meta tables and change tracking — -all in one base. GDA explicitly forbids this (no ORM/AR in the Domain layer, no -entity importing infrastructure, no entity calling its own repository). - -So the single class was split across the layers it conflated. This base keeps -only the **pure, infrastructure-free entity mechanics**; persistence and request -validation move to their proper homes. - -## What it keeps vs. removes - -| Kept (pure entity mechanics) | Removed (moved to its GDA home) | -| --- | --- | -| attribute bag + change tracking | `save()` / `delete()` / `restore()` → **Repository** (`DatabasePort`) | -| type casting via `DataCaster` (`$casts`) | `performInsert/Update`, meta tables, `getRepo_()` → **Repository** | -| `get{X}Attribute` / `set{X}Attribute` hooks | magic `__get` DB fallback → gone (entities never query) | -| domain-event buffer | `app()` / `kernel()` global lookups → gone | -| `reconstitute()` / `toRawArray()` (Hydrator seam) | — | -| mass-assignment guard (kept as a defense-in-depth safety net) | (primary validation still belongs in the DTO at the controller edge) | - ---- - -## Quick start - -```php -use Project\Support\Entity\Entity; - -final class User extends Entity -{ - protected string $primaryKey = 'id'; - - protected array $casts = [ - 'id' => 'int', - 'active' => 'bool', - 'roles' => '?json[array]', - 'createdAt' => 'datetime', - ]; - - protected array $fillable = ['name', 'email', 'active', 'roles']; - protected array $hidden = ['password']; // never serialized / dumped - protected array $appends = ['display']; // computed, added to output - protected array $dates = ['createdAt']; - - // Named constructor — records a creation event - public static function register(string $name, string $email): self - { - $u = (new self())->fill(['name' => $name, 'email' => $email, 'active' => true]); - $u->recordEvent(new UserRegistered($email)); - return $u; - } - - // Computed accessor surfaced via $appends - public function getDisplayAttribute(): string - { - return strtoupper($this->getString('name')); - } -} -``` - -```php -$user = User::register('ada', 'ada@example.com'); -$user->getBool('active'); // true -$user->toArray(); // ['name'=>'ada', ..., 'display'=>'ADA'] (no 'password') -foreach ($user->releaseEvents() as $event) { /* hand to the collector */ } -``` - ---- - -## Configuration properties - -Override these `protected` properties in your subclass: - -| Property | Type | Default | Purpose | -| --- | --- | --- | --- | -| `$primaryKey` | `string` | `'id'` | Key field used by `getKey()`/`exists()`/`is()` | -| `$casts` | `array` | `[]` | Field → cast type (see [Type casting](#type-casting-casts)) | -| `$customCasters` | `array` | `[]` | Extra cast handlers `[type => CastInterface]` | -| `$fillable` | `list` | `[]` | Mass-assignment whitelist | -| `$guarded` | `list` | `['*']` | Mass-assignment blacklist (default: deny all) | -| `$hidden` | `list` | `[]` | Excluded from array/JSON **and** redacted in dumps | -| `$visible` | `list` | `[]` | If set, ONLY these appear in array/JSON | -| `$appends` | `list` | `[]` | Computed accessor names added to output | -| `$dates` | `list` | `[]` | Fields serialized via `$dateFormat` | -| `$dateFormat` | `string` | `'Y-m-d H:i:s'` | Date serialization format | - ---- - -## Full API reference - -### Construction / lifecycle - -| Method | Returns | Notes | -| --- | --- | --- | -| `new static(?array $attributes = null)` | — | Raw hydration; **bypasses** guards; syncs original | -| `static::make()` | `static` | Blank instance | -| `static::reconstitute(array $row)` | `static` | Hydrate from a DB row; **records no events** | -| `replicate(array $except = [])` | `static` | Copy **without** the primary key (and `$except`) | -| `__clone()` | — | Resets change-tracking, events and seal | - -### Mass assignment - -| Method | Returns | Notes | -| --- | --- | --- | -| `fill(array $data)` | `static` | Writes only fillable keys (safe) | -| `forceFill(array $data)` | `static` | Bypasses guards — trusted data only | -| `isFillable(string $key)` | `bool` | Guard evaluation | - -### Attribute access - -| Method | Returns | -| --- | --- | -| `getAttribute(string $key)` | cast + accessor-applied value | -| `setAttribute(string $key, $value)` | `static` (mutator + cast applied) | -| `getRawAttribute(string $key)` | uncast stored value | -| `hasAttribute(string $key)` | `bool` | -| `only(array $keys)` / `except(array $keys)` | `array` | - -### Typed, null-safe getters - -| Method | Returns | -| --- | --- | -| `getString($key, $default='')` | `string` | -| `getInt($key, $default=0)` | `int` | -| `getFloat($key, $default=0.0)` | `float` | -| `getBool($key, $default=false)` | `bool` | -| `getArray($key, $default=[])` | `array` | -| `getDate($key)` | `?DateTimeImmutable` | - -### Serialization methods - -| Method | Returns | -| --- | --- | -| `toArray(bool $onlyChanged=false)` | visibility-filtered, cast, appends + dates | -| `toRawArray(bool $onlyChanged=false)` | raw DataSource-shaped attributes | -| `jsonSerialize()` | `array` (= `toArray()`) | -| `toJson(int $flags=0)` | `string` (throws on encode error) | -| `__toString()` | JSON | -| `makeHidden($keys)` / `makeVisible($keys)` | `static` | - -### Change-tracking methods - -| Method | Returns | -| --- | --- | -| `syncOriginal()` | `static` — snapshot current state | -| `isDirty(...$keys)` | `bool` | -| `isClean(...$keys)` | `bool` | -| `wasChanged(...$keys)` | `bool` (alias of `isDirty`) | -| `getChanges()` / `getDirty()` | `array` of changed fields | -| `getOriginal(?string $key=null, $default=null)` | snapshot value(s) | - -### Identity helpers - -| Method | Returns | -| --- | --- | -| `getKey()` | primary key value | -| `getKeyName()` | key field name | -| `exists()` | `bool` (non-empty key) | -| `is(?Entity $other)` / `isNot(?Entity $other)` | `bool` (same class + key) | - -### Domain-event methods - -| Method | Returns | -| --- | --- | -| `recordEvent(object $event)` | `void` (`protected`) | -| `hasEvents()` | `bool` | -| `releaseEvents()` | `list` (returns **and clears**) | - -### Immutability - -| Method | Returns | -| --- | --- | -| `seal()` | `static` — lock the bag | -| `isSealed()` | `bool` | - -### Interfaces implemented - -`JsonSerializable`, `ArrayAccess` (`$entity['field']`), `Stringable`. - ---- - -## Type casting (`$casts`) - -Casting is bidirectional and runs through `DataCaster`: - -- **read** (`getAttribute`/`toArray`) → `get` direction (DataSource → PHP) -- **write** (`setAttribute`) → `set` direction (PHP → DataSource) - -```php -protected array $casts = [ - 'id' => 'int', - 'price' => 'float', - 'active' => 'bool', - 'flags' => 'int-bool', // bool in PHP, 0/1 in the column - 'tags' => 'csv', - 'meta' => '?json[array]', // ? = nullable, [array] = decode assoc - 'opened' => 'datetime', // datetime[ms] / datetime[us] / datetime[Y-m-d] -]; -``` - -Built-in types: `int|integer`, `float|double`, `string`, `bool|boolean`, -`int-bool`, `csv`, `array`, `json`, `object`, `datetime`, `timestamp`. -Register custom ones via `$customCasters` (must implement -`Project\Support\Casting\CastInterface`). Full grammar: -[Casting README](../Casting/README.md). - -> `'bool'` casts only on **read**; use `'int-bool'` when the column stores `0/1` -> and you want `toRawArray()` to emit an int. - ---- - -## Accessors & mutators - -Define `get{Studly}Attribute($value)` / `set{Studly}Attribute($value)` to hook a -single field. Studly conversion handles `snake_case`, `kebab-case` and spaces. - -```php -public function getNameAttribute($v): string { return ucfirst((string) $v); } -public function setEmailAttribute($v): string { return strtolower(trim((string) $v)); } -``` - -Accessors run **after** casting on read; mutators run **before** casting on write. -Method existence is memoized per class for performance. - ---- - -## Security model - -**Mass assignment is denied by default.** - -```php -protected array $guarded = ['*']; // nothing mass-assignable… -protected array $fillable = ['name', 'email']; // …except these -``` - -```php -$user->fill($request->all()); // 'id', 'is_admin', 'password' silently dropped -$user->forceFill($trusted); // bypass — ONLY for internal, trusted data -``` - -This is **defense in depth**: the DTO at the controller edge is still the primary -validator; the entity guard is the second line so over-posting can never reach -the attribute bag. - -**Secrets never leak into logs.** `__debugInfo()` redacts every `$hidden` field -as `********`, so `var_dump($entity)`, stack traces and error dumps stay safe: - -```php -protected array $hidden = ['password', 'api_token']; -// var_dump($user) → ['password' => '********', 'api_token' => '********', ...] -``` - -**Read-only snapshots.** `seal()` makes the bag immutable — any -`set`/`__set`/`offsetSet`/`unset` throws `LogicException`. Use for cached -projections shared within a request so accidental writes are impossible. - ---- - -## Serialization - -`toArray()` / `jsonSerialize()` / `toJson()` apply, in order: - -1. **Visibility** — drop `$hidden`; if `$visible` is set, keep only those. -2. **Casting** — each value via its `$casts` entry. -3. **Date formatting** — `$dates` fields via `$dateFormat`; any - `DateTimeInterface` value is formatted; nested `JsonSerializable` is unwrapped. -4. **Appends** — each `$appends` accessor (subject to visibility). - -`toRawArray()` returns the **raw** stored attributes (DataSource shape) for -persistence — let the `DataConverter` apply row-level casts if you want a fully -typed raw array. - ---- - -## Change tracking - -```php -$user->syncOriginal(); // baseline (Repository calls this after load/save) -$user->name = 'grace'; -$user->isDirty(); // true -$user->isDirty('email'); // false -$user->wasChanged('name'); // true -$user->getDirty(); // ['name' => 'grace'] -$user->getOriginal('name'); // 'ada' -``` - -A Repository typically persists only `toRawArray(onlyChanged: true)` and calls -`syncOriginal()` after a successful write. - ---- - -## Domain events - -Entities **record** events during state changes; the **Service** flushes them -inside the transaction/commit pattern — the entity never dispatches. - -```php -public function deactivate(): void -{ - if (! $this->getBool('active')) { - throw new \DomainException('User already inactive'); - } - $this->active = false; - $this->recordEvent(new UserDeactivated($this->getKey())); -} -``` - -```php -// In the Service: -$user->deactivate(); -foreach ($user->releaseEvents() as $event) { - $this->collector->collect($event); // buffered in-tx, discarded on rollback -} -$this->repository->save($user); -``` - -`reconstitute()` (hydration) records **no** events. - ---- - -## Immutability sealing - -```php -$snapshot = User::reconstitute($row)->seal(); -$snapshot->name; // ✅ read freely -$snapshot->name = 'x'; // ❌ throws LogicException -$snapshot->isSealed(); // true -$copy = clone $snapshot; // clone is unsealed + tracking reset -``` - ---- - -## Use in the GDA layers - -```php -// ── Domain entity ── extends this base, no infrastructure imports -final class Invoice extends Entity { /* $casts, named ctors, transitions */ } - -// ── Service ── transaction + event pattern -$invoice->pay(); -foreach ($invoice->releaseEvents() as $e) { - $this->collector->collect($e); -} -$this->repository->save($invoice); - -// ── Repository ── the ONLY place that touches the DB (DatabasePort) -public function find(string $id): Invoice -{ - $row = $this->db->queryOne( - 'SELECT * FROM invoices WHERE id = :id AND tenant_id = :t', - ['id' => $id, 't' => $this->identity->tenantId] - ) ?? throw new RepositoryException("Invoice [{$id}] not found", layer: 'repository.invoice'); - - return Invoice::reconstitute($row); // or via DataConverter to apply casts -} - -public function save(Invoice $invoice): void -{ - $this->db->upsert('invoices', $invoice->toRawArray(onlyChanged: true), ['id']); - $invoice->syncOriginal(); -} -``` - -For automatic row-level casting through the hydrator, see the `DataConverter` -example in the [Casting README](../Casting/README.md). - ---- - -## Cookbook — exhaustive examples - -A copy-pasteable reference for every feature. Each block is self-contained. - -### 1. Defining an entity - -```php -use Project\Support\Entity\Entity; - -final class Article extends Entity -{ - protected string $primaryKey = 'id'; - - protected array $casts = [ - 'id' => 'int', - 'published' => 'bool', - 'views' => 'int', - 'rating' => 'float', - 'tags' => 'csv', - 'meta' => '?json[array]', - 'publishedAt' => '?datetime', - ]; - - protected array $fillable = ['title', 'body', 'tags', 'published']; - protected array $hidden = ['authorEmail']; - protected array $appends = ['excerpt']; - protected array $dates = ['publishedAt']; - - public function getExcerptAttribute(): string - { - return mb_substr($this->getString('body'), 0, 80); - } -} -``` - -### 2. Every cast type, round-tripped - -```php -$e = new class extends Entity { - protected array $casts = [ - 'n' => 'int', - 'amt' => 'float', - 's' => 'string', - 'b' => 'bool', - 'ib' => 'int-bool', // bool in PHP, 0/1 in DB - 'csv' => 'csv', - 'arr' => 'array', // PHP serialize() in DB - 'j' => 'json', - 'ja' => 'json[array]', // decode as assoc array - 'o' => 'object', - 'dt' => 'datetime', - 'ts' => 'timestamp', - ]; -}; - -$e->n = '42'; $e->n; // 42 (int) -$e->amt = '9.95'; $e->amt; // 9.95 (float) -$e->b = '1'; $e->b; // true (bool) -$e->ib = true; $e->toRawArray()['ib']; // 1 (int in DB shape) -$e->csv = ['a','b']; $e->csv; // ['a','b'] (array on read) -$e->ja = '{"k":1}'; $e->ja; // ['k' => 1] -$e->dt = '2024-01-02 03:04:05'; -$e->dt; // DateTimeImmutable -``` - -### 3. Custom cast (value object) - -```php -use Project\Support\Casting\CastInterface; - -final class MoneyCast implements CastInterface -{ - public static function get(mixed $v, array $p = [], ?object $h = null): Money - { - return Money::ofCents((int) $v); // DB int cents -> Money VO - } - public static function set(mixed $v, array $p = [], ?object $h = null): int - { - return $v instanceof Money ? $v->cents() : (int) $v; - } -} - -final class Order extends Entity -{ - protected array $customCasters = ['money' => MoneyCast::class]; - protected array $casts = ['total' => 'money']; -} - -$order = new Order(); -$order->total = Money::of(19.99); // stored as 1999 (cents) -$order->total; // Money VO again -$order->toRawArray()['total']; // 1999 -``` - -### 4. Accessors & mutators - -```php -final class Person extends Entity -{ - protected array $casts = ['name' => 'string']; - - // read transform (runs AFTER cast) - public function getNameAttribute($v): string { return ucwords((string) $v); } - - // write transform (runs BEFORE cast) - public function setEmailAttribute($v): string { return strtolower(trim((string) $v)); } - - // computed, exposed via $appends - protected array $appends = ['initials']; - public function getInitialsAttribute(): string - { - return implode('', array_map(fn($p) => $p[0] ?? '', explode(' ', $this->getString('name')))); - } -} - -$p = new Person(); -$p->name = 'ada lovelace'; $p->name; // 'Ada Lovelace' -$p->email = ' A@B.C '; $p->getRawAttribute('email'); // 'a@b.c' -$p->toArray()['initials']; // 'AL' -``` - -### 5. Mass assignment — safe vs. forced - -```php -final class Account extends Entity -{ - protected array $fillable = ['name', 'email']; // only these are mass-assignable - // $guarded defaults to ['*'] => everything else blocked -} - -$a = (new Account())->fill([ - 'name' => 'ada', - 'email' => 'a@b.c', - 'is_admin' => true, // ← silently dropped (not fillable) - 'id' => 999, // ← silently dropped -]); -$a->hasAttribute('is_admin'); // false - -// trusted, internal data only: -$a->forceFill(['id' => 7, 'is_admin' => true]); -$a->isFillable('email'); // true -$a->isFillable('is_admin'); // false -``` - -Whitelist instead of default-deny: - -```php -final class Tag extends Entity -{ - protected array $guarded = ['id']; // everything fillable EXCEPT id -} -``` - -### 6. Typed, null-safe getters - -```php -$e->getString('name', 'anon'); // string, default if null -$e->getInt('age'); // 0 if missing/non-numeric -$e->getFloat('rate'); // 0.0 default -$e->getBool('active'); // false default; understands "1"/"true"/"on"/"yes" -$e->getArray('roles'); // [] default; decodes a JSON string too -$e->getDate('createdAt'); // ?DateTimeImmutable (parses int/string) -``` - -### 7. Visibility — static and runtime - -```php -final class Secretish extends Entity -{ - protected array $hidden = ['password']; -} - -$s = Secretish::reconstitute(['id' => 1, 'password' => 'x', 'name' => 'ada']); -$s->toArray(); // ['id'=>1, 'name'=>'ada'] (no password) - -$s->makeVisible('password'); // expose at runtime -array_key_exists('password', $s->toArray()); // true - -$s->makeHidden(['name']); // hide more at runtime -$s->toArray(); // ['id'=>1, 'password'=>'x'] - -// whitelist mode — ONLY listed fields ever appear: -final class Slim extends Entity { protected array $visible = ['id', 'name']; } -``` - -### 8. Serialization surfaces - -```php -$e->toArray(); // cast + visibility + dates + appends -$e->toArray(onlyChanged: true);// only changed fields -$e->toRawArray(); // raw DB-shaped attributes (for persistence) -$e->jsonSerialize(); // == toArray() -$e->toJson(JSON_PRETTY_PRINT); // string (throws on encode error) -(string) $e; // JSON via Stringable -json_encode($e); // uses JsonSerializable automatically - -// dates honour $dates + $dateFormat -final class Event extends Entity { - protected array $dates = ['startsAt']; - protected string $dateFormat = 'Y-m-d'; -} -$ev = Event::reconstitute(['startsAt' => '2024-12-25 10:00:00']); -$ev->toArray()['startsAt']; // '2024-12-25' -``` - -### 9. ArrayAccess - -```php -$e['title'] = 'Hello'; // setAttribute (mutator + cast) -$e['title']; // getAttribute (cast + accessor) -isset($e['title']); // accessor value !== null -unset($e['title']); // removes from the bag -``` - -### 10. Change tracking & dirty-only persistence - -```php -$e = Article::reconstitute(['id' => 1, 'title' => 'A', 'views' => 10]); -$e->isDirty(); // false (just hydrated) - -$e->title = 'B'; -$e->views = 11; -$e->isDirty(); // true -$e->isDirty('title'); // true -$e->isClean('id'); // true -$e->wasChanged('views'); // true -$e->getDirty(); // ['title'=>'B', 'views'=>11] -$e->getChanges(); // (alias of getDirty) -$e->getOriginal('title'); // 'A' -$e->getOriginal(); // full original snapshot - -// persist only what changed, then re-baseline -$db->upsert('articles', $e->toRawArray(onlyChanged: true), ['id']); -$e->syncOriginal(); -$e->isDirty(); // false again -``` - -### 11. Domain events (Service pattern) - -```php -final class Subscription extends Entity -{ - public static function start(string $plan): self - { - $s = (new self())->forceFill(['plan' => $plan, 'status' => 'active']); - $s->recordEvent(new SubscriptionStarted($plan)); - return $s; - } - - public function cancel(): void - { - if ($this->getString('status') === 'cancelled') { - throw new \DomainException('Already cancelled'); - } - $this->status = 'cancelled'; - $this->recordEvent(new SubscriptionCancelled($this->getKey())); - } -} - -// In the Application Service — flush inside the transaction: -$sub->cancel(); -$this->collector->beginCollection(); -$this->transaction->begin(); -try { - $this->repository->save($sub); - foreach ($sub->releaseEvents() as $event) { // returns AND clears - $this->collector->collect($event); - } - $this->transaction->commit(); -} catch (\Throwable $e) { - $this->transaction->rollback(); - $this->collector->discard(); - throw $e; -} - -$sub->hasEvents(); // false — buffer drained -``` - -### 12. Immutability sealing (read-only snapshots) - -```php -$snapshot = Article::reconstitute($row)->seal(); -$snapshot->title; // ✅ reads fine -try { - $snapshot->title = 'x'; // ❌ throws LogicException -} catch (\LogicException $e) { /* sealed */ } - -$snapshot->isSealed(); // true -$editable = clone $snapshot; // clone is UNSEALED + tracking reset -$editable->isSealed(); // false -``` - -### 13. Replication & cloning - -```php -$tpl = Article::reconstitute(['id' => 5, 'title' => 'Template', 'views' => 99]); - -$copy = $tpl->replicate(); // no primary key -$copy->getRawAttribute('id'); // null → save() inserts a new row -$copy->getString('title'); // 'Template' - -$copy2 = $tpl->replicate(except: ['views']); // also drop views - -$clone = clone $tpl; // keeps attributes; resets original/events/seal -$clone->getOriginal(); // [] -``` - -### 14. Identity & comparison - -```php -$a = Article::reconstitute(['id' => 1]); -$b = Article::reconstitute(['id' => 1]); -$c = Article::reconstitute(['id' => 2]); -$new = new Article(); - -$a->is($b); // true (same class + same non-empty key) -$a->isNot($c); // true -$a->is($new); // false (new has no key) -$new->exists(); // false -$a->getKey(); // 1 -$a->getKeyName(); // 'id' -``` - -### 15. `only()` / `except()` - -```php -$e->only(['id', 'title']); // ['id'=>.., 'title'=>..] (cast values) -$e->except(['authorEmail']); // toArray() minus those keys -``` - -### 16. Full Repository CRUD with the Hydrator - -```php -use Project\Support\Hydration\DataConverter; - -final class ArticleRepository -{ - private DataConverter $converter; - - public function __construct( - private readonly DatabasePort $db, - private readonly Identity $identity, - ) { - $this->converter = new DataConverter( - types: ['id' => 'int', 'published' => 'bool', 'meta' => 'json[array]'], - reconstructor: 'reconstitute', // Article::reconstitute(array) - extractor: 'toRawArray', // Article::toRawArray() - ); - } - - public function find(string $id): Article - { - $row = $this->db->queryOne( - 'SELECT * FROM articles WHERE id = :id AND tenant_id = :t', - ['id' => $id, 't' => $this->identity->tenantId], - ) ?? throw new RepositoryException("Article [{$id}] not found", layer: 'repository.article'); - - return $this->converter->reconstruct(Article::class, $row); // casts applied - } - - /** @return Article[] */ - public function all(): array - { - $rows = $this->db->query('SELECT * FROM articles WHERE tenant_id = :t', - ['t' => $this->identity->tenantId]); - - return array_map(fn($r) => $this->converter->reconstruct(Article::class, $r), $rows); - } - - public function save(Article $a): void - { - $this->db->upsert('articles', $this->converter->extract($a), ['id']); - $a->syncOriginal(); - } -} -``` - -### 17. Serializing a collection - -```php -$articles = $repo->all(); -$payload = array_map(static fn(Article $a) => $a->toArray(), $articles); -$json = json_encode($articles); // each element uses JsonSerializable -``` - -### 18. Safe logging (secret redaction) - -```php -final class Credentials extends Entity { protected array $hidden = ['secret', 'token']; } - -$c = Credentials::reconstitute(['id' => 1, 'secret' => 'sk_live_x', 'token' => 'abc']); -var_dump($c); -// ['id'=>1, 'secret'=>'********', 'token'=>'********'] ← __debugInfo() redaction -log_debug(print_r($c, true)); // also redacted -``` - ---- - -## Design notes & caveats - -- This is a **convenience** base with a public attribute bag. The strict GDA gold - standard is still a `final` entity with a private constructor and fully - encapsulated state (private typed properties, no bag). Extend this base when - the flexible, WordPress-style attribute bag genuinely earns its keep - (heterogeneous/meta-driven records); prefer a hand-written `final` entity for - small, well-defined aggregates. -- `static::$methodCache` memoizes `method_exists` results. It caches **immutable - facts** (does class X define method Y), not request data, so it is safe under - OpenSwoole and does not leak between requests. -- `exists()` treats `null`, `''`, `0`, `'0'` as "no key". -- `offsetExists()`/`__isset()` use the **accessor** value (so a `null` cast result - reads as not-set); use `hasAttribute()` for a pure key-presence check. -- The base never validates business rules — invariants belong in the entity's own - transition methods (throwing `\DomainException`) and in DTOs. diff --git a/projects/Support/Seo/RouteCatalog.php b/projects/Support/Seo/RouteCatalog.php index 0cb2d6b..50ea462 100644 --- a/projects/Support/Seo/RouteCatalog.php +++ b/projects/Support/Seo/RouteCatalog.php @@ -4,6 +4,7 @@ namespace Project\Support\Seo; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\RouteIndex; use AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths; /** @@ -59,23 +60,38 @@ public static function fromManifest(?string $manifestPath = null): self /** * Public, static GET paths suitable for a sitemap (leading-slash paths). * + * DOMAIN GROUPS: a route may be grouped under a host, and a sitemap describes + * ONE host — so `$domain` selects which groups to enumerate. The default + * (null) returns only the shared, ungrouped routes, which is every route in a + * project that groups nothing and therefore leaves existing sitemaps + * byte-identical. Pass a host to get the groups that host matches (exact, + * wildcard or bare subdomain — see RouteIndex::hostCandidates) plus the shared + * ones, de-duplicated by path. + * * @param list $excludePrefixes Extra path prefixes to skip. * @param list $excludePaths Extra exact paths to skip. + * @param string|null $domain Host to enumerate, or null for shared only. * @return list */ - public function publicPaths(array $excludePrefixes = [], array $excludePaths = []): array + public function publicPaths(array $excludePrefixes = [], array $excludePaths = [], ?string $domain = null): array { $prefixes = [...self::DEFAULT_EXCLUDED_PREFIXES, ...$excludePrefixes]; $paths = [...self::DEFAULT_EXCLUDED_PATHS, ...$excludePaths]; + $wanted = $domain === null ? [] : RouteIndex::hostCandidates($domain); $found = []; foreach ($this->manifest as $key => $entry) { - [$method, $path] = array_pad(explode(' ', $key, 2), 2, ''); + $parsed = RouteIndex::parseKey($key); + $method = $parsed['method']; + $path = $parsed['path']; if (strtoupper($method) !== 'GET') { continue; } + if ($parsed['domain'] !== '' && !in_array($parsed['domain'], $wanted, true)) { + continue; // belongs to a different host — not this sitemap + } if ($path === '' || str_contains($path, '{')) { continue; // dynamic — cannot enumerate from the manifest } diff --git a/projects/projects.json b/projects/projects.json index 2cf9004..0967ef4 100644 --- a/projects/projects.json +++ b/projects/projects.json @@ -1,20 +1 @@ -{ - "shop": { - "name": "shop", - "version": "1.0.0", - "path": "/home/home/Documents/PROJECTS/psp-shop", - "domains": [ - "shop.com" - ] - }, - "hkmcode": { - "name": "hkmcode", - "version": "1.0.0", - "path": "/home/home/Documents/PROJECTS/hkmcode", - "domains": [ - "hkm.local", - "api.hkm.local", - "app.hkm.local" - ] - } -} +{} diff --git a/src/Commands/Migrate/CliCommandFactory.php b/src/Commands/Migrate/CliCommandFactory.php index 651eebc..8a5b6ad 100644 --- a/src/Commands/Migrate/CliCommandFactory.php +++ b/src/Commands/Migrate/CliCommandFactory.php @@ -61,7 +61,7 @@ public function all(): array return [ ...$this->migrate(), ...$this->generate(), - ...$this->tenant(), + // ...$this->tenant(), ...$this->seed(), ...$this->make(), ...$this->maintenance(), @@ -112,17 +112,17 @@ public function generate(): array * * @return list */ - public function tenant(): array - { - $c = $this->config; - return [ - new TenantMigrateRunCommand($c), - new TenantMigrateRollbackCommand($c), - new TenantMigrateResetCommand($c), - new TenantMigrateRefreshCommand($c), - new TenantMigrateStatusCommand($c), - ]; - } + // public function tenant(): array + // { + // $c = $this->config; + // return [ + // new TenantMigrateRunCommand($c), + // new TenantMigrateRollbackCommand($c), + // new TenantMigrateResetCommand($c), + // new TenantMigrateRefreshCommand($c), + // new TenantMigrateStatusCommand($c), + // ]; + // } /** * Seeder commands. diff --git a/src/Commands/Migrate/TenantCommand.php b/src/Commands/Migrate/TenantCommand.php deleted file mode 100644 index 21e7525..0000000 --- a/src/Commands/Migrate/TenantCommand.php +++ /dev/null @@ -1,167 +0,0 @@ - [__DIR__ . '/migrations'], - * 'tenants' => [ - * 'resolver' => new MyTenantResolver($pdo), - * ], - * ]; - * - * // Shape B — class name (default-constructible): - * return [ - * 'paths' => [__DIR__ . '/migrations'], - * 'tenants' => [ - * 'resolver_class' => MyTenantResolver::class, - * ], - * ]; - * - * Subclasses implement runForTenants(TenantAwareRunner $runner): int — they - * receive a wired runner and decide which method to call. - */ -abstract class TenantCommand extends LetMigrateCommand -{ - private ?TenantAwareRunner $cachedTenantRunner = null; - - /** - * Subclasses MUST register their own name/description and call this from - * configure() to pick up the standard tenant options. - */ - protected function registerTenantOptions(): void - { - $this->registerCommonOptions(); - $this->addOption('tenant', 't', - 'Tenant ID to operate on', - acceptsValue: true); - $this->addOption('all', '', - 'Operate on every registered tenant in sequence'); - } - - /** - * Build (and cache) the TenantAwareRunner from config. - * - * Validates that exactly one of --tenant / --all was supplied. Throws a - * clear runtime error if the config has no 'tenants' section or if the - * resolver can't be constructed. - */ - protected function tenantRunner(): TenantAwareRunner - { - if ($this->cachedTenantRunner !== null) { - return $this->cachedTenantRunner; - } - - $config = $this->loadConfig(); - $tenants = $config['tenants'] ?? null; - if (!is_array($tenants)) { - $this->error( - 'No tenant configuration. Add a "tenants" key to your config ' - . 'with either "resolver" (instance) or "resolver_class" (FQCN).', - ); - throw new \RuntimeException('Missing tenants config.'); - } - - $resolver = $this->buildResolver($tenants); - - // Strip the 'tenants' key from base config so it doesn't leak into - // per-tenant DriverRegistry::fromConfig() calls. - $baseConfig = array_diff_key($config, ['tenants' => 1]); - - return $this->cachedTenantRunner = new TenantAwareRunner( - resolver: $resolver, - baseConfig: $baseConfig, - logger: new NullLogger(), - ); - } - - /** - * @param array $tenants - */ - private function buildResolver(array $tenants): TenantResolverInterface - { - // Shape A — instance. - if (isset($tenants['resolver'])) { - $r = $tenants['resolver']; - if ($r instanceof TenantResolverInterface) { - return $r; - } - // Closure → call it. - if (is_callable($r)) { - $resolved = $r(); - if ($resolved instanceof TenantResolverInterface) { - return $resolved; - } - } - throw new \RuntimeException( - 'tenants.resolver did not produce a TenantResolverInterface.', - ); - } - - // Shape B — class name. - if (isset($tenants['resolver_class'])) { - $class = (string) $tenants['resolver_class']; - if (!class_exists($class)) { - throw new \RuntimeException( - "tenants.resolver_class '{$class}' does not exist.", - ); - } - $instance = new $class(); - if (!$instance instanceof TenantResolverInterface) { - throw new \RuntimeException( - "tenants.resolver_class '{$class}' must implement " - . 'AlfaCode\\LetMigrate\\Contract\\TenantResolverInterface.', - ); - } - return $instance; - } - - throw new \RuntimeException( - 'tenants.resolver or tenants.resolver_class is required.', - ); - } - - /** - * Convenience: validate that exactly one of --tenant or --all was given, - * and return the chosen tenant ID (or null when --all). - */ - protected function selectedTenant(): ?string - { - $tenant = $this->option('tenant'); - $all = $this->hasOption('all'); - - if ($tenant === null && !$all) { - $this->error('Specify exactly one of --tenant=ID or --all.'); - throw new \RuntimeException('Tenant target missing.'); - } - if ($tenant !== null && $all) { - $this->error('Cannot combine --tenant=ID with --all — pick one.'); - throw new \RuntimeException('Conflicting tenant flags.'); - } - - return $tenant !== null ? (string) $tenant : null; - } -} \ No newline at end of file diff --git a/src/Commands/Migrate/TenantMigrateRefreshCommand.php b/src/Commands/Migrate/TenantMigrateRefreshCommand.php deleted file mode 100644 index ae853ab..0000000 --- a/src/Commands/Migrate/TenantMigrateRefreshCommand.php +++ /dev/null @@ -1,71 +0,0 @@ -name = 'tenant:refresh'; - $this->description = 'Reset and re-run all migrations across one or all tenants'; - $this->registerTenantOptions(); - } - - protected function handle(): int - { - $runner = $this->tenantRunner(); - $tenant = $this->selectedTenant(); - - if ($tenant !== null) { - $this->info("Refreshing tenant: {$tenant}"); - $result = $runner->refreshForTenant($tenant); - - if ($this->wantsJson()) { - $this->emitJson([ - 'tenant' => $tenant, - 'result' => (new JsonResultPresenter())->resultData($result), - ]); - return self::SUCCESS; - } - - $this->alertSuccess('Tenant refreshed', [ - "Tenant: {$tenant}", - "Applied: {$result->appliedCount()}", - ]); - return self::SUCCESS; - } - - $this->info('Refreshing ALL tenants…'); - $results = []; - foreach (array_keys($runner->statusForAllTenants()) as $id) { - $results[$id] = $runner->refreshForTenant($id); - } - - if ($this->wantsJson()) { - $payload = []; - foreach ($results as $id => $result) { - $payload[$id] = (new JsonResultPresenter())->resultData($result); - } - $this->emitJson(['tenants' => $payload]); - return self::SUCCESS; - } - - $rows = []; - foreach ($results as $id => $result) { - $rows[] = [$id, (string) $result->appliedCount()]; - } - $this->table() - ->headers(['Tenant', 'Applied']) - ->rows($rows) - ->render(); - $this->alertSuccess('All tenants refreshed', ['Tenants: ' . count($results)]); - return self::SUCCESS; - } -} \ No newline at end of file diff --git a/src/Commands/Migrate/TenantMigrateResetCommand.php b/src/Commands/Migrate/TenantMigrateResetCommand.php deleted file mode 100644 index 1bfcc16..0000000 --- a/src/Commands/Migrate/TenantMigrateResetCommand.php +++ /dev/null @@ -1,63 +0,0 @@ -name = 'tenant:reset'; - $this->description = 'Reset (rollback all) migrations across one or all tenants — DESTRUCTIVE'; - $this->registerTenantOptions(); - } - - protected function handle(): int - { - $runner = $this->tenantRunner(); - $tenant = $this->selectedTenant(); - - if ($tenant !== null) { - $this->info("Resetting tenant: {$tenant}"); - $result = $runner->resetForTenant($tenant); - - if ($this->wantsJson()) { - $this->emitJson([ - 'tenant' => $tenant, - 'result' => (new JsonResultPresenter())->resultData($result), - ]); - return self::SUCCESS; - } - - $this->alertSuccess('Tenant reset', [ - "Tenant: {$tenant}", - "Rolled back: " . count((array) $result->rolledBack), - ]); - return self::SUCCESS; - } - - $this->info('Resetting ALL tenants…'); - $results = []; - foreach (array_keys($runner->statusForAllTenants()) as $id) { - $results[$id] = $runner->resetForTenant($id); - } - - if ($this->wantsJson()) { - $payload = []; - foreach ($results as $id => $result) { - $payload[$id] = (new JsonResultPresenter())->resultData($result); - } - $this->emitJson(['tenants' => $payload]); - return self::SUCCESS; - } - - $this->alertSuccess('All tenants reset', ['Tenants: ' . count($results)]); - return self::SUCCESS; - } -} \ No newline at end of file diff --git a/src/Commands/Migrate/TenantMigrateRollbackCommand.php b/src/Commands/Migrate/TenantMigrateRollbackCommand.php deleted file mode 100644 index 5fb66d9..0000000 --- a/src/Commands/Migrate/TenantMigrateRollbackCommand.php +++ /dev/null @@ -1,80 +0,0 @@ -name = 'tenant:rollback'; - $this->description = 'Roll back the last N migration batches across one or all tenants'; - $this->registerTenantOptions(); - $this->addOption('steps', 's', - 'Number of batches to roll back', - acceptsValue: true, default: '1'); - } - - protected function handle(): int - { - $runner = $this->tenantRunner(); - $tenant = $this->selectedTenant(); - $steps = max(1, (int) $this->option('steps', '1')); - - if ($tenant !== null) { - $this->info("Rolling back tenant: {$tenant} (steps: {$steps})"); - $result = $runner->rollbackForTenant($tenant, $steps); - - if ($this->wantsJson()) { - $this->emitJson([ - 'tenant' => $tenant, - 'steps' => $steps, - 'result' => (new JsonResultPresenter())->resultData($result), - ]); - return self::SUCCESS; - } - - $this->alertSuccess('Rollback complete', [ - "Tenant: {$tenant}", - "Rolled back: " . count((array) $result->rolledBack), - ]); - return self::SUCCESS; - } - - // --all - $this->info("Rolling back ALL tenants (steps: {$steps})…"); - $results = []; - foreach (array_keys($this->tenantRunner()->statusForAllTenants()) as $id) { - $results[$id] = $runner->rollbackForTenant($id, $steps); - } - - if ($this->wantsJson()) { - $payload = []; - foreach ($results as $id => $result) { - $payload[$id] = (new JsonResultPresenter())->resultData($result); - } - $this->emitJson(['tenants' => $payload, 'steps' => $steps]); - return self::SUCCESS; - } - - $rows = []; - foreach ($results as $id => $result) { - $rows[] = [$id, (string) count((array) $result->rolledBack)]; - } - $this->table() - ->headers(['Tenant', 'Rolled back']) - ->rows($rows) - ->render(); - $this->alertSuccess('All tenants rolled back', [ - 'Tenants: ' . count($results), - 'Steps: ' . $steps, - ]); - return self::SUCCESS; - } -} \ No newline at end of file diff --git a/src/Commands/Migrate/TenantMigrateRunCommand.php b/src/Commands/Migrate/TenantMigrateRunCommand.php deleted file mode 100644 index d4afffc..0000000 --- a/src/Commands/Migrate/TenantMigrateRunCommand.php +++ /dev/null @@ -1,73 +0,0 @@ -name = 'tenant:migrate'; - $this->description = 'Run migrations across one or all tenants'; - $this->registerTenantOptions(); - } - - protected function handle(): int - { - $runner = $this->tenantRunner(); - $tenant = $this->selectedTenant(); - - if ($tenant !== null) { - $this->info("Migrating tenant: {$tenant}"); - $result = $runner->runForTenant($tenant); - - if ($this->wantsJson()) { - $this->emitJson([ - 'tenant' => $tenant, - 'result' => (new JsonResultPresenter())->resultData($result), - ]); - return self::SUCCESS; - } - - $this->alertSuccess('Tenant migrated', [ - "Tenant: {$tenant}", - "Applied: {$result->appliedCount()}", - "Batch: {$result->batch}", - ]); - return self::SUCCESS; - } - - // --all - $this->info('Migrating ALL tenants…'); - $results = $runner->runForAllTenants(); - - if ($this->wantsJson()) { - $payload = []; - foreach ($results as $id => $result) { - $payload[$id] = (new JsonResultPresenter())->resultData($result); - } - $this->emitJson(['tenants' => $payload, 'count' => count($results)]); - return self::SUCCESS; - } - - $rows = []; - foreach ($results as $id => $result) { - $rows[] = [$id, (string) $result->appliedCount(), (string) $result->batch]; - } - $this->table() - ->headers(['Tenant', 'Applied', 'Batch']) - ->rows($rows) - ->render(); - $this->alertSuccess('All tenants migrated', [ - 'Tenants: ' . count($results), - ]); - return self::SUCCESS; - } -} \ No newline at end of file diff --git a/src/Commands/Migrate/TenantMigrateStatusCommand.php b/src/Commands/Migrate/TenantMigrateStatusCommand.php deleted file mode 100644 index 1b86dce..0000000 --- a/src/Commands/Migrate/TenantMigrateStatusCommand.php +++ /dev/null @@ -1,73 +0,0 @@ -name = 'tenant:status'; - $this->description = 'Show migration status across one or all tenants'; - $this->registerTenantOptions(); - } - - protected function handle(): int - { - $runner = $this->tenantRunner(); - $tenant = $this->selectedTenant(); - - if ($tenant !== null) { - $status = $runner->statusForTenant($tenant); - - if ($this->wantsJson()) { - $this->emitJson(['tenant' => $tenant, 'status' => $status]); - return self::SUCCESS; - } - - $this->section("Status for tenant: {$tenant}"); - $rows = []; - foreach ($status as $name => $row) { - $rows[] = [ - $name, - (string) ($row['status'] ?? '?'), - $row['batch'] !== null ? (string) $row['batch'] : '—', - ]; - } - $this->table() - ->headers(['Migration', 'Status', 'Batch']) - ->rows($rows) - ->render(); - return self::SUCCESS; - } - - $all = $runner->statusForAllTenants(); - - if ($this->wantsJson()) { - $this->emitJson(['tenants' => $all]); - return self::SUCCESS; - } - - // Render a flat aggregate: tenant + migration name + status + batch - foreach ($all as $id => $status) { - $this->section("Tenant: {$id}"); - $rows = []; - foreach ($status as $name => $row) { - $rows[] = [ - $name, - (string) ($row['status'] ?? '?'), - $row['batch'] !== null ? (string) $row['batch'] : '—', - ]; - } - $this->table() - ->headers(['Migration', 'Status', 'Batch']) - ->rows($rows) - ->render(); - } - return self::SUCCESS; - } -} \ No newline at end of file diff --git a/src/Kernel/Boot/BootPipeline.php b/src/Kernel/Boot/BootPipeline.php index 7aadeb1..a4df4f6 100644 --- a/src/Kernel/Boot/BootPipeline.php +++ b/src/Kernel/Boot/BootPipeline.php @@ -31,8 +31,24 @@ */ final class BootPipeline { - /** @var list Ordered boot stages — order is fixed and meaningful. */ - private array $stages; + /** + * Stages that COMPILE — they read module.json / config and write manifests. + * Skippable when BootStamp says the manifests are already current. + * + * @var list + */ + private array $compileStages; + + /** + * Stages that VALIDATE live objects (port bindings, security layers). They + * touch no disk, produce no manifest and cost nothing, so they run on EVERY + * build — a cached boot must still refuse a missing port. + * + * @var list + */ + private array $validateStages; + + private ManifestReader $reader; /** * @param list $moduleClasses @@ -43,6 +59,12 @@ final class BootPipeline * @param list $disabledRoutes * Project route-disable policy (Kernel::withRoutePolicy). "METHOD /path" or a * module domain; applied to plugin routes before project routes are compiled. + * @param array $projectGroups + * Project route GROUPS + source-wide route defaults (Kernel::withRouteGroups). + * Expanded into flat routes by the route-manifest compiler. + * @param list $projectDomains + * Hosts this project serves (proj.json "domains"). A route grouped under a + * host that is not registered fails the boot — it could never be reached. */ public function __construct( private readonly array $moduleClasses, @@ -50,29 +72,42 @@ public function __construct( array $securityLayers = [], array $projectRoutes = [], array $disabledRoutes = [], + array $projectGroups = [], + array $projectDomains = [], + ?ManifestReader $reader = null, ) { // Single reader shared across every manifest-reading stage: each module.json // (the single source of truth) is read + JSON-decoded ONCE and cached, instead // of once per stage. The cache populates on the first stage to touch a module - // and every later stage hits it. - $reader = new ManifestReader(); + // and every later stage hits it. The caller may pass its own so it can reuse + // the same cache (and ask which files were read) afterwards. + $this->reader = $reader ??= new ManifestReader(); - $this->stages = [ + $this->compileStages = [ new ValidateConfigStage($moduleClasses, reader: $reader), // 1. env vars present + typed new DetectConflictsStage($moduleClasses, reader: $reader), // 2. no two modules share solves() new DetectCyclesStage($moduleClasses, reader: $reader), // 3. no circular requires[] chains - new CompileServiceManifestStage($moduleClasses, projectRoutes: $projectRoutes, reader: $reader), // 4. dep graph → service-manifest.php - new CompileRouteManifestStage($moduleClasses, projectRoutes: $projectRoutes, disabledRoutes: $disabledRoutes, reader: $reader), // 5. routes[] → route-manifest.php + new CompileServiceManifestStage($moduleClasses, projectRoutes: $projectRoutes, reader: $reader, projectGroups: $projectGroups), // 4. dep graph → service-manifest.php + new CompileRouteManifestStage($moduleClasses, projectRoutes: $projectRoutes, disabledRoutes: $disabledRoutes, reader: $reader, projectGroups: $projectGroups, projectDomains: $projectDomains), // 5. routes[] → route-manifest.php new CompileViewManifestStage($moduleClasses, reader: $reader), // 6. views[] → view-manifest.php (project-first cascade) new CompileLangManifestStage($moduleClasses, reader: $reader), // 7. lang[] → lang-manifest.php (project-first cascade) new CompileJobManifestStage($moduleClasses, reader: $reader), // 8. jobs[] → job-manifest.php new CompileCommandManifestStage($moduleClasses, reader: $reader), // 9. commands[] → command-manifest.php new CompileConfigManifestStage($moduleClasses), // 10. config/*.php → config-manifest.php (project over plugin) + ]; + + $this->validateStages = [ new RegisterPortsStage($core), // 11. Port → Adapter bindings validated new BindSecurityStage($securityLayers), // 12. SecurityGateway layers validated ]; } + /** The reader the compile stages used — ask it which files they read. */ + public function reader(): ManifestReader + { + return $this->reader; + } + /** * Run all stages in order. Fail fast on any error. * @@ -80,7 +115,25 @@ public function __construct( */ public function run(): void { - foreach ($this->stages as $stage) { + $this->runStages([...$this->compileStages, ...$this->validateStages]); + } + + /** + * Run ONLY the stages that validate live objects. + * + * Used when BootStamp reports the compiled manifests are already current: the + * compilation is skipped, but a missing port binding or an unusable security + * layer must still fail the boot. + */ + public function runValidationOnly(): void + { + $this->runStages($this->validateStages); + } + + /** @param list $stages */ + private function runStages(array $stages): void + { + foreach ($stages as $stage) { try { $stage->run(); } catch (BootException $e) { diff --git a/src/Kernel/Boot/BootStamp.php b/src/Kernel/Boot/BootStamp.php new file mode 100644 index 0000000..e7b5d35 --- /dev/null +++ b/src/Kernel/Boot/BootStamp.php @@ -0,0 +1,176 @@ + $inputs + */ + public static function hash(array $inputs): string + { + return hash('sha256', serialize($inputs)); + } + + /** + * The compiled manifests are current for these inputs. + * + * @return array{essentials: list}|null the cached derivations, or + * null when a real compile is required + */ + public static function read(string $configHash): ?array + { + if (!is_file(Paths::cache(self::SENTINEL))) { + return null; + } + + $stamp = ManifestReader::readCompiled(self::FILE); + + if (($stamp['config'] ?? null) !== $configHash) { + return null; + } + + foreach ($stamp['files'] ?? [] as $path => $signature) { + if (self::signature((string) $path) !== $signature) { + return null; + } + } + + // A config file ADDED since the last compile appears in no entry above, + // so compare how many each watched directory holds. + foreach ($stamp['dirs'] ?? [] as $dir => $count) { + if (self::countPhp((string) $dir) !== $count) { + return null; + } + } + + return ['essentials' => $stamp['essentials'] ?? []]; + } + + /** + * Record a successful compile. + * + * @param list $sourceFiles module.json paths the compile read + * @param list $essentials resolved essential-module classes + */ + public static function write(string $configHash, array $sourceFiles, array $essentials): void + { + $files = []; + $dirs = []; + + foreach ($sourceFiles as $file) { + $files[$file] = self::signature($file); + + // Each module.json sits beside the module's own config/ directory, + // which CompileConfigManifestStage globs. + self::watchConfigDir(dirname($file) . '/config', $files, $dirs); + } + + self::watchConfigDir(Paths::config(), $files, $dirs); + + ManifestWriter::write(self::FILE, [ + 'config' => $configHash, + 'files' => $files, + 'dirs' => $dirs, + 'essentials' => array_values($essentials), + ]); + } + + /** + * @param array $files + * @param array $dirs + */ + private static function watchConfigDir(string $dir, array &$files, array &$dirs): void + { + if (!is_dir($dir)) { + return; + } + + $found = glob(rtrim($dir, '/') . '/*.php') ?: []; + + $dirs[$dir] = count($found); + + foreach ($found as $file) { + $files[$file] = self::signature($file); + } + } + + /** mtime:size, or '' when the file is gone — which invalidates. */ + private static function signature(string $path): string + { + $stat = @stat($path); + + return $stat === false ? '' : $stat['mtime'] . ':' . $stat['size']; + } + + private static function countPhp(string $dir): int + { + return is_dir($dir) ? count(glob(rtrim($dir, '/') . '/*.php') ?: []) : -1; + } +} diff --git a/src/Kernel/Boot/ManifestReader.php b/src/Kernel/Boot/ManifestReader.php index d587366..95a0caa 100644 --- a/src/Kernel/Boot/ManifestReader.php +++ b/src/Kernel/Boot/ManifestReader.php @@ -15,6 +15,16 @@ final class ManifestReader /** @var array> */ private array $cache = []; + /** + * Absolute module.json paths this reader has read, in first-seen order. + * + * BootStamp records them (with mtime+size) so a later build can tell whether + * anything the compile depended on has actually changed. + * + * @var array + */ + private array $files = []; + /** * @param class-string $moduleClass * @return array @@ -37,6 +47,8 @@ public function read(string $moduleClass): array throw new BootException("module.json not found for [{$moduleClass}] — expected at {$path}"); } + $this->files[$path] = true; + $raw = file_get_contents($path); $decoded = $raw !== false ? json_decode($raw, true) : null; if (!is_array($decoded)) { @@ -46,6 +58,16 @@ public function read(string $moduleClass): array return $this->cache[$moduleClass] = $decoded; } + /** + * Every module.json this reader has read, absolute paths. + * + * @return list + */ + public function files(): array + { + return array_keys($this->files); + } + /** * Read a COMPILED manifest written by {@see ManifestWriter} (route-manifest.php, * config-manifest.php, …). The counterpart to ManifestWriter::write(). diff --git a/src/Kernel/Boot/Stages/CompileRouteManifestStage.php b/src/Kernel/Boot/Stages/CompileRouteManifestStage.php index a795c21..0fa1c6d 100644 --- a/src/Kernel/Boot/Stages/CompileRouteManifestStage.php +++ b/src/Kernel/Boot/Stages/CompileRouteManifestStage.php @@ -3,14 +3,37 @@ namespace AlfacodeTeam\PhpServicePlatform\Kernel\Boot\Stages; use AlfacodeTeam\PhpServicePlatform\Kernel\Boot\{BootException, ManifestReader, ManifestWriter}; -use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\RouteParameter; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\{RouteIndex, RouteParameter}; -/** Reads routes[] from every module.json -> route-manifest.php (OPcache-cached). */ +/** + * Reads routes[] from every module.json -> route-manifest.php (OPcache-cached). + * + * THREE ARTEFACTS, ONE COMPILATION + * -------------------------------- + * route-manifest.php the canonical flat map, `"METHOD /path" => entry`. Its + * shape is PUBLIC (RouteCatalog, tooling and tests read + * it), so it only ever gains keys, never changes shape. + * route-index.php the matcher's ready-to-use index: static table, per-method + * first-segment buckets, and a precompiled anchored regex + + * parameter list per dynamic route. Everything RouteMatcher + * used to derive on every worker's first request. + * route-names.php name => {path, method}. Lets UrlGenerator build URLs + * without loading the whole route table — the difference + * between a worker that mints one email link and one that + * holds the entire routing surface in memory. + * + * Both derived files are OPTIONAL at runtime: every consumer falls back to + * deriving from route-manifest.php, so a stale deploy that predates them still + * boots and serves. + */ final class CompileRouteManifestStage implements BootStageContract { /** Synthetic scope for project-layer routes (no owning module). */ public const PROJECT_SCOPE = '__project__'; + /** Guard against a self-referencing groups[] structure. */ + private const MAX_GROUP_DEPTH = 16; + /** * @param list $moduleClasses * @param list $projectRoutes @@ -27,6 +50,16 @@ public function __construct( private readonly array $projectRoutes = [], private readonly array $disabledRoutes = [], private readonly ManifestReader $reader = new ManifestReader(), + private readonly array $projectGroups = [], + /** + * Hosts this project serves — proj.json "domains", via + * Kernel::withProjectDomains(). A route grouped under a host that is not + * in here could never be reached, so it fails the boot. Empty (a project + * that registers no domains) disables the check entirely. + * + * @var list + */ + private readonly array $projectDomains = [], ) {} public function run(): void @@ -61,37 +94,43 @@ public function run(): void // consistent with project routes. foreach ($this->moduleClasses as $moduleClass) { $manifest = $manifests[$moduleClass]; - foreach ($manifest['routes'] ?? [] as $route) { - if (!isset($route['method'], $route['path'], $route['handler'])) { - throw new BootException( - "Invalid route in [{$moduleClass}] - each route needs method, path and handler." - ); - } - if (!str_contains($route['handler'], '@')) { - throw new BootException( - "Route handler [{$route['handler']}] in [{$moduleClass}] must be in 'Controller@method' format." - ); - } - $this->validateParameterTypes($route['path'], "Route in [{$moduleClass}]"); - $key = strtoupper($route['method']) . ' ' . $route['path']; + // Module-wide route defaults. "routePrefix" is prepended to every + // path the module declares and "routeFilters" is merged in FRONT of + // each route's own filters[], so an admin plugin declares `auth` + // once instead of on all forty routes. Both are optional and absent + // by default, so existing module.json files compile identically. + foreach ($this->flatten($manifest, "[{$moduleClass}]") as $route) { + $path = $route['path']; + $key = RouteIndex::key($route['method'], $route['domain'], $path); + if (isset($routes[$key])) { throw new BootException( "Duplicate route [{$key}] declared by [{$moduleClass}] and [{$routes[$key]['module']}]." ); } + + $filters = $route['filters']; + $requires = $this->validateRequires( + $route['requires'], + $knownDomains, + "Route [{$key}] in [{$moduleClass}]", + ); + $routes[$key] = [ 'handler' => $route['handler'], 'module' => $moduleClass, 'solves' => $manifest['solves'], 'name' => $this->routeName($route, $key, $names, "[{$moduleClass}]"), - 'filters' => $this->normalizeFilters($route['filters'] ?? []), - 'requires' => $this->validateRequires( - $this->normalizeRequires($route['requires'] ?? []), - $knownDomains, - "Route [{$key}] in [{$moduleClass}]", - ), - ]; + 'filters' => $filters, + 'requires' => $requires, + 'faces' => $route['faces'], + 'domain' => $route['domain'], + ] + $this->precompile($route['handler'], $path, $manifest['solves'], $filters, "Route [{$key}] in [{$moduleClass}]"); + + // requires[] is validated above but precompile() ran before it was + // stored; recompute the graph key now that the final list is known. + $routes[$key]['graph_key'] = $manifest['solves'] . '|' . implode(',', $requires); } } @@ -115,24 +154,24 @@ public function run(): void // any module.json. They carry no module and resolve under the synthetic // PROJECT_SCOPE, whose dependency graph is empty — so route-level // requires[] is the ONLY way a project page pulls in a plugin. - foreach ($this->projectRoutes as $route) { - if (!isset($route['method'], $route['path'], $route['handler'])) { - throw new BootException( - 'Invalid project route - each route needs method, path and handler.' - ); - } - if (!str_contains($route['handler'], '@')) { - throw new BootException( - "Project route handler [{$route['handler']}] must be in 'Controller@method' format." - ); - } + // withRoutes() routes and any routes[] declared alongside the groups are + // BOTH the project's, so they concatenate. A `+` union here would have + // let one silently drop the other — array union keeps the LEFT key, so a + // routes[] passed to withRouteGroups() would have vanished without a word. + $projectSource = $this->projectGroups; + $projectSource['routes'] = [ + ...$this->projectRoutes, + ...(is_array($projectSource['routes'] ?? null) ? $projectSource['routes'] : []), + ]; + + foreach ($this->flatten($projectSource, 'the project') as $route) { // DETERMINISTIC PRIORITY: project routes are compiled AFTER every // plugin route and OVERRIDE a plugin route declaring the same // "METHOD path". This is the default project-over-plugin precedence — // never the reverse. Plugins cannot reclaim a route the project owns. - $this->validateParameterTypes($route['path'], 'Project route'); + $path = $route['path']; - $key = strtoupper($route['method']) . ' ' . $route['path']; + $key = RouteIndex::key($route['method'], $route['domain'], $path); // A project override INHERITS the overridden plugin route's name // unless it declares its own. Overriding changes where a name points, @@ -148,26 +187,621 @@ public function run(): void $declared = $inherited; } + $filters = $route['filters']; + $requires = $this->validateRequires($route['requires'], $knownDomains, "Project route [{$key}]"); + $routes[$key] = [ 'handler' => $route['handler'], 'module' => null, 'solves' => self::PROJECT_SCOPE, 'name' => $declared, 'overrides' => $routes[$key]['module'] ?? null, - 'filters' => $this->normalizeFilters($route['filters'] ?? []), + 'filters' => $filters, // Per-route module dependencies seeded into this request's graph // by LoadStage. Each must name a real module domain — fail at boot. - 'requires' => $this->validateRequires( + 'requires' => $requires, + 'faces' => $route['faces'], + 'domain' => $route['domain'], + ] + $this->precompile($route['handler'], $path, self::PROJECT_SCOPE, $filters, "Project route [{$key}]"); + + $routes[$key]['graph_key'] = self::PROJECT_SCOPE . '|' . implode(',', $requires); + } + + ManifestWriter::write('route-manifest.php', $routes); + ManifestWriter::write('route-index.php', RouteIndex::build($routes)); + ManifestWriter::write('route-names.php', RouteIndex::names($routes)); + } + + // ── Groups ─────────────────────────────────────────────────────────────── + + /** + * Flatten a route declaration source — a module.json, a proj.json, or the + * array passed to Kernel::withRoutes() — into a plain list of fully-resolved + * routes. + * + * A source may declare routes directly, and/or nest them in `groups[]`, which + * may nest further: + * + * "routePrefix": "/api", // source-wide defaults + * "routeFilters": ["auth"], + * "routeRequires":["view.rendering"], + * "routeDomain": "africavoting.local", + * "groups": [ + * { "prefix": "/admin", "filters": ["throttle:30,1"], "name": "admin.", + * "subdomain": "admin", "routes": [ … ], "groups": [ … ] } + * ] + * + * A group exists to say a thing ONCE that would otherwise be repeated on every + * route inside it. The whole expansion happens here, at boot — the manifest, + * the matcher and every request-time stage only ever see flat routes, so + * grouping costs exactly nothing at runtime. + * + * INHERITANCE + * prefix concatenated outward-in + * name concatenated outward-in, prefixed onto each route's own name + * filters merged, de-duplicated BY ALIAS — inner wins, so a group's + * "throttle:60,1" is replaced (not doubled) by a route's "throttle:5,1" + * requires union + * domain inner overrides outer (written literally — a host, + * "*.wildcard", or a bare subdomain; grouped VERBATIM) + * faces inner overrides outer when non-empty + * + * Nothing SUBTRACTS. A group cannot strip a filter an outer group added: + * removal is the project's prerogative and lives in routePolicy.disable, which + * is the single place authorised to veto. + * + * @param array $source + * @return list, requires: list, domain: string, faces: list}> + */ + private function flatten(array $source, string $owner): array + { + $scope = [ + 'prefix' => $this->normalizePrefix($source['routePrefix'] ?? '', $owner), + 'name' => $this->stringOrEmpty($source['routeName'] ?? '', $owner, 'routeName'), + 'filters' => $this->normalizeFilters($source['routeFilters'] ?? [], $owner), + 'requires' => $this->normalizeRequires($source['routeRequires'] ?? []), + 'domain' => '', + 'faces' => $this->normalizeFaces($source['routeFaces'] ?? []), + ]; + + // The module-wide domain takes a list too, so the three levels that can + // name a host — module-wide, group, route — all behave the same way. + // One of them quietly refusing a list is the kind of inconsistency that + // is only ever discovered by it not working. + $flat = []; + foreach ($this->domainsFor($source, $scope, $owner, 'routeDomain', 'routeSubdomain') as $domain) { + $scope['domain'] = $domain; + $flat = [...$flat, ...$this->flattenInto($source, $scope, $owner, 0)]; + } + + return $flat; + } + + /** + * @param array $source + * @param array{prefix: string, name: string, filters: list, requires: list, domain: string, faces: list} $inherited + * @return list> + */ + private function flattenInto(array $source, array $inherited, string $owner, int $depth): array + { + if ($depth > self::MAX_GROUP_DEPTH) { + throw new BootException( + "Route groups in {$owner} nest more than " . self::MAX_GROUP_DEPTH . ' levels deep. ' + . 'That is almost always a self-referencing structure rather than an intended hierarchy.' + ); + } + + $flat = []; + + foreach ($source['routes'] ?? [] as $route) { + if (!is_array($route) || !isset($route['method'], $route['path'], $route['handler'])) { + throw new BootException( + "Invalid route in {$owner} - each route needs method, path and handler." + ); + } + + $path = $this->normalizePath( + $inherited['prefix'] . (string) $route['path'], + "Route in {$owner}", + ); + + $name = $this->stringOrEmpty($route['name'] ?? '', $owner, 'name'); + + $entry = [ + 'method' => strtoupper(trim((string) $route['method'])), + 'path' => $path, + 'handler' => (string) $route['handler'], + // An unnamed route stays unnamed: a group's name prefix labels + // routes that opted into a name, it does not invent names. + 'name' => $name === '' ? null : $inherited['name'] . $name, + 'filters' => $this->mergeFilters( + $inherited['filters'], + $this->normalizeFilters($route['filters'] ?? [], "Route in {$owner}"), + ), + 'requires' => $this->mergeRequires( + $inherited['requires'], $this->normalizeRequires($route['requires'] ?? []), - $knownDomains, - "Project route [{$key}]", ), + 'domain' => $inherited['domain'], + 'faces' => $this->normalizeFaces($route['faces'] ?? []) ?: $inherited['faces'], ]; + + $domains = $this->domainsFor($route, $inherited, "Route in {$owner}"); + + // A NAMED route on several domains would claim one name several + // times. Names are a flat, application-wide namespace on purpose — + // UrlGenerator holds no request state, so it cannot pick a host — + // and the duplicate-name guard would otherwise report this later + // without explaining the cause. + if ($name !== '' && count($domains) > 1) { + throw new BootException(sprintf( + 'Route [%s] in %s names itself [%s] while declaring %d domains. ' + . 'Route names are one flat namespace, so one name cannot mean a ' + . 'different URL per host. Give each domain its own entry with a ' + . 'distinct name, or drop the name.', + $path, + $owner, + $inherited['name'] . $name, + count($domains), + )); + } + + // One flat route per domain. A single string yields one, exactly as + // before; a list yields one copy per host, each with its own route + // key, which is what makes them independently overridable. + foreach ($domains as $domain) { + $entry['domain'] = $domain; + $flat[] = $entry; + } } - ManifestWriter::write('route-manifest.php', $routes); + foreach ($source['groups'] ?? [] as $group) { + if (!is_array($group)) { + throw new BootException("Invalid route group in {$owner} - a group must be an object."); + } + + $context = "Route group in {$owner}"; + + $scope = [ + 'prefix' => $inherited['prefix'] + . $this->normalizePrefix($group['prefix'] ?? '', $context), + 'name' => $inherited['name'] + . $this->stringOrEmpty($group['name'] ?? '', $owner, 'group name'), + 'filters' => $this->mergeFilters( + $inherited['filters'], + $this->normalizeFilters($group['filters'] ?? [], $context), + ), + 'requires' => $this->mergeRequires( + $inherited['requires'], + $this->normalizeRequires($group['requires'] ?? []), + ), + 'domain' => $inherited['domain'], + 'faces' => $this->normalizeFaces($group['faces'] ?? []) ?: $inherited['faces'], + ]; + + // Expand the WHOLE subtree once per domain. Nested groups and routes + // inherit the one host they are being expanded for, so a list at any + // level composes with a list at any other. + foreach ($this->domainsFor($group, $inherited, $context) as $domain) { + $scope['domain'] = $domain; + $flat = [...$flat, ...$this->flattenInto($group, $scope, $owner, $depth + 1)]; + } + } + + return $flat; } + /** + * The DOMAIN a group of routes answers on, taken verbatim. + * + * "domain": "africavoting.local" a host + * "domain": "*.africavoting.local" a wildcard + * "subdomain": "organizer" a bare label + * + * This GROUPS — it does not verify. The compiler does not resolve the string, + * look it up in any registry, or check that this deployment serves it: a + * domain nothing requests simply never matches, exactly like a path nothing + * requests. Only case and surrounding whitespace are normalised, plus the two + * characters that would break the route key round-trip ({@see + * RouteIndex::parseKey}) — a space and an '@', neither of which occurs in a + * hostname. + */ + private function normalizeDomain(mixed $domain): string + { + if (!is_string($domain)) { + return ''; + } + + $domain = strtolower(trim($domain)); + + return str_contains($domain, ' ') || str_contains($domain, RouteIndex::DOMAIN_SEPARATOR) + ? '' + : $domain; + } + + /** + * The domains a route or group answers on — ONE OR MORE. + * + * `"domain"` and `"subdomain"` accept either a single string or a LIST, so + * one group can serve several hosts without being written out N times: + * + * "domain": "shop.example.com" + * "domain": ["shop.example.com", "shop.example.co.uk", "*.tenant.example.com"] + * "subdomain": ["admin", "staff"] + * + * Each entry is grouped verbatim and validated independently, exactly as a + * single value is, and the caller emits one copy of the route per entry. + * Groups already expand at boot into flat routes, so this costs nothing at + * request time — it is the same expansion with a wider fan-out. + * + * A non-string, non-list value is a BOOT FAILURE. It used to fall through + * `is_string()` to '', which silently turned "these routes belong to these + * two hosts" into "these routes are global, on every host" — the widest + * possible outcome, arrived at by accident, with nothing logged. + * + * @return list normalised domains, de-duplicated. `['']` means the + * shared (every-domain) table. + */ + private function normalizeDomainList(mixed $domain, string $context): array + { + if (is_string($domain)) { + return [$this->normalizeDomain($domain)]; + } + + if (!is_array($domain)) { + throw new BootException(sprintf( + '%s declares a domain of type [%s]. Use a string ("shop.example.com") ' + . 'or a list of strings (["a.example.com", "b.example.com"]).', + $context, + get_debug_type($domain), + )); + } + + if ($domain === []) { + throw new BootException(sprintf( + '%s declares an empty domain list. Remove the key to serve every ' + . 'domain, or name at least one host.', + $context, + )); + } + + $out = []; + foreach ($domain as $entry) { + if (!is_string($entry)) { + throw new BootException(sprintf( + '%s has a non-string entry of type [%s] in its domain list.', + $context, + get_debug_type($entry), + )); + } + + $normalized = $this->normalizeDomain($entry); + if ($normalized === '') { + throw new BootException(sprintf( + '%s lists [%s] as a domain, which is not usable as one. A domain ' + . 'may not be blank or contain a space or an "%s".', + $context, + $entry, + RouteIndex::DOMAIN_SEPARATOR, + )); + } + + // A repeat would compile the same route twice under one key and trip + // the duplicate-route guard — reporting a conflict the author would + // have to work backwards to recognise as their own copy-paste. + if (!in_array($normalized, $out, true)) { + $out[] = $normalized; + } + } + + return $out; + } + + /** + * The domains a route, group or module compiles under — always at least one. + * + * Declaring neither key inherits the enclosing scope, which is what makes an + * ungrouped route global and a nested group stay on its parent's host. + * + * The key names are parameters because the module-wide form spells them + * `routeDomain`/`routeSubdomain` while routes and groups use + * `domain`/`subdomain` — the same rule, read from different keys. + * + * @param array $source the route, group or module object + * @param array $inherited the enclosing scope + * @return list + */ + private function domainsFor( + array $source, + array $inherited, + string $context, + string $domainKey = 'domain', + string $subdomainKey = 'subdomain', + ): array { + $hasDomain = isset($source[$domainKey]); + $hasSubdomain = isset($source[$subdomainKey]); + + if (!$hasDomain && !$hasSubdomain) { + return [(string) $inherited['domain']]; + } + + $parents = $hasDomain + ? $this->normalizeDomainList($source[$domainKey], $context) + : []; + $labels = $hasSubdomain + ? $this->normalizeDomainList($source[$subdomainKey], $context) + : []; + + // A declared `domain` is BOTH a host in its own right AND the parent that + // `subdomain` attaches to. + // + // { "domain": "hkm.local", "subdomain": ["api", "auth"] } + // → hkm.local, api.hkm.local, auth.hkm.local + // + // Without this, those labels compiled BARE — and a bare label spans every + // domain by design, so the group also answered on api.somebody-else.com. + // Reading "api under hkm.local" and getting "api under anything" is not a + // difference anyone spots until it is exploited. + // + // A label with NO domain to attach to keeps the global meaning, because + // there is nothing for it to be relative to — that is what makes an + // `admin` panel appear on every brand. + if ($parents === []) { + foreach ($labels as $label) { + $this->validateDomain($label, $context); + } + + return $labels; + } + + foreach ($parents as $parent) { + $this->validateDomain($parent, $context); + } + + // No labels to attach: the domains stand alone. Returning here also keeps + // the wildcard check below from firing on `{"domain": "*.example.com"}`, + // which composes nothing and is entirely valid on its own. + if ($labels === []) { + return $parents; + } + + $domains = $parents; + foreach ($parents as $parent) { + if (str_starts_with($parent, '*.')) { + throw new BootException(sprintf( + '%s attaches subdomain [%s] to wildcard domain [%s]. A wildcard ' + . 'already covers every label under it, so the two cannot compose. ' + . 'Drop the subdomain, or name the parent host literally.', + $context, + $labels[0], + $parent, + )); + } + + foreach ($labels as $label) { + // The composed host is DERIVED from a parent already validated + // above, and DomainResolver reaches this project by suffix match + // on that same parent — so it needs no registration of its own. + $composed = $label . '.' . $parent; + if (!in_array($composed, $domains, true)) { + $domains[] = $composed; + } + } + } + + return $domains; + } + + /** + * Check that a declared HOST is one this project actually serves. + * + * The registry is the project's own `proj.json` "domains" — the same list + * DomainResolver matches an incoming Host against to build a DomainContext. + * Grouping routes under a host the project never registered produces routes + * that can never be reached: the request would have been routed to a + * different project, or refused, long before the router saw it. That is the + * same silent-404 class as an unknown `{name:type}` or a dead disable spec, + * so it fails the boot with the list of hosts that WOULD have worked. + * + * TWO THINGS ARE DELIBERATELY NOT CHECKED: + * + * - A BARE SUBDOMAIN (`"subdomain": "api"`, anything with no dot). It is + * domain-agnostic ON PURPOSE — it answers on api.example.com AND + * api.example2.com AND any future host with that first label — so there + * is no single registered host to check it against. + * - Anything at all when proj.json declares no "domains". A project that + * does not register its hosts has no registry to validate against, and + * inventing one is exactly the indirection this design avoids. + * + * A WILDCARD (`*.africavoting.local`) passes when the parent is registered or + * when any registered host falls under it — which is what makes it the right + * tool for tenant hosts that are added to the database, not to proj.json. + */ + private function validateDomain(string $domain, string $context): void + { + // No dot ⇒ a bare subdomain label, which spans every domain by design. + if ($domain === '' || $this->projectDomains === [] || !str_contains($domain, '.')) { + return; + } + + $wildcard = str_starts_with($domain, '*.'); + $suffix = $wildcard ? substr($domain, 2) : ''; + + foreach ($this->projectDomains as $host) { + $host = strtolower(trim((string) $host)); + + if ($wildcard + ? ($host === $suffix || str_ends_with($host, '.' . $suffix)) + : $host === $domain) { + return; + } + } + + throw new BootException(sprintf( + '%s groups routes under domain [%s], which this project does not serve. ' + . 'Registered domains (proj.json "domains"): %s. Add it there, use a wildcard ' + . 'like [*.%s], or declare a bare "subdomain" if the routes should answer on ' + . 'every domain.', + $context, + $domain, + implode(', ', $this->projectDomains), + ltrim(strstr($domain, '.') ?: $domain, '.'), + )); + } + + private function stringOrEmpty(mixed $value, string $owner, string $what): string + { + if ($value === null || $value === false || $value === '') { + return ''; + } + + if (!is_string($value)) { + throw new BootException("{$owner} declares a non-string {$what}."); + } + + return $value; + } + + /** + * @param list $inherited + * @param list $own + * @return list + */ + private function mergeRequires(array $inherited, array $own): array + { + if ($inherited === []) { + return $own; + } + + return array_values(array_unique([...$inherited, ...$own])); + } + + // ── Precompilation ─────────────────────────────────────────────────────── + + /** + * Everything about a route that is constant, computed once at boot so no + * request has to derive it: the handler split, the parsed filter specs, the + * dependency-graph cache key, and (for a dynamic path) the anchored regex. + * + * @param list $filters + * @return array + */ + private function precompile(string $handler, string $path, string $solves, array $filters, string $context): array + { + if (substr_count($handler, '@') !== 1) { + throw new BootException( + "{$context} has handler [{$handler}] — it must be in 'Controller@method' format " + . '(exactly one "@").' + ); + } + + [$class, $action] = explode('@', $handler, 2); + + if ($class === '' || $action === '') { + throw new BootException( + "{$context} has handler [{$handler}] — both the controller class and the method are required." + ); + } + + $compiled = ['class' => $class, 'action' => $action]; + + $this->verifyHandler($class, $action, $handler, $context); + + $specs = []; + foreach ($filters as $spec) { + $specs[] = self::parseFilterSpec($spec); + } + $compiled['filter_specs'] = $specs; + $compiled['graph_key'] = $solves . '|'; + + if (str_contains($path, '{')) { + $this->validateParameterTypes($path, $context); + + try { + $route = RouteParameter::compile($path); + } catch (\InvalidArgumentException $e) { + // A pattern that PCRE refuses compiles to a route which makes + // preg_match() return false on EVERY request — a permanent silent + // 404 that reads as a missing controller. Same anti-typo policy as + // unknown types, unknown requires[] domains and dead disable specs. + throw new BootException("{$context}: " . $e->getMessage(), previous: $e); + } + + $compiled['regex'] = $route['regex']; + $compiled['params'] = $route['params']; + } + + return $compiled; + } + + /** + * OPT-IN: check that the handler class and method actually exist. + * + * Off by default because it forces the autoloader to load every controller in + * the application at boot — real cost on a cold FPM process, and pointless in + * production where the routes demonstrably worked when the build was cut. + * Turn it on in development and CI (`ROUTE_VERIFY_HANDLERS=1`) and a renamed + * action fails the build with the route that references it, instead of 500ing + * the first time someone visits that page. + */ + private function verifyHandler(string $class, string $action, string $handler, string $context): void + { + static $enabled = null; + + $enabled ??= \function_exists('env') + && filter_var(env('ROUTE_VERIFY_HANDLERS', false), FILTER_VALIDATE_BOOL); + + if ($enabled !== true) { + return; + } + + if (!class_exists($class)) { + throw new BootException("{$context} references controller [{$class}], which does not exist."); + } + + if (!method_exists($class, $action)) { + throw new BootException( + "{$context} references [{$handler}], but [{$class}] has no method [{$action}]." + ); + } + + if (!(new \ReflectionMethod($class, $action))->isPublic()) { + throw new BootException( + "{$context} references [{$handler}], but [{$action}] is not public — " + . 'the pipeline can only invoke public controller actions.' + ); + } + } + + /** + * "throttle:60,1" => ['alias' => 'throttle', 'args' => ['60', '1']] + * + * Shared with RouteFilterStage, which used to run this on every request for + * every filter on the matched route. + * + * @return array{alias: string, args: list} + */ + public static function parseFilterSpec(string $spec): array + { + $spec = trim($spec); + + if (!str_contains($spec, ':')) { + return ['alias' => $spec, 'args' => []]; + } + + [$alias, $rawArgs] = explode(':', $spec, 2); + + return [ + 'alias' => trim($alias), + 'args' => array_values(array_filter( + array_map('trim', explode(',', $rawArgs)), + static fn(string $a): bool => $a !== '', + )), + ]; + } + + // ── Validation ─────────────────────────────────────────────────────────── + /** * Ensure every declared dependency names a domain some module solves(), * failing fast at boot with a descriptive message instead of a request-time @@ -215,9 +849,13 @@ private function applyDisablePolicy(array $routes): array $matched = 0; if ($isRouteKey) { - // Normalize "get /register" → "GET /register". - [$method, $path] = preg_split('/\s+/', $spec, 2) ?: [$spec, '']; - $key = strtoupper($method) . ' ' . $path; + // Normalize "get /register" → "GET /register", and + // "get@organizer /x" → "GET@organizer /x" (the domain stays + // lower-case — only the HTTP method is upper-cased). + [$verb, $path] = preg_split('/\s+/', $spec, 2) ?: [$spec, '']; + $parsed = RouteIndex::parseKey($verb . ' ' . $path); + $key = RouteIndex::key($parsed['method'], strtolower($parsed['domain']), $parsed['path']); + if (isset($routes[$key])) { unset($routes[$key]); $matched = 1; @@ -245,13 +883,6 @@ private function applyDisablePolicy(array $routes): array return $routes; } - /** - * Normalize a route's declared filters to a clean list of string specs. - * Accepts a single string ("auth") or a list (["auth", "throttle:60"]). - * - * @param mixed $filters - * @return list - */ /** * Resolve and claim a route's optional `"name"`. * @@ -314,17 +945,140 @@ private function validateParameterTypes(string $path, string $context): void } } - private function normalizeFilters(mixed $filters): array + /** + * A route path must be absolute. `Request::path()` always starts with '/', so + * a path declared as "users" compiles to the key "GET users" and can never be + * matched — an invisible dead endpoint. Fail loudly instead of prepending the + * slash, which would silently PUBLISH an endpoint the author believed was + * already live. + */ + private function normalizePath(string $path, string $context): string + { + if ($path === '' || $path[0] !== '/') { + throw new BootException( + "{$context} declares path [{$path}] which does not start with '/'. " + . 'Request paths are always absolute, so this route could never match.' + ); + } + + return $path; + } + + /** A module-wide route prefix: '' or an absolute path with no trailing slash. */ + private function normalizePrefix(mixed $prefix, string $context): string + { + if ($prefix === null || $prefix === '' || $prefix === false) { + return ''; + } + + if (!is_string($prefix)) { + throw new BootException("{$context} declares a non-string routePrefix."); + } + + $prefix = rtrim(trim($prefix), '/'); + + if ($prefix === '') { + return ''; + } + + if ($prefix[0] !== '/') { + throw new BootException( + "{$context} declares routePrefix [{$prefix}] which does not start with '/'." + ); + } + + return $prefix; + } + + /** + * Normalize a route's declared filters to a clean list of string specs. + * Accepts a single string ("auth") or a list (["auth", "throttle:60"]). + * + * @return list + */ + private function normalizeFilters(mixed $filters, string $context = 'A route'): array { if (is_string($filters)) { $filters = [$filters]; } + if ($filters === null || $filters === []) { + return []; + } if (!is_array($filters)) { + throw new BootException( + "{$context} declares filters that are neither a string nor a list." + ); + } + + $normalized = []; + foreach ($filters as $filter) { + if (!is_string($filter) && !is_int($filter) && !is_float($filter)) { + // Previously this hit "(string) $array" and produced the literal + // filter alias "Array", which then failed at request time. + throw new BootException( + "{$context} declares a filter that is not a string — filters are " + . 'aliases like "auth" or "throttle:60,1".' + ); + } + $filter = trim((string) $filter); + if ($filter !== '') { + $normalized[] = $filter; + } + } + + return $normalized; + } + + /** + * Module defaults first, then the route's own, de-duplicated by ALIAS so a + * route can re-declare "throttle:5,1" to override the module's "throttle:60,1" + * rather than running the stage twice. + * + * @param list $defaults + * @param list $own + * @return list + */ + private function mergeFilters(array $defaults, array $own): array + { + if ($defaults === []) { + return $own; + } + + $ownAliases = []; + foreach ($own as $spec) { + $ownAliases[self::parseFilterSpec($spec)['alias']] = true; + } + + $merged = []; + foreach ($defaults as $spec) { + if (!isset($ownAliases[self::parseFilterSpec($spec)['alias']])) { + $merged[] = $spec; + } + } + + return [...$merged, ...$own]; + } + + /** + * Optional face restriction — the project's DomainType values ('admin', 'api', + * …). Empty means "every face", which is what every existing route gets, so + * this is inert until a route opts in. The kernel stays domain-agnostic: it + * compares against the plain `route_face` request attribute and never imports + * the project's DomainContext. + * + * @return list + */ + private function normalizeFaces(mixed $faces): array + { + if (is_string($faces)) { + $faces = [$faces]; + } + if (!is_array($faces)) { return []; } return array_values(array_filter( - array_map(static fn($f): string => trim((string) $f), $filters), + array_map(static fn($f): string => strtolower(trim((string) $f)), $faces), static fn(string $f): bool => $f !== '', )); } diff --git a/src/Kernel/Boot/Stages/CompileServiceManifestStage.php b/src/Kernel/Boot/Stages/CompileServiceManifestStage.php index bdd7fba..01ca3e8 100644 --- a/src/Kernel/Boot/Stages/CompileServiceManifestStage.php +++ b/src/Kernel/Boot/Stages/CompileServiceManifestStage.php @@ -25,6 +25,8 @@ public function __construct( private readonly array $moduleClasses, private readonly array $projectRoutes = [], private readonly ManifestReader $reader = new ManifestReader(), + /** Project route groups — routes may be declared ONLY inside these. */ + private readonly array $projectGroups = [], ) {} public function run(): void @@ -73,7 +75,7 @@ public function run(): void // routes (Kernel::withRoutes). It has no module and no requires, so its // dependency graph is empty: the controller autowires from the request // container without running any module register(). - if ($this->projectRoutes !== []) { + if ($this->projectRoutes !== [] || ($this->projectGroups['groups'] ?? []) !== []) { $services[CompileRouteManifestStage::PROJECT_SCOPE] = [ 'name' => CompileRouteManifestStage::PROJECT_SCOPE, 'module' => null, diff --git a/src/Kernel/Kernel.php b/src/Kernel/Kernel.php index f9bc4b7..2073f97 100644 --- a/src/Kernel/Kernel.php +++ b/src/Kernel/Kernel.php @@ -3,7 +3,7 @@ namespace AlfacodeTeam\PhpServicePlatform\Kernel; -use AlfacodeTeam\PhpServicePlatform\Kernel\Boot\{BootException, BootPipeline, ManifestReader}; +use AlfacodeTeam\PhpServicePlatform\Kernel\Boot\{BootException, BootPipeline, BootStamp, ManifestReader}; use AlfacodeTeam\PhpServicePlatform\Kernel\Config\Repository as ConfigRepository; use AlfacodeTeam\PhpServicePlatform\Kernel\Container\CoreContainer; use AlfacodeTeam\PhpServicePlatform\Kernel\Contracts\ModuleContract; @@ -14,6 +14,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\HttpPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\{WorkerLoop, WorkerPipeline}; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\LoggerPort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\{RouteIndex, UrlGenerator}; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\{SecurityGateway, Contracts\SecurityLayerContract}; use AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths; @@ -47,6 +48,10 @@ final class Kernel private array $projectRoutes = []; /** @var array disable-spec => spec (de-duplicated, insertion order) */ private array $disabledRoutes = []; + /** @var array project route groups + source-wide route defaults */ + private array $projectGroups = []; + /** @var list hosts this project serves (proj.json "domains") */ + private array $projectDomains = []; private ?ErrorPipeline $errorPipeline = null; private ?\Closure $errorPipelineFun = null; private ?string $basePath = null; @@ -190,7 +195,83 @@ public function withEssentialModules(array $modules): self public function withRoutes(array $routes): self { foreach ($routes as $route) { - $this->projectRoutes[strtoupper($route['method'] ?? '') . ' ' . ($route['path'] ?? '')] = $route; + // The de-duplication key includes the DOMAIN group: two domains may + // legitimately declare the same "METHOD /path" with different + // handlers, and keying on method+path alone would silently drop one. + $domain = strtolower(trim((string) ($route['domain'] ?? $route['subdomain'] ?? ''))); + + $this->projectRoutes[RouteIndex::key( + (string) ($route['method'] ?? ''), + $domain, + (string) ($route['path'] ?? ''), + )] = $route; + } + return $this; + } + + /** + * Declare the project's route GROUPS and source-wide route defaults. + * + * A group states ONCE what would otherwise be repeated on every route inside + * it — a path prefix, filters, requires, a name prefix, and the DOMAIN the + * routes answer on. Groups may nest. + * + * ->withRouteGroups([ + * 'groups' => [ + * ['domain' => 'organizer', 'prefix' => '/dashboard', + * 'filters' => ['auth'], 'name' => 'organizer.', + * 'routes' => [ ... ]], + * ], + * ]) + * + * Because `domain` is part of the compiled route KEY, two domains may each + * define `GET /` with a different handler — this is how one project serves + * several brands, or grants a different access level per host, without + * forking. The domain is written literally (`africavoting.local`, + * `*.africavoting.local`, or a bare `organizer` subdomain) and is grouped + * verbatim: nothing resolves or validates it. + * + * The whole structure is expanded at BOOT into ordinary flat routes, so + * grouping costs nothing at request time. Merged shallowly with previous + * calls; `groups` accumulate so a base builder can contribute groups a child + * project extends. + * + * @param array $source + */ + public function withRouteGroups(array $source): self + { + $groups = [...($this->projectGroups['groups'] ?? []), ...($source['groups'] ?? [])]; + + $this->projectGroups = [...$this->projectGroups, ...$source]; + + if ($groups !== []) { + $this->projectGroups['groups'] = $groups; + } + + return $this; + } + + /** + * Declare the hosts this project serves — normally proj.json "domains", the + * same list DomainResolver matches an incoming Host against. + * + * Used to CHECK route domain groups at boot: grouping routes under a host the + * project never registered produces routes nothing can reach, because such a + * request would have been routed to another project (or refused) long before + * the router saw it. Declaring nothing here disables the check. + * + * A bare `"subdomain"` group is never checked — it answers on that label + * across every domain, so there is no single host to check it against. + * + * @param list $domains + */ + public function withProjectDomains(array $domains): self + { + foreach ($domains as $domain) { + $domain = strtolower(trim((string) $domain)); + if ($domain !== '' && !in_array($domain, $this->projectDomains, true)) { + $this->projectDomains[] = $domain; + } } return $this; } @@ -259,23 +340,73 @@ public function build(): self // deferred to materialize(), driven by whichever entry point is actually // used. A process that only serves HTTP never pays to build the worker // surface, and vice versa. - (new BootPipeline( + $reader = new ManifestReader(); + $pipeline = new BootPipeline( $this->moduleClasses, $this->core, $this->securityLayers, array_values($this->projectRoutes), array_values($this->disabledRoutes), - ))->run(); + $this->projectGroups, + $this->projectDomains, + $reader, + ); + + // BOOT CACHE (opt-in). Under PHP-FPM every request re-runs this method, + // recompiling manifests that are byte-identical to the last request's. + // When BOOT_CACHE is on and nothing the compile read has changed, skip + // the compilation and keep only the validation stages, which touch no + // disk and must still catch a missing port or an unusable layer. + $cached = BootStamp::enabled() ? BootStamp::read($this->buildHash()) : null; + + if ($cached !== null) { + $pipeline->runValidationOnly(); + // Recomputing these means re-reading every module.json — the very + // cost the cache exists to avoid — so they are cached alongside it. + $this->essentialModules = $cached['essentials']; + $this->built = true; + + return $this; + } + + $pipeline->run(); // Resolve essential DOMAIN entries (proj.json "essentials") to their // provider classes now that the module list is final. Fails the boot on - // an unknown domain — never a silent no-op essential. - $this->essentialModules = $this->resolveEssentialModules(); + // an unknown domain — never a silent no-op essential. Reuses the + // pipeline's reader, whose module.json cache is already warm. + $this->essentialModules = $this->resolveEssentialModules($reader); + + if (BootStamp::enabled()) { + BootStamp::write($this->buildHash(), $reader->files(), $this->essentialModules); + } $this->built = true; return $this; } + /** + * Everything the BUILDER contributes to compilation, as one hash. + * + * proj.json reaches the kernel as PHP arrays (routes, groups, domains, + * essentials, disable policy), so hashing these covers a proj.json edit + * without stat'ing it — and covers an edit to bootstrap/app.php itself, + * which no file-mtime check would catch. + */ + private function buildHash(): string + { + return BootStamp::hash([ + 'modules' => $this->moduleClasses, + 'essentials' => $this->essentialModules, + 'routes' => $this->projectRoutes, + 'groups' => $this->projectGroups, + 'disabled' => $this->disabledRoutes, + 'domains' => $this->projectDomains, + 'base' => $this->basePath, + 'project' => $this->projectPath, + ]); + } + /** * Resolve the essential-module list to provider classes. Entries containing * a namespace separator are class-strings and pass through; anything else is @@ -285,14 +416,13 @@ public function build(): self * @return list> * @throws BootException when a domain matches no registered module */ - private function resolveEssentialModules(): array + private function resolveEssentialModules(ManifestReader $reader): array { if ($this->essentialModules === []) { return []; } $byDomain = []; - $reader = new ManifestReader(); foreach ($this->moduleClasses as $class) { $m = $reader->read($class); if (isset($m['solves']) && is_string($m['solves'])) { @@ -366,6 +496,13 @@ private function materialize(RuntimeMode $mode): void $this->core->instance(ConfigRepository::class, $this->config()); // Expose kernel services to modules via the core container. + // UrlGenerator is a lazy singleton: a module that never builds a URL never + // pays for the route-name index, and one that does gets it injected rather + // than reaching for the global helper. + $this->core->singleton( + UrlGenerator::class, + static fn(): UrlGenerator => UrlGenerator::fromManifest((string) (env('APP_URL') ?: '')), + ); $this->core->instance(EventBus::class, $this->eventBus); $this->core->instance(WorkerPipeline::class, $this->workerPipe); $this->core->instance(HttpPipeline::class, $this->http); diff --git a/src/Kernel/Pipelines/Http/FilterRegistry.php b/src/Kernel/Pipelines/Http/FilterRegistry.php index aac0c3c..ab49a73 100644 --- a/src/Kernel/Pipelines/Http/FilterRegistry.php +++ b/src/Kernel/Pipelines/Http/FilterRegistry.php @@ -20,15 +20,21 @@ * $http->filter('auth', RequireAuthStage::class); * $http->filter('throttle', ApiRateLimitStage::class); * - * The alias map is global and stateless (built once at module boot). Stages are - * resolved per request from the CoreContainer, exactly like hook stages, so they - * remain OpenSwoole-safe (no per-request state on the registry). + * The alias map is global and stateless (built once at module boot). A resolved + * stage is MEMOIZED and shared across requests — the same lifetime HttpPipeline + * already gives its hook stages, and safe for the same reason: an + * HttpStageContract carries no per-request state (everything it needs travels on + * the Request). Without this, a route declaring `["auth","throttle:60,1"]` built + * two fresh stage objects on every single hit. */ final class FilterRegistry { /** @var array> */ private array $aliases = []; + /** @var array resolved once, reused */ + private array $instances = []; + /** * @param class-string $stageClass */ @@ -40,6 +46,13 @@ public function register(string $alias, string $stageClass): void ); } $this->aliases[$alias] = $stageClass; + unset($this->instances[$alias]); + } + + /** @return list every registered alias — used for boot-time validation. */ + public function aliases(): array + { + return array_keys($this->aliases); } public function has(string $alias): bool @@ -50,9 +63,15 @@ public function has(string $alias): bool /** * Resolve an alias to a stage instance (from the core container when bound, * otherwise a plain no-arg construction — mirrors HttpPipeline::resolveHook). + * + * Memoized: a stage is constructed at most once per worker. */ public function resolve(string $alias, CoreContainer $core): HttpStageContract { + if (isset($this->instances[$alias])) { + return $this->instances[$alias]; + } + if (!isset($this->aliases[$alias])) { throw new \InvalidArgumentException( "Unknown route filter alias [{$alias}]. Register it in a Provider::boot() via \$http->filter()." @@ -61,6 +80,6 @@ public function resolve(string $alias, CoreContainer $core): HttpStageContract $class = $this->aliases[$alias]; - return $core->has($class) ? $core->make($class) : new $class(); + return $this->instances[$alias] = $core->has($class) ? $core->make($class) : new $class(); } } diff --git a/src/Kernel/Pipelines/Http/HttpPipeline.php b/src/Kernel/Pipelines/Http/HttpPipeline.php index d20a3e2..483c6ab 100644 --- a/src/Kernel/Pipelines/Http/HttpPipeline.php +++ b/src/Kernel/Pipelines/Http/HttpPipeline.php @@ -12,6 +12,7 @@ CorrelationIdStage, SecurityStage, ResolveStage, LoadStage, RouteFilterStage, ExecuteStage, ErrorStage }; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\RouteIndex; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\SecurityGateway; use AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths; @@ -111,14 +112,23 @@ private function buildStages(): array $manifest = $this->loadManifest('service-manifest.php', ['services' => []]); $this->calculator ??= new DependencyGraphCalculator($manifest); $this->loader ??= new OnDemandLoader($this->core, $this->essentialModules); - $this->matcher ??= new RouteMatcher($this->loadManifest('route-manifest.php', [])); + $index = $this->routeIndex(); + $this->matcher ??= RouteMatcher::fromCompiled( + $index, + self::flag('ROUTE_HEAD_FALLBACK', true), + self::policy(), + ); + + if (self::flag('ROUTE_STRICT_FILTERS', true)) { + $this->assertFiltersRegistered(RouteIndex::entries($index)); + } return [ new ErrorStage($this->errorPipeline), // outermost wrapper new CorrelationIdStage(), new SecurityStage($this->gateway), ...$this->resolveHook('after.security'), - new ResolveStage($this->matcher), + new ResolveStage($this->matcher, self::flag('ROUTE_METHOD_NOT_ALLOWED', false)), new LoadStage($this->calculator, $this->loader, self::essentialDomains($manifest, $this->essentialModules)), ...$this->resolveHook('after.load'), new RouteFilterStage($this->filters, $this->core), @@ -167,6 +177,92 @@ private function loadManifest(string $file, array $default): array return is_array($data) ? $data : $default; } + /** + * Prefer `route-index.php` — the matcher-ready index the boot compiler built, + * which removes per-worker regex construction entirely. A deploy whose cache + * predates that file falls back to deriving the index from the flat manifest, + * so an un-recompiled application still serves. + * + * @return array + */ + private function routeIndex(): array + { + $index = $this->loadManifest('route-index.php', []); + + if (isset($index['static']) || isset($index['dynamic'])) { + return $index; + } + + return RouteIndex::build($this->loadManifest('route-manifest.php', [])); + } + + /** + * Every filter alias a route names must have been registered by some + * Provider::boot(). Checked once, here, because this runs AFTER module boot + * (the compiler cannot know the aliases yet) and BEFORE the first request — + * turning a per-request 500 on an unreachable page into a startup failure + * that names the route. + * + * Set ROUTE_STRICT_FILTERS=false to fall back to the previous behaviour (the + * unknown alias throws when that one route is requested). The escape hatch + * exists because this check runs for the WHOLE table: an application that has + * been quietly serving with one mis-declared filter on a page nobody visits + * should be able to deploy the upgrade first and fix the route second. + * + * @param array> $entries + */ + private function assertFiltersRegistered(array $entries): void + { + foreach ($entries as $key => $entry) { + $specs = $entry['filter_specs'] ?? null; + $specs = is_array($specs) + ? array_column($specs, 'alias') + : array_map( + static fn($f): string => explode(':', trim((string) $f), 2)[0], + is_array($entry['filters'] ?? null) ? $entry['filters'] : [], + ); + + foreach ($specs as $alias) { + if ($alias === '' || $this->filters->has($alias)) { + continue; + } + + throw new \InvalidArgumentException(sprintf( + 'Route [%s] declares filter [%s], which no Provider::boot() registered. ' + . 'Registered aliases: %s. Register it with $http->filter() in the plugin that ' + . 'provides it, or remove it from the route.', + $key, + $alias, + $this->filters->aliases() === [] ? '(none)' : implode(', ', $this->filters->aliases()), + )); + } + } + } + + /** Read a boolean env flag once, at pipeline build. */ + private static function flag(string $key, bool $default): bool + { + $value = \function_exists('env') ? env($key) : null; + + if ($value === null || $value === '') { + return $default; + } + + return filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? $default; + } + + /** ROUTE_TRAILING_SLASH: strict (default) | ignore | redirect. */ + private static function policy(): string + { + $value = \function_exists('env') ? strtolower(trim((string) (env('ROUTE_TRAILING_SLASH') ?? ''))) : ''; + + return match ($value) { + RouteMatcher::TRAILING_IGNORE => RouteMatcher::TRAILING_IGNORE, + RouteMatcher::TRAILING_REDIRECT => RouteMatcher::TRAILING_REDIRECT, + default => RouteMatcher::TRAILING_STRICT, + }; + } + /** * Map the essential module classes to their solves domains via the compiled * service manifest, so LoadStage can seed them (and thus their transitive diff --git a/src/Kernel/Pipelines/Http/RouteMatcher.php b/src/Kernel/Pipelines/Http/RouteMatcher.php index 3a481cb..2df342e 100644 --- a/src/Kernel/Pipelines/Http/RouteMatcher.php +++ b/src/Kernel/Pipelines/Http/RouteMatcher.php @@ -3,15 +3,16 @@ namespace AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http; -use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\RouteParameter; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\{RouteIndex, RouteParameter}; /** - * RouteMatcher — matches a method+path against the compiled route manifest. + * RouteMatcher — matches a method+path against the compiled route index. * * Exact matches are an O(1) hash lookup. Parameterized routes ({id}, {slug}) * fall back to a regex scan with NAMED capture, so captured values are returned - * and forwarded to the controller. Dynamic routes are bucketed by HTTP method, - * so a request only scans the regexes registered for its own method. + * and forwarded to the controller. Dynamic routes are bucketed by HTTP method + * AND by their first literal path segment, so a request only tests the handful + * of patterns that could possibly match its prefix. * * Placeholders may be TYPED — `{id:num}`, `{slug:slug}`, `{path:any}` — which * narrows the segment pattern so a non-matching value 404s at the routing layer @@ -20,77 +21,329 @@ * * Static routes are checked BEFORE dynamic ones, so a literal `/users/me` always * wins over `/users/{id}` regardless of declaration order. Among dynamic routes - * the first match wins, in manifest order. + * the first match wins, in manifest order (preserved across the bucket split by + * each candidate's ordinal). * * Built once and reused across requests — it holds no per-request state. + * + * ── PERCENT-DECODING IS PART OF MATCHING ───────────────────────────────────── + * `Request::path()` is the RAW path: `%2F` is three ordinary characters, so it + * slips through `[^/]+` untouched. Handing that to a controller means the "one + * path segment" guarantee evaporates the moment anything decodes it — and it + * must decode it, or `/users/José` arrives as `Jos%C3%A9`. + * + * So a captured value is decoded and then RE-VALIDATED against its declared + * type. `/files/..%2F..%2Fetc%2Fpasswd` no longer satisfies `{name}`, because + * the decoded `../../etc/passwd` does not. Decoding runs only when the value + * actually contains '%', so the common case costs one strpos. */ final class RouteMatcher { - /** @var array> "METHOD /path" => entry (static routes) */ + /** Trailing-slash policies. `strict` is the historical behaviour. */ + public const TRAILING_STRICT = 'strict'; + public const TRAILING_IGNORE = 'ignore'; + public const TRAILING_REDIRECT = 'redirect'; + + /** @var array>> domain => "METHOD /path" => entry */ private array $static = []; /** - * Dynamic (parameterized) routes bucketed by HTTP method, so a request only - * scans the regexes for ITS method instead of the whole dynamic table. + * Dynamic (parameterized) routes bucketed by domain, then HTTP method, then by + * first literal path segment, plus a `wild` list for routes whose first + * segment is itself a placeholder. * - * @var array, entry: array}>> + * @var array>>, wild?: list>}>> */ private array $dynamic = []; - /** @param array> $manifest */ - public function __construct(array $manifest) + /** @var list every method some route answers — used to compute Allow. */ + private array $methods = []; + + /** Whether ANY route is grouped under a domain — lets the common case skip the loops. */ + private bool $grouped = false; + + /** + * Legacy constructor: accepts the FLAT route manifest and derives the index + * in-process. Kept because it is public API (tests and any consumer that + * builds a matcher from a hand-made array). Prefer {@see fromCompiled()}, + * which reads an index the boot compiler already built. + * + * @param array> $manifest + */ + public function __construct( + array $manifest, + private readonly bool $headFallback = true, + private readonly string $trailingSlash = self::TRAILING_STRICT, + ) { + $this->apply(RouteIndex::build($manifest)); + } + + /** + * Build from `route-index.php` — the index the boot compiler precomputed. + * + * @param array $index + */ + public static function fromCompiled( + array $index, + bool $headFallback = true, + string $trailingSlash = self::TRAILING_STRICT, + ): self { + $matcher = new self([], $headFallback, $trailingSlash); + $matcher->apply($index); + + return $matcher; + } + + /** @param array $index */ + private function apply(array $index): void + { + $this->static = is_array($index['static'] ?? null) ? $index['static'] : []; + $this->dynamic = is_array($index['dynamic'] ?? null) ? $index['dynamic'] : []; + $this->methods = is_array($index['methods'] ?? null) ? array_values($index['methods']) : []; + $this->grouped = is_array($index['domains'] ?? null) && $index['domains'] !== []; + } + + /** + * Whether ANY route is grouped under a domain. + * + * Lets a caller skip expanding the request host into candidate keys — which + * costs more than the match itself — when no route could possibly use them. + */ + public function hasDomainGroups(): bool { - foreach ($manifest as $key => $entry) { - [$method, $path] = explode(' ', $key, 2); + return $this->grouped; + } - if (!str_contains($path, '{')) { - $this->static[$key] = $entry; - continue; + /** + * @param list $domains the domain-group keys this request may match, + * MOST SPECIFIC FIRST — normally {@see RouteIndex::hostCandidates()} + * applied to the request host. Empty means only the shared group, + * which is every route in an application that groups nothing. + * + * @return array{entry: array, params: array}|null + */ + public function match(string $method, string $path, array $domains = []): ?array + { + $found = $this->lookup($method, $path, $domains); + + if ($found === null && $this->trailingSlash === self::TRAILING_IGNORE) { + $found = $this->lookup($method, self::alternateSlash($path), $domains); + } + + // HEAD is defined as GET without a body. Without this, every page on the + // site 404s for link checkers, uptime monitors and caches that probe with + // HEAD. ExecuteStage drops the body again on the way out. + if ($found === null && $this->headFallback && $method === 'HEAD') { + $found = $this->lookup('GET', $path, $domains); + + if ($found === null && $this->trailingSlash === self::TRAILING_IGNORE) { + $found = $this->lookup('GET', self::alternateSlash($path), $domains); } + } + + return $found; + } - $params = []; - $regex = preg_replace_callback( - RouteParameter::PLACEHOLDER, - static function (array $m) use (&$params): string { - $name = preg_replace('/[^a-zA-Z0-9_]/', '', $m[1]); - $type = $m[2] ?? ''; - $params[] = $name; - - // An unknown type already failed the boot in - // CompileRouteManifestStage, so by here it is always valid. - return '(?P<' . $name . '>' . RouteParameter::pattern($type) . ')'; - }, - $path, - ); - - $this->dynamic[$method][] = [ - 'regex' => '#^' . $regex . '$#', - 'params' => $params, - 'entry' => $entry, - ]; + /** + * The methods that DO answer this path — for a `405 Method Not Allowed` and + * its mandatory `Allow` header. Only ever called after a miss, so the extra + * cross-method scan never touches the hot path. + * + * @return list + */ + public function allowedMethods(string $path, array $domains = []): array + { + $allowed = []; + + foreach ($this->methods as $method) { + if ($this->lookup($method, $path, $domains) !== null) { + $allowed[] = $method; + } } + + if ($allowed !== [] && $this->headFallback && in_array('GET', $allowed, true) + && !in_array('HEAD', $allowed, true)) { + $allowed[] = 'HEAD'; + } + + return $allowed; } /** + * Under the `redirect` trailing-slash policy: the canonical path this one + * should be sent to, or null when the alternate form does not match either. + */ + public function canonicalPath(string $method, string $path, array $domains = []): ?string + { + if ($this->trailingSlash !== self::TRAILING_REDIRECT) { + return null; + } + + $alternate = self::alternateSlash($path); + + if ($alternate === $path || $this->lookup($method, $alternate, $domains) === null) { + return null; + } + + return $alternate; + } + + // ── internals ──────────────────────────────────────────────────────────── + + /** + * One exact lookup. + * + * Order is SPECIFICITY within each table, and static still beats dynamic: + * + * 1. each domain group's static routes, most specific first + * 2. the shared group's static routes + * 3. each domain group's dynamic routes, most specific first + * 4. the shared group's dynamic routes + * + * Doing all the static work before any dynamic work preserves the invariant + * that a literal `/users/me` always beats `/users/{id}` — if a domain group + * were searched end-to-end first, its `/users/{id}` would swallow the shared + * literal `/users/me`, which is the kind of surprise this router exists to + * avoid. Within that, a domain group wins over the shared group, so declaring + * `GET /` under `organizer` overrides the shared `GET /` on that host only. + * + * @param list $domains most specific first * @return array{entry: array, params: array}|null */ - public function match(string $method, string $path): ?array + private function lookup(string $method, string $path, array $domains = []): ?array { $key = $method . ' ' . $path; - if (isset($this->static[$key])) { - return ['entry' => $this->static[$key], 'params' => []]; + + // An application that groups nothing pays a single bool check for all of + // this and then behaves exactly as it did before domain groups existed. + if ($this->grouped) { + foreach ($domains as $domain) { + if (isset($this->static[$domain][$key])) { + return ['entry' => $this->static[$domain][$key], 'params' => []]; + } + } + } + + if (isset($this->static[''][$key])) { + return ['entry' => $this->static[''][$key], 'params' => []]; + } + + if ($this->grouped) { + foreach ($domains as $domain) { + $match = $this->scan($this->dynamic[$domain][$method] ?? null, $path); + if ($match !== null) { + return $match; + } + } + } + + return $this->scan($this->dynamic[''][$method] ?? null, $path); + } + + /** + * Scan one domain+method's dynamic candidates in declaration order. + * + * @param array{buckets?: array>>, wild?: list>}|null $bucketed + * @return array{entry: array, params: array}|null + */ + private function scan(?array $bucketed, string $path): ?array + { + if ($bucketed === null) { + return null; } - foreach ($this->dynamic[$method] ?? [] as $route) { - if (preg_match($route['regex'], $path, $matches) === 1) { - $params = []; - foreach ($route['params'] as $name) { - $params[$name] = $matches[$name] ?? ''; + $candidates = $bucketed['buckets'][RouteIndex::requestSegment($path)] ?? []; + $wild = $bucketed['wild'] ?? []; + + if ($wild === []) { + foreach ($candidates as $route) { + $match = $this->test($route, $path); + if ($match !== null) { + return $match; } - return ['entry' => $route['entry'], 'params' => $params]; + } + + return null; + } + + // Merge the two ordered lists on the fly (no allocation) so a wildcard + // route declared before a bucketed one still wins, exactly as it would + // have in a single flat scan. + $i = 0; + $j = 0; + $n = count($candidates); + $m = count($wild); + + while ($i < $n || $j < $m) { + if ($j >= $m || ($i < $n && $candidates[$i]['ord'] <= $wild[$j]['ord'])) { + $route = $candidates[$i++]; + } else { + $route = $wild[$j++]; + } + + $match = $this->test($route, $path); + if ($match !== null) { + return $match; } } return null; } + + /** + * Test one candidate, decoding and re-validating every captured value. + * + * A capture that decodes into something its type forbids is treated as NO + * MATCH rather than as an error, so a later route still gets its chance — + * identical to how a value that never matched the pattern behaves. + * + * @param array $route + * @return array{entry: array, params: array}|null + */ + private function test(array $route, string $path): ?array + { + if (preg_match($route['regex'], $path, $matches) !== 1) { + return null; + } + + $params = []; + + foreach ($route['params'] as $param) { + $name = is_array($param) ? $param['name'] : $param; + $type = is_array($param) ? ($param['type'] ?? '') : ''; + $value = $matches[$name] ?? ''; + + if ($value !== '' && str_contains($value, '%')) { + $decoded = rawurldecode($value); + + if ($decoded !== $value) { + // A NUL byte truncates strings in every C-backed API it later + // reaches (filesystem, some DB drivers) — never let one through. + if (str_contains($decoded, "\0")) { + return null; + } + + if (preg_match('#^' . RouteParameter::pattern($type) . '$#D', $decoded) !== 1) { + return null; + } + + $value = $decoded; + } + } + + $params[$name] = $value; + } + + return ['entry' => $route['entry'], 'params' => $params]; + } + + /** '/users/' <-> '/users'. The root path has no alternate form. */ + private static function alternateSlash(string $path): string + { + if ($path === '/' || $path === '') { + return $path; + } + + return str_ends_with($path, '/') ? rtrim($path, '/') : $path . '/'; + } } diff --git a/src/Kernel/Pipelines/Http/Stages/ExecuteStage.php b/src/Kernel/Pipelines/Http/Stages/ExecuteStage.php index b79dcad..b931e40 100644 --- a/src/Kernel/Pipelines/Http/Stages/ExecuteStage.php +++ b/src/Kernel/Pipelines/Http/Stages/ExecuteStage.php @@ -15,7 +15,15 @@ public function handle(Request $request, callable $next): Response $container = $request->container(); $scope = $entry['solves'] ?? ''; - [$controllerClass, $method] = explode('@', $entry['handler']); + // The handler split is constant per route, so the boot compiler bakes it + // into the entry. explode() is the fallback for a manifest compiled by an + // older kernel. + if (isset($entry['class'], $entry['action'])) { + $controllerClass = $entry['class']; + $method = $entry['action']; + } else { + [$controllerClass, $method] = explode('@', $entry['handler'], 2); + } $controller = $container->makeInScope($controllerClass, $scope); @@ -32,6 +40,25 @@ public function handle(Request $request, callable $next): Response $response = $controller->$method($request, ...$params); } - return $response->withHeader('X-Correlation-ID', $request->attribute('correlation_id', '')); + $response = $response->withHeader('X-Correlation-ID', $request->attribute('correlation_id', '')); + + // A HEAD request is served by the GET route (see RouteMatcher) and must + // return the GET headers with NO body. Rebuilding an empty response also + // discards any stream callback or file path, so a HEAD probe on a large + // download never reads the file. + return $request->method() === 'HEAD' ? self::withoutBody($response) : $response; + } + + /** + * Same status and headers, empty body. Content-Length is dropped rather than + * faked: RFC 9110 permits omitting it on a HEAD response, and computing it + * would mean generating the very body we are trying not to produce. + */ + private static function withoutBody(Response $response): Response + { + $headers = $response->headers(); + unset($headers['content-length'], $headers['Content-Length']); + + return Response::empty($response->status())->withHeaders($headers); } } diff --git a/src/Kernel/Pipelines/Http/Stages/LoadStage.php b/src/Kernel/Pipelines/Http/Stages/LoadStage.php index c0e5844..9737cc4 100644 --- a/src/Kernel/Pipelines/Http/Stages/LoadStage.php +++ b/src/Kernel/Pipelines/Http/Stages/LoadStage.php @@ -42,6 +42,13 @@ public function handle(Request $request, callable $next): Response $entry = $request->attribute('route_entry'); $extra = is_array($entry) && is_array($entry['requires'] ?? null) ? $entry['requires'] : []; + // The cache key is constant per route, so the boot compiler bakes it into + // the entry as `graph_key`. The implode() below is the fallback for a + // manifest compiled by an older kernel. + $key = is_array($entry) && is_string($entry['graph_key'] ?? null) + ? $entry['graph_key'] + : $service . '|' . implode(',', $extra); + // Essential modules are registered on every request anyway (see // OnDemandLoader) — resolving their domains THROUGH the graph as well // brings their transitive requires[] with them, so an essential like @@ -49,7 +56,6 @@ public function handle(Request $request, callable $next): Response // unbound contract on routes that never pulled it in. The calculator // visits each domain once, so nothing registers twice. Graphs are // memoized per worker (see $graphs). - $key = $service . '|' . implode(',', $extra); $graph = $this->graphs[$key] ??= $this->calculator->resolve($service, [...$extra, ...$this->essentialDomains]); $container = $this->loader->load($graph, $request); diff --git a/src/Kernel/Pipelines/Http/Stages/ResolveStage.php b/src/Kernel/Pipelines/Http/Stages/ResolveStage.php index c48de4f..006d1a7 100644 --- a/src/Kernel/Pipelines/Http/Stages/ResolveStage.php +++ b/src/Kernel/Pipelines/Http/Stages/ResolveStage.php @@ -6,18 +6,47 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Http\{Request, Response}; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\Contracts\HttpStageContract; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\RouteMatcher; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\RouteIndex; +/** + * ResolveStage — turns method+path into the route entry the rest of the pipeline + * runs on, or ends the request with a 404 before any module is loaded. + * + * Three optional behaviours, all OFF or inert by default so an existing app + * resolves exactly as it did: + * + * - `405 Method Not Allowed` (+ Allow header) instead of 404 when the path + * exists under a different method. Opt-in, because a 405 confirms that a + * path exists and so hands a scanner free reconnaissance; a 404 does not. + * - a trailing-slash redirect, under the `redirect` policy. + * - a face restriction: a route may declare `faces: ["admin"]` and is then + * invisible on any other face. The kernel stays domain-agnostic — it reads + * the plain `route_face` request attribute and never imports the project's + * DomainContext. A mismatch 404s rather than 403s: a route the caller may + * not reach on this host should not advertise that it exists elsewhere. + */ final class ResolveStage implements HttpStageContract { public function __construct( - private readonly RouteMatcher $matcher + private readonly RouteMatcher $matcher, + private readonly bool $methodNotAllowed = false, ) {} public function handle(Request $request, callable $next): Response { - $match = $this->matcher->match($request->method(), $request->path()); + $method = $request->method(); + $path = $request->path(); + // Expanding the host into candidate keys costs more than the match + // itself, so an application that groups nothing never pays for it. + $domains = $this->matcher->hasDomainGroups() ? self::domains($request) : []; + + $match = $this->matcher->match($method, $path, $domains); if ($match === null) { + return $this->miss($method, $path, $domains); + } + + if (!$this->faceAllows($request, $match['entry'])) { return Response::notFound(); } @@ -28,4 +57,76 @@ public function handle(Request $request, callable $next): Response return $next($request); } + + /** + * The domain-group keys this request may match, most specific first. + * + * The host comes from the `route_host` attribute when an entry point set one + * — that is DomainContext->host, which DomainResolver already matched against + * projects.json. Otherwise it falls back to `Request::host()`, the raw Host + * header. Prefer the attribute: the header is client-controlled and no + * trusted-host allowlist filters it here, so on the fallback path a caller + * can choose which domain group serves it. + * + * @return list + */ + private static function domains(Request $request): array + { + $host = $request->attribute('route_host'); + + return RouteIndex::hostCandidates( + is_string($host) && $host !== '' ? $host : $request->host(), + ); + } + + /** + * No route matched: redirect to the canonical form, 405, or 404. + * + * @param list $domains + */ + private function miss(string $method, string $path, array $domains): Response + { + $canonical = $this->matcher->canonicalPath($method, $path, $domains); + if ($canonical !== null) { + return Response::permanentRedirect($canonical); + } + + if ($this->methodNotAllowed) { + $allowed = $this->matcher->allowedMethods($path, $domains); + + if ($allowed !== []) { + return Response::json([ + 'error' => [ + 'code' => 'method_not_allowed', + 'message' => "The {$method} method is not supported for this route.", + ], + ], 405)->withHeader('Allow', implode(', ', $allowed)); + } + } + + return Response::notFound(); + } + + /** + * @param array $entry + */ + private function faceAllows(Request $request, array $entry): bool + { + $faces = $entry['faces'] ?? []; + + if (!is_array($faces) || $faces === []) { + return true; // unrestricted — every route, unless it opted in + } + + $face = $request->attribute('route_face'); + + if (!is_string($face) || $face === '') { + // Nothing declared the current face (CLI-driven tests, an entry point + // that does not resolve a domain). Restricting on unknown information + // would silently 404 the route everywhere. + return true; + } + + return in_array(strtolower($face), $faces, true); + } } diff --git a/src/Kernel/Pipelines/Http/Stages/RouteFilterStage.php b/src/Kernel/Pipelines/Http/Stages/RouteFilterStage.php index 2639be4..2998132 100644 --- a/src/Kernel/Pipelines/Http/Stages/RouteFilterStage.php +++ b/src/Kernel/Pipelines/Http/Stages/RouteFilterStage.php @@ -3,6 +3,7 @@ namespace AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\Stages; +use AlfacodeTeam\PhpServicePlatform\Kernel\Boot\Stages\CompileRouteManifestStage; use AlfacodeTeam\PhpServicePlatform\Kernel\Container\CoreContainer; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\{Request, Response}; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\Contracts\HttpStageContract; @@ -28,6 +29,10 @@ * (keyed by alias) so a stage can read its own configuration per route: * * $args = $request->attribute('filter_args')['throttle'] ?? []; + * + * The spec parse is CONSTANT per route, so the boot compiler stores the result as + * `filter_specs` and this stage reads it; the inline parse below is only the + * fallback for a manifest compiled by an older kernel. */ final class RouteFilterStage implements HttpStageContract { @@ -38,10 +43,10 @@ public function __construct( public function handle(Request $request, callable $next): Response { - $entry = $request->attribute('route_entry'); - $filters = is_array($entry) ? ($entry['filters'] ?? []) : []; + $entry = $request->attribute('route_entry'); + $specs = $this->specs($entry); - if (!is_array($filters) || $filters === []) { + if ($specs === []) { return $next($request); } @@ -50,12 +55,12 @@ public function handle(Request $request, callable $next): Response $aliases = []; $args = []; - foreach ($filters as $spec) { - [$alias, $params] = $this->parse((string) $spec); - $stages[] = $this->registry->resolve($alias, $this->core); + foreach ($specs as $spec) { + $alias = $spec['alias']; + $stages[] = $this->registry->resolve($alias, $this->core); $aliases[] = $alias; - if ($params !== []) { - $args[$alias] = $params; + if ($spec['args'] !== []) { + $args[$alias] = $spec['args']; } } @@ -78,24 +83,30 @@ public function handle(Request $request, callable $next): Response } /** - * "throttle:60,1" => ['throttle', ['60', '1']] - * "auth" => ['auth', []] - * - * @return array{0: string, 1: list} + * @param mixed $entry + * @return list}> */ - private function parse(string $spec): array + private function specs(mixed $entry): array { - $spec = trim($spec); - if (!str_contains($spec, ':')) { - return [$spec, []]; + if (!is_array($entry)) { + return []; + } + + $specs = $entry['filter_specs'] ?? null; + if (is_array($specs)) { + return $specs; } - [$alias, $rawArgs] = explode(':', $spec, 2); - $params = array_values(array_filter( - array_map('trim', explode(',', $rawArgs)), - static fn(string $a): bool => $a !== '', - )); + $filters = $entry['filters'] ?? []; + if (!is_array($filters) || $filters === []) { + return []; + } + + $parsed = []; + foreach ($filters as $spec) { + $parsed[] = CompileRouteManifestStage::parseFilterSpec((string) $spec); + } - return [trim($alias), $params]; + return $parsed; } } diff --git a/src/Kernel/Routing/RouteIndex.php b/src/Kernel/Routing/RouteIndex.php new file mode 100644 index 0000000..40b8a3e --- /dev/null +++ b/src/Kernel/Routing/RouteIndex.php @@ -0,0 +1,300 @@ + ["GET /health" => entry, …], + * 'dynamic' => ['GET' => [ + * 'buckets' => ['users' => [candidate, …]], // keyed by first LITERAL segment + * 'wild' => [candidate, …], // first segment is a placeholder + * ]], + * 'methods' => ['GET', 'POST', …], + * ] + * + * where a candidate is `['ord' => int, 'regex' => string, 'params' => […], 'entry' => […]]`. + * + * WHY BUCKETS + * ----------- + * Matching used to scan every dynamic pattern registered for the request's + * method. A path can only be matched by a dynamic route whose first segment is + * either the same literal or a placeholder, so bucketing by that segment reduces + * the scan to the few patterns that can possibly match. `ord` preserves manifest + * order across the bucket/wild split, so first-match-wins is unchanged. + * + * DOMAINS + * ------- + * A route may be grouped under a DOMAIN — written literally, as the host or the + * subdomain it answers on: + * + * { "domain": "africavoting.local", "routes": [ … ] } + * { "domain": "*.africavoting.local", "routes": [ … ] } + * { "subdomain": "organizer", "routes": [ … ] } + * + * The compiler GROUPS by that string verbatim — it does not resolve it, look it + * up anywhere, or check that it is a host this deployment serves. It is part of + * the route KEY, which is the whole point: `GET /` can exist once per domain with + * a different handler each time. '' is the shared group every ungrouped route + * lives in. + * + * Matching a request is the reverse: {@see hostCandidates()} expands the incoming + * host into the group keys that could hold it, most specific first, and the + * matcher tries each and then the shared group. + */ +final class RouteIndex +{ + /** Separates the HTTP method from the domain in a route key: `GET@organizer /path`. */ + public const DOMAIN_SEPARATOR = '@'; + + /** + * Build a route key. Ungrouped routes keep the historical `"METHOD /path"` + * exactly, so every existing manifest entry and every consumer that splits on + * the first space is unaffected. + */ + public static function key(string $method, string $domain, string $path): string + { + return strtoupper($method) + . ($domain === '' ? '' : self::DOMAIN_SEPARATOR . $domain) + . ' ' . $path; + } + + /** + * Split a route key back into its parts. + * + * Note the shape of a grouped key: the domain rides on the METHOD segment, so + * `explode(' ', $key, 2)` still yields a clean, leading-slash path for any + * consumer that has not been taught about domain groups. Such a consumer sees + * the method as `GET@organizer`, which no HTTP method matches, so it SKIPS the + * route rather than emitting a corrupted path. Failing safe was the deciding + * factor in choosing this format. + * + * @return array{method: string, domain: string, path: string} + */ + public static function parseKey(string $key): array + { + [$verb, $path] = array_pad(explode(' ', $key, 2), 2, ''); + + $at = strpos($verb, self::DOMAIN_SEPARATOR); + + return $at === false + ? ['method' => $verb, 'domain' => '', 'path' => $path] + : ['method' => substr($verb, 0, $at), 'domain' => substr($verb, $at + 1), 'path' => $path]; + } + + /** + * The domain-group keys an incoming host could match, MOST SPECIFIC FIRST. + * + * organizer.africavoting.local + * → organizer.africavoting.local the exact host + * → *.africavoting.local a wildcard on each parent suffix + * → *.local + * → organizer the bare subdomain label + * + * So a group may be declared as a full host, a wildcard, or just a subdomain, + * and the most specific declaration wins. Nothing is validated: a key nothing + * expands to simply never matches, exactly like a path nothing requests. + * + * @return list + */ + public static function hostCandidates(string $host): array + { + // Same normalisation DomainResolver applies: lower-case, no port, no + // trailing dot, IPv6 brackets unwrapped. + $host = strtolower(trim($host)); + $host = trim(explode(':', ltrim($host, '['), 2)[0], "].\t\n\r "); + + if ($host === '') { + return []; + } + + $candidates = [$host]; + $labels = explode('.', $host); + $count = count($labels); + + for ($i = 1; $i < $count; $i++) { + $candidates[] = '*.' . implode('.', array_slice($labels, $i)); + } + + // A bare label only means "subdomain" when there is one to speak of: + // for "hkmvote.local", "hkmvote" is the site itself, not a subdomain. + if ($count > 2) { + $candidates[] = $labels[0]; + } + + return $candidates; + } + /** + * @param array> $routes flat manifest, "METHOD /path" => entry + * @return array{static: array>>, dynamic: array>>, wild?: list>}>>, methods: list, domains: list} + */ + public static function build(array $routes): array + { + $static = []; + $dynamic = []; + $methods = []; + $domains = []; + $ordinal = 0; + + foreach ($routes as $key => $entry) { + ['method' => $method, 'domain' => $domain, 'path' => $path] = self::parseKey((string) $key); + + $methods[$method] = true; + if ($domain !== '') { + $domains[$domain] = true; + } + + // The inner key drops the domain — it is already the outer dimension + // — so a lookup is $static[$domain]["GET /path"]. + $innerKey = $method . ' ' . $path; + + if (!str_contains($path, '{')) { + $static[$domain][$innerKey] = $entry; + continue; + } + + $regex = $entry['regex'] ?? null; + $params = $entry['params'] ?? null; + + if (!is_string($regex) || !is_array($params)) { + try { + $compiled = RouteParameter::compile($path); + } catch (\InvalidArgumentException) { + // A path PCRE cannot represent. The boot compiler rejects + // these outright; here — reading a manifest compiled by an + // older kernel — dropping just this route preserves the old + // behaviour (that one endpoint 404s) instead of taking the + // whole pipeline down. + continue; + } + $regex = $compiled['regex']; + $params = $compiled['params']; + } + + $candidate = [ + 'ord' => $ordinal++, + 'key' => $key, + 'regex' => $regex, + 'params' => $params, + 'entry' => $entry, + ]; + + $segment = self::firstLiteralSegment($path); + + if ($segment === null) { + $dynamic[$domain][$method]['wild'][] = $candidate; + } else { + $dynamic[$domain][$method]['buckets'][$segment][] = $candidate; + } + } + + return [ + 'static' => $static, + 'dynamic' => $dynamic, + 'methods' => array_keys($methods), + 'domains' => array_keys($domains), + ]; + } + + /** + * The first path segment when it is literal, or null when it contains a + * placeholder (and so cannot be used as a bucket key). + */ + public static function firstLiteralSegment(string $path): ?string + { + $rest = ltrim($path, '/'); + $slash = strpos($rest, '/'); + $first = $slash === false ? $rest : substr($rest, 0, $slash); + + return str_contains($first, '{') ? null : $first; + } + + /** The first segment of a REQUEST path — the bucket key to look up. */ + public static function requestSegment(string $path): string + { + $rest = ltrim($path, '/'); + $slash = strpos($rest, '/'); + + return $slash === false ? $rest : substr($rest, 0, $slash); + } + + /** + * Every route entry in an index, keyed by "METHOD /path" — for callers that + * need to walk all routes (boot-time validation) without also loading the + * flat manifest and holding a second copy of the table. + * + * @param array $index + * @return array> + */ + public static function entries(array $index): array + { + $entries = []; + + foreach (is_array($index['static'] ?? null) ? $index['static'] : [] as $domain => $table) { + foreach ($table as $innerKey => $entry) { + ['method' => $method, 'path' => $path] = self::parseKey((string) $innerKey); + $entries[self::key($method, (string) $domain, $path)] = $entry; + } + } + + foreach (is_array($index['dynamic'] ?? null) ? $index['dynamic'] : [] as $byMethod) { + foreach (is_array($byMethod) ? $byMethod : [] as $bucketed) { + $lists = [...array_values($bucketed['buckets'] ?? []), $bucketed['wild'] ?? []]; + + foreach ($lists as $list) { + foreach ($list as $candidate) { + $entries[$candidate['key'] ?? ''] = $candidate['entry'] ?? []; + } + } + } + } + + unset($entries['']); + + return $entries; + } + + /** + * name => {path, method, domain} — everything UrlGenerator needs, nothing else. + * + * Names stay a FLAT, application-wide namespace even with domain groups: two + * domains that both want a route called `home` must name them `vote.home` and + * `africa.home` (a group's `name` prefix makes that one declaration). The + * alternative — per-domain names — would force UrlGenerator to know which + * domain it is generating for, and it deliberately holds no request state so + * that CLI commands and queue workers can build links at all. + * + * @param array> $routes + * @return array + */ + public static function names(array $routes): array + { + $names = []; + + foreach ($routes as $key => $entry) { + $name = $entry['name'] ?? null; + if (!is_string($name) || $name === '') { + continue; + } + ['method' => $method, 'domain' => $domain, 'path' => $path] = self::parseKey((string) $key); + $names[$name] = ['path' => $path, 'method' => $method, 'domain' => $domain]; + } + + return $names; + } +} \ No newline at end of file diff --git a/src/Kernel/Routing/RouteParameter.php b/src/Kernel/Routing/RouteParameter.php index ddea824..9b7785d 100644 --- a/src/Kernel/Routing/RouteParameter.php +++ b/src/Kernel/Routing/RouteParameter.php @@ -6,8 +6,8 @@ /** * Route parameter types — the `{name:type}` grammar. * - * Shared by CompileRouteManifestStage (which VALIDATES type names at boot) and - * RouteMatcher (which compiles them to regex at match time), so the two can + * Shared by CompileRouteManifestStage (which VALIDATES type names and PRECOMPILES + * the regex at boot) and RouteMatcher (which matches with it), so the two can * never disagree about what a type means. * * WHY TYPES EXIST @@ -38,6 +38,25 @@ * route must repeat the plugin's exact path, type suffix included. This is the * same literal-match rule that already governs overrides; typing does not * loosen it. + * + * ── ADDITIONS ──────────────────────────────────────────────────────────────── + * + * `path` — like `any` (crosses '/') but REFUSES a `..` sequence and control + * characters. `any` is kept byte-for-byte as it was, so nothing + * regresses; `path` is what a file-serving route should use. + * `enum(a|b)` — a closed set of literal values. Members are restricted to + * `[A-Za-z0-9_.-]` and are preg_quote'd, so the grammar can never + * inject regex metacharacters (no ReDoS surface from JSON). + * `{id?}` — an OPTIONAL parameter. The separator in front of it is folded + * into the optional group, so `/posts/{page?}` matches `/posts` + * as well as `/posts/2`. Omitted parameters are reported as ''. + * + * DECODING CONTRACT + * ----------------- + * {@see RouteMatcher} matches against the RAW (percent-encoded) request path and + * then decodes each captured value and RE-VALIDATES it against this table. A + * type therefore constrains what the CONTROLLER receives, not merely what the + * wire bytes looked like — `%2F` cannot smuggle a '/' past `{id}` any more. */ final class RouteParameter { @@ -54,23 +73,37 @@ final class RouteParameter 'uuid' => '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', 'segment' => '[^/]+', 'any' => '.*', + // Traversal-safe catch-all: crosses '/', but no '..' anywhere and no + // control characters. Prefer this over `any` for anything that reaches a + // filesystem or a StoragePort. + 'path' => '(?!(?s:.*)\.\.)[^\x00-\x1f\x7f]+', ]; /** The pattern used when no type is given — unchanged from before typing existed. */ public const DEFAULT_PATTERN = '[^/]+'; - /** Matches one `{name}` or `{name:type}` placeholder. */ - public const PLACEHOLDER = '/\{([^}:]+)(?::([^}]+))?\}/'; + /** + * Matches one placeholder: `{name}`, `{name:type}`, `{name?}`, `{name:type?}`. + * + * The NAME class stays permissive (anything but `}`, `:` and `?`) so paths + * that already rely on the matcher's name sanitisation — `{user-id}` — keep + * compiling exactly as they did. Tightening it here would silently turn a + * working route into a never-matching literal. + */ + public const PLACEHOLDER = '/\{([^}:?]+)(?::([^}?]+))?(\?)?\}/'; + + /** A well-formed `enum(a|b|c)` type. Members may not contain regex metacharacters. */ + private const ENUM = '/^enum\(([A-Za-z0-9_.-]+(?:\|[A-Za-z0-9_.-]+)*)\)$/'; /** @return list every valid type name, for error messages */ public static function names(): array { - return array_keys(self::TYPES); + return [...array_keys(self::TYPES), 'enum(a|b|…)']; } public static function isValidType(string $type): bool { - return isset(self::TYPES[$type]); + return isset(self::TYPES[$type]) || preg_match(self::ENUM, $type) === 1; } /** @@ -85,21 +118,49 @@ public static function pattern(string $type): string return self::DEFAULT_PATTERN; } - if (!self::isValidType($type)) { - throw new \InvalidArgumentException( - "Unknown route parameter type [{$type}]. Valid types: " . implode(', ', self::names()) . '.' + if (isset(self::TYPES[$type])) { + return self::TYPES[$type]; + } + + if (preg_match(self::ENUM, $type, $m) === 1) { + $members = array_map( + static fn(string $v): string => preg_quote($v, '#'), + explode('|', $m[1]), ); + + return '(?:' . implode('|', $members) . ')'; } - return self::TYPES[$type]; + throw new \InvalidArgumentException( + "Unknown route parameter type [{$type}]. Valid types: " . implode(', ', self::names()) . '.' + ); } /** * Every placeholder in a path, as [name, type] pairs (type '' when untyped). * + * Kept to exactly this shape — it is public API. Use {@see parseDetailed()} + * when the optional flag matters. + * * @return list */ public static function parse(string $path): array + { + $found = []; + + foreach (self::parseDetailed($path) as $placeholder) { + $found[] = ['name' => $placeholder['name'], 'type' => $placeholder['type']]; + } + + return $found; + } + + /** + * Every placeholder with its optional flag. + * + * @return list + */ + public static function parseDetailed(string $path): array { if (!str_contains($path, '{')) { return []; @@ -110,11 +171,120 @@ public static function parse(string $path): array $found = []; foreach ($matches as $match) { $found[] = [ - 'name' => $match[1], - 'type' => $match[2] ?? '', + 'name' => $match[1], + 'type' => ($match[2] ?? '') !== '' ? $match[2] : '', + 'optional' => ($match[3] ?? '') === '?', ]; } return $found; } + + /** + * The capture-group name for a placeholder. + * + * PCRE group names must be `[A-Za-z_][A-Za-z0-9_]*`, so a declared `{user-id}` + * is folded to `userid`. This sanitisation predates typing and is preserved + * verbatim: the resulting key is what `route_params` has always contained. + */ + public static function groupName(string $declaredName): string + { + return (string) preg_replace('/[^a-zA-Z0-9_]/', '', $declaredName); + } + + /** + * Compile a path template to an anchored regex plus its parameter list. + * + * THE single place a route path becomes a regex — the boot compiler calls it + * to bake the result into the manifest, and RouteMatcher calls it only when + * handed a legacy (un-indexed) manifest. One implementation, so the compiled + * and the on-the-fly paths cannot drift. + * + * Three properties this guarantees that the previous inline compilation did + * not: + * - literal text is preg_quote'd, so `/sitemap.xml/{id}` cannot match + * `/sitemapXxml/1`; + * - the pattern is anchored with `$…#D`, so a trailing newline in the + * request path cannot satisfy `$`; + * - duplicate or PCRE-invalid group names throw here instead of producing + * a pattern that makes preg_match() return false on every request (a + * permanent silent 404). + * + * @return array{regex: string, params: list} + * + * @throws \InvalidArgumentException on an unknown type or an unusable name + */ + public static function compile(string $path): array + { + if (!str_contains($path, '{')) { + return ['regex' => '#^' . preg_quote($path, '#') . '$#D', 'params' => []]; + } + + preg_match_all(self::PLACEHOLDER, $path, $sets, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + + $regex = ''; + $params = []; + $seen = []; + $offset = 0; + + foreach ($sets as $set) { + [$whole, $start] = $set[0]; + + $declared = $set[1][0]; + + // With PREG_OFFSET_CAPTURE an unmatched group is reported as + // ['', -1] — or omitted entirely when it is trailing — so both the + // group's presence and its offset have to be checked. + $type = isset($set[2]) && $set[2][1] !== -1 ? $set[2][0] : ''; + $optional = isset($set[3]) && $set[3][1] !== -1; + + $name = self::groupName($declared); + + if ($name === '' || !preg_match('/^[A-Za-z_]/', $name)) { + throw new \InvalidArgumentException(sprintf( + 'Route parameter {%s} in [%s] is not a usable capture name. ' + . 'A name must start with a letter or underscore once non-word characters are stripped ' + . '(so {2fa} is invalid; use {twoFactor}).', + $declared, + $path, + )); + } + + if (isset($seen[$name])) { + throw new \InvalidArgumentException(sprintf( + 'Route parameter {%s} in [%s] repeats the capture name [%s]. ' + . 'Each placeholder in a path must be uniquely named — duplicates compile to an ' + . 'invalid pattern that never matches.', + $declared, + $path, + $name, + )); + } + $seen[$name] = true; + + $literal = substr($path, $offset, $start - $offset); + $offset = $start + strlen($whole); + + // An optional parameter swallows the separator in front of it, so + // `/posts/{page?}` matches `/posts` as well as `/posts/7`. + $separator = ''; + if ($optional && $literal !== '' && str_ends_with($literal, '/')) { + $literal = substr($literal, 0, -1); + $separator = '/'; + } + + $group = '(?P<' . $name . '>' . self::pattern($type) . ')'; + + $regex .= preg_quote($literal, '#') + . ($optional ? '(?:' . preg_quote($separator, '#') . $group . ')?' : $group); + + $params[] = ['name' => $name, 'type' => $type, 'optional' => $optional]; + } + + $regex .= preg_quote(substr($path, $offset), '#'); + + // `$…#D` — without the D modifier, `$` also matches immediately before a + // trailing newline, so "/users/1\n" would satisfy "#^/users/{id:num}$#". + return ['regex' => '#^' . $regex . '$#D', 'params' => $params]; + } } diff --git a/src/Kernel/Routing/UrlGenerator.php b/src/Kernel/Routing/UrlGenerator.php index 70a50b1..25598f6 100644 --- a/src/Kernel/Routing/UrlGenerator.php +++ b/src/Kernel/Routing/UrlGenerator.php @@ -45,12 +45,29 @@ */ final class UrlGenerator { + /** + * Matches one placeholder together with the separator in front of it, so an + * omitted optional parameter takes its '/' with it. + */ + private const PLACEHOLDER_WITH_SEPARATOR = '#(/?)\{([^}:?]+)(?::([^}?]+))?(\?)?\}#'; + /** @var array route name => path template */ private array $byName = []; /** @var array route name => HTTP method */ private array $methodByName = []; + /** + * route name => the domain group it belongs to ('' when ungrouped). + * + * Used only for ABSOLUTE urls: a project serving two brands has two routes + * called `vote.home` and `africa.home`, and generating both against a single + * APP_URL would send half its links to the wrong site. + * + * @var array + */ + private array $domainByName = []; + /** * @param array> $manifest compiled route manifest * @param string $base base URL for absolute generation, e.g. https://app.example.com @@ -61,25 +78,45 @@ public function __construct( private readonly string $base = '', private readonly string $secret = '', ) { - foreach ($manifest as $key => $entry) { - $name = $entry['name'] ?? null; - if (!is_string($name) || $name === '') { - continue; - } - [$method, $path] = explode(' ', $key, 2); - $this->byName[$name] = $path; - $this->methodByName[$name] = $method; + foreach (RouteIndex::names($manifest) as $name => $route) { + $this->byName[$name] = $route['path']; + $this->methodByName[$name] = $route['method']; + $this->domainByName[$name] = $route['domain'] ?? ''; } } - /** Build from the compiled manifest on disk. */ + /** + * Build from the compiled manifests on disk. + * + * Prefers `route-names.php` — a name => {path, method} index the boot compiler + * writes. Reading it instead of the full route table matters most where this + * class is actually used: a CLI command or queue worker that mints one + * password-reset link should not hold the application's entire routing + * surface in memory to do it. Falls back to the flat manifest when the index + * is absent (a deploy whose cache predates it). + */ public static function fromManifest(string $base = '', string $secret = ''): self { - return new self( - ManifestReader::readCompiled('route-manifest.php'), - $base, - $secret !== '' ? $secret : (string) (\function_exists('env') ? (env('APP_KEY') ?: '') : ''), - ); + $secret = $secret !== '' + ? $secret + : (string) (\function_exists('env') ? (env('APP_KEY') ?: '') : ''); + + /** @var array $names */ + $names = ManifestReader::readCompiled('route-names.php'); + + if ($names !== []) { + $generator = new self([], $base, $secret); + + foreach ($names as $name => $route) { + $generator->byName[$name] = $route['path'] ?? ''; + $generator->methodByName[$name] = $route['method'] ?? 'GET'; + $generator->domainByName[$name] = $route['domain'] ?? ''; + } + + return $generator; + } + + return new self(ManifestReader::readCompiled('route-manifest.php'), $base, $secret); } public function has(string $name): bool @@ -99,7 +136,8 @@ public function methodFor(string $name): ?string * Parameters not consumed by a path placeholder become the query string, so * `route('search', ['q' => 'x'])` on `/search` yields `/search?q=x`. * - * @param array $parameters + * @param array $parameters a null or '' + * value counts as OMITTED, which is what an optional `{page?}` wants * * @throws \InvalidArgumentException on an unknown name, a missing required * parameter, or a value that violates the placeholder's declared type @@ -121,7 +159,13 @@ public function route(string $name, array $parameters = [], bool $absolute = fal $path .= '?' . http_build_query($remaining); } - return $absolute ? $this->absolute($path) : $path; + return $absolute ? $this->absolute($path, $this->domainByName[$name] ?? '') : $path; + } + + /** The domain group a named route belongs to, or '' when it is ungrouped. */ + public function domainFor(string $name): string + { + return $this->domainByName[$name] ?? ''; } /** @@ -149,7 +193,8 @@ public function to(string $path, array $query = [], bool $absolute = false): str * timestamp) which is covered by the same signature, so the deadline cannot be * extended by editing the URL. * - * @param array $parameters + * @param array $parameters a null or '' + * value counts as OMITTED, which is what an optional `{page?}` wants * @param int|null $expiresIn seconds from now; null = no expiry * * @throws \RuntimeException when no signing secret is configured — failing @@ -170,8 +215,10 @@ public function signedRoute( $url = $this->route($name, $parameters); + // The signature covers the path and query only, never the host, so + // choosing a per-domain base cannot invalidate it. return $absolute - ? $this->absolute($this->appendSignature($url)) + ? $this->absolute($this->appendSignature($url), $this->domainByName[$name] ?? '') : $this->appendSignature($url); } @@ -181,6 +228,11 @@ public function signedRoute( * Accepts a path with query string, e.g. `/verify/7?expires=…&signature=…`. * Pass the path only — a host is not covered by the signature, so including * one would make verification fail behind a proxy that rewrites it. + * + * The query is compared BYTE FOR BYTE with the `signature` pair removed, not + * parsed and re-serialised. `parse_str()` rewrites '.', ' ' and '[' inside + * parameter NAMES, so a legitimately signed URL carrying such a parameter + * could never validate — the check failed closed, but it failed. */ public function hasValidSignature(string $url): bool { @@ -190,20 +242,36 @@ public function hasValidSignature(string $url): bool [$path, $query] = array_pad(explode('?', $url, 2), 2, ''); - parse_str($query, $params); + $signature = null; + $expires = null; + $signed = []; + + foreach ($query === '' ? [] : explode('&', $query) as $pair) { + [$key, $value] = array_pad(explode('=', $pair, 2), 2, ''); + + // Only the FIRST signature pair is lifted out; a second one injected + // by an attacker stays in the signed material and breaks the match. + if ($key === 'signature' && $signature === null) { + $signature = urldecode($value); + continue; + } + + if ($key === 'expires') { + $expires = urldecode($value); + } - $signature = $params['signature'] ?? null; - unset($params['signature']); + $signed[] = $pair; + } - if (!is_string($signature) || $signature === '') { + if ($signature === null || $signature === '') { return false; } - if (isset($params['expires']) && (int) $params['expires'] < time()) { + if ($expires !== null && (int) $expires < time()) { return false; } - $expected = $this->sign($path . ($params === [] ? '' : '?' . http_build_query($params))); + $expected = $this->sign($path . ($signed === [] ? '' : '?' . implode('&', $signed))); // hash_equals — a timing-safe comparison. Never ===. return hash_equals($expected, $signature); @@ -212,51 +280,94 @@ public function hasValidSignature(string $url): bool // ── internals ──────────────────────────────────────────────────────────── /** - * Replace `{name}` / `{name:type}` with values, validating each against its - * declared type. Unconsumed parameters are returned via $remaining. + * Replace `{name}` / `{name:type}` / `{name?}` with values, validating each + * against its declared type. Unconsumed parameters are returned via $remaining. * - * @param array $parameters - * @param array $remaining + * A repeated placeholder (`/a/{id}/b/{id}`) is supported: consumption is + * tracked in a set rather than by removing the value, which previously made + * the second occurrence report a missing parameter. + * + * @param array $parameters a null or '' + * value counts as OMITTED, which is what an optional `{page?}` wants + * @param array $remaining */ private function substitute(string $name, string $template, array $parameters, array &$remaining): string { - $remaining = $parameters; + $consumed = []; $path = preg_replace_callback( - RouteParameter::PLACEHOLDER, - function (array $m) use ($name, &$remaining): string { - $param = $m[1]; - $type = $m[2] ?? ''; + self::PLACEHOLDER_WITH_SEPARATOR, + function (array $m) use ($name, $parameters, &$consumed): string { + $separator = $m[1]; + $param = $m[2]; + $type = ($m[3] ?? '') !== '' ? $m[3] : ''; + $optional = ($m[4] ?? '') === '?'; + + $present = array_key_exists($param, $parameters) + && $parameters[$param] !== null + && $parameters[$param] !== ''; + + if (!$present) { + if ($optional) { + // Takes its separator with it: /posts/{page?} → /posts + $consumed[$param] = true; + + return ''; + } - if (!array_key_exists($param, $remaining)) { throw new \InvalidArgumentException( "Route [{$name}] needs a value for {{$param}}." ); } - $value = (string) $remaining[$param]; - unset($remaining[$param]); + $value = (string) $parameters[$param]; + $consumed[$param] = true; // Generating a URL the matcher cannot match is always a bug. $pattern = RouteParameter::pattern($type); - if (preg_match('#^' . $pattern . '$#', $value) !== 1) { + if (preg_match('#^' . $pattern . '$#D', $value) !== 1) { throw new \InvalidArgumentException( "Value [{$value}] for {{$param}} on route [{$name}] does not satisfy type" . ($type === '' ? ' (a single path segment)' : " [{$type}]") . '.' ); } - return rawurlencode($value); + return $separator . rawurlencode($value); }, $template, ); + $remaining = array_diff_key($parameters, $consumed); + return (string) $path; } - private function absolute(string $path): string + /** + * Prefix a path with the right origin. + * + * A route grouped under a concrete HOST is absolute against THAT host, so a + * two-brand project links each brand to itself instead of sending every link + * to whatever single APP_URL happens to be configured. The scheme is taken + * from the configured base (https when there is none). + * + * A wildcard (`*.example.com`) or a bare subdomain (`api`) names no single + * host — there is nothing to build an origin from — so those fall back to the + * configured base, exactly as an ungrouped route does. + */ + private function absolute(string $path, string $domain = ''): string + { + return rtrim($this->originFor($domain), '/') . $path; + } + + private function originFor(string $domain): string { - return rtrim($this->base, '/') . $path; + if ($domain === '' || str_starts_with($domain, '*.') || !str_contains($domain, '.')) { + return $this->base; + } + + $scheme = $this->base !== '' ? parse_url($this->base, PHP_URL_SCHEME) : null; + + return (is_string($scheme) && $scheme !== '' ? $scheme : 'https') . '://' . $domain; } private function appendSignature(string $url): string diff --git a/src/Kernel/Support/helpers.php b/src/Kernel/Support/helpers.php index 03385da..7b03242 100644 --- a/src/Kernel/Support/helpers.php +++ b/src/Kernel/Support/helpers.php @@ -3,6 +3,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Boot\ManifestReader; use AlfacodeTeam\PhpServicePlatform\Kernel\Config\Repository; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\UrlGenerator; use AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths; use Project\Support\Collection; @@ -85,7 +86,7 @@ function env(string $key, mixed $default = null): mixed return ($value === false || $value === null) ? $default : $value; } -} +} @@ -128,4 +129,58 @@ function collect(iterable $items = []): Collection { return new Collection($items); } +} + +if (!function_exists('url')) { + /** + * The shared URL generator, built from the compiled route-name index. + * + * Named routes exist so that a link SURVIVES a project overriding or moving + * the page it points at — but that only pays off if something actually calls + * the generator, so it gets a helper like config() does. Built once per + * process; the base URL comes from APP_URL (leave it empty and every URL is + * relative, which is what a single-host deployment wants). + */ + function url(): UrlGenerator + { + /** @var UrlGenerator|null $generator */ + static $generator = null; + + return $generator ??= UrlGenerator::fromManifest((string) (env('APP_URL') ?: '')); + } +} + +if (!function_exists('route')) { + /** + * The URL for a NAMED route. + * + * route('user.show', ['id' => 7]); // /users/7 + * route('user.show', ['id' => 7], true); // https://app.test/users/7 + * + * Throws on an unknown name or a value its placeholder type forbids — a + * broken link fails at the call site instead of 404ing in production. + * + * @param array $parameters + */ + function route(string $name, array $parameters = [], bool $absolute = false): string + { + return url()->route($name, $parameters, $absolute); + } +} + +if (!function_exists('signed_route')) { + /** + * A tamper-proof URL for a named route (email verification, one-time actions). + * + * @param array $parameters + * @param int|null $expiresIn seconds from now; null = no expiry + */ + function signed_route( + string $name, + array $parameters = [], + ?int $expiresIn = null, + bool $absolute = false, + ): string { + return url()->signedRoute($name, $parameters, $expiresIn, $absolute); + } } \ No newline at end of file diff --git a/src/System/GlobalKernelProjectScaffolder.php b/src/System/GlobalKernelProjectScaffolder.php index 36062f8..1be8fea 100644 --- a/src/System/GlobalKernelProjectScaffolder.php +++ b/src/System/GlobalKernelProjectScaffolder.php @@ -503,7 +503,14 @@ private function httpEntry(string $projectName): string try { $request = Request::capture(); if ($domain !== null) { - $request = $request->withAttribute('domain', $domain); + $request = $request + ->withAttribute('domain', $domain) + // The FACE (admin/api/project/public) and the HOST let a route declare + // where it exists. Both come from the host DomainResolver already + // VALIDATED against projects.json — never the raw Host header, which + // the client controls and could otherwise pick its own route table. + ->withAttribute('route_face', $domain->type->value) + ->withAttribute('route_host', $domain->host); } $kernel->http()->handle($request)->send(); } catch (\Throwable $e) { diff --git a/templates/app/bootstrap/app.php b/templates/app/bootstrap/app.php index 76dff05..79072c0 100644 --- a/templates/app/bootstrap/app.php +++ b/templates/app/bootstrap/app.php @@ -83,17 +83,13 @@ // Plugins — module providers (registered into the kernel below). use Plugins\Crypto\Provider as CryptoProvider; use Plugins\Logger\Provider as LoggerProvider; -use Plugins\I18n\Provider as I18nProvider; use Plugins\Database\Provider as DatabaseProvider; use Plugins\Commands\Provider as CommandsProvider; use Plugins\Storage\Provider as StorageProvider; -use Plugins\HttpClient\Provider as HttpClientProvider; use Plugins\Validation\Provider as ValidationProvider; use Plugins\Session\Provider as SessionProvider; use Plugins\Cookie\Provider as CookieProvider; use Plugins\RedisCache\Provider as RedisCacheProvider; -use Plugins\SiteSEO\Application\Listeners\EnqueueIndexNowListener; -use Plugins\SiteSEO\Provider as SiteSeoModule; use Plugins\View\Provider as ViewModule; use Plugins\SecurityFilters\Provider as SecurityFiltersModule; @@ -101,7 +97,7 @@ // Flat layout: this directory's grandparent is the project root. $projectRoot = dirname(__DIR__, 2); -// ----------------------------------------------------------------------------- +// ------------------------------ ----------------------------------------------- // STEP 2 — DOMAIN RESOLUTION // Translate the request's Host header into a DomainContext (project face: // admin / api / project / public + any features). This stays in the project @@ -174,10 +170,10 @@ $ports = [ CachePort::class => static fn(): InMemoryCache => new InMemoryCache(), - DatabasePort::class => static fn(): PdoDatabase => new PdoDatabase( - dsn: $env('DB_DSN', 'sqlite::memory:') ?? 'sqlite::memory:', - username: $env('DB_USERNAME'), - password: $env('DB_PASSWORD'), + DatabasePort::class => static fn(): MultiDriverDatabaseAdapter => + new MultiDriverDatabaseAdapter((new DatabaseConfigurationFactory())->fromEnvironment()), + HashingPort::class => static fn(): PasswordHasher => new PasswordHasher( + cost: (int) ($env('HASH_BCRYPT_COST', '12') ?? '12'), ), HashingPort::class => static fn(): PasswordHasher => new PasswordHasher( cost: (int) ($env('HASH_BCRYPT_COST', '12') ?? '12'), @@ -191,13 +187,6 @@ // when REDIS_HOST is set. Lets `php app/worker/run.php` drain real jobs. QueuePort::class => static fn(): FileQueue => new FileQueue($projectRoot . '/var/queue'), - // The SEO module subscribes EnqueueIndexNowListener to seo.url_published, but - // the EventBus resolves listeners from the CoreContainer — so bind it here - // with the QueuePort. (The factory receives the container.) - EnqueueIndexNowListener::class => static fn($c) => new EnqueueIndexNowListener( - $c->make(QueuePort::class), - ), - // ── When you enable the User + Tenancy plugins ─────────────────────────── // The User plugin subscribes ProvisionTenantProfileListener to user.registered // to write the per-tenant user_profiles row. The EventBus resolves listeners @@ -246,6 +235,14 @@ // the synthetic '__project__' scope — no module register() runs for them. // Keep these controllers thin; real domain logic lives in plugins. ->withRoutes(EntryHelpers::projectRoutes($projectRoot)) + // Route GROUPS from proj.json: a prefix / filters / requires / name + // prefix / SITE stated once for every route inside the group, and + // expanded into flat routes at boot. `site` is part of the route key, + // so one project can answer `GET /` differently per group of hosts. + ->withRouteGroups(EntryHelpers::projectRouteGroups($projectRoot)) + // The hosts this project serves. A route grouped under a domain that is + // not in proj.json "domains" fails the boot — nothing could ever reach it. + ->withProjectDomains(EntryHelpers::projectDomains($projectRoot)) // Project ROUTE POLICY declared in proj.json ("routePolicy": {"disable": []}). // A plugin OWNS its routes, but the project is the final authority: it can @@ -286,10 +283,6 @@ // plus crypto helpers other modules consume. CryptoProvider::class, - // I18n (solves: i18n.translation) — translation/localisation: message catalogues, - // locale negotiation, and the translator used by modules and views. - I18nProvider::class, - // Validation (solves: validation.rules) — the shared request-validation // engine. Its boot() loads config/validation.php and registers the // CommonRules + FinancialRules packs. DTOs extend Plugins\Validation\ @@ -311,20 +304,11 @@ // "requires": ["storage.local"]. StorageProvider::class, - // HttpClient (solves: http.client) — the HttpClientPort for OUTBOUND - // HTTP (calling third-party APIs from gateways). Required by SiteSEO. - HttpClientProvider::class, - // View (solves: view.rendering) — server-side PHP templating: layouts, // sections, the project-first view cascade and `namespace::view` // resolution. Routes opt in via "requires": ["view.rendering"]. ViewModule::class, - // SiteSEO (solves: seo.management) — SEO toolkit: sitemaps, Open Graph, - // JSON-LD, robots, IndexNow. Exposes SeoServiceContract + the /api/seo/* - // routes. Needs http.client (above) for its network actions. - SiteSeoModule::class, - // Edge (solves: edge.routing) — generates this host's web-server front // config from the project's domains: an nginx SNI stream splitter when // nginx+Apache both run, else a plain nginx/Apache vhost (docroot @@ -333,6 +317,17 @@ // CLI: `hkm cli -p edge:status | edge:apply | edge:hosts`. \Plugins\Edge\Provider::class, + // ── Not installed — add when you need them ─────────────────────── + // Each is one command; it fetches the plugin, its dependencies, and + // wires them into this list for you. + // + // hkm plugins install i18n // i18n.translation — __(), locales + // hkm plugins install http-client // http.client — outbound HTTP + // hkm plugins install siteseo // seo.management — sitemaps, JSON-LD + // // (also needs http-client, and a + // // QueuePort-bound EnqueueIndexNowListener + // // in withPorts() for index-on-publish) + // Identity stack (enable together in an app that needs accounts): // \Plugins\User\Provider::class, // user.management (identity + settings) // \Plugins\Feedback\Provider::class, // feedback.management (/ajx/feedback) diff --git a/templates/app/bootstrap/kernel-autoload.php b/templates/app/bootstrap/kernel-autoload.php index 3f10307..3b95735 100644 --- a/templates/app/bootstrap/kernel-autoload.php +++ b/templates/app/bootstrap/kernel-autoload.php @@ -39,7 +39,8 @@ * `composer require` the kernel * locally, this alone is enough and * the steps below are skipped. - * 2. $PSP_GLOBAL_AUTOLOAD — explicit override env var. Point + * 2. $HKM_KERNEL_HOME/vendor/autoload.php — the installed kernel. + * 2b. $PSP_GLOBAL_AUTOLOAD — explicit override env var. Point * it at any vendor/autoload.php * (e.g. the monorepo's) to reuse a * specific kernel + its plugins. @@ -105,20 +106,41 @@ function psp_require_kernel_autoload(): void $candidates[] = $explicit; } - // (3) Composer's configured home directory, if COMPOSER_HOME is set. + // (3) The installed kernel, via HKM_KERNEL_HOME. + // + // This is how `hkm` installs itself — a system install under + // /opt/hkm-kernel, or a user install under ~/.local/share/hkm/kernel — + // and without it that kernel is invisible to PHP. `hkm run` papered + // over the gap by exporting PSP_GLOBAL_AUTOLOAD for its child, so the + // dev server worked and NOTHING else did: the same project served by + // nginx/PHP-FPM, or a worker started by systemd, or a plain + // `php app/cli/run.php`, died on "Could not load the global kernel + // autoload" with a correctly installed kernel sitting on disk. + $kernelHome = getenv('HKM_KERNEL_HOME'); + if (is_string($kernelHome) && $kernelHome !== '') { + $candidates[] = rtrim($kernelHome, '/\\') . '/vendor/autoload.php'; + } + + // (4) Composer's configured home directory, if COMPOSER_HOME is set. $composerHome = getenv('COMPOSER_HOME'); if (is_string($composerHome) && $composerHome !== '') { $candidates[] = rtrim($composerHome, '/\\') . '/vendor/autoload.php'; } - // (4)+(5) Default global Composer homes on Linux/macOS. + // (5)+(6) Default global Composer homes on Linux/macOS, plus the + // standard `hkm upgrade --user` install path — the one place a kernel + // lands when the operator has no root and never exported anything. $home = getenv('HOME'); if (is_string($home) && $home !== '') { $home = rtrim($home, '/\\'); $candidates[] = $home . '/.config/composer/vendor/autoload.php'; // current default $candidates[] = $home . '/.composer/vendor/autoload.php'; // legacy default + $candidates[] = $home . '/.local/share/hkm/kernel/vendor/autoload.php'; } + // (7) The system install path used by the .deb / install.sh. + $candidates[] = '/opt/hkm-kernel/vendor/autoload.php'; + // Try each candidate; the first one that makes the kernel class // resolvable wins and we return immediately. foreach ($candidates as $autoload) { diff --git a/templates/app/public/index.php b/templates/app/public/index.php index aec43ba..188bc16 100644 --- a/templates/app/public/index.php +++ b/templates/app/public/index.php @@ -46,7 +46,14 @@ // attribute (never via a global — coroutine/Swoole safe). $request = Request::capture(); if (isset($domain) && $domain !== null) { - $request = $request->withAttribute('domain', $domain); + $request = $request + ->withAttribute('domain', $domain) + // The FACE (admin/api/project/public) and the HOST let a route declare + // where it exists. Both come from the host DomainResolver already + // VALIDATED against projects.json — never the raw Host header, which + // the client controls and could otherwise pick its own route table. + ->withAttribute('route_face', $domain->type->value) + ->withAttribute('route_host', $domain->host); } // Run the HTTP pipeline (security → resolve → load → execute) and emit the diff --git a/templates/app/swoole/index.php b/templates/app/swoole/index.php index dfc7b3f..7688c09 100644 --- a/templates/app/swoole/index.php +++ b/templates/app/swoole/index.php @@ -135,7 +135,12 @@ $hostHeader = $req->header['host'] ?? null; $domain = EntryHelpers::resolveDomain($rootPath, is_string($hostHeader) ? $hostHeader : null); if ($domain !== null) { - $request = $request->withAttribute('domain', $domain); + $request = $request + ->withAttribute('domain', $domain) + // Face + host come from the VALIDATED host (see the FPM entry point + // for why the raw Host header must never select a route table). + ->withAttribute('route_face', $domain->type->value) + ->withAttribute('route_host', $domain->host); } $response = $kernel->http()->handle($request); diff --git a/templates/plugin/migration_alter.php b/templates/plugin/migration_alter.php new file mode 100644 index 0000000..02ec26d --- /dev/null +++ b/templates/plugin/migration_alter.php @@ -0,0 +1,35 @@ +table('{{LOWER}}', static function ($t) { + // $t->string('widget_id', 64)->nullable(); + // $t->index('widget_id'); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->table('{{LOWER}}', static function ($t) { + // $t->dropColumn('widget_id'); + }); + } +}; diff --git a/templates/simple/app/bootstrap/app.php b/templates/simple/app/bootstrap/app.php new file mode 100644 index 0000000..83b667c --- /dev/null +++ b/templates/simple/app/bootstrap/app.php @@ -0,0 +1,216 @@ +http()->handle(...)` for web, `$kernel->cli()->run(...)` for the + * terminal. + * + * ----------------------------------------------------------------------------- + * WHY THIS ONE IS EMPTY + * ----------------------------------------------------------------------------- + * No plugins are enabled. Not "none yet" — none, deliberately. + * + * The framework loads only what a request actually needs, so a plugin you have + * not enabled costs nothing at runtime. It does cost something everywhere else: + * a download, a directory, a line of wiring, a version to keep current, and one + * more thing to understand before you can read your own bootstrap. Starting at + * zero means everything present here is something you asked for. + * + * Add one when a requirement arrives, not in case it does: + * + * hkm plugins install database # DatabasePort, migrations + * hkm plugins install view # PHP templates + * hkm plugins install auth # login, tokens, sessions + * + * `hkm plugins install` fetches the plugin AND the plugins it depends on, wires + * them into this file in dependency order, and publishes their config and + * migrations. `hkm plugins list` shows what is enabled; `hkm plugins domains` + * shows which plugin provides a capability you are looking for. + * + * The full starter (`hkm new `, without --simple) comes with a working + * database, session, cookie, cache, view and validation stack already wired. + * + * ----------------------------------------------------------------------------- + * BOOT ORDER (top to bottom — the order matters) + * ----------------------------------------------------------------------------- + * 1. autoload find the kernel, register the class loaders + * 2. environment load the .env cascade BEFORE anything reads config + * 3. error net catch failures that happen before the kernel is live + * 4. kernel declare paths, routes, security, modules + * 5. build compile manifests and hand the kernel back + */ + +// ----------------------------------------------------------------------------- +// STEP 0 — AUTOLOAD +// kernel-autoload.php only DEFINES the resolver; calling it is what actually +// registers the kernel's class loaders. Requiring the file and forgetting the +// call leaves every framework class undefined, and the failure surfaces on the +// first one used rather than here. +// ----------------------------------------------------------------------------- +if (!function_exists('psp_require_kernel_autoload') || !function_exists('psp_kernel_home')) { + require_once __DIR__ . '/kernel-autoload.php'; +} +psp_require_kernel_autoload(); + +use AlfacodeTeam\PhpServicePlatform\Kernel\Kernel; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\CachePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Layers\CsrfTokenLayer; + +use Project\Bootstrap\EntryHelpers; +use Project\Infrastructure\FileCache; +use Project\Infrastructure\LazyDatabasePort; +use Project\Infrastructure\PdoDatabase; +use Project\Bootstrap\Environment\ErrorGuard; +use Project\Bootstrap\Environment\LoadEnvironment; + +// ----------------------------------------------------------------------------- +// STEP 1 — PATHS +// Flat layout: the scaffolded directory IS the project, so this file's +// grandparent (bootstrap → app → root) is the project root. +// ----------------------------------------------------------------------------- +$projectRoot = dirname(__DIR__, 2); + +// ----------------------------------------------------------------------------- +// STEP 2 — DOMAIN RESOLUTION +// Turn the request's Host header into a DomainContext (which project face is +// being served, and its features). Null under CLI and workers — no Host header +// there, which is expected and handled downstream. +// ----------------------------------------------------------------------------- +$domain = EntryHelpers::resolveDomain($projectRoot, $_SERVER['HTTP_HOST'] ?? null); + +// ----------------------------------------------------------------------------- +// STEP 3 — ENVIRONMENT +// Load .env before anything reads configuration. Real process environment +// always wins, so server config is never clobbered by a file. +// +// Values land in $_ENV/$_SERVER and NOT in putenv(), so read them with the +// env() helper — getenv() will not see them. +// ----------------------------------------------------------------------------- +LoadEnvironment::load($projectRoot, $domain, $_SERVER['argv'] ?? null); + +// ----------------------------------------------------------------------------- +// STEP 4 — PRE-KERNEL ERROR NET +// The outer safety net, for failures the kernel's own error pipeline cannot +// catch because it is not running yet: parse errors, fatals, out-of-memory. +// Writes to the same log the kernel uses, so everything lands in one file. +// ----------------------------------------------------------------------------- +ErrorGuard::install($projectRoot . '/var/logs/errors.log'); + +// ----------------------------------------------------------------------------- +// STEP 5 — THE KERNEL +// ----------------------------------------------------------------------------- +return Kernel::configure() + + // Where things live. Flat layout, so both are the project root. + ->withBasePath($projectRoot) + ->withProjectPath($projectRoot) + + // ------------------------------------------------------------------------- + // PORTS + // ------------------------------------------------------------------------- + // The kernel requires a DatabasePort and a CachePort to be bound before it + // will boot. These two are the kernel's OWN implementations — no plugin + // involved — so an empty project starts and serves immediately. + // + // Both are deliberately modest, and both are meant to be replaced: + // + // hkm plugins install database // pooled multi-driver adapter + // hkm plugins install redis-cache // Redis CachePort + QueuePort + // + // Installing either one rewrites the binding below to use it. + ->withPorts([ + // Lazy: the closure runs on FIRST USE, not at boot. A project with no + // database configured therefore boots and serves normally, and only a + // request that actually touches the database pays for a connection — + // or fails, which is the honest moment to find out DB_DSN is unset. + DatabasePort::class => new LazyDatabasePort( + static fn (): PdoDatabase => new PdoDatabase( + env('DB_DSN', 'sqlite:' . $projectRoot . '/var/database.sqlite'), + env('DB_USERNAME'), + env('DB_PASSWORD'), + ), + ), + + // File-backed, so a cached value survives between requests under + // PHP-FPM (an in-memory cache would not — each request is a new + // process, and every read would miss). + CachePort::class => new FileCache($projectRoot . '/var/cache/data'), + ]) + + // Routes come from proj.json — never from PHP. Declaring them as data is + // what lets the kernel compile a route manifest at build time and resolve a + // request without loading a single module. + ->withRoutes(EntryHelpers::projectRoutes($projectRoot)) + // Route GROUPS from proj.json: a prefix / filters / requires / name + // prefix / SITE stated once for every route inside the group, and + // expanded into flat routes at boot. `site` is part of the route key, + // so one project can answer `GET /` differently per group of hosts. + ->withRouteGroups(EntryHelpers::projectRouteGroups($projectRoot)) + // The hosts this project serves. A route grouped under a domain that is + // not in proj.json "domains" fails the boot — nothing could ever reach it. + ->withProjectDomains(EntryHelpers::projectDomains($projectRoot)) + + // A project can also switch OFF a route a plugin declares, without forking + // the plugin: proj.json "routePolicy": { "disable": ["GET /register"] }. + ->withRoutePolicy(EntryHelpers::projectRoutePolicy($projectRoot)) + + ->withSecurity([ + // The only security layer the kernel ships: stateless HMAC-signed CSRF + // tokens. Nothing is stored and no cookie value is trusted as the + // token, so cookie injection cannot bypass it. + // + // The secret defaults to APP_KEY. An EMPTY APP_KEY fails closed — every + // state-changing request is denied — so set one before serving traffic: + // hkm key:generate + new CsrfTokenLayer( + headerName: 'X-CSRF-Token', + formField: '_csrf_token', + lifetime: 43200, // 12 hours, in seconds + // Paths that never carry a browser session; APIs authenticate with + // a token instead, for which CSRF is meaningless. + exemptPaths: ['/api'], + ), + + // Authentication is NOT here. The kernel ships no token validator on + // purpose — add the Auth plugin and its layers when you need accounts: + // hkm plugins install auth + ]) + + // ------------------------------------------------------------------------- + // MODULES + // ------------------------------------------------------------------------- + // Empty, and that is the point of --simple. `hkm plugins install ` + // adds entries here for you, in dependency order, with a comment saying + // what each one solves. + // + // A module listed here is loaded ON DEMAND: only when a route being served + // needs it. Listing one costs nothing until something asks for it. + ->withModules([ + // + ]) + + // ------------------------------------------------------------------------- + // ESSENTIAL MODULES + // ------------------------------------------------------------------------- + // Registered into EVERY request, needed or not. Reserve this for + // cross-cutting request-scoped infrastructure (sessions, cookies) that + // cannot be an app-lifetime port — and keep the list short, because each + // entry and its whole dependency graph registers on every single request. + // + // Read from proj.json "essentials": [...], so which plugins are global is a + // deployment decision rather than a code edit. + ->withEssentialModules(EntryHelpers::projectEssentials($projectRoot)) + + // Compile-only: this validates config and compiles the manifests. The + // entry point materializes the kernel on its first http()/cli() call. + ->build(); diff --git a/tests/Fixtures/PrefixedModule/Provider.php b/tests/Fixtures/PrefixedModule/Provider.php new file mode 100644 index 0000000..83cf7b9 --- /dev/null +++ b/tests/Fixtures/PrefixedModule/Provider.php @@ -0,0 +1,16 @@ +root = sys_get_temp_dir() . '/hkm-bootstamp-' . bin2hex(random_bytes(6)); + mkdir($this->root . '/var/cache/manifests', 0775, true); + mkdir($this->root . '/config', 0775, true); + + $this->previousProject = Paths::project(); + Paths::setBase($this->root); + Paths::setProject($this->root); + + $this->previousEnv = $_ENV['BOOT_CACHE'] ?? false; + + // A compiled manifest must exist for a cached boot to be usable at all. + ManifestWriter::write('route-manifest.php', ['GET /' => ['handler' => 'C@m']]); + } + + protected function tearDown(): void + { + Paths::setProject($this->previousProject); + + if ($this->previousEnv === false) { + unset($_ENV['BOOT_CACHE']); + } else { + $_ENV['BOOT_CACHE'] = $this->previousEnv; + } + + foreach (glob($this->root . '/var/cache/manifests/*') ?: [] as $f) { + @unlink($f); + } + foreach (glob($this->root . '/config/*') ?: [] as $f) { + @unlink($f); + } + @rmdir($this->root . '/config'); + @rmdir($this->root . '/var/cache/manifests'); + @rmdir($this->root . '/var/cache'); + @rmdir($this->root . '/var'); + @rmdir($this->root); + } + + private function hash(mixed $inputs = ['modules' => ['A']]): string + { + return BootStamp::hash(is_array($inputs) ? $inputs : [$inputs]); + } + + // ── The flag ──────────────────────────────────────────────────────────── + + public function test_it_is_off_unless_explicitly_enabled(): void + { + unset($_ENV['BOOT_CACHE']); + self::assertFalse(BootStamp::enabled()); + + $_ENV['BOOT_CACHE'] = ''; + self::assertFalse(BootStamp::enabled()); + + $_ENV['BOOT_CACHE'] = '0'; + self::assertFalse(BootStamp::enabled(), 'a falsy value must not enable it'); + } + + public function test_it_is_on_for_a_truthy_value(): void + { + foreach (['1', 'true', 'on', 'yes'] as $value) { + $_ENV['BOOT_CACHE'] = $value; + self::assertTrue(BootStamp::enabled(), "[{$value}] should enable the cache"); + } + } + + // ── Hit ───────────────────────────────────────────────────────────────── + + public function test_a_fresh_stamp_is_a_hit_and_returns_the_cached_essentials(): void + { + BootStamp::write($this->hash(), [], ['App\\SessionProvider']); + + $cached = BootStamp::read($this->hash()); + + self::assertNotNull($cached); + // Recomputing these means re-reading every module.json — the exact cost + // the cache exists to avoid — so they ride along with it. + self::assertSame(['App\\SessionProvider'], $cached['essentials']); + } + + public function test_no_stamp_at_all_is_a_miss(): void + { + self::assertNull(BootStamp::read($this->hash())); + } + + // ── Every way it must MISS ────────────────────────────────────────────── + + public function test_changed_builder_inputs_miss(): void + { + // proj.json and bootstrap/app.php reach the kernel as PHP arrays, so this + // covers edits to both without stat'ing either. + BootStamp::write($this->hash(['modules' => ['A']]), [], []); + + self::assertNull(BootStamp::read($this->hash(['modules' => ['A', 'B']]))); + } + + public function test_a_modified_source_file_misses(): void + { + $file = $this->root . '/module.json'; + file_put_contents($file, '{"solves":"a"}'); + + BootStamp::write($this->hash(), [$file], []); + self::assertNotNull(BootStamp::read($this->hash())); + + file_put_contents($file, '{"solves":"a","routes":[]}'); + clearstatcache(); + + self::assertNull(BootStamp::read($this->hash()), 'size changed'); + } + + public function test_a_deleted_source_file_misses(): void + { + $file = $this->root . '/module.json'; + file_put_contents($file, '{"solves":"a"}'); + BootStamp::write($this->hash(), [$file], []); + + unlink($file); + clearstatcache(); + + self::assertNull(BootStamp::read($this->hash())); + } + + public function test_a_modified_config_file_misses(): void + { + file_put_contents($this->root . '/config/mail.php', ' "a"];'); + BootStamp::write($this->hash(), [], []); + self::assertNotNull(BootStamp::read($this->hash())); + + file_put_contents($this->root . '/config/mail.php', ' "bbbbb"];'); + clearstatcache(); + + self::assertNull(BootStamp::read($this->hash())); + } + + public function test_an_ADDED_config_file_misses(): void + { + // The subtle one: a new file appears in no recorded entry, so only the + // per-directory count catches it. + file_put_contents($this->root . '/config/mail.php', 'hash(), [], []); + self::assertNotNull(BootStamp::read($this->hash())); + + file_put_contents($this->root . '/config/queue.php', 'hash())); + } + + public function test_a_REMOVED_config_file_misses(): void + { + file_put_contents($this->root . '/config/mail.php', 'root . '/config/queue.php', 'hash(), [], []); + + unlink($this->root . '/config/queue.php'); + clearstatcache(); + + self::assertNull(BootStamp::read($this->hash())); + } + + public function test_a_missing_compiled_manifest_misses(): void + { + // The stamp may be pristine while the manifests it vouches for were + // cleared by a deploy. Never serve from a cache with nothing behind it. + BootStamp::write($this->hash(), [], []); + unlink(Paths::cache('manifests/route-manifest.php')); + + self::assertNull(BootStamp::read($this->hash())); + } + + public function test_a_plugin_config_directory_beside_a_module_json_is_watched(): void + { + // BootStamp derives each plugin's config/ from where its module.json is, + // because that is what CompileConfigManifestStage globs. + mkdir($this->root . '/plugin/config', 0775, true); + $module = $this->root . '/plugin/module.json'; + file_put_contents($module, '{"solves":"a"}'); + file_put_contents($this->root . '/plugin/config/thing.php', 'hash(), [$module], []); + self::assertNotNull(BootStamp::read($this->hash())); + + file_put_contents($this->root . '/plugin/config/thing.php', ' true];'); + clearstatcache(); + self::assertNull(BootStamp::read($this->hash())); + + @unlink($this->root . '/plugin/config/thing.php'); + @unlink($module); + @rmdir($this->root . '/plugin/config'); + @rmdir($this->root . '/plugin'); + } +} diff --git a/tests/Unit/Kernel/Boot/RouteCompilationTest.php b/tests/Unit/Kernel/Boot/RouteCompilationTest.php new file mode 100644 index 0000000..6401bb2 --- /dev/null +++ b/tests/Unit/Kernel/Boot/RouteCompilationTest.php @@ -0,0 +1,281 @@ +root = sys_get_temp_dir() . '/hkm-routecompile-' . bin2hex(random_bytes(6)); + mkdir($this->root . '/var/cache/manifests', 0775, true); + + $this->previousProject = Paths::project(); + Paths::setBase($this->root); + Paths::setProject($this->root); + } + + protected function tearDown(): void + { + Paths::setProject($this->previousProject); + + foreach (glob($this->root . '/var/cache/manifests/*') ?: [] as $f) { + @unlink($f); + } + @rmdir($this->root . '/var/cache/manifests'); + @rmdir($this->root . '/var/cache'); + @rmdir($this->root . '/var'); + @rmdir($this->root); + } + + /** + * @param list> $projectRoutes + * @param list $modules + * @param list $disable + */ + private function compile(array $projectRoutes = [], array $modules = [], array $disable = []): void + { + (new CompileRouteManifestStage( + $modules, + projectRoutes: $projectRoutes, + disabledRoutes: $disable, + reader: new ManifestReader(), + ))->run(); + } + + /** @return array */ + private function manifest(string $file = 'route-manifest.php'): array + { + return ManifestReader::readCompiled($file); + } + + // ── Precompilation ────────────────────────────────────────────────────── + + public function test_the_handler_split_is_baked_into_the_entry(): void + { + $this->compile([['method' => 'GET', 'path' => '/x', 'handler' => 'App\\C@show']]); + + $entry = $this->manifest()['GET /x']; + self::assertSame('App\\C', $entry['class']); + self::assertSame('show', $entry['action']); + self::assertSame('App\\C@show', $entry['handler'], 'the original stays for existing readers'); + } + + public function test_filter_specs_are_parsed_at_boot(): void + { + $this->compile([[ + 'method' => 'GET', 'path' => '/x', 'handler' => 'App\\C@show', + 'filters' => ['auth', 'throttle:60,1'], + ]]); + + $entry = $this->manifest()['GET /x']; + + self::assertSame(['auth', 'throttle:60,1'], $entry['filters'], 'raw specs are preserved'); + self::assertSame( + [ + ['alias' => 'auth', 'args' => []], + ['alias' => 'throttle', 'args' => ['60', '1']], + ], + $entry['filter_specs'], + ); + } + + public function test_the_dependency_graph_key_is_precomputed(): void + { + $this->compile([['method' => 'GET', 'path' => '/x', 'handler' => 'App\\C@show']]); + + self::assertSame('__project__|', $this->manifest()['GET /x']['graph_key']); + } + + public function test_a_dynamic_route_carries_its_compiled_regex(): void + { + $this->compile([['method' => 'GET', 'path' => '/u/{id:num}', 'handler' => 'App\\C@show']]); + + $entry = $this->manifest()['GET /u/{id:num}']; + + self::assertStringEndsWith('$#D', $entry['regex'], 'anchored with the D modifier'); + self::assertSame([['name' => 'id', 'type' => 'num', 'optional' => false]], $entry['params']); + } + + public function test_a_static_route_carries_no_regex(): void + { + $this->compile([['method' => 'GET', 'path' => '/x', 'handler' => 'App\\C@show']]); + + self::assertArrayNotHasKey('regex', $this->manifest()['GET /x']); + } + + // ── The derived manifests ─────────────────────────────────────────────── + + public function test_the_matcher_index_is_written_alongside_the_manifest(): void + { + $this->compile([ + ['method' => 'GET', 'path' => '/health', 'handler' => 'App\\C@up'], + ['method' => 'GET', 'path' => '/u/{id}', 'handler' => 'App\\C@show'], + ['method' => 'GET', 'path' => '/{slug}', 'handler' => 'App\\C@page'], + ]); + + $index = $this->manifest('route-index.php'); + + // '' is the shared site every unscoped route lives in. + self::assertArrayHasKey('GET /health', $index['static']['']); + self::assertArrayHasKey('u', $index['dynamic']['']['GET']['buckets']); + self::assertCount(1, $index['dynamic']['']['GET']['wild']); + self::assertSame(['GET'], $index['methods']); + self::assertSame([], $index['domains']); + } + + public function test_the_name_index_is_written_alongside_the_manifest(): void + { + $this->compile([ + ['method' => 'GET', 'path' => '/u/{id}', 'handler' => 'App\\C@show', 'name' => 'user.show'], + ['method' => 'POST', 'path' => '/u', 'handler' => 'App\\C@store'], + ]); + + self::assertSame( + ['user.show' => ['path' => '/u/{id}', 'method' => 'GET', 'domain' => '']], + $this->manifest('route-names.php'), + ); + } + + // ── Module-level declarations ─────────────────────────────────────────── + + public function test_a_module_route_prefix_is_applied_to_every_route(): void + { + $this->compile(modules: [PrefixedProvider::class]); + + $manifest = $this->manifest(); + + self::assertArrayHasKey('GET /api/v1/things', $manifest); + self::assertArrayHasKey('POST /api/v1/things', $manifest); + self::assertArrayHasKey('GET /api/v1/things/{id:num}', $manifest); + } + + public function test_module_default_filters_are_merged_in_front(): void + { + $this->compile(modules: [PrefixedProvider::class]); + + self::assertSame( + ['auth', 'throttle:60,1'], + $this->manifest()['GET /api/v1/things']['filters'], + ); + } + + public function test_a_route_overrides_a_module_default_of_the_same_alias(): void + { + $this->compile(modules: [PrefixedProvider::class]); + + // The route declares throttle:5,1 — it must replace the module's 60,1 + // rather than run the throttle stage twice with different budgets. + self::assertSame( + ['auth', 'throttle:5,1'], + $this->manifest()['POST /api/v1/things']['filters'], + ); + } + + public function test_a_prefixed_route_keeps_its_name(): void + { + $this->compile(modules: [PrefixedProvider::class]); + + self::assertSame( + ['things.index' => ['path' => '/api/v1/things', 'method' => 'GET', 'domain' => '']], + $this->manifest('route-names.php'), + ); + } + + // ── Validation: each of these used to compile to a dead route ─────────── + + public function test_a_path_without_a_leading_slash_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/does not start with/'); + + $this->compile([['method' => 'GET', 'path' => 'users', 'handler' => 'App\\C@show']]); + } + + public function test_a_duplicate_parameter_name_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/repeats the capture name/'); + + $this->compile([['method' => 'GET', 'path' => '/a/{id}/b/{id}', 'handler' => 'App\\C@show']]); + } + + public function test_a_parameter_name_pcre_rejects_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/not a usable capture name/'); + + $this->compile([['method' => 'GET', 'path' => '/{2fa}', 'handler' => 'App\\C@show']]); + } + + public function test_a_handler_without_a_separator_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches("/'Controller@method' format/"); + + $this->compile([['method' => 'GET', 'path' => '/x', 'handler' => 'App\\C']]); + } + + public function test_a_handler_with_two_separators_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches("/'Controller@method' format/"); + + $this->compile([['method' => 'GET', 'path' => '/x', 'handler' => 'App\\C@a@b']]); + } + + public function test_a_non_string_filter_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/not a string/'); + + $this->compile([[ + 'method' => 'GET', 'path' => '/x', 'handler' => 'App\\C@show', + 'filters' => [['auth']], + ]]); + } + + // ── Compatibility: a name that sanitises to something valid still works ── + + public function test_a_hyphenated_parameter_name_still_compiles(): void + { + // Sanitisation to 'userid' predates typing; tightening the grammar here + // would silently kill routes that work today. + $this->compile([['method' => 'GET', 'path' => '/u/{user-id}', 'handler' => 'App\\C@show']]); + + self::assertSame( + [['name' => 'userid', 'type' => '', 'optional' => false]], + $this->manifest()['GET /u/{user-id}']['params'], + ); + } + + public function test_handler_verification_is_off_by_default(): void + { + // A controller class that does not exist must NOT fail the build unless + // ROUTE_VERIFY_HANDLERS is explicitly enabled. + $this->compile([['method' => 'GET', 'path' => '/x', 'handler' => 'No\\Such\\Controller@show']]); + + self::assertArrayHasKey('GET /x', $this->manifest()); + } +} diff --git a/tests/Unit/Kernel/Boot/RouteGroupTest.php b/tests/Unit/Kernel/Boot/RouteGroupTest.php new file mode 100644 index 0000000..149bd79 --- /dev/null +++ b/tests/Unit/Kernel/Boot/RouteGroupTest.php @@ -0,0 +1,818 @@ +root = sys_get_temp_dir() . '/hkm-routegroup-' . bin2hex(random_bytes(6)); + mkdir($this->root . '/var/cache/manifests', 0775, true); + + $this->previousProject = Paths::project(); + Paths::setBase($this->root); + Paths::setProject($this->root); + } + + protected function tearDown(): void + { + Paths::setProject($this->previousProject); + + foreach (glob($this->root . '/var/cache/manifests/*') ?: [] as $f) { + @unlink($f); + } + @rmdir($this->root . '/var/cache/manifests'); + @rmdir($this->root . '/var/cache'); + @rmdir($this->root . '/var'); + @rmdir($this->root); + } + + /** + * @param array $groups + * @param list> $projectRoutes + * @param list $disable + * @param list $domains + */ + private function compile( + array $groups = [], + array $projectRoutes = [], + array $disable = [], + array $domains = [], + ): void { + (new CompileRouteManifestStage( + [], + projectRoutes: $projectRoutes, + disabledRoutes: $disable, + reader: new ManifestReader(), + projectGroups: $groups, + projectDomains: $domains, + ))->run(); + } + + /** @return array */ + private function manifest(string $file = 'route-manifest.php'): array + { + return ManifestReader::readCompiled($file); + } + + private function matcher(): RouteMatcher + { + return RouteMatcher::fromCompiled($this->manifest('route-index.php')); + } + + /** @return array{entry: array, params: array}|null */ + private function matchHost(string $path, string $host): ?array + { + return $this->matcher()->match('GET', $path, RouteIndex::hostCandidates($host)); + } + + // ── Key format ────────────────────────────────────────────────────────── + + public function test_an_ungrouped_route_key_is_unchanged(): void + { + self::assertSame('GET /x', RouteIndex::key('GET', '', '/x')); + self::assertSame( + ['method' => 'GET', 'domain' => '', 'path' => '/x'], + RouteIndex::parseKey('GET /x'), + ); + } + + public function test_a_grouped_key_keeps_the_path_parseable_by_an_older_reader(): void + { + $key = RouteIndex::key('GET', 'africavoting.local', '/dash'); + + self::assertSame('GET@africavoting.local /dash', $key); + + // The decisive property: a consumer that splits on the first space and + // has never heard of domain groups still gets a clean, leading-slash + // path, and sees a method no HTTP verb matches — so it SKIPS rather than + // emitting a corrupted URL. + [$verb, $path] = explode(' ', $key, 2); + self::assertSame('/dash', $path); + self::assertNotSame('GET', strtoupper($verb)); + } + + // ── Grouping ──────────────────────────────────────────────────────────── + + public function test_a_group_applies_its_prefix_filters_and_name(): void + { + $this->compile([ + 'groups' => [[ + 'prefix' => '/admin', + 'filters' => ['auth'], + 'name' => 'admin.', + 'routes' => [ + ['method' => 'GET', 'path' => '/users', 'handler' => 'A\\C@index', 'name' => 'users'], + ], + ]], + ]); + + $entry = $this->manifest()['GET /admin/users']; + + self::assertSame(['auth'], $entry['filters']); + self::assertSame('admin.users', $entry['name']); + } + + public function test_groups_nest_and_accumulate(): void + { + $this->compile([ + 'routePrefix' => '/api', + 'groups' => [[ + 'prefix' => '/v1', + 'filters' => ['auth'], + 'name' => 'api.', + 'groups' => [[ + 'prefix' => '/admin', + 'filters' => ['shield'], + 'name' => 'admin.', + 'routes' => [ + ['method' => 'GET', 'path' => '/stats', 'handler' => 'A\\C@stats', 'name' => 'stats'], + ], + ]], + ]], + ]); + + $entry = $this->manifest()['GET /api/v1/admin/stats']; + + self::assertSame(['auth', 'shield'], $entry['filters']); + self::assertSame('api.admin.stats', $entry['name']); + } + + public function test_an_inner_declaration_overrides_an_outer_filter_of_the_same_alias(): void + { + $this->compile([ + 'routeFilters' => ['throttle:60,1'], + 'groups' => [[ + 'routes' => [ + ['method' => 'GET', 'path' => '/burst', 'handler' => 'A\\C@x', 'filters' => ['throttle:5,1']], + ], + ]], + ]); + + // Replaced, not doubled — running the throttle stage twice with two + // different budgets is never what was meant. + self::assertSame(['throttle:5,1'], $this->manifest()['GET /burst']['filters']); + } + + public function test_an_unnamed_route_stays_unnamed_inside_a_named_group(): void + { + // A group's name is a PREFIX for routes that opted into a name; it does + // not invent names for routes that never asked for one. + $this->compile([ + 'groups' => [[ + 'name' => 'admin.', + 'routes' => [['method' => 'GET', 'path' => '/a', 'handler' => 'A\\C@a']], + ]], + ]); + + self::assertNull($this->manifest()['GET /a']['name']); + self::assertSame([], $this->manifest('route-names.php')); + } + + public function test_routes_declared_alongside_groups_are_not_dropped(): void + { + // withRoutes() routes and a routes[] passed to withRouteGroups() are BOTH + // the project's, so they concatenate. This was an array union, which keeps + // the LEFT key — so the second list vanished without a word. + $this->compile( + ['routePrefix' => '/api/v2', 'routes' => [ + ['method' => 'GET', 'path' => '/ping', 'handler' => 'A\\C@ping'], + ]], + [['method' => 'GET', 'path' => '/direct', 'handler' => 'A\\C@direct']], + ); + + $manifest = $this->manifest(); + + self::assertArrayHasKey('GET /api/v2/ping', $manifest, 'routes[] beside groups must survive'); + self::assertArrayHasKey('GET /api/v2/direct', $manifest, 'withRoutes() routes must survive'); + } + + public function test_a_route_replaces_a_module_wide_filter_of_the_same_alias(): void + { + $this->compile([ + 'routeFilters' => ['throttle:60,1'], + 'routes' => [ + ['method' => 'GET', 'path' => '/ping', 'handler' => 'A\\C@ping'], + ['method' => 'POST', 'path' => '/import', 'handler' => 'A\\C@import', + 'filters' => ['auth', 'throttle:5,1']], + ], + ]); + + $manifest = $this->manifest(); + + self::assertSame(['throttle:60,1'], $manifest['GET /ping']['filters']); + self::assertSame(['auth', 'throttle:5,1'], $manifest['POST /import']['filters']); + } + + public function test_runaway_group_nesting_fails_the_boot(): void + { + $group = ['routes' => [['method' => 'GET', 'path' => '/x', 'handler' => 'A\\C@x']]]; + for ($i = 0; $i < 20; $i++) { + $group = ['groups' => [$group]]; + } + + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/nest more than/'); + + $this->compile($group); + } + + // ── The compiler GROUPS, it does not VERIFY ───────────────────────────── + + public function test_the_domain_is_taken_verbatim(): void + { + $this->compile([ + 'groups' => [ + ['domain' => 'africavoting.local', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\Af@home']]], + ['domain' => '*.africavoting.local', 'routes' => [['method' => 'GET', 'path' => '/w', 'handler' => 'A\\W@home']]], + ['subdomain' => 'organizer', 'routes' => [['method' => 'GET', 'path' => '/o', 'handler' => 'A\\Or@home']]], + ], + ]); + + $manifest = $this->manifest(); + + self::assertArrayHasKey('GET@africavoting.local /', $manifest); + self::assertArrayHasKey('GET@*.africavoting.local /w', $manifest); + self::assertArrayHasKey('GET@organizer /o', $manifest); + } + + public function test_a_domain_this_deployment_does_not_serve_still_compiles(): void + { + // Nothing is resolved or checked. A domain nothing requests simply never + // matches — exactly like a path nothing requests. + $this->compile([ + 'groups' => [['domain' => 'not-a-host-we-serve.example', 'routes' => [ + ['method' => 'GET', 'path' => '/x', 'handler' => 'A\\C@x'], + ]]], + ]); + + self::assertArrayHasKey('GET@not-a-host-we-serve.example /x', $this->manifest()); + } + + public function test_the_domain_is_lower_cased(): void + { + $this->compile([ + 'groups' => [['domain' => ' AfricaVoting.LOCAL ', 'routes' => [ + ['method' => 'GET', 'path' => '/x', 'handler' => 'A\\C@x'], + ]]], + ]); + + self::assertArrayHasKey('GET@africavoting.local /x', $this->manifest()); + } + + // ── Host → group matching ─────────────────────────────────────────────── + + public function test_host_candidates_are_ordered_most_specific_first(): void + { + self::assertSame( + ['organizer.africavoting.local', '*.africavoting.local', '*.local', 'organizer'], + RouteIndex::hostCandidates('organizer.africavoting.local'), + ); + + // No subdomain to speak of, so no bare label. + self::assertSame(['hkmvote.local', '*.local'], RouteIndex::hostCandidates('hkmvote.local')); + self::assertSame(['localhost'], RouteIndex::hostCandidates('localhost')); + self::assertSame([], RouteIndex::hostCandidates('')); + } + + public function test_a_port_and_case_are_stripped_from_the_request_host(): void + { + self::assertSame(['hkmvote.local', '*.local'], RouteIndex::hostCandidates('HKMVote.local:8443')); + } + + public function test_two_domains_may_declare_the_same_path(): void + { + $this->compile([ + 'groups' => [ + ['domain' => 'hkmvote.local', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\Vote@home']]], + ['domain' => 'africavoting.local', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\Africa@home']]], + ], + ]); + + // As one key these would have been a duplicate-route boot failure, and + // `faces` could only have hidden one of them. + self::assertSame('A\\Vote@home', $this->matchHost('/', 'hkmvote.local')['entry']['handler']); + self::assertSame('A\\Africa@home', $this->matchHost('/', 'africavoting.local')['entry']['handler']); + self::assertNull($this->matchHost('/', 'unknown.example')); + } + + public function test_a_bare_subdomain_group_matches_that_subdomain_on_any_host(): void + { + $this->compile([ + 'groups' => [['subdomain' => 'organizer', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\Org@home'], + ]]], + ]); + + self::assertSame('A\\Org@home', $this->matchHost('/', 'organizer.africavoting.local')['entry']['handler']); + self::assertSame('A\\Org@home', $this->matchHost('/', 'organizer.hkmvote.local')['entry']['handler']); + self::assertNull($this->matchHost('/', 'app.hkmvote.local')); + } + + public function test_a_wildcard_group_matches_any_subdomain_of_its_parent(): void + { + $this->compile([ + 'groups' => [['domain' => '*.africavoting.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\Wild@home'], + ]]], + ]); + + self::assertSame('A\\Wild@home', $this->matchHost('/', 'news.africavoting.local')['entry']['handler']); + self::assertNull($this->matchHost('/', 'africavoting.local'), 'the apex is not a subdomain of itself'); + } + + public function test_an_exact_host_beats_a_wildcard_which_beats_a_bare_subdomain(): void + { + $this->compile([ + 'groups' => [ + ['subdomain' => 'organizer', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\Sub@home']]], + ['domain' => '*.africavoting.local', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\Wild@home']]], + ['domain' => 'organizer.africavoting.local', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\Exact@home']]], + ], + ]); + + // All three could match; specificity decides, not declaration order. + self::assertSame('A\\Exact@home', $this->matchHost('/', 'organizer.africavoting.local')['entry']['handler']); + self::assertSame('A\\Wild@home', $this->matchHost('/', 'news.africavoting.local')['entry']['handler']); + self::assertSame('A\\Sub@home', $this->matchHost('/', 'organizer.hkmvote.local')['entry']['handler']); + } + + public function test_a_grouped_route_overrides_the_shared_one_on_that_host_only(): void + { + $this->compile( + ['groups' => [['subdomain' => 'organizer', 'routes' => [ + ['method' => 'GET', 'path' => '/dashboard', 'handler' => 'A\\Organizer@dash'], + ]]]], + [['method' => 'GET', 'path' => '/dashboard', 'handler' => 'A\\Shared@dash']], + ); + + self::assertSame('A\\Organizer@dash', $this->matchHost('/dashboard', 'organizer.hkmvote.local')['entry']['handler']); + self::assertSame('A\\Shared@dash', $this->matchHost('/dashboard', 'app.hkmvote.local')['entry']['handler']); + self::assertSame('A\\Shared@dash', $this->matcher()->match('GET', '/dashboard')['entry']['handler']); + } + + public function test_a_shared_static_route_still_beats_a_grouped_dynamic_one(): void + { + // Static-beats-dynamic is an invariant. Searching a domain group + // end-to-end first would let its /users/{id} swallow the shared literal + // /users/me. + $this->compile( + ['groups' => [['domain' => 'hkmvote.local', 'routes' => [ + ['method' => 'GET', 'path' => '/users/{id}', 'handler' => 'A\\Vote@show'], + ]]]], + [['method' => 'GET', 'path' => '/users/me', 'handler' => 'A\\Shared@me']], + ); + + self::assertSame('A\\Shared@me', $this->matchHost('/users/me', 'hkmvote.local')['entry']['handler']); + self::assertSame('A\\Vote@show', $this->matchHost('/users/7', 'hkmvote.local')['entry']['handler']); + } + + public function test_the_index_reports_which_domains_exist(): void + { + $this->compile([ + 'groups' => [['domain' => 'hkmvote.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]], + ]); + + self::assertSame(['hkmvote.local'], $this->manifest('route-index.php')['domains']); + } + + public function test_an_ungrouped_application_is_unaffected(): void + { + $this->compile([], [['method' => 'GET', 'path' => '/x', 'handler' => 'A\\C@x']]); + + self::assertSame([], $this->manifest('route-index.php')['domains']); + self::assertNotNull($this->matcher()->match('GET', '/x')); + self::assertNotNull($this->matchHost('/x', 'anything.example')); + } + + // ── Checked against the hosts the project actually serves ─────────────── + + public function test_a_registered_domain_compiles(): void + { + $this->compile( + ['groups' => [['domain' => 'africavoting.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]]], + domains: ['hkmvote.local', 'africavoting.local'], + ); + + self::assertArrayHasKey('GET@africavoting.local /', $this->manifest()); + } + + public function test_an_unregistered_domain_fails_the_boot(): void + { + // Nothing could ever reach it: a request for that host would have been + // routed to a different project, or refused, before the router ran. + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/this project does not serve/'); + + $this->compile( + ['groups' => [['domain' => 'typo.africavotng.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]]], + domains: ['hkmvote.local', 'africavoting.local'], + ); + } + + public function test_the_failure_lists_the_registered_domains(): void + { + try { + $this->compile( + ['groups' => [['domain' => 'nope.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]]], + domains: ['hkmvote.local'], + ); + self::fail('expected a BootException'); + } catch (BootException $e) { + self::assertStringContainsString('hkmvote.local', $e->getMessage()); + self::assertStringContainsString('subdomain', $e->getMessage(), 'points at the escape hatch'); + } + } + + public function test_a_wildcard_passes_when_its_parent_is_registered(): void + { + // The right tool for tenant hosts, which land in the database rather + // than in proj.json. + $this->compile( + ['groups' => [['domain' => '*.africavoting.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]]], + domains: ['africavoting.local'], + ); + + self::assertArrayHasKey('GET@*.africavoting.local /', $this->manifest()); + } + + public function test_a_wildcard_passes_when_a_registered_host_falls_under_it(): void + { + $this->compile( + ['groups' => [['domain' => '*.africavoting.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]]], + domains: ['organizer.africavoting.local'], + ); + + self::assertArrayHasKey('GET@*.africavoting.local /', $this->manifest()); + } + + public function test_a_bare_subdomain_is_never_checked(): void + { + // It answers on that label across EVERY domain, so there is no single + // registered host to check it against. + $this->compile( + ['groups' => [['subdomain' => 'api', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]]], + domains: ['example.com'], + ); + + self::assertArrayHasKey('GET@api /', $this->manifest()); + } + + public function test_a_project_that_registers_no_domains_is_not_checked(): void + { + $this->compile(['groups' => [['domain' => 'anything.at.all', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]]]); + + self::assertArrayHasKey('GET@anything.at.all /', $this->manifest()); + } + + // ── The two "global" guarantees ───────────────────────────────────────── + + public function test_an_ungrouped_route_is_reachable_from_every_domain(): void + { + $this->compile( + ['groups' => [['domain' => 'hkmvote.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\Vote@home'], + ]]]], + [['method' => 'GET', 'path' => '/health', 'handler' => 'A\\Shared@health']], + domains: ['hkmvote.local', 'africavoting.local'], + ); + + foreach (['hkmvote.local', 'africavoting.local', 'anything.example', 'localhost'] as $host) { + self::assertSame( + 'A\\Shared@health', + $this->matchHost('/health', $host)['entry']['handler'], + "ungrouped route must answer on {$host}", + ); + } + } + + public function test_a_subdomain_group_answers_on_that_label_of_every_domain(): void + { + // "api" without a domain ⇒ api.example.com AND api.example2.com AND any + // future host with that first label. + $this->compile(['groups' => [['subdomain' => 'api', 'prefix' => '/v1', 'routes' => [ + ['method' => 'GET', 'path' => '/ping', 'handler' => 'A\\Api@ping'], + ]]]]); + + foreach (['api.example.com', 'api.example2.com', 'api.brand-new.test'] as $host) { + self::assertSame( + 'A\\Api@ping', + $this->matchHost('/v1/ping', $host)['entry']['handler'], + "subdomain group must answer on {$host}", + ); + } + + self::assertNull($this->matchHost('/v1/ping', 'www.example.com')); + } + + // ── Names stay flat ───────────────────────────────────────────────────── + + public function test_route_names_remain_a_flat_namespace_across_domains(): void + { + // Two domains cannot both claim 'home'. UrlGenerator holds no request + // state — it could not pick between them — so this stays a boot failure + // and a group's name prefix is the intended fix. + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/Duplicate route name \[home\]/'); + + $this->compile(['groups' => [ + ['domain' => 'hkmvote.local', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\V@h', 'name' => 'home']]], + ['domain' => 'africavoting.local', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\A@h', 'name' => 'home']]], + ]]); + } + + public function test_a_group_name_prefix_disambiguates_two_domains(): void + { + $this->compile(['groups' => [ + ['domain' => 'hkmvote.local', 'name' => 'vote.', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\V@h', 'name' => 'home']]], + ['domain' => 'africavoting.local', 'name' => 'africa.', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\A@h', 'name' => 'home']]], + ]]); + + self::assertSame( + [ + 'vote.home' => ['path' => '/', 'method' => 'GET', 'domain' => 'hkmvote.local'], + 'africa.home' => ['path' => '/', 'method' => 'GET', 'domain' => 'africavoting.local'], + ], + $this->manifest('route-names.php'), + ); + } + + // ── A LIST of domains ─────────────────────────────────────────────────── + + public function test_a_group_may_name_several_domains_at_once(): void + { + $this->compile(['groups' => [[ + 'domain' => ['a.test', 'b.test'], + 'routes' => [['method' => 'GET', 'path' => '/dash', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test', 'b.test']); + + // One route per host, each with its own key — so either can later be + // overridden or disabled without touching the other. + self::assertArrayHasKey('GET@a.test /dash', $this->manifest()); + self::assertArrayHasKey('GET@b.test /dash', $this->manifest()); + + self::assertNotNull($this->matchHost('/dash', 'a.test')); + self::assertNotNull($this->matchHost('/dash', 'b.test')); + self::assertNull($this->matchHost('/dash', 'c.test')); + } + + public function test_a_route_may_name_several_domains_at_once(): void + { + $this->compile(domains: ['a.test', 'b.test'], projectRoutes: [ + ['method' => 'GET', 'path' => '/p', 'handler' => 'A\\A@h', 'domain' => ['a.test', 'b.test']], + ]); + + self::assertArrayHasKey('GET@a.test /p', $this->manifest()); + self::assertArrayHasKey('GET@b.test /p', $this->manifest()); + } + + public function test_a_subdomain_list_answers_on_each_label(): void + { + $this->compile(['groups' => [[ + 'subdomain' => ['admin', 'staff'], + 'routes' => [['method' => 'GET', 'path' => '/ops', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test', 'b.test']); + + self::assertNotNull($this->matchHost('/ops', 'admin.anything.test')); + self::assertNotNull($this->matchHost('/ops', 'staff.other.test')); + self::assertNull($this->matchHost('/ops', 'public.anything.test')); + } + + public function test_a_nested_group_composes_with_an_outer_list(): void + { + $this->compile(['groups' => [[ + 'domain' => ['a.test', 'b.test'], + 'groups' => [[ + 'prefix' => '/admin', + 'routes' => [['method' => 'GET', 'path' => '/x', 'handler' => 'A\\A@h']], + ]], + ]]], domains: ['a.test', 'b.test']); + + self::assertArrayHasKey('GET@a.test /admin/x', $this->manifest()); + self::assertArrayHasKey('GET@b.test /admin/x', $this->manifest()); + } + + public function test_a_repeated_domain_does_not_compile_the_route_twice(): void + { + // Would otherwise trip the duplicate-route guard and report a conflict + // the author has to work backwards to recognise as their own copy-paste. + $this->compile(['groups' => [[ + 'domain' => ['a.test', 'A.TEST', ' a.test '], + 'routes' => [['method' => 'GET', 'path' => '/dash', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test', 'b.test']); + + self::assertCount(1, $this->manifest()); + } + + public function test_every_domain_in_a_list_is_validated(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/does not serve/'); + + $this->compile(['groups' => [[ + 'domain' => ['a.test', 'not-registered.test'], + 'routes' => [['method' => 'GET', 'path' => '/dash', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test', 'b.test']); + } + + public function test_a_non_string_domain_fails_the_boot_instead_of_going_global(): void + { + // The regression this guards: a non-string used to fall through + // is_string() to '', which silently turned "these routes belong to this + // host" into "these routes answer on EVERY host" — the widest possible + // outcome, reached by accident, with nothing logged. + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/domain of type/'); + + $this->compile(['groups' => [[ + 'domain' => 42, + 'routes' => [['method' => 'GET', 'path' => '/dash', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test', 'b.test']); + } + + public function test_an_empty_domain_list_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/empty domain list/'); + + $this->compile(['groups' => [[ + 'domain' => [], + 'routes' => [['method' => 'GET', 'path' => '/dash', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test', 'b.test']); + } + + public function test_a_named_route_may_not_span_several_domains(): void + { + // Names are one flat namespace; one name cannot mean a different URL + // per host, because UrlGenerator holds no request state. + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/one flat namespace/'); + + $this->compile(domains: ['a.test', 'b.test'], projectRoutes: [ + ['method' => 'GET', 'path' => '/p', 'handler' => 'A\\A@h', + 'name' => 'p', 'domain' => ['a.test', 'b.test']], + ]); + } + + // ── domain AND subdomain together (subdomain is RELATIVE to domain) ──── + + public function test_a_subdomain_is_attached_to_the_declared_domain(): void + { + // A declared `domain` is BOTH a host in its own right AND the parent the + // subdomain attaches to. Before, the label compiled BARE — and a bare + // label spans every domain, so this also answered on admin.anyone-else.com. + $this->compile(['groups' => [[ + 'domain' => 'brand.test', + 'subdomain' => 'admin', + 'routes' => [['method' => 'GET', 'path' => '/ops', 'handler' => 'A\\A@h']], + ]]], domains: ['brand.test']); + + self::assertSame( + ['GET@brand.test /ops', 'GET@admin.brand.test /ops'], + array_keys($this->manifest()), + ); + + self::assertNotNull($this->matchHost('/ops', 'brand.test')); + self::assertNotNull($this->matchHost('/ops', 'admin.brand.test')); + // The decisive one: NOT that label on somebody else's domain. + self::assertNull($this->matchHost('/ops', 'admin.anyone-else.com')); + } + + public function test_every_label_attaches_to_every_domain(): void + { + $this->compile(['groups' => [[ + 'domain' => ['a.test', 'b.test'], + 'subdomain' => ['admin', 'staff'], + 'routes' => [['method' => 'GET', 'path' => '/ops', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test', 'b.test']); + + foreach ([ + 'GET@a.test /ops', 'GET@b.test /ops', + 'GET@admin.a.test /ops', 'GET@staff.a.test /ops', + 'GET@admin.b.test /ops', 'GET@staff.b.test /ops', + ] as $key) { + self::assertArrayHasKey($key, $this->manifest()); + } + self::assertCount(6, $this->manifest()); + } + + public function test_a_subdomain_with_no_domain_stays_global(): void + { + // Nothing to be relative to, so the documented bare-label meaning holds — + // this is what puts an `admin` panel on every brand. + $this->compile(['groups' => [[ + 'subdomain' => 'admin', + 'routes' => [['method' => 'GET', 'path' => '/ops', 'handler' => 'A\\A@h']], + ]]]); + + self::assertArrayHasKey('GET@admin /ops', $this->manifest()); + self::assertNotNull($this->matchHost('/ops', 'admin.anyone-else.com')); + } + + public function test_a_composed_host_needs_no_registration_of_its_own(): void + { + // Only the PARENT is in proj.json domains[]. DomainResolver reaches this + // project by suffix match on that parent, so the composed host is + // reachable and validating it separately would just be busywork. + $this->compile(['groups' => [[ + 'domain' => 'hkm.local', + 'subdomain' => ['api', 'auth'], + 'routes' => [['method' => 'GET', 'path' => '/x', 'handler' => 'A\\A@h']], + ]]], domains: ['hkm.local']); + + self::assertArrayHasKey('GET@api.hkm.local /x', $this->manifest()); + self::assertArrayHasKey('GET@auth.hkm.local /x', $this->manifest()); + } + + public function test_a_subdomain_may_not_attach_to_a_wildcard(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/cannot compose/'); + + $this->compile(['groups' => [[ + 'domain' => '*.hkm.local', + 'subdomain' => 'api', + 'routes' => [['method' => 'GET', 'path' => '/x', 'handler' => 'A\\A@h']], + ]]], domains: ['hkm.local']); + } + + public function test_a_wildcard_domain_alone_still_compiles(): void + { + // Regression: the wildcard guard above must not fire when there is no + // subdomain to attach — `{"domain": "*.x"}` composes nothing and is valid. + $this->compile(['groups' => [[ + 'domain' => '*.hkm.local', + 'routes' => [['method' => 'GET', 'path' => '/x', 'handler' => 'A\\A@h']], + ]]], domains: ['hkm.local']); + + self::assertArrayHasKey('GET@*.hkm.local /x', $this->manifest()); + } + + public function test_a_route_may_attach_a_subdomain_to_its_domain(): void + { + $this->compile(domains: ['a.test'], projectRoutes: [ + ['method' => 'GET', 'path' => '/p', 'handler' => 'A\\A@h', + 'domain' => 'a.test', 'subdomain' => 'api'], + ]); + + self::assertArrayHasKey('GET@a.test /p', $this->manifest()); + self::assertArrayHasKey('GET@api.a.test /p', $this->manifest()); + } + + public function test_a_bad_subdomain_is_reported_even_when_domain_is_valid(): void + { + // The subdomain used to be skipped entirely when a domain was present, + // so a mistake in it could not be reported at all. + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/domain of type/'); + + $this->compile(['groups' => [[ + 'domain' => 'a.test', + 'subdomain' => 42, + 'routes' => [['method' => 'GET', 'path' => '/ops', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test']); + } +} diff --git a/tests/Unit/Kernel/Pipelines/Http/RouteMatcherHardeningTest.php b/tests/Unit/Kernel/Pipelines/Http/RouteMatcherHardeningTest.php new file mode 100644 index 0000000..7717566 --- /dev/null +++ b/tests/Unit/Kernel/Pipelines/Http/RouteMatcherHardeningTest.php @@ -0,0 +1,285 @@ + $paths */ + private function matcher(array $paths, string $method = 'GET', bool $head = true): RouteMatcher + { + $manifest = []; + foreach ($paths as $path) { + $manifest[$method . ' ' . $path] = ['handler' => 'C@m', 'solves' => 'x']; + } + + return new RouteMatcher($manifest, headFallback: $head); + } + + // ── Percent-decoding: the segment guarantee must survive the decode ────── + + public function test_an_encoded_slash_cannot_smuggle_a_path_separator(): void + { + $m = $this->matcher(['/files/{name}']); + + // '%2F' is three ordinary characters, so it sails through [^/]+ — and the + // moment the controller decodes it, the "one segment" promise is gone. + self::assertNull($m->match('GET', '/files/..%2F..%2Fetc%2Fpasswd')); + } + + public function test_an_encoded_slash_is_rejected_for_a_typed_segment_too(): void + { + $m = $this->matcher(['/p/{slug:slug}']); + + self::assertNull($m->match('GET', '/p/a%2Fb')); + } + + public function test_a_captured_value_reaches_the_controller_decoded(): void + { + $m = $this->matcher(['/users/{name}']); + + // Previously the controller received the raw 'Jos%C3%A9'. + self::assertSame('José', $m->match('GET', '/users/Jos%C3%A9')['params']['name']); + } + + public function test_a_nul_byte_is_never_delivered(): void + { + $m = $this->matcher(['/files/{name:any}']); + + self::assertNull($m->match('GET', '/files/report%00.pdf')); + } + + public function test_an_undecodable_percent_is_passed_through_unchanged(): void + { + $m = $this->matcher(['/p/{code}']); + + // '%zz' is not a valid escape; rawurldecode leaves it alone and so do we. + self::assertSame('100%zz', $m->match('GET', '/p/100%zz')['params']['code']); + } + + // ── Anchoring ─────────────────────────────────────────────────────────── + + public function test_a_trailing_newline_does_not_satisfy_the_end_anchor(): void + { + $m = $this->matcher(['/users/{id:num}']); + + // Without the D modifier, PCRE's '$' also matches before a final newline. + self::assertNull($m->match('GET', "/users/12\n")); + self::assertNotNull($m->match('GET', '/users/12')); + } + + public function test_a_literal_dot_in_a_path_is_not_a_wildcard(): void + { + $m = $this->matcher(['/feed.xml/{id:num}']); + + self::assertNotNull($m->match('GET', '/feed.xml/1')); + self::assertNull($m->match('GET', '/feedXxml/1'), 'the dot must be quoted'); + } + + // ── New types ─────────────────────────────────────────────────────────── + + public function test_the_path_type_crosses_slashes_but_refuses_traversal(): void + { + $m = $this->matcher(['/dl/{file:path}']); + + self::assertSame('a/b/c.txt', $m->match('GET', '/dl/a/b/c.txt')['params']['file']); + self::assertNull($m->match('GET', '/dl/../../etc/passwd')); + self::assertNull($m->match('GET', '/dl/a/..%2Fb')); + } + + public function test_any_is_unchanged_and_still_a_bare_catch_all(): void + { + // `any` keeps its exact previous meaning so no existing route regresses; + // `path` is the safe alternative to opt into. + $m = $this->matcher(['/files/{p:any}']); + + self::assertSame('../secret', $m->match('GET', '/files/../secret')['params']['p']); + } + + /** @return array */ + public static function enumCases(): array + { + return [ + 'a member matches' => ['draft', true], + 'another member' => ['published', true], + 'a non-member does not' => ['deleted', false], + 'a prefix does not' => ['draf', false], + ]; + } + + #[DataProvider('enumCases')] + public function test_an_enum_type_admits_only_its_members(string $value, bool $expected): void + { + $m = $this->matcher(['/posts/{status:enum(draft|published)}']); + + self::assertSame($expected, $m->match('GET', '/posts/' . $value) !== null); + } + + public function test_enum_members_cannot_inject_regex(): void + { + // Members are preg_quote'd, so '.' is a literal dot, not "any character". + $m = $this->matcher(['/v/{v:enum(1.0|2.0)}']); + + self::assertNotNull($m->match('GET', '/v/1.0')); + self::assertNull($m->match('GET', '/v/1x0')); + } + + // ── Optional parameters ───────────────────────────────────────────────── + + public function test_an_optional_parameter_may_be_omitted_with_its_separator(): void + { + $m = $this->matcher(['/posts/{page?}']); + + self::assertNotNull($m->match('GET', '/posts')); + self::assertSame('', $m->match('GET', '/posts')['params']['page']); + self::assertSame('2', $m->match('GET', '/posts/2')['params']['page']); + } + + public function test_an_optional_parameter_still_honours_its_type(): void + { + $m = $this->matcher(['/posts/{page:num?}']); + + self::assertNotNull($m->match('GET', '/posts')); + self::assertNotNull($m->match('GET', '/posts/3')); + self::assertNull($m->match('GET', '/posts/three')); + } + + // ── Ordering across the bucket split ──────────────────────────────────── + + public function test_a_wildcard_route_declared_first_still_wins(): void + { + // '/{slug}' buckets as a wildcard and '/pages/{id}' under 'pages'; the + // matcher must merge them back into declaration order. + $m = $this->matcher(['/{slug}', '/pages/{id}']); + + self::assertSame(['slug' => 'pages'], $m->match('GET', '/pages')['params']); + } + + public function test_a_bucketed_route_declared_first_wins_over_a_wildcard(): void + { + $m = $this->matcher(['/pages/{id}', '/{a}/{b}']); + + self::assertSame(['id' => '7'], $m->match('GET', '/pages/7')['params']); + } + + public function test_a_route_in_another_bucket_is_never_reached(): void + { + $m = $this->matcher(['/users/{id}', '/posts/{id}']); + + self::assertNotNull($m->match('GET', '/posts/1')); + self::assertNull($m->match('GET', '/nope/1')); + } + + // ── HEAD and Allow ────────────────────────────────────────────────────── + + public function test_head_is_served_by_the_get_route(): void + { + $m = $this->matcher(['/health', '/users/{id:num}']); + + self::assertNotNull($m->match('HEAD', '/health')); + self::assertSame(['id' => '9'], $m->match('HEAD', '/users/9')['params']); + } + + public function test_head_fallback_can_be_switched_off(): void + { + $m = $this->matcher(['/health'], head: false); + + self::assertNull($m->match('HEAD', '/health')); + } + + public function test_allowed_methods_reports_the_other_verbs(): void + { + $manifest = [ + 'GET /things' => ['handler' => 'C@m', 'solves' => 'x'], + 'POST /things' => ['handler' => 'C@m', 'solves' => 'x'], + 'DELETE /t/{id}' => ['handler' => 'C@m', 'solves' => 'x'], + ]; + $m = new RouteMatcher($manifest); + + self::assertSame(['GET', 'POST', 'HEAD'], $m->allowedMethods('/things')); + self::assertSame(['DELETE'], $m->allowedMethods('/t/1')); + self::assertSame([], $m->allowedMethods('/nothing')); + } + + // ── Trailing-slash policy ─────────────────────────────────────────────── + + public function test_the_default_policy_is_strict(): void + { + $m = $this->matcher(['/users']); + + self::assertNull($m->match('GET', '/users/')); + self::assertNull($m->canonicalPath('GET', '/users/')); + } + + public function test_the_ignore_policy_matches_either_form(): void + { + $m = new RouteMatcher( + ['GET /users' => ['handler' => 'C@m', 'solves' => 'x']], + trailingSlash: RouteMatcher::TRAILING_IGNORE, + ); + + self::assertNotNull($m->match('GET', '/users/')); + } + + public function test_the_redirect_policy_reports_the_canonical_path(): void + { + $m = new RouteMatcher( + ['GET /users' => ['handler' => 'C@m', 'solves' => 'x']], + trailingSlash: RouteMatcher::TRAILING_REDIRECT, + ); + + self::assertNull($m->match('GET', '/users/'), 'redirect policy does not match directly'); + self::assertSame('/users', $m->canonicalPath('GET', '/users/')); + self::assertNull($m->canonicalPath('GET', '/'), 'the root has no alternate form'); + } + + // ── The precompiled index and the derived one must agree ──────────────── + + public function test_a_precompiled_index_matches_identically(): void + { + $manifest = [ + 'GET /users/{id:num}' => ['handler' => 'C@m', 'solves' => 'x'], + 'GET /users/me' => ['handler' => 'C@m', 'solves' => 'x'], + 'GET /{slug}' => ['handler' => 'C@m', 'solves' => 'x'], + ]; + + $derived = new RouteMatcher($manifest); + $compiled = RouteMatcher::fromCompiled(RouteIndex::build($manifest)); + + foreach (['/users/7', '/users/me', '/anything', '/users/a/b'] as $path) { + self::assertEquals( + $derived->match('GET', $path), + $compiled->match('GET', $path), + "diverged on {$path}", + ); + } + } + + public function test_a_route_pcre_cannot_represent_is_dropped_not_fatal(): void + { + // A manifest compiled by an older kernel may contain a duplicate capture + // name. That one route is unusable either way; the rest must still serve. + $m = $this->matcher(['/a/{id}/b/{id}', '/ok/{id}']); + + self::assertNull($m->match('GET', '/a/1/b/2')); + self::assertNotNull($m->match('GET', '/ok/1')); + } +} diff --git a/tests/Unit/Kernel/Pipelines/Http/RoutingStagesTest.php b/tests/Unit/Kernel/Pipelines/Http/RoutingStagesTest.php new file mode 100644 index 0000000..8652dd4 --- /dev/null +++ b/tests/Unit/Kernel/Pipelines/Http/RoutingStagesTest.php @@ -0,0 +1,272 @@ + $id, 'body' => 'x']); + } +} + +/** Records that it ran and what arguments the route handed it. */ +final class RecordingFilterStage implements HttpStageContract +{ + /** @var list>> */ + public static array $seen = []; + + public function handle(Request $request, callable $next): Response + { + self::$seen[] = $request->attribute('filter_args', []); + + return $next($request); + } +} + +/** + * The routing stages wired together — what the unit tests of RouteMatcher and + * the compiler cannot show on their own: that the precompiled entry keys the + * stages now read are actually the ones the pipeline produces, and that the old + * un-precompiled entry shape still works. + */ +#[CoversClass(ResolveStage::class)] +#[CoversClass(ExecuteStage::class)] +#[CoversClass(RouteFilterStage::class)] +#[CoversClass(FilterRegistry::class)] +final class RoutingStagesTest extends TestCase +{ + protected function setUp(): void + { + RecordingFilterStage::$seen = []; + } + + private function container(): ModuleContainer + { + return new ModuleContainer(new CoreContainer()); + } + + private function request(string $method, string $path): Request + { + return Request::create($path, $method)->withContainer($this->container()); + } + + /** @param array $entry */ + private function matcher(array $entry, string $key = 'GET /u/{id:num}'): RouteMatcher + { + return new RouteMatcher([$key => $entry]); + } + + /** @return array */ + private function precompiledEntry(): array + { + return [ + 'handler' => StageTestController::class . '@show', + 'class' => StageTestController::class, + 'action' => 'show', + 'solves' => '__project__', + 'filters' => [], + ]; + } + + // ── ResolveStage ──────────────────────────────────────────────────────── + + public function test_a_match_publishes_the_route_attributes(): void + { + $stage = new ResolveStage($this->matcher($this->precompiledEntry())); + + $seen = null; + $stage->handle($this->request('GET', '/u/7'), function (Request $r) use (&$seen): Response { + $seen = $r; + + return Response::empty(200); + }); + + self::assertSame(['id' => '7'], $seen->attribute('route_params')); + self::assertSame('__project__', $seen->attribute('target_service')); + self::assertSame(StageTestController::class, $seen->attribute('route_entry')['class']); + } + + public function test_a_miss_is_a_404_before_anything_downstream_runs(): void + { + $stage = new ResolveStage($this->matcher($this->precompiledEntry())); + + $response = $stage->handle( + $this->request('GET', '/nope'), + static fn(): Response => self::fail('the pipeline must stop at the miss'), + ); + + self::assertSame(404, $response->status()); + } + + public function test_a_wrong_method_is_a_404_by_default(): void + { + // 405 confirms that a path exists, so it stays opt-in. + $stage = new ResolveStage($this->matcher($this->precompiledEntry())); + + self::assertSame(404, $stage->handle( + $this->request('POST', '/u/7'), + static fn(): Response => Response::empty(200), + )->status()); + } + + public function test_405_is_returned_with_an_allow_header_when_enabled(): void + { + $stage = new ResolveStage($this->matcher($this->precompiledEntry()), methodNotAllowed: true); + + $response = $stage->handle( + $this->request('POST', '/u/7'), + static fn(): Response => Response::empty(200), + ); + + self::assertSame(405, $response->status()); + self::assertSame('GET, HEAD', $response->headers()['Allow']); + } + + public function test_a_face_restricted_route_is_invisible_on_another_face(): void + { + $entry = ['faces' => ['admin']] + $this->precompiledEntry(); + $stage = new ResolveStage($this->matcher($entry)); + + $request = $this->request('GET', '/u/7')->withAttribute('route_face', 'api'); + + self::assertSame(404, $stage->handle( + $request, + static fn(): Response => Response::empty(200), + )->status()); + } + + public function test_a_face_restricted_route_resolves_on_its_own_face(): void + { + $entry = ['faces' => ['admin']] + $this->precompiledEntry(); + $stage = new ResolveStage($this->matcher($entry)); + + $request = $this->request('GET', '/u/7')->withAttribute('route_face', 'admin'); + + self::assertSame(200, $stage->handle($request, static fn(): Response => Response::empty(200))->status()); + } + + public function test_an_unrestricted_route_is_unaffected_by_the_face(): void + { + $stage = new ResolveStage($this->matcher($this->precompiledEntry())); + $request = $this->request('GET', '/u/7')->withAttribute('route_face', 'api'); + + self::assertSame(200, $stage->handle($request, static fn(): Response => Response::empty(200))->status()); + } + + // ── ExecuteStage ──────────────────────────────────────────────────────── + + public function test_it_invokes_the_precompiled_class_and_action(): void + { + $request = $this->request('GET', '/u/7') + ->withAttribute('route_entry', $this->precompiledEntry()) + ->withAttribute('route_params', ['id' => '7']); + + $response = (new ExecuteStage())->handle($request, static fn(): Response => Response::empty(200)); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('"id":"7"', $response->body()); + } + + public function test_a_legacy_entry_without_the_precompiled_split_still_runs(): void + { + // A manifest compiled by an older kernel has only `handler`. + $entry = [ + 'handler' => StageTestController::class . '@show', + 'solves' => '__project__', + ]; + + $request = $this->request('GET', '/u/7') + ->withAttribute('route_entry', $entry) + ->withAttribute('route_params', ['id' => '9']); + + $response = (new ExecuteStage())->handle($request, static fn(): Response => Response::empty(200)); + + self::assertStringContainsString('"id":"9"', $response->body()); + } + + public function test_a_head_request_keeps_the_headers_and_drops_the_body(): void + { + $request = $this->request('HEAD', '/u/7') + ->withAttribute('route_entry', $this->precompiledEntry()) + ->withAttribute('route_params', ['id' => '7']) + ->withAttribute('correlation_id', 'abc-123'); + + $response = (new ExecuteStage())->handle($request, static fn(): Response => Response::empty(200)); + + self::assertSame(200, $response->status()); + self::assertSame('', $response->body(), 'HEAD must not carry a body'); + self::assertSame('abc-123', $response->headers()['X-Correlation-ID']); + } + + // ── RouteFilterStage ──────────────────────────────────────────────────── + + public function test_precompiled_filter_specs_are_used_verbatim(): void + { + $registry = new FilterRegistry(); + $registry->register('throttle', RecordingFilterStage::class); + + $entry = $this->precompiledEntry(); + $entry['filter_specs'] = [['alias' => 'throttle', 'args' => ['60', '1']]]; + + $request = $this->request('GET', '/u/7')->withAttribute('route_entry', $entry); + $response = (new RouteFilterStage($registry, new CoreContainer())) + ->handle($request, static fn(): Response => Response::empty(204)); + + self::assertSame(204, $response->status()); + self::assertSame([['throttle' => ['60', '1']]], RecordingFilterStage::$seen); + } + + public function test_a_legacy_entry_falls_back_to_parsing_the_raw_specs(): void + { + $registry = new FilterRegistry(); + $registry->register('throttle', RecordingFilterStage::class); + + $entry = ['filters' => ['throttle:5,1']] + $this->precompiledEntry(); + + $request = $this->request('GET', '/u/7')->withAttribute('route_entry', $entry); + (new RouteFilterStage($registry, new CoreContainer())) + ->handle($request, static fn(): Response => Response::empty(204)); + + self::assertSame([['throttle' => ['5', '1']]], RecordingFilterStage::$seen); + } + + public function test_an_unknown_alias_stops_the_request_rather_than_skipping_the_filter(): void + { + $registry = new FilterRegistry(); + $request = $this->request('GET', '/u/7')->withAttribute( + 'route_entry', + ['filter_specs' => [['alias' => 'ghost', 'args' => []]]] + $this->precompiledEntry(), + ); + + $this->expectExceptionMessageMatches('/Unknown route filter alias \[ghost\]/'); + + (new RouteFilterStage($registry, new CoreContainer())) + ->handle($request, static fn(): Response => Response::empty(204)); + } + + public function test_a_resolved_filter_stage_is_reused_across_requests(): void + { + $registry = new FilterRegistry(); + $registry->register('throttle', RecordingFilterStage::class); + $core = new CoreContainer(); + + self::assertSame( + $registry->resolve('throttle', $core), + $registry->resolve('throttle', $core), + 'stages are stateless — constructing one per request is pure waste', + ); + } +} diff --git a/tests/Unit/Kernel/Routing/UrlGeneratorTest.php b/tests/Unit/Kernel/Routing/UrlGeneratorTest.php index dc1692f..ae09b0d 100644 --- a/tests/Unit/Kernel/Routing/UrlGeneratorTest.php +++ b/tests/Unit/Kernel/Routing/UrlGeneratorTest.php @@ -176,4 +176,143 @@ public function test_a_signature_from_a_different_key_is_rejected(): void self::assertFalse($this->generator(secret: 'a-completely-different-key')->hasValidSignature($signed)); } + + public function test_a_query_key_php_would_mangle_still_validates(): void + { + // parse_str() rewrites '.', ' ' and '[' inside parameter NAMES, so + // round-tripping the query through it made a legitimately signed URL + // impossible to verify. The comparison is now byte-for-byte. + $url = $this->generator()->signedRoute('search', ['user.name' => 'ada', 'a b' => 'c']); + + self::assertTrue($this->generator()->hasValidSignature($url)); + } + + public function test_a_second_injected_signature_is_rejected(): void + { + $url = $this->generator()->signedRoute('user.show', ['id' => 7]); + + self::assertFalse($this->generator()->hasValidSignature($url . '&signature=deadbeef')); + } + + public function test_tampering_with_the_query_invalidates_the_signature(): void + { + $url = $this->generator()->signedRoute('search', ['q' => 'safe']); + + self::assertFalse($this->generator()->hasValidSignature(str_replace('safe', 'evil', $url))); + } + + // ── Repeated and optional placeholders ────────────────────────────────── + + public function test_a_repeated_placeholder_is_substituted_everywhere(): void + { + $url = new UrlGenerator( + ['GET /a/{id}/b/{id}' => ['name' => 'twice', 'handler' => 'C@m']], + secret: self::SECRET, + ); + + // Consumption used to remove the value, so the second {id} reported a + // missing parameter. + self::assertSame('/a/7/b/7', $url->route('twice', ['id' => 7])); + } + + public function test_an_optional_parameter_may_be_omitted(): void + { + $url = new UrlGenerator( + ['GET /posts/{page:num?}' => ['name' => 'posts', 'handler' => 'C@m']], + secret: self::SECRET, + ); + + self::assertSame('/posts', $url->route('posts'), 'the separator goes with it'); + self::assertSame('/posts/2', $url->route('posts', ['page' => 2])); + } + + public function test_an_optional_parameter_is_still_type_checked(): void + { + $url = new UrlGenerator( + ['GET /posts/{page:num?}' => ['name' => 'posts', 'handler' => 'C@m']], + secret: self::SECRET, + ); + + $this->expectExceptionMessageMatches('/does not satisfy type \[num\]/'); + $url->route('posts', ['page' => 'two']); + } + + // ── Absolute URLs follow the route's own domain group ─────────────────── + + private function multiBrand(string $base = 'https://hkmvote.local'): UrlGenerator + { + return new UrlGenerator( + [ + 'GET@hkmvote.local /' => ['name' => 'vote.home', 'handler' => 'C@m'], + 'GET@africavoting.local /' => ['name' => 'africa.home', 'handler' => 'C@m'], + 'GET@*.africavoting.local /t' => ['name' => 'tenant.home', 'handler' => 'C@m'], + 'GET@api /ping' => ['name' => 'api.ping', 'handler' => 'C@m'], + 'GET /health' => ['name' => 'health', 'handler' => 'C@m'], + ], + base: $base, + secret: self::SECRET, + ); + } + + public function test_a_grouped_route_is_absolute_against_its_own_host(): void + { + // Generating both brands against one APP_URL would send half the links + // to the wrong site. + $url = $this->multiBrand(); + + self::assertSame('https://hkmvote.local/', $url->route('vote.home', absolute: true)); + self::assertSame('https://africavoting.local/', $url->route('africa.home', absolute: true)); + } + + public function test_the_scheme_is_taken_from_the_configured_base(): void + { + self::assertSame( + 'http://africavoting.local/', + $this->multiBrand(base: 'http://hkmvote.local')->route('africa.home', absolute: true), + ); + } + + public function test_a_wildcard_or_bare_subdomain_falls_back_to_the_base(): void + { + // Neither names a single host, so there is no origin to build. + $url = $this->multiBrand(); + + self::assertSame('https://hkmvote.local/t', $url->route('tenant.home', absolute: true)); + self::assertSame('https://hkmvote.local/ping', $url->route('api.ping', absolute: true)); + } + + public function test_an_ungrouped_route_still_uses_the_configured_base(): void + { + self::assertSame('https://hkmvote.local/health', $this->multiBrand()->route('health', absolute: true)); + } + + public function test_relative_generation_is_unaffected_by_the_domain(): void + { + self::assertSame('/', $this->multiBrand()->route('africa.home')); + } + + public function test_the_domain_of_a_named_route_is_reportable(): void + { + self::assertSame('africavoting.local', $this->multiBrand()->domainFor('africa.home')); + self::assertSame('', $this->multiBrand()->domainFor('health')); + } + + public function test_a_signed_absolute_url_uses_its_domain_and_still_verifies(): void + { + // The signature covers path+query only, never the host, so picking a + // per-domain origin cannot invalidate it. + $url = $this->multiBrand(); + $signed = $url->signedRoute('africa.home', ['id' => 7], absolute: true); + + self::assertStringStartsWith('https://africavoting.local/', $signed); + self::assertTrue($url->hasValidSignature(substr($signed, strlen('https://africavoting.local')))); + } + + public function test_a_trailing_newline_cannot_be_smuggled_into_a_value(): void + { + // '$' without the D modifier would accept "7\n" here and generate a URL + // the matcher then refuses. + $this->expectExceptionMessageMatches('/does not satisfy type \[num\]/'); + $this->generator()->route('user.show', ['id' => "7\n"]); + } } diff --git a/tests/Unit/Project/Bootstrap/ProjectRouteDeclarationTest.php b/tests/Unit/Project/Bootstrap/ProjectRouteDeclarationTest.php new file mode 100644 index 0000000..b64a7e8 --- /dev/null +++ b/tests/Unit/Project/Bootstrap/ProjectRouteDeclarationTest.php @@ -0,0 +1,130 @@ +project = sys_get_temp_dir() . '/hkm-projroutes-' . bin2hex(random_bytes(6)); + mkdir($this->project, 0775, true); + } + + protected function tearDown(): void + { + @unlink($this->project . '/proj.json'); + @rmdir($this->project); + } + + /** @param array $data */ + private function proj(array $data): void + { + file_put_contents($this->project . '/proj.json', json_encode($data)); + } + + public function test_route_groups_and_source_defaults_are_read(): void + { + $this->proj([ + 'name' => 'hkmvote', + 'routePrefix' => '/app', + 'groups' => [['domain' => 'hkmvote.local', 'routes' => []]], + 'ignored' => 'not a route declaration', + ]); + + $source = EntryHelpers::projectRouteGroups($this->project); + + self::assertSame('/app', $source['routePrefix']); + self::assertCount(1, $source['groups']); + self::assertArrayNotHasKey('ignored', $source); + } + + public function test_a_project_without_groups_yields_nothing(): void + { + $this->proj(['name' => 'plain']); + + self::assertSame([], EntryHelpers::projectRouteGroups($this->project)); + } + + public function test_a_missing_proj_json_yields_nothing(): void + { + self::assertSame([], EntryHelpers::projectRouteGroups($this->project . '/nope')); + self::assertSame([], EntryHelpers::projectRoutes($this->project . '/nope')); + } + + public function test_routes_pass_through_the_domain_name_and_faces_keys(): void + { + $this->proj([ + 'routes' => [[ + 'method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home', + 'name' => 'home', 'domain' => 'hkmvote.local', 'faces' => ['project'], + 'filters' => ['auth'], 'requires' => ['view.rendering'], + ]], + ]); + + $route = EntryHelpers::projectRoutes($this->project)[0]; + + self::assertSame('hkmvote.local', $route['domain']); + self::assertSame('home', $route['name']); + self::assertSame(['project'], $route['faces']); + self::assertSame(['auth'], $route['filters']); + self::assertSame(['view.rendering'], $route['requires']); + } + + public function test_a_subdomain_declaration_passes_through_too(): void + { + $this->proj([ + 'routes' => [[ + 'method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home', + 'subdomain' => 'organizer', + ]], + ]); + + self::assertSame('organizer', EntryHelpers::projectRoutes($this->project)[0]['subdomain']); + } + + public function test_the_projects_registered_domains_are_read(): void + { + $this->proj(['name' => 'hkmvote', 'domains' => ['HKMVote.local', ' africavoting.local ', '']]); + + self::assertSame( + ['hkmvote.local', 'africavoting.local'], + EntryHelpers::projectDomains($this->project), + ); + } + + public function test_a_project_without_domains_reads_as_unregistered(): void + { + // Which disables the route-domain check entirely — a project that does + // not register its hosts has no registry to validate against. + $this->proj(['name' => 'plain']); + + self::assertSame([], EntryHelpers::projectDomains($this->project)); + self::assertSame([], EntryHelpers::projectDomains($this->project . '/nope')); + } + + public function test_a_malformed_route_is_dropped_rather_than_fatal(): void + { + $this->proj(['routes' => [['method' => 'GET'], ['method' => 'GET', 'path' => '/ok', 'handler' => 'A\\C@ok']]]); + + $routes = EntryHelpers::projectRoutes($this->project); + + self::assertCount(1, $routes); + self::assertSame('/ok', $routes[0]['path']); + } +} diff --git a/tools/README.md b/tools/README.md index df4bda0..6a777e0 100644 --- a/tools/README.md +++ b/tools/README.md @@ -3,6 +3,157 @@ Native launcher + project tooling for the AlfacodeTeam PhpServicePlatform. Builds two binaries: `hkm` (the launcher/CLI) and `hkm-config`. +## Install (no root) + +```sh +# latest release, into ~/.local — nothing is written outside your home +curl -fsSL https://github.com/AlfaCode-Team/hkm-kernel/releases/latest/download/install.sh | sh + +# or, from a downloaded tarball / this checkout +./tools/install.sh hkm-kernel-1.2.3-linux-x86_64.tar.gz +./tools/install.sh --version v1.2.3 +HKM_PREFIX=/srv/hkm ./tools/install.sh +./tools/install.sh --uninstall +``` + +| Path | Holds | +|---|---| +| `~/.local/bin/hkm`, `hkm-config` | the launcher | +| `~/.local/lib/hkm-kernel/` | kernel source + `vendor/` | +| `~/.config/hkm/config.env` | launcher config — outside the install tree | +| `~/.local/share/hkm/` | project registry — outside the install tree | + +Upgrades replace the kernel tree but carry `projects/projects.json` and +`projects/platform.json` across; `--uninstall` leaves your config and registry +alone. + +**The `bin/` + `lib/hkm-kernel/` pairing is load-bearing.** `resolveHome()` in +`src/lib/kernel.zig` probes `/lib/hkm-kernel`, which is why +the launcher finds its kernel with no env var and no config file — both from an +extracted tarball run in place and from `~/.local`. Change one side and you must +change the other. + +**The install has no preconditions.** It does not require PHP, composer, git or +node to be present — it copies files into your home, runs `hkm doctor`, and +finishes successfully either way. Gating it on a runtime an administrator has +not installed yet would leave you without the binary that tells you what to ask +for. + +## `hkm doctor` — what the kernel needs + +The single authority on whether this machine can run the kernel. It enumerates +every requirement, not just PHP: + +| Section | Checks | +|---|---| +| Launcher | this binary, whether its dir is on `PATH`, whether another `hkm` shadows it | +| Kernel | kernel root, PHP CLI, `composer.json`, `src/`, the four first-party `modules/`, `vendor/autoload.php`, writability | +| Configuration | `config.env`, a stale `HKM_KERNEL_HOME` pin, userdata dir + writability, the registry | +| Tooling | `php`, `composer`, `git`, `node`, `npm` — each labelled with what needs it | +| PHP runtime | version >= 8.4.1, the nine required extensions, a PDO driver, `memory_limit`, plus optional redis/swoole/gd/intl/zip/sodium/opcache | + +Output ends in two lists, each line carrying the command that fixes it: + +- **Must fix** — blocks the kernel. Exit code 1, so `hkm doctor` gates CI. +- **Worth fixing** — warns only. Exit code 0. + +The extension and version checks are asked of PHP itself through a `php -r` +preflight, so they describe the exact runtime a project will use rather than a +guess. With no `php` on PATH that section is skipped and reported, not fatal to +the run. + +Installing **PHP and its extensions** is the one part that needs an +administrator. Everything else `doctor` reports, you can fix yourself. + +### The `.deb` (system-wide, needs root) + +`hkm-kernel__amd64.deb` installs `/opt/hkm-kernel` + `/usr/bin/hkm` for +**all** users, with apt managing the PHP dependency chain. Use it for multi-user +machines, servers and CI images. It is the exception; the tarball is the default. + +Root is required there for packaging reasons only — `dpkg` must run as root, +`/opt` and `/usr/bin` are root-owned, `Depends:` drives apt, and `postinst` runs +composer into a root-owned tree. The launcher itself has never needed root. + +Note `/usr/bin` normally precedes `~/.local/bin` on `PATH`, so a leftover `.deb` +install silently shadows a user install. `install.sh` warns when it sees one; +remove it with `sudo apt remove hkm-kernel`. + +## Two installs on one machine + +A system install and a user install **coexist by design** and are updated +separately. Everything below follows from that, and `hkm version` is the command +that shows the whole picture at once: + +``` +$ hkm version + scope kernel kernel version launcher + system /opt/hkm-kernel 1.3.1 /usr/bin/hkm (1.3.1) +→ user ~/.local/lib/hkm-kernel 1.4.0 ~/.local/bin/hkm (1.4.0) +``` + +Three versions are in play and they can all differ: the **launcher** binary's +compile-time stamp, the **kernel** on disk (from its `composer.json`), and one +of each per scope. `hkm --version` still prints just this launcher's, for +scripts. + +### Which install does `hkm upgrade` touch? + +Privilege decides, so the two forms are two predictable commands rather than one +command with a machine-dependent target: + +| Command | Target | Artifact | +|---|---|---| +| `hkm upgrade` | `~/.local` (this user) | the linux `.tar.gz` + its `install.sh` | +| `sudo hkm upgrade` | `/opt` + `/usr/bin` | the `.deb`, via apt | +| `hkm upgrade --user` / `--system` | forces either | as above | + +`hkm upgrade --check` reports the scope you asked about and names the *other* +one when it is also behind — because "you are on the latest version" is +misleading when the launcher your `PATH` resolves belongs to the scope that was +not checked. + +Versions come from the **kernel being replaced**, never from `banner.version()`. +Comparing the launcher's compile-time stamp to the latest tag answered "is this +binary current" while the command went on to replace a kernel somewhere else. + +### Kernel resolution, and why a config pin no longer wins + +`~/.config/hkm/config.env` is read by **every** `hkm` on the machine. When +`HKM_KERNEL_HOME` was checked first, whichever installer wrote it last silently +redirected the other install: + +``` +$ /usr/bin/hkm --version → 1.3.1 # the .deb's launcher +$ /usr/bin/hkm doctor + kernel root ~/.local/share/hkm/kernel # …the USER's kernel + resolved via HKM_KERNEL_HOME override +``` + +Upgrading either scope then looked like a no-op. Resolution now ranks sources by +how specific they are to *this* invocation (`src/lib/kernel.zig`): + +1. `HKM_CLI_PATH` / `HKM_KERNEL_HOME` **exported in the real environment** +2. **self-location** relative to the launcher's own executable — per-install by + construction, so the other scope cannot affect it. A launcher in a system bin + dir (`/usr/bin`) claims `/opt/hkm-kernel` here, since no relative probe can + reach it from there +3. `HKM_KERNEL_HOME` from `config.env` — now a **fallback**, for custom layouts + self-location genuinely cannot find +4. `/opt/hkm-kernel` + +So a pin still works wherever it was actually needed; it no longer overrides an +install sitting next to the binary. `hkm-config check` writes one only when +self-location failed, and `hkm-config unset HKM_KERNEL_HOME` clears a stale one. + +### `hkm upgrade --local` + +Installs the current checkout over an installed kernel, obeying the same scope +rule (non-root → your user install, which it creates if absent). It stamps the +`git describe` version into the destination `composer.json`, so the result can +report what it is — without that, a locally installed kernel read `unstamped` +forever and had nothing to compare on the next upgrade. + ## Layout ``` diff --git a/tools/build.zig b/tools/build.zig index 3ff30c5..8e4f433 100644 --- a/tools/build.zig +++ b/tools/build.zig @@ -59,9 +59,13 @@ pub fn build(b: *std.Build) void { // imports like `@import("../constants.zig")` resolve inside the module. // Running `zig test src/lib/memory.zig` directly makes src/lib the module // root and that import fails, which is misleading rather than useful. + // The stamper's logic lives in lib/composer_version.zig — it is shared with + // `hkm version` / `hkm upgrade`, which READ the field the stamper writes. + // Testing the library rather than the executable wrapper is what keeps the + // read and write halves verified against each other. const stamp_tests = b.addTest(.{ .root_module = b.createModule(.{ - .root_source_file = b.path("src/stamp.zig"), + .root_source_file = b.path("src/lib/composer_version.zig"), .target = target, .optimize = optimize, }), @@ -69,7 +73,7 @@ pub fn build(b: *std.Build) void { const unit_tests = b.addTest(.{ .root_module = b.createModule(.{ - .root_source_file = b.path("src/main.zig"), + .root_source_file = b.path("src/tests.zig"), .target = target, .optimize = optimize, }), @@ -102,6 +106,11 @@ pub fn build(b: *std.Build) void { }), }); + // Installed so it can be run and tested directly. Without this the only + // copies live in .zig-cache under content-hashed directories, and verifying + // its behaviour against real composer means guessing which one is current. + b.installArtifact(stamper); + if (!std.mem.eql(u8, version, "0.0.0-dev")) { const stamp_run = b.addRunArtifact(stamper); stamp_run.addFileArg(b.path("../composer.json")); diff --git a/tools/bundle.sh b/tools/bundle.sh index 8cdadcb..5f605eb 100755 --- a/tools/bundle.sh +++ b/tools/bundle.sh @@ -3,9 +3,19 @@ # bundle.sh — build the `hkm` launcher for every OS and assemble installable # bundles under dist/. Run from the repo root or from tools/. # -# ./tools/bundle.sh # build all: linux .deb tree, macos, windows zip +# ./tools/bundle.sh # build all: linux tarball + .deb, macos, windows # ./tools/bundle.sh linux # only one target # +# Linux produces TWO artifacts, and the tarball is the primary one: +# +# hkm-kernel--linux-x86_64.tar.gz user-local / portable. No root. Extract +# anywhere, or install into ~/.local with +# tools/install.sh. +# hkm-kernel__amd64.deb system-wide. Needs root; use it for +# multi-user machines, servers and CI +# images where apt managing the PHP +# dependency chain is the point. +# # What a bundle contains: # • the native `hkm` + `hkm-config` launcher (Zig, statically linked) # • the kernel PHP source (src/) + vendor/ (composer --no-dev) @@ -100,19 +110,52 @@ build_zig() { # $1 = zig target triple, $2 = out dir rm -rf "$DIST"; mkdir -p "$DIST" -# ─── Linux: .deb (amd64) ──────────────────────────────────────────────────── +# ─── Linux: portable tarball (amd64) — the DEFAULT install path ───────────── +# Layout is chosen so the launcher self-locates with no env var and no config: +# resolveHome() probes "/lib/hkm-kernel" (tools/src/lib/ +# kernel.zig), so bin/ + lib/ side by side works BOTH when the tree is extracted +# somewhere and run in place, and when install.sh copies it into ~/.local — +# because the relative layout is identical in both cases. +# +# hkm-kernel--linux-x86_64/ +# ├── bin/hkm, bin/hkm-config +# ├── lib/hkm-kernel/ (kernel source; vendor/ built by install.sh) +# └── install.sh user-local installer, no root if [[ "$want" == all || "$want" == linux ]]; then build_zig x86_64-linux-gnu "$DIST/_zig/linux" + TB="hkm-kernel-${VERSION}-linux-x86_64"; T="$DIST/$TB" + mkdir -p "$T/bin" + stage_kernel "$T/lib/$KERNEL_DIRNAME" + cp "$DIST/_zig/linux/bin/hkm" "$T/bin/hkm" + cp "$DIST/_zig/linux/bin/hkm-config" "$T/bin/hkm-config" + chmod +x "$T/bin/hkm" "$T/bin/hkm-config" + cp "$TOOLS/install.sh" "$T/install.sh" + chmod +x "$T/install.sh" + ( cd "$DIST" && tar -czf "${TB}.tar.gz" "$TB" && rm -rf "$TB" ) + say "wrote $DIST/${TB}.tar.gz (user-local, no root)" +fi + +# ─── Linux: .deb (amd64) — system-wide, needs root ────────────────────────── +if [[ "$want" == all || "$want" == linux ]]; then PKG="hkm-kernel_${VERSION}_amd64"; P="$DIST/$PKG" mkdir -p "$P/DEBIAN" "$P/usr/bin" stage_kernel "$P/opt/$KERNEL_DIRNAME" cp "$DIST/_zig/linux/bin/hkm" "$P/usr/bin/hkm" cp "$DIST/_zig/linux/bin/hkm-config" "$P/usr/bin/hkm-config" chmod +x "$P/usr/bin/hkm" "$P/usr/bin/hkm-config" - # composer is a hard dependency now: the package ships SOURCE, not vendor/, and - # resolves dependencies on the target in postinst. Network access is required - # at install time. In MODULES=git mode, git is also required to fetch modules. - DEPS="php8.4-cli, php8.4-mbstring, php8.4-curl, php8.4-xml, php8.4-zip, composer, ca-certificates" + # PHP is RECOMMENDED, not required — same principle as the user-local install: + # putting the package on disk must not be gated on a runtime an administrator + # may install differently (ondrej PPA, Sury, a hand-built PHP, a container base + # image). apt installs Recommends by default, so the common case is unchanged; + # what changes is that a machine whose repos have no `php8.4-*` can still + # install hkm and be TOLD what is missing by `hkm doctor`, instead of dpkg + # refusing and leaving the operator with nothing to run. + # + # ca-certificates stays a hard dependency: postinst fetches over TLS, and + # without it that fails in a way no diagnostic can explain. + DEPS="ca-certificates" + RECS="php8.4-cli, php8.4-mbstring, php8.4-curl, php8.4-xml, php8.4-zip, composer" + RECS="$RECS, php8.4-mysql | php8.4-pgsql | php8.4-sqlite3, php8.4-redis, php8.4-intl" [ "$MODULES" = git ] && DEPS="$DEPS, git" cat > "$P/DEBIAN/control" < Depends: ${DEPS} -Recommends: php8.4-mysql | php8.4-pgsql | php8.4-sqlite3, php8.4-redis, php8.4-intl +Recommends: ${RECS} Description: PhpServicePlatform (HKM) kernel and native launcher Installs the kernel PHP source (src, plugins, projects, modules) under /opt/hkm-kernel and a native hkm launcher in /usr/bin. PHP dependencies are resolved with composer at install time (vendor/ is not bundled), so the runtime matches this machine's PHP. Needs network access during install. - Run 'hkm doctor' afterwards to verify PHP and required extensions. + . + PHP and composer are Recommends rather than Depends, so this package installs + even where they are provided by another repository or built by hand. Run + 'hkm doctor' afterwards: it lists every requirement the kernel has and the + command to fix each one that is missing. + . + For a single user, prefer the user-local tarball install (no root): + hkm-kernel--linux-x86_64.tar.gz + install.sh. EOF # conffiles: the project registry + platform map are USER DATA. Marking them as # dpkg conffiles makes upgrades PRESERVE the user's versions instead of @@ -141,14 +191,20 @@ EOF #!/bin/sh set -e KERNEL=/opt/${KERNEL_DIRNAME} -echo "hkm-kernel: resolving PHP dependencies with composer…" -if [ -x "\$KERNEL/install.sh" ]; then +# Best effort, never fatal. PHP is a Recommends, so it may legitimately be +# absent here; failing the package install would leave the operator with no +# hkm binary and therefore no way to run the diagnostic that explains why. +if ! command -v php >/dev/null 2>&1; then + echo "hkm-kernel: no php on PATH yet — skipping dependency resolution." + echo "hkm-kernel: run 'hkm doctor' to see what is required." +elif [ -x "\$KERNEL/install.sh" ]; then + echo "hkm-kernel: resolving PHP dependencies with composer…" ( cd "\$KERNEL" && ./install.sh ) || { - echo "WARNING: composer install failed. Fix connectivity/PHP, then run:"; + echo "WARNING: dependency resolution did not complete. After fixing it:"; echo " sudo sh -c 'cd \$KERNEL && ./install.sh'"; } fi -echo "hkm-kernel installed. Verify your environment with: hkm doctor" +echo "hkm-kernel installed. See what it still needs with: hkm doctor" exit 0 EOF chmod +x "$P/DEBIAN/postinst" diff --git a/tools/install.sh b/tools/install.sh new file mode 100755 index 0000000..23617d6 --- /dev/null +++ b/tools/install.sh @@ -0,0 +1,349 @@ +#!/usr/bin/env sh +# --------------------------------------------------------------------------- +# install.sh — install the HKM kernel + launcher for the CURRENT USER. +# +# No root. Nothing is written outside your home directory. +# +# ./install.sh # download the latest release, install +# ./install.sh hkm-kernel-1.2.3-linux-x86_64.tar.gz +# ./install.sh --version v1.2.3 # download a specific tag +# HKM_PREFIX=/srv/hkm ./install.sh # install somewhere else +# ./install.sh --uninstall +# +# Installs: +# $HKM_PREFIX/bin/hkm, hkm-config (default: ~/.local/bin) +# $HKM_PREFIX/lib/hkm-kernel/ kernel source + vendor/ +# +# That relative layout is not arbitrary: the launcher resolves its kernel by +# probing "/lib/hkm-kernel" (tools/src/lib/kernel.zig), +# so bin/ and lib/ side by side means self-location works with no environment +# variable and no config file. +# +# Your data is NOT inside the install tree and survives upgrades: +# ~/.config/hkm/config.env launcher config +# ~/.local/share/hkm/ project registry (HKM_USERDATA_DIR) +# +# Still needed from your system administrator, once: PHP >= 8.4 with the +# extensions `hkm doctor` lists. Installing PHP is the one thing a user-local +# install genuinely cannot do for you. +# --------------------------------------------------------------------------- +set -eu + +REPO="${HKM_REPO:-AlfaCode-Team/hkm-kernel}" +PREFIX="${HKM_PREFIX:-$HOME/.local}" +KERNEL_DIRNAME="hkm-kernel" +DEST="$PREFIX/lib/$KERNEL_DIRNAME" +BINDIR="$PREFIX/bin" + +TARBALL="" +WANT_TAG="" +DO_UNINSTALL=0 +SKIP_COMPOSER=0 + +# ── output helpers ────────────────────────────────────────────────────────── +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + C_B='\033[36m'; C_G='\033[32m'; C_Y='\033[33m'; C_R='\033[31m'; C_0='\033[0m' +else + C_B=''; C_G=''; C_Y=''; C_R=''; C_0='' +fi +say() { printf "${C_B}▶${C_0} %s\n" "$*"; } +ok() { printf "${C_G}✓${C_0} %s\n" "$*"; } +warn() { printf "${C_Y}!${C_0} %s\n" "$*" >&2; } +die() { printf "${C_R}✗${C_0} %s\n" "$*" >&2; exit 1; } + +usage() { + sed -n '3,30p' "$0" | sed 's/^# \{0,1\}//' + exit 0 +} + +# ── arguments ─────────────────────────────────────────────────────────────── +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage ;; + --uninstall) DO_UNINSTALL=1 ;; + --version) shift; [ $# -gt 0 ] || die "--version needs a tag (e.g. v1.2.3)"; WANT_TAG="$1" ;; + --prefix) shift; [ $# -gt 0 ] || die "--prefix needs a path"; PREFIX="$1" + DEST="$PREFIX/lib/$KERNEL_DIRNAME"; BINDIR="$PREFIX/bin" ;; + --no-composer) SKIP_COMPOSER=1 ;; + -*) die "unknown option: $1 (try --help)" ;; + *) TARBALL="$1" ;; + esac + shift +done + +# ── uninstall ─────────────────────────────────────────────────────────────── +if [ "$DO_UNINSTALL" -eq 1 ]; then + say "Removing $DEST" + rm -rf "$DEST" + rm -f "$BINDIR/hkm" "$BINDIR/hkm-config" + ok "Removed. Your data was left alone:" + printf ' %s\n %s\n' "${XDG_CONFIG_HOME:-$HOME/.config}/hkm" \ + "${XDG_DATA_HOME:-$HOME/.local/share}/hkm" + printf ' Delete those too if you want a clean slate.\n' + exit 0 +fi + +# ── what is already on this machine ───────────────────────────────────────── +# Installing over an existing install is the ordinary case, not an error — but +# the two are independent, upgrade separately, and PATH silently decides which +# launcher a bare `hkm` runs. Reporting both up front is what turns "I installed +# it and the version did not change" into something the reader can see coming. +kernel_version() { # $1 = kernel root → prints the stamped version, or nothing + [ -f "$1/composer.json" ] || return 0 + sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1/composer.json" | head -1 +} + +SYS_ROOT=/opt/hkm-kernel +SYS_VER="$(kernel_version "$SYS_ROOT")" +OLD_VER="$(kernel_version "$DEST")" + +if [ -d "$SYS_ROOT" ] || [ -d "$DEST" ]; then + say "Existing installs on this machine:" + [ -d "$SYS_ROOT" ] && printf ' system %-28s %s\n' "$SYS_ROOT" "${SYS_VER:-unstamped}" + [ -d "$DEST" ] && printf ' user %-28s %s\n' "$DEST" "${OLD_VER:-unstamped}" +fi + +# /usr/bin usually precedes ~/.local/bin on PATH, so a leftover .deb install +# silently wins and the user debugs the wrong copy. +if [ -x /usr/bin/hkm ] && [ "$BINDIR" = "$HOME/.local/bin" ]; then + warn "A system-wide hkm exists at /usr/bin/hkm (installed from the .deb)." + warn "It comes FIRST on PATH, so a bare 'hkm' will still run that one." + warn "Either remove it (sudo apt remove hkm-kernel), or put $BINDIR ahead of" + warn "/usr/bin in your PATH. 'hkm version' shows both installs at any time." +fi + +# The pre-1.4 'hkm upgrade --user' target. It is NOT self-locatable, so it could +# only ever be reached through a config pin — and that pin is read by every +# launcher on the machine, which is how a user install came to redirect the +# system launcher's kernel. Nothing writes there now; say it is being left. +LEGACY_USER="${XDG_DATA_HOME:-$HOME/.local/share}/hkm/kernel" +if [ -f "$LEGACY_USER/composer.json" ] && [ "$LEGACY_USER" != "$DEST" ]; then + warn "An older user kernel remains at $LEGACY_USER" + warn "It is superseded by this install and is no longer updated." + warn "Delete it once 'hkm version' shows the new one active." +fi + +# ── acquire the tarball ───────────────────────────────────────────────────── +TMP="$(mktemp -d)" +cleanup() { rm -rf "$TMP"; } +trap cleanup EXIT INT TERM + +fetch() { # $1 = url, $2 = out + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$2" "$1" + else + die "need curl or wget to download (or pass a .tar.gz path)" + fi +} + +arch_slug() { + case "$(uname -m)" in + x86_64|amd64) echo "x86_64" ;; + aarch64|arm64) echo "aarch64" ;; + *) die "unsupported architecture: $(uname -m)" ;; + esac +} + +if [ -n "$TARBALL" ]; then + [ -f "$TARBALL" ] || die "no such file: $TARBALL" + say "Using $TARBALL" +else + OS="$(uname -s)" + [ "$OS" = "Linux" ] || die "auto-download supports Linux; on $OS use the .app/.zip bundle, or pass a tarball" + ARCH="$(arch_slug)" + + if [ -n "$WANT_TAG" ]; then + TAG="$WANT_TAG" + else + say "Looking up the latest release of $REPO…" + API="https://api.github.com/repos/$REPO/releases/latest" + fetch "$API" "$TMP/rel.json" || die "could not reach GitHub. Download the tarball and pass its path." + # Deliberately not jq — this script must run on a bare machine. + TAG="$(sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TMP/rel.json" | head -1)" + [ -n "$TAG" ] || die "could not parse the latest tag. Pass --version vX.Y.Z or a tarball path." + fi + + VER="${TAG#v}" + NAME="hkm-kernel-${VER}-linux-${ARCH}.tar.gz" + URL="https://github.com/$REPO/releases/download/$TAG/$NAME" + say "Downloading $NAME" + fetch "$URL" "$TMP/$NAME" || die "download failed: $URL" + TARBALL="$TMP/$NAME" +fi + +# ── unpack and validate before touching the destination ───────────────────── +say "Unpacking…" +mkdir -p "$TMP/x" +tar -xzf "$TARBALL" -C "$TMP/x" || die "could not extract $TARBALL" + +# The archive has a single top-level directory. +SRC="$(find "$TMP/x" -mindepth 1 -maxdepth 1 -type d | head -1)" +[ -n "$SRC" ] || die "unexpected archive layout (no top-level directory)" +[ -f "$SRC/lib/$KERNEL_DIRNAME/composer.json" ] \ + || die "this does not look like an hkm tarball (no lib/$KERNEL_DIRNAME/composer.json)" +[ -x "$SRC/bin/hkm" ] || die "launcher missing from the archive (bin/hkm)" + +# ── preserve the registry across an upgrade ───────────────────────────────── +# projects.json / platform.json are USER DATA that happen to live in the kernel +# tree. `hkm-config check` (run at the end) migrates them OUT to the userdata +# dir, which is the permanent fix — but a user who has never run it still has +# their only copy in here, and the swap below would delete it. So carry them +# across first. The .deb marks the same two files as dpkg conffiles. +PRESERVE="projects/projects.json projects/platform.json" +if [ -d "$DEST" ]; then + for rel in $PRESERVE; do + if [ -f "$DEST/$rel" ]; then + mkdir -p "$SRC/lib/$KERNEL_DIRNAME/$(dirname "$rel")" + cp "$DEST/$rel" "$SRC/lib/$KERNEL_DIRNAME/$rel" + say "Kept your $rel" + fi + done +fi + +# ── install ───────────────────────────────────────────────────────────────── +mkdir -p "$BINDIR" "$PREFIX/lib" + +# Swap rather than overwrite in place: a half-copied kernel is worse than an old +# one, and rm -rf on the live tree would break a concurrent `hkm` invocation. +NEW="$PREFIX/lib/.$KERNEL_DIRNAME.new.$$" +OLD="$PREFIX/lib/.$KERNEL_DIRNAME.old.$$" +rm -rf "$NEW" +cp -R "$SRC/lib/$KERNEL_DIRNAME" "$NEW" + +if [ -d "$DEST" ]; then mv "$DEST" "$OLD"; fi +mv "$NEW" "$DEST" +rm -rf "$OLD" + +cp "$SRC/bin/hkm" "$BINDIR/hkm" +cp "$SRC/bin/hkm-config" "$BINDIR/hkm-config" +chmod +x "$BINDIR/hkm" "$BINDIR/hkm-config" +ok "Installed to $DEST" + +# ── drop a kernel pin this install makes redundant ────────────────────────── +# The launcher loads ~/.config/hkm/config.env into its environment before +# resolving. That file is shared by EVERY hkm on the machine, so a pin written +# for one install redirected the other one too — a .deb launcher reporting 1.3.1 +# while running a kernel out of the user's home. +# +# The bin/ + lib/ layout above is self-locating (the launcher probes +# "/lib/hkm-kernel"), so this install needs no pin at +# all. Removing one is therefore strictly better than repointing it: a repointed +# pin still applies machine-wide, while no pin lets each launcher find its own +# kernel. A pin aimed somewhere ELSE is an operator's deliberate choice about a +# custom layout and is only reported. +CFG="${XDG_CONFIG_HOME:-$HOME/.config}/hkm/config.env" +if [ -f "$CFG" ]; then + PINNED="$(sed -n 's/^[[:space:]]*HKM_KERNEL_HOME[[:space:]]*=[[:space:]]*//p' "$CFG" | tail -1)" + if [ -n "$PINNED" ]; then + if [ "$PINNED" = "$DEST" ]; then + "$BINDIR/hkm-config" unset HKM_KERNEL_HOME >/dev/null 2>&1 \ + && ok "Removed the redundant HKM_KERNEL_HOME pin (the launcher self-locates)" + elif [ "$PINNED" = "$LEGACY_USER" ]; then + # The pre-1.4 '--user' target. Not a custom layout an operator chose — a + # location this very install supersedes, and the one a machine that hit + # the cross-scope hijack is pinned to. Leaving it would keep a superseded + # kernel as the fallback for every launcher here, so remove it: the user + # kernel it named has just been replaced by the one at $DEST. + "$BINDIR/hkm-config" unset HKM_KERNEL_HOME >/dev/null 2>&1 \ + && ok "Removed the HKM_KERNEL_HOME pin to the superseded $PINNED" + else + warn "config.env pins HKM_KERNEL_HOME=$PINNED" + warn "This install does not need it, and it is shared with every other hkm" + warn "on this machine. Clear it with: hkm-config unset HKM_KERNEL_HOME" + fi + fi +fi + +# ── PHP dependencies (best effort — NEVER a precondition) ─────────────────── +# Installing is unconditional on purpose. Copying files into your own home +# cannot fail for want of PHP, and refusing to do it until an administrator has +# installed php8.4-mbstring helps nobody: you end up unable to even read `hkm +# doctor`, which is the thing that would have told you what to ask for. +# +# So: try to build vendor/, and if anything is missing just say so and finish. +# `hkm doctor` is the single authority on whether the environment can actually +# run the kernel, and it is installed and usable either way. +if [ "$SKIP_COMPOSER" -eq 1 ]; then + say "Skipping dependency resolution (--no-composer)." +elif ! command -v php >/dev/null 2>&1; then + say "No php on PATH — skipping dependency resolution for now." +elif [ -x "$DEST/install.sh" ]; then + say "Resolving PHP dependencies (composer install --no-dev)…" + # The kernel ships its own composer/modules helper (it also handles + # modules.lock and falls back to downloading composer.phar). + ( cd "$DEST" && ./install.sh ) || warn "Dependency resolution did not complete — hkm doctor will show why." +else + say "Resolving PHP dependencies (composer install --no-dev)…" + ( cd "$DEST" && composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist ) \ + || warn "Dependency resolution did not complete — hkm doctor will show why." +fi + +# ── PATH ──────────────────────────────────────────────────────────────────── +case ":${PATH}:" in + *":$BINDIR:"*) ok "$BINDIR is on your PATH" ;; + *) + warn "$BINDIR is NOT on your PATH." + printf ' Add it, then open a new terminal:\n' + case "${SHELL##*/}" in + zsh) printf ' echo '\''export PATH="%s:$PATH"'\'' >> ~/.zshrc\n' "$BINDIR" ;; + fish) printf ' fish_add_path %s\n' "$BINDIR" ;; + *) printf ' echo '\''export PATH="%s:$PATH"'\'' >> ~/.bashrc\n' "$BINDIR" ;; + esac + ;; +esac + +# ── move the registry out of the kernel tree ──────────────────────────────── +# `hkm-config check` creates ~/.local/share/hkm and MIGRATES projects.json + +# platform.json out of the kernel tree into it (ensureUserdata in config.zig). +# After this, an upgrade cannot touch the registry at all — it no longer lives +# in the replaced tree. +# +# It no longer pins HKM_KERNEL_HOME for a self-locating layout like this one; +# see the pin section above for why a machine-wide pin was the wrong default. +if [ -x "$BINDIR/hkm-config" ]; then + say "Checking configuration…" + # It exits non-zero when vendor/ is absent, which is the expected state after + # --no-composer — so only surface that as a problem when composer did run. + if ! "$BINDIR/hkm-config" check >/dev/null 2>&1; then + [ "$SKIP_COMPOSER" -eq 1 ] \ + || warn "hkm-config check reported problems — run '$BINDIR/hkm-config check' to see them." + fi +fi + +# ── verify ────────────────────────────────────────────────────────────────── +# The install itself has already succeeded. doctor's exit code reports the +# ENVIRONMENT, not the install, so it must not become this script's exit code. +printf '\n' +DOCTOR_OK=1 +if [ -x "$BINDIR/hkm" ]; then + say "Checking what the kernel still needs…" + "$BINDIR/hkm" doctor || DOCTOR_OK=0 +fi + +printf '\n' +ok "Installed for $(id -un) only; nothing was written outside your home." + +# State the version transition explicitly. "Installed" with no number is what +# leaves someone unsure whether anything changed — especially when another +# install on the machine is what their PATH actually resolves. +NEW_VER="$(kernel_version "$DEST")" +if [ -n "$OLD_VER" ] && [ -n "$NEW_VER" ] && [ "$OLD_VER" != "$NEW_VER" ]; then + printf ' Version: %s -> %s\n' "$OLD_VER" "$NEW_VER" +elif [ -n "$NEW_VER" ]; then + printf ' Version: %s\n' "$NEW_VER" +fi + +if [ "$DOCTOR_OK" -eq 0 ]; then + printf ' Some requirements are not met yet — see "Must fix" above.\n' + printf ' Installing PHP and its extensions needs an administrator; everything\n' + printf ' else you can do yourself. Re-check any time with: hkm doctor\n' +fi +printf ' Kernel: %s\n' "$DEST" +printf ' Config: %s/hkm/config.env\n' "${XDG_CONFIG_HOME:-$HOME/.config}" +printf ' Data: %s/hkm\n' "${XDG_DATA_HOME:-$HOME/.local/share}" +printf ' Remove: %s --uninstall\n' "$0" +printf '\n' +printf ' Every install on this machine, and which one your PATH runs: hkm version\n' +printf ' Update this one later (no root): hkm upgrade\n' diff --git a/tools/src/commands/doctor.zig b/tools/src/commands/doctor.zig index 9eb0957..2332ca2 100644 --- a/tools/src/commands/doctor.zig +++ b/tools/src/commands/doctor.zig @@ -1,27 +1,98 @@ -//! `hkm doctor` — diagnose the local environment before install / first run. +//! `hkm doctor` — the single authority on "can this machine run the kernel?". //! -//! Verifies the machine can actually run a PhpServicePlatform project: -//! • a `php` binary is on PATH (or HKM_PHP_BIN) and is >= 8.4 -//! • every REQUIRED PHP extension is loaded -//! • reports OPTIONAL extensions (redis, swoole, pdo drivers …) as hints -//! • at least one PDO driver is present -//! • the kernel autoload is resolvable (packaged install or --dev checkout) +//! Installing is deliberately unconditional: `tools/install.sh` only copies +//! files into your home and never demands PHP, composer or an administrator. +//! That trade means SOMETHING has to tell you what is still missing, in one +//! place, with the command to fix each item. This is that something. //! -//! The extension/version checks are delegated to PHP itself (a `php -r` preflight -//! script) so they reflect the EXACT runtime a project will use — not a guess. -//! Exit code is 0 only when PHP is present, new enough, and no required -//! extension is missing; otherwise 1 (CI-friendly gate before `hkm run`). +//! It walks every requirement the kernel actually has, in the order they matter: +//! +//! Launcher this binary, whether its dir is on PATH, and whether another +//! `hkm` earlier on PATH would shadow it +//! Kernel the kernel root, its PHP CLI, the first-party modules/ +//! path-repositories, and vendor/autoload.php +//! Configuration ~/.config/hkm/config.env, a STALE HKM_KERNEL_HOME pin, the +//! userdata dir and the project registry +//! Tooling php, composer, git, node/npm — each with what needs it +//! PHP runtime version, required extensions, a PDO driver (asked of PHP +//! itself via a `php -r` preflight, so it reflects the exact +//! runtime a project will use rather than a guess) +//! +//! Exit code is 0 only when every HARD requirement passes, so it works as a CI +//! gate. Soft findings (no git, no node, PATH not set up) warn and do not fail. const std = @import("std"); +const builtin = @import("builtin"); const run_cmd = @import("run.zig"); +const install_scope = @import("../lib/install_scope.zig"); const kernel = @import("../lib/kernel.zig"); const prompt = @import("../lib/prompt.zig"); +const userconfig = @import("../lib/userconfig.zig"); +const util = @import("../lib/util.zig"); const Io = std.Io; +const Dir = std.Io.Dir; const EnvMap = std.process.Environ.Map; -/// The PHP preflight. Kept as a single `-r` program so `hkm doctor` needs no -/// files on disk. Prints a human table and exits non-zero on a hard failure. +/// Accumulates findings so the run never stops at the first problem — someone +/// with three things missing should learn all three from one command. +const Report = struct { + hard: std.ArrayList([]const u8) = .empty, + soft: std.ArrayList([]const u8) = .empty, + allocator: std.mem.Allocator, + + fn fail(self: *Report, fix: []const u8) void { + self.hard.append(self.allocator, fix) catch {}; + } + fn hint(self: *Report, fix: []const u8) void { + self.soft.append(self.allocator, fix) catch {}; + } +}; + +const OK = "OK"; +const MISSING = "MISSING"; + +fn mark(present: bool) []const u8 { + return if (present) OK else MISSING; +} + +/// Locate an executable by walking PATH. Returns the FIRST match, which is the +/// one that would actually run. +fn findOnPath(allocator: std.mem.Allocator, io: Io, env: *EnvMap, name: []const u8) ?[]const u8 { + const path = env.get("PATH") orelse return null; + var it = std.mem.splitScalar(u8, path, ':'); + while (it.next()) |dir| { + if (dir.len == 0) continue; + const cand = std.fs.path.join(allocator, &.{ dir, name }) catch continue; + if (util.fileExists(io, cand)) return cand; + } + return null; +} + +/// Every match on PATH, in order — used to detect one install shadowing another. +fn countOnPath(allocator: std.mem.Allocator, io: Io, env: *EnvMap, name: []const u8) usize { + const path = env.get("PATH") orelse return 0; + var n: usize = 0; + var it = std.mem.splitScalar(u8, path, ':'); + while (it.next()) |dir| { + if (dir.len == 0) continue; + const cand = std.fs.path.join(allocator, &.{ dir, name }) catch continue; + if (util.fileExists(io, cand)) n += 1; + } + return n; +} + +fn dirOnPath(env: *EnvMap, dir: []const u8) bool { + const path = env.get("PATH") orelse return false; + var it = std.mem.splitScalar(u8, path, ':'); + while (it.next()) |entry| { + if (std.mem.eql(u8, util.trimSlash(entry), util.trimSlash(dir))) return true; + } + return false; +} + +/// The PHP preflight. A single `-r` program so `hkm doctor` needs no files on +/// disk. Prints a table and exits non-zero on a hard failure. const preflight = \\$reqPhp = '8.4.1'; \\$okPhp = version_compare(PHP_VERSION, $reqPhp, '>='); @@ -36,6 +107,8 @@ const preflight = \\$drivers = class_exists('PDO') ? PDO::getAvailableDrivers() : []; \\$hasDriver = (bool) array_intersect(['mysql','pgsql','sqlite','sqlsrv'], $drivers); \\printf(" pdo-driver %s (%s)\n", $hasDriver ? 'OK' : 'MISSING <-- need one', $drivers ? implode(',', $drivers) : 'none'); + \\$ml = ini_get('memory_limit'); + \\printf(" memory_limit %s\n", $ml === false ? 'unknown' : $ml); \\$optional = [ \\ 'redis' => 'RedisCache plugin (cache + queue)', \\ 'swoole' => 'OpenSwoole HTTP server (api face)', @@ -44,16 +117,13 @@ const preflight = \\ 'intl' => 'i18n / locale formatting', \\ 'zip' => 'archive support', \\ 'sodium' => 'modern crypto (recommended)', + \\ 'opcache' => 'production performance', \\]; \\echo "\n optional:\n"; \\foreach ($optional as $e => $why) { \\ printf(" ext-%-11s %-9s %s\n", $e, extension_loaded($e) ? 'present' : 'absent', $why); \\} - \\$hardFail = !$okPhp || $missing || !$hasDriver; - \\echo "\n"; - \\if ($hardFail) { echo " RESULT: FAIL — resolve the required items above.\n"; } - \\else { echo " RESULT: OK — environment satisfies the framework requirements.\n"; } - \\exit($hardFail ? 1 : 0); + \\exit((!$okPhp || $missing || !$hasDriver) ? 1 : 0); ; fn phpBin(allocator: std.mem.Allocator, env: *EnvMap) ![]const u8 { @@ -65,44 +135,242 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c _ = args; prompt.intro("hkm doctor"); + var rep = Report{ .allocator = allocator }; + const cwd = Dir.cwd(); + + // ── Platform ──────────────────────────────────────────────────────────── prompt.section("Platform"); - prompt.item("os", @tagName(@import("builtin").os.tag)); - prompt.item("arch", @tagName(@import("builtin").cpu.arch)); + prompt.item("os", @tagName(builtin.os.tag)); + prompt.item("arch", @tagName(builtin.cpu.arch)); + + // ── Launcher ──────────────────────────────────────────────────────────── + prompt.section("Launcher"); + var own_dir: ?[]const u8 = null; + if (std.process.executableDirPathAlloc(io, allocator)) |d| { + own_dir = d; + prompt.item("this binary", d); + if (dirOnPath(env, d)) { + prompt.item("on PATH", "yes"); + } else { + prompt.item("on PATH", "NO — `hkm` will not be found in a new shell"); + rep.hint(try std.fmt.allocPrint(allocator, "add to PATH: export PATH=\"{s}:$PATH\"", .{d})); + } + } else |_| { + prompt.item("this binary", "unknown"); + } + + // Two installs (a .deb in /usr/bin and a user install in ~/.local/bin) is a + // normal state, and the one that wins is decided by PATH order — silently. + const n_hkm = countOnPath(allocator, io, env, "hkm"); + if (n_hkm > 1) { + const first = findOnPath(allocator, io, env, "hkm") orelse "?"; + prompt.item("copies on PATH", try std.fmt.allocPrint(allocator, "{d} — first is {s}", .{ n_hkm, first })); + if (own_dir) |d| { + const own_exe = try std.fs.path.join(allocator, &.{ d, "hkm" }); + if (!std.mem.eql(u8, own_exe, first)) { + prompt.warn("another hkm earlier on PATH shadows this one."); + rep.hint("remove the system copy (sudo apt remove hkm-kernel) or reorder PATH"); + } + } + } + + // ── Installs ──────────────────────────────────────────────────────────── + // + // Listed before the kernel section because the two scopes are the context + // everything below it is read in. A machine can hold both, they upgrade + // separately, and PATH silently decides which launcher a bare `hkm` runs — + // so "which kernel am I even looking at" has to be answered first. + const home_early = try kernel.resolveHomeDetailed(allocator, io, env); + prompt.section("Installs"); + var install_rows: std.ArrayList([]const []const u8) = .empty; + var any_install = false; + for ([_]install_scope.Scope{ .system, .user }) |sc| { + const inst = install_scope.detect(allocator, io, env, sc); + if (inst.present) any_install = true; - // Show WHERE the launcher will find the kernel PHP CLI, and whether it - // actually exists — the #1 thing to confirm on a portable/.app/zip install. + const active: []const u8 = blk: { + const root = home_early.root orelse break :blk " "; + break :blk if (std.mem.eql(u8, util.trimSlash(root), util.trimSlash(inst.root))) "→" else " "; + }; + + try install_rows.append(allocator, try allocator.dupe([]const u8, &.{ + active, + sc.label(), + inst.root, + if (!inst.present) "not installed" else install_scope.versionLabel(inst.version), + if (!inst.present) "-" else if (inst.vendor) OK else "no vendor/", + })); + + if (inst.legacy_root) |legacy| { + rep.hint(try std.fmt.allocPrint( + allocator, + "a stale user kernel remains at {s} — migrate with `hkm upgrade --user`, then delete it", + .{legacy}, + )); + } + } + prompt.table(allocator, &.{ "", "scope", "kernel root", "version", "deps" }, install_rows.items); + if (!any_install) { + rep.hint("no kernel installed in either scope — `hkm upgrade --user` installs one without root"); + } + + // ── Kernel ────────────────────────────────────────────────────────────── prompt.section("Kernel"); const k = try kernel.resolve(allocator, io, env); prompt.item("cli path", k.path); prompt.item("resolved via", kernel.sourceLabel(k.source)); - prompt.item("present", if (k.exists) "yes" else "NO — set HKM_KERNEL_HOME or reinstall"); + prompt.item("cli present", if (k.exists) OK else "MISSING — reinstall or set HKM_KERNEL_HOME"); + if (!k.exists) rep.fail("kernel CLI missing — reinstall, or: hkm-config set-kernel-home "); + + const home_opt = home_early.root; + var vendor_ok = false; + if (home_opt) |home| { + prompt.item("kernel root", home); + + const composer_json = try std.fs.path.join(allocator, &.{ home, "composer.json" }); + const has_manifest = util.fileExists(io, composer_json); + prompt.item("composer.json", mark(has_manifest)); + if (!has_manifest) rep.fail("kernel root has no composer.json — the install is incomplete"); + + const src_dir = try std.fs.path.join(allocator, &.{ home, "src" }); + prompt.item("src/", mark(util.dirExists(cwd, io, src_dir))); + // The first-party packages are composer PATH repositories. When they are + // absent, `composer install` fails outright rather than degrading — so + // this is worth naming individually. + const mods = [_][]const u8{ "bind-it", "php-io-cli", "let-migrate", "http" }; + var missing_mods: usize = 0; + for (mods) |m| { + const p = try std.fs.path.join(allocator, &.{ home, "modules", m, "composer.json" }); + if (!util.fileExists(io, p)) missing_mods += 1; + } + prompt.item("modules/ (4 first-party)", if (missing_mods == 0) + OK + else + try std.fmt.allocPrint(allocator, "{d} MISSING — composer install will fail", .{missing_mods})); + if (missing_mods > 0) { + rep.fail("first-party modules missing — reinstall the bundle, or: git submodule update --init"); + } + + const autoload = try std.fs.path.join(allocator, &.{ home, "vendor", "autoload.php" }); + vendor_ok = util.fileExists(io, autoload); + prompt.item("vendor/autoload.php", if (vendor_ok) OK else "MISSING — dependencies not installed"); + if (!vendor_ok) { + rep.fail(try std.fmt.allocPrint(allocator, "install dependencies: cd {s} && ./install.sh", .{home})); + } + + prompt.item("root writable", if (util.canWrite(io, home)) "yes" else "no — composer install will fail here"); + if (!util.canWrite(io, home)) { + rep.hint("kernel root is not writable by you (a root-owned /opt install?) — prefer a user install"); + } + } else { + prompt.item("kernel root", "NOT FOUND"); + rep.fail("no kernel found — install it, or: hkm-config set-kernel-home "); + } + + // ── Configuration ─────────────────────────────────────────────────────── + prompt.section("Configuration"); + if (try userconfig.path(allocator, env)) |cfg| { + prompt.item("config file", cfg); + prompt.item("exists", if (util.fileExists(io, cfg)) "yes" else "no (defaults in use)"); + } + + // A config-file pin is shared by EVERY launcher on the machine, so one left + // behind by a user install used to redirect the system launcher's kernel + // too. Self-location now outranks it (lib/kernel.zig), which makes a + // leftover pin harmless but still worth removing: it is consulted whenever + // a launcher cannot self-locate, and that is a hard failure to read. + if (try userconfig.get(allocator, io, env, "HKM_KERNEL_HOME")) |pin| { + prompt.item("HKM_KERNEL_HOME", pin); + const in_use = if (home_opt) |h| std.mem.eql(u8, util.trimSlash(pin), util.trimSlash(h)) else false; + if (home_early.source == .kernel_home_config) { + prompt.warn("this kernel comes from the config pin — the launcher could not self-locate one."); + } else if (!in_use) { + prompt.warn("the pin points at a different kernel than the one in use (self-location wins)."); + rep.hint("remove the stale pin: hkm-config unset HKM_KERNEL_HOME"); + } else { + rep.hint("the pin is redundant (self-location finds the same kernel): hkm-config unset HKM_KERNEL_HOME"); + } + } else { + prompt.item("HKM_KERNEL_HOME", "not pinned (self-locating)"); + } + + const userdata = try userconfig.get(allocator, io, env, "HKM_USERDATA_DIR"); + if (userdata) |ud| { + prompt.item("userdata dir", ud); + prompt.item("writable", if (util.canWrite(io, ud)) "yes" else "NO — the registry cannot be updated"); + if (!util.canWrite(io, ud)) rep.fail("userdata dir is not writable — check its ownership"); + + const proj = try std.fs.path.join(allocator, &.{ ud, "projects.json" }); + prompt.item("projects.json", if (util.fileExists(io, proj)) OK else "absent (no projects registered yet)"); + } else { + prompt.item("userdata dir", "not pinned — run: hkm-config check"); + rep.hint("pin a persistent registry dir so upgrades cannot touch it: hkm-config check"); + } + + // ── Tooling ───────────────────────────────────────────────────────────── + prompt.section("Tooling"); const php = try phpBin(allocator, env); + const php_path = findOnPath(allocator, io, env, php); + prompt.item("php", php_path orelse "MISSING — required to run anything"); + if (php_path == null) { + rep.fail("install PHP >= 8.4 (Debian: sudo apt install php8.4-cli)"); + } - prompt.section("PHP runtime & extensions"); + const composer_path = findOnPath(allocator, io, env, "composer"); + prompt.item("composer", composer_path orelse "absent — needed only to build vendor/"); + if (composer_path == null and !vendor_ok) { + // The kernel's install.sh downloads composer.phar when composer is not + // installed, so this is a hint rather than a hard failure. + rep.hint("no composer — the kernel's install.sh will fetch composer.phar instead"); + } - // Run the preflight with stdout/stderr inherited so PHP prints the table. - var argv = [_][]const u8{ php, "-d", "display_errors=stderr", "-r", preflight }; - const code = run_cmd.spawnWait(io, env, &argv) catch |e| { - prompt.err("could not execute the PHP binary — is PHP installed and on PATH?"); - prompt.item("tried", php); - prompt.item("override", "set HKM_PHP_BIN=/full/path/to/php"); - prompt.blank(); - prompt.section("Install PHP >= 8.4"); - prompt.item("Debian/Ubuntu", "sudo apt install php8.4-cli php8.4-{mbstring,curl,pdo,mysql,xml}"); + prompt.item("git", findOnPath(allocator, io, env, "git") orelse "absent — needed by `hkm plugins` git sources"); + prompt.item("node", findOnPath(allocator, io, env, "node") orelse "absent — needed by `hkm ui` (frontend only)"); + prompt.item("npm", findOnPath(allocator, io, env, "npm") orelse "absent — needed by `hkm ui` (frontend only)"); + + // ── PHP runtime & extensions ──────────────────────────────────────────── + prompt.section("PHP runtime & extensions"); + if (php_path == null) { + prompt.warn("skipped — no php binary to ask."); + prompt.item("Debian/Ubuntu", "sudo apt install php8.4-cli php8.4-{mbstring,curl,xml,zip,mysql}"); prompt.item("macOS (brew)", "brew install php"); - prompt.item("Windows", "winget install PHP.PHP (or https://windows.php.net)"); - prompt.item("detail", @errorName(e)); - return 1; - }; + prompt.item("Windows", "winget install PHP.PHP"); + prompt.item("override", "set HKM_PHP_BIN=/full/path/to/php"); + } else { + var argv = [_][]const u8{ php, "-d", "display_errors=stderr", "-r", preflight }; + const code = run_cmd.spawnWait(io, env, &argv) catch |e| blk: { + prompt.err("could not execute the PHP binary."); + prompt.item("tried", php); + prompt.item("detail", @errorName(e)); + break :blk @as(u8, 1); + }; + if (code != 0) { + rep.fail("PHP runtime does not meet requirements — see the table above"); + } + } + // ── Verdict ───────────────────────────────────────────────────────────── prompt.blank(); - if (code == 0) { - prompt.ok("environment is ready — you can run `hkm run` / `hkm new`."); - } else { - prompt.warn("environment is INCOMPLETE — install the items marked required above."); - prompt.item("Debian/Ubuntu", "sudo apt install php8.4-{mbstring,curl,openssl,pdo,mysql,sqlite3}"); - prompt.item("macOS (brew)", "brew install php # bundles the common extensions"); + if (rep.hard.items.len == 0 and rep.soft.items.len == 0) { + prompt.ok("everything the kernel needs is present."); + return 0; + } + + if (rep.hard.items.len > 0) { + prompt.section("Must fix"); + for (rep.hard.items) |f| prompt.item("→", f); + } + if (rep.soft.items.len > 0) { + prompt.section("Worth fixing"); + for (rep.soft.items) |f| prompt.item("→", f); + } + + prompt.blank(); + if (rep.hard.items.len > 0) { + prompt.warn("environment is INCOMPLETE — the items under \"Must fix\" block the kernel."); + return 1; } - return code; + prompt.ok("environment is usable; the notes above are optional improvements."); + return 0; } diff --git a/tools/src/commands/module.zig b/tools/src/commands/module.zig index ec84875..b6760a5 100644 --- a/tools/src/commands/module.zig +++ b/tools/src/commands/module.zig @@ -335,7 +335,17 @@ fn removeModule(allocator: std.mem.Allocator, io: Io, env: *EnvMap, root: []cons _ = try runGit(allocator, io, env, root, &.{ "submodule", "deinit", "-f", rel }); _ = try runGit(allocator, io, env, root, &.{ "rm", "-f", rel }); Dir.cwd().deleteTree(io, try std.fmt.allocPrint(allocator, "{s}/.git/modules/{s}", .{ root, rel })) catch {}; - Dir.cwd().deleteTree(io, modulePath) catch {}; + // Kept, not swallowed: reporting "Removed submodule X" for a directory that + // is still on disk sends the reader looking for a different problem. + var delete_failed = false; + Dir.cwd().deleteTree(io, modulePath) catch { + delete_failed = true; + }; + if (delete_failed) { + prompt.err(try std.fmt.allocPrint(allocator, "could not delete {s} — it is still on disk.", .{modulePath})); + prompt.muted(" remove it by hand, then re-run to finish unwiring composer.json."); + return 1; + } // `git rm` already strips the .gitmodules section on modern git, so this is a // best-effort fallback for older git — silence its "no such section" noise. _ = try runGitQuiet(allocator, io, env, root, &.{ "config", "-f", ".gitmodules", "--remove-section", try std.fmt.allocPrint(allocator, "submodule.{s}", .{rel}) }); diff --git a/tools/src/commands/new.zig b/tools/src/commands/new.zig index cdb7a4f..22162ba 100644 --- a/tools/src/commands/new.zig +++ b/tools/src/commands/new.zig @@ -30,6 +30,10 @@ const prompt = @import("../lib/prompt.zig"); const util = @import("../lib/util.zig"); const services = @import("../lib/services.zig"); const plugin_assets = @import("../lib/plugin_assets.zig"); +const plugins_cmd = @import("plugins.zig"); +const plugin_boot = @import("../lib/plugin_bootstrap.zig"); +const installer = @import("../lib/plugin_install.zig"); +const lockfile = @import("../lib/plugin_lock.zig"); const Dir = std.Io.Dir; const Io = std.Io; @@ -115,6 +119,12 @@ const Options = struct { /// Domains for proj.json + the kernel registry. Null until resolved (flag or /// interactive prompt); see resolveDomains(). domains: ?[]const []const u8 = null, + /// --verify-plugins: run each plugin's own test suite while installing. + /// Off by default — scaffolding installs ~19 pinned, already-released + /// plugins, and testing each costs a composer install plus a phpunit run. + verify_plugins: bool = false, + /// Template variant: "" for the full starter, "simple" for the empty one. + variant: []const u8 = "", /// --no-register skips writing to the kernel projects.json registry. register: bool = true, /// --no-install skips running `composer install` after scaffolding. @@ -133,6 +143,8 @@ fn parse(allocator: std.mem.Allocator, args: []const []const u8) !?Options { var register = true; var install = true; var key = true; + var verify_plugins = false; + var variant: []const u8 = ""; var i: usize = 2; while (i < args.len) : (i += 1) { @@ -149,6 +161,14 @@ fn parse(allocator: std.mem.Allocator, args: []const []const u8) !?Options { if (i + 1 >= args.len) return error.MissingDomainsValue; i += 1; domains_csv = args[i]; + } else if (std.mem.eql(u8, a, "--simple") or std.mem.eql(u8, a, "--empty") or + std.mem.eql(u8, a, "--minimal")) + { + variant = "simple"; + } else if (std.mem.startsWith(u8, a, "--template=")) { + variant = a["--template=".len..]; + } else if (std.mem.eql(u8, a, "--verify-plugins")) { + verify_plugins = true; } else if (std.mem.eql(u8, a, "--no-register")) { register = false; } else if (std.mem.eql(u8, a, "--no-install")) { @@ -171,6 +191,8 @@ fn parse(allocator: std.mem.Allocator, args: []const []const u8) !?Options { .name = try allocator.dupe(u8, resolved_name), .studly = try studly(allocator, resolved_name), .domains = if (domains_csv) |csv| try splitDomains(allocator, csv) else null, + .verify_plugins = verify_plugins, + .variant = variant, .register = register, .install = install, .key = key, @@ -213,6 +235,9 @@ fn printHelp() void { prompt.item(" --project=", "project name (default: derived from path)"); prompt.item(" --domains=a.com,b.com", "comma-separated domains to register"); prompt.item(" --no-register", "skip kernel registry registration"); + prompt.item(" --simple", "empty project: no plugins at all (aliases: --empty/--minimal)"); + prompt.item(" --template=", "scaffold from a template variant under templates//"); + prompt.item(" --verify-plugins", "run each plugin's test suite while installing (slow)"); prompt.item(" --help, -h", "show this help"); prompt.blank(); prompt.section("Example"); @@ -263,8 +288,10 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c return 1; }; prompt.muted(try std.fmt.allocPrint(allocator, "templates: {s}", .{tpl_dir})); + var written: usize = 0; for (templates) |t| { - const raw = (try templateBody(allocator, io, tpl_dir, t)) orelse { + if (skippedByVariant(opts.variant, t.dest)) continue; + const raw = (try templateBody(allocator, io, tpl_dir, t, opts.variant)) orelse { prompt.err(try std.fmt.allocPrint( allocator, "Missing template '{s}' in {s}", @@ -275,8 +302,9 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c const data = try render(allocator, raw, opts); const p = try util.join(allocator, opts.path, t.dest); try cwd.writeFile(io, .{ .sub_path = p, .data = data }); + written += 1; } - prompt.ok(try std.fmt.allocPrint(allocator, "Scaffolded {d} files", .{templates.len})); + prompt.ok(try std.fmt.allocPrint(allocator, "Scaffolded {d} files", .{written})); // 3. register the project in the kernel's projects.json registry. if (opts.register) { @@ -293,7 +321,33 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c try composerInstall(allocator, io, env, opts); } - // 6. publish the assets (config/migrations/seeders/factories/resources) of + // 6. fetch the plugins the scaffolded bootstrap wires. + // + // The template enables a dozen providers (Logger, Crypto, Database, …) and + // maps Plugins\ to the project's plugins/ directory — but nothing put them + // there. The kernel stopped shipping plugins when they moved to their own + // repositories, so a freshly scaffolded project died on its first request + // with `Class "Plugins\Logger\Provider" does not exist`. They are fetched + // from git here, which is the same path `hkm plugins install` uses. + var plugins_missing: usize = 0; + if (opts.install) { + plugins_missing = installBootstrapPlugins(allocator, io, env, opts) catch blk: { + prompt.warn("Could not install the bootstrap's plugins — run 'hkm plugins install ' later."); + break :blk 1; + }; + } + + // 6b. wire each installed plugin's Support/helpers.php require. + // + // A helpers file defines global functions (`view()`, `cookie()`, …) that the + // plugin's own code calls. Nothing autoloads a bare function file, so an + // unwired one is an undefined-function fatal at the first call — a project + // that scaffolds cleanly and dies on its first request. + if (opts.install) { + _ = plugins_cmd.healSupportRequires(allocator, io, env, opts.path, false) catch 0; + } + + // 7. publish the assets (config/migrations/seeders/factories/resources) of // every plugin the project bootstrap enables (copy only — no migrate). plugin_assets.publishEnabled(allocator, io, env, opts.path) catch { prompt.warn("Could not publish plugin assets — run 'hkm plugins enable

' later."); @@ -304,7 +358,25 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c prompt.note("Next steps:"); prompt.muted(try std.fmt.allocPrint(allocator, " cd {s}", .{opts.path})); if (!opts.install) prompt.muted(" composer install"); - prompt.muted(" hkm run # or: php -S localhost:8000 -t app/public"); + // Only offer `hkm run` when running it would actually work — otherwise the + // next step is installing what is missing, listed above. + if (plugins_missing > 0) { + prompt.muted(" # install the missing plugins listed above first"); + } else { + prompt.muted(" hkm run # or: php -S localhost:8000 -t app/public"); + } + + // Saying "ready" when the providers the bootstrap wires are not on disk + // sends the user to `hkm run` for a fatal they were already warned about + // twenty lines earlier — and the last line is the one that gets read. + if (plugins_missing > 0) { + prompt.outro(try std.fmt.allocPrint( + allocator, + "Project '{s}' scaffolded — but {d} plugin(s) are missing, so it will not boot yet", + .{ opts.name, plugins_missing }, + )); + return 1; + } prompt.outro(try std.fmt.allocPrint(allocator, "Project '{s}' is ready", .{opts.name})); return 0; @@ -436,12 +508,47 @@ fn registerProject(allocator: std.mem.Allocator, io: Io, env: *EnvMap, opts: Opt /// The body for one template: read `

/` from disk. Templates with no /// `src` (.gitkeep) are always empty. Returns null when a required source file /// is missing on disk (the caller turns this into a clear error). -fn templateBody(allocator: std.mem.Allocator, io: Io, dir: []const u8, t: Template) !?[]const u8 { +/// Read a template file, letting a VARIANT override individual files. +/// +/// A variant ships only what differs — `templates/simple/` is one file, the +/// bootstrap — and everything else resolves to the shared template. A full +/// parallel tree would double every file in it and start drifting on the first +/// edit that only landed in one copy. +fn templateBody(allocator: std.mem.Allocator, io: Io, dir: []const u8, t: Template, variant: []const u8) !?[]const u8 { const src = t.src orelse return ""; + + if (variant.len > 0) { + const override = try std.fmt.allocPrint(allocator, "{s}/{s}/{s}", .{ dir, variant, src }); + if (Dir.cwd().readFileAlloc(io, override, allocator, .limited(8 * 1024 * 1024))) |body| { + return body; + } else |_| {} + } + const path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ dir, src }); return Dir.cwd().readFileAlloc(io, path, allocator, .limited(8 * 1024 * 1024)) catch null; } +/// Files that only make sense alongside the plugin they configure. +/// +/// `config/storage.php` configures the Storage plugin, `config/let-migrate.php` +/// the Database one. Scaffolding them into a project with no plugins leaves a +/// beginner reading configuration for something that is not installed and +/// wondering what they are missing. `hkm plugins install storage` publishes its +/// own config when the plugin actually arrives. +const plugin_owned_config = [_][]const u8{ + "config/storage.php", + "config/let-migrate.php", + "resources/welcome.php", +}; + +fn skippedByVariant(variant: []const u8, dest: []const u8) bool { + if (!std.mem.eql(u8, variant, "simple")) return false; + for (plugin_owned_config) |p| { + if (std.mem.eql(u8, p, dest)) return true; + } + return false; +} + // -------------------------------------------------------------------------- // template rendering // -------------------------------------------------------------------------- @@ -474,3 +581,106 @@ fn domainsJson(allocator: std.mem.Allocator, domains: []const []const u8) ![]con try out.appendSlice(allocator, " ]"); return out.toOwnedSlice(allocator); } + +/// Install every plugin the scaffolded bootstrap enables. +/// +/// Reads app/bootstrap/app.php rather than a hard-coded list, so the set can +/// never drift from what the template actually wires — a list here that fell +/// behind the template would reproduce exactly the missing-class failure this +/// exists to prevent. +/// Returns the number of plugins that could NOT be installed. +/// Record an installed plugin in plugins.lock.json, naming it if that fails. +/// +/// The install itself succeeded, so this is not fatal — but a silent failure +/// leaves the lock disagreeing with what is on disk, and the user with no idea +/// which plugin to re-add. +fn recordOrWarn( + allocator: std.mem.Allocator, + io: Io, + projectRoot: []const u8, + name: []const u8, + entry: lockfile.Entry, +) void { + installer.recordInLock(allocator, io, projectRoot, entry) catch { + const msg = std.fmt.allocPrint( + allocator, + "{s}: installed, but could not be recorded in plugins.lock.json — run `hkm plugins add {s}` to repair the lock.", + .{ name, name }, + ) catch return; + prompt.warn(msg); + }; +} + +fn installBootstrapPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, opts: Options) !usize { + const bootstrap = try util.join(allocator, opts.path, "app/bootstrap/app.php"); + const source = Dir.cwd().readFileAlloc(io, bootstrap, allocator, .limited(4 * 1024 * 1024)) catch return 0; + + var aliases: std.ArrayList(plugin_boot.Alias) = .empty; + try plugin_boot.collectAliases(allocator, source, &aliases); + + var enabled: std.ArrayList(plugin_boot.Enabled) = .empty; + try plugin_boot.collectEnabled(allocator, source, aliases.items, &enabled); + + if (enabled.items.len == 0) return 0; + + prompt.section("Installing plugins"); + + var ok: usize = 0; + // Names, not just a count: the message that follows is the only place the + // user learns WHICH plugins are missing, and "3 of 19 failed" leaves them + // diffing the bootstrap against plugins/ to find out. + var failed: std.ArrayList([]const u8) = .empty; + for (enabled.items) |e| { + const outcome = installer.install(allocator, io, env, opts.path, e.name, .{ + .interactive = false, + // Scaffolding installs ~19 pinned, already-released plugins. Running + // each one's suite means a composer install plus a phpunit run per + // plugin — tens of minutes, for versions that were tested when they + // were released. Verification stays the default for a deliberate + // single install, where it is worth the wait; here it is opt-in. + .verify = opts.verify_plugins, + }) catch { + try failed.append(allocator, e.name); + continue; + }; + switch (outcome) { + .refused => |why| { + try failed.append(allocator, e.name); + prompt.warn(why); + }, + .installed, .up_to_date, .linked, .updated => { + ok += 1; + _ = installer.report(allocator, e.name, outcome, false) catch {}; + // A lockfile write that fails is NOT a successful install: + // swallowing it left the command reporting success while + // plugins.lock.json did not record the plugin, so the next + // `hkm plugins` run cannot tell it is already there. + switch (outcome) { + .installed, .up_to_date, .linked => |entry| recordOrWarn(allocator, io, opts.path, e.name, entry), + .updated => |u| recordOrWarn(allocator, io, opts.path, e.name, u.to), + .refused => {}, + } + }, + } + } + + if (failed.items.len > 0) { + prompt.warn(try std.fmt.allocPrint( + allocator, + "{d} of {d} plugin(s) could not be installed — the project will not boot until they are:", + .{ failed.items.len, enabled.items.len }, + )); + // One command per plugin, and no separate list of bare names above it: + // the commands already name every one, and printing both meant reading + // the same nineteen names twice. `install` takes a SINGLE plugin — its + // second positional is the project path, so space-joining the names + // would install the first and treat the rest as a directory. + for (failed.items) |name| { + prompt.muted(try std.fmt.allocPrint(allocator, " hkm plugins install {s}", .{name})); + } + } else { + prompt.ok(try std.fmt.allocPrint(allocator, "{d} plugin(s) installed", .{ok})); + } + + return failed.items.len; +} diff --git a/tools/src/commands/plugins.zig b/tools/src/commands/plugins.zig index d50be79..8ad6281 100644 --- a/tools/src/commands/plugins.zig +++ b/tools/src/commands/plugins.zig @@ -15,6 +15,7 @@ const util = @import("../lib/util.zig"); const sources = @import("../lib/plugin_sources.zig"); const boot = @import("../lib/plugin_bootstrap.zig"); const assets = @import("../lib/plugin_assets.zig"); +const penv = @import("../lib/plugin_env.zig"); const ui = @import("../lib/plugin_ui.zig"); const deps = @import("../lib/plugin_deps.zig"); const installer = @import("../lib/plugin_install.zig"); @@ -22,6 +23,12 @@ const pgit = @import("../lib/plugin_git.zig"); const plock = @import("../lib/plugin_lock.zig"); const pregistry = @import("../lib/plugin_registry.zig"); const banner = @import("../lib/banner.zig"); +const plugin_ui = @import("../lib/plugin_ui.zig"); +const registry = @import("../lib/registry.zig"); +const domains = @import("../lib/plugin_domains.zig"); +const pstore = @import("../lib/plugin_store.zig"); +const userconfig = @import("../lib/userconfig.zig"); +const plugin_assets = @import("../lib/plugin_assets.zig"); const services = @import("../lib/services.zig"); const Dir = std.Io.Dir; @@ -33,7 +40,7 @@ const Located = sources.Located; const Enabled = boot.Enabled; const Activation = boot.Activation; -const Action = enum { analyze, verify, recover, enable, disable, update, upgrade, create, delete, make_migration, make_seeder, make_factory, install, uninstall, versions, outdated, sync_lock }; +const Action = enum { analyze, verify, recover, enable, disable, update, upgrade, create, delete, make_migration, make_seeder, make_factory, install, uninstall, versions, outdated, sync_lock, prune, domain_map, store_cmd }; pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []const u8) !u8 { var action: Action = .analyze; @@ -48,6 +55,25 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c var force = false; // --full: clone full history instead of --depth 1. var full_clone = false; + // --no-verify: install without running the plugin's test suite. The run + // costs a composer install per plugin, so it has to be skippable. + var verify = true; + // Was --verify / --no-verify given explicitly? A single deliberate install + // verifies by default; a BATCH (restore, or a dependency closure) does not, + // because the cost is a composer install plus a phpunit run PER PLUGIN and + // the versions being restored were tested when they were released. An + // explicit flag overrides either default. + var verify_explicit = false; + // --no-deps: install ONLY what was named. Dependencies come from the + // plugin's requires[] and are fetched by default, because a plugin without + // them is on disk and still cannot boot. + var with_deps = true; + // --set= / --migrate for `hkm plugins store`. + var set_store: []const u8 = ""; + // --latest: ignore the lock's pinned versions and take the newest release + // of every plugin. The opposite of the default, which is reproducibility. + var want_latest = false; + var migrate_store = false; var operands: std.ArrayList([]const u8) = .empty; var saw_action = false; @@ -71,6 +97,20 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c force = true; } else if (std.mem.eql(u8, a, "--full")) { full_clone = true; + } else if (std.mem.eql(u8, a, "--no-verify") or std.mem.eql(u8, a, "--skip-tests")) { + verify = false; + verify_explicit = true; + } else if (std.mem.eql(u8, a, "--verify") or std.mem.eql(u8, a, "--run-tests")) { + verify = true; + verify_explicit = true; + } else if (std.mem.eql(u8, a, "--no-deps")) { + with_deps = false; + } else if (std.mem.startsWith(u8, a, "--set=")) { + set_store = a["--set=".len..]; + } else if (std.mem.eql(u8, a, "--migrate")) { + migrate_store = true; + } else if (std.mem.eql(u8, a, "--latest") or std.mem.eql(u8, a, "--upgrade")) { + want_latest = true; } else if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) { printHelp(); return 0; @@ -89,16 +129,37 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c switch (action) { .analyze => return analyze(allocator, io, env, op(ops, 0), show_all), .install => { + // No plugin named → install everything the PROJECT declares, the + // way `composer install` does. Disambiguated the same way `update` + // is: an operand that resolves to a project root is the target, not + // a plugin — otherwise `hkm plugins install ./my-app` would try to + // install the project directory as a git remote. + const batch_verify = verify_explicit and verify; if (ops.len == 0) { - prompt.err("Usage: hkm plugins install [path|name] [--version=vX.Y.Z] [--force] [--full] [--dry-run]"); - return 2; + return restoreCmd(allocator, io, env, ".", .{ + .dry_run = dry_run, + .force = force, + .full = full_clone, + .verify = batch_verify, + }, with_deps, want_latest); + } + if (ops.len == 1) { + if ((try services.resolveRoot(allocator, io, env, op(ops, 0))) != null) { + return restoreCmd(allocator, io, env, op(ops, 0), .{ + .dry_run = dry_run, + .force = force, + .full = full_clone, + .verify = batch_verify, + }, with_deps, want_latest); + } } return installCmd(allocator, io, env, op(ops, 0), op(ops, 1), .{ .version = want_version, .dry_run = dry_run, .force = force, .full = full_clone, - }); + .verify = verify, + }, with_deps); }, .uninstall => { if (ops.len == 0) { @@ -115,12 +176,15 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c return versionsCmd(allocator, io, env, op(ops, 0)); }, .outdated => return outdatedCmd(allocator, io, env, op(ops, 0)), + .prune => return pruneCmd(allocator, io, env, op(ops, 0), dry_run), + .domain_map => return domainsCmd(allocator, io, env, op(ops, 0)), + .store_cmd => return storeCmd(allocator, io, env, set_store, migrate_store, dry_run), .sync_lock => return lockCmd(allocator, io, env, op(ops, 0), dry_run, .{ .version = want_version, .force = force, .full = full_clone, }), - .verify => return verifyPlugins(allocator, io, env, op(ops, 0), fix), + .verify => return verifyPlugins(allocator, io, env, op(ops, 0), fix, dry_run), .recover => return recoverAssets(allocator, io, env, op(ops, 0), dry_run), .enable, .disable => { if (ops.len == 0) { @@ -188,7 +252,8 @@ fn actionFromWordOpt(a: []const u8) ?Action { if (std.mem.eql(u8, a, "disable") or std.mem.eql(u8, a, "remove") or std.mem.eql(u8, a, "off")) return .disable; if (std.mem.eql(u8, a, "update") or std.mem.eql(u8, a, "sync")) return .update; if (std.mem.eql(u8, a, "upgrade") or std.mem.eql(u8, a, "reconcile") or std.mem.eql(u8, a, "migrate")) return .upgrade; - if (std.mem.eql(u8, a, "create") or std.mem.eql(u8, a, "new") or std.mem.eql(u8, a, "scaffold")) return .create; + if (std.mem.eql(u8, a, "create") or std.mem.eql(u8, a, "new") or + std.mem.eql(u8, a, "make") or std.mem.eql(u8, a, "scaffold")) return .create; if (std.mem.eql(u8, a, "delete") or std.mem.eql(u8, a, "del") or std.mem.eql(u8, a, "destroy") or std.mem.eql(u8, a, "rm")) return .delete; if (std.mem.eql(u8, a, "make:migration") or std.mem.eql(u8, a, "make-migration") or @@ -204,7 +269,12 @@ fn actionFromWordOpt(a: []const u8) ?Action { if (std.mem.eql(u8, a, "versions") or std.mem.eql(u8, a, "releases")) return .versions; if (std.mem.eql(u8, a, "outdated")) return .outdated; if (std.mem.eql(u8, a, "lock")) return .sync_lock; - if (std.mem.eql(u8, a, "list") or std.mem.eql(u8, a, "ls")) return .analyze; + if (std.mem.eql(u8, a, "prune") or std.mem.eql(u8, a, "gc")) return .prune; + if (std.mem.eql(u8, a, "domains")) return .domain_map; + if (std.mem.eql(u8, a, "store") or std.mem.eql(u8, a, "cache")) return .store_cmd; + if (std.mem.eql(u8, a, "list") or std.mem.eql(u8, a, "ls") or + std.mem.eql(u8, a, "analyze") or std.mem.eql(u8, a, "analyse") or + std.mem.eql(u8, a, "status")) return .analyze; if (std.mem.eql(u8, a, "verify") or std.mem.eql(u8, a, "check") or std.mem.eql(u8, a, "doctor") or std.mem.eql(u8, a, "scan") or std.mem.eql(u8, a, "audit")) return .verify; if (std.mem.eql(u8, a, "recover") or std.mem.eql(u8, a, "recover-assets") or @@ -215,15 +285,41 @@ fn actionFromWordOpt(a: []const u8) ?Action { /// Resolve a project root from `target` or error out. Shared by every action. fn requireRoot(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []const u8) !?[]const u8 { return (try services.resolveRoot(allocator, io, env, target)) orelse { - prompt.err(try std.fmt.allocPrint( - allocator, - "'{s}' is neither a project folder (with proj.json) nor a registered name.", - .{if (target.len == 0) "." else target}, - )); + // Distinguish "you named something wrong" from "you are standing in the + // wrong directory". The second is what happens when a command that + // defaults to the cwd is run from the kernel checkout, and telling + // someone that '.' is not a registered name explains nothing. + const implicit = target.len == 0 or std.mem.eql(u8, target, "."); + if (implicit) { + prompt.err("This directory is not a project — there is no proj.json here."); + } else { + prompt.err(try std.fmt.allocPrint( + allocator, + "'{s}' is neither a project folder (with proj.json) nor a registered name.", + .{target}, + )); + } + + prompt.muted(" run it from inside a project, or name one: hkm plugins "); + listKnownProjects(allocator, io, env); return null; }; } +/// Name the projects the kernel already knows, so "name one" is actionable +/// rather than an instruction to go and remember what they are called. +fn listKnownProjects(allocator: std.mem.Allocator, io: Io, env: *EnvMap) void { + const jsonPath = (registry.resolvePath(allocator, io, env) catch return) orelse return; + const entries = registry.list(allocator, io, jsonPath) catch return; + if (entries.len == 0) return; + + prompt.muted(""); + prompt.muted(" registered projects:"); + for (entries) |e| { + prompt.muted(std.fmt.allocPrint(allocator, " {s: <16}{s}", .{ e.name, e.path }) catch continue); + } +} + fn readBootstrap(allocator: std.mem.Allocator, io: Io, bootstrap: []const u8) !?[]const u8 { return Dir.cwd().readFileAlloc(io, bootstrap, allocator, .limited(4 * 1024 * 1024)) catch { prompt.err(try std.fmt.allocPrint(allocator, "Cannot read {s}", .{bootstrap})); @@ -358,7 +454,7 @@ fn subtreeLabel(sub: []const u8) []const u8 { /// seeders, factories, views) copied into the project + tracked in the manifest? /// Also checks the Support/helpers.php require. Reports per plugin; `--fix` /// delegates to `update` to publish anything missing and heal support requires. -fn verifyPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []const u8, fix: bool) !u8 { +fn verifyPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []const u8, fix: bool, dry_run: bool) !u8 { const root = (try requireRoot(allocator, io, env, target)) orelse return 1; const bootstrap = try std.fmt.allocPrint(allocator, "{s}/app/bootstrap/app.php", .{root}); @@ -411,6 +507,10 @@ fn verifyPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []c var plugins_dir: ?[]const u8 = null; var plugin_path: ?[]const u8 = null; var src_label: []const u8 = "—"; + // A link into the shared store whose target is gone. dirExists follows + // symlinks, so this is indistinguishable from "never installed" unless + // it is checked for separately — and the two need different repairs. + var dangling: ?[]const u8 = null; for (search) |src| { const d = srcs.dirFor(src) orelse continue; const fp = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ d, e.name }); @@ -420,6 +520,7 @@ fn verifyPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []c src_label = sources.sourceLabel(src); break; } + if (dangling == null and util.isSymlink(io, fp)) dangling = fp; } if (e.solves) |s| { @@ -428,18 +529,28 @@ fn verifyPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []c } if (plugin_path) |pp| { prompt.muted(try std.fmt.allocPrint(allocator, " source: {s} ({s})", .{ src_label, pp })); + } else if (dangling) |dp| { + prompt.err(try std.fmt.allocPrint( + allocator, + " \u{2717} broken link — {s} points into the shared store, but the target is gone", + .{dp}, + )); + prompt.muted(" repair: hkm plugins lock (restores every plugin at its locked version)"); + issues += 1; } else { - prompt.err(" ✗ plugin folder not found on disk — cannot verify its assets"); + prompt.err(" \u{2717} plugin folder not found on disk — cannot verify its assets"); + prompt.muted(try std.fmt.allocPrint(allocator, " install: hkm plugins install {s}", .{e.name})); issues += 1; } // 1. requires[] — each must be solved by another ENABLED plugin, be a // kernel port (no plugin provides it), else it is a real gap. const meta = if (plugins_dir) |pd| try sources.readModuleMeta(allocator, io, pd, e.name) else null; - const requires = if (meta) |m| m.requires else &[_][]const u8{}; + const requires = if (meta) |m| m.requires else &[_]sources.Requirement{}; if (requires.len > 0) { prompt.muted(" requires:"); - for (requires) |req| { + for (requires) |r| { + const req = r.domain; if (enabledSolves(enabled.items, req)) |provider| { prompt.muted(try std.fmt.allocPrint(allocator, " ✓ {s} ({s})", .{ req, provider })); } else if (deps.providerForDomain(cat.items, req)) |p| { @@ -492,8 +603,10 @@ fn verifyPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []c // 3. Support/helpers.php require wiring. const helpers = try std.fmt.allocPrint(allocator, "{s}/Support/helpers.php", .{pp}); if (util.fileExists(io, helpers)) { - const stag = try boot.supportTag(allocator, e.name); - if (std.mem.indexOf(u8, source, stag) != null) { + // Checked against the require itself, not just its marker + // comment — see supportRequireWired. + const expr = (try supportHelpersExpr(allocator, io, env, root, pp)) orelse ""; + if (boot.supportRequireWired(allocator, source, e.name, expr)) { prompt.muted(" ✓ Support/helpers.php require wired"); } else { prompt.err(" ✗ ships Support/helpers.php but its require is NOT wired in the bootstrap"); @@ -519,8 +632,15 @@ fn verifyPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []c prompt.warn(try std.fmt.allocPrint(allocator, "{d} plugin(s) with {d} issue(s) total", .{ with_issues, total_issues })); if (fix) { - prompt.section("Fixing — publishing missing assets + wiring Support requires"); - _ = try updatePlugins(allocator, io, env, "", target, false); + // --dry-run is threaded through, not dropped. It used to pass a + // hardcoded false, so `verify --fix --dry-run` — a command whose whole + // purpose is to show what WOULD change — published assets, ran + // migrations and rewrote the bootstrap. + prompt.section(if (dry_run) + "Fixing (dry run) — what would be published and wired" + else + "Fixing — publishing missing assets + wiring Support requires"); + _ = try updatePlugins(allocator, io, env, "", target, dry_run); prompt.note("Re-run `hkm plugins verify` to confirm; unmet requires need `hkm plugins enable `."); return 0; } @@ -659,13 +779,19 @@ fn enableWithDeps( if (located == null and !dry_run) { prompt.muted(try std.fmt.allocPrint(allocator, "{s} is not installed — fetching it…", .{folder})); - const outcome = try installer.install(allocator, io, env, root, folder, .{}); + // Honour a remote the lock already records: a plugin installed from a + // URL must be re-fetched from that URL, not from the registry's guess + // at the same name. + const known = if (plock.read(allocator, io, root)) |l| l.find(folder) else |_| null; + const outcome = try installer.install(allocator, io, env, root, folder, .{ + .remote = if (known) |k| k.remote else "", + }); switch (outcome) { .refused => |why| { prompt.err(why); return 1; }, - .installed, .up_to_date, .updated => { + .installed, .up_to_date, .linked, .updated => { fetched = outcome; _ = try installer.report(allocator, folder, outcome, false); }, @@ -687,20 +813,36 @@ fn enableWithDeps( var steps: std.ArrayList(Step) = .empty; for (needed.items) |dep| { if (boot.findEnabled(enabled, dep.located.name) != null) continue; // already wired - // Deps are pulled into the route graph via requires[] → on-demand is correct. - try steps.append(allocator, .{ .folder = dep.located.name, .dir = dep.located.dir, .essential = false, .dependency = true }); + // Deps reach the route graph through requires[], so on-demand is right + // for them — UNLESS the plugin itself says it cannot work that way. + try steps.append(allocator, .{ + .folder = dep.located.name, + .dir = dep.located.dir, + .essential = declaresEssential(allocator, io, dep.located.dir, dep.located.name), + .dependency = true, + }); } const target_enabled = boot.findEnabled(enabled, folder) != null; if (!target_enabled) { const dir = if (located) |l| l.dir else if (deps.findByName(cat, folder)) |p| p.located.dir else null; - try steps.append(allocator, .{ .folder = folder, .dir = dir, .essential = essential, .dependency = false }); + // -e forces it; otherwise the plugin's own manifest decides. + const as_essential = essential or + (if (dir) |d| declaresEssential(allocator, io, d, folder) else false); + if (as_essential and !essential) { + prompt.muted(try std.fmt.allocPrint( + allocator, + "{s} declares activation: essential — wiring it into withEssentialModules()", + .{folder}, + )); + } + try steps.append(allocator, .{ .folder = folder, .dir = dir, .essential = as_essential, .dependency = false }); } if (fetched) |outcome| { // Record it only after the fetch succeeded, so the lock never names a // plugin the project does not actually have. switch (outcome) { - .installed, .up_to_date => |e| try installer.recordInLock(allocator, io, root, e), + .installed, .up_to_date, .linked => |e| try installer.recordInLock(allocator, io, root, e), .updated => |u| try installer.recordInLock(allocator, io, root, u.to), .refused => {}, } @@ -765,7 +907,7 @@ fn phpQuote(allocator: std.mem.Allocator, s: []const u8) ![]const u8 { /// • anywhere else (a globally-installed package, an odd mount) /// → `''` (absolute literal — last resort) /// `null` when the plugin ships no helpers file to wire. -fn supportHelpersExpr(allocator: std.mem.Allocator, io: Io, env: *EnvMap, root: []const u8, pluginPath: []const u8) !?[]const u8 { +pub fn supportHelpersExpr(allocator: std.mem.Allocator, io: Io, env: *EnvMap, root: []const u8, pluginPath: []const u8) !?[]const u8 { const helpers = util.trimSlash(try std.fmt.allocPrint(allocator, "{s}/Support/helpers.php", .{pluginPath})); if (!util.fileExists(io, helpers)) return null; @@ -857,6 +999,19 @@ fn enableOne( for (preview.items) |p| prompt.muted(try std.fmt.allocPrint(allocator, " {s}", .{p})); prompt.muted(" + would run migrate:run --force"); } + + const vars = try penv.readVars(allocator, io, cd, folder); + if (vars.len > 0) { + const plan = try penv.seed(allocator, io, root, folder, vars, true); + if (plan.added.len > 0) { + prompt.muted(try std.fmt.allocPrint( + allocator, + " + would add {d} env var(s) to .env ({d} already present):", + .{ plan.added.len, plan.skipped }, + )); + for (plan.added) |v| prompt.muted(try std.fmt.allocPrint(allocator, " {s}", .{v.key})); + } + } } return updated; } @@ -869,6 +1024,51 @@ fn enableOne( prompt.muted(try std.fmt.allocPrint(allocator, " wired Support/helpers.php (require_once {s})", .{expr})); if (chosenDir) |cd| { + // Seed the plugin's declared env vars BEFORE migrations run: a + // migration reads the database config, and the whole point of writing + // the block is that the operator can see and set it first. + const vars = try penv.readVars(allocator, io, cd, folder); + if (vars.len > 0) { + const seeded = penv.seed(allocator, io, root, folder, vars, false) catch |e| blk: { + prompt.warn(try std.fmt.allocPrint( + allocator, + "could not write .env ({t}) — add {s}'s config[] variables by hand.", + .{ e, folder }, + )); + break :blk penv.Seeded{ .added = &.{}, .skipped = 0, .path = "", .created = false }; + }; + + if (seeded.added.len > 0) { + if (seeded.created) prompt.muted(" created .env"); + prompt.ok(try std.fmt.allocPrint( + allocator, + "Added {d} env var(s) to .env ({d} already present)", + .{ seeded.added.len, seeded.skipped }, + )); + + // Name the ones that BLOCK a boot separately. Everything else is + // a knob with a working default; these are the ones the operator + // has to act on, and burying them in a list of twenty would mean + // finding out from a failed boot instead. + var needs_value: usize = 0; + for (seeded.added) |v| { + if (v.required and v.default == null) needs_value += 1; + } + if (needs_value > 0) { + prompt.warn(try std.fmt.allocPrint( + allocator, + "{d} of them are REQUIRED and have no default — the boot fails until you set them:", + .{needs_value}, + )); + for (seeded.added) |v| { + if (v.required and v.default == null) { + prompt.muted(try std.fmt.allocPrint(allocator, " {s}", .{v.key})); + } + } + } + } + } + const fp = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ cd, folder }); var published: std.ArrayList([]const u8) = .empty; try assets.publishAssets(allocator, io, fp, root, &published); @@ -1588,19 +1788,42 @@ fn makeInPlugin(allocator: std.mem.Allocator, io: Io, env: *EnvMap, kind: MakeKi return 1; }; - const studlyName = try util.studly(allocator, name); - const lowerName = try util.lower(allocator, studlyName); + // The name is the user's, and it survives verbatim. + // + // It used to go through studly()+lower(), which silently ate the + // underscores: `make:migration Demo add_widgets` wrote + // `create_addwidgets_table.php` around `$schema->create('addwidgets')` — a + // name nobody typed, describing a table nobody wanted. + const snakeName = try util.snake(allocator, name); + const suffix = switch (kind) { + .migration => "", + .seeder => "Seeder", + .factory => "Factory", + }; + // `make:seeder WidgetSeeder` means WidgetSeeder, not WidgetSeederSeeder. + const baseName = util.stripSuffix(name, suffix); + const studlyName = try util.studly(allocator, baseName); + const migrationName = try migrationFileName(allocator, snakeName); + const tableName = try migrationTable(allocator, snakeName); const tpl_src = switch (kind) { - .migration => "migration.php", + // A name that alters gets a body that alters. Scaffolding create() for + // `add_widgets_to_orders` generated code that fails on any environment + // where `orders` already exists — which is all of them. + .migration => if (std.mem.startsWith(u8, migrationName, "create_")) + "migration.php" + else + "migration_alter.php", .seeder => "seeder.php", .factory => "factory.php", }; const dest_rel = switch (kind) { - .migration => try std.fmt.allocPrint(allocator, "database/migrations/{s}_create_{s}_table.php", .{ try util.timestampPrefix(allocator), lowerName }), + .migration => try std.fmt.allocPrint(allocator, "database/migrations/{s}_{s}.php", .{ try util.timestampPrefix(allocator), migrationName }), .seeder => try std.fmt.allocPrint(allocator, "database/seeders/{s}Seeder.php", .{studlyName}), .factory => try std.fmt.allocPrint(allocator, "database/factories/{s}Factory.php", .{studlyName}), }; + // The migration template names a TABLE; the others name a class. + const lowerName = if (kind == .migration) tableName else try util.lower(allocator, studlyName); const folderPath = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ chosen.dir, chosen.name }); const dest = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ folderPath, dest_rel }); @@ -1639,12 +1862,56 @@ fn makeInPlugin(allocator: std.mem.Allocator, io: Io, env: *EnvMap, kind: MakeKi return 0; } + +/// Verbs a migration name can start with. A name beginning with one already +/// says what it does, so it is used as written; anything else is wrapped as +/// `create__table`, which is what a bare noun ("widgets") means. +const migration_verbs = [_][]const u8{ + "create_", "add_", "update_", "drop_", "remove_", "rename_", "alter_", "change_", "modify_", +}; + +fn startsWithVerb(snakeName: []const u8) bool { + for (migration_verbs) |v| { + if (std.mem.startsWith(u8, snakeName, v)) return true; + } + return false; +} + +/// The migration file name (no timestamp, no extension). +fn migrationFileName(allocator: std.mem.Allocator, snakeName: []const u8) ![]const u8 { + if (startsWithVerb(snakeName)) return snakeName; + return std.fmt.allocPrint(allocator, "create_{s}_table", .{snakeName}); +} + +/// The table a migration name is about. +/// +/// A best guess, and deliberately a plain one — it seeds the scaffold, and the +/// author edits it. `add_widgets_to_orders` is about `orders`, not `widgets`: +/// the thing after `_to_` is the table being changed. +fn migrationTable(allocator: std.mem.Allocator, snakeName: []const u8) ![]const u8 { + var t = snakeName; + + if (std.mem.indexOf(u8, t, "_to_")) |i| return allocator.dupe(u8, t[i + 4 ..]); + if (std.mem.indexOf(u8, t, "_from_")) |i| return allocator.dupe(u8, t[i + 6 ..]); + if (std.mem.indexOf(u8, t, "_on_")) |i| return allocator.dupe(u8, t[i + 4 ..]); + + for (migration_verbs) |v| { + if (std.mem.startsWith(u8, t, v)) { + t = t[v.len..]; + break; + } + } + if (std.mem.endsWith(u8, t, "_table")) t = t[0 .. t.len - "_table".len]; + + return allocator.dupe(u8, if (t.len > 0) t else snakeName); +} + // ── help ────────────────────────────────────────────────────────────────────── fn printHelp() void { prompt.intro("hkm plugins"); prompt.section("Usage"); - prompt.item("hkm plugins [path|name]", "show the plugins/modules a project enables"); + prompt.item("hkm plugins [path|name]", "show the plugins/modules a project enables (aliases: list/ls/analyze/status)"); prompt.item("hkm plugins verify [proj]", "audit enabled plugins: wiring, deps + copied assets/views/migrations/configs"); prompt.item("hkm plugins recover [proj]", "rebuild var/plugin-assets.json from on-disk assets (aliases: rebuild/reindex)"); prompt.item("hkm plugins enable [proj]", "wire a plugin into the project bootstrap"); @@ -1655,11 +1922,17 @@ fn printHelp() void { prompt.item("hkm plugins delete [proj]", "delete a plugin folder from disk"); prompt.blank(); prompt.section("From git"); - prompt.item("hkm plugins install [proj]", "fetch a plugin from its git remote (aliases: fetch/get)"); + prompt.item("hkm plugins install [proj]", "install every plugin the project declares but does not have yet"); + prompt.item("hkm plugins install --latest", "…and move every one to its newest release (alias: --upgrade)"); + prompt.item("hkm plugins install [proj]", "fetch one plugin from its git remote (aliases: fetch/get)"); + prompt.item("hkm plugins install [proj]", "…or from any remote directly — a fork, a mirror, an unregistered plugin"); prompt.item("hkm plugins uninstall [proj]", "delete an installed plugin and drop it from the lock"); prompt.item("hkm plugins versions ", "list the releases available on the remote"); prompt.item("hkm plugins outdated [proj]", "show which locked plugins have a newer release"); prompt.item("hkm plugins lock [proj]", "restore every plugin at the exact version plugins.lock.json records"); + prompt.item("hkm plugins prune [proj]", "delete shared-store versions no project pins any more (alias: gc)"); + prompt.item("hkm plugins domains [proj]", "show which plugin provides each domain a requires[] can name"); + prompt.item("hkm plugins store", "show the global plugin cache (--set=, --migrate; alias: cache)"); prompt.item("hkm plugins make:migration ", "add a migration INTO a plugin (not published)"); prompt.item("hkm plugins make:seeder|make:factory ", "add a seeder/factory into a plugin"); prompt.blank(); @@ -1671,6 +1944,10 @@ fn printHelp() void { prompt.item("--version=", "install/update to a specific release instead of the newest"); prompt.item("--force", "overwrite a plugin working copy that has uncommitted changes"); prompt.item("--full", "clone full history instead of a shallow --depth 1"); + prompt.item("--no-verify", "install without running the plugin's test suite first"); + prompt.item("--verify", "run the suite even for a batch install, where it is off by default"); + prompt.item("--no-deps", "install only the named plugin — skip the plugins its requires[] needs"); + prompt.item("--latest", "restore: ignore the locked versions and take the newest release of each"); prompt.item("--fix, -f", "verify: publish missing assets + wire Support requires"); prompt.item("--help, -h", "show this help"); prompt.blank(); @@ -1686,7 +1963,7 @@ fn printHelp() void { prompt.item("upgrade", "split-safe: a migration moved to a new plugin keeps its data; only manifest ownership transfers, no DDL re-runs (aliases: reconcile/migrate)"); prompt.item("create", "scaffolds a complete plugin (config, migration, seeder, factory, view)"); prompt.item("Support helpers", "a plugin's Support/helpers.php is require_once'd in the bootstrap on enable, removed on disable"); - prompt.item("aliases", "enable=add/on · disable=remove/off · create=new/make · delete=del/rm"); + prompt.item("aliases", "enable=add/on · disable=remove/off · create=new/make/scaffold · delete=del/rm/destroy"); prompt.blank(); prompt.section("Resolution"); prompt.item("path", "a directory holding proj.json"); @@ -1706,32 +1983,683 @@ fn installCmd( plugin: []const u8, target: []const u8, opts: installer.Options, + with_deps: bool, ) !u8 { const root = (try requireRoot(allocator, io, env, target)) orelse return 1; prompt.intro("hkm plugins install"); prompt.ok(try std.fmt.allocPrint(allocator, "project {s}", .{root})); - const remote = try pregistry.remoteFor(allocator, env, plugin); + // A URL in place of a name installs straight from that remote. The display + // name is only a first guess taken from the repository — the installer + // replaces it with whatever the plugin's module.json declares. + const by_url = pregistry.isRemoteUrl(plugin); + var call_opts = opts; + const name = if (by_url) blk: { + call_opts.remote = std.mem.trim(u8, plugin, " \t\r\n"); + break :blk pregistry.nameFromRemote(allocator, plugin) catch { + prompt.err(try std.fmt.allocPrint( + allocator, + "Could not work out a plugin name from '{s}' — it has no repository name in it.", + .{plugin}, + )); + return 2; + }; + } else plugin; + + const remote = if (by_url) call_opts.remote else try pregistry.remoteFor(allocator, env, name); prompt.muted(try std.fmt.allocPrint(allocator, "remote {s}", .{remote})); + if (by_url) { + prompt.muted(try std.fmt.allocPrint(allocator, "name {s} (from the repository)", .{name})); + // Where it lands is the one thing a URL install changes silently, and + // it decides which composer resolves the plugin. + if (!pregistry.remoteIsFirstParty(env, remote)) { + prompt.muted("target this project's plugins/ (not a first-party remote)"); + } + } + + const outcome = try installer.install(allocator, io, env, root, name, call_opts); + + // Report — and later wire in — the name the INSTALLER settled on, not the + // argument. A URL install's argument is a URL, and a plugin whose + // module.json disagreed with its repository name is now on disk under the + // module.json name; using the argument printed a name nothing has, and had + // enable try to fetch a plugin called "https://…". + const final_name = switch (outcome) { + .installed, .up_to_date, .linked => |e| e.name, + .updated => |u| u.to.name, + .refused => name, + }; - const outcome = try installer.install(allocator, io, env, root, plugin, opts); - const code = try installer.report(allocator, plugin, outcome, opts.dry_run); + const code = try installer.report(allocator, final_name, outcome, opts.dry_run); + + // ── Its dependencies ──────────────────────────────────────────────────── + // + // A plugin declares what it needs as DOMAINS, and a plugin whose domains + // are not on disk installs cleanly and then fails at boot — the same + // class of failure as a project scaffolded without its plugins. Fetch the + // closure now, while there is somewhere to report it. + if (with_deps and code == 0 and !opts.dry_run) { + _ = installDependencies(allocator, io, env, root, final_name, opts, null) catch |e| { + prompt.warn(try std.fmt.allocPrint( + allocator, + "could not resolve {s}'s dependencies ({s}) — run 'hkm plugins verify' to see what is missing.", + .{ final_name, @errorName(e) }, + )); + return 0; + }; + } // Only a real change touches the lock file; a dry run must leave the // project byte-identical. if (!opts.dry_run) { switch (outcome) { - .installed, .up_to_date => |e| try installer.recordInLock(allocator, io, root, e), + .installed, .up_to_date, .linked => |e| try installer.recordInLock(allocator, io, root, e), .updated => |u| try installer.recordInLock(allocator, io, root, u.to), .refused => {}, } } - if (code == 0) { - prompt.outro(if (opts.dry_run) "Dry run — nothing was written" else "Enable it with: hkm plugins enable " ++ ""); + if (code != 0 or opts.dry_run) { + if (opts.dry_run) prompt.outro("Dry run — nothing was written"); + return code; + } + + // ── Finish the job ────────────────────────────────────────────────────── + // + // An installed plugin that is not wired into the bootstrap does nothing, + // and one whose assets are not published is wired but half-present. Doing + // the whole sequence here is the difference between "downloaded" and + // "usable"; each step is reported so a partial result is visible rather + // than assumed. + return finishInstall(allocator, io, env, root, final_name); +} + +/// `hkm plugins install` with no plugin — install everything the project +/// declares but does not have. +/// +/// The gap this closes: a project cloned from git carries its bootstrap and its +/// plugins.lock.json, and nothing else. `lock` restores only what the lock +/// records, so a plugin enabled in the bootstrap but never locked was invisible +/// to it; `update` and `upgrade` operate on plugins already on disk and reported +/// "0 plugins" on a checkout with none. The only way through was `hkm plugins +/// enable ` once per plugin, relying on enable's auto-fetch. +/// +/// Two sources, and the lock wins where they overlap — a locked version is a +/// deliberate pin, and restoring it as "newest" would defeat having a lock: +/// +/// plugins.lock.json → the exact version + remote recorded +/// the bootstrap → enabled plugins the lock has never heard of, newest +fn restoreCmd( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + target: []const u8, + base: installer.Options, + with_deps: bool, + latest: bool, +) !u8 { + const root = (try requireRoot(allocator, io, env, target)) orelse return 1; + + prompt.intro("hkm plugins install"); + if (latest) { + prompt.muted("--latest: taking the newest release of every plugin, ignoring the locked versions"); + } + prompt.ok(try std.fmt.allocPrint(allocator, "project {s}", .{root})); + + const Want = struct { name: []const u8, version: []const u8, remote: []const u8, from_lock: bool }; + var wanted: std.ArrayList(Want) = .empty; + + const lock = plock.read(allocator, io, root) catch plock.Lock{}; + for (lock.entries.items) |e| { + // A local plugin is not fetched from anywhere — it IS the project's. + if (std.mem.eql(u8, e.source, "local")) continue; + try wanted.append(allocator, .{ + .name = e.name, + // An empty version means "newest allowed". Dropping the pin is the + // whole of --latest: everything else about the restore is the same. + .version = if (latest) "" else e.version, + .remote = e.remote, + .from_lock = true, + }); + } + + const bootstrap = try std.fmt.allocPrint(allocator, "{s}/app/bootstrap/app.php", .{root}); + if (try readBootstrap(allocator, io, bootstrap)) |source| { + var aliases: std.ArrayList(boot.Alias) = .empty; + try boot.collectAliases(allocator, source, &aliases); + var enabled: std.ArrayList(Enabled) = .empty; + try boot.collectEnabled(allocator, source, aliases.items, &enabled); + + for (enabled.items) |e| { + var known = false; + for (wanted.items) |w| { + if (util.eqlIgnoreCase(w.name, e.name)) known = true; + } + if (!known) try wanted.append(allocator, .{ + .name = e.name, + .version = "", + .remote = "", + .from_lock = false, + }); + } + } + + if (wanted.items.len == 0) { + prompt.warn("This project declares no plugins — nothing to install."); + prompt.muted(" plugins come from app/bootstrap/app.php and plugins.lock.json."); + prompt.outro("Nothing to do"); + return 0; + } + + // What the project can already see, in either source. + const srcs = try sources.discoverSources(allocator, io, env, root); + const search = &[_]Source{ .project, .kernel }; + + var present: usize = 0; + var installed: usize = 0; + var pulled: usize = 0; // dependencies the project never listed + var pulled_names: std.ArrayList([]const u8) = .empty; + var failed: std.ArrayList([]const u8) = .empty; + // Plugins whose wiring still has to be done. Fetching one is only half the + // job: a plugin on disk that no bootstrap names is inert, and a DEPENDENCY + // that is installed-but-not-enabled fails the boot outright — the kernel + // refuses a requires[] domain no enabled module solves. + var to_wire: std.ArrayList([]const u8) = .empty; + + for (wanted.items) |w| { + const folder = try pregistry.canonicalName(allocator, w.name); + + var found = false; + for (search) |src| { + const d = srcs.dirFor(src) orelse continue; + const fp = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ d, folder }); + if (util.dirExists(Dir.cwd(), io, fp)) found = true; + } + // --latest has to reach a plugin that is already installed — that is + // exactly the plugin it exists to move forward. + if (found and !base.force and !latest) { + present += 1; + continue; + } + + if (base.dry_run) { + prompt.muted(try std.fmt.allocPrint(allocator, "would install {s} {s}", .{ + folder, + if (w.version.len > 0) w.version else "(newest)", + })); + installed += 1; + continue; + } + + var opts = base; + opts.version = w.version; + opts.remote = w.remote; + // One classmap rebuild for the whole run, not one per plugin. + opts.defer_autoload = true; + + const outcome = installer.install(allocator, io, env, root, folder, opts) catch { + try failed.append(allocator, folder); + continue; + }; + switch (outcome) { + .refused => |why| { + prompt.warn(why); + try failed.append(allocator, folder); + }, + .installed, .up_to_date, .linked, .updated => { + _ = try installer.report(allocator, folder, outcome, false); + switch (outcome) { + .installed, .up_to_date, .linked => |e| try installer.recordInLock(allocator, io, root, e), + .updated => |u| try installer.recordInLock(allocator, io, root, u.to), + .refused => {}, + } + installed += 1; + try to_wire.append(allocator, folder); + }, + } + } + + // Dependencies, for EVERY declared plugin — not only the ones just fetched. + // + // Scoping this to fresh installs meant a project whose plugins were all + // present never had its dependency graph checked at all. That is precisely + // when it matters: testpp had every plugin it listed, and still could not + // boot, because Tenancy's ROUTES require http.pageflow and nothing had ever + // gone looking for it. + if (!base.dry_run and with_deps) { + var dep_base = base; + dep_base.defer_autoload = true; + for (wanted.items) |w| { + const folder = try pregistry.canonicalName(allocator, w.name); + pulled += installDependencies(allocator, io, env, root, folder, dep_base, &pulled_names) catch 0; + } + } + + // Everything is on disk; make it visible to PHP, once. + if (!base.dry_run and (installed > 0 or pulled > 0)) { + installer.refreshAllAutoload(allocator, io, env, root); + } + + // Then wire it in — for EVERY plugin the project declares, not only the + // ones just downloaded. + // + // "Nothing to download" and "nothing to do" are different states. A project + // can have every plugin on disk and still not boot, because a dependency + // was fetched but never added to the bootstrap; scoping this to fresh + // installs meant re-running the command on such a project reported success + // and changed nothing. enable is idempotent — a plugin whose whole closure + // is already wired costs one no-op — so running it over everything is both + // cheap and the only way this command can promise a runnable project. + if (!base.dry_run) { + prompt.section("Wiring into the bootstrap"); + for (wanted.items) |w| { + const folder = try pregistry.canonicalName(allocator, w.name); + wirePlugin(allocator, io, env, root, folder) catch {}; + } + for (to_wire.items) |name| { + wirePlugin(allocator, io, env, root, name) catch {}; + } + // Anything the dependency walk pulled in is on disk but not yet wired. + for (pulled_names.items) |name| { + wirePlugin(allocator, io, env, root, name) catch {}; + } + + // Assets and UI once, after all the wiring — not per plugin. + plugin_assets.publishEnabled(allocator, io, env, root) catch { + prompt.warn("assets could not be published — run: hkm plugins update"); + }; + } + + // A plugin's Support/helpers.php defines global functions its own code + // calls; nothing autoloads a bare function file, so an unwired one is an + // undefined-function fatal at the first call. enable wires it for plugins + // it newly enables — this catches the ones that were already enabled and + // never had it wired. + if (!base.dry_run) { + _ = healSupportRequires(allocator, io, env, root, false) catch 0; + } + + if (present > 0) { + prompt.muted(try std.fmt.allocPrint(allocator, "{d} already installed", .{present})); + } + + if (failed.items.len > 0) { + prompt.warn(try std.fmt.allocPrint( + allocator, + "{d} plugin(s) could not be installed — the project will not boot until they are:", + .{failed.items.len}, + )); + for (failed.items) |name| { + prompt.muted(try std.fmt.allocPrint(allocator, " hkm plugins install {s}", .{name})); + } + prompt.outro(try std.fmt.allocPrint(allocator, "{d} installed, {d} failed", .{ installed, failed.items.len })); + return 1; + } + + if (base.dry_run) { + prompt.outro("Dry run — nothing was written"); + return 0; + } + + if (installed == 0) { + prompt.outro("Everything this project declares is already installed"); + return 0; + } + + if (pulled > 0) { + // Counted separately because they are not what was asked for: they are + // what the declared plugins turned out to need. + prompt.outro(try std.fmt.allocPrint( + allocator, + "{d} plugin(s) installed, plus {d} pulled in as dependencies", + .{ installed, pulled }, + )); + return 0; + } + prompt.outro(try std.fmt.allocPrint(allocator, "{d} plugin(s) installed", .{installed})); + return 0; +} + +/// Install everything `folder` declares in its requires[], transitively. +/// +/// Breadth-first over a queue rather than recursion, so a dependency cycle +/// costs a `seen` lookup instead of a stack overflow — and plugins DO form +/// long chains here (OAuth2 → Auth → User → Database, Crypto, Mail, …). +/// +/// Returns the number of plugins installed. Domains that resolve to nothing are +/// reported rather than failed on: a requires[] entry with no provider is +/// usually satisfied by a kernel port bound in withPorts(), which is not +/// something to fetch. +fn installDependencies( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + root: []const u8, + folder: []const u8, + base: installer.Options, + pulled_out: ?*std.ArrayList([]const u8), +) !usize { + var queue: std.ArrayList([]const u8) = .empty; + try queue.append(allocator, folder); + + var seen: std.ArrayList([]const u8) = .empty; + try seen.append(allocator, folder); + + var unresolved: std.ArrayList([]const u8) = .empty; + var installed: usize = 0; + var announced = false; + + var head: usize = 0; + while (head < queue.items.len) : (head += 1) { + const current = queue.items[head]; + + // Re-discovered each round: the previous iteration installed plugins, + // so a domain unresolvable a moment ago may now be answered by disk. + const srcs = try sources.discoverSources(allocator, io, env, root); + var cat: std.ArrayList(deps.Provider) = .empty; + try deps.catalogue(allocator, io, srcs, &.{ .project, .kernel }, &cat); + + const prov = deps.findByName(cat.items, current) orelse continue; + + for (prov.requires) |req| { + const domain = req.domain; + // Already provided by something on disk: nothing to fetch. + if (deps.providerForDomain(cat.items, domain) != null) continue; + + var overridden = false; + const hit = domains.resolveRequirement(cat.items, req, &overridden) orelse { + if (!util.contains(unresolved.items, domain)) { + try unresolved.append(allocator, domain); + } + continue; + }; + if (util.contains(seen.items, hit.folder)) continue; + try seen.append(allocator, hit.folder); + + if (!announced) { + prompt.section("Dependencies"); + announced = true; + } + prompt.muted(try std.fmt.allocPrint( + allocator, + "{s} ← needed for {s}{s}", + .{ + hit.folder, + domain, + if (hit.origin == .declared) " (repo declared by the plugin)" else "", + }, + )); + if (overridden) { + // Never silent: the manifest asked for one repository and it is + // being fetched from another. + prompt.muted(try std.fmt.allocPrint( + allocator, + " ignoring the declared repo — {s} is a platform domain, provided by {s}", + .{ domain, hit.folder }, + )); + } + + var dep_opts = base; + // Batched: one classmap rebuild after the closure, not one per + // dependency. Left alone when the CALLER is already batching. + dep_opts.defer_autoload = true; + // The root's VERSION does not carry to a different plugin — it would + // ask for a tag that does not exist there. A requirement that names + // its own ref does apply. + dep_opts.version = hit.version; + // Likewise the remote: resolved from the dependency's own name, + // unless the requirement declared where to get it. + dep_opts.remote = hit.repo; + + const outcome = installer.install(allocator, io, env, root, hit.folder, dep_opts) catch |e| { + prompt.warn(try std.fmt.allocPrint( + allocator, + "{s}: could not be installed ({s}).", + .{ hit.folder, @errorName(e) }, + )); + continue; + }; + + switch (outcome) { + .refused => |why| prompt.warn(why), + .installed, .up_to_date, .linked, .updated => { + _ = try installer.report(allocator, hit.folder, outcome, false); + switch (outcome) { + .installed, .up_to_date, .linked => |e| try installer.recordInLock(allocator, io, root, e), + .updated => |u| try installer.recordInLock(allocator, io, root, u.to), + .refused => {}, + } + installed += 1; + if (pulled_out) |out| try out.append(allocator, hit.folder); + // Its own requires[] are now in scope. + try queue.append(allocator, hit.folder); + }, + } + } + } + + // Only when this call owns the batch — a caller that set defer_autoload is + // installing more and will dump once itself. + if (installed > 0 and !base.defer_autoload) { + installer.refreshAllAutoload(allocator, io, env, root); + } + + if (unresolved.items.len > 0) { + // Not an error. Ports are bound in withPorts() and have no plugin to + // fetch — but a genuinely missing third-party plugin looks identical + // from here, so name them and let the reader judge. + prompt.muted(""); + prompt.muted("Not provided by any known plugin — kernel ports, or plugins to install by name/URL:"); + for (unresolved.items) |d| { + prompt.muted(try std.fmt.allocPrint(allocator, " {s}", .{d})); + } + } + + return installed; +} + +/// Wire every enabled plugin's `Support/helpers.php` require that is missing. +/// +/// A plugin's helpers file defines global functions its OWN code and the +/// project's code call directly (`__()`, `vite()`, `storage_config()`). Nothing +/// autoloads a plain function file — composer's `files` entry only covers +/// packages, and these plugins are linked in, not required as packages — so it +/// has to be `require_once`'d from the bootstrap or every call to it is an +/// undefined-function fatal. A plugin enabled before it shipped helpers, or +/// enabled by a path that predates the wiring, ends up exactly there: present, +/// loaded, and broken at the first helper call. +/// +/// Returns how many were wired (or would be, when `dry_run`). +pub fn healSupportRequires( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + root: []const u8, + dry_run: bool, +) !usize { + const bootstrap = try std.fmt.allocPrint(allocator, "{s}/app/bootstrap/app.php", .{root}); + const source = (try readBootstrap(allocator, io, bootstrap)) orelse return 0; + + var aliases: std.ArrayList(boot.Alias) = .empty; + try boot.collectAliases(allocator, source, &aliases); + var enabled: std.ArrayList(Enabled) = .empty; + try boot.collectEnabled(allocator, source, aliases.items, &enabled); + if (enabled.items.len == 0) return 0; + + const srcs = try sources.discoverSources(allocator, io, env, root); + const search = &[_]Source{ .project, .kernel }; + + var out = source; + var wired: usize = 0; + + for (enabled.items) |e| { + var path: ?[]const u8 = null; + for (search) |src| { + const d = srcs.dirFor(src) orelse continue; + const fp = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ d, e.name }); + if (util.dirExists(Dir.cwd(), io, fp)) { + path = fp; + break; + } + } + const pp = path orelse continue; + + const expr = (try supportHelpersExpr(allocator, io, env, root, pp)) orelse continue; + const woven = try boot.insertSupportRequire(allocator, out, e.name, expr); + if (woven.ptr == out.ptr) continue; // already wired + + out = woven; + wired += 1; + const verb = if (dry_run) "Would wire" else "Wired"; + prompt.ok(try std.fmt.allocPrint(allocator, "{s} Support/helpers.php for {s}", .{ verb, e.name })); + prompt.muted(try std.fmt.allocPrint(allocator, " + require_once {s}", .{expr})); + } + + if (wired > 0 and !dry_run) { + try Dir.cwd().writeFile(io, .{ .sub_path = bootstrap, .data = out }); + } + return wired; +} + +/// Wire a freshly installed plugin in: enable it, publish its assets, federate +/// its UI. Failures downgrade to warnings — the plugin IS installed, and a +/// missing UI mirror should not read as a failed install. +/// Does this plugin's module.json say it must be registered on every request? +/// +/// A plugin whose pipeline stage runs globally needs its bindings present +/// globally. Enabling such a plugin on-demand yields a project that installs, +/// boots, and then throws on the first request — a failure three steps removed +/// from its cause. `"activation": "essential"` moves that knowledge into the +/// plugin, where it is known, instead of the user's head. +fn declaresEssential(allocator: std.mem.Allocator, io: Io, dir: []const u8, name: []const u8) bool { + const meta = (sources.readModuleMeta(allocator, io, dir, name) catch return false) orelse return false; + const a = meta.activation orelse return false; + return util.eqlIgnoreCase(std.mem.trim(u8, a, " \t\r\n"), "essential"); +} + +/// Wire ONE plugin and its unmet requires[] closure into the bootstrap. +/// +/// Split out of finishInstall so a batch can wire many plugins and then publish +/// assets ONCE. Calling the full finish per plugin re-published every enabled +/// plugin's assets each time — quadratic, for a result identical to doing it +/// once at the end. +/// +/// Re-reads the bootstrap on every call: the previous plugin's wiring changed +/// it, and enabling against a stale copy would drop those edits. +fn wirePlugin( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + root: []const u8, + plugin: []const u8, +) !void { + const bootstrap = try std.fmt.allocPrint(allocator, "{s}/app/bootstrap/app.php", .{root}); + const source = (try readBootstrap(allocator, io, bootstrap)) orelse return; + + var aliases: std.ArrayList(boot.Alias) = .empty; + try boot.collectAliases(allocator, source, &aliases); + var enabled: std.ArrayList(Enabled) = .empty; + try boot.collectEnabled(allocator, source, aliases.items, &enabled); + + const srcs = try sources.discoverSources(allocator, io, env, root); + const search = &[_]Source{ .project, .kernel }; + var cat: std.ArrayList(deps.Provider) = .empty; + try deps.catalogue(allocator, io, srcs, search, &cat); + + var matches: std.ArrayList(Located) = .empty; + try sources.locate(allocator, io, srcs, plugin, search, &matches); + const located = sources.chooseLocated(allocator, matches.items); + + _ = enableWithDeps( + allocator, io, env, root, bootstrap, source, + cat.items, enabled.items, located, plugin, false, false, + ) catch |e| { + prompt.warn(try std.fmt.allocPrint( + allocator, + "{s}: could not be enabled ({t}) — run: hkm plugins enable {s}", + .{ plugin, e, plugin }, + )); + }; +} + +fn finishInstall( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + root: []const u8, + plugin: []const u8, +) !u8 { + const bootstrap = try std.fmt.allocPrint(allocator, "{s}/app/bootstrap/app.php", .{root}); + const source = (try readBootstrap(allocator, io, bootstrap)) orelse { + prompt.warn("No app/bootstrap/app.php — installed, but not enabled."); + return 0; + }; + + var aliases: std.ArrayList(boot.Alias) = .empty; + try boot.collectAliases(allocator, source, &aliases); + var enabled: std.ArrayList(Enabled) = .empty; + try boot.collectEnabled(allocator, source, aliases.items, &enabled); + + // Run the enable pass even when the plugin ITSELF is already wired. + // + // Skipping it on that basis meant a plugin listed in the bootstrap never had + // its requires[] closure resolved: `hkm plugins install` fetched Tenancy's + // Database and I18n, put them on disk, and left the bootstrap naming only + // Tenancy — a project that boots straight into "requires a domain no + // enabled module solves". Being enabled says nothing about whether what it + // DEPENDS on is. + // + // enableWithDeps is already the right shape for this: it computes the + // unmet closure and reports "already enabled" only when the plugin AND + // everything under it is wired, so the call costs nothing when there is + // nothing to do. + { + const srcs = try sources.discoverSources(allocator, io, env, root); + const search = &[_]Source{ .project, .kernel }; + var cat: std.ArrayList(deps.Provider) = .empty; + try deps.catalogue(allocator, io, srcs, search, &cat); + + var matches: std.ArrayList(Located) = .empty; + try sources.locate(allocator, io, srcs, plugin, search, &matches); + const located = sources.chooseLocated(allocator, matches.items); + + _ = enableWithDeps( + allocator, io, env, root, bootstrap, source, + cat.items, enabled.items, located, plugin, false, false, + ) catch |e| { + prompt.warn(try std.fmt.allocPrint( + allocator, + "installed, but could not be enabled ({t}) — run: hkm plugins enable {s}", + .{ e, plugin }, + )); + return 0; + }; + } + + plugin_assets.publishEnabled(allocator, io, env, root) catch { + prompt.warn("assets could not be published — run: hkm plugins update"); + }; + + syncPluginUi(allocator, io, env, root, plugin); + + prompt.outro("Installed, enabled, assets published"); + return 0; +} + +/// Mirror the plugin's ui/ into the project frontend, when it ships one. +fn syncPluginUi(allocator: std.mem.Allocator, io: Io, env: *EnvMap, root: []const u8, plugin: []const u8) void { + var uis: std.ArrayList(plugin_ui.UiPlugin) = .empty; + plugin_ui.discover(allocator, io, env, root, &uis) catch return; + + for (uis.items) |u| { + if (!util.eqlIgnoreCase(u.name, plugin)) continue; + if (u.linked) return; // a live symlink must not be overwritten by a copy + const n = plugin_ui.syncPlugin(allocator, io, root, u, false) catch return; + prompt.ok(std.fmt.allocPrint(allocator, "ui {s} → {s} ({d} file(s))", .{ u.name, u.alias, n }) catch return); + plugin_ui.writeGlue(allocator, io, root, uis.items) catch {}; + return; } - return code; } /// `hkm plugins uninstall [proj]` — delete the plugin folder and drop @@ -1748,41 +2676,71 @@ fn uninstallCmd( force: bool, ) !u8 { const root = (try requireRoot(allocator, io, env, target)) orelse return 1; - const dir = try installer.targetDir(allocator, root, plugin); + + // Canonical folder, not the raw argument: `install crypto` creates Crypto, + // so `uninstall crypto` has to look for Crypto or it finds nothing. + const folder = try pregistry.canonicalName(allocator, plugin); prompt.intro("hkm plugins uninstall"); - if (!util.dirExists(Dir.cwd(), io, dir)) { - prompt.warn(try std.fmt.allocPrint(allocator, "{s} is not installed in this project.", .{plugin})); + // What this project actually has is the ENTRY under its own plugins/ — + // usually a symlink into the shared store. Looking at the store path + // directly (as this used to) reported "not installed" for every plugin + // installed the modern way, while the link and the lock entry sat right + // there. + const link = try std.fs.path.join(allocator, &.{ root, "plugins", folder }); + + var lock = try plock.read(allocator, io, root); + const locked = lock.find(folder); + + if (!util.dirExists(Dir.cwd(), io, link) and locked == null) { + prompt.warn(try std.fmt.allocPrint(allocator, "{s} is not installed in this project.", .{folder})); return 0; } - // Uncommitted work in a plugin folder is usually a local fix in progress. - if (!force and pgit.isRepo(io, dir, allocator) and pgit.isDirty(allocator, io, env, dir)) { + // A REAL directory here (not a link) is either a third-party plugin or a + // working copy someone is editing — worth the dirty check. A store link is + // a pristine clone, so the check cannot fire on it. + // The dirty check only makes sense for a REAL directory here — a + // third-party plugin, or a working copy someone is editing. A symlink + // points at a managed store copy, which install deliberately strips of + // tests/ and vendor/ — so `git status` there always reports deletions and + // the check would refuse EVERY uninstall unless forced. + const managed = util.isSymlink(io, link); + if (!force and !managed and pgit.isRepo(io, link, allocator) and pgit.isDirty(allocator, io, env, link)) { prompt.err(try std.fmt.allocPrint( allocator, "{s} has uncommitted local changes. Commit or stash them, or pass --force to delete anyway.", - .{plugin}, + .{folder}, )); return 1; } if (dry_run) { - prompt.muted(try std.fmt.allocPrint(allocator, "would delete {s}", .{dir})); + prompt.muted(try std.fmt.allocPrint(allocator, "would remove {s}", .{link})); + if (locked) |e| prompt.muted(try std.fmt.allocPrint(allocator, "would drop lock entry {s} {s}", .{ e.name, e.version })); + prompt.muted("the shared store copy is kept — other projects may pin that version (hkm plugins prune)"); prompt.outro("Dry run — nothing was written"); return 0; } - Dir.cwd().deleteTree(io, dir) catch { - prompt.err(try std.fmt.allocPrint(allocator, "could not delete {s}", .{dir})); - return 1; + // deleteFile first: deleteTree on a SYMLINK would follow it and delete the + // shared store copy every other project depends on. + Dir.cwd().deleteFile(io, link) catch { + Dir.cwd().deleteTree(io, link) catch { + prompt.err(try std.fmt.allocPrint(allocator, "could not remove {s}", .{link})); + return 1; + }; }; - var lock = try plock.read(allocator, io, root); - _ = lock.remove(plugin); + _ = lock.remove(folder); try plock.write(allocator, io, root, &lock, banner.version()); - prompt.ok(try std.fmt.allocPrint(allocator, "removed {s}", .{plugin})); + // The project's autoloader still lists the old path until it is rebuilt. + installer.refreshAutoload(allocator, io, env, try std.fs.path.join(allocator, &.{ root, "plugins" })); + + prompt.ok(try std.fmt.allocPrint(allocator, "removed {s}", .{folder})); + prompt.muted("the shared store copy is kept for other projects — reclaim it with: hkm plugins prune"); prompt.outro("It may still be wired in the bootstrap — run: hkm plugins disable"); return 0; } @@ -1795,7 +2753,10 @@ fn versionsCmd(allocator: std.mem.Allocator, io: Io, env: *EnvMap, plugin: []con return 1; } - const remote = try pregistry.remoteFor(allocator, env, plugin); + const remote = if (pregistry.isRemoteUrl(plugin)) + std.mem.trim(u8, plugin, " \t\r\n") + else + try pregistry.remoteFor(allocator, env, plugin); prompt.intro(try std.fmt.allocPrint(allocator, "Releases of {s}", .{plugin})); prompt.muted(try std.fmt.allocPrint(allocator, "remote {s}", .{remote})); @@ -1866,7 +2827,11 @@ fn outdatedCmd(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []con prompt.outro("Everything is on its latest release"); return 0; } - prompt.outro(try std.fmt.allocPrint(allocator, "{d} plugin(s) behind — update with: hkm plugins install ", .{behind})); + prompt.note(""); + prompt.muted("move them all forward: hkm plugins install --latest"); + prompt.muted("or just one: hkm plugins install "); + prompt.muted("or pin one exactly: hkm plugins install --version=vX.Y.Z"); + prompt.outro(try std.fmt.allocPrint(allocator, "{d} plugin(s) behind", .{behind})); return 0; } @@ -1908,6 +2873,10 @@ fn lockCmd( .dry_run = dry_run, .force = base.force, .full = base.full, + // Restore from where it actually came from. Re-deriving the remote + // from the name would send a URL-installed plugin to the registry's + // guess instead — a different repository, at the same version. + .remote = e.remote, }); if ((try installer.report(allocator, e.name, outcome, dry_run)) != 0) failed += 1; } @@ -1919,3 +2888,336 @@ fn lockCmd( prompt.outro(if (dry_run) "Dry run — nothing was written" else "Project matches plugins.lock.json"); return 0; } + +/// `hkm plugins store` — where the global plugin cache is, and moving it. +/// +/// One download per (plugin, version, origin), shared by every project: project +/// A fetching Auth v1.2.0 pays for it once, project B links at what is already +/// there. This command is how that location is inspected, relocated, and how +/// caches left in older layouts are folded into it. +fn storeCmd( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + set_to: []const u8, + migrate: bool, + dry_run: bool, +) !u8 { + prompt.intro("hkm plugins store"); + + const kernel_fallback = blk: { + const p = installer.pluginsRoot(allocator, io, env, ".") catch break :blk "."; + break :blk util.parentOf(p) orelse "."; + }; + + if (set_to.len > 0) { + const abs = util.trimSlash(std.mem.trim(u8, set_to, " \t\r\n")); + if (abs.len == 0 or abs[0] != '/') { + prompt.err("--set needs an ABSOLUTE path — the store is shared by projects in different directories."); + return 2; + } + if (dry_run) { + prompt.muted(try std.fmt.allocPrint(allocator, "would set HKM_PLUGIN_STORE={s}", .{abs})); + prompt.outro("Dry run — nothing was written"); + return 0; + } + Dir.cwd().createDirPath(io, abs) catch { + prompt.err(try std.fmt.allocPrint(allocator, "could not create {s}", .{abs})); + return 1; + }; + userconfig.set(allocator, io, env, "HKM_PLUGIN_STORE", abs) catch { + prompt.err("could not write the config file."); + return 1; + }; + prompt.ok(try std.fmt.allocPrint(allocator, "store set to {s}", .{abs})); + prompt.muted(" existing caches stay where they are — fold them in with: hkm plugins store --migrate"); + // Read back through the same path resolution the installer uses, so + // what is reported is what will actually be used. + try env.put("HKM_PLUGIN_STORE", abs); + } + + const root_dir = try pstore.root(allocator, env, kernel_fallback); + prompt.ok(try std.fmt.allocPrint(allocator, "store {s}", .{root_dir})); + prompt.muted(try std.fmt.allocPrint(allocator, "layout /-", .{})); + + if (migrate) { + const moved = try migrateStores(allocator, io, env, root_dir, kernel_fallback, dry_run); + if (moved == 0) prompt.muted("nothing to migrate — no cache found in an older location."); + } + + // Contents. + var plugins: usize = 0; + var versions: usize = 0; + if (util.dirExists(Dir.cwd(), io, root_dir)) { + var d = Dir.cwd().openDir(io, root_dir, .{ .iterate = true }) catch { + prompt.outro("store is not readable"); + return 1; + }; + defer d.close(io); + var it = d.iterate(); + while (try it.next(io)) |e| { + if (e.kind != .directory) continue; + plugins += 1; + const pd = try std.fs.path.join(allocator, &.{ root_dir, e.name }); + var vd = Dir.cwd().openDir(io, pd, .{ .iterate = true }) catch continue; + defer vd.close(io); + var vit = vd.iterate(); + while (try vit.next(io)) |v| { + if (v.kind == .directory) versions += 1; + } + } + } + + prompt.blank(); + prompt.item("cached", try std.fmt.allocPrint(allocator, "{d} plugin(s), {d} version(s)", .{ plugins, versions })); + prompt.muted("reclaim unreferenced versions with: hkm plugins prune"); + prompt.outro("Shared by every project on this machine"); + return 0; +} + +/// Fold caches left in older locations into the current store. +/// +/// Two layouts predate it: `/plugin-store` (when the store lived beside +/// the kernel) and `/plugin-store` (when it followed the install +/// target, so every project kept its own copy). Entries are MOVED, never +/// merged over: a destination that already exists is left alone, because the +/// two directories are the same (plugin, version, origin) and the one already +/// in place is the one projects are linked to. +fn migrateStores( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + dest_root: []const u8, + kernel_root: []const u8, + dry_run: bool, +) !usize { + var sources_list: std.ArrayList([]const u8) = .empty; + try sources_list.append(allocator, try std.fs.path.join(allocator, &.{ kernel_root, pstore.dir_name })); + if (try registry.resolvePath(allocator, io, env)) |jsonPath| { + for (try registry.list(allocator, io, jsonPath)) |e| { + try sources_list.append(allocator, try std.fs.path.join(allocator, &.{ e.path, pstore.dir_name })); + } + } + + var moved: usize = 0; + for (sources_list.items) |src| { + if (std.mem.eql(u8, src, dest_root)) continue; + if (!util.dirExists(Dir.cwd(), io, src)) continue; + + prompt.section(try std.fmt.allocPrint(allocator, "migrating {s}", .{src})); + + var d = Dir.cwd().openDir(io, src, .{ .iterate = true }) catch continue; + defer d.close(io); + var it = d.iterate(); + while (try it.next(io)) |plugin| { + if (plugin.kind != .directory) continue; + const from_plugin = try std.fs.path.join(allocator, &.{ src, plugin.name }); + var vd = Dir.cwd().openDir(io, from_plugin, .{ .iterate = true }) catch continue; + defer vd.close(io); + var vit = vd.iterate(); + while (try vit.next(io)) |v| { + if (v.kind != .directory) continue; + const from = try std.fs.path.join(allocator, &.{ from_plugin, v.name }); + const to_plugin = try std.fs.path.join(allocator, &.{ dest_root, plugin.name }); + const to = try std.fs.path.join(allocator, &.{ to_plugin, v.name }); + + if (util.dirExists(Dir.cwd(), io, to)) { + prompt.muted(try std.fmt.allocPrint(allocator, " {s}/{s} already cached — left in place", .{ plugin.name, v.name })); + continue; + } + if (dry_run) { + prompt.muted(try std.fmt.allocPrint(allocator, " would move {s}/{s}", .{ plugin.name, v.name })); + moved += 1; + continue; + } + Dir.cwd().createDirPath(io, to_plugin) catch {}; + Dir.cwd().rename(from, Dir.cwd(), to, io) catch { + prompt.warn(try std.fmt.allocPrint(allocator, " could not move {s}/{s}", .{ plugin.name, v.name })); + continue; + }; + prompt.ok(try std.fmt.allocPrint(allocator, " moved {s}/{s}", .{ plugin.name, v.name })); + moved += 1; + } + } + } + + if (moved > 0 and !dry_run) { + // The project links point at the OLD paths and are now dangling. + prompt.muted(""); + prompt.muted("project links still point at the old paths — repoint them with:"); + prompt.muted(" hkm plugins lock (in each project)"); + } + return moved; +} + +/// `hkm plugins prune` — drop shared-store versions nothing pins any more. +/// +/// The store keeps one copy per (plugin, version) so projects can share a +/// download and pin independently. Nothing ever removed from it, so every +/// version any project EVER used accumulated forever. This is the other half of +/// that design. +/// +/// A version is kept if ANY known project's plugins.lock.json still names it. +/// "Known" means the kernel registry plus, if given, the project argument — so a +/// project that was never registered is invisible here. That is why an +/// unreadable or missing lock aborts rather than being treated as "pins +/// nothing": guessing wrong deletes a version a live project depends on. +fn pruneCmd(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []const u8, dry_run: bool) !u8 { + prompt.intro("hkm plugins prune"); + + const plugins_dir = try installer.pluginsRoot(allocator, io, env, if (target.len > 0) target else "."); + const kernel_root = util.parentOf(plugins_dir) orelse "."; + const store = try pstore.root(allocator, env, kernel_root); + + prompt.muted(try std.fmt.allocPrint(allocator, "store {s}", .{store})); + + if (!util.dirExists(Dir.cwd(), io, store)) { + prompt.muted("no shared store — nothing to prune."); + return 0; + } + + // Collect every project that might pin something. + var roots: std.ArrayList([]const u8) = .empty; + if (try registry.resolvePath(allocator, io, env)) |jsonPath| { + for (try registry.list(allocator, io, jsonPath)) |e| { + try roots.append(allocator, e.path); + } + } + if (target.len > 0) { + if (try services.resolveRoot(allocator, io, env, target)) |r| try roots.append(allocator, r); + } + + if (roots.items.len == 0) { + prompt.err("no registered projects found — refusing to prune."); + prompt.muted(" every store version would look unreferenced, and pruning would delete all of them."); + prompt.muted(" register a project first (hkm discover), or pass one: hkm plugins prune "); + return 1; + } + + // Everything still pinned, as "/". + var pinned: std.ArrayList([]const u8) = .empty; + for (roots.items) |root| { + const lock = plock.read(allocator, io, root) catch continue; + for (lock.entries.items) |e| { + if (e.version.len == 0) continue; + // The exact directory name, origin hash included — a fork's copy + // must not be kept alive by the upstream's lock entry. + const key = try pstore.versionKey(allocator, e.version, e.remote); + try pinned.append(allocator, try std.fmt.allocPrint(allocator, "{s}/{s}", .{ e.name, key })); + // Entries written before origin hashing are bare versions. + if (e.remote.len > 0) { + try pinned.append(allocator, try std.fmt.allocPrint(allocator, "{s}/{s}", .{ e.name, e.version })); + } + } + } + + prompt.ok(try std.fmt.allocPrint(allocator, "{d} project(s) pin {d} version(s)", .{ roots.items.len, pinned.items.len })); + // Said out loud because it is the one way this can do damage: a project the + // kernel has never been told about pins nothing as far as prune can see, so + // its versions look free. Deleting one breaks that project's plugin links. + prompt.muted(" only registered projects are consulted — run hkm discover first if any are missing."); + + var freed: usize = 0; + var kept: usize = 0; + var names = Dir.cwd().openDir(io, store, .{ .iterate = true }) catch { + prompt.err("could not read the store."); + return 1; + }; + defer names.close(io); + + var name_it = names.iterate(); + while (try name_it.next(io)) |plugin_entry| { + if (plugin_entry.kind != .directory) continue; + + const plugin_dir = try std.fs.path.join(allocator, &.{ store, plugin_entry.name }); + var versions = Dir.cwd().openDir(io, plugin_dir, .{ .iterate = true }) catch continue; + defer versions.close(io); + + var v_it = versions.iterate(); + while (try v_it.next(io)) |v| { + if (v.kind != .directory) continue; + + const key = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ plugin_entry.name, v.name }); + if (util.contains(pinned.items, key)) { + kept += 1; + continue; + } + + const path = try std.fs.path.join(allocator, &.{ plugin_dir, v.name }); + if (dry_run) { + prompt.muted(try std.fmt.allocPrint(allocator, "would delete {s}", .{key})); + } else { + Dir.cwd().deleteTree(io, path) catch { + prompt.warn(try std.fmt.allocPrint(allocator, "could not delete {s}", .{key})); + continue; + }; + prompt.ok(try std.fmt.allocPrint(allocator, "deleted {s}", .{key})); + } + freed += 1; + } + } + + if (freed == 0) { + prompt.outro(try std.fmt.allocPrint(allocator, "nothing to prune — all {d} stored version(s) are still pinned", .{kept})); + return 0; + } + prompt.outro(try std.fmt.allocPrint( + allocator, + "{s} {d} version(s); {d} still pinned", + .{ if (dry_run) "would free" else "freed", freed, kept }, + )); + return 0; +} + +/// `hkm plugins domains` — the domain → plugin lookup, and where each entry +/// came from. +/// +/// Exists because the mapping is invisible otherwise: a plugin's requires[] +/// names domains, and nothing in a project says which plugin answers one. When +/// an install reports a domain it could not resolve, this is the table to read. +fn domainsCmd(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []const u8) !u8 { + const root = (try services.resolveRoot(allocator, io, env, if (target.len > 0) target else ".")) orelse ""; + + prompt.intro("hkm plugins domains"); + + // Installed plugins first — their module.json is the authority, and seeing + // them separated from the built-in table is the point: one is fact, the + // other is this tool's last known good guess. + var cat: std.ArrayList(deps.Provider) = .empty; + if (root.len > 0) { + const srcs = try sources.discoverSources(allocator, io, env, root); + try deps.catalogue(allocator, io, srcs, &.{ .project, .kernel }, &cat); + prompt.ok(try std.fmt.allocPrint(allocator, "project {s}", .{root})); + } + + var installed: usize = 0; + for (cat.items) |p| { + if (p.solves == null) continue; + installed += 1; + } + + if (installed > 0) { + prompt.section("Installed — read from each plugin's module.json"); + for (cat.items) |p| { + const d = p.solves orelse continue; + prompt.item(d, p.located.name); + } + } + + prompt.section("Built in — used for plugins not installed yet"); + var seeded: usize = 0; + for (domains.seed) |m| { + // Don't repeat what disk already answered above. + if (deps.providerForDomain(cat.items, m.domain) != null) continue; + prompt.item(m.domain, m.folder); + seeded += 1; + } + if (seeded == 0) prompt.muted(" (every seeded domain is already installed)"); + + prompt.outro(try std.fmt.allocPrint( + allocator, + "{d} from disk, {d} from the built-in table", + .{ installed, seeded }, + )); + return 0; +} diff --git a/tools/src/commands/upgrade.zig b/tools/src/commands/upgrade.zig index 25a6454..4e16643 100644 --- a/tools/src/commands/upgrade.zig +++ b/tools/src/commands/upgrade.zig @@ -1,23 +1,52 @@ -//! `hkm upgrade [--check]` — check for and apply kernel updates. +//! `hkm upgrade` — check for and apply kernel updates, per INSTALL SCOPE. //! -//! hkm upgrade --check # compare the installed version to the latest release -//! hkm upgrade # git checkout → pull + composer; packaged → guidance +//! hkm upgrade # update the install this user owns (~/.local) — no root +//! sudo hkm upgrade # update the system install (/opt + /usr/bin) +//! hkm upgrade --check # compare each scope's kernel to the latest release +//! hkm upgrade --local # install THIS checkout over an installed kernel +//! +//! WHY THE SCOPE SPLIT EXISTS +//! -------------------------- +//! Linux publishes TWO artifacts and the tarball is the primary one (see +//! tools/bundle.sh): a user-local tarball that needs no root, and a .deb for +//! multi-user machines. `hkm upgrade` only ever fetched the .deb and shelled +//! out to `sudo apt-get`, so: +//! +//! • a user install could not update itself at all — the command "succeeded", +//! updated /opt, and left ~/.local/bin/hkm exactly as it was; +//! • PATH usually resolves ~/.local/bin BEFORE /usr/bin, so the very next +//! command ran the old launcher and the version had not moved; +//! • and a non-root user was prompted for a password to update a copy of the +//! kernel they were not running. +//! +//! So the target is now chosen by privilege, which makes the two forms two +//! predictable commands rather than one command with a machine-dependent +//! target: root → system, otherwise → user. `--system` / `--user` override it. //! //! "Latest" is the highest v* tag on the kernel repo, discovered with -//! `git ls-remote` (no API token, works for the public repo). The header is the -//! HKM banner + current version. +//! `git ls-remote` (no API token, works for the public repo). +//! +//! VERSIONS ARE READ FROM THE KERNEL, NOT FROM THIS BINARY. `banner.version()` +//! is stamped into the launcher at compile time, so comparing it to the latest +//! tag answered "is this BINARY current" while the command went on to replace a +//! KERNEL somewhere else entirely. With two scopes present those two are +//! routinely different numbers. const std = @import("std"); const banner = @import("../lib/banner.zig"); +const composer_version = @import("../lib/composer_version.zig"); +const install_scope = @import("../lib/install_scope.zig"); const kernel = @import("../lib/kernel.zig"); const run_cmd = @import("run.zig"); const util = @import("../lib/util.zig"); +const userconfig = @import("../lib/userconfig.zig"); const semver = @import("../lib/semver.zig"); const prompt = @import("../lib/prompt.zig"); const Dir = std.Io.Dir; const Io = std.Io; const EnvMap = std.process.Environ.Map; +const Scope = install_scope.Scope; /// Version handling comes from lib/semver.zig rather than a local copy. /// @@ -76,14 +105,6 @@ fn latestTag(allocator: std.mem.Allocator, io: Io, env: *EnvMap, include_pre: bo return if (best) |b| .{ .tag = b } else .none; } -/// Kernel root (the dir holding composer.json + install.sh) from the resolved -/// CLI path `/bin/hkm`. -fn kernelRoot(allocator: std.mem.Allocator, io: Io, env: *EnvMap) ?[]const u8 { - const r = kernel.resolve(allocator, io, env) catch return null; - const bin = std.fs.path.dirname(r.path) orelse return null; // /bin - return std.fs.path.dirname(bin); // -} - /// The file set a release ships, mirroring SRC_PATHS in tools/bundle.sh. /// /// Kept in step with that script deliberately: a local install that copied a @@ -104,23 +125,57 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c // Opt IN to dev / rc releases. Off by default: a pre-release must never // reach someone who did not ask for one. var include_pre = false; + // --local builds the checkout before copying it. Without this the tools/ + // binaries in zig-out could be older than the source being installed, so + // "install my local changes" would ship a launcher that predates them — + // the one failure mode a local test install must not have. + var build_first = true; + // Which install to act on. Null = decide from privilege (root → system, + // otherwise → user), which is what makes `sudo hkm upgrade` and plain + // `hkm upgrade` two different, predictable commands. + var scope: ?Scope = null; + for (args[1..]) |a| { if (std.mem.eql(u8, a, "--check") or std.mem.eql(u8, a, "-c")) check_only = true; if (std.mem.eql(u8, a, "--local") or std.mem.eql(u8, a, "-l")) from_local = true; if (std.mem.eql(u8, a, "--dry-run") or std.mem.eql(u8, a, "-n")) dry_run = true; if (std.mem.eql(u8, a, "--yes") or std.mem.eql(u8, a, "-y")) assume_yes = true; if (std.mem.eql(u8, a, "--pre")) include_pre = true; + if (std.mem.eql(u8, a, "--user") or std.mem.eql(u8, a, "-u")) scope = .user; + if (std.mem.eql(u8, a, "--system") or std.mem.eql(u8, a, "-s")) scope = .system; + if (std.mem.eql(u8, a, "--no-build")) build_first = false; if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) { printHelp(); return 0; } } - banner.print(); + const target = scope orelse install_scope.defaultScope(env); + + banner.print(allocator, io, env); + + // A system upgrade that is not root cannot write /opt, and every step after + // this point would fail one at a time with a permission error. Say it once, + // at the top, with the command that works. + if (target == .system and !install_scope.isRoot(env)) { + prompt.warn("a system upgrade needs root — re-run it as: sudo hkm upgrade --system"); + prompt.muted(" (or drop --system to update your own user install, which needs no root)"); + return 1; + } + + if (from_local) return localUpgrade(allocator, io, env, target, dry_run, assume_yes, build_first); - if (from_local) return localUpgrade(allocator, io, env, dry_run, assume_yes); + const inst = install_scope.detect(allocator, io, env, target); + if (!inst.resolved) { + prompt.err("cannot locate a user install directory (no HOME and no HKM_PREFIX)."); + prompt.muted(" set one: HKM_PREFIX=/srv/hkm hkm upgrade --user"); + return 1; + } - const current = parseVer(banner.version()); + prompt.section("Target"); + prompt.item("scope", target.how()); + prompt.item("kernel", inst.root); + prompt.item("installed", if (inst.present) install_scope.versionLabel(inst.version) else "not installed"); prompt.muted("checking for updates…"); const latest = switch (latestTag(allocator, io, env, include_pre)) { @@ -137,78 +192,123 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c }, }; const latest_ver = parseVer(latest); - - prompt.item("installed", banner.version()); prompt.item("latest", latest); - switch (current.order(latest_ver)) { + // The version of the KERNEL BEING REPLACED, not of this binary. Those are + // different numbers whenever the launcher on PATH belongs to the other + // scope — which is the state that made upgrades look like no-ops. + const current = if (inst.present and inst.version != null) + parseVer(inst.version.?) + else + Ver{}; // absent or unstamped → treat as older than anything, so it installs + + if (!inst.present) { + prompt.warn("nothing installed in this scope yet — this will be a fresh install."); + } else if (inst.version == null) { + // A `--local` install carries the checkout's composer.json, which is + // deliberately unstamped. There is nothing to compare, so proceed + // rather than refuse. + prompt.warn("the installed kernel carries no version — installing the latest release over it."); + } else switch (current.order(latest_ver)) { .eq => { - prompt.ok("you are on the latest version."); + prompt.ok("this scope is on the latest version."); + try reportOtherScope(allocator, io, env, target, latest_ver); return 0; }, .gt => { - prompt.ok("your version is newer than the latest release (dev build)."); + prompt.ok("this scope is newer than the latest release (dev build)."); + try reportOtherScope(allocator, io, env, target, latest_ver); return 0; }, - .lt => { - prompt.warn("an update is available."); - }, + .lt => prompt.warn("an update is available."), } if (check_only) { - prompt.item("to update", "run: hkm upgrade"); + prompt.item("to update", if (target == .system) "run: sudo hkm upgrade --system" else "run: hkm upgrade"); + try reportOtherScope(allocator, io, env, target, latest_ver); return 0; } - // Perform the update. - const root = kernelRoot(allocator, io, env) orelse { - prompt.err("could not locate the kernel install (set HKM_KERNEL_HOME)."); - return 1; - }; - const git_dir = try std.fs.path.join(allocator, &.{ root, ".git" }); - - if (util.fileExists(io, git_dir)) { - // Git checkout install → pull + re-resolve composer deps. + // A git checkout is updated with git, not by unpacking a release over it. + const git_dir = try std.fs.path.join(allocator, &.{ inst.root, ".git" }); + if (inst.present and util.fileExists(io, git_dir)) { prompt.section("Updating (git)"); - var pull = [_][]const u8{ "git", "-C", root, "pull", "--ff-only", "--tags" }; + var pull = [_][]const u8{ "git", "-C", inst.root, "pull", "--ff-only", "--tags" }; _ = run_cmd.spawnWait(io, env, &pull) catch {}; - const installer = try std.fs.path.join(allocator, &.{ root, "install.sh" }); + const installer = try std.fs.path.join(allocator, &.{ inst.root, "install.sh" }); if (util.fileExists(io, installer)) { var sh = [_][]const u8{ "sh", installer }; _ = run_cmd.spawnWait(io, env, &sh) catch {}; } prompt.ok("kernel updated. Verify with: hkm doctor"); + try reportOtherScope(allocator, io, env, target, latest_ver); return 0; } - // Packaged install: detect OS, download the matching artifact, install it. - return performPackagedUpgrade(allocator, io, env, latest); + const code = try performPackagedUpgrade(allocator, io, env, target, latest); + + // Say what was NOT updated, right after saying what was. This is the exact + // moment the old behaviour misled: the command reported success, and the + // very next `hkm` ran the other scope's launcher at the old version with + // nothing on screen connecting the two. + if (code == 0) try reportOtherScope(allocator, io, env, target, latest_ver); + return code; +} + +/// Mention the OTHER scope when it is also installed and also behind. +/// +/// Without this, "you are on the latest version" is true of the scope that was +/// checked and false of the one the user's PATH actually runs — which is the +/// precise shape of "I upgraded and the version did not change". +fn reportOtherScope(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: Scope, latest: Ver) !void { + const other: Scope = if (target == .system) .user else .system; + const inst = install_scope.detect(allocator, io, env, other); + if (!inst.resolved or !inst.present) return; + + const v = inst.version orelse { + prompt.blank(); + prompt.muted(try std.fmt.allocPrint( + allocator, + "note: a {s} install also exists at {s} (unstamped version).", + .{ other.label(), inst.root }, + )); + return; + }; + + if (parseVer(v).order(latest) != .lt) return; + + prompt.blank(); + prompt.warn(try std.fmt.allocPrint( + allocator, + "the {s} install is still on {s} and was NOT touched.", + .{ other.label(), v }, + )); + prompt.item("kernel", inst.root); + prompt.item("update it", if (other == .system) "sudo hkm upgrade --system" else "hkm upgrade --user"); } -/// Download the release artifact for THIS OS and install it. The binary is built -/// per-OS, so builtin.os.tag / cpu.arch are comptime — only this platform's path -/// is compiled in. -fn performPackagedUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, latest: []const u8) !u8 { +/// Download the release artifact for THIS OS + scope and install it. The binary +/// is built per-OS, so builtin.os.tag / cpu.arch are comptime — only this +/// platform's path is compiled in. +fn performPackagedUpgrade( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + target: Scope, + latest: []const u8, +) !u8 { const os = @import("builtin").os.tag; - const arch = @import("builtin").cpu.arch; - const ver = if (latest.len > 0 and (latest[0] == 'v' or latest[0] == 'V')) latest[1..] else latest; // "1.0.1" + const ver = composer_version.normalize(latest); // "1.0.1" + + if (os == .linux) return linuxUpgrade(allocator, io, env, target, latest, ver); const asset: []const u8 = switch (os) { - .linux => try std.fmt.allocPrint(allocator, "hkm-kernel_{s}_amd64.deb", .{ver}), .macos => try std.fmt.allocPrint(allocator, "hkm-kernel-{s}-macos-universal.tar.gz", .{ver}), .windows => try std.fmt.allocPrint(allocator, "hkm-kernel-{s}-windows-x86_64.zip", .{ver}), else => return errUnsupported(), }; - if (os == .linux and arch != .x86_64) { - prompt.err("only an amd64 .deb is published; your architecture has no prebuilt package."); - return 1; - } - const url = try std.fmt.allocPrint( - allocator, - "https://github.com/{s}/releases/download/{s}/{s}", - .{ banner.repo(), latest, asset }, - ); + const url = try assetUrl(allocator, latest, asset); const tmp = try std.fs.path.join(allocator, &.{ "/tmp", asset }); prompt.section("Downloading update"); @@ -221,28 +321,25 @@ fn performPackagedUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, la prompt.section("Installing"); switch (os) { - .linux => { - // apt handles the local .deb + its dependencies; needs root. - var argv = [_][]const u8{ "sudo", "apt-get", "install", "-y", tmp }; - const code = run_cmd.spawnWait(io, env, &argv) catch 1; - if (code != 0) { - // Fallback: dpkg then fix deps. - var dpkg = [_][]const u8{ "sudo", "dpkg", "-i", tmp }; - _ = run_cmd.spawnWait(io, env, &dpkg) catch {}; - var fix = [_][]const u8{ "sudo", "apt-get", "-f", "install", "-y" }; - _ = run_cmd.spawnWait(io, env, &fix) catch {}; - } - }, .macos => { // Replace the kernel resources in place, then re-resolve composer. - const root = kernelRoot(allocator, io, env) orelse "/Applications/HKM.app/Contents/Resources/opt/hkm-kernel"; + const root = (try kernel.resolveHome(allocator, io, env)) orelse + "/Applications/HKM.app/Contents/Resources/opt/hkm-kernel"; const app_root = std.fs.path.dirname(std.fs.path.dirname(std.fs.path.dirname(root) orelse root) orelse root) orelse root; var untar = [_][]const u8{ "tar", "-xzf", tmp, "-C", app_root, "--strip-components=0" }; - _ = run_cmd.spawnWait(io, env, &untar) catch {}; + if ((run_cmd.spawnWait(io, env, &untar) catch 1) != 0) { + prompt.err("could not unpack the release — the previous kernel is still in place."); + prompt.muted(try std.fmt.allocPrint(allocator, " the archive is at {s}", .{tmp})); + return 1; + } const installer = try std.fs.path.join(allocator, &.{ root, "install.sh" }); if (util.fileExists(io, installer)) { var sh = [_][]const u8{ "sh", installer }; - _ = run_cmd.spawnWait(io, env, &sh) catch {}; + if ((run_cmd.spawnWait(io, env, &sh) catch 1) != 0) { + prompt.err("unpacked, but install.sh failed — the install may be half-updated."); + prompt.muted(" re-run it by hand, then check: hkm doctor"); + return 1; + } } }, .windows => { @@ -254,10 +351,152 @@ fn performPackagedUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, la } prompt.blank(); - prompt.ok("updated. Verify with: hkm doctor"); + prompt.ok("updated. Verify with: hkm version"); return 0; } +/// Linux publishes one artifact per scope; pick the one that matches. +fn linuxUpgrade( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + target: Scope, + tag: []const u8, + ver: []const u8, +) !u8 { + const arch = switch (@import("builtin").cpu.arch) { + .x86_64 => "x86_64", + .aarch64 => "aarch64", + else => "", + }; + + return switch (target) { + // ── user: the portable tarball + its own installer. No root anywhere. ── + .user => blk: { + if (arch.len == 0) { + prompt.err("no user-local tarball is published for this architecture."); + break :blk 1; + } + const asset = try std.fmt.allocPrint(allocator, "hkm-kernel-{s}-linux-{s}.tar.gz", .{ ver, arch }); + const url = try assetUrl(allocator, tag, asset); + const tmp = try std.fs.path.join(allocator, &.{ "/tmp", asset }); + + prompt.section("Downloading update"); + prompt.item("asset", asset); + prompt.item("from", url); + if (!download(io, env, url, tmp)) { + prompt.err("download failed — check your connection and try again."); + break :blk 1; + } + + // The tarball carries the user installer at its top level. Running + // it — rather than reimplementing the copy here — is what keeps the + // upgrade identical to a first install: it preserves the project + // registry, swaps the tree atomically, resolves composer against + // THIS machine's PHP, and repairs a stale config pin. + // Unpack into a directory cleared first. A leftover tree from an + // interrupted run could otherwise supply an install.sh from a + // different build than the archive just downloaded. + const work = try std.fmt.allocPrint(allocator, "/tmp/hkm-upgrade-{d}", .{std.Thread.getCurrentId()}); + var rm = [_][]const u8{ "rm", "-rf", work }; + _ = run_cmd.spawnWait(io, env, &rm) catch {}; + Dir.cwd().createDirPath(io, work) catch {}; + var untar = [_][]const u8{ "tar", "-xzf", tmp, "-C", work }; + if ((run_cmd.spawnWait(io, env, &untar) catch 1) != 0) { + prompt.err("could not unpack the release — the previous kernel is still in place."); + prompt.muted(try std.fmt.allocPrint(allocator, " the archive is at {s}", .{tmp})); + break :blk 1; + } + + const installer = try findInstaller(allocator, io, work, ver, arch); + if (installer == null) { + prompt.err("the archive has no install.sh at its top level — cannot continue."); + prompt.muted(try std.fmt.allocPrint(allocator, " unpacked at {s}", .{work})); + break :blk 1; + } + + prompt.section("Installing (user-local, no root)"); + var sh = [_][]const u8{ "sh", installer.?, tmp }; + const code = run_cmd.spawnWait(io, env, &sh) catch 1; + if (code != 0) { + prompt.err("the installer reported a failure — check the output above."); + break :blk 1; + } + + prompt.blank(); + prompt.ok("user install updated. Verify with: hkm version"); + break :blk 0; + }, + + // ── system: the .deb, via apt so its dependencies resolve. ──────────── + .system => blk: { + if (@import("builtin").cpu.arch != .x86_64) { + prompt.err("only an amd64 .deb is published; your architecture has no prebuilt package."); + prompt.muted(" the user-local tarball has no such limit: hkm upgrade --user"); + break :blk 1; + } + const asset = try std.fmt.allocPrint(allocator, "hkm-kernel_{s}_amd64.deb", .{ver}); + const url = try assetUrl(allocator, tag, asset); + const tmp = try std.fs.path.join(allocator, &.{ "/tmp", asset }); + + prompt.section("Downloading update"); + prompt.item("asset", asset); + prompt.item("from", url); + if (!download(io, env, url, tmp)) { + prompt.err("download failed — check your connection and try again."); + break :blk 1; + } + + prompt.section("Installing (system-wide)"); + // Already root by the time we get here (run() refuses otherwise), + // so call apt directly. Prefixing `sudo` unconditionally broke on + // the machines where a system install is most useful — containers + // and CI images run as root and frequently ship no sudo at all. + var argv = [_][]const u8{ "apt-get", "install", "-y", tmp }; + const code = run_cmd.spawnWait(io, env, &argv) catch 1; + if (code != 0) { + // Fallback: dpkg then fix deps. Both results are KEPT: with them + // discarded, an upgrade where apt AND dpkg both failed printed + // "updated" and left the old kernel installed — the user then + // debugs a version they believe they are no longer running. + var dpkg = [_][]const u8{ "dpkg", "-i", tmp }; + const dpkg_code = run_cmd.spawnWait(io, env, &dpkg) catch 1; + var fix = [_][]const u8{ "apt-get", "-f", "install", "-y" }; + const fix_code = run_cmd.spawnWait(io, env, &fix) catch 1; + if (dpkg_code != 0 and fix_code != 0) { + prompt.err("installation FAILED — the previous kernel is still in place."); + prompt.muted(try std.fmt.allocPrint(allocator, " the package is downloaded at {s}", .{tmp})); + prompt.muted(" try it by hand: sudo apt-get install -y "); + break :blk 1; + } + } + + prompt.blank(); + prompt.ok("system install updated. Verify with: hkm version"); + break :blk 0; + }, + }; +} + +/// `/hkm-kernel--linux-/install.sh`, verified to exist. +/// +/// The archive's top-level directory name is fixed by bundle.sh, so it is +/// derived rather than discovered — a directory listing would need a readdir +/// whose API differs across the toolchains this has to build on. +fn findInstaller(allocator: std.mem.Allocator, io: Io, work: []const u8, ver: []const u8, arch: []const u8) !?[]const u8 { + const top = try std.fmt.allocPrint(allocator, "hkm-kernel-{s}-linux-{s}", .{ ver, arch }); + const path = try std.fs.path.join(allocator, &.{ work, top, "install.sh" }); + return if (util.fileExists(io, path)) path else null; +} + +fn assetUrl(allocator: std.mem.Allocator, tag: []const u8, asset: []const u8) ![]const u8 { + return std.fmt.allocPrint( + allocator, + "https://github.com/{s}/releases/download/{s}/{s}", + .{ banner.repo(), tag, asset }, + ); +} + fn errUnsupported() u8 { prompt.err("automatic upgrade is not supported on this platform — download from the releases page."); return 1; @@ -274,46 +513,70 @@ fn download(io: Io, env: *EnvMap, url: []const u8, dest: []const u8) bool { fn printHelp() void { prompt.intro("hkm upgrade"); prompt.section("Usage"); - prompt.item("hkm upgrade", "download and install the latest published release"); - prompt.item("hkm upgrade --check", "report whether an update exists, install nothing"); - prompt.item("hkm upgrade --local", "install THIS checkout over the installed kernel"); + prompt.item("hkm upgrade", "update YOUR install (~/.local) from the latest release — no root"); + prompt.item("sudo hkm upgrade", "update the SYSTEM install (/opt + /usr/bin)"); + prompt.item("hkm upgrade --check", "report what each scope is on, install nothing"); + prompt.item("hkm upgrade --local", "install THIS checkout over an installed kernel"); + prompt.blank(); + prompt.section("Scope"); + prompt.muted("chosen from privilege unless you say otherwise: root → system, else → user"); + prompt.item("--user, -u", "act on ~/.local/lib/hkm-kernel + ~/.local/bin (never needs root)"); + prompt.item("--system, -s", "act on /opt/hkm-kernel + /usr/bin (needs root)"); prompt.blank(); prompt.section("Options"); prompt.item("--local, -l", "source the update from the local checkout instead of GitHub"); prompt.item("--dry-run, -n", "show what --local would copy, write nothing"); prompt.item("--yes, -y", "skip the confirmation prompt"); + prompt.item("--no-build", "with --local: skip `zig build`, install what is in tools/zig-out"); prompt.item("--pre", "consider pre-releases (dev / rc) when checking for updates"); prompt.item("--check, -c", "check only"); prompt.item("--help, -h", "show this help"); - prompt.outro("--local needs write access to the installed kernel (usually sudo)"); + prompt.outro("`hkm version` shows both scopes and which one your PATH actually runs"); } -/// `hkm upgrade --local` — install the LOCAL checkout over the INSTALLED kernel. +/// `hkm upgrade --local` — install the LOCAL checkout over an INSTALLED kernel. /// /// The normal upgrade path fetches a published release. This one exists for the -/// case that path cannot serve: you have changed the kernel and want the -/// installed copy — the one every project on this machine actually runs — to be -/// that change, without tagging a release first. +/// case that path cannot serve: you have changed the kernel and want an +/// installed copy — the one projects on this machine actually run — to be that +/// change, without tagging a release first. /// -/// It copies the same file set a .deb ships (shipped_paths, mirroring +/// It copies the same file set a release ships (shipped_paths, mirroring /// bundle.sh), so the result behaves like a real install rather than a /// half-synced hybrid. -fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: bool, assume_yes: bool) !u8 { +fn localUpgrade( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + target: Scope, + dry_run: bool, + assume_yes: bool, + build_first: bool, +) !u8 { // SOURCE: the checkout this command is being run from or pointed at. - const src = (try kernel.resolveDevHome(allocator, io)) orelse { - prompt.err("no local kernel checkout found. Run this from inside the monorepo, or set HKM_DEV_HOME to it."); + const src = (try resolveSource(allocator, io, env)) orelse { + prompt.err("no local kernel checkout found."); + prompt.muted(" set one: hkm-config set HKM_DEV_HOME /path/to/the/checkout"); + prompt.muted(" or run this from inside it."); return 1; }; - // TARGET: the installed kernel every project on this machine resolves to. - const dest = kernelRoot(allocator, io, env) orelse { - prompt.err("could not locate an installed kernel to update. Is hkm installed (/opt/hkm-kernel)?"); + const inst = install_scope.detect(allocator, io, env, target); + if (!inst.resolved) { + prompt.err("cannot locate a user install directory (no HOME and no HKM_PREFIX)."); + prompt.muted(" set one: HKM_PREFIX=/srv/hkm hkm upgrade --local --user"); return 1; - }; + } + const dest = inst.root; + + // The user scope may not exist yet — creating it is the correct outcome of + // "install my checkout for me", and refusing would leave a non-root user + // with no way to get a kernel at all. + if (target == .user) Dir.cwd().createDirPath(io, dest) catch {}; // Copying a checkout over itself would delete files mid-walk and leave the // only copy of the kernel in an unknown state. - if (std.mem.eql(u8, src, dest)) { + if (std.mem.eql(u8, util.trimSlash(src), util.trimSlash(dest))) { prompt.err("the local checkout IS the installed kernel — there is nothing to copy."); prompt.muted(src); return 1; @@ -324,11 +587,14 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo return 1; } + const version = localVersion(allocator, io, env, src) orelse "unknown"; + prompt.section("Local upgrade"); prompt.item("source", src); prompt.item("target", dest); - prompt.item("version", localVersion(allocator, io, env, src) orelse "unknown"); - prompt.item("installed", installedVersion(allocator, io, dest) orelse banner.version()); + prompt.item("scope", target.how()); + prompt.item("version", version); + prompt.item("installed", if (inst.present) install_scope.versionLabel(inst.version) else "not installed"); if (dry_run) { prompt.blank(); @@ -338,13 +604,21 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo return 0; } - // Overwriting the kernel every project on this machine runs is not - // something to do on a typo. + // Overwriting a kernel that projects on this machine run is not something + // to do on a typo. if (!assume_yes and !prompt.confirm(io, "Overwrite the installed kernel with this checkout?", false)) { prompt.muted("cancelled"); return 1; } + // Build the checkout first, so the launcher that gets installed is the one + // built from the source being installed. + if (build_first) buildCheckout(allocator, io, env, src); + + // Untracked files under the installed paths are NOT copied — see below — + // so say which ones, before the install silently omits them. + warnUntracked(allocator, io, env, src); + // git ls-files gives exactly the TRACKED files, so build artifacts, vendor/ // and local scratch never leak into the install — the same guarantee // bundle.sh relies on. @@ -367,6 +641,7 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo if (needs_root) prompt.muted("target is not writable — using sudo"); var copied: usize = 0; + var skipped: usize = 0; // tracked by git, absent from the working tree var failed: usize = 0; var lines = std.mem.splitScalar(u8, listing.stdout, '\n'); while (lines.next()) |raw| { @@ -380,28 +655,81 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo const to = try std.fs.path.join(allocator, &.{ dest, rel_dest }); copyOne(allocator, io, env, from, to, needs_root) catch |e| { + // A file git tracks but the working tree no longer has is NOT a + // write failure — nothing was lost, because there was nothing to + // copy. Treating it as one aborted the install before the launcher + // was replaced, so a checkout with one uncommitted deletion could + // never update its own `hkm` binary: every upgrade errored, the + // stale launcher stayed, and the cause looked unrelated. + if (e == error.FileNotFound) { + skipped += 1; + if (skipped <= 10) { + prompt.muted(try std.fmt.allocPrint( + allocator, + " skipped {s} — tracked by git, missing from the working tree", + .{rel_dest}, + )); + } + continue; + } failed += 1; - // Report the FIRST failure with its cause. Counting 645 silent - // failures tells the user something went wrong and nothing about - // what, which is barely better than failing silently. - if (failed == 1) { + // Every failure, with its cause — capped so a systemic problem does + // not bury the summary. Reporting only the first meant "7 file(s) + // could not be written" alongside ONE filename, leaving the reader + // to guess whether the other six shared that cause. + if (failed <= 10) { prompt.err(try std.fmt.allocPrint(allocator, "{s}: {t}", .{ rel_dest, e })); + } else if (failed == 11) { + prompt.muted(" (further failures not listed)"); } continue; }; copied += 1; + // bin/hkm is the PHP CLI the launcher hands off to — it must stay + // executable for the same reason. + if (std.mem.eql(u8, rel_dest, "bin/hkm")) util.chmodExec(io, to); } prompt.ok(try std.fmt.allocPrint(allocator, "copied {d} file(s)", .{copied})); + + if (skipped > 0) { + // Worth saying, not worth failing over: the installed kernel matches + // the working tree, which is what --local promises. + prompt.warn(try std.fmt.allocPrint( + allocator, + "{d} file(s) are tracked by git but deleted locally — not installed.", + .{skipped}, + )); + prompt.muted(" git status --short | grep '^ D' # see them"); + prompt.muted(" git checkout -- # restore, or commit the deletion"); + } + if (failed > 0) { prompt.err(try std.fmt.allocPrint(allocator, "{d} file(s) could not be written — the install may be inconsistent.", .{failed})); return 1; } + // Record what was installed, so the result can report its own version. + // + // The checkout's composer.json has NO version field — build.zig only stamps + // a release build, deliberately. Copying it verbatim therefore produced an + // installed kernel that could never say what it was: `hkm version` read + // "unstamped" forever and `hkm upgrade` had nothing to compare, which is a + // large part of why a local install looked like it "did not upgrade". + if (composer_version.writeTo(allocator, io, dest, version)) { + // Report what actually landed in the file. A `git describe` version is + // re-spelled as build metadata to satisfy Composer, so echoing the + // input would name a string the installed kernel does not carry — and + // the next `hkm version` would appear to contradict this line. + prompt.item("stamped", composer_version.ofKernel(allocator, io, dest) orelse version); + } else { + prompt.muted(" could not record the version in the installed composer.json"); + } + // The native launcher is built, not tracked, so it is copied separately — // and only when it exists, since a checkout that has never run `zig build` // has nothing to install. - installLauncher(allocator, io, env, src, needs_root); + installLauncher(allocator, io, env, src, target, needs_root); // vendor/ is deliberately not shipped, so dependencies are resolved against // the TARGET's PHP rather than whatever the checkout happened to resolve. @@ -413,29 +741,134 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo const code = run_cmd.spawnWait(io, env, if (needs_root) &sudo_sh else &sh) catch 1; if (code != 0) prompt.warn("install.sh reported an error — run it manually in the target to finish."); } else { - prompt.muted("no install.sh in the target — skipping composer step."); + // A --local copy carries only tracked files, and install.sh is written + // by bundle.sh at package time — so it is absent here. Run composer + // directly, or the target has no vendor/ and cannot boot. + // --no-scripts: the target is an INSTALLED kernel, never a git + // checkout, and the only scripts this package defines set up developer + // git hooks. Running them there printed "fatal: not in a git directory" + // on every install; guarding the script itself silenced the error but + // left composer echoing a long command line instead. Skipping scripts + // for a destination that cannot use them removes both, and leaves the + // script simple for the checkout where it does apply. + var composer = [_][]const u8{ "composer", "install", "--no-dev", "--optimize-autoloader", "--no-interaction", "--no-scripts", "--working-dir", dest }; + var sudo_composer = [_][]const u8{ "sudo", "composer", "install", "--no-dev", "--optimize-autoloader", "--no-interaction", "--no-scripts", "--working-dir", dest }; + const ccode = run_cmd.spawnWait(io, env, if (needs_root) &sudo_composer else &composer) catch 1; + if (ccode != 0) { + prompt.warn("composer install failed — the kernel has no vendor/ and cannot boot."); + // Name the usual cause. A bare "composer install failed" sends + // people to their network or their PHP version, when in practice it + // is almost always a cache left root-owned by an earlier + // `sudo composer` — the error surfaces as "Permission denied" on a + // .zip deep inside ~/.cache/composer. + prompt.muted(" if it said 'Permission denied' under ~/.cache/composer, the cache is root-owned:"); + prompt.muted(" sudo chown -R \"$USER\" ~/.cache/composer"); + prompt.muted(try std.fmt.allocPrint( + allocator, + " then re-run: composer install --no-dev --working-dir {s}", + .{dest}, + )); + } } - prompt.outro("Installed kernel updated from the local checkout. Verify with: hkm doctor"); + try clearStalePin(allocator, io, env, dest); + + prompt.outro("Installed kernel updated from the local checkout. Verify with: hkm version"); return 0; } -/// The TARGET's version, read from the composer.json that ships with it. +/// Remove a config.env HKM_KERNEL_HOME pin that this install makes redundant. +/// +/// A user install at ~/.local/lib/hkm-kernel is self-located by +/// ~/.local/bin/hkm, so no pin is needed to reach it. Writing one anyway is what +/// created the original fault: config.env is read by BOTH launchers, so the pin +/// a user-level install left behind also redirected /usr/bin/hkm to the user's +/// kernel. Deleting it hands resolution back to self-location, where each +/// launcher finds its own install. +/// +/// A pin pointing somewhere ELSE is left alone — that is an operator's +/// deliberate choice about a custom layout, and silently discarding it would be +/// its own surprise. It is reported instead. +fn clearStalePin(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dest: []const u8) !void { + const pinned = (userconfig.get(allocator, io, env, "HKM_KERNEL_HOME") catch null) orelse return; + const p = util.trimSlash(std.mem.trim(u8, pinned, " \t\r\n")); + if (p.len == 0) return; + + if (std.mem.eql(u8, p, util.trimSlash(dest))) { + if (userconfig.unset(allocator, io, env, "HKM_KERNEL_HOME") catch false) { + prompt.muted(" removed the now-redundant HKM_KERNEL_HOME pin (the launcher self-locates)"); + } + return; + } + + prompt.warn(try std.fmt.allocPrint( + allocator, + "config.env still pins HKM_KERNEL_HOME={s}", + .{p}, + )); + prompt.muted(" that is only a fallback now, but it will be used if a launcher cannot self-locate."); + prompt.muted(" clear it with: hkm-config unset HKM_KERNEL_HOME"); +} + +/// The checkout to install FROM. /// -/// Not banner.version(): that is the version THIS BINARY was stamped with, and -/// the binary being run is usually the local dev build — so the "installed" -/// line would report the source's version on both sides and always look like a -/// no-op. The two differ exactly when this command is worth running. -fn installedVersion(allocator: std.mem.Allocator, io: Io, dest: []const u8) ?[]const u8 { - const path = std.fs.path.join(allocator, &.{ dest, "composer.json" }) catch return null; - const body = Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch return null; - - const parsed = std.json.parseFromSliceLeaky(std.json.Value, allocator, body, .{}) catch return null; - if (parsed != .object) return null; - const v = parsed.object.get("version") orelse return null; - if (v != .string or v.string.len == 0) return null; - - return v.string; +/// Order: HKM_DEV_HOME, then the working directory, then the launcher's own +/// location. +/// +/// The last of those used to be the only one, via kernel.resolveDevHome — which +/// climbs from the EXECUTABLE's directory. Once the launcher is installed to +/// ~/.local/bin that climb can never reach a checkout, so `hkm upgrade --local` +/// failed for the very user who had just installed it, while HKM_DEV_HOME sat +/// in config.env pointing straight at the answer. +fn resolveSource(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !?[]const u8 { + if (env.get("HKM_DEV_HOME")) |h| { + const t = util.trimSlash(std.mem.trim(u8, h, " \t\r\n")); + if (t.len > 0 and kernel.isKernelDir(io, t)) return try allocator.dupe(u8, t); + } + + // Walk up from the working directory: running it from anywhere inside the + // checkout should just work. + if (env.get("PWD")) |pwd| { + var cur = util.trimSlash(pwd); + var depth: usize = 0; + while (depth < 32 and cur.len > 0) : (depth += 1) { + if (kernel.isKernelDir(io, cur)) return try allocator.dupe(u8, cur); + const parent = std.fs.path.dirname(cur) orelse break; + if (std.mem.eql(u8, parent, cur)) break; + cur = parent; + } + } + + return kernel.resolveDevHome(allocator, io); +} + +/// Run `zig build` in the checkout's tools/ before installing it. +/// +/// The version passed is `git describe`, so the installed binary reports the +/// exact commit it came from — which is the whole point of a local test +/// install. That string is deliberately NOT composer-valid for a dev checkout +/// ("1.1.0-dev.2-12-g29dccfb"), so the stamper skips composer.json and the +/// working tree stays clean; only the binary carries it. +fn buildCheckout(allocator: std.mem.Allocator, io: Io, env: *EnvMap, src: []const u8) void { + const tools = std.fs.path.join(allocator, &.{ src, "tools" }) catch return; + const build_zig = std.fs.path.join(allocator, &.{ tools, "build.zig" }) catch return; + if (!util.fileExists(io, build_zig)) return; // not a checkout with tools/ + + prompt.section("Building"); + + const version = localVersion(allocator, io, env, src) orelse "0.0.0-dev"; + const dversion = std.fmt.allocPrint(allocator, "-Dversion={s}", .{version}) catch return; + + var argv = [_][]const u8{ "zig", "build", dversion, "--build-file", build_zig }; + const code = run_cmd.spawnWait(io, env, &argv) catch { + prompt.warn("zig not found — installing whatever is already in tools/zig-out."); + return; + }; + if (code != 0) { + prompt.warn("build failed — installing whatever is already in tools/zig-out."); + return; + } + prompt.ok(std.fmt.allocPrint(allocator, "built {s}", .{version}) catch "built"); } /// `git describe` in the checkout, so the source's real version is reported @@ -474,26 +907,178 @@ fn copyOne(allocator: std.mem.Allocator, io: Io, env: *EnvMap, from: []const u8, try Dir.cwd().writeFile(io, .{ .sub_path = to, .data = data }); } -/// Install the freshly built native launcher next to the one in use. -fn installLauncher(allocator: std.mem.Allocator, io: Io, env: *EnvMap, src: []const u8, needs_root: bool) void { +/// Name the untracked files that this install will skip. +/// +/// `--local` installs `git ls-files` output, which is the right rule: it is what +/// keeps vendor/, build output and scratch files out of the installed kernel. +/// The cost is that a NEW file — a template variant, a new source file — is +/// invisible to it, and the install silently produces a kernel without it. That +/// failure is near-impossible to read from the outside: the command reports +/// success and the feature simply is not there. +fn warnUntracked(allocator: std.mem.Allocator, io: Io, env: *EnvMap, src: []const u8) void { + const res = std.process.run(allocator, io, .{ + .argv = &.{ + "git", "-C", src, "ls-files", "--others", "--exclude-standard", + "--", "src", "plugins", "projects", "templates", + "composer.json", "bin", "modules", + }, + .environ_map = env, + }) catch return; + + var shown: usize = 0; + var lines = std.mem.splitScalar(u8, res.stdout, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + if (shown == 0) { + prompt.warn("untracked files will NOT be installed — `git add` them first:"); + } + if (shown < 10) { + prompt.muted(std.fmt.allocPrint(allocator, " {s}", .{line}) catch continue); + } + shown += 1; + } + if (shown > 10) { + prompt.muted(std.fmt.allocPrint(allocator, " … and {d} more", .{shown - 10}) catch return); + } +} + +/// Install the freshly built native launcher into the target scope's bin dir. +/// +/// The bin dir follows the SCOPE, not the kernel path: a user install's +/// launcher belongs beside its kernel in ~/.local/bin, and writing it to +/// /usr/bin would both need root and overwrite the other install's binary — the +/// two-installs-one-file collision this whole change is about. +fn installLauncher( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + src: []const u8, + target: Scope, + needs_root: bool, +) void { const built = std.fs.path.join(allocator, &.{ src, "tools", "zig-out", "bin", "hkm" }) catch return; if (!util.fileExists(io, built)) { - prompt.muted("no built launcher in tools/zig-out — run `zig build` there to update /usr/bin/hkm too."); + prompt.muted("no built launcher in tools/zig-out — run `zig build` there to update the hkm binary too."); return; } + const bin_dir = switch (target) { + .system => install_scope.system_bin_dir, + .user => install_scope.userBinDir(allocator, env) orelse { + prompt.warn("could not determine a user bin directory (no HOME) — launcher not installed."); + return; + }, + }; + if (target == .user) Dir.cwd().createDirPath(io, bin_dir) catch {}; + const targets = [_][]const u8{ "hkm", "hkm-config" }; + var failed: usize = 0; + var installed_any = false; for (targets) |name| { const from = std.fs.path.join(allocator, &.{ src, "tools", "zig-out", "bin", name }) catch continue; if (!util.fileExists(io, from)) continue; - const to = std.fmt.allocPrint(allocator, "/usr/bin/{s}", .{name}) catch continue; + const to = std.fs.path.join(allocator, &.{ bin_dir, name }) catch continue; - if (needs_root) { + var copied = true; + if (needs_root and target == .system) { var cp = [_][]const u8{ "sudo", "cp", "-f", from, to }; - _ = run_cmd.spawnWait(io, env, &cp) catch continue; + const code = run_cmd.spawnWait(io, env, &cp) catch blk: { + break :blk @as(u8, 1); + }; + copied = code == 0; } else { - copyOne(allocator, io, env, from, to, false) catch continue; + // Write beside it, then rename over. + // + // A running executable cannot be written to (ETXTBSY), and the most + // ordinary reason for one to be running is a dev server started + // with this very launcher. Overwriting in place made `hkm upgrade` + // fail for the entire time `hkm run` was up. rename() replaces the + // directory entry instead of the file: the running process keeps + // its old inode and finishes normally, and the next invocation + // picks up the new build. + const staged = std.fmt.allocPrint(allocator, "{s}.hkm-new", .{to}) catch continue; + copyOne(allocator, io, env, from, staged, false) catch { + copied = false; + }; + if (copied) { + util.chmodExec(io, staged); + Dir.cwd().rename(staged, Dir.cwd(), to, io) catch { + Dir.cwd().deleteFile(io, staged) catch {}; + copied = false; + }; + } } + + if (!copied) { + // Reported, never swallowed. This used to `catch continue` and then + // print "native launcher updated" regardless, so an upgrade that + // installed NOTHING looked identical to one that worked — and the + // next command silently ran the old binary. The usual cause is the + // launcher being executed right now (ETXTBSY): a background `hkm` + // still running holds it busy and every write to it fails. + failed += 1; + prompt.warn(std.fmt.allocPrint( + allocator, + "could not replace {s} — it is still the OLD build.", + .{to}, + ) catch "could not replace the launcher — it is still the OLD build."); + prompt.muted(" check what still holds it: pgrep -af hkm"); + continue; + } + + // The copy above writes bytes only, so the executable bit is lost. A + // launcher installed without it fails at the first invocation with + // "permission denied", long after the install reported success. + util.chmodExec(io, to); + installed_any = true; } - prompt.ok("native launcher updated"); + + if (failed > 0) return; + if (installed_any) { + prompt.ok(std.fmt.allocPrint(allocator, "native launcher updated in {s}", .{bin_dir}) catch "native launcher updated"); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "a release artifact URL is built for the requested tag" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const url = try assetUrl(a, "v1.3.1", "hkm-kernel-1.3.1-linux-x86_64.tar.gz"); + try std.testing.expect(std.mem.endsWith(u8, url, "/releases/download/v1.3.1/hkm-kernel-1.3.1-linux-x86_64.tar.gz")); +} + +test "asset names drop the tag's leading v but the URL path keeps it" { + // The tag is "v1.3.1" and every artifact is named "1.3.1" — mixing the two + // up yields a 404 that reads as "download failed, check your connection". + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const ver = composer_version.normalize("v1.3.1"); + try std.testing.expectEqualStrings("1.3.1", ver); + + const asset = try std.fmt.allocPrint(a, "hkm-kernel-{s}-linux-x86_64.tar.gz", .{ver}); + const url = try assetUrl(a, "v1.3.1", asset); + try std.testing.expect(std.mem.indexOf(u8, url, "/v1.3.1/") != null); + try std.testing.expect(std.mem.indexOf(u8, url, "hkm-kernel-1.3.1-linux") != null); +} + +test "an unstamped install compares as older than any release" { + // A --local install has no version in composer.json. Treating that as + // "equal" would make `hkm upgrade` refuse to replace it forever, which is + // the state a user reads as "upgrading does not change the version". + const latest = parseVer("v1.3.1"); + const unstamped = Ver{}; + try std.testing.expectEqual(std.math.Order.lt, unstamped.order(latest)); +} + +test "a pre-release sorts below its release so a dev tag is opt-in" { + try std.testing.expectEqual(std.math.Order.lt, parseVer("1.4.0-dev").order(parseVer("1.4.0"))); + try std.testing.expectEqual(std.math.Order.gt, parseVer("1.4.0").order(parseVer("1.3.1"))); } diff --git a/tools/src/commands/version.zig b/tools/src/commands/version.zig new file mode 100644 index 0000000..a6c38b8 --- /dev/null +++ b/tools/src/commands/version.zig @@ -0,0 +1,252 @@ +//! `hkm version` — the banner, plus WHICH kernel each install scope holds. +//! +//! The old version command printed one number: `build_info.version`, stamped +//! into the launcher binary at compile time. On a machine with a single install +//! that is the right answer. On a machine with two — a .deb under /opt and a +//! user install under ~/.local, which is an ordinary state — it answers a +//! question nobody asked, and produces exactly the confusion that makes an +//! upgrade look like it did nothing: +//! +//! $ hkm --version → 0.0.0-dev (a stale ~/.local/bin launcher) +//! $ /usr/bin/hkm --version → 1.3.1 (the .deb, first on nobody's PATH) +//! $ sudo hkm upgrade → updates /opt … and the number never moves +//! +//! Three separate versions are in play and they can all differ: +//! +//! • the LAUNCHER binary's stamp — what `--version` reports; +//! • the KERNEL on disk, from its composer.json — what actually runs; +//! • and one of each, per scope. +//! +//! So this prints all of them, marks which kernel this invocation resolves, and +//! says why. `hkm --version` keeps its single-line, script-friendly output. + +const std = @import("std"); +const banner = @import("../lib/banner.zig"); +const install_scope = @import("../lib/install_scope.zig"); +const kernel = @import("../lib/kernel.zig"); +const prompt = @import("../lib/prompt.zig"); +const util = @import("../lib/util.zig"); + +const Io = std.Io; +const EnvMap = std.process.Environ.Map; +const Scope = install_scope.Scope; + +pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []const u8) !u8 { + for (args[1..]) |a| { + if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) { + printHelp(); + return 0; + } + } + + banner.print(allocator, io, env); + + const active = try kernel.resolveHomeDetailed(allocator, io, env); + const self_exe = std.process.executableDirPathAlloc(io, allocator) catch null; + + // ── Installs ──────────────────────────────────────────────────────────── + prompt.section("Installs"); + + var rows: std.ArrayList([]const []const u8) = .empty; + var any_present = false; + + for ([_]Scope{ .system, .user }) |scope| { + const inst = install_scope.detect(allocator, io, env, scope); + if (inst.present) any_present = true; + + const marker: []const u8 = blk: { + const root = active.root orelse break :blk " "; + break :blk if (std.mem.eql(u8, util.trimSlash(root), util.trimSlash(inst.root))) "→" else " "; + }; + + const kernel_ver: []const u8 = if (inst.present) + install_scope.versionLabel(inst.version) + else + "not installed"; + + try rows.append(allocator, try allocator.dupe([]const u8, &.{ + marker, + scope.label(), + inst.root, + kernel_ver, + try launcherCell(allocator, io, env, inst, self_exe), + })); + } + + prompt.table( + allocator, + &.{ "", "scope", "kernel", "kernel version", "launcher" }, + rows.items, + ); + + if (!any_present) { + prompt.blank(); + prompt.warn("no kernel is installed in either scope."); + prompt.muted(" user-local (no root): hkm upgrade --user"); + prompt.muted(" system-wide: sudo hkm upgrade --system"); + } + + // ── Active ────────────────────────────────────────────────────────────── + prompt.section("Active"); + if (active.root) |root| { + prompt.item("kernel", root); + prompt.item("resolved via", kernel.sourceLabel(active.source)); + if (install_scope.scopeOf(allocator, env, root)) |s| { + prompt.item("scope", s.label()); + } else { + // A dev checkout or a custom prefix. Worth naming so nobody reads + // the table above and concludes the CLI is running one of those two. + prompt.item("scope", "neither — a checkout or a custom prefix"); + } + } else { + prompt.item("kernel", "NONE FOUND"); + } + prompt.item("this launcher", banner.version()); + if (self_exe) |d| prompt.item("launcher path", try std.fs.path.join(allocator, &.{ d, install_scope.launcher_name })); + + // ── Anything that will mislead the reader later ───────────────────────── + try warnings(allocator, io, env, active, self_exe); + + prompt.outro("upgrade this scope with: hkm upgrade (sudo hkm upgrade for system)"); + return 0; +} + +/// The launcher column: its path and the version IT reports. +/// +/// The version is obtained by running ` --version`, not read off +/// disk, because it is compiled into the binary and there is no other way to +/// see it. That is the point of the column: a launcher whose stamp differs from +/// its kernel's version is the single most common reason an upgrade "did +/// nothing", and it is invisible from any file on disk. +fn launcherCell( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + inst: install_scope.Install, + self_exe: ?[]const u8, +) ![]const u8 { + const exe = inst.launcher orelse return "absent"; + + // Never spawn ourselves: we already know this binary's version, and running + // it would be a pointless subprocess on the hot path of a trivial command. + if (self_exe) |d| { + const own = try std.fs.path.join(allocator, &.{ d, install_scope.launcher_name }); + if (std.mem.eql(u8, own, exe)) { + return std.fmt.allocPrint(allocator, "{s} ({s}, this one)", .{ exe, banner.version() }); + } + } + + const v = launcherVersion(allocator, io, env, exe) orelse return exe; + return std.fmt.allocPrint(allocator, "{s} ({s})", .{ exe, v }); +} + +/// Ask a launcher binary what version it was built as. +/// +/// `hkm --version` prints "hkm (HKM Kernel) " to stdout; take the last +/// whitespace-separated token. Null on any failure — an unreadable version is +/// never a reason to fail the command that reports it. +fn launcherVersion(allocator: std.mem.Allocator, io: Io, env: *EnvMap, exe: []const u8) ?[]const u8 { + const res = std.process.run(allocator, io, .{ + .argv = &.{ exe, "--version" }, + .environ_map = env, + }) catch return null; + switch (res.term) { + .exited => |c| if (c != 0) return null, + else => return null, + } + const line = std.mem.trim(u8, res.stdout, " \t\r\n"); + if (line.len == 0) return null; + const last = std.mem.lastIndexOfScalar(u8, line, ' ') orelse return line; + const v = line[last + 1 ..]; + return if (v.len == 0) null else v; +} + +/// The states that make a later "my upgrade did nothing" report inevitable. +fn warnings( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + active: kernel.ResolvedHome, + self_exe: ?[]const u8, +) !void { + var said = false; + const note = struct { + fn head(flag: *bool) void { + if (flag.*) return; + prompt.section("Worth knowing"); + flag.* = true; + } + }; + + // 1. A different `hkm` earlier on PATH than this one. The upgrade you run + // and the binary your next command uses are then two different installs. + if (findOnPath(allocator, io, env, install_scope.launcher_name)) |first| { + if (self_exe) |d| { + const own = try std.fs.path.join(allocator, &.{ d, install_scope.launcher_name }); + if (!std.mem.eql(u8, own, first)) { + note.head(&said); + prompt.warn("another hkm comes first on your PATH — that is the one a bare `hkm` runs."); + prompt.item("first on PATH", first); + prompt.item("this binary", own); + } + } + } + + // 2. A user install still at the pre-1.4 location. It is a second kernel on + // disk that only a config pin can reach, and it is what the pin usually + // points at on a machine that hit this bug. + const user = install_scope.detect(allocator, io, env, .user); + if (user.legacy_root) |legacy| { + note.head(&said); + prompt.warn("a user kernel exists at the old location and is no longer updated."); + prompt.item("legacy", legacy); + prompt.item("current", user.root); + prompt.item("migrate", "hkm upgrade --user (then delete the legacy directory)"); + } + + // 3. Resolution falling back to a config pin. Legitimate for a custom + // prefix, and the fingerprint of a stale pin otherwise. + if (active.source == .kernel_home_config) { + note.head(&said); + prompt.warn("the active kernel comes from a config.env pin, not from this launcher's own install."); + prompt.item("clear it", "hkm-config unset HKM_KERNEL_HOME"); + } + + // 4. A resolved kernel with no dependencies cannot run anything, and the + // version above would otherwise look perfectly healthy. + if (active.root) |root| { + const autoload = try std.fs.path.join(allocator, &.{ root, "vendor", "autoload.php" }); + if (!util.fileExists(io, autoload)) { + note.head(&said); + prompt.warn("the active kernel has no vendor/ — it cannot boot."); + prompt.item("fix", try std.fmt.allocPrint(allocator, "cd {s} && ./install.sh", .{root})); + } + } +} + +/// First match for `name` on PATH — the one a bare command actually runs. +fn findOnPath(allocator: std.mem.Allocator, io: Io, env: *EnvMap, name: []const u8) ?[]const u8 { + const path = env.get("PATH") orelse return null; + var it = std.mem.splitScalar(u8, path, ':'); + while (it.next()) |dir| { + if (dir.len == 0) continue; + const cand = std.fs.path.join(allocator, &.{ dir, name }) catch continue; + if (util.fileExists(io, cand)) return cand; + } + return null; +} + +fn printHelp() void { + prompt.intro("hkm version"); + prompt.section("Usage"); + prompt.item("hkm version", "banner + the kernel version in each install scope"); + prompt.item("hkm --version", "one line, for scripts (this launcher's version only)"); + prompt.blank(); + prompt.section("What the columns mean"); + prompt.item("kernel version", "from /composer.json — the code that actually runs"); + prompt.item("launcher", "the hkm binary for that scope, and the version it was built as"); + // Spelled out rather than printed as the bare glyph: prompt.item pads keys + // by byte length, and a 3-byte arrow would misalign the whole block. + prompt.item("arrow marker", "the install this invocation resolves"); + prompt.outro("a launcher and kernel that disagree is why an upgrade can look like a no-op"); +} diff --git a/tools/src/config.zig b/tools/src/config.zig index b977617..5cc2e05 100644 --- a/tools/src/config.zig +++ b/tools/src/config.zig @@ -7,9 +7,22 @@ //! hkm-config set-kernel-home

# pin HKM_KERNEL_HOME //! hkm-config set-autoload

# pin HKM_GLOBAL_AUTOLOAD (vendor/autoload.php) //! hkm-config set-dev-home

# pin HKM_DEV_HOME (dev checkout used by --dev) +//! hkm-config unset # remove a key (e.g. a stale HKM_KERNEL_HOME) //! //! "check" resolves the kernel (env → relative to this binary → /opt/hkm-kernel) -//! and, if the config file is missing or stale, writes HKM_KERNEL_HOME for you. +//! and fills in what is missing. +//! +//! WHY IT NO LONGER PINS HKM_KERNEL_HOME UNCONDITIONALLY +//! ----------------------------------------------------- +//! This file is read by EVERY hkm launcher on the machine, and a machine can +//! hold two installs (the .deb's /opt and a user's ~/.local — see +//! lib/install_scope.zig). Writing HKM_KERNEL_HOME here on behalf of whichever +//! install ran `check` last therefore redirected the OTHER install's kernel +//! too: /usr/bin/hkm reported version 1.3.1 while running a kernel out of the +//! user's home. So the pin is now written only when it is actually needed — +//! when the launcher cannot find its kernel by self-locating relative to its +//! own binary. For the standard layouts (both installers produce one) it is +//! left absent, and each launcher resolves its own install independently. const std = @import("std"); const kernel = @import("lib/kernel.zig"); @@ -76,6 +89,20 @@ pub fn main(init: std.process.Init.Minimal) !void { prompt.ok("HKM_DEV_HOME saved. Use `hkm --dev` to target it."); return; } + if (std.mem.eql(u8, action, "unset") or std.mem.eql(u8, action, "clear")) { + if (args.len < 3) return usage(); + const removed = userconfig.unset(allocator, io, &env, args[2]) catch |e| { + prompt.err(@errorName(e)); + std.process.exit(1); + }; + if (removed) { + prompt.ok(try std.fmt.allocPrint(allocator, "{s} removed.", .{args[2]})); + prompt.muted("verify what the launcher resolves now with: hkm version"); + } else { + prompt.muted(try std.fmt.allocPrint(allocator, "{s} was not set — nothing to do.", .{args[2]})); + } + return; + } if (std.mem.eql(u8, action, "check") or std.mem.eql(u8, action, "configure")) { std.process.exit(try runCheck(allocator, io, &env)); } @@ -85,11 +112,12 @@ pub fn main(init: std.process.Init.Minimal) !void { fn usage() void { prompt.section("hkm-config"); - prompt.item("hkm-config", "check config; auto-configure if incomplete"); + prompt.item("hkm-config", "check config; fill in what is missing"); prompt.item("hkm-config print", "show the config file path + contents"); - prompt.item("hkm-config set-kernel-home

", "pin the kernel root"); + prompt.item("hkm-config set-kernel-home

", "pin the kernel root (only needed for a custom layout)"); prompt.item("hkm-config set-autoload

", "pin vendor/autoload.php"); prompt.item("hkm-config set-dev-home

", "pin the development kernel checkout used by --dev"); + prompt.item("hkm-config unset ", "remove a key — e.g. a stale HKM_KERNEL_HOME"); } fn runCheck(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !u8 { @@ -104,13 +132,15 @@ fn runCheck(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !u8 { prompt.item("exists", if (util.fileExists(io, cfg)) "yes" else "no (will create)"); // 1. Locate the kernel. - const home = (try kernel.resolveHome(allocator, io, env)) orelse { + const resolved = try kernel.resolveHomeDetailed(allocator, io, env); + const home = resolved.root orelse { prompt.blank(); prompt.err("no kernel found."); prompt.item("fix", "install the hkm-kernel package, or: hkm-config set-kernel-home "); return 1; }; prompt.item("kernel home", home); + prompt.item("resolved via", kernel.sourceLabel(resolved.source)); // 2. Check kernel pieces. const autoload = try std.fs.path.join(allocator, &.{ home, "vendor", "autoload.php" }); @@ -120,10 +150,35 @@ fn runCheck(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !u8 { prompt.item("vendor/autoload.php", if (have_vendor) "present" else "MISSING"); prompt.item("projects registry", if (have_registry) "present" else "absent (no projects registered yet)"); - // 3. Ensure HKM_KERNEL_HOME is persisted and current. + // 3. Persist HKM_KERNEL_HOME only when the launcher genuinely needs it. + // + // Self-location is per-install and cannot be affected by the other + // scope; a pin in this file is shared by every launcher on the machine. + // So a pin is written only when self-location failed — and an existing + // one that has become redundant is REMOVED, because leaving it is what + // let a user install silently redirect the system launcher's kernel. const saved = try userconfig.get(allocator, io, env, "HKM_KERNEL_HOME"); + const self_locating = resolved.source == .self_located or resolved.source == .default; var wrote = false; - if (saved == null or !std.mem.eql(u8, saved.?, home)) { + + if (self_locating) { + if (saved != null) { + if (std.mem.eql(u8, util.trimSlash(saved.?), util.trimSlash(home))) { + if (try userconfig.unset(allocator, io, env, "HKM_KERNEL_HOME")) { + prompt.item("HKM_KERNEL_HOME", "removed — redundant, the launcher self-locates"); + wrote = true; + } + } else { + // Points elsewhere: an operator's deliberate choice, or a stale + // pin from another install. Not ours to delete silently, but it + // must not be mistaken for the kernel resolved above. + prompt.warn("config.env pins HKM_KERNEL_HOME at a different path."); + prompt.item("pinned", saved.?); + prompt.item("in use", home); + prompt.item("clear it", "hkm-config unset HKM_KERNEL_HOME"); + } + } + } else if (saved == null or !std.mem.eql(u8, saved.?, home)) { try userconfig.set(allocator, io, env, "HKM_KERNEL_HOME", home); wrote = true; } @@ -154,11 +209,12 @@ fn runCheck(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !u8 { } if (wrote) { - prompt.ok("configuration written — HKM_KERNEL_HOME + HKM_USERDATA_DIR pinned."); + prompt.ok("configuration written."); } else { prompt.ok("configuration is complete."); } prompt.muted("verify the runtime with: hkm doctor"); + prompt.muted("see every install on this machine with: hkm version"); return 0; } diff --git a/tools/src/lib/banner.zig b/tools/src/lib/banner.zig index d9aefa0..342a460 100644 --- a/tools/src/lib/banner.zig +++ b/tools/src/lib/banner.zig @@ -29,10 +29,43 @@ pub fn repo() []const u8 { /// Full banner: ASCII art + version + tagline. Used as the header of the /// version/update commands. -pub fn print() void { +/// +/// Takes io/env so the PHP line can report the runtime ACTUALLY on this machine +/// rather than a compiled-in constant — which is the only version of the two +/// that can differ from what the user expects, and the one that explains most +/// "it works on my machine" reports. +pub fn print(allocator: std.mem.Allocator, io: std.Io, env: *std.process.Environ.Map) void { std.debug.print("\n{s}{s}{s}\n", .{ cyan, art, reset }); std.debug.print(" {s}HKM Kernel{s} {s}· Gated Demand Architecture{s}\n", .{ bold, reset, dim, reset }); - std.debug.print(" {s}version {s}{s}{s}\n\n", .{ dim, reset, build_info.version, reset }); + std.debug.print(" {s}version {s}{s}{s}\n", .{ dim, reset, build_info.version, reset }); + std.debug.print(" {s}PHP {s}{s}{s}\n\n", .{ dim, reset, phpVersion(allocator, io, env) orelse "not found", reset }); +} + +/// The PHP runtime's version, or null when php is absent or unreadable. +/// +/// Asked of PHP itself (`-r 'echo PHP_VERSION;'`) rather than parsed out of +/// `php -v`, whose first line carries build metadata and varies between +/// distributions. Honours HKM_PHP_BIN, so the banner reports the interpreter +/// this tool would actually run — not whichever `php` happens to be first on +/// PATH. +/// +/// Never fails the banner: a missing PHP is a real state worth SHOWING (the +/// kernel cannot run without it), not a reason to refuse to print a version. +fn phpVersion(allocator: std.mem.Allocator, io: std.Io, env: *std.process.Environ.Map) ?[]const u8 { + const bin = env.get("HKM_PHP_BIN") orelse "php"; + + const res = std.process.run(allocator, io, .{ + .argv = &.{ bin, "-r", "echo PHP_VERSION;" }, + .environ_map = env, + }) catch return null; + + switch (res.term) { + .exited => |c| if (c != 0) return null, + else => return null, + } + + const v = std.mem.trim(u8, res.stdout, " \t\r\n"); + return if (v.len == 0) null else v; } /// One-line version, for `hkm --version` piped/scripted use. diff --git a/tools/src/lib/composer_version.zig b/tools/src/lib/composer_version.zig new file mode 100644 index 0000000..8678ac5 --- /dev/null +++ b/tools/src/lib/composer_version.zig @@ -0,0 +1,576 @@ +//! Read and write the `"version"` field of a composer.json. +//! +//! Extracted from src/stamp.zig so that BOTH the build-time stamper and +//! `hkm upgrade` / `hkm version` share one implementation. They must: the +//! stamper writes the field, and the CLI reads it back as the only version +//! marker an installed kernel has. Two copies of the parsing rules would +//! eventually disagree about what counts as a version, and the visible symptom +//! would be an upgrade that reports the wrong number. +//! +//! WHY THE FIELD EXISTS AT ALL (IT IS NORMALLY A LIABILITY) +//! ------------------------------------------------------- +//! A hard-coded "version" in composer.json usually does more harm than good: +//! Composer derives a package's version from its git tags, and a literal field +//! OVERRIDES that. Once the two can disagree, they eventually do — someone tags +//! v1.2.0 and forgets the field, and every consumer resolves the stale number +//! with no error anywhere. This repository has already been bitten by it once +//! (phpshots/bind-it pinned "0.1.3" and its real tags were ignored). +//! +//! It earns its place here for one reason: the native distribution ships +//! WITHOUT a .git directory. A .deb or a tarball has no tags to derive from, so +//! the field is the only version marker the installed kernel has — which is +//! exactly what `hkm version` reports per install scope. + +const std = @import("std"); + +const Io = std.Io; + +// --------------------------------------------------------------------------- +// Reading +// --------------------------------------------------------------------------- + +/// The value of the top-level "version" key, or null when the file has none. +/// +/// Textual rather than a JSON parse for the same reason `stamp` is: this is +/// called on files written by hand and by the stamper, and the caller only ever +/// wants one scalar. A parse would allocate the whole document to answer it. +pub fn parse(source: []const u8) ?[]const u8 { + const span = findVersionValue(source) orelse return null; + const v = source[span.start..span.end]; + return if (v.len == 0) null else v; +} + +/// The version of the kernel installed at `root`, read from `/composer.json`. +/// +/// Null when the root has no composer.json (not an install), or when it has one +/// with no version — which is the normal state of a GIT CHECKOUT, since +/// build.zig deliberately only stamps a release build. +pub fn ofKernel(allocator: std.mem.Allocator, io: Io, root: []const u8) ?[]const u8 { + const path = std.fs.path.join(allocator, &.{ root, "composer.json" }) catch return null; + const body = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch return null; + const v = parse(body) orelse return null; + return allocator.dupe(u8, v) catch null; +} + +// --------------------------------------------------------------------------- +// Writing +// --------------------------------------------------------------------------- + +/// Strip surrounding whitespace and ONE leading `v`, the form Composer wants. +/// +/// One 'v', from the FRONT only. A `trim(..., "v")` would strip the cutset from +/// both ends, so any version ENDING in 'v' lost it: "1.1.0-dev" became +/// "1.1.0-de", which then failed validation and silently skipped stamping. +pub fn normalize(raw: []const u8) []const u8 { + var v = std.mem.trim(u8, raw, " \t\r\n"); + if (v.len > 0 and (v[0] == 'v' or v[0] == 'V')) v = v[1..]; + return v; +} + +/// Rewrite a `git describe` version as semver BUILD METADATA. +/// +/// 1.3.1-2-g34abb2c → 1.3.1+2.g34abb2c +/// +/// Composer rejects the first (its `-` suffix is a stability tag, and "2" is +/// not one) and accepts the second. The two carry identical information and, +/// crucially, identical PRECEDENCE: semver §10 excludes build metadata from +/// ordering, and lib/semver.zig drops everything after `+` — which is exactly +/// what `parseDescribed` already does with the `-2-g34abb2c` form. So this is a +/// change of spelling, not of meaning. +/// +/// Null for anything that is not a describe version; the caller must not invent +/// a spelling for a version it does not recognise. +pub fn describeToComposer(allocator: std.mem.Allocator, version: []const u8) ?[]const u8 { + const v = normalize(version); + if (!isDescribeVersion(v)) return null; + + // Split at the '-' that begins the "-g" trailer: the second + // '-' from the end, since isDescribeVersion has already established both. + const g = std.mem.lastIndexOfScalar(u8, v, '-') orelse return null; + const d = std.mem.lastIndexOfScalar(u8, v[0..g], '-') orelse return null; + + const base = v[0..d]; // "1.3.1" + const commits = v[d + 1 .. g]; // "2" + const sha = v[g + 1 ..]; // "g34abb2c" + + const out = std.fmt.allocPrint(allocator, "{s}+{s}.{s}", .{ base, commits, sha }) catch return null; + return if (composerValid(out)) out else null; +} + +/// Write `version` into `

/composer.json`, best effort. +/// +/// Returns true only when the file now carries a version. Used after a +/// `--local` install: the checkout's composer.json has NO version field (only a +/// release build is stamped, deliberately), so without this the freshly +/// installed kernel is permanently unable to report what it is — `hkm version` +/// reads "unstamped" forever and `hkm upgrade` has nothing to compare, which is +/// a large part of why a local install looked like it never upgraded. +/// +/// A `git describe` version — the shape EVERY build between releases has — is +/// re-spelled as build metadata rather than dropped. That does not contradict +/// the release stamper's rule of "the exact tag or nothing": a release must +/// match the tag it claims to be, whereas a build two commits past v1.3.1 +/// corresponds to no tag at all, and recording which commit it is beats +/// recording nothing. +pub fn writeTo(allocator: std.mem.Allocator, io: Io, dir: []const u8, version: []const u8) bool { + var v = normalize(version); + if (v.len == 0) return false; + if (!composerValid(v)) { + v = describeToComposer(allocator, v) orelse return false; + } + + const path = std.fs.path.join(allocator, &.{ dir, "composer.json" }) catch return false; + const source = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(8 * 1024 * 1024)) catch return false; + + const updated = stamp(allocator, source, v) catch return false; + if (updated) |out| { + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = out }) catch return false; + return true; + } + // null means "already correct" — which is still the outcome asked for. + return true; +} + +/// Semver build metadata: dot-separated identifiers of [0-9A-Za-z-], each +/// non-empty. Deliberately strict — this string is written verbatim into JSON. +fn validMetadata(meta: []const u8) bool { + if (meta.len == 0) return false; + + var it = std.mem.splitScalar(u8, meta, '.'); + while (it.next()) |ident| { + if (ident.len == 0) return false; + for (ident) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '-') return false; + } + } + return true; +} + +/// Nothing that would break out of a JSON string, whatever validation decided. +/// composerValid() is the gate; this is the seatbelt, because the cost of being +/// wrong is a composer.json no install can parse. +fn jsonSafe(v: []const u8) bool { + for (v) |c| { + if (c == '"' or c == '\\' or c < 0x20 or c == 0x7f) return false; + } + return true; +} + +/// Does this look like `git describe` output — "--g"? +/// +/// Matched on the trailing "--g" only, so a real pre-release +/// ("1.1.0-beta.1") is not mistaken for one and still gets a warning. +pub fn isDescribeVersion(v: []const u8) bool { + const g = std.mem.lastIndexOfScalar(u8, v, '-') orelse return false; + const sha = v[g + 1 ..]; + if (sha.len < 2 or sha[0] != 'g') return false; + for (sha[1..]) |c| { + if (!std.ascii.isHex(c)) return false; + } + + const head = v[0..g]; + const d = std.mem.lastIndexOfScalar(u8, head, '-') orelse return false; + const count = head[d + 1 ..]; + if (count.len == 0) return false; + for (count) |c| { + if (!std.ascii.isDigit(c)) return false; + } + return true; +} + +/// Whether Composer will accept this as a package version. +/// +/// A deliberately CONSERVATIVE subset of Composer's own pattern: numeric parts, +/// then an optional stability tag, then an optional `-dev`. Anything it is not +/// sure about is rejected, because the failure mode of a false accept (an +/// install that cannot resolve dependencies) is much worse than a false reject +/// (no version field, which is the status quo for a checkout anyway). +pub fn composerValid(v: []const u8) bool { + var s_ = v; + if (s_.len == 0) return false; + if (s_[0] == 'v' or s_[0] == 'V') s_ = s_[1..]; + + // Build metadata is allowed, but it still has to BE metadata. Discarding it + // unchecked let anything through — `composerValid("1.1.0+\"")` returned + // true, and stamp() writes the version raw between JSON quotes, so that one + // input produced an unparseable composer.json. Semver defines metadata as + // dot-separated [0-9A-Za-z-] identifiers; anything else is rejected. + if (std.mem.indexOfScalar(u8, s_, '+')) |i| { + if (!validMetadata(s_[i + 1 ..])) return false; + s_ = s_[0..i]; + } + if (s_.len == 0) return false; + + // 1-4 numeric components separated by '.' or '-'. + var i: usize = 0; + var parts: usize = 0; + while (i < s_.len and parts < 4) { + const start = i; + while (i < s_.len and std.ascii.isDigit(s_[i])) i += 1; + if (i == start) return false; // expected a number + parts += 1; + if (i < s_.len and (s_[i] == '.' or s_[i] == '-')) { + // Only continue the numeric run when a digit follows. + if (i + 1 < s_.len and std.ascii.isDigit(s_[i + 1])) { + i += 1; + continue; + } + } + break; + } + if (parts == 0) return false; + if (i == s_.len) return true; // plain numeric version + + // Optional separator before the stability tag. + if (s_[i] == '.' or s_[i] == '-' or s_[i] == '_') i += 1; + if (i == s_.len) return false; // trailing separator + + const tail = s_[i..]; + + // Bare "dev" is the only form Composer accepts — no counter after it. + if (std.ascii.eqlIgnoreCase(tail, "dev")) return true; + if (std.ascii.eqlIgnoreCase(tail, "x-dev")) return true; + + // stability tag, optionally followed by (.|-)?digits, repeated. + const tags = [_][]const u8{ "stable", "beta", "alpha", "patch", "rc", "pl", "b", "a", "p" }; + for (tags) |tag| { + if (tail.len < tag.len) continue; + if (!std.ascii.eqlIgnoreCase(tail[0..tag.len], tag)) continue; + + var rest = tail[tag.len..]; + while (rest.len > 0) { + if (rest[0] == '.' or rest[0] == '-') rest = rest[1..]; + if (rest.len == 0) return false; // trailing separator + const start = rest.len; + while (rest.len > 0 and std.ascii.isDigit(rest[0])) rest = rest[1..]; + if (rest.len == start) return false; // expected digits + } + return true; + } + + return false; +} + +/// Return the file with `version` applied, or null when it is already correct. +/// +/// The edit is textual rather than a JSON re-serialise so the file keeps its +/// hand-maintained key order and indentation. Rewriting it through a JSON +/// encoder would reorder every key and produce an unreadable diff per release. +pub fn stamp(allocator: std.mem.Allocator, source: []const u8, version: []const u8) !?[]const u8 { + // The version is written raw between JSON quotes below, so refuse outright + // anything that could terminate the string or embed a control character. + if (!jsonSafe(version)) return null; + + if (findVersionValue(source)) |span| { + if (std.mem.eql(u8, source[span.start..span.end], version)) return null; // no-op + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(allocator, source[0..span.start]); + try out.appendSlice(allocator, version); + try out.appendSlice(allocator, source[span.end..]); + return try out.toOwnedSlice(allocator); + } + + // No "version" key: insert one directly after "name", which is where a + // reader looks for it and where composer's own docs put it. + const anchor = std.mem.indexOf(u8, source, "\"name\"") orelse return null; + const line_end = std.mem.indexOfScalarPos(u8, source, anchor, '\n') orelse return null; + + const indent = detectIndent(source, anchor); + + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(allocator, source[0 .. line_end + 1]); + try out.appendSlice(allocator, indent); + try out.appendSlice(allocator, "\"version\": \""); + try out.appendSlice(allocator, version); + try out.appendSlice(allocator, "\",\n"); + try out.appendSlice(allocator, source[line_end + 1 ..]); + return try out.toOwnedSlice(allocator); +} + +const Span = struct { start: usize, end: usize }; + +/// Byte range of the STRING VALUE of a top-level "version" key. +fn findVersionValue(source: []const u8) ?Span { + var search: usize = 0; + while (std.mem.indexOfPos(u8, source, search, "\"version\"")) |key_at| { + search = key_at + 9; + + // Step over whitespace and the colon. + var i = key_at + 9; + while (i < source.len and (source[i] == ' ' or source[i] == '\t')) i += 1; + if (i >= source.len or source[i] != ':') continue; + i += 1; + while (i < source.len and (source[i] == ' ' or source[i] == '\t')) i += 1; + if (i >= source.len or source[i] != '"') continue; + + const start = i + 1; + const end = std.mem.indexOfScalarPos(u8, source, start, '"') orelse return null; + return .{ .start = start, .end = end }; + } + return null; +} + +/// The leading whitespace of the line containing `pos`, so an inserted key +/// matches the file's existing indentation rather than imposing a new one. +fn detectIndent(source: []const u8, pos: usize) []const u8 { + var line_start = pos; + while (line_start > 0 and source[line_start - 1] != '\n') line_start -= 1; + + var i = line_start; + while (i < source.len and (source[i] == ' ' or source[i] == '\t')) i += 1; + return source[line_start..i]; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "replaces an existing version value" { + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "alfacode-team/php-service-platform", + \\ "version": "1.0.0", + \\ "type": "library" + \\} + ; + const out = (try stamp(a, src, "1.0.21")).?; + defer a.free(out); + + try std.testing.expect(std.mem.indexOf(u8, out, "\"version\": \"1.0.21\"") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "1.0.0") == null); + // Everything else must be untouched. + try std.testing.expect(std.mem.indexOf(u8, out, "\"type\": \"library\"") != null); +} + +test "inserts the key after name when absent, matching indentation" { + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "alfacode-team/php-service-platform", + \\ "type": "library" + \\} + ; + const out = (try stamp(a, src, "1.0.21")).?; + defer a.free(out); + + try std.testing.expect(std.mem.indexOf(u8, out, " \"version\": \"1.0.21\",\n") != null); + // It must still parse. + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const parsed = try std.json.parseFromSliceLeaky(std.json.Value, arena.allocator(), out, .{}); + try std.testing.expectEqualStrings("1.0.21", parsed.object.get("version").?.string); +} + +test "an already-correct version is a no-op" { + // Returning null keeps the build from rewriting the file (and dirtying the + // working tree) on every single invocation. + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "x/y", + \\ "version": "1.0.21" + \\} + ; + try std.testing.expect((try stamp(a, src, "1.0.21")) == null); +} + +test "does not mistake a nested version for the package's own" { + // "require" blocks are full of version-looking keys; only a top-level + // "version" KEY should ever be rewritten. + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "x/y", + \\ "require": { "php": ">=8.4" } + \\} + ; + const out = (try stamp(a, src, "2.0.0")).?; + defer a.free(out); + + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const parsed = try std.json.parseFromSliceLeaky(std.json.Value, arena.allocator(), out, .{}); + try std.testing.expectEqualStrings("2.0.0", parsed.object.get("version").?.string); + try std.testing.expectEqualStrings(">=8.4", parsed.object.get("require").?.object.get("php").?.string); +} + +test "a leading v is stripped so composer sees a bare version" { + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "x/y" + \\} + ; + const out = (try stamp(a, src, normalize("v1.0.21"))).?; + defer a.free(out); + try std.testing.expect(std.mem.indexOf(u8, out, "\"version\": \"v") == null); + try std.testing.expect(std.mem.indexOf(u8, out, "\"version\": \"1.0.21\"") != null); +} + +test "accepts the versions composer accepts" { + // Verified against `composer validate` before being encoded here. + for ([_][]const u8{ + "1.1.0", "1.0.21", "v1.1.0", "1.1.0-dev", "1.1.0-beta.2", + "1.1.0-RC2", "1.1.0-alpha.2", "1.2.3.4", "1.1.0+meta", + }) |v| { + try std.testing.expect(composerValid(v)); + } +} + +test "rejects the version that broke a real install" { + // "1.1.0-dev.2" was stamped from a git tag and made `composer install` + // abort on every machine that took the update. Composer's dev suffix takes + // no counter. + try std.testing.expect(!composerValid("1.1.0-dev.2")); + try std.testing.expect(!composerValid("1.1.0-dev2")); +} + +test "rejects anything it cannot vouch for" { + for ([_][]const u8{ + "", "v", "abc", "1.1.0-", "1.1.0-nonsense", "1.1.0-beta.", "-1.0.0", + }) |v| { + try std.testing.expect(!composerValid(v)); + } +} + +test "a version ending in 'v' keeps its last character" { + // Regression: the trim used the cutset " \t\r\nv" on BOTH ends, so + // "1.1.0-dev" arrived as "1.1.0-de" and was rejected as invalid — the one + // pre-release form Composer actually accepts. + try std.testing.expectEqualStrings("1.1.0-dev", normalize("v1.1.0-dev")); + try std.testing.expect(composerValid("1.1.0-dev")); + try std.testing.expect(!composerValid("1.1.0-de")); +} + +test "build metadata is validated, not waved through" { + // The bug: metadata was discarded unchecked, so this returned true — and + // stamp() writes the version raw between JSON quotes, producing a + // composer.json no install can parse. + try std.testing.expect(!composerValid("1.1.0+\"")); + try std.testing.expect(!composerValid("1.1.0+a\\b")); + try std.testing.expect(!composerValid("1.1.0+a\nb")); + try std.testing.expect(!composerValid("1.1.0+")); // empty metadata + try std.testing.expect(!composerValid("1.1.0+a..b")); // empty identifier + try std.testing.expect(!composerValid("1.1.0+a b")); + + // …while real metadata still passes. + try std.testing.expect(composerValid("1.1.0+build.1")); + try std.testing.expect(composerValid("1.1.0+20260812")); + try std.testing.expect(composerValid("1.1.0+g29dccfb")); + try std.testing.expect(composerValid("1.1.0-beta.1+exp.sha.5114f85")); +} + +test "stamp refuses a version that could break out of the JSON string" { + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "acme/pkg", + \\ "type": "library" + \\} + ; + for ([_][]const u8{ "1.0.0+\"", "1.0.0\\", "1.0.0\n", "1.0.0\x7f" }) |bad| { + try std.testing.expect((try stamp(a, src, bad)) == null); + } +} + +test "a stamped composer.json is still parseable JSON" { + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "acme/pkg", + \\ "type": "library" + \\} + ; + const out = (try stamp(a, src, "1.2.0")) orelse return error.ExpectedOutput; + defer a.free(out); + + const parsed = try std.json.parseFromSlice(std.json.Value, a, out, .{}); + defer parsed.deinit(); + try std.testing.expectEqualStrings("1.2.0", parsed.value.object.get("version").?.string); +} + +test "a git describe version is recognised so dev builds stay quiet" { + try std.testing.expect(isDescribeVersion("1.1.0-dev.2-12-g29dccfb")); + try std.testing.expect(isDescribeVersion("1.0.21-138-gbdbbf34")); + + // A real pre-release must NOT be mistaken for one: those are release + // intents, and silently skipping them is how a release ships unstamped. + try std.testing.expect(!isDescribeVersion("1.1.0-beta.1")); + try std.testing.expect(!isDescribeVersion("1.1.0-dev.2")); + try std.testing.expect(!isDescribeVersion("1.1.0")); + try std.testing.expect(!isDescribeVersion("1.1.0-12-gzz")); +} + +test "parse reads back what stamp wrote" { + // The read and the write are two halves of one contract: `hkm version` + // reports what a release build stamped. A change to either that breaks the + // round trip makes every installed kernel report "unknown". + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "acme/pkg" + \\} + ; + const out = (try stamp(a, src, "1.3.1")).?; + defer a.free(out); + try std.testing.expectEqualStrings("1.3.1", parse(out).?); +} + +test "a git describe version is re-spelled as composer-valid build metadata" { + // The version every build between releases carries. Composer rejects the + // "-2-g34abb2c" form, so a --local install used to record nothing at all + // and could never report what it was. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const out = describeToComposer(a, "v1.3.1-2-g34abb2c").?; + try std.testing.expectEqualStrings("1.3.1+2.g34abb2c", out); + try std.testing.expect(composerValid(out)); + + const long = describeToComposer(a, "1.0.21-138-gbdbbf34").?; + try std.testing.expectEqualStrings("1.0.21+138.gbdbbf34", long); + try std.testing.expect(composerValid(long)); +} + +test "the re-spelling preserves precedence exactly" { + // The whole justification: semver excludes build metadata from ordering, + // and lib/semver.zig's parseDescribed already collapses the '-' form to the + // same base version. If these two ever disagreed, `hkm upgrade` would rank a + // local build differently depending on which spelling happened to be on + // disk. + const semver = @import("semver.zig"); + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const described = semver.parseDescribed("1.3.1-2-g34abb2c").?; + const respelled = semver.parseDescribed(describeToComposer(a, "1.3.1-2-g34abb2c").?).?; + try std.testing.expectEqual(std.math.Order.eq, described.order(respelled)); + try std.testing.expectEqual(std.math.Order.eq, respelled.order(semver.Version.parse("1.3.1").?)); +} + +test "describeToComposer invents nothing for a version it does not recognise" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // A real pre-release is a release intent, not a describe trailer — silently + // rewriting one would make composer.json disagree with the tag it claims. + try std.testing.expect(describeToComposer(a, "1.3.1-beta.1") == null); + try std.testing.expect(describeToComposer(a, "1.3.1") == null); + try std.testing.expect(describeToComposer(a, "garbage") == null); +} + +test "parse returns null for a checkout composer.json with no version" { + // The normal state of this repo: build.zig only stamps a release build, so + // a checkout legitimately has no version and must not report a wrong one. + const src = + \\{ + \\ "name": "alfacode-team/php-service-platform", + \\ "require": { "php": ">=8.4" } + \\} + ; + try std.testing.expect(parse(src) == null); +} diff --git a/tools/src/lib/install_scope.zig b/tools/src/lib/install_scope.zig new file mode 100644 index 0000000..8396360 --- /dev/null +++ b/tools/src/lib/install_scope.zig @@ -0,0 +1,415 @@ +//! The two install SCOPES a machine can hold, and how to find each one. +//! +//! HKM ships two installers with two different footprints, and until this file +//! existed nothing in the CLI modelled that they can both be present: +//! +//! system .deb /opt/hkm-kernel + /usr/bin/{hkm,hkm-config} root +//! user tarball ~/.local/lib/hkm-kernel + ~/.local/bin/{hkm,hkm-config} no root +//! +//! Both are legitimate and they COEXIST — a machine with a system install from +//! an earlier deploy, plus a user install for day-to-day work, is the ordinary +//! state, not a broken one. What was broken was that every part of the CLI +//! spoke of "the installed kernel" as if there were one: +//! +//! • `hkm upgrade` on Linux only ever fetched the .deb and shelled out to +//! sudo apt-get, so a user install could never update itself. PATH then +//! resolved the STALE user launcher first and the upgrade looked like it +//! had done nothing. +//! • `hkm version` printed the launcher's own compile-time stamp and named no +//! kernel at all, so with two installs present it answered a question +//! nobody asked. +//! • One shared `HKM_KERNEL_HOME` pin in ~/.config/hkm/config.env was read by +//! BOTH launchers, so whichever installer ran last silently redirected the +//! other one's kernel. (See lib/kernel.zig for how resolution now stops +//! that.) +//! +//! Every one of those is the same missing distinction. This file supplies it: +//! given the environment, report what is installed in each scope, at what +//! version, and which one this invocation is actually running. +//! +//! The paths here are not free parameters. `system` mirrors the layout +//! tools/bundle.sh writes into the .deb, and `user` mirrors what +//! tools/install.sh writes into $HKM_PREFIX — including the bin/ + lib/ pairing +//! that lets the launcher self-locate its kernel with no env var at all. +//! Changing one side without the other breaks resolution. + +const std = @import("std"); +const composer_version = @import("composer_version.zig"); +const util = @import("util.zig"); + +const Io = std.Io; +const EnvMap = std.process.Environ.Map; + +pub const Scope = enum { + system, + user, + + pub fn label(self: Scope) []const u8 { + return switch (self) { + .system => "system", + .user => "user", + }; + } + + /// How that scope is installed — used in guidance, so it names the command + /// the reader should actually run. + pub fn how(self: Scope) []const u8 { + return switch (self) { + .system => "system-wide (.deb, needs root)", + .user => "user-local (tarball, no root)", + }; + } +}; + +/// The system kernel root. Fixed by the .deb's own layout. +pub const system_root = "/opt/hkm-kernel"; + +/// Where the .deb puts the launcher. +pub const system_bin_dir = "/usr/bin"; + +/// Directories a launcher living in means "this is the system install". +/// +/// Needed because /usr/bin/hkm CANNOT self-locate /opt/hkm-kernel by relative +/// probing — there is no fixed relative path between them — so without this the +/// system launcher fell through to the config-file pin, which is precisely the +/// hijack this module exists to prevent. +pub const system_bin_dirs = [_][]const u8{ "/usr/bin", "/usr/local/bin", "/bin", "/sbin", "/usr/sbin" }; + +/// Is `dir` one of the system bin directories? +pub fn isSystemBinDir(dir: []const u8) bool { + const d = util.trimSlash(dir); + for (system_bin_dirs) |candidate| { + if (std.mem.eql(u8, d, candidate)) return true; + } + return false; +} + +/// The invoking user's home directory. +/// +/// Honours SUDO_USER, because under `sudo hkm …` HOME is root's (/root) while +/// every user-scope path the command needs to REPORT belongs to the person who +/// typed the command. Without this, `sudo hkm version` would claim there is no +/// user install on a machine that has one. +pub fn homeDir(allocator: std.mem.Allocator, env: *EnvMap) ?[]const u8 { + if (env.get("SUDO_USER")) |user| { + if (user.len > 0 and !std.mem.eql(u8, user, "root")) { + return std.fmt.allocPrint(allocator, "/home/{s}", .{user}) catch null; + } + } + const home = env.get("HOME") orelse return null; + if (home.len == 0) return null; + return util.trimSlash(home); +} + +/// The user install PREFIX: $HKM_PREFIX, else ~/.local. +/// +/// Same variable tools/install.sh reads, so `--prefix /srv/hkm` and +/// `HKM_PREFIX=/srv/hkm hkm upgrade` land in the same place. +pub fn userPrefix(allocator: std.mem.Allocator, env: *EnvMap) ?[]const u8 { + if (env.get("HKM_PREFIX")) |p| { + if (p.len > 0) return util.trimSlash(p); + } + const home = homeDir(allocator, env) orelse return null; + return std.fmt.allocPrint(allocator, "{s}/.local", .{home}) catch null; +} + +/// The user kernel root: `/lib/hkm-kernel`. +/// +/// This path is chosen so `/bin/hkm` self-locates it by probing +/// "/lib/hkm-kernel" (lib/kernel.zig). That is what makes a +/// user install need NO environment variable and NO config pin — and therefore +/// what stops it from having to write a pin that then hijacks the system +/// install. The earlier `--user` target (~/.local/share/hkm/kernel) sat outside +/// every probe, so it could only be reached through a pin; see legacyUserRoot. +pub fn userRoot(allocator: std.mem.Allocator, env: *EnvMap) ?[]const u8 { + const prefix = userPrefix(allocator, env) orelse return null; + return std.fmt.allocPrint(allocator, "{s}/lib/hkm-kernel", .{prefix}) catch null; +} + +/// Where a user install puts its launchers: `/bin`. +pub fn userBinDir(allocator: std.mem.Allocator, env: *EnvMap) ?[]const u8 { + const prefix = userPrefix(allocator, env) orelse return null; + return std.fmt.allocPrint(allocator, "{s}/bin", .{prefix}) catch null; +} + +/// Where `hkm upgrade --local --user` used to install: the userdata dir. +/// +/// Still probed so a machine that took that path is RECOGNISED rather than +/// reported as having no user install — and so the migration can name it. +/// Nothing writes here any more. +pub fn legacyUserRoot(allocator: std.mem.Allocator, env: *EnvMap) ?[]const u8 { + if (env.get("XDG_DATA_HOME")) |x| { + if (x.len > 0) return std.fmt.allocPrint(allocator, "{s}/hkm/kernel", .{util.trimSlash(x)}) catch null; + } + const home = homeDir(allocator, env) orelse return null; + return std.fmt.allocPrint(allocator, "{s}/.local/share/hkm/kernel", .{home}) catch null; +} + +/// Is this process running with root privileges? +/// +/// This is the switch that makes `sudo hkm upgrade` update the system install +/// and a plain `hkm upgrade` update the user's own. EFFECTIVE uid rather than +/// SUDO_USER, because that is what actually decides whether the write to /opt +/// will succeed — `su -`, a root shell and a container all have no SUDO_USER +/// and are all genuinely root. +/// +/// The syscall is reached per-platform rather than through std.posix, which has +/// no geteuid in the pinned toolchain (0.17.0-dev). Linux gets the raw syscall +/// so a statically linked launcher needs no libc; everything else POSIX goes +/// through the libc symbol. +pub fn isRoot(env: *EnvMap) bool { + _ = env; + return switch (@import("builtin").os.tag) { + .windows => false, + .linux => std.os.linux.geteuid() == 0, + else => std.c.geteuid() == 0, + }; +} + +/// The scope a command should act on when the user named none. +/// +/// Root → system, otherwise → user. Deliberately derived from privilege rather +/// than from what happens to be installed: it makes `sudo hkm upgrade` and +/// `hkm upgrade` two predictable, different commands instead of one command +/// whose target depends on machine state. +pub fn defaultScope(env: *EnvMap) Scope { + return if (isRoot(env)) .system else .user; +} + +/// What is installed in one scope. +pub const Install = struct { + scope: Scope, + /// Could this scope's paths be resolved at all? + /// + /// False only for `.user` with no HOME and no HKM_PREFIX — a cron job or a + /// stripped service environment. It exists so an unresolvable user scope is + /// never quietly represented by the SYSTEM paths: an upgrade that fell back + /// that way would write to /opt on behalf of a command the user ran + /// specifically to avoid touching /opt. + resolved: bool, + /// Kernel root for this scope — always populated, even when absent, so a + /// diagnostic can say WHERE it looked. Meaningless when `resolved` is false. + root: []const u8, + /// Directory the launcher for this scope lives in, when it is resolvable. + bin_dir: ?[]const u8, + /// The kernel root holds a composer.json. + present: bool, + /// `"version"` from that composer.json — null for a checkout-style install + /// that was never stamped. + version: ?[]const u8, + /// Path to the launcher binary, when one exists there. + launcher: ?[]const u8, + /// Dependencies resolved (vendor/autoload.php present). + vendor: bool, + /// A LEGACY user install found at the old ~/.local/share/hkm/kernel path. + /// Only ever set for .user. + legacy_root: ?[]const u8 = null, +}; + +/// Inspect one scope. Never fails: an absent install is a result, not an error. +pub fn detect(allocator: std.mem.Allocator, io: Io, env: *EnvMap, scope: Scope) Install { + const maybe_root: ?[]const u8 = switch (scope) { + .system => system_root, + .user => userRoot(allocator, env), + }; + const bin_dir: ?[]const u8 = switch (scope) { + .system => system_bin_dir, + .user => userBinDir(allocator, env), + }; + + var out = Install{ + .scope = scope, + .resolved = maybe_root != null, + .root = maybe_root orelse "(no HOME — user scope unresolvable)", + .bin_dir = bin_dir, + .present = false, + .version = null, + .launcher = null, + .vendor = false, + }; + const root = maybe_root orelse return out; + + const manifest = std.fs.path.join(allocator, &.{ root, "composer.json" }) catch return out; + out.present = util.fileExists(io, manifest); + if (out.present) { + out.version = composer_version.ofKernel(allocator, io, root); + if (std.fs.path.join(allocator, &.{ root, "vendor", "autoload.php" })) |autoload| { + out.vendor = util.fileExists(io, autoload); + } else |_| {} + } + + if (bin_dir) |dir| { + if (std.fs.path.join(allocator, &.{ dir, launcher_name })) |exe| { + if (util.fileExists(io, exe)) out.launcher = exe; + } else |_| {} + } + + // A user install left at the pre-1.4 path is worth surfacing even when the + // current one is fine — it is a second kernel on disk that a stale pin can + // still point at. + if (scope == .user) { + if (legacyUserRoot(allocator, env)) |legacy| { + if (!std.mem.eql(u8, legacy, root)) { + if (std.fs.path.join(allocator, &.{ legacy, "composer.json" })) |m| { + if (util.fileExists(io, m)) out.legacy_root = legacy; + } else |_| {} + } + } + } + + return out; +} + +/// The launcher's filename on this platform. +pub const launcher_name = if (@import("builtin").os.tag == .windows) "hkm.exe" else "hkm"; + +/// Which scope does a kernel root belong to? Null when it is neither — a dev +/// checkout, or an operator's custom prefix. +pub fn scopeOf(allocator: std.mem.Allocator, env: *EnvMap, root: []const u8) ?Scope { + const r = util.trimSlash(root); + if (std.mem.eql(u8, r, system_root)) return .system; + if (userRoot(allocator, env)) |u| { + if (std.mem.eql(u8, r, util.trimSlash(u))) return .user; + } + if (legacyUserRoot(allocator, env)) |u| { + if (std.mem.eql(u8, r, util.trimSlash(u))) return .user; + } + return null; +} + +/// The version to print for a kernel root: its stamped version, or a marker. +/// +/// "unstamped" rather than "unknown" is deliberate — for a `--local` install +/// from a checkout it is the CORRECT answer, and it points at the reason +/// (nothing stamped it) instead of implying something is broken. +pub fn versionLabel(v: ?[]const u8) []const u8 { + return v orelse "unstamped"; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "the user kernel root is the path the launcher can self-locate" { + // bin/ + lib/ side by side is what lib/kernel.zig probes as + // "/lib/hkm-kernel". If this pairing drifts, a user + // install becomes reachable only through a config pin — and a config pin is + // read by BOTH launchers, which is the hijack this module exists to stop. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = std.process.Environ.Map.init(a); + defer env.deinit(); + try env.put("HOME", "/home/tester"); + + try std.testing.expectEqualStrings("/home/tester/.local/lib/hkm-kernel", userRoot(a, &env).?); + try std.testing.expectEqualStrings("/home/tester/.local/bin", userBinDir(a, &env).?); +} + +test "HKM_PREFIX relocates both halves together" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = std.process.Environ.Map.init(a); + defer env.deinit(); + try env.put("HOME", "/home/tester"); + try env.put("HKM_PREFIX", "/srv/hkm/"); + + // The trailing slash must not produce "//lib" — install.sh writes the same + // two paths and they have to match byte for byte for scopeOf to work. + try std.testing.expectEqualStrings("/srv/hkm/lib/hkm-kernel", userRoot(a, &env).?); + try std.testing.expectEqualStrings("/srv/hkm/bin", userBinDir(a, &env).?); +} + +test "sudo reports the invoking user's install, not root's" { + // Under `sudo hkm version` HOME is /root. Resolving the user scope from it + // would claim the machine has no user install while one sits in the home + // directory of the person who typed the command. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = std.process.Environ.Map.init(a); + defer env.deinit(); + try env.put("HOME", "/root"); + try env.put("SUDO_USER", "tester"); + + try std.testing.expectEqualStrings("/home/tester", homeDir(a, &env).?); + try std.testing.expectEqualStrings("/home/tester/.local/lib/hkm-kernel", userRoot(a, &env).?); +} + +test "SUDO_USER=root is not treated as a different user" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = std.process.Environ.Map.init(a); + defer env.deinit(); + try env.put("HOME", "/root"); + try env.put("SUDO_USER", "root"); + + try std.testing.expectEqualStrings("/root", homeDir(a, &env).?); +} + +test "scopeOf recognises both current roots and the legacy user one" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = std.process.Environ.Map.init(a); + defer env.deinit(); + try env.put("HOME", "/home/tester"); + + try std.testing.expectEqual(Scope.system, scopeOf(a, &env, "/opt/hkm-kernel").?); + try std.testing.expectEqual(Scope.system, scopeOf(a, &env, "/opt/hkm-kernel/").?); + try std.testing.expectEqual(Scope.user, scopeOf(a, &env, "/home/tester/.local/lib/hkm-kernel").?); + // The pre-1.4 --user target still resolves to the user scope, so a machine + // holding one is diagnosed rather than reported as "neither". + try std.testing.expectEqual(Scope.user, scopeOf(a, &env, "/home/tester/.local/share/hkm/kernel").?); + // A dev checkout belongs to no install scope. + try std.testing.expect(scopeOf(a, &env, "/home/tester/Documents/HKMCODE") == null); +} + +test "a launcher in a system bin dir is recognised as the system install" { + // This is what lets /usr/bin/hkm claim /opt/hkm-kernel ahead of a + // config-file pin. Without it the .deb launcher has no self-location at all + // and follows whatever the last user-level installer wrote. + try std.testing.expect(isSystemBinDir("/usr/bin")); + try std.testing.expect(isSystemBinDir("/usr/bin/")); + try std.testing.expect(isSystemBinDir("/usr/local/bin")); + try std.testing.expect(!isSystemBinDir("/home/tester/.local/bin")); + try std.testing.expect(!isSystemBinDir("/opt/hkm-kernel/bin")); +} + +test "an unstamped kernel says so rather than claiming to be unknown" { + try std.testing.expectEqualStrings("1.3.1", versionLabel("1.3.1")); + try std.testing.expectEqualStrings("unstamped", versionLabel(null)); +} + +test "an unresolvable user scope never resolves to the system paths" { + // With no HOME and no HKM_PREFIX there is no user install to speak of. The + // dangerous outcome is not "no result" but a SILENT fallback to /opt: `hkm + // upgrade` would then write system-wide on behalf of a command whose whole + // purpose is to avoid that. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); + defer threaded.deinit(); + + var env = std.process.Environ.Map.init(a); + defer env.deinit(); + + const user = detect(a, threaded.io(), &env, .user); + try std.testing.expect(!user.resolved); + try std.testing.expect(!std.mem.eql(u8, user.root, system_root)); + try std.testing.expect(!user.present); + + // The system scope is always resolvable — its paths are constants. + const system = detect(a, threaded.io(), &env, .system); + try std.testing.expect(system.resolved); + try std.testing.expectEqualStrings(system_root, system.root); +} diff --git a/tools/src/lib/kernel.zig b/tools/src/lib/kernel.zig index 47242bc..362feae 100644 --- a/tools/src/lib/kernel.zig +++ b/tools/src/lib/kernel.zig @@ -1,15 +1,57 @@ //! Kernel location resolution shared by the launcher passthrough (main.zig) and //! `hkm doctor`. Given the environment, returns the path to the kernel's PHP CLI //! (`/bin/hkm`) that the launcher invokes as `php …`. +//! +//! RESOLUTION ORDER, AND WHY A CONFIG PIN NO LONGER WINS +//! ---------------------------------------------------- +//! A machine can hold two installs at once — the .deb's /opt/hkm-kernel and a +//! user's ~/.local/lib/hkm-kernel (see lib/install_scope.zig). Both launchers +//! read the SAME ~/.config/hkm/config.env, and HKM_KERNEL_HOME used to be +//! checked before anything else. So whichever installer wrote that pin last +//! silently redirected the other install too: +//! +//! $ /usr/bin/hkm --version → 1.3.1 (the .deb's launcher) +//! $ /usr/bin/hkm doctor +//! kernel root /home/me/.local/share/hkm/kernel ← the USER's kernel +//! resolved via HKM_KERNEL_HOME override +//! +//! Upgrading either scope then appeared to do nothing, because the version on +//! screen came from a launcher whose kernel belonged to the other install. The +//! order below fixes that by ranking the sources by how specific they are to +//! THIS invocation: +//! +//! 1. HKM_CLI_PATH / HKM_KERNEL_HOME exported in the real environment — +//! this command's explicit instruction, always wins. +//! 2. Self-location relative to this launcher's own executable, which is +//! per-install by construction and cannot be affected by the other one. +//! For a launcher in a system bin dir (/usr/bin) that includes claiming +//! /opt/hkm-kernel, since no relative probe can reach it from there. +//! 3. HKM_KERNEL_HOME from config.env — now a FALLBACK, for installs at a +//! custom path that self-location genuinely cannot find. +//! 4. /opt/hkm-kernel, the last-resort default. +//! +//! The behaviour change is narrow: a config pin still works whenever the +//! launcher cannot self-locate a kernel, which is the case it was added for. It +//! no longer overrides an install that is sitting right next to the binary. const std = @import("std"); +const install_scope = @import("install_scope.zig"); +const userconfig = @import("userconfig.zig"); const util = @import("util.zig"); const Io = std.Io; const EnvMap = std.process.Environ.Map; /// How the kernel CLI path was determined — surfaced by `hkm doctor`. -pub const Source = enum { cli_path_env, kernel_home_env, self_located, default }; +pub const Source = enum { + cli_path_env, + /// HKM_KERNEL_HOME exported in the real environment. + kernel_home_env, + /// HKM_KERNEL_HOME from ~/.config/hkm/config.env (a fallback, not an override). + kernel_home_config, + self_located, + default, +}; pub const Resolved = struct { path: []const u8, @@ -23,40 +65,51 @@ fn envGet(allocator: std.mem.Allocator, map: *EnvMap, key: []const u8) !?[]const return try allocator.dupe(u8, v); } +/// HKM_KERNEL_HOME, split by where it came from. +const Pin = struct { + value: []const u8, + /// From config.env rather than a real export — see the header. + from_config: bool, +}; + +fn kernelHomePin(allocator: std.mem.Allocator, env: *EnvMap) !?Pin { + const raw = (try envGet(allocator, env, "HKM_KERNEL_HOME")) orelse return null; + const v = util.trimSlash(std.mem.trim(u8, raw, " \t\r\n")); + if (v.len == 0) return null; + return .{ .value = v, .from_config = userconfig.isFileSourced(env, "HKM_KERNEL_HOME") }; +} + /// Resolve the kernel PHP CLI path with full provenance (for diagnostics). pub fn resolve(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !Resolved { - // 1. Explicit overrides always win. + // 1. An explicit CLI path is the most specific instruction there is. if (try envGet(allocator, env, "HKM_CLI_PATH")) |v| { return .{ .path = v, .source = .cli_path_env, .exists = util.fileExists(io, v) }; } - if (try envGet(allocator, env, "HKM_KERNEL_HOME")) |home| { - const p = try std.fs.path.join(allocator, &.{ home, "bin", "hkm" }); - return .{ .path = p, .source = .kernel_home_env, .exists = util.fileExists(io, p) }; - } - // 2. Self-locate the kernel RELATIVE to this launcher's own executable, so a - // portable/zip/.app install needs no env var. Candidates cover every - // bundle layout produced by tools/bundle.sh: - // macOS .app: /hkm + ../Resources/opt/hkm-kernel/bin/hkm - // Windows zip: /hkm.exe + hkm-kernel/bin/hkm - // portable: /hkm + ../opt/hkm-kernel/bin/hkm - if (std.process.executableDirPathAlloc(io, allocator)) |dir| { - const rels = [_][]const []const u8{ - &.{ dir, "..", "Resources", "opt", "hkm-kernel", "bin", "hkm" }, - &.{ dir, "hkm-kernel", "bin", "hkm" }, - &.{ dir, "..", "opt", "hkm-kernel", "bin", "hkm" }, - &.{ dir, "..", "lib", "hkm-kernel", "bin", "hkm" }, - }; - for (rels) |parts| { - const cand = try std.fs.path.join(allocator, parts); - if (util.fileExists(io, cand)) { - return .{ .path = cand, .source = .self_located, .exists = true }; - } + const pin = try kernelHomePin(allocator, env); + + // 2. A REAL exported HKM_KERNEL_HOME outranks everything below it. + if (pin) |p| { + if (!p.from_config) { + const path = try std.fs.path.join(allocator, &.{ p.value, "bin", "hkm" }); + return .{ .path = path, .source = .kernel_home_env, .exists = util.fileExists(io, path) }; } - } else |_| {} + } + + // 3. Self-locate relative to this launcher's own executable. + if (try selfLocateRoot(allocator, io)) |root| { + const path = try std.fs.path.join(allocator, &.{ root, "bin", "hkm" }); + return .{ .path = path, .source = .self_located, .exists = util.fileExists(io, path) }; + } - // 3. Default for a system package install (Linux .deb → /opt/hkm-kernel). - const def = try std.fs.path.join(allocator, &.{ "/opt", "hkm-kernel", "bin", "hkm" }); + // 4. A config-file pin — the fallback for a custom install layout. + if (pin) |p| { + const path = try std.fs.path.join(allocator, &.{ p.value, "bin", "hkm" }); + return .{ .path = path, .source = .kernel_home_config, .exists = util.fileExists(io, path) }; + } + + // 5. Default for a system package install (Linux .deb → /opt/hkm-kernel). + const def = try std.fs.path.join(allocator, &.{ install_scope.system_root, "bin", "hkm" }); return .{ .path = def, .source = .default, .exists = util.fileExists(io, def) }; } @@ -79,34 +132,79 @@ fn isKernelRoot(io: Io, dir: []const u8) bool { return util.fileExists(io, marker); } +/// The kernel root belonging to THIS launcher, found from its own location. +/// +/// Candidates cover every layout tools/bundle.sh and tools/install.sh produce: +/// macOS .app: /hkm + ../Resources/opt/hkm-kernel +/// Windows zip: /hkm.exe + hkm-kernel +/// portable: /hkm + ../opt/hkm-kernel +/// user install:/bin/hkm + ../lib/hkm-kernel +/// dev monorepo:repo/bin/hkm + repo root +/// +/// Plus one case that is NOT a relative probe: a launcher installed in a system +/// bin directory belongs to the .deb, whose kernel is /opt/hkm-kernel by +/// construction. There is no fixed relative path from /usr/bin to /opt, so +/// without this branch the system launcher has no self-location at all and +/// falls through to whatever pin a user-level installer happened to write — +/// which is exactly the hijack described in the header. +fn selfLocateRoot(allocator: std.mem.Allocator, io: Io) !?[]const u8 { + const dir = std.process.executableDirPathAlloc(io, allocator) catch return null; + const parent = std.fs.path.dirname(dir) orelse dir; + + const rels = [_][]const []const u8{ + &.{ parent, "Resources", "opt", "hkm-kernel" }, // macOS .app (MacOS→Contents) + &.{ dir, "hkm-kernel" }, // windows/portable zip + &.{ parent, "opt", "hkm-kernel" }, // portable + &.{ parent, "lib", "hkm-kernel" }, // install.sh (bin/ + lib/ pairing) + &.{parent}, // dev monorepo: repo/bin/hkm → repo root + }; + for (rels) |parts| { + const cand = try std.fs.path.join(allocator, parts); + if (isKernelRoot(io, cand)) return cand; + } + + if (install_scope.isSystemBinDir(dir) and isKernelRoot(io, install_scope.system_root)) { + return install_scope.system_root; + } + + return null; +} + /// Resolve the kernel ROOT directory (the folder holding composer.json, vendor/, -/// projects/). Used by `run`, the registry, and `hkm-config`. Order: -/// 1. HKM_KERNEL_HOME -/// 2. self-located relative to THIS executable (installed .deb/.app/zip, or the -/// dev monorepo when running repo/bin/hkm) -/// 3. /opt/hkm-kernel default -/// Returns null when no kernel can be found. +/// projects/). Used by `run`, the registry, and `hkm-config`. +/// +/// Same precedence as `resolve` — see the header. Returns null when no kernel +/// can be found. pub fn resolveHome(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !?[]const u8 { - if (env.get("HKM_KERNEL_HOME")) |h| { - if (h.len > 0) return util.trimSlash(h); + return (try resolveHomeDetailed(allocator, io, env)).root; +} + +pub const ResolvedHome = struct { + root: ?[]const u8, + source: Source, +}; + +/// `resolveHome` with provenance, so a caller can act on HOW the kernel was +/// found. `hkm-config check` uses it to avoid writing a pin for a kernel that +/// self-location already reaches — writing one is what created the machine-wide +/// pin that redirected the other install in the first place. +pub fn resolveHomeDetailed(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !ResolvedHome { + const pin = try kernelHomePin(allocator, env); + + if (pin) |p| { + if (!p.from_config) return .{ .root = p.value, .source = .kernel_home_env }; } - if (std.process.executableDirPathAlloc(io, allocator)) |dir| { - // parent = the dir ABOVE the executable's dir (normalized, no ".."). - const parent = std.fs.path.dirname(dir) orelse dir; - const rels = [_][]const []const u8{ - &.{ parent, "Resources", "opt", "hkm-kernel" }, // macOS .app (MacOS→Contents) - &.{ dir, "hkm-kernel" }, // windows/portable zip - &.{ parent, "opt", "hkm-kernel" }, // portable - &.{ parent, "lib", "hkm-kernel" }, - &.{parent}, // dev monorepo: repo/bin/hkm → repo root - }; - for (rels) |parts| { - const cand = try std.fs.path.join(allocator, parts); - if (isKernelRoot(io, cand)) return cand; - } - } else |_| {} - if (isKernelRoot(io, "/opt/hkm-kernel")) return "/opt/hkm-kernel"; - return null; + + if (try selfLocateRoot(allocator, io)) |root| { + return .{ .root = root, .source = .self_located }; + } + + if (pin) |p| return .{ .root = p.value, .source = .kernel_home_config }; + + if (isKernelRoot(io, install_scope.system_root)) { + return .{ .root = install_scope.system_root, .source = .default }; + } + return .{ .root = null, .source = .default }; } /// Resolve the DEVELOPMENT kernel root by walking UP the directory tree from @@ -132,8 +230,81 @@ pub fn resolveDevHome(allocator: std.mem.Allocator, io: Io) !?[]const u8 { pub fn sourceLabel(s: Source) []const u8 { return switch (s) { .cli_path_env => "HKM_CLI_PATH override", - .kernel_home_env => "HKM_KERNEL_HOME override", + .kernel_home_env => "HKM_KERNEL_HOME (exported)", + .kernel_home_config => "HKM_KERNEL_HOME (config.env fallback)", .self_located => "self-located (relative to launcher)", .default => "default (/opt/hkm-kernel)", }; } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "an exported HKM_KERNEL_HOME still overrides everything" { + // The escape hatch has to keep working: a real export is this invocation's + // explicit instruction and must not be demoted along with the config file. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = EnvMap.init(a); + defer env.deinit(); + try env.put("HKM_KERNEL_HOME", "/somewhere/custom"); + + const pin = (try kernelHomePin(a, &env)).?; + try std.testing.expectEqualStrings("/somewhere/custom", pin.value); + try std.testing.expect(!pin.from_config); +} + +test "a config.env pin is marked as such so it can be demoted" { + // This is the regression guard for the reported bug: /usr/bin/hkm (v1.3.1) + // resolving its kernel to ~/.local/share/hkm/kernel because a user-level + // install had written that pin into the shared config file. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = EnvMap.init(a); + defer env.deinit(); + try env.put("HKM_KERNEL_HOME", "/home/me/.local/share/hkm/kernel"); + try env.put(userconfig.file_keys_marker, "HKM_KERNEL_HOME,HKM_USERDATA_DIR"); + + const pin = (try kernelHomePin(a, &env)).?; + try std.testing.expect(pin.from_config); +} + +test "a pin's trailing slash is trimmed so comparisons hold" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = EnvMap.init(a); + defer env.deinit(); + try env.put("HKM_KERNEL_HOME", " /opt/hkm-kernel/ "); + + try std.testing.expectEqualStrings("/opt/hkm-kernel", (try kernelHomePin(a, &env)).?.value); +} + +test "an empty pin is treated as absent, not as the root directory" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = EnvMap.init(a); + defer env.deinit(); + try env.put("HKM_KERNEL_HOME", " "); + + try std.testing.expect((try kernelHomePin(a, &env)) == null); +} + +test "every source has a distinct human label" { + // doctor prints these; two sources sharing a label would make the one + // diagnostic that explains a hijack unable to distinguish its two causes. + const sources = [_]Source{ .cli_path_env, .kernel_home_env, .kernel_home_config, .self_located, .default }; + for (sources, 0..) |a, i| { + for (sources[i + 1 ..]) |b| { + try std.testing.expect(!std.mem.eql(u8, sourceLabel(a), sourceLabel(b))); + } + } +} diff --git a/tools/src/lib/plugin_bootstrap.zig b/tools/src/lib/plugin_bootstrap.zig index f9ba526..b71cb58 100644 --- a/tools/src/lib/plugin_bootstrap.zig +++ b/tools/src/lib/plugin_bootstrap.zig @@ -117,12 +117,44 @@ pub fn collectFromArray( search = pos + needle.len; if (token.len == 0) continue; + // A commented-out provider is not enabled. + // + // Scanning for `::class` with no notion of comments meant the template's + // documented-as-optional identity stack — User, Feedback, Auth, Tenancy, + // all four written as `// \Plugins\User\Provider::class,` — counted as + // enabled. Every new project downloaded and wired four plugins it had + // explicitly not asked for, and the comment that said "enable together + // in an app that needs accounts" was decoration. + if (isCommentedOut(block, b)) continue; + const name = resolvePlugin(token, aliases) orelse token; if (isEnabled(out.items, name)) continue; // de-dupe try out.append(allocator, .{ .name = name, .token = token, .activation = activation }); } } +/// Is the token at `at` inside a comment? +/// +/// Line-level: a `//` earlier on the same line, or a line whose first non-space +/// character starts a block comment or continues one (` * `). That covers how +/// providers are actually commented out in a bootstrap, and — unlike a full PHP +/// parse — cannot itself go wrong in a way that silently drops a live entry. +/// +/// Deliberately NOT fooled by a trailing comment: `Provider::class, // note` +/// has its `//` AFTER the token and stays enabled. +fn isCommentedOut(block: []const u8, at: usize) bool { + const line_start = if (std.mem.lastIndexOfScalar(u8, block[0..at], '\n')) |nl| nl + 1 else 0; + const before = block[line_start..at]; + + if (std.mem.indexOf(u8, before, "//") != null) return true; + + const trimmed = std.mem.trimStart(u8, before, " \t"); + if (std.mem.startsWith(u8, trimmed, "*")) return true; // inside a docblock + if (std.mem.startsWith(u8, trimmed, "/*")) return true; + + return false; +} + pub fn isEnabled(items: []const Enabled, name: []const u8) bool { for (items) |e| { if (std.mem.eql(u8, e.name, name)) return true; @@ -214,15 +246,38 @@ pub fn supportTag(allocator: std.mem.Allocator, folder: []const u8) ![]const u8 return std.fmt.allocPrint(allocator, "{s}{s}]", .{ support_tag_open, folder }); } +/// Is `folder`'s Support helpers require ACTUALLY present? +/// +/// The marker comment alone is not enough. It tracks ownership, not presence, +/// and the two come apart the moment someone deletes the require line while +/// debugging and leaves the comment above it — after which every check keyed on +/// the marker reports the helpers as wired while PHP fatals on the first call +/// to one of them. Requiring the `require_once` itself makes the check say what +/// it claims to say. +pub fn supportRequireWired(allocator: std.mem.Allocator, source: []const u8, folder: []const u8, expr: []const u8) bool { + const tag = supportTag(allocator, folder) catch return false; + if (std.mem.indexOf(u8, source, tag) == null) return false; + + // The expression identifies the file; a require_once naming it is the wiring. + if (expr.len > 0) { + if (std.mem.indexOf(u8, source, expr)) |at| { + const before = source[0..at]; + if (std.mem.lastIndexOf(u8, before, "require_once") != null) return true; + } + return false; + } + return true; +} + /// Insert a managed `require_once ` for `folder`'s Support/helpers.php after /// the autoload call in the bootstrap. `expr` is the PHP expression that follows /// `require_once ` (including its trailing `;`). Idempotent — returns the source /// unchanged (same slice) when the plugin's require is already present, so callers /// can compare pointers to detect a no-op. pub fn insertSupportRequire(allocator: std.mem.Allocator, source: []const u8, folder: []const u8, expr: []const u8) ![]const u8 { - const tag = try supportTag(allocator, folder); - if (std.mem.indexOf(u8, source, tag) != null) return source; // already wired + if (supportRequireWired(allocator, source, folder, expr)) return source; + const tag = try supportTag(allocator, folder); const block = try std.fmt.allocPrint( allocator, "\n// {s} Support helpers — managed by `hkm plugins`\nrequire_once {s}", @@ -339,3 +394,31 @@ pub fn removeFromArray(allocator: std.mem.Allocator, source: []const u8, token: } return .{ .text = try out.toOwnedSlice(allocator), .removed = try removed.toOwnedSlice(allocator) }; } + +test "a commented-out provider is not enabled" { + const a = std.testing.allocator; + const src = + \\withModules([ + \\ \Plugins\Logger\Provider::class, + \\ // Identity stack (enable together in an app that needs accounts): + \\ // \Plugins\User\Provider::class, + \\ // \Plugins\Auth\Provider::class, + \\ \Plugins\View\Provider::class, // still enabled — the // is AFTER it + \\ ]) + \\ ->build(); + ; + + var aliases: std.ArrayList(Alias) = .empty; + defer aliases.deinit(a); + var out: std.ArrayList(Enabled) = .empty; + defer out.deinit(a); + try collectEnabled(a, src, aliases.items, &out); + + try std.testing.expectEqual(@as(usize, 2), out.items.len); + try std.testing.expect(isEnabled(out.items, "Logger")); + try std.testing.expect(isEnabled(out.items, "View")); + try std.testing.expect(!isEnabled(out.items, "User")); + try std.testing.expect(!isEnabled(out.items, "Auth")); +} diff --git a/tools/src/lib/plugin_deps.zig b/tools/src/lib/plugin_deps.zig index ec3e752..a5279ac 100644 --- a/tools/src/lib/plugin_deps.zig +++ b/tools/src/lib/plugin_deps.zig @@ -28,7 +28,7 @@ const Enabled = boot.Enabled; pub const Provider = struct { located: Located, solves: ?[]const u8 = null, - requires: []const []const u8 = &.{}, + requires: []const sources.Requirement = &.{}, pub fn name(self: Provider) []const u8 { return self.located.name; @@ -56,10 +56,33 @@ pub fn catalogue( if (findByName(out.items, p) != null) continue; // first source wins (project shadows kernel) const meta = try sources.readModuleMeta(allocator, io, dir, p); + + // Module-level and route-level requires are MERGED here. + // + // The kernel distinguishes them at runtime — a route-level domain + // is seeded into that one request's graph — but not at boot: a + // route naming a domain no registered module solves fails the whole + // build. So for installing and enabling they are one list. Reading + // only the module-level one is why a project with Tenancy booted + // straight into "Route [GET /tenants] requires unknown module + // domain [http.pageflow]": the walk fetched Tenancy's two declared + // dependencies and none of the four its routes need. + var reqs: std.ArrayList(sources.Requirement) = .empty; + if (meta) |m| { + for (m.requires) |r| try reqs.append(allocator, r); + for (m.route_requires) |r| { + var seen = false; + for (reqs.items) |e| { + if (std.mem.eql(u8, e.domain, r.domain)) seen = true; + } + if (!seen) try reqs.append(allocator, r); + } + } + try out.append(allocator, .{ .located = .{ .name = p, .source = src, .dir = dir }, .solves = if (meta) |m| m.solves else null, - .requires = if (meta) |m| m.requires else &.{}, + .requires = reqs.items, }); } } @@ -106,7 +129,8 @@ fn visit( missing: *std.ArrayList([]const u8), rootFolder: []const u8, ) !void { - for (p.requires) |domain| { + for (p.requires) |req| { + const domain = req.domain; if (providerForDomain(cat, domain)) |dep| { // Don't list the plugin being enabled, and de-dupe. if (util.eqlIgnoreCase(dep.located.name, rootFolder)) continue; @@ -144,8 +168,8 @@ pub fn enabledDependentsOf( /// Does `p` require `domain` directly or transitively (following providers)? fn dependsOnDomain(cat: []const Provider, p: Provider, domain: []const u8, skip: []const u8) bool { for (p.requires) |req| { - if (std.mem.eql(u8, req, domain)) return true; - const next = providerForDomain(cat, req) orelse continue; + if (std.mem.eql(u8, req.domain, domain)) return true; + const next = providerForDomain(cat, req.domain) orelse continue; if (util.eqlIgnoreCase(next.located.name, skip)) continue; if (dependsOnDomain(cat, next, domain, skip)) return true; } diff --git a/tools/src/lib/plugin_domains.zig b/tools/src/lib/plugin_domains.zig new file mode 100644 index 0000000..b79b70e --- /dev/null +++ b/tools/src/lib/plugin_domains.zig @@ -0,0 +1,266 @@ +//! Which plugin provides a given domain. +//! +//! A plugin declares its dependencies in module.json as the DOMAINS it needs +//! ("crypto.services", "cache.redis") — never as repository names. That is the +//! right thing for the framework: a module depends on a capability, not on who +//! happens to ship it. It leaves the installer with a lookup to do, and the +//! lookup cannot be guessed: +//! +//! crypto.services → hkm-plugin-crypto the first segment works +//! logging.application → hkm-plugin-logger …and here it does not +//! http.client → hkm-plugin-http-client +//! http.cookies → hkm-plugin-cookie four different plugins +//! http.pageflow → hkm-plugin-pageflow share one first segment +//! http.security_filters → hkm-plugin-security-filters +//! +//! Thirteen of the twenty-eight first-party domains do not match their +//! repository name, and "http" alone is ambiguous four ways — so a naming +//! convention cannot carry this. It is resolved from three sources, most +//! trustworthy first. + +const std = @import("std"); +const sources = @import("plugin_sources.zig"); +const deps = @import("plugin_deps.zig"); +const util = @import("util.zig"); +const pregistry = @import("plugin_registry.zig"); + +const Io = std.Io; +const EnvMap = std.process.Environ.Map; + +pub const Mapping = struct { domain: []const u8, folder: []const u8 }; + +/// How a domain was resolved — worth reporting, because the three sources carry +/// different weight and a seeded guess can be stale in a way disk never is. +pub const Origin = enum { + /// Read from an installed plugin's own module.json. Cannot be wrong. + installed, + /// From the built-in table below. Right for first-party plugins, and only + /// as current as the release of this tool. + seed, + /// Declared by the plugin that needs it, as a repo on its requires[] entry. + /// The only source that can reach a plugin nothing else has heard of. + declared, +}; + +pub const Resolution = struct { + folder: []const u8, + origin: Origin, + /// Set only for `.declared` — where to fetch it, and at which ref. + repo: []const u8 = "", + version: []const u8 = "", +}; + +/// First-party domain → plugin folder. +/// +/// Generated from the plugin repositories rather than typed, and ordered by +/// domain. It exists for one case: resolving a dependency of a plugin that is +/// not installed yet, where there is no module.json on disk to read. Once a +/// plugin IS installed its own manifest takes over, which is why a stale entry +/// here degrades to a wrong first guess rather than a wrong answer. +/// +/// Adding a first-party plugin means adding its line. `hkm plugins domains` +/// prints the table, and the test at the bottom of this file keeps it honest. +pub const seed = [_]Mapping{ + .{ .domain = "audit.trail", .folder = "Audit" }, + .{ .domain = "auth.identity", .folder = "Auth" }, + .{ .domain = "auth.social", .folder = "SocialAuth" }, + .{ .domain = "authorization.policy", .folder = "Authorization" }, + .{ .domain = "cache.redis", .folder = "RedisCache" }, + .{ .domain = "crypto.services", .folder = "Crypto" }, + .{ .domain = "database.management", .folder = "Database" }, + .{ .domain = "dev.tooling", .folder = "DevTools" }, + .{ .domain = "edge.routing", .folder = "Edge" }, + .{ .domain = "feedback.management", .folder = "Feedback" }, + .{ .domain = "http.client", .folder = "HttpClient" }, + .{ .domain = "http.cookies", .folder = "Cookie" }, + .{ .domain = "http.pageflow", .folder = "Pageflow" }, + .{ .domain = "http.security_filters", .folder = "SecurityFilters" }, + .{ .domain = "i18n.translation", .folder = "I18n" }, + .{ .domain = "logging.application", .folder = "Logger" }, + .{ .domain = "mail.delivery", .folder = "Mail" }, + .{ .domain = "oauth.server", .folder = "OAuth2" }, + .{ .domain = "seo.management", .folder = "SiteSEO" }, + .{ .domain = "session.management", .folder = "Session" }, + .{ .domain = "storage.local", .folder = "Storage" }, + .{ .domain = "system.commands", .folder = "Commands" }, + .{ .domain = "tenancy.routing", .folder = "Tenancy" }, + .{ .domain = "tenant.settings", .folder = "Settings" }, + .{ .domain = "user.management", .folder = "User" }, + .{ .domain = "validation.rules", .folder = "Validation" }, + .{ .domain = "view.rendering", .folder = "View" }, + .{ .domain = "vite.manifest", .folder = "ViteManifest" }, +}; + +/// The plugin folder that provides `domain`, or null when nothing knows. +/// +/// Consults installed plugins first: their module.json is the authority, it +/// covers third-party plugins the table has never heard of, and it is right +/// even when the table is out of date. +pub fn resolve( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + projectRoot: []const u8, + domain: []const u8, +) !?Resolution { + const srcs = try sources.discoverSources(allocator, io, env, projectRoot); + var cat: std.ArrayList(deps.Provider) = .empty; + try deps.catalogue(allocator, io, srcs, &.{ .project, .kernel }, &cat); + + if (deps.providerForDomain(cat.items, domain)) |p| { + return .{ .folder = p.located.name, .origin = .installed }; + } + + for (seed) |m| { + if (std.mem.eql(u8, m.domain, domain)) return .{ .folder = m.folder, .origin = .seed }; + } + + return null; +} + +/// As `resolve`, but against a catalogue the caller already built — for loops +/// that would otherwise re-scan every plugins directory once per domain. +pub fn resolveIn(cat: []const deps.Provider, domain: []const u8) ?Resolution { + if (deps.providerForDomain(cat, domain)) |p| { + return .{ .folder = p.located.name, .origin = .installed }; + } + for (seed) |m| { + if (std.mem.eql(u8, m.domain, domain)) return .{ .folder = m.folder, .origin = .seed }; + } + return null; +} + +/// Resolve a requirement, allowing the repo it declares to answer for domains +/// nothing else knows. +/// +/// The declared repo is consulted LAST, after disk and the built-in table, and +/// that ordering is a security property rather than a preference. A plugin can +/// name any URL it likes; if a declaration outranked the curated table, then +/// installing any plugin could silently redirect `crypto.services` — a +/// first-party domain, on the trusted path, in the shared kernel directory — to +/// a repository of its author's choosing. Consulting it last means a +/// declaration can only ever REACH a domain the platform has no answer for, +/// which is the case it exists to serve. +/// +/// `overridden` is set when a declaration was ignored because the platform +/// already had an answer, so the caller can say so rather than diverge silently. +pub fn resolveRequirement( + cat: []const deps.Provider, + req: sources.Requirement, + overridden: *bool, +) ?Resolution { + overridden.* = false; + + if (resolveIn(cat, req.domain)) |hit| { + if (req.repo.len > 0 and hit.origin == .seed) overridden.* = true; + return hit; + } + + if (req.repo.len == 0) return null; + + // Nothing else knows this domain. The folder name comes from the repo, and + // is corrected from the plugin's own module.json once it is fetched. + const folder = pregistry.nameFromRemote(std.heap.page_allocator, req.repo) catch return null; + return .{ + .folder = folder, + .origin = .declared, + .repo = req.repo, + .version = req.version, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "the seed table has no duplicate or empty entries" { + // A duplicated domain would resolve to whichever line came first, silently. + for (seed, 0..) |a, i| { + try std.testing.expect(a.domain.len > 0); + try std.testing.expect(a.folder.len > 0); + for (seed[i + 1 ..]) |b| { + try std.testing.expect(!std.mem.eql(u8, a.domain, b.domain)); + } + } +} + +test "every seeded folder is the canonical spelling of itself" { + // The folder has to match the PSR-4 namespace exactly, so a seed entry that + // is not already canonical would install to a directory the autoloader + // never looks in — the failure this whole lookup exists to prevent. + const a = std.testing.allocator; + for (seed) |m| { + const canon = try pregistry.canonicalName(a, m.folder); + defer a.free(canon); + try std.testing.expectEqualStrings(m.folder, canon); + } +} + +test "the domains a convention could not reach are the ones that matter" { + // Guards the premise of this file: if these ever became derivable from + // their domain, the table would be dead weight. They are not. + const cases = [_]Mapping{ + .{ .domain = "logging.application", .folder = "Logger" }, + .{ .domain = "cache.redis", .folder = "RedisCache" }, + .{ .domain = "http.cookies", .folder = "Cookie" }, + .{ .domain = "http.security_filters", .folder = "SecurityFilters" }, + .{ .domain = "oauth.server", .folder = "OAuth2" }, + .{ .domain = "seo.management", .folder = "SiteSEO" }, + }; + for (cases) |c| { + const got = resolveIn(&.{}, c.domain) orelse return error.Unresolved; + try std.testing.expectEqualStrings(c.folder, got.folder); + try std.testing.expect(got.origin == .seed); + } +} + +test "a declared repo reaches an unknown domain but never overrides a known one" { + const sources_mod = @import("plugin_sources.zig"); + var overridden = false; + + // The case it exists for: nothing on disk, nothing in the table. + const unknown = sources_mod.Requirement{ + .domain = "telemetry.exotic", + .repo = "https://github.com/acme/hkm-plugin-telemetry.git", + .version = "^1.2", + }; + const hit = resolveRequirement(&.{}, unknown, &overridden) orelse return error.Unresolved; + try std.testing.expect(hit.origin == .declared); + try std.testing.expectEqualStrings("Telemetry", hit.folder); + try std.testing.expectEqualStrings("^1.2", hit.version); + try std.testing.expect(!overridden); + + // The case that must NOT work: a plugin cannot redirect a platform domain + // to a repository of its choosing — that domain installs into the SHARED + // kernel directory, where it would affect every project on the machine. + const hijack = sources_mod.Requirement{ + .domain = "crypto.services", + .repo = "https://github.com/attacker/hkm-plugin-crypto.git", + }; + const safe = resolveRequirement(&.{}, hijack, &overridden) orelse return error.Unresolved; + try std.testing.expect(safe.origin == .seed); + try std.testing.expectEqualStrings("Crypto", safe.folder); + try std.testing.expectEqualStrings("", safe.repo); + // …and the caller is told, so the divergence is never silent. + try std.testing.expect(overridden); + + // No repo and no answer: unresolved, not guessed. + const bare = sources_mod.Requirement{ .domain = "nothing.knows.this" }; + try std.testing.expect(resolveRequirement(&.{}, bare, &overridden) == null); +} + +test "a plugin's route-level requires are dependencies too" { + // Regression: Tenancy declares database.management + i18n.translation at + // module level, and http.pageflow / auth.identity / user.management / + // audit.trail on individual ROUTES. Reading only the module level installed + // two of six, and the project failed to boot on + // Route [GET /tenants] requires unknown module domain [http.pageflow] + // because CompileRouteManifestStage enforces route requires at BUILD time. + const sources_mod = @import("plugin_sources.zig"); + + const route_only = [_]sources_mod.Requirement{.{ .domain = "http.pageflow" }}; + var overridden = false; + + const hit = resolveRequirement(&.{}, route_only[0], &overridden) orelse return error.Unresolved; + try std.testing.expectEqualStrings("Pageflow", hit.folder); +} diff --git a/tools/src/lib/plugin_env.zig b/tools/src/lib/plugin_env.zig new file mode 100644 index 0000000..6e2201f --- /dev/null +++ b/tools/src/lib/plugin_env.zig @@ -0,0 +1,259 @@ +//! Seed a plugin's declared env vars into the project's `.env`. +//! +//! Every plugin lists the environment it reads in `module.json` `config[]`, and +//! the kernel FAILS THE BOOT when a required one is absent (ValidateConfigStage). +//! Before this, enabling a plugin left the operator to discover that list from a +//! stack trace, one variable per boot attempt. Enabling now writes the whole set +//! into `.env` at once, so the knobs are visible where you configure things. +//! +//! Three shapes, and the difference between them is load-bearing: +//! +//! default present KEY=value written ACTIVE — the documented default +//! required, no default KEY= written ACTIVE but EMPTY +//! optional, no default # KEY= written COMMENTED +//! +//! An empty value is not the same as an absent one. ValidateConfigStage treats +//! `''` as missing (`$value === null || $value === ''`), so a required var +//! written empty still fails the boot loudly until someone supplies a real +//! secret — which is what should happen. An OPTIONAL var written empty would +//! instead be read as the string `''` and silently beat the plugin's own +//! internal default, so those stay commented: present and documented, but not +//! overriding anything. +//! +//! Nothing already in the file is ever touched. Re-enabling a plugin, or +//! enabling a second one that shares a variable, adds only what is missing. + +const std = @import("std"); +const util = @import("util.zig"); + +const Io = std.Io; +const Dir = std.Io.Dir; + +/// One declared variable from a plugin's `module.json` `config[]`. +pub const Var = struct { + key: []const u8, + /// "string" | "int" | "float" | "bool" — informational, written as a comment. + type_name: ?[]const u8 = null, + required: bool = true, + /// Rendered default. Null when the plugin declared none. + default: ?[]const u8 = null, +}; + +pub const Seeded = struct { + /// Variables written into the file. + added: []const Var, + /// Variables already present (in any form) and therefore left alone. + skipped: usize, + /// The file that was (or would be) written. + path: []const u8, + /// True when the .env did not exist and was created. + created: bool, +}; + +/// Read `config[]` out of a plugin's module.json. +/// +/// Accepts both declared shapes: a bare string (`"APP_KEY"`, required, untyped) +/// and the object form (`{ "key": …, "type": …, "required": …, "default": … }`). +pub fn readVars( + allocator: std.mem.Allocator, + io: Io, + pluginsDir: []const u8, + name: []const u8, +) ![]const Var { + const path = try std.fmt.allocPrint(allocator, "{s}/{s}/module.json", .{ pluginsDir, name }); + const content = Dir.cwd().readFileAlloc(io, path, allocator, .limited(4 * 1024 * 1024)) catch return &.{}; + + const trimmed = std.mem.trim(u8, content, " \t\r\n"); + if (trimmed.len == 0) return &.{}; + + const parsed = std.json.parseFromSliceLeaky(std.json.Value, allocator, trimmed, .{}) catch return &.{}; + if (parsed != .object) return &.{}; + + const config = parsed.object.get("config") orelse return &.{}; + if (config != .array) return &.{}; + + var out: std.ArrayList(Var) = .empty; + for (config.array.items) |entry| { + switch (entry) { + .string => |s| { + if (s.len == 0) continue; + try out.append(allocator, .{ .key = s }); + }, + .object => |o| { + const key = switch (o.get("key") orelse continue) { + .string => |s| s, + else => continue, + }; + if (key.len == 0) continue; + + try out.append(allocator, .{ + .key = key, + .type_name = switch (o.get("type") orelse std.json.Value{ .null = {} }) { + .string => |s| s, + else => null, + }, + // Absent means required — same default the kernel applies. + .required = switch (o.get("required") orelse std.json.Value{ .bool = true }) { + .bool => |b| b, + else => true, + }, + .default = try renderDefault(allocator, o.get("default")), + }); + }, + else => {}, + } + } + + return out.items; +} + +/// Render a JSON default as it should appear on the right of `KEY=`. +/// +/// An explicit JSON `null` is NOT a default — it means "no value", which is +/// exactly the state an absent key already expresses. +fn renderDefault(allocator: std.mem.Allocator, value: ?std.json.Value) !?[]const u8 { + const v = value orelse return null; + return switch (v) { + .string => |s| s, + .integer => |i| try std.fmt.allocPrint(allocator, "{d}", .{i}), + .float => |f| try std.fmt.allocPrint(allocator, "{d}", .{f}), + .bool => |b| if (b) "true" else "false", + .null => null, + // An array or object cannot be expressed in a dotenv value. + else => null, + }; +} + +/// True when `key` already appears in the file, whether set or commented out. +/// +/// A commented entry counts as present on purpose: it means a previous seed (or +/// a person) already put that knob in front of the operator, and writing it a +/// second time would grow the file every time a plugin is re-enabled. +pub fn hasKey(content: []const u8, key: []const u8) bool { + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |raw| { + var line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + + // Look past a comment marker so `# KEY=` is recognised too. + while (line.len > 0 and (line[0] == '#' or line[0] == ' ' or line[0] == '\t')) { + line = line[1..]; + line = std.mem.trimStart(u8, line, " \t"); + } + if (line.len <= key.len) continue; + if (!std.mem.startsWith(u8, line, key)) continue; + + // Must be followed by '=' — otherwise APP_KEY would match APP_KEY_ID. + const rest = std.mem.trimStart(u8, line[key.len..], " \t"); + if (rest.len > 0 and rest[0] == '=') return true; + } + return false; +} + +/// Append every variable of `vars` that the project's `.env` does not already +/// mention, under a labelled block. Creates the file when absent. +pub fn seed( + allocator: std.mem.Allocator, + io: Io, + projectRoot: []const u8, + pluginName: []const u8, + vars: []const Var, + dry_run: bool, +) !Seeded { + const path = try std.fs.path.join(allocator, &.{ projectRoot, ".env" }); + + const existing = Dir.cwd().readFileAlloc(io, path, allocator, .limited(8 * 1024 * 1024)) catch ""; + const created = existing.len == 0 and !util.fileExists(io, path); + + var missing: std.ArrayList(Var) = .empty; + var skipped: usize = 0; + for (vars) |v| { + if (hasKey(existing, v.key)) { + skipped += 1; + } else { + try missing.append(allocator, v); + } + } + + if (missing.items.len == 0 or dry_run) { + return .{ .added = missing.items, .skipped = skipped, .path = path, .created = created }; + } + + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(allocator, existing); + + // Exactly one blank line before the block, whatever the file ended with. + if (out.items.len > 0) { + while (out.items.len > 0 and (out.items[out.items.len - 1] == '\n' or out.items[out.items.len - 1] == '\r')) { + _ = out.pop(); + } + try out.appendSlice(allocator, "\n\n"); + } + + try out.appendSlice(allocator, try std.fmt.allocPrint( + allocator, + "# ─── {s} ─────────────────────────────────────────────────\n" ++ + "# Declared in the plugin's module.json config[]. Added by `hkm plugins enable`.\n", + .{pluginName}, + )); + + for (missing.items) |v| { + if (v.default) |d| { + try out.appendSlice(allocator, try std.fmt.allocPrint(allocator, "{s}={s}\n", .{ v.key, d })); + continue; + } + + if (v.required) { + // Active but empty. The kernel counts '' as missing, so the boot + // still stops here until a real value is supplied — which is the + // correct outcome for something like an API key. + try out.appendSlice(allocator, try std.fmt.allocPrint( + allocator, + "{s}= # REQUIRED{s} — set this before booting\n", + .{ v.key, typeSuffix(allocator, v.type_name) }, + )); + continue; + } + + // Optional with no default: COMMENTED. Writing it empty would be read as + // the string '' and would quietly beat the plugin's own default. + try out.appendSlice(allocator, try std.fmt.allocPrint( + allocator, + "# {s}= # optional{s}\n", + .{ v.key, typeSuffix(allocator, v.type_name) }, + )); + } + + Dir.cwd().writeFile(io, .{ .sub_path = path, .data = out.items }) catch |e| return e; + + // A .env holds secrets; a freshly created one should not be world-readable. + if (created) util.chmod600(io, path); + + return .{ .added = missing.items, .skipped = skipped, .path = path, .created = created }; +} + +fn typeSuffix(allocator: std.mem.Allocator, type_name: ?[]const u8) []const u8 { + const t = type_name orelse return ""; + if (t.len == 0) return ""; + return std.fmt.allocPrint(allocator, " ({s})", .{t}) catch ""; +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +test "hasKey matches a set value" { + try std.testing.expect(hasKey("FOO=1\nBAR=2\n", "FOO")); +} + +test "hasKey matches a commented entry so re-enabling does not duplicate it" { + try std.testing.expect(hasKey("# FOO=\n", "FOO")); + try std.testing.expect(hasKey("#FOO=\n", "FOO")); +} + +test "hasKey does not match a longer key with the same prefix" { + try std.testing.expect(!hasKey("APP_KEY_ID=x\n", "APP_KEY")); + try std.testing.expect(hasKey("APP_KEY_ID=x\nAPP_KEY=y\n", "APP_KEY")); +} + +test "hasKey ignores a key mentioned only in prose" { + try std.testing.expect(!hasKey("# see APP_KEY for details\n", "APP_KEY")); +} diff --git a/tools/src/lib/plugin_git.zig b/tools/src/lib/plugin_git.zig index 95b2b0e..8b46765 100644 --- a/tools/src/lib/plugin_git.zig +++ b/tools/src/lib/plugin_git.zig @@ -142,6 +142,83 @@ pub fn resolveVersion( return null; } +/// A ref a plugin can be installed at. +pub const Ref = struct { + name: []const u8, + kind: enum { tag, branch }, + /// Null for a branch — a branch has no version, which is exactly why one + /// cannot be pinned in the shared version-keyed store. + version: ?semver.Version = null, + + pub fn isBranch(self: Ref) bool { + return self.kind == .branch; + } +}; + +/// Every branch head on the remote. +pub fn listBranches(allocator: std.mem.Allocator, io: Io, env: *EnvMap, url: []const u8) GitError![]const []const u8 { + const listing = capture(allocator, io, env, &.{ "git", "ls-remote", "--heads", "--refs", url }) orelse + return GitError.RemoteUnreachable; + + var out: std.ArrayList([]const u8) = .empty; + var lines = std.mem.splitScalar(u8, listing, '\n'); + while (lines.next()) |line| { + const marker = "refs/heads/"; + const idx = std.mem.indexOf(u8, line, marker) orelse continue; + const name = std.mem.trim(u8, line[idx + marker.len ..], " \t\r"); + if (name.len == 0) continue; + out.append(allocator, allocator.dupe(u8, name) catch continue) catch continue; + } + return out.items; +} + +/// Resolve what the user asked for into a concrete ref on the remote. +/// +/// `want` may be a semver constraint ("^1.2"), an exact tag ("v1.2.0"), or a +/// branch ("main", "develop"). They are tried in that order, and the order is +/// the point: a bare "1.0" is a CONSTRAINT, not a branch named 1.0, and +/// resolving it as a branch would quietly install something else entirely. +/// +/// Empty `want` means the newest release tag — never a branch. Falling back to +/// a branch when a plugin has no releases would install an unpinnable moving +/// target from a bare `hkm plugins install `. +pub fn resolveRef( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + url: []const u8, + want: []const u8, +) GitError!?Ref { + const w = std.mem.trim(u8, want, " \t\r\n"); + const tags = try listTags(allocator, io, env, url); + + if (w.len == 0) { + if (tags.len == 0) return null; + return .{ .name = tags[0].name, .kind = .tag, .version = tags[0].version }; + } + + // Exact tag first: an exact name is unambiguous, and checking it before the + // constraint means a tag whose name is not semver-shaped still installs. + for (tags) |t| { + if (std.mem.eql(u8, t.name, w)) return .{ .name = t.name, .kind = .tag, .version = t.version }; + } + + // Then as a semver constraint. + for (tags) |t| { + const ok = semver.satisfies(t.version, w) catch break; // not a constraint — try a branch + if (ok) return .{ .name = t.name, .kind = .tag, .version = t.version }; + } + + // Finally a branch. Only reached when it is neither a known tag nor a + // constraint any tag satisfies. + const branches = listBranches(allocator, io, env, url) catch &[_][]const u8{}; + for (branches) |b| { + if (std.mem.eql(u8, b, w)) return .{ .name = b, .kind = .branch }; + } + + return null; +} + /// Clone `url` into `dest` at `ref`. /// /// Shallow (`--depth 1`) and single-branch: a plugin is consumed, not developed, diff --git a/tools/src/lib/plugin_install.zig b/tools/src/lib/plugin_install.zig index 38d4790..68090d5 100644 --- a/tools/src/lib/plugin_install.zig +++ b/tools/src/lib/plugin_install.zig @@ -23,6 +23,9 @@ const semver = @import("semver.zig"); const banner = @import("banner.zig"); const prompt = @import("prompt.zig"); const util = @import("util.zig"); +const store = @import("plugin_store.zig"); +const run_cmd = @import("../commands/run.zig"); +const kernel = @import("kernel.zig"); const Dir = std.Io.Dir; const Io = std.Io; @@ -32,6 +35,10 @@ pub const Outcome = union(enum) { installed: lockfile.Entry, /// Already present at the requested version — nothing to do. up_to_date: lockfile.Entry, + /// The version was already in the shared store, so nothing was downloaded, + /// but this PROJECT gained it. Distinct from `up_to_date`, which reads as + /// "nothing happened" — and something did. + linked: lockfile.Entry, updated: struct { from: []const u8, to: lockfile.Entry }, /// Refused. `why` is a complete, user-facing sentence. refused: []const u8, @@ -46,11 +53,153 @@ pub const Options = struct { force: bool = false, /// Clone full history instead of --depth 1. full: bool = false, + /// Run the plugin's own test suite before installing it. + verify: bool = true, + /// Whether a failing suite may be escalated to the user. FALSE for + /// unattended callers (`hkm new`, CI): there is nobody to answer, and + /// installing a plugin whose tests fail because nothing could ask is how a + /// broken plugin reaches a project silently. + interactive: bool = true, + /// Explicit git remote, bypassing name→URL resolution. Set when the user + /// gave a URL instead of a plugin name, and when restoring a lock entry + /// that records where its plugin actually came from. + remote: []const u8 = "", + /// Skip the composer autoload refresh. For callers installing SEVERAL + /// plugins: the dump is a full classmap rebuild of the whole tree, so doing + /// it per plugin costs N rebuilds to reach the state one at the end gives. + /// A caller that sets this MUST call refreshAutoload itself when done, or + /// the plugins are on disk and invisible to PHP. + defer_autoload: bool = false, }; -/// Directory a plugin would be installed into for this project. -pub fn targetDir(allocator: std.mem.Allocator, projectRoot: []const u8, name: []const u8) ![]const u8 { - return std.fs.path.join(allocator, &.{ projectRoot, "plugins", name }); +/// The version-keyed store — always `/plugin-store//`. +/// +/// One physical copy per (plugin, version), reused by every project that pins +/// that version — so two projects on the same release share the download, and a +/// project on an older release keeps its own copy rather than being dragged +/// forward. A project references its pin with a symlink from its own plugins/, +/// which is what lets the PROJECT's composer resolve it and what makes the +/// pinned version per-project rather than machine-wide. +/// +/// Deliberately NOT under `/plugins/`: that path is PSR-4 mapped by the +/// kernel's composer, and a `.store` directory inside it would be scanned as if +/// every version were a plugin. +pub fn storeDir( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + projectRoot: []const u8, + name: []const u8, + version: []const u8, +) !?[]const u8 { + return storeDirFor(allocator, io, env, projectRoot, name, version, ""); +} + +/// As `storeDir`, with the first-party decision already made — see `pluginsRootFor`. +pub fn storeDirFor( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + projectRoot: []const u8, + name: []const u8, + version: []const u8, + remote: []const u8, +) !?[]const u8 { + // ALWAYS the global cache, first-party or not. + // + // It used to follow the install target, so a third-party plugin was stored + // under the PROJECT — and every other project on the machine wanting the + // same plugin at the same version downloaded and kept its own copy. That + // defeats the point: one copy per (plugin, version, origin), shared. + // + // Storing centrally does NOT change which composer owns the plugin. What + // composer sees is the LINK in /plugins/, and that is still + // per project — the store is only where the bytes live. + const fallback = try kernelFallbackRoot(allocator, io, env, projectRoot); + const path = try store.entryDir(allocator, env, fallback, name, version, remote); + return path; +} + +/// The kernel root, used only as the store location of last resort — a machine +/// with no HOME and no configured cache directory. +fn kernelFallbackRoot(allocator: std.mem.Allocator, io: Io, env: *EnvMap, projectRoot: []const u8) ![]const u8 { + const plugins = try pluginsRootFor(allocator, io, env, projectRoot, true); + return util.parentOf(plugins) orelse projectRoot; +} + +/// Where first-party plugins are installed: the KERNEL's plugins directory. +/// +/// Not the project's. Plugins under the AlfaCode-Team org are shared +/// infrastructure — one copy serves every project on the machine, which is what +/// plugin_sources already calls the "kernel" source and treats as the +/// contributor-protected one. Installing per project would give each its own +/// copy of the same nineteen packages and no single place to update them. +/// +/// Falls back to the project's own plugins/ when no kernel root can be resolved +/// (a bare checkout, or a project outside a kernel install), so the command +/// still works rather than failing on a machine that has no /opt install. +pub fn targetDir( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + projectRoot: []const u8, + name: []const u8, +) ![]const u8 { + const dir = try pluginsRoot(allocator, io, env, projectRoot); + return std.fs.path.join(allocator, &.{ dir, name }); +} + +/// The directory plugins are installed into, created if absent. +pub fn pluginsRoot( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + projectRoot: []const u8, +) ![]const u8 { + return pluginsRootFor(allocator, io, env, projectRoot, pregistry.isFirstParty(env)); +} + +/// As `pluginsRoot`, but with the first-party decision already made. +/// +/// Separate because an explicit remote answers that question by itself, and the +/// environment-based answer would be wrong for it: `hkm plugins install +/// https://github.com/AlfaCode-Team/hkm-plugin-logger.git` must reach the same +/// shared kernel directory as `hkm plugins install logger`, and a URL pointing +/// anywhere else must not. +pub fn pluginsRootFor( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + projectRoot: []const u8, + first_party: bool, +) ![]const u8 { + // Third-party plugins belong to the project that asked for them, and are + // resolved by the PROJECT's composer. Only first-party packages go into the + // shared kernel. + if (!first_party) { + return std.fs.path.join(allocator, &.{ projectRoot, "plugins" }); + } + + if (try sources.kernelPluginsDir(allocator, io, env, projectRoot)) |kd| return kd; + + // kernelPluginsDir only returns a path that already EXISTS. A fresh kernel + // install has no plugins/ yet, so derive it from the kernel root and let the + // caller create it. + if (env.get("HKM_KERNEL_HOME")) |h| { + if (h.len > 0) return std.fmt.allocPrint(allocator, "{s}/plugins", .{util.trimSlash(h)}); + } + if (try kernelRootFromCli(allocator, io, env)) |root| { + return std.fmt.allocPrint(allocator, "{s}/plugins", .{root}); + } + + return std.fs.path.join(allocator, &.{ projectRoot, "plugins" }); +} + +/// Kernel root derived from the resolved CLI path (`/bin/hkm`). +fn kernelRootFromCli(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !?[]const u8 { + const r = kernel.resolve(allocator, io, env) catch return null; + const bin = std.fs.path.dirname(r.path) orelse return null; + return std.fs.path.dirname(bin); } /// Read the kernel constraint out of an already-fetched plugin directory. @@ -84,6 +233,92 @@ fn gateMessage(allocator: std.mem.Allocator, env: *EnvMap, name: []const u8, con }; } + +/// Outcome of running a freshly fetched plugin's own test suite. +const Verdict = enum { + passed, + failed, + /// No test suite, or no runner after a successful dependency install. + unavailable, + /// Dependencies could not be resolved, so the suite could not be reached. + /// Distinct from `unavailable` because the causes and the fix differ: this + /// one is almost always the environment (an unwritable composer cache, no + /// network, missing auth), not the plugin. + blocked, +}; + +/// Install the plugin's dev dependencies and run its tests, in `dir`. +/// +/// A packaged kernel ships no phpunit — install.sh runs `composer install +/// --no-dev` — so the runner has to come from the plugin itself. That costs a +/// composer install per plugin, which is why `verify` is a switch rather than +/// unconditional. +fn runPluginTests(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dir: []const u8, name: []const u8) Verdict { + const tests_dir = std.fs.path.join(allocator, &.{ dir, "tests" }) catch return .unavailable; + if (!util.dirExists(Dir.cwd(), io, tests_dir)) return .unavailable; // nothing to run + + prompt.muted(std.fmt.allocPrint(allocator, "{s}: resolving test dependencies…", .{name}) catch name); + + var composer = [_][]const u8{ "composer", "install", "--no-interaction", "--no-progress", "--working-dir", dir }; + const cinstall = run_cmd.spawnWait(io, env, &composer) catch return .unavailable; + if (cinstall != 0) return .blocked; + + const phpunit = std.fs.path.join(allocator, &.{ dir, "vendor", "bin", "phpunit" }) catch return .unavailable; + if (!util.fileExists(io, phpunit)) return .unavailable; + + prompt.muted(std.fmt.allocPrint(allocator, "{s}: running tests…", .{name}) catch name); + + // Paths are passed EXPLICITLY rather than relying on the working directory: + // hkm runs from wherever the user invoked it, so phpunit found neither a + // phpunit.xml nor a test path and simply printed its own usage — which the + // exit code then reported as a failure. + var run = [_][]const u8{ phpunit, "--no-coverage", "--do-not-cache-result", "--bootstrap", "", tests_dir }; + const autoload = std.fs.path.join(allocator, &.{ dir, "vendor", "autoload.php" }) catch return .unavailable; + run[4] = autoload; + const code = run_cmd.spawnWait(io, env, &run) catch return .unavailable; + return if (code == 0) .passed else .failed; +} + +/// Strip everything a consumer does not need from an installed plugin. +/// +/// tests/ and vendor/ are development artefacts: vendor/ here holds the plugin's +/// DEV dependencies (phpunit and friends) pulled purely to run the suite, and +/// leaving it would shadow the kernel's own autoloader with a second copy of +/// shared packages. Removed after verification, never before — deleting the +/// tests first would make the verification impossible. +fn stripDevArtefacts(io: Io, allocator: std.mem.Allocator, dir: []const u8) void { + for ([_][]const u8{ "tests", "vendor", "composer.lock", "phpunit.xml", "phpunit.xml.dist" }) |entry| { + const path = std.fs.path.join(allocator, &.{ dir, entry }) catch continue; + Dir.cwd().deleteTree(io, path) catch {}; + } +} + +/// Make the kernel's autoloader aware of a newly installed plugin. +/// +/// The kernel maps `Plugins\` to its plugins/ directory, so a new folder is only +/// discoverable once the classmap is regenerated. +pub fn refreshAutoload(allocator: std.mem.Allocator, io: Io, env: *EnvMap, pluginsDir: []const u8) void { + const kernel_root = util.parentOf(pluginsDir) orelse return; + const composer_json = std.fs.path.join(allocator, &.{ kernel_root, "composer.json" }) catch return; + if (!util.fileExists(io, composer_json)) return; + + var argv = [_][]const u8{ "composer", "dump-autoload", "--no-interaction", "--working-dir", kernel_root }; + _ = run_cmd.spawnWait(io, env, &argv) catch {}; +} + +/// Refresh every composer that could own a plugin directory for this project. +/// +/// The counterpart to `Options.defer_autoload`: call once after a batch. +pub fn refreshAllAutoload(allocator: std.mem.Allocator, io: Io, env: *EnvMap, projectRoot: []const u8) void { + const project_plugins = std.fs.path.join(allocator, &.{ projectRoot, "plugins" }) catch return; + refreshAutoload(allocator, io, env, project_plugins); + + const kernel_plugins = pluginsRootFor(allocator, io, env, projectRoot, true) catch return; + if (!std.mem.eql(u8, kernel_plugins, project_plugins)) { + refreshAutoload(allocator, io, env, kernel_plugins); + } +} + /// Install a plugin into `/plugins/` from its git remote. /// /// Idempotent: an already-present plugin at the requested version reports @@ -100,15 +335,40 @@ pub fn install( return .{ .refused = "git is not installed or not on PATH — it is required to fetch plugins." }; } - const remote = try pregistry.remoteFor(allocator, env, name); - const dest = try targetDir(allocator, projectRoot, name); - const pluginsDir = try std.fs.path.join(allocator, &.{ projectRoot, "plugins" }); + // An explicit remote wins over name→URL resolution: it is how a fork, a + // private mirror or a plugin that was never in the registry gets installed, + // and how a lock entry is restored from wherever its plugin came from. + const remote = if (opts.remote.len > 0) + opts.remote + else + try pregistry.remoteFor(allocator, env, name); + + // Where it lands follows the REMOTE, not the environment — see pluginsRootFor. + const first_party = if (opts.remote.len > 0) + pregistry.remoteIsFirstParty(env, opts.remote) + else + pregistry.isFirstParty(env); - const already = git.isRepo(io, dest, allocator); + const pluginsDir = try pluginsRootFor(allocator, io, env, projectRoot, first_party); + + // The DIRECTORY must match the PSR-4 namespace, not whatever the user + // typed: `install crypto` has to produce plugins/Crypto or the autoloader + // will never find Plugins\Crypto\Provider. + var folder = try pregistry.canonicalName(allocator, name); + var dest = try std.fs.path.join(allocator, &.{ pluginsDir, folder }); + + // A REAL working copy at dest — not a link into the shared store. + // + // The distinction decides whether the in-place update path below may run, + // and getting it wrong is destructive: `git checkout` through a store + // symlink rewrites the shared (plugin, version) directory that every other + // project pinning that version is linked to. A managed link is re-pointed, + // never checked out. + const already = !util.isSymlink(io, dest) and git.isRepo(io, dest, allocator); // Resolve the constraint to a concrete tag BEFORE fetching, so we never // install from a moving branch. - const tag = git.resolveVersion(allocator, io, env, remote, opts.version) catch |e| { + const tag = git.resolveRef(allocator, io, env, remote, opts.version) catch |e| { return .{ .refused = try std.fmt.allocPrint( allocator, "{s}: {s} ({s})", @@ -123,7 +383,7 @@ pub fn install( const msg = if (opts.version.len > 0) try std.fmt.allocPrint( allocator, - "{s} has no release matching '{s}' on {s}. Run `hkm plugins versions {s}` to see what exists.", + "{s} has no tag, version or branch matching '{s}' on {s}. Run `hkm plugins versions {s}` to see what exists.", .{ name, opts.version, remote, name }, ) else @@ -135,15 +395,81 @@ pub fn install( return .{ .refused = msg }; }; + // ── Already in the store at this version? ─────────────────────────────── + // + // Two projects pinning the same release must not download it twice. The + // store is keyed by (plugin, version), so a second project just links at + // what is already there. + // A branch is a moving target with no version, and the store's whole + // premise is that a (plugin, version) directory never changes. Two projects + // tracking "main" at different commits would collide on one path, and the + // second would silently get the first's checkout. Branch installs therefore + // use the flat per-project layout instead. + const storable = !want.isBranch(); + + // The hashed entry, or a pre-hash one left by an older layout. + // + // Migrated caches keep their bare `` directory names, and projects + // are symlinked straight at those paths. Looking only for the hashed name + // would miss every one of them — re-downloading the entire cache once, and + // leaving the old copies orphaned but still linked. Accepting the legacy + // name (without renaming it, which would break those links) means the two + // layouts coexist and new installs converge on the hashed one. + const store_hit: ?[]const u8 = if (!storable) null else blk: { + if (try storeDirFor(allocator, io, env, projectRoot, folder, want.name, remote)) |hashed| { + if (util.dirExists(Dir.cwd(), io, hashed)) break :blk hashed; + } + if (try storeDirFor(allocator, io, env, projectRoot, folder, want.name, "")) |legacy| { + if (util.dirExists(Dir.cwd(), io, legacy)) break :blk legacy; + } + break :blk null; + }; + + if (store_hit) |store_path| { + { + const link = try std.fs.path.join(allocator, &.{ projectRoot, "plugins", folder }); + const had_it = util.dirExists(Dir.cwd(), io, link); + + // WHERE the existing link points, not merely that one exists. + // + // Testing only for existence reported "up to date" for a link that + // was about to be repointed at a different version — or, after an + // install from a fork's URL, at a different plugin entirely. The + // relink happened either way; only the message was wrong, which is + // the worst of both. + const current = if (had_it) util.linkTarget(allocator, io, link) else null; + const unchanged = if (current) |c| std.mem.eql(u8, c, store_path) else false; + + if (!opts.dry_run and !unchanged) try linkIntoProject(allocator, io, projectRoot, folder, store_path); + + const entry = lockfile.Entry{ + .name = folder, + .remote = remote, + .version = want.name, + .commit = git.headCommit(allocator, io, env, store_path) orelse "", + .kernel = constraintOf(allocator, io, util.parentOf(store_path) orelse store_path, std.fs.path.basename(store_path)) orelse "", + }; + + if (unchanged) return .{ .up_to_date = entry }; + if (!had_it) return .{ .linked = entry }; + + // Repointed. The version it came FROM is the store_path directory the old + // link named; a real directory (a pre-store_path install) has no version + // in its path, so say so rather than inventing one. + const from = if (current) |c| store.versionOf(std.fs.path.basename(c)) else "an unmanaged copy"; + return .{ .updated = .{ .from = from, .to = entry } }; + } + } + if (already) { const current = git.headTag(allocator, io, env, dest) orelse ""; if (std.mem.eql(u8, current, want.name)) { return .{ .up_to_date = .{ - .name = name, + .name = folder, .remote = remote, .version = want.name, .commit = git.headCommit(allocator, io, env, dest) orelse "", - .kernel = constraintOf(allocator, io, pluginsDir, name) orelse "", + .kernel = constraintOf(allocator, io, pluginsDir, folder) orelse "", } }; } @@ -158,10 +484,10 @@ pub fn install( if (opts.dry_run) { return .{ .updated = .{ .from = current, .to = .{ - .name = name, + .name = folder, .remote = remote, .version = want.name, - .kernel = constraintOf(allocator, io, pluginsDir, name) orelse "", + .kernel = constraintOf(allocator, io, pluginsDir, folder) orelse "", } } }; } @@ -183,7 +509,7 @@ pub fn install( .remote = remote, .version = want.name, .commit = git.headCommit(allocator, io, env, dest) orelse "", - .kernel = constraintOf(allocator, io, pluginsDir, name) orelse "", + .kernel = constraintOf(allocator, io, pluginsDir, folder) orelse "", } } }; } @@ -209,30 +535,203 @@ pub fn install( const staged_name = std.fs.path.basename(staging); const constraint = constraintOf(allocator, io, staged_parent, staged_name); + // The plugin's own module.json outranks the repository name. + // + // Installing by name, the two always agree. Installing by URL they need + // not: a fork called `our-logger`, or a repo that simply spells its name + // differently, would land in plugins/OurLogger while its classes live in + // Plugins\Logger — present on disk, invisible to PSR-4, and surfacing much + // later as "Class does not exist". Correct it here, before anything moves. + if (try sources.readModuleMeta(allocator, io, staged_parent, staged_name)) |meta| { + if (meta.name) |declared_raw| if (declared_raw.len > 0) { + const declared = try pregistry.canonicalName(allocator, declared_raw); + if (!std.mem.eql(u8, declared, folder)) { + prompt.muted(try std.fmt.allocPrint( + allocator, + "{s}: the repository declares itself as '{s}' — installing under that name.", + .{ folder, declared }, + )); + folder = declared; + dest = try std.fs.path.join(allocator, &.{ pluginsDir, folder }); + } + }; + } + if (try gateMessage(allocator, env, name, constraint)) |msg| { Dir.cwd().deleteTree(io, staging) catch {}; return .{ .refused = msg }; } + // ── Verify BEFORE the plugin lands in plugins/ ─────────────────────────── + // + // Run in the staging copy so a plugin whose tests fail never reaches the + // directory the bootstrap wires from. Verifying after the move would mean + // deciding what to do with a broken plugin that is already installed. + if (opts.verify) { + switch (runPluginTests(allocator, io, env, staging, name)) { + .passed => prompt.ok(try std.fmt.allocPrint(allocator, "{s}: tests passed", .{name})), + .unavailable => prompt.muted(try std.fmt.allocPrint( + allocator, + "{s}: no test suite to run", + .{name}, + )), + // Not the plugin's fault, and not something to fail the install + // over — but say WHY, because "could not verify" with no cause + // sends people looking at the plugin. + .blocked => { + prompt.warn(try std.fmt.allocPrint( + allocator, + "{s}: could not resolve test dependencies — installed WITHOUT verification.", + .{name}, + )); + prompt.muted(" usually: an unwritable composer cache, no network, or GitHub auth."); + prompt.muted(" if the cache is root-owned: sudo chown -R \"$USER\" ~/.cache/composer"); + }, + .failed => { + const accepted = opts.interactive and prompt.confirm( + io, + try std.fmt.allocPrint( + allocator, + "{s}: its tests FAILED. Install it anyway?", + .{name}, + ), + false, + ); + if (!accepted) { + Dir.cwd().deleteTree(io, staging) catch {}; + return .{ .refused = try std.fmt.allocPrint( + allocator, + "{s}: test suite failed — not installed.{s}", + .{ + name, + if (opts.interactive) + "" + else + " Nothing could ask, so it was skipped rather than installed unverified; re-run interactively to override.", + }, + ) }; + } + prompt.warn(try std.fmt.allocPrint( + allocator, + "{s}: installing despite failing tests, at your request.", + .{name}, + )); + }, + } + } + + // Only now that it is trusted: drop the development artefacts. + stripDevArtefacts(io, allocator, staging); + + // Land it in the version-keyed store when one is available, so the copy is + // shared; fall back to the flat plugins/ layout when it is not. + const final_dest = if (storable) blk_outer: { + const store_path = (try storeDirFor(allocator, io, env, projectRoot, folder, want.name, remote)) orelse break :blk_outer dest; + if (util.parentOf(store_path)) |parent| Dir.cwd().createDirPath(io, parent) catch {}; + break :blk_outer store_path; + } else dest; + Dir.cwd().createDirPath(io, pluginsDir) catch {}; - Dir.cwd().rename(staging, Dir.cwd(), dest, io) catch { + + // Landing flat, over a path that is currently a link into the store: drop + // the link first. Renaming onto it would either fail or, worse, follow it + // and write through into the shared copy. + if (std.mem.eql(u8, final_dest, dest) and util.isSymlink(io, dest)) { + Dir.cwd().deleteFile(io, dest) catch {}; + } + + Dir.cwd().rename(staging, Dir.cwd(), final_dest, io) catch { Dir.cwd().deleteTree(io, staging) catch {}; return .{ .refused = try std.fmt.allocPrint( allocator, "{s}: fetched successfully but could not be moved into {s}.", - .{ name, dest }, + .{ name, final_dest }, ) }; }; + // Point the project at the version it just pinned. Without this the plugin + // sits in the store and the project cannot see it. + if (!std.mem.eql(u8, final_dest, dest)) { + try linkIntoProject(allocator, io, projectRoot, folder, final_dest); + } + + // The composer that owns the directory needs its classmap regenerated + // before the new plugin is discoverable. + // + // Two directories can be involved — the shared kernel's and the project's — + // but for a third-party plugin they are the SAME path, and dumping it twice + // rebuilt the entire classmap for no gain. Deferred entirely when the caller + // is installing a batch and will dump once at the end. + if (!opts.defer_autoload) { + const project_plugins = try std.fs.path.join(allocator, &.{ projectRoot, "plugins" }); + refreshAutoload(allocator, io, env, pluginsDir); + if (!std.mem.eql(u8, pluginsDir, project_plugins)) { + refreshAutoload(allocator, io, env, project_plugins); + } + } + return .{ .installed = .{ - .name = name, + .name = folder, .remote = remote, .version = want.name, - .commit = git.headCommit(allocator, io, env, dest) orelse "", + .commit = git.headCommit(allocator, io, env, final_dest) orelse "", .kernel = constraint orelse "", } }; } +/// Link `/plugins/` at the store copy the project pinned. +/// +/// A symlink rather than a copy: the point of the store is that one version +/// exists once on disk. The project's own composer maps Plugins\\ to plugins/, +/// so it resolves THROUGH the link — which is what makes the pinned version a +/// per-project fact rather than a machine-wide one. +fn linkIntoProject( + allocator: std.mem.Allocator, + io: Io, + projectRoot: []const u8, + folder: []const u8, + target: []const u8, +) !void { + const plugins = try std.fs.path.join(allocator, &.{ projectRoot, "plugins" }); + Dir.cwd().createDirPath(io, plugins) catch {}; + + const link = try std.fs.path.join(allocator, &.{ plugins, folder }); + + // Create the new link under a temporary name and RENAME it over the old + // one. Deleting first and linking second leaves a window — and, if the + // symlink call fails, a permanent state — where the project has no plugin + // at all, having had a working one a moment earlier. rename(2) replaces + // atomically. + const tmp = try std.fmt.allocPrint(allocator, "{s}.hkm-new", .{link}); + Dir.cwd().deleteFile(io, tmp) catch {}; + Dir.cwd().deleteTree(io, tmp) catch {}; + + Dir.cwd().symLink(io, target, tmp, .{ .is_directory = true }) catch |e| { + prompt.warn(std.fmt.allocPrint( + allocator, + "{s}: could not link into the project ({t}) — the plugin is in the store but this project cannot see it.", + .{ folder, e }, + ) catch folder); + return e; + }; + + // A previous FLAT install leaves a real directory; rename cannot replace a + // non-empty directory, so that one case still needs an explicit removal. + if (!util.isSymlink(io, link) and util.dirExists(Dir.cwd(), io, link)) { + Dir.cwd().deleteTree(io, link) catch {}; + } + + Dir.cwd().rename(tmp, Dir.cwd(), link, io) catch |e| { + Dir.cwd().deleteFile(io, tmp) catch {}; + prompt.warn(std.fmt.allocPrint( + allocator, + "{s}: could not link into the project ({t}) — the plugin is in the store but this project cannot see it.", + .{ folder, e }, + ) catch folder); + return e; + }; +} + /// Record an outcome in the project's lock file. pub fn recordInLock( allocator: std.mem.Allocator, @@ -260,6 +759,12 @@ pub fn report(allocator: std.mem.Allocator, name: []const u8, outcome: Outcome, prompt.muted(try std.fmt.allocPrint(allocator, "up to date {s} {s}", .{ name, e.version })); return 0; }, + .linked => |e| { + // Say what happened: the project gained the plugin, it just did not + // need downloading because another project already had that version. + prompt.ok(try std.fmt.allocPrint(allocator, "linked {s} {s} (already in the store)", .{ name, e.version })); + return 0; + }, .updated => |u| { prompt.ok(try std.fmt.allocPrint(allocator, "{s}{s} {s} → {s}", .{ if (dry_run) "would update " else "updated ", diff --git a/tools/src/lib/plugin_registry.zig b/tools/src/lib/plugin_registry.zig index 286f3a3..699f00e 100644 --- a/tools/src/lib/plugin_registry.zig +++ b/tools/src/lib/plugin_registry.zig @@ -51,6 +51,26 @@ pub fn slugFor(allocator: std.mem.Allocator, folder: []const u8) ![]const u8 { return util.lower(allocator, folder); } +/// The canonical FOLDER name for a plugin, whatever spelling the user typed. +/// +/// The install directory has to match the PSR-4 namespace exactly: `Plugins\` +/// maps to plugins/, so `Plugins\Crypto\Provider` must live in plugins/Crypto. +/// Installing to whatever the user typed meant `hkm plugins install crypto` +/// produced plugins/crypto — the files were there, the autoloader could not see +/// them, and the failure surfaced later as a missing Provider class. +/// +/// Resolution order: an override table entry (matched on either spelling), then +/// studly-case. The table is what makes `oauth2` → `OAuth2` and `siteseo` → +/// `SiteSEO` rather than the `Oauth2` / `Siteseo` studly-case would produce. +pub fn canonicalName(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { + for (slug_overrides) |o| { + if (util.eqlIgnoreCase(input, o.folder) or util.eqlIgnoreCase(input, o.slug)) { + return allocator.dupe(u8, o.folder); + } + } + return util.studly(allocator, input); +} + /// The organisation to fetch from. HKM_PLUGIN_ORG lets a fork or a private /// mirror be used without rebuilding the tool. pub fn org(env: *EnvMap) []const u8 { @@ -82,6 +102,116 @@ pub fn remoteFor(allocator: std.mem.Allocator, env: *EnvMap, folder: []const u8) return std.fmt.allocPrint(allocator, "https://github.com/{s}/hkm-plugin-{s}.git", .{ org(env), slug }); } +/// Does this look like a git remote rather than a plugin name? +/// +/// Plugin names are bare identifiers (`auth`, `SocialAuth`), so anything +/// carrying a scheme, an `scp`-style `host:path`, or a filesystem path is a +/// remote the user wants fetched directly. Checked in that order because +/// `git@github.com:Org/repo.git` has no scheme and would otherwise be missed. +pub fn isRemoteUrl(input: []const u8) bool { + const s = std.mem.trim(u8, input, " \t\r\n"); + if (s.len == 0) return false; + + for ([_][]const u8{ "https://", "http://", "ssh://", "git://", "file://" }) |scheme| { + if (std.mem.startsWith(u8, s, scheme)) return true; + } + + // scp-style: user@host:path — the ':' must come after the '@' and be + // followed by something, or it is a plain name with a stray colon. + if (std.mem.indexOfScalar(u8, s, '@')) |at| { + if (std.mem.indexOfScalarPos(u8, s, at, ':')) |colon| { + if (colon + 1 < s.len) return true; + } + } + + // A local clone, bare or otherwise. + if (s[0] == '/' or std.mem.startsWith(u8, s, "./") or std.mem.startsWith(u8, s, "../")) return true; + + return false; +} + +/// The plugin FOLDER name implied by a remote URL. +/// +/// Takes the repository basename, drops a `.git` suffix and the `hkm-plugin-` +/// prefix the first-party repos carry, then canonicalises — so +/// `https://github.com/AlfaCode-Team/hkm-plugin-social-auth.git` yields +/// `SocialAuth`, exactly as `hkm plugins install social-auth` would. +/// +/// This is a starting guess, not the final answer: the authority on a plugin's +/// name is the `name` field of its own module.json, which cannot be read until +/// the repository has been fetched. The installer re-checks it there and moves +/// the plugin if the two disagree — a repo whose directory name does not match +/// its namespace would otherwise install to a path PSR-4 never looks in. +pub fn nameFromRemote(allocator: std.mem.Allocator, url: []const u8) ![]const u8 { + var s = std.mem.trim(u8, url, " \t\r\n"); + s = std.mem.trimEnd(u8, s, "/"); + + // Basename, for either separator: scp-style remotes use ':' before the path. + if (std.mem.lastIndexOfAny(u8, s, "/:")) |i| s = s[i + 1 ..]; + + if (std.mem.endsWith(u8, s, ".git")) s = s[0 .. s.len - 4]; + if (std.mem.startsWith(u8, s, "hkm-plugin-")) s = s["hkm-plugin-".len ..]; + + if (s.len == 0) return error.UnnamedRemote; + return canonicalName(allocator, s); +} + +/// Is an EXPLICIT remote one of the first-party packages? +/// +/// Same question as `isFirstParty`, asked of a URL the user supplied rather +/// than of the environment — it decides whether the plugin lands in the shared +/// kernel or in the project. The answer is yes only for the configured org's +/// `hkm-plugin-*` repositories on github: a fork, a mirror, or anything else is +/// the project's business, and installing it into the kernel would impose one +/// project's choice on every other project on the machine. +pub fn remoteIsFirstParty(env: *EnvMap, url: []const u8) bool { + const s = std.mem.trim(u8, url, " \t\r\n"); + if (std.mem.indexOf(u8, s, "github.com") == null) return false; + + // The org must be the path segment immediately BEFORE the repo, not merely + // present somewhere in the URL — otherwise a mirror at + // git.example.com/AlfaCode-Team/… would pass by containing the name. + // Matched by hand rather than by building "/hkm-plugin-": this is + // called from paths that have no allocator to spare and no place to free. + const at = std.mem.indexOf(u8, s, "/hkm-plugin-") orelse return false; + const before = s[0..at]; + const o = org(env); + if (before.len < o.len) return false; + if (!std.mem.eql(u8, before[before.len - o.len ..], o)) return false; + + // What precedes the org must be a separator, so "not-AlfaCode-Team" fails. + if (before.len == o.len) return true; + const sep = before[before.len - o.len - 1]; + return sep == '/' or sep == ':'; +} + +/// Is this plugin one of the first-party AlfaCode-Team packages? +/// +/// Decides WHERE it installs, which in turn decides which composer autoloader +/// resolves it: +/// +/// first-party -> /plugins — the kernel's composer maps Plugins\ +/// there, so one copy serves every +/// project on the machine. +/// third-party -> /plugins — the project's own composer maps +/// Plugins\ there, so it stays local to +/// the project that asked for it. +/// +/// Both autoloaders are registered at runtime and each resolves its own +/// directory, so the two never collide. +/// +/// "First-party" means the remote resolves to the default org with no override. +/// Pointing HKM_PLUGIN_ORG or HKM_PLUGIN_REMOTE elsewhere makes it third-party +/// by definition: it is no longer a package this kernel vouches for, and +/// installing it into the shared kernel would impose one user's fork on every +/// project on the machine. +pub fn isFirstParty(env: *EnvMap) bool { + if (env.get("HKM_PLUGIN_REMOTE")) |t| { + if (std.mem.trim(u8, t, " \t\r\n").len > 0) return false; + } + return std.mem.eql(u8, org(env), default_org); +} + /// The running kernel's version, parsed. Null when the build stamped something /// unparseable (never expected — the default is "0.0.0-dev"). pub fn kernelVersion() ?semver.Version { @@ -187,3 +317,55 @@ test "a malformed constraint is refused rather than ignored" { const c = checkKernel(">=not-a-version"); try std.testing.expect(c == .bad_constraint); } + +test "a remote URL is told apart from a plugin name" { + // Names are bare identifiers; anything with a scheme, an scp-style + // host:path, or a filesystem path is a remote. + try std.testing.expect(isRemoteUrl("https://github.com/AlfaCode-Team/hkm-plugin-logger.git")); + try std.testing.expect(isRemoteUrl("http://git.internal/hkm/logger.git")); + try std.testing.expect(isRemoteUrl("ssh://git@host/team/logger.git")); + try std.testing.expect(isRemoteUrl("git@github.com:AlfaCode-Team/hkm-plugin-logger.git")); + try std.testing.expect(isRemoteUrl("/srv/git/logger.git")); + try std.testing.expect(isRemoteUrl("./vendor-fork")); + + try std.testing.expect(!isRemoteUrl("logger")); + try std.testing.expect(!isRemoteUrl("SocialAuth")); + try std.testing.expect(!isRemoteUrl("")); +} + +test "the plugin name comes out of the repository name" { + const a = std.testing.allocator; + + // The hkm-plugin- prefix and the .git suffix are both dropped, and the + // result goes through canonicalName — so a URL install lands in exactly the + // same directory as installing the same plugin by name. + const cases = [_]struct { url: []const u8, want: []const u8 }{ + .{ .url = "https://github.com/AlfaCode-Team/hkm-plugin-logger.git", .want = "Logger" }, + .{ .url = "https://github.com/AlfaCode-Team/hkm-plugin-social-auth.git", .want = "SocialAuth" }, + .{ .url = "https://github.com/AlfaCode-Team/hkm-plugin-oauth2", .want = "OAuth2" }, + .{ .url = "git@github.com:AlfaCode-Team/hkm-plugin-siteseo.git", .want = "SiteSEO" }, + // Trailing slash, and a repo that carries no prefix at all. + .{ .url = "https://example.com/team/billing/", .want = "Billing" }, + }; + for (cases) |c| { + const got = try nameFromRemote(a, c.url); + defer a.free(got); + try std.testing.expectEqualStrings(c.want, got); + } +} + +test "only the configured org's plugin repos count as first-party" { + var env = EnvMap.init(std.testing.allocator); + defer env.deinit(); + + // First-party decides that the plugin lands in the SHARED kernel, where it + // affects every project on the machine — so a fork must not qualify merely + // by being a copy of one. + try std.testing.expect(remoteIsFirstParty(&env, "https://github.com/AlfaCode-Team/hkm-plugin-logger.git")); + try std.testing.expect(remoteIsFirstParty(&env, "git@github.com:AlfaCode-Team/hkm-plugin-logger.git")); + try std.testing.expect(!remoteIsFirstParty(&env, "https://github.com/someone-else/hkm-plugin-logger.git")); + // Contains the org name, but as a suffix of a different one. + try std.testing.expect(!remoteIsFirstParty(&env, "https://github.com/not-AlfaCode-Team/hkm-plugin-logger.git")); + try std.testing.expect(!remoteIsFirstParty(&env, "https://git.internal/AlfaCode-Team/hkm-plugin-logger.git")); + try std.testing.expect(!remoteIsFirstParty(&env, "/srv/git/hkm-plugin-logger.git")); +} diff --git a/tools/src/lib/plugin_sources.zig b/tools/src/lib/plugin_sources.zig index 3113e78..33c0c0d 100644 --- a/tools/src/lib/plugin_sources.zig +++ b/tools/src/lib/plugin_sources.zig @@ -152,25 +152,88 @@ pub fn listPluginDirs(allocator: std.mem.Allocator, io: Io, pluginsDir: []const defer d.close(io); var it = d.iterate(); while (try it.next(io)) |entry| { - if (entry.kind != .directory) continue; if (entry.name.len > 0 and entry.name[0] == '.') continue; + + // A SYMLINK to a plugin counts. Projects reference a version in the + // shared store by linking it into their own plugins/, and a plain + // `kind != .directory` check reports that entry as `.sym_link` and + // skips it — making every store-linked plugin invisible to discovery, + // and to everything built on it (locate, the dependency catalogue, + // asset publishing, `plugins list`). + switch (entry.kind) { + .directory => {}, + .sym_link => { + // Only follow links that actually resolve to a directory, so a + // dangling or file link is not mistaken for a plugin. + const target = std.fmt.allocPrint(allocator, "{s}/{s}", .{ util.trimSlash(pluginsDir), entry.name }) catch continue; + if (!util.dirExists(Dir.cwd(), io, target)) continue; + }, + else => continue, + } + try out.append(allocator, try allocator.dupe(u8, entry.name)); } } // ── module.json ──────────────────────────────────────────────────────────────── +/// One entry of a module's `requires[]`. +/// +/// A dependency is named by DOMAIN, never by repository — that is the framework +/// being right: a module depends on a capability, not on who ships it. It does +/// leave a plugin outside the platform's own catalogue with no way to say where +/// its dependency comes from, so an entry may also be an object carrying that: +/// +/// "requires": [ +/// "database.management", +/// { "domain": "telemetry.exotic", +/// "repo": "https://github.com/acme/hkm-plugin-telemetry.git", +/// "version": "^1.2" } +/// ] +/// +/// The string form stays exactly as it was — every existing module.json parses +/// unchanged, and first-party plugins have no reason to write the long form. +pub const Requirement = struct { + domain: []const u8, + /// Where to fetch the plugin providing `domain`, for domains this platform + /// has never heard of. Empty when the entry was a plain string. + repo: []const u8 = "", + /// A semver constraint ("^1.2"), an exact tag ("v1.2.0"), or a branch + /// ("main"). Empty means the newest release tag. + version: []const u8 = "", +}; + pub const ModuleMeta = struct { name: ?[]const u8 = null, solves: ?[]const u8 = null, version: ?[]const u8 = null, - /// "requires" — the domains this module depends on (each a `solves` value of - /// another module, or a kernel port). Empty when absent. - requires: []const []const u8 = &.{}, + /// "requires" — what this module depends on. Empty when absent. + requires: []const Requirement = &.{}, + /// Domains named by INDIVIDUAL ROUTES (`routes[].requires[]`). + /// + /// Kept separate because they mean something different to the KERNEL — a + /// route-level entry is seeded into that one request's graph, not every + /// request's — but they are just as mandatory: CompileRouteManifestStage + /// fails the whole boot when a route names a domain no registered module + /// solves. A plugin whose routes require http.pageflow needs Pageflow + /// installed and enabled exactly as much as one that requires it up top. + route_requires: []const Requirement = &.{}, /// "documentation" — preferred enable-time doc (string, or array joined). doc: ?[]const u8 = null, /// "description" — fallback doc text. description: ?[]const u8 = null, + /// "activation" — "essential" when the plugin only works if it is + /// registered into EVERY request. + /// + /// Most plugins are on-demand: the kernel loads them when a route needs + /// them, and that is strictly better. A few cannot be — a plugin whose + /// pipeline stage runs on every request needs its bindings present on every + /// request, and enabling it on-demand produces a project that installs + /// cleanly, boots cleanly, and throws at the first request instead + /// ("no TenantIdentifier is bound for this request"). Declaring it here + /// lets `hkm plugins enable` put it in the right list without the user + /// having to know. + activation: ?[]const u8 = null, /// "kernel" — semver constraint on the kernel this plugin supports /// (e.g. "^1.0"). Absent means "no opinion" and never blocks installation; /// see plugin_registry.checkKernel. @@ -191,14 +254,140 @@ pub fn readModuleMeta(allocator: std.mem.Allocator, io: Io, pluginsDir: []const .name = strField(parsed.object, "name"), .solves = strField(parsed.object, "solves"), .version = strField(parsed.object, "version"), - .requires = try strArrayField(allocator, parsed.object, "requires"), + .requires = try requiresField(allocator, parsed.object), + .route_requires = try routeRequiresField(allocator, parsed.object), .doc = try docField(allocator, parsed.object, "documentation"), .description = strField(parsed.object, "description"), .kernel = strField(parsed.object, "kernel"), + .activation = strField(parsed.object, "activation"), }; } -/// Read an array-of-strings field (e.g. "requires"). Returns an empty slice when +/// Read "requires", accepting both the string and the object form. +/// +/// An object without a usable "domain" is SKIPPED rather than defaulted: a +/// requirement whose domain could not be read cannot be resolved, satisfied or +/// reported, and inventing one would attach its repo to the wrong dependency. +fn requiresField(allocator: std.mem.Allocator, obj: std.json.ObjectMap) ![]const Requirement { + const v = obj.get("requires") orelse return &.{}; + if (v != .array) return &.{}; + + var out: std.ArrayList(Requirement) = .empty; + for (v.array.items) |item| { + // "tag" and "branch" are the same field to git — one ref to clone at. + // Named separately because a manifest saying "branch": "main" reads + // better than "version": "main". + const parsed = parseRequirement(item) orelse continue; + try out.append(allocator, parsed); + } + return out.toOwnedSlice(allocator); +} + +/// Collect every domain named by a route-level `requires[]`, de-duplicated. +/// +/// Routes reach the manifest by three paths, and a dependency declared on ANY of +/// them is equally mandatory — the boot fails when a route names a domain no +/// registered module solves, wherever that route was written: +/// +/// "routeRequires": [...] a module-wide default applied to every route +/// "routes": [ { "requires" } ] a route declared at the top level +/// "groups": [ { "requires", "routes": [...], "groups": [...] } ] nested +/// +/// Missing the grouped ones would let `hkm plugins enable` resolve a plugin's +/// dependencies, install them, and still produce a project that fails at boot. +fn routeRequiresField(allocator: std.mem.Allocator, obj: std.json.ObjectMap) ![]const Requirement { + var out: std.ArrayList(Requirement) = .empty; + try collectRequires(allocator, obj, &out, 0); + return out.toOwnedSlice(allocator); +} + +/// Matches CompileRouteManifestStage::MAX_GROUP_DEPTH — a self-referencing +/// structure is rejected there, and must not spin here either. +const max_group_depth: u8 = 16; + +/// Walk one route-declaration source: its module-wide `routeRequires`, each +/// `routes[].requires[]`, and every nested group, recursively. +fn collectRequires( + allocator: std.mem.Allocator, + obj: std.json.ObjectMap, + out: *std.ArrayList(Requirement), + depth: u8, +) !void { + if (depth > max_group_depth) return; + + // Module-wide / group-wide default. + if (obj.get("routeRequires")) |v| try appendRequirements(allocator, v, out); + if (obj.get("requires")) |v| { + // Only meaningful on a GROUP — a module's own top-level requires[] is + // read separately by requiresField(). Harmless either way: duplicates + // are dropped, and both lists name domains that must be installed. + if (depth > 0) try appendRequirements(allocator, v, out); + } + + if (obj.get("routes")) |routes| { + if (routes == .array) { + for (routes.array.items) |route| { + if (route != .object) continue; + if (route.object.get("requires")) |reqs| { + try appendRequirements(allocator, reqs, out); + } + } + } + } + + if (obj.get("groups")) |groups| { + if (groups == .array) { + for (groups.array.items) |group| { + if (group != .object) continue; + try collectRequires(allocator, group.object, out, depth + 1); + } + } + } +} + +/// Append every requirement in a `requires[]` value, skipping duplicates. +fn appendRequirements( + allocator: std.mem.Allocator, + value: std.json.Value, + out: *std.ArrayList(Requirement), +) !void { + if (value != .array) return; + + for (value.array.items) |item| { + const parsed = parseRequirement(item) orelse continue; + var seen = false; + for (out.items) |e| { + if (std.mem.eql(u8, e.domain, parsed.domain)) seen = true; + } + if (!seen) try out.append(allocator, parsed); + } +} + +/// One requires[] entry, in either the string or the object form. +fn parseRequirement(item: std.json.Value) ?Requirement { + switch (item) { + .string => { + if (item.string.len == 0) return null; + return .{ .domain = item.string }; + }, + .object => { + const domain = strField(item.object, "domain") orelse return null; + if (domain.len == 0) return null; + return .{ + .domain = domain, + .repo = strField(item.object, "repo") orelse + strField(item.object, "remote") orelse + strField(item.object, "url") orelse "", + .version = strField(item.object, "version") orelse + strField(item.object, "tag") orelse + strField(item.object, "branch") orelse "", + }; + }, + else => return null, + } +} + +/// Read an array-of-strings field. Returns an empty slice when /// absent, not an array, or empty. Non-string elements are skipped. fn strArrayField(allocator: std.mem.Allocator, obj: std.json.ObjectMap, key: []const u8) ![]const []const u8 { const v = obj.get(key) orelse return &.{}; @@ -233,3 +422,100 @@ fn docField(allocator: std.mem.Allocator, obj: std.json.ObjectMap, key: []const else => return null, } } + +// ── tests ─────────────────────────────────────────────────────────────────── + +/// Parse a module.json body and collect its route-level requires. +fn testRouteRequires(allocator: std.mem.Allocator, json: []const u8) ![]const Requirement { + const parsed = try std.json.parseFromSliceLeaky(std.json.Value, allocator, json, .{}); + return routeRequiresField(allocator, parsed.object); +} + +fn hasDomain(list: []const Requirement, domain: []const u8) bool { + for (list) |r| { + if (std.mem.eql(u8, r.domain, domain)) return true; + } + return false; +} + +test "route requires are collected from top-level routes" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const got = try testRouteRequires(arena.allocator(), + \\{ "routes": [ { "requires": ["view.rendering"] } ] } + ); + + try std.testing.expect(hasDomain(got, "view.rendering")); +} + +test "route requires are collected from NESTED groups" { + // A plugin that moves its routes into groups[] declares the same mandatory + // dependencies — missing them would install cleanly and fail at boot. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const got = try testRouteRequires(arena.allocator(), + \\{ + \\ "routeRequires": ["database.management"], + \\ "routes": [ { "requires": ["http.client"] } ], + \\ "groups": [ + \\ { "requires": ["audit.trail"], + \\ "routes": [ { "requires": ["storage.local"] } ], + \\ "groups": [ { "routes": [ { "requires": ["view.rendering"] } ] } ] } + \\ ] + \\} + ); + + try std.testing.expect(hasDomain(got, "database.management")); // module-wide default + try std.testing.expect(hasDomain(got, "http.client")); // top-level route + try std.testing.expect(hasDomain(got, "audit.trail")); // the group itself + try std.testing.expect(hasDomain(got, "storage.local")); // a route inside it + try std.testing.expect(hasDomain(got, "view.rendering")); // a nested group +} + +test "a module's own top-level requires is not double-counted here" { + // requiresField() already reads it; collecting it again would be harmless + // but muddies which list a domain came from. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const got = try testRouteRequires(arena.allocator(), + \\{ "requires": ["crypto.services"], "routes": [] } + ); + + try std.testing.expect(!hasDomain(got, "crypto.services")); +} + +test "duplicate domains across groups are collected once" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const got = try testRouteRequires(arena.allocator(), + \\{ + \\ "routes": [ { "requires": ["view.rendering"] } ], + \\ "groups": [ { "routes": [ { "requires": ["view.rendering"] } ] } ] + \\} + ); + + var count: usize = 0; + for (got) |r| { + if (std.mem.eql(u8, r.domain, "view.rendering")) count += 1; + } + try std.testing.expectEqual(@as(usize, 1), count); +} + +test "a self-referencing group structure terminates" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + // 40 levels — deeper than max_group_depth, which the compiler also rejects. + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(std.testing.allocator); + for (0..40) |_| try buf.appendSlice(std.testing.allocator, "{\"groups\":["); + try buf.appendSlice(std.testing.allocator, "{\"routes\":[{\"requires\":[\"deep.domain\"]}]}"); + for (0..40) |_| try buf.appendSlice(std.testing.allocator, "]}"); + + const got = try testRouteRequires(arena.allocator(), buf.items); + _ = got; // reaching here at all is the assertion: it returned. +} diff --git a/tools/src/lib/plugin_store.zig b/tools/src/lib/plugin_store.zig new file mode 100644 index 0000000..95924ac --- /dev/null +++ b/tools/src/lib/plugin_store.zig @@ -0,0 +1,238 @@ +//! The global plugin cache: one download per (plugin, version, origin), +//! shared by every project on the machine. +//! +//! A project does not own the plugins it uses — it references them. Project A +//! installing Auth v1.2.0 downloads it once; project B wanting the same version +//! links at what is already there and downloads nothing. That is the whole +//! point of a store, and it only holds if the store is GLOBAL: when it followed +//! the install target, third-party plugins landed under the project and every +//! project kept its own copy of identical bytes. +//! +//! ## Layout +//! +//! //-/ +//! +//! The version alone is not a safe key. Two repositories can both publish +//! `v1.0.0` of a plugin called Logger — a fork and its upstream, a private +//! mirror and the public original — and keying on the version alone would give +//! the second one the FIRST one's files, silently, with no error anywhere. The +//! hash is of the remote URL, so those are different directories. +//! +//! It is a hash of the ORIGIN rather than of the content because the lookup has +//! to happen BEFORE anything is downloaded — the question "do I already have +//! this?" is asked when all that is known is the remote and the tag. A content +//! hash could only be computed after the download it is meant to avoid. + +const std = @import("std"); +const util = @import("util.zig"); + +const Dir = std.Io.Dir; +const Io = std.Io; +const EnvMap = std.process.Environ.Map; + +/// Directory name under the store root, so it stays recognisable in `ls`. +pub const dir_name = "plugin-store"; + +/// Where the cache lives. +/// +/// Resolution order, most explicit first: +/// +/// 1. HKM_PLUGIN_STORE — env, or `hkm plugins store --set` (which +/// writes it to the same config the launcher +/// loads into the environment) +/// 2. $XDG_CACHE_HOME/hkm/… — the conventional per-user cache +/// 3. $HOME/.cache/hkm/… — same, when XDG_CACHE_HOME is unset +/// 4. /plugin-store — the caller's kernel root, for a machine +/// with no HOME (containers, CI) +/// +/// A cache directory is the right home: the contents are re-downloadable, +/// per-user, and safe for a cleaner to delete — losing it costs a re-fetch, +/// never a project. +pub fn root(allocator: std.mem.Allocator, env: *EnvMap, fallback: []const u8) ![]const u8 { + if (env.get("HKM_PLUGIN_STORE")) |v| { + const t = std.mem.trim(u8, v, " \t\r\n"); + if (t.len > 0) return allocator.dupe(u8, util.trimSlash(t)); + } + + if (env.get("XDG_CACHE_HOME")) |x| { + const t = std.mem.trim(u8, x, " \t\r\n"); + if (t.len > 0) return std.fmt.allocPrint(allocator, "{s}/hkm/{s}", .{ util.trimSlash(t), dir_name }); + } + + if (env.get("HOME")) |h| { + const t = std.mem.trim(u8, h, " \t\r\n"); + if (t.len > 0) return std.fmt.allocPrint(allocator, "{s}/.cache/hkm/{s}", .{ util.trimSlash(t), dir_name }); + } + + return std.fmt.allocPrint(allocator, "{s}/{s}", .{ util.trimSlash(fallback), dir_name }); +} + +/// Short, stable hash of a remote URL. +/// +/// SHA-256 truncated to 8 hex characters. Truncation is fine here: this +/// separates a handful of origins for the same plugin, it is not a security +/// boundary, and a collision would need two remotes whose digests share 32 +/// bits AND that publish the same version of the same plugin name. +/// +/// The URL is normalised first so `…/plugin.git`, `…/plugin` and `…/plugin/` +/// are one entry rather than three copies of identical bytes. +pub fn originHash(allocator: std.mem.Allocator, remote: []const u8) ![]const u8 { + var s = std.mem.trim(u8, remote, " \t\r\n"); + s = std.mem.trimEnd(u8, s, "/"); + if (std.mem.endsWith(u8, s, ".git")) s = s[0 .. s.len - 4]; + + var digest: [32]u8 = undefined; + var h = std.crypto.hash.sha2.Sha256.init(.{}); + // Case-insensitively: a host is case-insensitive, and GitHub treats the + // owner/repo path that way too, so differing only in case is the same repo. + var buf: [256]u8 = undefined; + var i: usize = 0; + while (i < s.len) { + const n = @min(buf.len, s.len - i); + for (0..n) |j| { + const c = s[i + j]; + buf[j] = if (c >= 'A' and c <= 'Z') c - 'A' + 'a' else c; + } + h.update(buf[0..n]); + i += n; + } + h.final(&digest); + + return std.fmt.allocPrint(allocator, "{x}", .{digest[0..4]}); +} + +/// The directory name for one cached version: `-`. +/// +/// An empty remote yields the bare version. That keeps entries written before +/// origin hashing readable, and means a caller with no remote to offer still +/// gets a usable (if less precise) key rather than an error. +pub fn versionKey(allocator: std.mem.Allocator, version: []const u8, remote: []const u8) ![]const u8 { + if (remote.len == 0) return allocator.dupe(u8, version); + const h = try originHash(allocator, remote); + // Freed here rather than left to the caller's arena: this is also called + // from tests and from long-lived loops, where an intermediate that only + // ever gets formatted into the result has no reason to outlive it. + defer allocator.free(h); + return std.fmt.allocPrint(allocator, "{s}-{s}", .{ version, h }); +} + +/// Full path to one cached version of one plugin. +pub fn entryDir( + allocator: std.mem.Allocator, + env: *EnvMap, + fallback: []const u8, + name: []const u8, + version: []const u8, + remote: []const u8, +) ![]const u8 { + const r = try root(allocator, env, fallback); + const key = try versionKey(allocator, version, remote); + return std.fs.path.join(allocator, &.{ r, name, key }); +} + +/// Path to a plugin's directory in the store (all its versions). +pub fn pluginDir( + allocator: std.mem.Allocator, + env: *EnvMap, + fallback: []const u8, + name: []const u8, +) ![]const u8 { + const r = try root(allocator, env, fallback); + return std.fs.path.join(allocator, &.{ r, name }); +} + +/// The VERSION part of a store entry name, without the origin hash. +/// +/// Entry directories are `-`, and the hash is an implementation +/// detail of the cache — showing it in "updated v2.0.0-42f5f5a5 → v2.0.1" +/// exposes a name the user never typed and cannot look up. +pub fn versionOf(entry: []const u8) []const u8 { + const dash = std.mem.lastIndexOfScalar(u8, entry, '-') orelse return entry; + const suffix = entry[dash + 1 ..]; + if (suffix.len != 8) return entry; // not our hash — part of the version + for (suffix) |c| { + const hex = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f'); + if (!hex) return entry; + } + return entry[0..dash]; +} + +/// Does `dir` (a `-` entry name) hold this version, whatever its +/// origin? Used by prune and by "is any copy of this version present" checks, +/// where the origin is not known or does not matter. +pub fn entryIsVersion(entry: []const u8, version: []const u8) bool { + if (std.mem.eql(u8, entry, version)) return true; // pre-hash entry + if (!std.mem.startsWith(u8, entry, version)) return false; + return entry.len > version.len and entry[version.len] == '-'; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "the same repo spelled differently is one cache entry" { + const a = std.testing.allocator; + + const forms = [_][]const u8{ + "https://github.com/AlfaCode-Team/hkm-plugin-logger.git", + "https://github.com/AlfaCode-Team/hkm-plugin-logger", + "https://github.com/AlfaCode-Team/hkm-plugin-logger/", + "https://github.com/alfacode-team/hkm-plugin-logger.git", + }; + const first = try originHash(a, forms[0]); + defer a.free(first); + for (forms[1..]) |f| { + const h = try originHash(a, f); + defer a.free(h); + try std.testing.expectEqualStrings(first, h); + } +} + +test "different origins of the same version never share a directory" { + const a = std.testing.allocator; + + // The failure this prevents: a fork and its upstream both publishing + // v1.0.0, the second silently getting the first's files. + const upstream = try versionKey(a, "v1.0.0", "https://github.com/AlfaCode-Team/hkm-plugin-logger.git"); + defer a.free(upstream); + const fork = try versionKey(a, "v1.0.0", "https://github.com/someone/hkm-plugin-logger.git"); + defer a.free(fork); + + try std.testing.expect(!std.mem.eql(u8, upstream, fork)); + try std.testing.expect(std.mem.startsWith(u8, upstream, "v1.0.0-")); + try std.testing.expect(std.mem.startsWith(u8, fork, "v1.0.0-")); +} + +test "an entry is recognised as its version, hashed or not" { + try std.testing.expect(entryIsVersion("v1.0.0-1a2b3c4d", "v1.0.0")); + try std.testing.expect(entryIsVersion("v1.0.0", "v1.0.0")); // written before hashing + // v1.0.10 must not be read as v1.0.1 — the separator is what stops it. + try std.testing.expect(!entryIsVersion("v1.0.10", "v1.0.1")); + try std.testing.expect(!entryIsVersion("v2.0.0-1a2b3c4d", "v1.0.0")); +} + +test "an explicit HKM_PLUGIN_STORE wins over every default" { + const a = std.testing.allocator; + var env = EnvMap.init(a); + defer env.deinit(); + + try env.put("HOME", "/home/someone"); + const by_home = try root(a, &env, "/opt/hkm"); + defer a.free(by_home); + try std.testing.expectEqualStrings("/home/someone/.cache/hkm/plugin-store", by_home); + + try env.put("HKM_PLUGIN_STORE", "/srv/shared/plugins/"); + const explicit = try root(a, &env, "/opt/hkm"); + defer a.free(explicit); + // Trailing slash trimmed, so joins never produce a doubled separator. + try std.testing.expectEqualStrings("/srv/shared/plugins", explicit); +} + +test "the origin hash is stripped for display" { + try std.testing.expectEqualStrings("v2.0.0", versionOf("v2.0.0-42f5f5a5")); + try std.testing.expectEqualStrings("v2.0.0", versionOf("v2.0.0")); + // A pre-release suffix is part of the version, not a hash. + try std.testing.expectEqualStrings("v1.0.0-beta", versionOf("v1.0.0-beta")); + // Eight chars but not hex. + try std.testing.expectEqualStrings("v1.0.0-zzzzzzzz", versionOf("v1.0.0-zzzzzzzz")); +} diff --git a/tools/src/lib/prompt.zig b/tools/src/lib/prompt.zig index 19e576e..3b1e6ed 100644 --- a/tools/src/lib/prompt.zig +++ b/tools/src/lib/prompt.zig @@ -68,6 +68,16 @@ pub fn section(title: []const u8) void { /// A two-column help row: a cyan key padded to 30 cols, then a dimmed /// description. Use for usage lines, flags, env vars, and examples. pub fn item(key: []const u8, desc: []const u8) void { + // A key longer than the column still needs a gap before its description. + // Without one, every long usage line in `--help` read as one run-on word: + // "hkm plugins enable [proj]wire a plugin into the project". + if (key.len >= 30) { + std.debug.print( + bar ++ " " ++ cyan ++ "{s}" ++ reset ++ " " ++ gray ++ "{s}" ++ reset ++ "\n", + .{ key, desc }, + ); + return; + } std.debug.print( bar ++ " " ++ cyan ++ "{s: <30}" ++ reset ++ gray ++ "{s}" ++ reset ++ "\n", .{ key, desc }, diff --git a/tools/src/lib/userconfig.zig b/tools/src/lib/userconfig.zig index 46174ce..b267e5b 100644 --- a/tools/src/lib/userconfig.zig +++ b/tools/src/lib/userconfig.zig @@ -32,11 +32,31 @@ pub fn path(allocator: std.mem.Allocator, env: *EnvMap) !?[]const u8 { return null; } +/// Sentinel variable recording which keys in `env` came from the CONFIG FILE +/// rather than from the real process environment. +/// +/// The distinction matters because the two carry different authority. A real +/// `export HKM_KERNEL_HOME=…` is this invocation's explicit instruction. A value +/// in config.env is a machine-wide default that BOTH the system launcher +/// (/usr/bin/hkm) and a user launcher (~/.local/bin/hkm) read — so treating it +/// as an override let whichever installer wrote it last silently redirect the +/// other install's kernel. Resolution (lib/kernel.zig) demotes a file-sourced +/// pin below self-location for exactly that reason, and needs this to tell them +/// apart after load() has flattened both into one map. +pub const file_keys_marker = "HKM_CONFIG_FILE_KEYS"; + /// Load KEY=VALUE lines into `env`, WITHOUT overriding keys already set in the /// real environment. Silently no-ops if the file is absent. Best-effort. +/// +/// Also records the loaded keys under `file_keys_marker`, so a later reader can +/// ask whether a value was the operator's explicit export or just the config +/// file's default. See `isFileSourced`. pub fn load(allocator: std.mem.Allocator, io: Io, env: *EnvMap) void { const cfg = (path(allocator, env) catch return) orelse return; const content = Dir.cwd().readFileAlloc(io, cfg, allocator, .limited(64 * 1024)) catch return; + + var sourced: std.ArrayList(u8) = .empty; + var lines = std.mem.splitScalar(u8, content, '\n'); while (lines.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); @@ -48,7 +68,25 @@ pub fn load(allocator: std.mem.Allocator, io: Io, env: *EnvMap) void { // Process env wins — only fill in what isn't already set. if (env.get(key) != null) continue; env.put(key, val) catch continue; + + if (sourced.items.len > 0) sourced.append(allocator, ',') catch {}; + sourced.appendSlice(allocator, key) catch {}; + } + + if (sourced.items.len > 0) env.put(file_keys_marker, sourced.items) catch {}; +} + +/// Did `key`'s current value in `env` come from the config file? +/// +/// False for a key the operator exported themselves (load() skips those), and +/// false in any process that never called load(). +pub fn isFileSourced(env: *EnvMap, key: []const u8) bool { + const list = env.get(file_keys_marker) orelse return false; + var it = std.mem.splitScalar(u8, list, ','); + while (it.next()) |k| { + if (std.mem.eql(u8, k, key)) return true; } + return false; } /// Read a single key from the config file (not the environment). Null if absent. @@ -103,3 +141,76 @@ pub fn set(allocator: std.mem.Allocator, io: Io, env: *EnvMap, key: []const u8, // Owner-only: this file may later hold overrides an operator considers private. @import("util.zig").chmod600(io, cfg); } + +/// Remove KEY from the config file, preserving every other line. Returns true +/// when a line was actually removed. +/// +/// The counterpart to `set`, and needed for one specific repair: a stale +/// `HKM_KERNEL_HOME` pointing at an install that no longer exists (or at the +/// OTHER scope's kernel). Repointing it perpetuates a machine-wide pin that +/// both launchers read; deleting it hands resolution back to self-location, +/// where each launcher finds its own kernel and neither can affect the other. +pub fn unset(allocator: std.mem.Allocator, io: Io, env: *EnvMap, key: []const u8) !bool { + const cfg = (try path(allocator, env)) orelse return error.MissingHome; + + const content = Dir.cwd().readFileAlloc(io, cfg, allocator, .limited(64 * 1024)) catch return false; + + var out: std.ArrayList(u8) = .empty; + var removed = false; + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trim(u8, raw, "\r"); + if (line.len == 0) continue; + const eq = std.mem.indexOfScalar(u8, line, '='); + if (eq != null and std.mem.eql(u8, std.mem.trim(u8, line[0..eq.?], " \t"), key)) { + removed = true; + continue; + } + try out.appendSlice(allocator, line); + try out.append(allocator, '\n'); + } + if (!removed) return false; + + try Dir.cwd().writeFile(io, .{ .sub_path = cfg, .data = out.items }); + @import("util.zig").chmod600(io, cfg); + return true; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "isFileSourced separates a config default from an explicit export" { + // The whole point: a value the operator exported is this invocation's + // instruction, while a value from config.env is a machine-wide default that + // BOTH launchers read. Conflating them let a user install's pin redirect the + // system launcher's kernel. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = EnvMap.init(a); + defer env.deinit(); + + try env.put(file_keys_marker, "HKM_KERNEL_HOME,HKM_USERDATA_DIR"); + + try std.testing.expect(isFileSourced(&env, "HKM_KERNEL_HOME")); + try std.testing.expect(isFileSourced(&env, "HKM_USERDATA_DIR")); + try std.testing.expect(!isFileSourced(&env, "HKM_DEV_HOME")); + // A prefix of a listed key must not match — splitting on ',' is what makes + // that true, and a substring search would not. + try std.testing.expect(!isFileSourced(&env, "HKM_KERNEL")); +} + +test "isFileSourced is false when nothing was loaded" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = EnvMap.init(a); + defer env.deinit(); + try env.put("HKM_KERNEL_HOME", "/opt/hkm-kernel"); + + // No marker → the value can only have come from the real environment. + try std.testing.expect(!isFileSourced(&env, "HKM_KERNEL_HOME")); +} diff --git a/tools/src/lib/util.zig b/tools/src/lib/util.zig index 92c9cc5..8b8b580 100644 --- a/tools/src/lib/util.zig +++ b/tools/src/lib/util.zig @@ -51,6 +51,74 @@ pub fn chmod600(io: Io, path: []const u8) void { f.setPermissions(io, @enumFromInt(0o600)) catch {}; } +/// Is `path` a symbolic link? readLink succeeds only on one. +pub fn isSymlink(io: Io, path: []const u8) bool { + var buf: [std.fs.max_path_bytes]u8 = undefined; + _ = Dir.cwd().readLink(io, path, &buf) catch return false; + return true; +} + +/// snake_case, from any spelling: "AddWidgets"/"addWidgets"/"add widgets" all +/// become "add_widgets". An underscore already present is preserved — which is +/// the whole point, since studly()+lower() destroys it. +pub fn snake(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + var prev_lower = false; + for (input) |c| { + if (c == ' ' or c == '-' or c == '.' or c == '/') { + if (out.items.len > 0 and out.items[out.items.len - 1] != '_') try out.append(allocator, '_'); + prev_lower = false; + continue; + } + if (c >= 'A' and c <= 'Z') { + // Boundary only after a lower-case run, so "HTTPClient" does not + // become "h_t_t_p_client". + if (prev_lower and out.items.len > 0) try out.append(allocator, '_'); + try out.append(allocator, c - 'A' + 'a'); + prev_lower = false; + continue; + } + try out.append(allocator, c); + prev_lower = (c >= 'a' and c <= 'z') or (c >= '0' and c <= '9'); + } + return out.toOwnedSlice(allocator); +} + +/// Drop `suffix` from the end of `name`, case-insensitively. Used so +/// `make:seeder WidgetSeeder` produces WidgetSeeder.php, not +/// WidgetSeederSeeder.php. +pub fn stripSuffix(name: []const u8, suffix: []const u8) []const u8 { + if (name.len <= suffix.len) return name; + const tail = name[name.len - suffix.len ..]; + var i: usize = 0; + while (i < suffix.len) : (i += 1) { + const a = if (tail[i] >= 'A' and tail[i] <= 'Z') tail[i] - 'A' + 'a' else tail[i]; + const b = if (suffix[i] >= 'A' and suffix[i] <= 'Z') suffix[i] - 'A' + 'a' else suffix[i]; + if (a != b) return name; + } + return name[0 .. name.len - suffix.len]; +} + +/// Where a symlink points, duped into `allocator`; null when `path` is not one. +pub fn linkTarget(allocator: std.mem.Allocator, io: Io, path: []const u8) ?[]const u8 { + var buf: [std.fs.max_path_bytes]u8 = undefined; + // readLink returns the byte count written into the buffer, not a slice. + const n = Dir.cwd().readLink(io, path, &buf) catch return null; + return allocator.dupe(u8, buf[0..n]) catch null; +} + +/// Make a file executable (0755). No-op on Windows. +/// +/// A plain read-then-write copy does NOT carry the mode across, so a launcher +/// copied that way lands as 0644 and cannot be run — the install looks like it +/// worked right up until the first invocation. +pub fn chmodExec(io: Io, path: []const u8) void { + if (@import("builtin").os.tag == .windows) return; + const f = Dir.cwd().openFile(io, path, .{}) catch return; + defer f.close(io); + f.setPermissions(io, @enumFromInt(0o755)) catch {}; +} + // ── path strings ──────────────────────────────────────────────────────────── /// Trim trailing path separators (keeps a lone "/"). @@ -230,3 +298,27 @@ pub fn appendJsonString(allocator: std.mem.Allocator, out: *std.ArrayList(u8), s try out.append(allocator, '"'); } +test "snake_case keeps underscores the caller already wrote" { + const a = std.testing.allocator; + const cases = [_]struct { in: []const u8, want: []const u8 }{ + .{ .in = "add_widgets", .want = "add_widgets" }, + .{ .in = "AddWidgets", .want = "add_widgets" }, + .{ .in = "addWidgets", .want = "add_widgets" }, + .{ .in = "add widgets", .want = "add_widgets" }, + .{ .in = "add_widgets_to_orders", .want = "add_widgets_to_orders" }, + .{ .in = "widgets", .want = "widgets" }, + }; + for (cases) |c| { + const got = try snake(a, c.in); + defer a.free(got); + try std.testing.expectEqualStrings(c.want, got); + } +} + +test "an existing suffix is not doubled" { + try std.testing.expectEqualStrings("Widget", stripSuffix("WidgetSeeder", "Seeder")); + try std.testing.expectEqualStrings("Widget", stripSuffix("Widgetseeder", "Seeder")); + try std.testing.expectEqualStrings("Widget", stripSuffix("Widget", "Seeder")); + // Not a suffix, merely a substring. + try std.testing.expectEqualStrings("SeederThing", stripSuffix("SeederThing", "Seeder")); +} diff --git a/tools/src/main.zig b/tools/src/main.zig index 0c0a56c..6439d39 100644 --- a/tools/src/main.zig +++ b/tools/src/main.zig @@ -10,6 +10,7 @@ const ui_cmd = @import("commands/ui.zig"); const cli_cmd = @import("commands/cli.zig"); const doctor_cmd = @import("commands/doctor.zig"); const upgrade_cmd = @import("commands/upgrade.zig"); +const version_cmd = @import("commands/version.zig"); const kernel = @import("lib/kernel.zig"); const util = @import("lib/util.zig"); const userconfig = @import("lib/userconfig.zig"); @@ -17,8 +18,8 @@ const banner = @import("lib/banner.zig"); const prompt = @import("lib/prompt.zig"); const memory = @import("lib/memory.zig"); -fn printHelp() void { - banner.print(); +fn printHelp(allocator: std.mem.Allocator, io: std.Io, env: *std.process.Environ.Map) void { + banner.print(allocator, io, env); prompt.section("Usage"); prompt.item("hkm new [opts]", "scaffold a new PhpServicePlatform project"); @@ -31,10 +32,10 @@ fn printHelp() void { prompt.item("hkm module [create|delete]", "scaffold a first-party kernel package (modules/)"); prompt.item("hkm ui [sync|list|link|clean]", "federate enabled plugins' UIs into the frontend"); prompt.item("hkm update ", "refresh a project's kernel registry entry"); - prompt.item("hkm upgrade [--check]", "check for / apply a kernel update"); - prompt.item("hkm upgrade --local", "install THIS checkout over the installed kernel"); + prompt.item("hkm upgrade [--check]", "update YOUR install; sudo hkm upgrade updates the system one"); + prompt.item("hkm upgrade --local", "install THIS checkout over an installed kernel"); prompt.item("hkm doctor", "diagnose the local environment"); - prompt.item("hkm version", "show the HKM banner + version (also --version, -v)"); + prompt.item("hkm version", "kernel version in each install scope (also --version, -v)"); prompt.item("hkm help", "show this help"); prompt.item("hkm --dev", "use the development kernel (this monorepo) instead of the installed stable copy"); prompt.item("hkm --mem", "print the memory inspector dashboard when the command finishes (debug builds)"); @@ -239,13 +240,13 @@ fn dispatch(init: std.process.Init.Minimal, mm: *memory.Manager) !u8 { } if (args.len <= 1) { - printHelp(); + printHelp(allocator, io, &env_map); return 0; } const cmd = args[1]; if (std.mem.eql(u8, cmd, "help") or std.mem.eql(u8, cmd, "--help") or std.mem.eql(u8, cmd, "-h")) { - printHelp(); + printHelp(allocator, io, &env_map); return 0; } if (std.mem.eql(u8, cmd, "--version") or std.mem.eql(u8, cmd, "-v")) { @@ -253,8 +254,9 @@ fn dispatch(init: std.process.Init.Minimal, mm: *memory.Manager) !u8 { return 0; } if (std.mem.eql(u8, cmd, "version")) { - banner.print(); - return 0; + var scope = CmdScope.begin(mm, "version"); + defer scope.end(); + return try version_cmd.run(scope.allocator(), io, &env_map, args); } if (std.mem.eql(u8, cmd, "upgrade") or std.mem.eql(u8, cmd, "self-update")) { var scope = CmdScope.begin(mm, "upgrade"); diff --git a/tools/src/stamp.zig b/tools/src/stamp.zig index 65d6609..602a197 100644 --- a/tools/src/stamp.zig +++ b/tools/src/stamp.zig @@ -2,34 +2,25 @@ //! //! stamp //! -//! Run from build.zig so a versioned build carries its version everywhere, -//! not just in the compiled binary. +//! Run from build.zig so a versioned build carries its version everywhere, not +//! just in the compiled binary. //! -//! WHY THIS IS NARROW ON PURPOSE -//! ----------------------------- -//! A hard-coded "version" in composer.json normally does more harm than good: -//! Composer derives a package's version from its git tags, and a literal field -//! OVERRIDES that. Once the two can disagree, they eventually do — someone tags -//! v1.2.0 and forgets the field, and every consumer resolves the stale number -//! with no error anywhere. This repository has already been bitten by it once -//! (phpshots/bind-it pinned "0.1.3" in composer.json and its real tags were -//! ignored). +//! The parsing, validation and rewriting all live in lib/composer_version.zig, +//! because `hkm version` and `hkm upgrade` READ the field this writes. When the +//! two halves lived in separate copies, a reader and a writer that disagreed +//! about what counts as a version would surface as an installed kernel +//! reporting the wrong number, with nothing pointing at the cause. //! -//! It earns its place here for one reason: the native distribution ships -//! WITHOUT a .git directory. A .deb or a zip has no tags to derive from, so the -//! field is the only version marker the installed kernel has. -//! -//! Hence the rule build.zig applies: stamp only when an explicit -Dversion was -//! passed — which is what tools/bundle.sh does for a release. A plain `zig build` -//! leaves composer.json untouched, so a dev build never dirties the working tree -//! with "0.0.0-dev" that someone then commits by accident. -//! -//! The edit is textual rather than a JSON re-serialise so the file keeps its -//! hand-maintained key order, indentation and comments-by-convention. Rewriting -//! it through a JSON encoder would reorder every key and produce an unreadable -//! diff on every release. +//! WHY STAMPING IS NARROW ON PURPOSE +//! --------------------------------- +//! build.zig stamps only when an explicit -Dversion was passed — which is what +//! tools/bundle.sh does for a release. A plain `zig build` leaves composer.json +//! untouched, so a dev build never dirties the working tree with "0.0.0-dev" +//! that someone then commits by accident. See lib/composer_version.zig for why +//! the field is a liability in a git checkout and necessary in a bundle. const std = @import("std"); +const composer_version = @import("lib/composer_version.zig"); pub fn main(init: std.process.Init.Minimal) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); @@ -47,166 +38,50 @@ pub fn main(init: std.process.Init.Minimal) !void { } const path = args[1]; - const version = std.mem.trim(u8, args[2], " \t\r\nv"); - + const version = composer_version.normalize(args[2]); if (version.len == 0) return; // nothing meaningful to stamp + // A version Composer cannot parse is far worse than no version at all: + // `composer install` ABORTS on it, so the package never resolves its + // dependencies. That happened for real — "1.1.0-dev.2" was stamped from the + // git tag, and every install of that release failed with + // + // "./composer.json" does not match the expected JSON schema: + // - version : Does not match the regex pattern ... + // + // Composer's `dev` suffix takes NO counter ("1.1.0-dev" is valid, + // "1.1.0-dev.2" and "1.1.0-dev2" are not). Rather than rewrite the version + // into something Composer likes — which would make composer.json disagree + // with the tag it was built from — the field is simply left out. It is + // optional; a broken install is not. + if (!composer_version.composerValid(version)) { + // A `git describe` version ("1.1.0-dev.2-12-g29dccfb") is what every + // build from a checkout between releases looks like. It is EXPECTED to + // be unstampable, so saying so on every single dev build trains people + // to ignore the message — and then they ignore it on the release build + // where it matters. Skip quietly for that shape; warn for anything else. + if (composer_version.isDescribeVersion(version)) return; + + // A version longer than the buffer would make bufPrint fail, and + // returning there skipped the marker with NO diagnostic at all — the + // silent failure this warning exists to prevent. Fall back to a fixed + // message so every rejected version is reported. + var buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint( + &buf, + "stamp: '{s}' is not a valid Composer version — leaving composer.json alone.\n" ++ + " (Composer accepts 1.2.3, 1.2.3-dev, 1.2.3-beta.4, 1.2.3-RC1; a 'dev' suffix takes no number.)\n", + .{version}, + ) catch + "stamp: the requested version is not valid for Composer — leaving composer.json alone.\n"; + std.Io.File.stderr().writeStreamingAll(io, msg) catch {}; + return; + } + // A missing composer.json is not a build failure: the same build.zig runs // in checkouts and in staging trees that do not carry one. const source = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(8 * 1024 * 1024)) catch return; - const updated = try stamp(allocator, source, version) orelse return; // already correct + const updated = try composer_version.stamp(allocator, source, version) orelse return; // already correct try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = updated }); } - -/// Return the file with `version` applied, or null when it is already correct. -/// -/// Exposed for testing. -pub fn stamp(allocator: std.mem.Allocator, source: []const u8, version: []const u8) !?[]const u8 { - if (findVersionValue(source)) |span| { - if (std.mem.eql(u8, source[span.start..span.end], version)) return null; // no-op - var out: std.ArrayList(u8) = .empty; - try out.appendSlice(allocator, source[0..span.start]); - try out.appendSlice(allocator, version); - try out.appendSlice(allocator, source[span.end..]); - return try out.toOwnedSlice(allocator); - } - - // No "version" key: insert one directly after "name", which is where a - // reader looks for it and where composer's own docs put it. - const anchor = std.mem.indexOf(u8, source, "\"name\"") orelse return null; - const line_end = std.mem.indexOfScalarPos(u8, source, anchor, '\n') orelse return null; - - const indent = detectIndent(source, anchor); - - var out: std.ArrayList(u8) = .empty; - try out.appendSlice(allocator, source[0 .. line_end + 1]); - try out.appendSlice(allocator, indent); - try out.appendSlice(allocator, "\"version\": \""); - try out.appendSlice(allocator, version); - try out.appendSlice(allocator, "\",\n"); - try out.appendSlice(allocator, source[line_end + 1 ..]); - return try out.toOwnedSlice(allocator); -} - -const Span = struct { start: usize, end: usize }; - -/// Byte range of the STRING VALUE of a top-level "version" key. -fn findVersionValue(source: []const u8) ?Span { - var search: usize = 0; - while (std.mem.indexOfPos(u8, source, search, "\"version\"")) |key_at| { - search = key_at + 9; - - // Step over whitespace and the colon. - var i = key_at + 9; - while (i < source.len and (source[i] == ' ' or source[i] == '\t')) i += 1; - if (i >= source.len or source[i] != ':') continue; - i += 1; - while (i < source.len and (source[i] == ' ' or source[i] == '\t')) i += 1; - if (i >= source.len or source[i] != '"') continue; - - const start = i + 1; - const end = std.mem.indexOfScalarPos(u8, source, start, '"') orelse return null; - return .{ .start = start, .end = end }; - } - return null; -} - -/// The leading whitespace of the line containing `pos`, so an inserted key -/// matches the file's existing indentation rather than imposing a new one. -fn detectIndent(source: []const u8, pos: usize) []const u8 { - var line_start = pos; - while (line_start > 0 and source[line_start - 1] != '\n') line_start -= 1; - - var i = line_start; - while (i < source.len and (source[i] == ' ' or source[i] == '\t')) i += 1; - return source[line_start..i]; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -test "replaces an existing version value" { - const a = std.testing.allocator; - const src = - \\{ - \\ "name": "alfacode-team/php-service-platform", - \\ "version": "1.0.0", - \\ "type": "library" - \\} - ; - const out = (try stamp(a, src, "1.0.21")).?; - defer a.free(out); - - try std.testing.expect(std.mem.indexOf(u8, out, "\"version\": \"1.0.21\"") != null); - try std.testing.expect(std.mem.indexOf(u8, out, "1.0.0") == null); - // Everything else must be untouched. - try std.testing.expect(std.mem.indexOf(u8, out, "\"type\": \"library\"") != null); -} - -test "inserts the key after name when absent, matching indentation" { - const a = std.testing.allocator; - const src = - \\{ - \\ "name": "alfacode-team/php-service-platform", - \\ "type": "library" - \\} - ; - const out = (try stamp(a, src, "1.0.21")).?; - defer a.free(out); - - try std.testing.expect(std.mem.indexOf(u8, out, " \"version\": \"1.0.21\",\n") != null); - // It must still parse. - var arena = std.heap.ArenaAllocator.init(a); - defer arena.deinit(); - const parsed = try std.json.parseFromSliceLeaky(std.json.Value, arena.allocator(), out, .{}); - try std.testing.expectEqualStrings("1.0.21", parsed.object.get("version").?.string); -} - -test "an already-correct version is a no-op" { - // Returning null keeps the build from rewriting the file (and dirtying the - // working tree) on every single invocation. - const a = std.testing.allocator; - const src = - \\{ - \\ "name": "x/y", - \\ "version": "1.0.21" - \\} - ; - try std.testing.expect((try stamp(a, src, "1.0.21")) == null); -} - -test "does not mistake a nested version for the package's own" { - // "require" blocks are full of version-looking keys; only a top-level - // "version" KEY should ever be rewritten. - const a = std.testing.allocator; - const src = - \\{ - \\ "name": "x/y", - \\ "require": { "php": ">=8.4" } - \\} - ; - const out = (try stamp(a, src, "2.0.0")).?; - defer a.free(out); - - var arena = std.heap.ArenaAllocator.init(a); - defer arena.deinit(); - const parsed = try std.json.parseFromSliceLeaky(std.json.Value, arena.allocator(), out, .{}); - try std.testing.expectEqualStrings("2.0.0", parsed.object.get("version").?.string); - try std.testing.expectEqualStrings(">=8.4", parsed.object.get("require").?.object.get("php").?.string); -} - -test "a leading v is stripped so composer sees a bare version" { - const a = std.testing.allocator; - const src = - \\{ - \\ "name": "x/y" - \\} - ; - // main() trims the 'v'; stamp() receives it already trimmed. Assert the - // shape composer expects. - const out = (try stamp(a, src, "1.0.21")).?; - defer a.free(out); - try std.testing.expect(std.mem.indexOf(u8, out, "\"version\": \"v") == null); -} diff --git a/tools/src/templates/app/bootstrap/app.php b/tools/src/templates/app/bootstrap/app.php index 3a4ce5c..01b0811 100644 --- a/tools/src/templates/app/bootstrap/app.php +++ b/tools/src/templates/app/bootstrap/app.php @@ -82,19 +82,16 @@ // Plugins — module providers (registered into the kernel below). use Plugins\Crypto\Provider as CryptoProvider; -use Plugins\I18n\Provider as I18nProvider; +use Plugins\Logger\Provider as LoggerProvider; use Plugins\Database\Provider as DatabaseProvider; use Plugins\Commands\Provider as CommandsProvider; use Plugins\Storage\Provider as StorageProvider; -use Plugins\HttpClient\Provider as HttpClientProvider; +use Plugins\Validation\Provider as ValidationProvider; use Plugins\Session\Provider as SessionProvider; use Plugins\Cookie\Provider as CookieProvider; use Plugins\RedisCache\Provider as RedisCacheProvider; -use Plugins\SiteSEO\Application\Listeners\EnqueueIndexNowListener; -use Plugins\SiteSEO\Provider as SiteSeoModule; use Plugins\View\Provider as ViewModule; use Plugins\SecurityFilters\Provider as SecurityFiltersModule; -use Plugins\Edge\Provider as EdgeProvider; // Flat layout: this directory's grandparent is the project root. @@ -190,12 +187,16 @@ // when REDIS_HOST is set. Lets `php app/worker/run.php` drain real jobs. QueuePort::class => static fn(): FileQueue => new FileQueue($projectRoot . '/var/queue'), - // The SEO module subscribes EnqueueIndexNowListener to seo.url_published, but - // the EventBus resolves listeners from the CoreContainer — so bind it here - // with the QueuePort. (The factory receives the container.) - EnqueueIndexNowListener::class => static fn($c) => new EnqueueIndexNowListener( - $c->make(QueuePort::class), - ), + // ── When you enable the User + Tenancy plugins ─────────────────────────── + // The User plugin subscribes ProvisionTenantProfileListener to user.registered + // to write the per-tenant user_profiles row. The EventBus resolves listeners + // from the CoreContainer, so bind it here WITH Tenancy's connection resolver + // (same pattern as the SEO listener above). Left unbound it safely no-ops. + // + // \Plugins\User\Infrastructure\Listeners\ProvisionTenantProfileListener::class + // => static fn($c) => new \Plugins\User\Infrastructure\Listeners\ProvisionTenantProfileListener( + // $c->make(\Plugins\Tenancy\API\Contracts\TenantConnectionResolverContract::class), + // ), ]; if (filter_var($env('DB_POOL_ENABLED', 'false'), FILTER_VALIDATE_BOOL)) { @@ -234,6 +235,21 @@ // the synthetic '__project__' scope — no module register() runs for them. // Keep these controllers thin; real domain logic lives in plugins. ->withRoutes(EntryHelpers::projectRoutes($projectRoot)) + // Route GROUPS from proj.json: a prefix / filters / requires / name + // prefix / SITE stated once for every route inside the group, and + // expanded into flat routes at boot. `site` is part of the route key, + // so one project can answer `GET /` differently per group of hosts. + ->withRouteGroups(EntryHelpers::projectRouteGroups($projectRoot)) + // The hosts this project serves. A route grouped under a domain that is + // not in proj.json "domains" fails the boot — nothing could ever reach it. + ->withProjectDomains(EntryHelpers::projectDomains($projectRoot)) + + // Project ROUTE POLICY declared in proj.json ("routePolicy": {"disable": []}). + // A plugin OWNS its routes, but the project is the final authority: it can + // veto specific plugin routes ("METHOD /path") or a whole plugin's routes (a + // module domain) without forking the plugin. Applied to plugin routes before + // project routes compile — an unmatched spec fails the boot. + ->withRoutePolicy(EntryHelpers::projectRoutePolicy($projectRoot)) // Security layers run BEFORE any module loads — a denied request costs zero // module work. CsrfTokenLayer here is a stateless, HMAC-signed token @@ -255,21 +271,30 @@ // in. Use for capabilities only SOME routes need (views, outbound HTTP, // storage). A route opts in via its "requires" in proj.json / module.json. ->withModules([ - // Crypto (solves: crypto) — provides the concrete AesEncrypter and + // Logger (solves: logging.application) — supplies the LoggerPort adapter. + // Channel/level come from config/logger.php (LOG_CHANNEL, LOG_LEVEL, + // LOG_FILE). Keep this registered: components that log (Database, + // Tenancy, EventBus, command auditing) degrade to silence without it, + // and silent logging is indistinguishable from nothing having happened. + LoggerProvider::class, + + // Crypto (solves: crypto.services) — provides the concrete AesEncrypter and // PasswordHasher classes behind the Encryption/Hashing port factories, // plus crypto helpers other modules consume. CryptoProvider::class, - // I18n (solves: i18n) — translation/localisation: message catalogues, - // locale negotiation, and the translator used by modules and views. - I18nProvider::class, + // Validation (solves: validation.rules) — the shared request-validation + // engine. Its boot() loads config/validation.php and registers the + // CommonRules + FinancialRules packs. DTOs extend Plugins\Validation\ + // AbstractDto; built-in rules work without this, the packs need it. + ValidationProvider::class, - // Database (solves: database.query) — the multi-driver database stack: + // Database (solves: database.management) — the multi-driver database stack: // the DatabasePort adapter, the pooled adapter that borrows from the // ConnectionPool, and connection/schema management. DatabaseProvider::class, - // Commands (solves: commands) — registers this project's console + // Commands (solves: system.commands) — registers this project's console // commands into the CLI pipeline (run via `php app/cli/run.php`). CommandsProvider::class, @@ -279,25 +304,37 @@ // "requires": ["storage.local"]. StorageProvider::class, - // HttpClient (solves: http.client) — the HttpClientPort for OUTBOUND - // HTTP (calling third-party APIs from gateways). Required by SiteSEO. - HttpClientProvider::class, - // View (solves: view.rendering) — server-side PHP templating: layouts, // sections, the project-first view cascade and `namespace::view` // resolution. Routes opt in via "requires": ["view.rendering"]. ViewModule::class, - // SiteSEO (solves: seo.management) — SEO toolkit: sitemaps, Open Graph, - // JSON-LD, robots, IndexNow. Exposes SeoServiceContract + the /api/seo/* - // routes. Needs http.client (above) for its network actions. - SiteSeoModule::class, - - // Edge (solves: edge.routing) — generates the host's web-server front - // config (nginx SNI stream splitter / nginx-only / Apache vhost) from the - // platform's registered domains. CLI-first: `hkm edge:status`, - // `hkm edge:apply`. Routes opt in via "requires": ["edge.routing"]. - EdgeProvider::class, + // Edge (solves: edge.routing) — generates this host's web-server front + // config from the project's domains: an nginx SNI stream splitter when + // nginx+Apache both run, else a plain nginx/Apache vhost (docroot + // app/public, PHP-FPM or Swoole) with the run-env injected. Local + // (.local/.test) domains go to /etc/hosts instead (dev only). + // CLI: `hkm cli -p edge:status | edge:apply | edge:hosts`. + \Plugins\Edge\Provider::class, + + // ── Not installed — add when you need them ─────────────────────── + // Each is one command; it fetches the plugin, its dependencies, and + // wires them into this list for you. + // + // hkm plugins install i18n // i18n.translation — __(), locales + // hkm plugins install http-client // http.client — outbound HTTP + // hkm plugins install siteseo // seo.management — sitemaps, JSON-LD + // // (also needs http-client, and a + // // QueuePort-bound EnqueueIndexNowListener + // // in withPorts() for index-on-publish) + + // Identity stack (enable together in an app that needs accounts): + // \Plugins\User\Provider::class, // user.management (identity + settings) + // \Plugins\Feedback\Provider::class, // feedback.management (/ajx/feedback) + // \Plugins\Auth\Provider::class, // auth.identity (login/tokens) + // \Plugins\Tenancy\Provider::class, // tenancy.routing (multi-tenant) + // The User plugin queues a verification email on signup ONLY when a + // MailPort is bound in withPorts() above (else it is skipped). ]) // ESSENTIAL modules: registered into EVERY request container regardless of @@ -326,6 +363,22 @@ SecurityFiltersModule::class, ]) + // PROJECT-DECLARED essentials from proj.json ("essentials": [ ... ]) — each + // entry is a module DOMAIN (a plugin's solves value). This is the project's + // lever for which plugins are global WITHOUT editing this file: e.g. a + // multi-tenant project declares "tenancy.routing" here, a single-tenant one + // simply doesn't. The named module must be in withModules() above; the + // kernel resolves the domain at build() and an unknown domain FAILS the + // boot (never a silent no-op). Keep this list SHORT — every essential (and + // its requires[] graph) registers on every request. + // + // Session-cookie login: Auth's SessionAuthStage resolves the logged-in user + // on a route ONLY when auth.identity + user.management are in that request's + // graph (the stage self-guards otherwise). An app where users stay signed in + // across ALL pages therefore declares BOTH here; a JWT/PAT-only API needs + // neither (token layers run before any module loads). + ->withEssentialModules(EntryHelpers::projectEssentials($projectRoot)) + // Compile-only. Returns the Kernel to the entry point, which materializes it // on the first http()/cli() call. ->build(); diff --git a/tools/src/templates/app/bootstrap/kernel-autoload.php b/tools/src/templates/app/bootstrap/kernel-autoload.php index 3f10307..455a3e5 100644 --- a/tools/src/templates/app/bootstrap/kernel-autoload.php +++ b/tools/src/templates/app/bootstrap/kernel-autoload.php @@ -39,7 +39,8 @@ * `composer require` the kernel * locally, this alone is enough and * the steps below are skipped. - * 2. $PSP_GLOBAL_AUTOLOAD — explicit override env var. Point + * 2. $HKM_KERNEL_HOME/vendor/autoload.php — the installed kernel. + * 2b. $PSP_GLOBAL_AUTOLOAD — explicit override env var. Point * it at any vendor/autoload.php * (e.g. the monorepo's) to reuse a * specific kernel + its plugins. @@ -105,20 +106,47 @@ function psp_require_kernel_autoload(): void $candidates[] = $explicit; } - // (3) Composer's configured home directory, if COMPOSER_HOME is set. + // (3) The installed kernel, via HKM_KERNEL_HOME. + // + // This is how `hkm` installs itself — a system install under + // /opt/hkm-kernel, or a user install under ~/.local/lib/hkm-kernel — + // and without it that kernel is invisible to PHP. `hkm run` papered + // over the gap by exporting PSP_GLOBAL_AUTOLOAD for its child, so the + // dev server worked and NOTHING else did: the same project served by + // nginx/PHP-FPM, or a worker started by systemd, or a plain + // `php app/cli/run.php`, died on "Could not load the global kernel + // autoload" with a correctly installed kernel sitting on disk. + $kernelHome = getenv('HKM_KERNEL_HOME'); + if (is_string($kernelHome) && $kernelHome !== '') { + $candidates[] = rtrim($kernelHome, '/\\') . '/vendor/autoload.php'; + } + + // (4) Composer's configured home directory, if COMPOSER_HOME is set. $composerHome = getenv('COMPOSER_HOME'); if (is_string($composerHome) && $composerHome !== '') { $candidates[] = rtrim($composerHome, '/\\') . '/vendor/autoload.php'; } - // (4)+(5) Default global Composer homes on Linux/macOS. + // (5)+(6) Default global Composer homes on Linux/macOS, plus the user + // install path — the one place a kernel lands when the operator has no + // root and never exported anything. + // + // The user path is tried BEFORE the system one below. A machine can + // hold both, and the user install is the one that user chose to manage + // (`hkm upgrade` targets it without root); falling to /opt first would + // run a kernel they may not even have write access to. $home = getenv('HOME'); if (is_string($home) && $home !== '') { $home = rtrim($home, '/\\'); $candidates[] = $home . '/.config/composer/vendor/autoload.php'; // current default $candidates[] = $home . '/.composer/vendor/autoload.php'; // legacy default + $candidates[] = $home . '/.local/lib/hkm-kernel/vendor/autoload.php'; // install.sh / hkm upgrade --user + $candidates[] = $home . '/.local/share/hkm/kernel/vendor/autoload.php'; // pre-1.4 --user target } + // (7) The system install path used by the .deb. + $candidates[] = '/opt/hkm-kernel/vendor/autoload.php'; + // Try each candidate; the first one that makes the kernel class // resolvable wins and we return immediately. foreach ($candidates as $autoload) { diff --git a/tools/src/templates/app/public/index.php b/tools/src/templates/app/public/index.php index 39225c8..b70739b 100644 --- a/tools/src/templates/app/public/index.php +++ b/tools/src/templates/app/public/index.php @@ -46,7 +46,14 @@ // attribute (never via a global — coroutine/Swoole safe). $request = Request::capture(); if (isset($domain) && $domain !== null) { - $request = $request->withAttribute('domain', $domain); + $request = $request + ->withAttribute('domain', $domain) + // The FACE (admin/api/project/public) and the HOST let a route declare + // where it exists. Both come from the host DomainResolver already + // VALIDATED against projects.json — never the raw Host header, which + // the client controls and could otherwise pick its own route table. + ->withAttribute('route_face', $domain->type->value) + ->withAttribute('route_host', $domain->host); } // Run the HTTP pipeline (security → resolve → load → execute) and emit the diff --git a/tools/src/templates/app/swoole/index.php b/tools/src/templates/app/swoole/index.php index dfc7b3f..7688c09 100644 --- a/tools/src/templates/app/swoole/index.php +++ b/tools/src/templates/app/swoole/index.php @@ -135,7 +135,12 @@ $hostHeader = $req->header['host'] ?? null; $domain = EntryHelpers::resolveDomain($rootPath, is_string($hostHeader) ? $hostHeader : null); if ($domain !== null) { - $request = $request->withAttribute('domain', $domain); + $request = $request + ->withAttribute('domain', $domain) + // Face + host come from the VALIDATED host (see the FPM entry point + // for why the raw Host header must never select a route table). + ->withAttribute('route_face', $domain->type->value) + ->withAttribute('route_host', $domain->host); } $response = $kernel->http()->handle($request); diff --git a/tools/src/templates/frontend/docs/HOW_IT_WORKS.md b/tools/src/templates/frontend/docs/HOW_IT_WORKS.md index 200f2a6..448c8b3 100644 --- a/tools/src/templates/frontend/docs/HOW_IT_WORKS.md +++ b/tools/src/templates/frontend/docs/HOW_IT_WORKS.md @@ -237,9 +237,72 @@ See `plugins/User/ui/README.md` for a complete worked example (admin list/detail | `Link` | `@pageflow/react` | in-app navigation (no full reload); `only`, `as`, `preserveScroll` | | `useForm` | `@pageflow/react` | forms with CSRF, `processing`, `errors` | | `router` | `@pageflow/react` | imperative visits / partial reloads (`only: [...]`) | +| `AdminLayout`, `useSetPageHeader`, `ResourceListShell`, `DataTable` | `@pageflow/admin` | the admin shell + kit (see below) | | `Button`, `Dialog`, … | `@ui/*` | the shared shadcn design system (49 components) | | `cn` | `@lib/utils` | Tailwind class merge | -| `useTheme`, `ThemeProvider` | `@providers/theme` | light/dark | +| `useTheme`, `ThemeProvider` | `@providers/theme` | light / dark / system | + +--- + +## The admin shell — `@pageflow/admin` + +A third Pageflow entry point (beside `core` and `react`) carrying the admin +shell, the navigation registry and the domain-free building blocks. Nothing in +`@pageflow/core` or `@pageflow/react` imports it, so a public-only surface never +bundles it. + +### Attach the layout as a PERSISTENT layout + +```tsx +import type { ReactNode } from "react"; +import { AdminLayout, useSetPageHeader } from "@pageflow/admin"; + +export default function Sales() { + useSetPageHeader({ title: "Sales", actions: [{ label: "Export", onClick: exportCsv }] }); + return
; +} + +Sales.layout = (page: ReactNode) => {page}; +``` + +Pageflow applies `Component.layout` **outside** the swapped page, so the shell +survives navigation — sidebar scroll, open menus and the nav overflow +calculation are all preserved. Wrapping the page's own return instead remounts +the entire sidebar on every click. + +`AdminLayout` takes no data props: it reads the reserved **`adminShell`** shared +prop (user, tenant, switchable tenants, feature flags, logout/account/settings +URLs). Share it once server-side and every page has it. + +`AuthLayout` is the nav-free equivalent for login / register / consent pages. + +### Contribute a sidebar section from a plugin + +Each plugin declares its own navigation in `ui/admin/nav.ts`; the admin surface +globs `/plugins/*/admin/nav.ts`, so the registry never names a business domain: + +```ts +import { Building2 } from "lucide-react"; +import { registerModule, registerFeature } from "@pageflow/admin"; + +registerFeature({ id: "rental", label: "Rental management" }); + +registerModule({ + id: "rental", + sectionLabel: "Rental", + order: 40, + features: ["rental"], // hidden unless proj.json enables it + items: [{ id: "properties", label: "Properties", icon: Building2, + path: "/admin/rental/properties" }], +}); +``` + +Visibility is driven by the server: `proj.json` `features[]` → +`DomainContext->features` → `adminShell.features`. A flag matching nothing logs a +warning naming it rather than silently doing nothing. + +Full reference — every export, the settings-tab registry, the list/table kit: +`plugins/hkm-plugin-pageflow/ui/admin/README.md`. Add more shadcn components with `npx shadcn add ` (writes into `src/shared/ui/`, driven by `components.json`). diff --git a/tools/src/templates/frontend/src/shared/providers/theme.tsx b/tools/src/templates/frontend/src/shared/providers/theme.tsx index e30a09b..0b9a23a 100644 --- a/tools/src/templates/frontend/src/shared/providers/theme.tsx +++ b/tools/src/templates/frontend/src/shared/providers/theme.tsx @@ -1,22 +1,92 @@ import * as React from "react"; -type Theme = "light" | "dark"; -const ThemeContext = React.createContext<{ theme: Theme; toggle: () => void }>({ - theme: "light", +/** + * Three-state theme: an explicit choice, or "system" (the default) which follows + * the OS and keeps following it if the OS setting changes mid-session. + * + * The theme belongs to the PROJECT, not to a plugin — `@pageflow/admin`'s + * `` is only a control over this context, so two plugins can never + * end up fighting over the app's appearance. + */ +export type Theme = "light" | "dark" | "system"; + +/** What is actually painted — "system" resolved against the OS preference. */ +export type ResolvedTheme = "light" | "dark"; + +interface ThemeContextValue { + /** The user's choice, including "system". */ + theme: Theme; + /** What that currently resolves to. */ + resolvedTheme: ResolvedTheme; + setTheme: (theme: Theme) => void; + /** Flip between light and dark. From "system" it flips away from the OS value. */ + toggle: () => void; +} + +const STORAGE_KEY = "theme"; + +const ThemeContext = React.createContext({ + theme: "system", + resolvedTheme: "light", + setTheme: () => {}, toggle: () => {}, }); -/** Minimal light/dark provider — swap for your real one as the app grows. */ -export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setTheme] = React.useState( - () => (localStorage.getItem("theme") as Theme) || "light", - ); +function systemTheme(): ResolvedTheme { + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +function storedTheme(): Theme { + if (typeof window === "undefined") return "system"; + const stored = window.localStorage.getItem(STORAGE_KEY); + return stored === "light" || stored === "dark" || stored === "system" ? stored : "system"; +} + +export function ThemeProvider({ + children, + defaultTheme = "system", +}: { + children: React.ReactNode; + defaultTheme?: Theme; +}) { + const [theme, setThemeState] = React.useState(() => storedTheme() ?? defaultTheme); + const [systemPref, setSystemPref] = React.useState(systemTheme); + + // Keep following the OS while the choice is "system". + React.useEffect(() => { + const query = window.matchMedia("(prefers-color-scheme: dark)"); + const onChange = (event: MediaQueryListEvent) => + setSystemPref(event.matches ? "dark" : "light"); + + setSystemPref(query.matches ? "dark" : "light"); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); + }, []); + + const resolvedTheme: ResolvedTheme = theme === "system" ? systemPref : theme; + React.useEffect(() => { - document.documentElement.classList.toggle("dark", theme === "dark"); - localStorage.setItem("theme", theme); - }, [theme]); - const toggle = () => setTheme((t) => (t === "light" ? "dark" : "light")); - return {children}; + document.documentElement.classList.toggle("dark", resolvedTheme === "dark"); + document.documentElement.style.colorScheme = resolvedTheme; + }, [resolvedTheme]); + + const setTheme = React.useCallback((next: Theme) => { + setThemeState(next); + window.localStorage.setItem(STORAGE_KEY, next); + }, []); + + const value = React.useMemo( + () => ({ + theme, + resolvedTheme, + setTheme, + toggle: () => setTheme(resolvedTheme === "dark" ? "light" : "dark"), + }), + [theme, resolvedTheme, setTheme], + ); + + return {children}; } export const useTheme = () => React.useContext(ThemeContext); diff --git a/tools/src/templates/frontend/src/shared/styles/theme.css b/tools/src/templates/frontend/src/shared/styles/theme.css index 5fcf149..f8644a1 100644 --- a/tools/src/templates/frontend/src/shared/styles/theme.css +++ b/tools/src/templates/frontend/src/shared/styles/theme.css @@ -40,6 +40,35 @@ --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; + + /* + * Admin sidebar. Consumed by @pageflow/admin's shell — a plugin cannot ship + * the CSS variables its own components depend on and still be overridable + * per project, so they live here. Restyle freely; keep the NAMES. + */ + --sidebar-bg: 0 0% 100%; + --sidebar-fg: 0 0% 30%; + --sidebar-fg-muted: 0 0% 55%; + --sidebar-fg-active: 221.2 83.2% 53.3%; + --sidebar-border: 214.3 31.8% 91.4%; + --sidebar-hover: 210 40% 96%; + --sidebar-active: 221.2 83.2% 53.3%; + --sidebar-section: 0 0% 45%; + --sidebar-width: 260px; + + /* + * Row metrics for the sidebar's overflow calculator. It decides how many nav + * rows fit WITHOUT measuring every one, so these must track the padding the + * shell actually renders. 0.3 hard-coded them in the TSX, where a padding + * change was a silent miscount; reading them from here means a project that + * restyles the sidebar can correct the arithmetic in the same place. + */ + --nav-row-h: 36px; + --nav-row-h-compact: 32px; + --nav-section-label-h: 28px; + --nav-section-label-h-compact: 24px; + --nav-section-gap: 16px; + --nav-section-gap-compact: 12px; } .dark { @@ -67,6 +96,15 @@ --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; + + --sidebar-bg: 222.2 84% 4.9%; + --sidebar-fg: 215 20.2% 65.1%; + --sidebar-fg-muted: 215 20.2% 45%; + --sidebar-fg-active: 217.2 91.2% 59.8%; + --sidebar-border: 217.2 32.6% 17.5%; + --sidebar-hover: 217.2 32.6% 14%; + --sidebar-active: 217.2 91.2% 59.8%; + --sidebar-section: 215 20.2% 45%; } /* Map the HSL tokens onto Tailwind's color/radius scales (v4 `@theme inline`). */ @@ -96,6 +134,15 @@ --color-chart-4: hsl(var(--chart-4)); --color-chart-5: hsl(var(--chart-5)); + --color-sidebar-bg: hsl(var(--sidebar-bg)); + --color-sidebar-fg: hsl(var(--sidebar-fg)); + --color-sidebar-fg-muted: hsl(var(--sidebar-fg-muted)); + --color-sidebar-fg-active: hsl(var(--sidebar-fg-active)); + --color-sidebar-border: hsl(var(--sidebar-border)); + --color-sidebar-hover: hsl(var(--sidebar-hover)); + --color-sidebar-active: hsl(var(--sidebar-active)); + --color-sidebar-section: hsl(var(--sidebar-section)); + --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -110,4 +157,17 @@ background-color: hsl(var(--background)); color: hsl(var(--foreground)); } + + /* Sidebar rows animate colour only — never layout, which would fight the + overflow calculator's ResizeObserver. */ + .sidebar-transition { + transition-property: color, background-color, border-color; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + } + @media (prefers-reduced-motion: reduce) { + .sidebar-transition { + transition: none; + } + } } diff --git a/tools/src/templates/frontend/src/surfaces/admin/index.tsx b/tools/src/templates/frontend/src/surfaces/admin/index.tsx index 02f020f..d6c071f 100644 --- a/tools/src/templates/frontend/src/surfaces/admin/index.tsx +++ b/tools/src/templates/frontend/src/surfaces/admin/index.tsx @@ -1,8 +1,17 @@ import "./styles/index.css"; import { createRoot } from "react-dom/client"; -import { createPageflowApp } from "@pageflow/react"; +import { createPageflowApp, AppErrorBoundary } from "@pageflow/react"; import { ThemeProvider } from "@providers/theme"; +// ── Admin navigation ──────────────────────────────────────────────────────── +// Each plugin that contributes to the sidebar ships `ui/admin/nav.ts`, which +// calls registerModule()/registerFeature() at import time. Globbing them here — +// rather than the registry importing a hard-coded list — is what keeps +// @pageflow/admin free of every business domain's name. The project's own +// nav files load LAST, so a project can unregister or re-order plugin modules. +import.meta.glob("/plugins/*/admin/nav.ts", { eager: true }); +import.meta.glob("./nav/*.ts", { eager: true }); + // ── Pageflow bootstrap ────────────────────────────────────────────────────── // The server (Plugins\Pageflow\Http\PageflowResponder) renders a page object // { component, props, url, version }. @pageflow/* is FEDERATED from the enabled @@ -52,10 +61,15 @@ createPageflowApp({ page: initialPage, resolve: resolveComponent, setup({ el, App, props }: { el: HTMLElement; App: any; props: any }) { + // The boundary is OUTSIDE the app: a throw in any page (including the + // "Page not found" resolveComponent raises) would otherwise unmount + // everything and leave a blank document. createRoot(el).render( - - - , + + + + + , ); }, progress: { delay: 0, color: "#6366f1" }, diff --git a/tools/src/templates/frontend/src/surfaces/project/index.tsx b/tools/src/templates/frontend/src/surfaces/project/index.tsx index 2da7024..1de4e2a 100644 --- a/tools/src/templates/frontend/src/surfaces/project/index.tsx +++ b/tools/src/templates/frontend/src/surfaces/project/index.tsx @@ -1,6 +1,6 @@ import "./styles/index.css"; import { createRoot, hydrateRoot } from "react-dom/client"; -import { createPageflowApp } from "@pageflow/react"; +import { createPageflowApp, AppErrorBoundary } from "@pageflow/react"; import { ThemeProvider } from "@providers/theme"; // ── Project (public) surface bootstrap ─────────────────────────────────────── @@ -48,10 +48,18 @@ createPageflowApp({ page: initialPage, resolve: resolveComponent, setup({ el, App, props }: { el: HTMLElement; App: any; props: any }) { + // The boundary is OUTSIDE the app: a throw in any page (including the + // "Page not found" resolveComponent raises) would otherwise unmount + // everything and leave a blank document — on the PUBLIC surface, to a + // visitor. It comes from @pageflow/react, not @pageflow/admin: it is + // dependency-free, and reaching for the admin entry would pull the whole + // shell into a marketing bundle. const tree = ( - - - + + + + + ); // Hydrate server-rendered HTML when present; otherwise mount fresh. if (el.hasChildNodes()) { diff --git a/tools/src/templates/plugin/migration_alter.php b/tools/src/templates/plugin/migration_alter.php new file mode 100644 index 0000000..02ec26d --- /dev/null +++ b/tools/src/templates/plugin/migration_alter.php @@ -0,0 +1,35 @@ +table('{{LOWER}}', static function ($t) { + // $t->string('widget_id', 64)->nullable(); + // $t->index('widget_id'); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->table('{{LOWER}}', static function ($t) { + // $t->dropColumn('widget_id'); + }); + } +}; diff --git a/tools/src/templates/simple/app/bootstrap/app.php b/tools/src/templates/simple/app/bootstrap/app.php new file mode 100644 index 0000000..83b667c --- /dev/null +++ b/tools/src/templates/simple/app/bootstrap/app.php @@ -0,0 +1,216 @@ +http()->handle(...)` for web, `$kernel->cli()->run(...)` for the + * terminal. + * + * ----------------------------------------------------------------------------- + * WHY THIS ONE IS EMPTY + * ----------------------------------------------------------------------------- + * No plugins are enabled. Not "none yet" — none, deliberately. + * + * The framework loads only what a request actually needs, so a plugin you have + * not enabled costs nothing at runtime. It does cost something everywhere else: + * a download, a directory, a line of wiring, a version to keep current, and one + * more thing to understand before you can read your own bootstrap. Starting at + * zero means everything present here is something you asked for. + * + * Add one when a requirement arrives, not in case it does: + * + * hkm plugins install database # DatabasePort, migrations + * hkm plugins install view # PHP templates + * hkm plugins install auth # login, tokens, sessions + * + * `hkm plugins install` fetches the plugin AND the plugins it depends on, wires + * them into this file in dependency order, and publishes their config and + * migrations. `hkm plugins list` shows what is enabled; `hkm plugins domains` + * shows which plugin provides a capability you are looking for. + * + * The full starter (`hkm new `, without --simple) comes with a working + * database, session, cookie, cache, view and validation stack already wired. + * + * ----------------------------------------------------------------------------- + * BOOT ORDER (top to bottom — the order matters) + * ----------------------------------------------------------------------------- + * 1. autoload find the kernel, register the class loaders + * 2. environment load the .env cascade BEFORE anything reads config + * 3. error net catch failures that happen before the kernel is live + * 4. kernel declare paths, routes, security, modules + * 5. build compile manifests and hand the kernel back + */ + +// ----------------------------------------------------------------------------- +// STEP 0 — AUTOLOAD +// kernel-autoload.php only DEFINES the resolver; calling it is what actually +// registers the kernel's class loaders. Requiring the file and forgetting the +// call leaves every framework class undefined, and the failure surfaces on the +// first one used rather than here. +// ----------------------------------------------------------------------------- +if (!function_exists('psp_require_kernel_autoload') || !function_exists('psp_kernel_home')) { + require_once __DIR__ . '/kernel-autoload.php'; +} +psp_require_kernel_autoload(); + +use AlfacodeTeam\PhpServicePlatform\Kernel\Kernel; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\CachePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Layers\CsrfTokenLayer; + +use Project\Bootstrap\EntryHelpers; +use Project\Infrastructure\FileCache; +use Project\Infrastructure\LazyDatabasePort; +use Project\Infrastructure\PdoDatabase; +use Project\Bootstrap\Environment\ErrorGuard; +use Project\Bootstrap\Environment\LoadEnvironment; + +// ----------------------------------------------------------------------------- +// STEP 1 — PATHS +// Flat layout: the scaffolded directory IS the project, so this file's +// grandparent (bootstrap → app → root) is the project root. +// ----------------------------------------------------------------------------- +$projectRoot = dirname(__DIR__, 2); + +// ----------------------------------------------------------------------------- +// STEP 2 — DOMAIN RESOLUTION +// Turn the request's Host header into a DomainContext (which project face is +// being served, and its features). Null under CLI and workers — no Host header +// there, which is expected and handled downstream. +// ----------------------------------------------------------------------------- +$domain = EntryHelpers::resolveDomain($projectRoot, $_SERVER['HTTP_HOST'] ?? null); + +// ----------------------------------------------------------------------------- +// STEP 3 — ENVIRONMENT +// Load .env before anything reads configuration. Real process environment +// always wins, so server config is never clobbered by a file. +// +// Values land in $_ENV/$_SERVER and NOT in putenv(), so read them with the +// env() helper — getenv() will not see them. +// ----------------------------------------------------------------------------- +LoadEnvironment::load($projectRoot, $domain, $_SERVER['argv'] ?? null); + +// ----------------------------------------------------------------------------- +// STEP 4 — PRE-KERNEL ERROR NET +// The outer safety net, for failures the kernel's own error pipeline cannot +// catch because it is not running yet: parse errors, fatals, out-of-memory. +// Writes to the same log the kernel uses, so everything lands in one file. +// ----------------------------------------------------------------------------- +ErrorGuard::install($projectRoot . '/var/logs/errors.log'); + +// ----------------------------------------------------------------------------- +// STEP 5 — THE KERNEL +// ----------------------------------------------------------------------------- +return Kernel::configure() + + // Where things live. Flat layout, so both are the project root. + ->withBasePath($projectRoot) + ->withProjectPath($projectRoot) + + // ------------------------------------------------------------------------- + // PORTS + // ------------------------------------------------------------------------- + // The kernel requires a DatabasePort and a CachePort to be bound before it + // will boot. These two are the kernel's OWN implementations — no plugin + // involved — so an empty project starts and serves immediately. + // + // Both are deliberately modest, and both are meant to be replaced: + // + // hkm plugins install database // pooled multi-driver adapter + // hkm plugins install redis-cache // Redis CachePort + QueuePort + // + // Installing either one rewrites the binding below to use it. + ->withPorts([ + // Lazy: the closure runs on FIRST USE, not at boot. A project with no + // database configured therefore boots and serves normally, and only a + // request that actually touches the database pays for a connection — + // or fails, which is the honest moment to find out DB_DSN is unset. + DatabasePort::class => new LazyDatabasePort( + static fn (): PdoDatabase => new PdoDatabase( + env('DB_DSN', 'sqlite:' . $projectRoot . '/var/database.sqlite'), + env('DB_USERNAME'), + env('DB_PASSWORD'), + ), + ), + + // File-backed, so a cached value survives between requests under + // PHP-FPM (an in-memory cache would not — each request is a new + // process, and every read would miss). + CachePort::class => new FileCache($projectRoot . '/var/cache/data'), + ]) + + // Routes come from proj.json — never from PHP. Declaring them as data is + // what lets the kernel compile a route manifest at build time and resolve a + // request without loading a single module. + ->withRoutes(EntryHelpers::projectRoutes($projectRoot)) + // Route GROUPS from proj.json: a prefix / filters / requires / name + // prefix / SITE stated once for every route inside the group, and + // expanded into flat routes at boot. `site` is part of the route key, + // so one project can answer `GET /` differently per group of hosts. + ->withRouteGroups(EntryHelpers::projectRouteGroups($projectRoot)) + // The hosts this project serves. A route grouped under a domain that is + // not in proj.json "domains" fails the boot — nothing could ever reach it. + ->withProjectDomains(EntryHelpers::projectDomains($projectRoot)) + + // A project can also switch OFF a route a plugin declares, without forking + // the plugin: proj.json "routePolicy": { "disable": ["GET /register"] }. + ->withRoutePolicy(EntryHelpers::projectRoutePolicy($projectRoot)) + + ->withSecurity([ + // The only security layer the kernel ships: stateless HMAC-signed CSRF + // tokens. Nothing is stored and no cookie value is trusted as the + // token, so cookie injection cannot bypass it. + // + // The secret defaults to APP_KEY. An EMPTY APP_KEY fails closed — every + // state-changing request is denied — so set one before serving traffic: + // hkm key:generate + new CsrfTokenLayer( + headerName: 'X-CSRF-Token', + formField: '_csrf_token', + lifetime: 43200, // 12 hours, in seconds + // Paths that never carry a browser session; APIs authenticate with + // a token instead, for which CSRF is meaningless. + exemptPaths: ['/api'], + ), + + // Authentication is NOT here. The kernel ships no token validator on + // purpose — add the Auth plugin and its layers when you need accounts: + // hkm plugins install auth + ]) + + // ------------------------------------------------------------------------- + // MODULES + // ------------------------------------------------------------------------- + // Empty, and that is the point of --simple. `hkm plugins install ` + // adds entries here for you, in dependency order, with a comment saying + // what each one solves. + // + // A module listed here is loaded ON DEMAND: only when a route being served + // needs it. Listing one costs nothing until something asks for it. + ->withModules([ + // + ]) + + // ------------------------------------------------------------------------- + // ESSENTIAL MODULES + // ------------------------------------------------------------------------- + // Registered into EVERY request, needed or not. Reserve this for + // cross-cutting request-scoped infrastructure (sessions, cookies) that + // cannot be an app-lifetime port — and keep the list short, because each + // entry and its whole dependency graph registers on every single request. + // + // Read from proj.json "essentials": [...], so which plugins are global is a + // deployment decision rather than a code edit. + ->withEssentialModules(EntryHelpers::projectEssentials($projectRoot)) + + // Compile-only: this validates config and compiles the manifests. The + // entry point materializes the kernel on its first http()/cli() call. + ->build(); diff --git a/tools/src/tests.zig b/tools/src/tests.zig new file mode 100644 index 0000000..2a02c07 --- /dev/null +++ b/tools/src/tests.zig @@ -0,0 +1,69 @@ +//! Test aggregator — the single root the `test` step compiles. +//! +//! Zig collects tests only from files it actually analyses, and analysis is +//! lazy: a file imported but whose declarations are never referenced along a +//! compiled path contributes NOTHING, tests included. Pointing the test step at +//! `main.zig` therefore ran whichever tests the command graph happened to drag +//! in and silently skipped the rest — nine of them, spread across +//! plugin_store, plugin_domains and plugin_bootstrap, among them the checks +//! guarding fork/version collisions in the plugin cache and the "a commented-out +//! provider is not enabled" rule. +//! +//! A test that never runs is worse than no test: it reports safety it is not +//! providing. Referencing every file here forces each one to be analysed, so a +//! new `test "..."` block runs the moment it is written. +//! +//! Generated from `find src -name '*.zig'`. Adding a source file means adding a +//! line here BY HAND; nothing enforces it, because the enforcement would need a +//! directory walk and every Zig filesystem API that could do it differs between +//! the pinned toolchain and the one likely to be installed. Until that is +//! settled, the check is: +//! +//! find src -name '*.zig' | sed 's|^src/||' | grep -vE '^(main|tests)\.zig$' \ +//! | while read f; do grep -q "\"$f\"" src/tests.zig || echo "MISSING: $f"; done + +const std = @import("std"); + +test { + _ = @import("commands/cli.zig"); + _ = @import("commands/discover.zig"); + _ = @import("commands/doctor.zig"); + _ = @import("commands/list.zig"); + _ = @import("commands/module.zig"); + _ = @import("commands/new.zig"); + _ = @import("commands/plugins.zig"); + _ = @import("commands/run.zig"); + _ = @import("commands/ui.zig"); + _ = @import("commands/update.zig"); + _ = @import("commands/upgrade.zig"); + _ = @import("commands/version.zig"); + _ = @import("config.zig"); + _ = @import("constants.zig"); + _ = @import("lib/banner.zig"); + _ = @import("lib/composer_version.zig"); + _ = @import("lib/install_scope.zig"); + _ = @import("lib/inspector/dashboard.zig"); + _ = @import("lib/inspector/meminspector.zig"); + _ = @import("lib/inspector/tracked.zig"); + _ = @import("lib/kernel.zig"); + _ = @import("lib/memory.zig"); + _ = @import("lib/plugin_assets.zig"); + _ = @import("lib/plugin_bootstrap.zig"); + _ = @import("lib/plugin_env.zig"); + _ = @import("lib/plugin_deps.zig"); + _ = @import("lib/plugin_domains.zig"); + _ = @import("lib/plugin_git.zig"); + _ = @import("lib/plugin_install.zig"); + _ = @import("lib/plugin_lock.zig"); + _ = @import("lib/plugin_registry.zig"); + _ = @import("lib/plugin_sources.zig"); + _ = @import("lib/plugin_store.zig"); + _ = @import("lib/plugin_ui.zig"); + _ = @import("lib/prompt.zig"); + _ = @import("lib/registry.zig"); + _ = @import("lib/semver.zig"); + _ = @import("lib/services.zig"); + _ = @import("lib/userconfig.zig"); + _ = @import("lib/util.zig"); + _ = @import("stamp.zig"); +}