Skip to content

Release/v1.3.2 - #112

Open
hakeemRash wants to merge 175 commits into
mainfrom
release/v1.3.2
Open

Release/v1.3.2#112
hakeemRash wants to merge 175 commits into
mainfrom
release/v1.3.2

Conversation

@hakeemRash

@hakeemRash hakeemRash commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #

Type of change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 💥 Breaking change (fix or feature that changes existing behavior)
  • 📝 Documentation only
  • 🧹 Refactor / chore (no functional change)
  • 🚀 Release PR (mastermain, includes a CHANGELOG version bump)

Target branch

  • This PR targets master (development), or
  • This is a mastermain release PR and adds a ## [x.y.z] - YYYY-MM-DD section to CHANGELOG.md.

How has this been tested?

vendor/bin/phpunit

Checklist

  • My code follows the Gated Demand Architecture rules (no Laravel/Symfony/Slim patterns).
  • Every PHP file has declare(strict_types=1);.
  • The five access rules are respected (Controller → Service → Repository/Gateway → Port/SDK; Domain imports nothing external).
  • Routes are declared in module.json / proj.json, not in PHP.
  • Every env var read is declared in the relevant config[].
  • Vendor exceptions are translated at their layer (no \PDOException/SDK exceptions escape).
  • I added or updated tests and they pass locally.
  • I updated documentation where relevant.
  • For shippable changes, I added a ## [Unreleased] entry to CHANGELOG.md.
  • CI is green.

Screenshots / notes (optional)

hakeemRash added 30 commits July 7, 2026 22:15
Remove .claude/, CLAUDE.md, .github/copilot-instructions.md and docs/ from
version control (kept locally, gitignored) so they are not published to GitHub.
chore: remove AI-assistant config and internal docs from the repo
Previously the ASCII banner only appeared on 'hkm version'. It now headers
the default help output too.
feat(cli): show Sentinel banner on bare hkm and hkm help
hkm upgrade now detects the OS, downloads the matching release artifact,
and installs it (Linux apt / macOS install.sh / Windows install.bat),
instead of only printing manual instructions.
feat(upgrade): auto-download and install updates per OS
…g.env

- run/registry now resolve the kernel relative to the launcher (installed
  /opt/hkm-kernel or dev repo), fixing 'Kernel registry not found' on
  packaged installs and stopping use of a dev kernel found via PWD.
- hkm-config checks the kernel + writes/repairs HKM_KERNEL_HOME.
- launcher loads ~/.config/hkm/config.env at startup (real env wins).

No version change.
fix(cli): self-locate installed kernel + real hkm-config
Scaffolding templates moved tools/src/templates -> top-level templates/.
tools/ is not bundled, so hkm new / hkm ui init could not find templates on
a packaged install. bundle.sh now ships templates/ (exempt from the docs/
tools strip); services resolves <kernel>/templates via self-location.

No version change.
fix(templates): ship templates in the kernel (move out of tools/)
Kernel self-location, real hkm-config, config.env loading, and templates
shipped inside the kernel.
…help note

- projects/projects.json is committed empty ({}) so developer-local
  registrations (and machine paths) never ship in the repo or bundles.
- .githooks/pre-commit forces projects.json to {} in every commit; enable
  with: git config core.hooksPath .githooks
- hkm help notes the env vars are auto-detected (override only if needed).
…help

chore: empty committed project registry + help note
projects.json + platform.json are user data. HKM_USERDATA_DIR relocates them
outside the kernel install (honoured by the hkm CLI registry and the PHP
DomainResolver), and the .deb marks them as conffiles so an in-place upgrade
preserves the user's registrations. Falls back to <kernel>/projects when unset.
feat(userdata): HKM_USERDATA_DIR so updates don't clobber the registry
…ata)

hkm-config now resolves/pins HKM_KERNEL_HOME AND provisions the persistent
userdata dir: creates XDG_DATA_HOME/hkm (or ~/.local/share/hkm), migrates any
existing registry into it, and pins HKM_USERDATA_DIR. One command configures
everything the launcher and runtime need.
feat(config): hkm-config provisions the full environment
- .env (holds generated APP_KEY) written chmod 600; config.env too
- debug output force-disabled when APP_ENV=production regardless of APP_DEBUG
- new projects ship app/public/.htaccess (deny dotfiles, no listing, drop
  X-Powered-By, baseline security headers, front-controller rewrite)
