Skip to content

release: v1.3.2 — scope-aware install, upgrade and version reporting - #110

Merged
hakeemRash merged 176 commits into
mainfrom
release/v1.3.2
Aug 17, 2026
Merged

release: v1.3.2 — scope-aware install, upgrade and version reporting#110
hakeemRash merged 176 commits into
mainfrom
release/v1.3.2

Conversation

@hakeemRash

Copy link
Copy Markdown
Contributor

Releases v1.3.2. auto-release.yml reads the top CHANGELOG.md heading, so
merging this tags v1.3.2 and publishes the build.

Merge with Squash. main forbids merge commits and this branch carries 36
of them (it is master's history). A merge commit or a plain merge is rejected
by branch protection — that is the GH006 error.

Same content as #109, on a fresh branch so the 17 stale review threads from the
1.2.0 cycle do not block the merge. #109 can be closed once this lands.

v1.3.2 — two install scopes that stop hijacking each other

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 came from the same gap:

  • 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.
    A .deb launcher reported 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 first, the command reported success and the next invocation ran
    the old launcher unchanged.
  • Upgrade decisions used the launcher's compile-time version, then replaced a
    kernel somewhere else — two numbers that differ exactly when the launcher on
    PATH belongs to the other scope.

What changed

Resolution ranks sources by how specific they are to the invocation: exported env
var → self-location → config pin → /opt. A launcher in /usr/bin claims
/opt/hkm-kernel at the self-location step, since no relative probe reaches it
from there.

hkm upgrade picks its scope from privilege — root → system (.deb via apt),
otherwise → user (tarball + its install.sh), with --user / --system to
force it. No sudo anywhere in the user path.

New hkm version reports every install: the kernel version in each scope, the
launcher serving it and the version it was built as, and which one PATH runs.
New hkm-config unset <KEY> clears a stale pin; install.sh now removes a
redundant or superseded pin instead of repointing it.

Full detail in CHANGELOG.md under [1.3.2].

Verification

  • Zig: 127/127 tests pass (new coverage for scope resolution, pin provenance,
    version round-tripping).
  • PHPUnit: 308/308 pass at the committed submodule pointer.
  • Verified on a machine in the broken state: a user-layout launcher self-locates
    past the shared pin; scope defaults behave under root and non-root; a full
    --local --user install stamps a version Composer validates.

Not included

The uncommitted modules/php-io-cli bump (53620ec04147c7) is deliberately
left out — it breaks 4 tests in UnknownOptionTest (AbstractCommand.php:175,
undefined array key name), so including it would fail the PHPUnit gate.

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 23 commits August 7, 2026 14:09
…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.
@hakeemRash
hakeemRash merged commit 26c75b7 into main Aug 17, 2026
20 of 22 checks passed
hakeemRash added a commit that referenced this pull request Aug 18, 2026
main is the SQUASH of #110, so it is a new commit that master's history does
not contain, and git reports the branches as diverged. Their trees are not:
origin/main and master@299a271 are the same tree (9abe29c), and master has
only moved forward since. -s ours therefore records the merge without touching
a single file — the content already includes everything main has.

The merge commit itself disappears when this PR is squash-merged, which is what
branch protection on main requires.
hakeemRash added a commit that referenced this pull request Aug 18, 2026
main is the SQUASH of #110, so it is a new commit that master's history does
not contain, and git reports the branches as diverged. Their trees are not:
origin/main and master@299a271 are the same tree (9abe29c), and master has
only moved forward since. -s ours therefore records the merge without touching
a single file — the content already includes everything main has.

The merge commit itself disappears when this PR is squash-merged, which is what
branch protection on main requires.
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.

3 participants