История коммитов

.
feat(admin): background tasks with arguments
A queued task is run later, by the scheduler, in another process — so what
it needs to know has to travel with it. "Install a module" is not a task
until it says which module.

The state of a task now carries its arguments, getQueued() returns those
states rather than bare command names, and the runner passes them to
ArrayInput. AsAdminTask grew a "listed" flag: a task that needs an argument
has nothing to offer a screen of buttons, so it stays off the maintenance
page while remaining queueable from wherever that argument comes from.

The consumer is real, which is why this was worth doing at all: the
confirmation screen of a module operation now offers "run in the
background", and install, update and uninstall go to the scheduler with the
module key and the --demo / --purge flags. A module with heavy migrations
does not fit into the time a web request is given on a modest host.
Switching a module on or off stays synchronous — that is a line in a file.

Verified end to end: queued with an argument, picked up by
admin-tasks:run-queued, migration applied, module installed, output kept in
the state of the task.
.
feat(modules): composer as a delivery channel
A module distributed to other sites is an ordinary package with
"type": "johncms-module", installed with composer require. The CMS finds it
by asking the Composer runtime which packages of that type are installed —
InstalledVersions::getInstalledPackagesByType(), already in the vendor
directory — so the module simply stays where Composer put it.

That is not what the plan said. It called for composer/installers plus
oomphinc/composer-installers-extender, which move a package of a custom
type into modules/{$vendor}/{$name}/. Both were installed and both worked,
but Composer reports the extender as abandoned with no replacement, and
without it the custom type means nothing and the package lands in vendor/
anyway. An abandoned dependency in the core, to move a directory, is a bad
trade. Both are gone again.

So a module can now come from two places, and ChainModuleRepository puts
them behind one answer: modules/ first, vendor/ second. Nothing above it
knows the difference — the manifest carries the path.

The hooks only speak. After installing a package they print the command to
finish with and note the key; before removing one they warn that the tables
and the data stay and point at module:uninstall --purge. The previous
attempt at this booted the whole application inside the Composer process
and ran migrations from there, which meant composer install on a developer
machine migrating whatever database the configuration pointed at.

Verified against a real package through a path repository: require put it
in vendor/vasya/blog, module:list saw it as discovered, module:install
finished the job, and composer remove printed the warning.
.
chore(i18n): regenerate the templates after the move under a vendor
The .pot files still pointed at modules/<name>/… for every string in them:
the templates were last generated before the modules moved under their
vendor. Regenerating rewrites those references and picks up the strings of
the modules section along the way.

Nothing but source references and the new strings changed; no .po was
touched, so no translation was lost.
.
feat(admin): the modules section
The screen the modules of a site are managed from: what is installed, what
is lying in the directory waiting to be, what is wrong with the rest, and
an upload form for a zip package.

It is behind system.modules.manage, and that permission is carried by no
built-in role — installing a module is running its code here, so it has to
be granted deliberately. The routes sit outside the super-admin group on
purpose: requiring admin.settings.manage on top would mean the two can
never be handed out separately, and a functional test pins that an
administrator without the module permission is refused.

Every operation goes through a confirmation that spells out what will
happen — removing a module keeps its tables unless the checkbox says
otherwise — and answers with the steps it took, the same list the console
prints. Each one is written to the log, since this is the door code comes
in through. A system module is offered neither "switch off" nor "remove",
and the service refuses both even if the request is made by hand.
.
feat(modules): install a module from a zip package
For the site that has no SSH: module:install --from=blog.zip unpacks a
package and installs it, or updates the module if the site already has it.

The archive is looked into before a byte reaches the disk. Paths leading
outside the module, absolute paths, symlinks, an unpacked size beyond a
sane limit, too many entries, no module.php, more than one directory at the
top — each is refused with a sentence saying what is wrong with the file.
None of that is exotic: it is what an archive from an unknown author looks
like when it means harm, and it was verified against real ones — the zip
slip entry never created config/pwned.txt, the symlink to /etc/passwd was
refused by name.

Where the module ends up is decided by the key in its manifest, not by the
directory inside the archive. It is unpacked into data/tmp/, never straight
into modules/, where a half-written module would be picked up by the next
request; the previous version is moved into data/backups/ and the new one
takes its place with a single rename, which goes back if anything fails.
A system module cannot be replaced this way — that is what upgrading the
CMS is for.

This also fixes something older. The registry factory and the container
each built their own FilesystemModuleRepository and ModuleStateStore, and
each cached separately: unpacking put files on disk while the install
service went on insisting there was no such module. Both now come from
ModuleRegistryFactory, and the repository interface has forget(). The same
cause had already cost a run of migrations reporting "0 applied".
.
feat(view): let a module add itself to the menus
An installed module that nobody can find is a module nobody uses, and both
menus are templates of the theme — which a module has no business editing.
So it declares MenuItems and the theme draws them: MenuItemProviderInterface
is tagged by PSRContainerFactory alongside the other extension points, so
implementing the interface is all a module does.

The permission is checked before the template, not in it: a theme should
never have to know which permission stands behind a line, and an item the
visitor cannot open simply never arrives. The registry resets between
requests, since what it worked out belongs to one visitor.

An icon is either an id in the sprite of the theme or the address of an
image the module ships, told apart by how the string starts.

The function is called menu_items() rather than module_menu(): the admin
sidebar already has a variable of that name for the active group. The admin
items are drawn inside the "Modules" group, where the admin pages of forum,
news and collections already live.
.
feat(auth): remove the permissions of a module when it is uninstalled
Which permissions belong to a module is asked of the module — its
permission providers are still in the container while it is being
uninstalled, and they are the only authority on what it declared. Matching
by the prefix of a key would be a guess: admin.settings.manage belongs to
the core, not to whichever module is called admin. Telling a provider from
a module is what ModuleManifest::namespaces() is for.

They are taken back only with --purge, alongside the tables. A plain
uninstall keeps both, so a module put back finds its settings where it left
them — SaveRoleUseCase already keeps keys the catalogue does not know. What
is removed is written to data/backups/permissions-<vendor>-<name>-<date>.json
first: permissions are configuration somebody spent an evening on.

Granting them at install time is not possible in the same process — the
container was compiled before the module existed, so its providers are not
in the catalogue yet. Rather than pretend otherwise, the report says it:
"run auth:sync-roles once the module is loaded". The applier still runs,
being idempotent and useful for everything else.
.
feat(modules): publish the assets of a module
A module may ship styles and scripts of its own. They are shipped built —
there is no Vite on a site that only runs the CMS — and installing the
module copies them out of modules/, which is not reachable by URL, into
public/modules/<alias>/. Switching the module off takes them out again.

Only files a browser loads are copied. Everything under the document root
is served by the web server, so a .php among the assets of a module would
be code the module put where anyone can run it; a module that needs to
answer a request declares a route. A directory left with nothing
publishable is not created at all.

Copying rather than linking: half the shared hosting this CMS runs on
cannot make a symlink, and on the other half it is a way to lose a whole
site. --symlink exists for developing a module and has to be asked for.

Published files alone do nothing, so the layout prints what the modules
declare: module_assets() next to vite() in the base templates, with the
version of the module as the cache buster, since a module has no build
hashes to rely on. A template of the module addresses a single file with
module_asset('blog', 'js/app.js').
.
feat(modules): the lifecycle of a module
Installing, switching on and off, updating and uninstalling — in one
service, which is the only place any of it happens. Files on disk remain
somebody else's business: Composer put them there, or an archive, or a
person over FTP. What happens here is the record the site keeps, the
migrations, the hooks of the module and the caches that would otherwise
still describe the previous set of modules.

Every operation answers with the steps it took rather than a boolean.
Installing is half a dozen things in a row and any of them can be the one
that failed; the console prints that list, and the admin panel will show
it.

What it refuses is as much the point as what it does: a module that is not
there, one already installed, an alias another module holds, a version this
site cannot run, a system module, one another installed module needs — and,
before anything is undone, a module whose migrations never said how to be
rolled back. Undoing half a module leaves a schema nobody can describe.

Uninstalling keeps the tables. --purge is what deletes the data, and it is
a separate word on purpose.

Installer gets install() and update($from, $to) back, and none of its
methods are abstract any more: a module overrides what it needs. Tables are
still created by migrations alone. ModuleInstaller is gone, and step 4 of
the site installer calls the installers of the modules itself.

Two things this turned up:

The registry and the state store cache what they read, and the factory
builds them from instances of its own — not the ones in the container. So
installing wrote the file while the migrations ran against a list of
sources the new module was not in yet: the first run reported "0 applied"
with a live migration on disk. Both grew a forget(), called after every
write.

Migrator is final readonly, so it can be neither replaced nor mocked. The
service now depends on MigrationRunnerInterface — run, rollback,
irreversible — which is what .agents/architecture.md asks for anyway and
the only way to test these operations without a database.
.
feat(modules): compatibility and dependencies
A module may now say what it cannot run without: a version of PHP, a
version of the CMS, and other modules. Constraints are Composer
constraints, read by composer/semver — the language a module author already
writes in composer.json, and not a second one invented here.

The check runs in the registry, in a loop until nothing changes. A module
whose requirement is not met becomes incompatible and is not loaded, and
neither is whatever was built on it: requirements chain, and one pass would
leave a module loaded against a dependency that is not there — which is the
container failing to compile, on a page nobody touched.

Two rules the plan did not have, and the site needs both:

A module a system module requires stays loaded even when the configuration
switches it off, and the listing says who is holding it. Obeying would take
the admin panel down and with it the only way to put the module back. For
the same reason a system module can no longer be switched off at all —
"system" now means that rather than a recommendation.

The dependencies that exist are declared: admin needs forum and
registration, contacts needs consent, profile needs forum, guestbook and
mail, registration needs consent. They were found by reading constructors
and confirmed by switching each module off — without the declaration the
container fails with "Cannot autowire … references interface", with it the
dependent module reports itself incompatible and the site stays up.

The sweep in the previous commit missed half of these: it searched the
output for "references class", and Symfony writes "references interface"
for an interface.

CMS_VERSION becomes 10.0, without which requires.johncms means nothing.
It also moves the cache prefix (jc9_9 -> jc10_0) and asks johncms.com for
the languages of 10.0; both are in the changelog.