- env.example documents the production/secret-handling expectations
Security hardening (scaffolding perms, prod debug gate, Apache+nginx web
config), HKM_USERDATA_DIR for persistent registry across updates, and
hkm-config full-environment setup.
New projects scaffold app/apache.conf.example (DocumentRoot=app/public,
deny dotfiles, only index.php executable, security headers).
Adds the Apache virtual-host sample to project scaffolding (alongside nginx).
- README now documents native install (.deb/.tar.gz/.zip), the hkm command set,
  HKM_* env vars, requirements, dev/build flow, and security defaults.
- Remove links to the removed docs/ai-context files from the Auth plugin README.
docs: refresh README + fix broken links
Route policy — the third route verb (add/override/DISABLE):
- Kernel::withRoutePolicy() + proj.json "routePolicy": {"disable": []} let a
  project veto plugin routes without forking the plugin. Specs are either
  "METHOD /path" (one route) or a module domain (all of a plugin's routes).
- CompileRouteManifestStage applies the policy to plugin routes AFTER they
  compile and BEFORE project routes, so a disabled key can be re-declared by
  the project. An unmatched spec fails the boot (anti-typo guard).
- EntryHelpers::projectRoutePolicy() reads the proj.json block.

hkm dev environment for contributors:
- `hkm <command> --dev` pins one invocation to the development kernel
  (HKM_DEV_HOME from config.env, or walk-up self-location from a repo-built
  launcher). Exports HKM_KERNEL_HOME + HKM_CLI_PATH for the child only;
  fails loudly when no dev kernel is found.
- hkm-config set-dev-home <path> (validated) + help/README documentation.

Templates: scaffolded proj.json ships the routePolicy stub, bootstrap wires
withRoutePolicy(), project README documents the three route verbs.
Project routePolicy.disable (veto plugin routes without forking) and the
hkm --dev contributor environment (stable install + dev checkout side by side).
Alshatri and others added 28 commits August 7, 2026 13:23
Records the re-cut from main. v1.1.0-dev.2 was tagged from master before #107
merged, so its artefacts predate the PHPStan fixes; dev.3 is the same contents
built from the branch releases are supposed to come from.
…ry machine

`hkm upgrade` to v1.1.0-dev.2 failed on the target with:

    "./composer.json" does not match the expected JSON schema:
     - version : Does not match the regex pattern ...

The version was stamped straight from the git tag, and "1.1.0-dev.2" is not a
valid Composer version: Composer's `dev` suffix takes NO counter. "1.1.0-dev"
is accepted, "1.1.0-dev.2" and "1.1.0-dev2" are not. `composer install` ABORTS
on an unparseable version, so the package never resolved its dependencies —
the .deb installed and then failed to finish setting itself up.

The stamper now validates before writing and SKIPS when the version is not
something Composer accepts, printing why. The field is optional; a broken
install is not. It deliberately does not rewrite the version into a
Composer-friendly shape, which would make composer.json disagree with the tag
it was built from.

TWO BUGS FOUND WHILE VERIFYING

- main() trimmed the cutset " \t\r\nv" from BOTH ends, so any version ENDING in
  'v' lost it: "1.1.0-dev" arrived as "1.1.0-de" and was rejected — the one
  pre-release form Composer does accept. Only a leading 'v' is stripped now.
- hkm-stamp was never installed as an artifact, so the only copies lived in
  .zig-cache under content-hashed directories. Verifying its behaviour meant
  guessing which one was current, and I initially tested a stale binary. It is
  installed now.

Validated by cross-checking the validator against `composer validate` itself
across 15 version strings — zero disagreements, including every case above.
Renames the pre-release from -dev.N to -beta.1 because Composer will not accept
the former: its `dev` suffix takes no counter, so "1.1.0-dev.2" and
"1.1.0-dev.3" are unparseable. The build stamps the version into composer.json,
and `composer install` ABORTS on a version it cannot parse — so both previous
pre-releases unpacked and then failed to resolve their dependencies on every
machine that took them.

-beta.1 is Composer-valid, sorts above 1.0.21 and below a stable 1.1.0, and
means the version marker the native distribution relies on is present again
rather than being skipped by the stamper's new validation.

Verified by building an actual .deb at this version and checking the shipped
composer.json: the version is stamped, `composer validate` passes (previously a
schema error), and `composer install --no-dev --dry-run` — the exact command
install.sh runs — completes with exit 0.
… back-merge

The fix for the install-breaking version stamp reached neither branch. It was
committed to master as d51b215, then `648db50 Merge branch 'main' into master`
resolved the conflict in favour of main's older copy and dropped it, three
minutes before PR #108 merged — which is why that PR squashed to a commit
changing ZERO files, and why neither main nor master contains composerValid().

Re-applies, unchanged from the reviewed version:

- stamp.zig validates the version against a conservative subset of Composer's
  own pattern and SKIPS rather than writing something Composer rejects.
  "1.1.0-dev.N" is not valid — Composer's dev suffix takes no counter — and
  `composer install` ABORTS on an unparseable version, so v1.1.0-dev.2 and
  v1.1.0-dev.3 unpacked and then failed to resolve dependencies everywhere.
- Only a LEADING 'v' is stripped. The previous trim used a cutset on both ends,
  so "1.1.0-dev" became "1.1.0-de" — the one pre-release form Composer accepts.
- hkm-stamp is installed as an artifact so its behaviour can be verified
  against real composer instead of guessing which .zig-cache copy is current.

CHANGELOG names the release 1.1.0-beta.1, which Composer accepts, so the
version marker the native distribution needs is present rather than skipped.

Re-verified after restoring: 41 tests pass (32 kernel + 9 stamp), a real .deb
built at 1.1.0-beta.1 carries the version, `composer validate` passes, and
`composer install --no-dev --dry-run` completes with exit 0.
…s plugins

    Class "Plugins\Logger\Provider" does not exist

The scaffolded bootstrap enables nineteen providers (Logger, Crypto, Database,
…) and the scaffolded composer.json maps Plugins\ to the project's plugins/
directory — but nothing ever put anything there. The kernel stopped shipping
plugins when they moved to their own repositories, and `hkm new` was never
taught to fetch them, so every project created since the decoupling died on its
first request.

`hkm new` now installs them over git, through the same path `hkm plugins
install` uses: resolved to a tag, kernel-compatibility checked, and recorded in
plugins.lock.json so the project is reproducible from the moment it is created.

The set is read from the scaffolded app/bootstrap/app.php rather than hard-coded
here. A list in this file would drift from what the template actually wires, and
the drift would reproduce exactly the missing-class failure this fixes.

Failures are reported per plugin and summarised, rather than left to surface as
a class-not-found at runtime: the message says how many are missing and that the
project will not boot until they are installed.

Verified end to end: scaffolding a project installs 19 plugins, publishes 52
assets, and the bootstrap now returns a Kernel instead of throwing. Confirmed
plugins/Logger/Provider.php — the exact class from the report — is present.
…ache

The routing layer, end to end.

DECLARATION
  groups[] in module.json / proj.json states a prefix, filters, requires, a name
  prefix and a domain ONCE for every route inside; groups nest. Module-wide
  routePrefix / routeFilters / routeRequires / routeName / routeDomain do the
  same for a whole file. Everything expands at BOOT into ordinary flat routes,
  so grouping costs nothing at request time.

DOMAINS
  A route may declare the host it answers on — "africavoting.local",
  "*.example.com", or a bare "subdomain": "api". The domain is part of the route
  KEY, not a post-match filter, 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 checked against proj.json "domains".

SECURITY
  Captured parameters are percent-decoded and RE-VALIDATED against their type,
  so /files/..%2F..%2Fetc%2Fpasswd no longer satisfies {name} — and /users/José
  reaches the controller decoded instead of as Jos%C3%A9. Patterns are anchored
  with the D modifier (a trailing newline no longer satisfies $), literal path
  text is preg_quote'd, and signed-URL verification compares the query
  byte-for-byte instead of round-tripping it through parse_str().

  New {file:path} type: a catch-all that refuses ".." — use it instead of {any}
  for anything reaching a filesystem. New {s:enum(a|b)} and optional {page?}.

COST
  Kernel::build() was not idempotent, so under PHP-FPM the whole boot pipeline
  ran on EVERY request — ~2 ms and ~150 KB of writes for ~130 routes, producing
  byte-identical output. BOOT_CACHE=1 skips it when nothing it read changed:
  ~0.02 ms, 86x cheaper. Off by default; clear var/cache/manifests on deploy.

  The matcher now reads a precompiled index (route-index.php) instead of
  deriving regexes per worker, and buckets dynamic routes by first literal
  segment. Filter stages resolve once per worker rather than per request.

ANTI-TYPO
  These now fail the BOOT instead of compiling into a route that silently never
  matched: a path not starting with '/', a duplicate or PCRE-invalid capture
  name, a handler without exactly one '@', an unregistered filter alias, and a
  route domain absent from proj.json "domains".
The 1.2.0 release build could not check out: the submodule pointer referenced
cd3f71c, which exists only on a developer machine and was never pushed to
AlfaCode-Team/Let-Migrate.

    fatal: remote error: upload-pack: not our ref cd3f71c…
    Fetched in submodule path 'modules/let-migrate', but it did not contain it.

Restores the pointer to 68f72db, the newest commit the remote actually has. The
local checkout is left where it is, so the unpushed work is not lost — push it
to Let-Migrate and bump the pointer deliberately when it is ready to ship.
- Introduced `plugin_domains.zig` to manage domain resolution for plugins, allowing for better dependency handling based on declared capabilities rather than repository names.
- Implemented `plugin_store.zig` to create a global plugin cache, ensuring that plugins are shared across projects and reducing redundant downloads.
- Added migration template `migration_alter.php` for altering existing database tables in plugins.
- Created a simple application bootstrap file `app.php` to initialize the project with a focus on minimalism and explicit dependency management.
- Established a test aggregator `tests.zig` to ensure all relevant tests are executed, improving test coverage and reliability.
`composer validate` is a required check and it failed on 1.2.0: the lock file's
content-hash covers the version field, so stamping a literal version dirtied the
lock, and Composer warns about the field on a Packagist-published package.

The field is absent from the repo BY DESIGN — build.zig says why: the native
distribution ships without a .git directory, so a .deb or zip has no tags to
derive a version from and needs the marker; but in a checkout "a literal version
OVERRIDES the tags, and the two silently drift apart". tools/bundle.sh stamps it
at release time, which is how the published v1.2.0 artifacts got their version.
The previous fix set the gitlink in the index only, leaving the submodule's
working tree on the unpushed cd3f71c — and the next commit silently picked it
back up, so CI failed again with "not our ref cd3f71c". This moves the working
tree too, so index, tree and HEAD agree and it cannot drift back.

The unpushed work is preserved as the branch `wip/unpushed-cd3f71c` inside
modules/let-migrate. Push it to AlfaCode-Team/Let-Migrate and bump the pointer
deliberately when it is ready to ship.
…rupts JSON

Three fixes from the review on #109.

DUPLICATE CHECKS
php-analysis.yml listed master under BOTH push and pull_request, so every
master->main PR ran it twice — the push event for refs/heads/master and the
pull_request event for refs/pull/N/merge. The concurrency group is keyed on the
ref, so the two never collided, and each PR showed "composer audit", "PHPStan"
and "Semgrep" twice. Limited push to main, which is the shape ci.yml already
uses (and why PHPUnit and Zig build appeared only once). Master is still
analysed — through the PR.

COMPOSER.JSON CORRUPTION
composerValid() discarded everything after '+' without looking at it, so
`1.1.0+"` validated; stamp() then writes the version RAW between JSON quotes and
produced an unparseable composer.json:

    "version": "1.1.0+"",

Build metadata is now validated as semver defines it — dot-separated
[0-9A-Za-z-] identifiers — and stamp() refuses outright any version carrying a
quote, backslash or control character. Regression tests cover both.

SILENT DIAGNOSTICS
  - A version longer than the 256-byte message buffer made bufPrint fail, and
    the error path returned WITHOUT printing anything, so the marker was skipped
    with no diagnostic at all. Falls back to a fixed message.
  - `hkm new` swallowed recordInLock failures while still counting the plugin as
    installed, so the command reported success with a lock that did not list it.
    It now names the plugin and how to repair the lock.
`domain` / `subdomain` previously took a single host, at the route and group
level, while the module-wide `routeDomain` took one too. A project serving
several hosts therefore could not pin a group to "these three and not that one"
— the only way to express it was to duplicate the whole group per host, which
is how a route ends up on a host nobody meant to serve it on.

All three levels — module-wide, group, route — now take either a string or a
list, and behave identically. One of them quietly refusing a list is the kind
of inconsistency only ever discovered by it not working.

The domain stays part of the route KEY, so one project still answers GET /
differently per host, and a route grouped under a host the project does not
serve is still rejected at boot rather than silently unreachable.
Every plugin lists the environment it reads in module.json `config[]`, and the
kernel FAILS THE BOOT when a required one is absent (ValidateConfigStage).
Until now 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, in 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 — boot still fails,
                                    but it fails pointing at a line you can see
  optional, no default  # KEY=      written COMMENTED — documents the knob
                                    without pinning a value
The .deb was the only install path, so trying the kernel meant apt, sudo and a
system-wide PHP — a high price for "does this work on my machine", and
impossible on a box you do not own.

Linux release builds now produce TWO artifacts and the tarball is the primary
one: tools/install.sh unpacks the kernel and launcher entirely inside $HOME,
writes nothing outside it, and needs no privileges. install.sh is published
alongside the assets so `curl … | sh` works without a checkout. The .deb stays
for multi-user machines and CI images, where a system-wide install and
apt-managed PHP are the point.

`hkm doctor` grew the diagnostics this makes necessary: which install is
actually being used, 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".
Pageflow v1.1.0 ships @pageflow/admin — an admin shell that deliberately owns
no state of its own. Three things have to exist on the PROJECT side for that to
work, and the scaffold now provides all three:

- **A three-state theme.** `@providers/theme` exposes { theme, resolvedTheme,
  setTheme, toggle } and a "system" default that keeps following the OS if the
  OS setting changes mid-session. The shell's <ThemeToggle> is only a control
  over this context, so two plugins can never fight over the app's appearance.
- **Sidebar CSS variables.** A plugin cannot ship the variables its own
  components depend on and still be overridable per project, so they live in
  the project's theme.css. Restyle freely; keep the NAMES.
- **A globbed nav registry.** Each plugin contributing to the sidebar ships
  ui/admin/nav.ts and registers at import time. Globbing them beats a
  hard-coded list in the registry, which every new plugin would have to edit.

Both surfaces also wrap their tree in AppErrorBoundary: without one, a throw in
any page component unmounts the whole app rather than the page that failed.
655829006 merges two parallel implementations of unknown-option handling into
src/AbstractCommand.php and keeps BOTH: `private array $unknownOptions` is
declared twice (lines 50 and 78), with two incompatible row shapes
(`spelling`/`key` populated at 482 and resolved by resolveUnknownOptions(),
`token`/`name` populated at 158/520 and rejected by rejectUnknownOptions()).

A duplicated property is a fatal at CLASS LOAD, so this is not one failing test
— at that pointer every command built on AbstractCommand dies with
"Cannot redeclare AlfacodeTeam\PhpIoCli\AbstractCommand::$unknownOptions".
The kernel suite surfaces it as UnknownOptionTest ending the PHP process.

Pinned back to 53620ec, the last commit where the class loads: 312 tests, 585
assertions green. Which of the two implementations is canonical is php-io-cli's
call, so this reverts the POINTER only and changes nothing in that repo.
A machine can hold both a system (.deb) and a user (tarball) install. The CLI
did not model that: a shared config.env HKM_KERNEL_HOME pin let either install
redirect the other's kernel, and hkm upgrade could only ever update the system
scope. Both made installing or upgrading appear to do nothing.

Release 1.3.2.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants