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

.
fix(admin): a section of a module is a page of the panel too
The news section rendered the whole admin menu in the source English. The
panel domain was entered by the access guards of the admin module, and a
section guarded by a permission of its own — news, collections — never went
through them. The same awaited any third-party module with a section: not a
bug of the news module but a hole in the contract.

Routes now say it: ->adminArea(), on a route or on a group, the way
->permission() already works. The pipeline enters the context from that
attribute, right after the module context and before any middleware, so a
page gets the translations of the panel and the first link of its
navigation chain whichever module it belongs to.

The guards stop entering it themselves — with the pipeline doing it, the
"Админ панель" link would be added twice.

Two things this turned up. A nested group inherits nothing from the group
around it, middleware included, so the super-admin group needed the mark of
its own: without that, 51 routes of /admin/settings and /admin/ip-bans lost
the context the guards used to give them. And consent and contacts relied
on AdminAccessMiddleware for it, so removing it from there would have fixed
the news section while breaking those two — both are marked as well.

Verified by walking every route under /admin: none is left unmarked.
.
i18n(ru): translate the modules section
The 32 strings the modules section brought, plus five in the system domain
that had been sitting untranslated: the permission to install modules, the
names of the captcha services and the hint under the image code.

Wording follows what the screens actually do rather than the English
literally — "Switched on/off" for a module is "Включён/Выключен", and the
warning before a purge says outright that it cannot be undone.

Also fixes something the suite was quietly depending on. The functional
tests drive real requests through the real registry, which reads the state
file of the site — so a developer who had switched a module off on their
own installation would see unrelated tests fail with a 404. The test
bootstrap now points the registry at a state file that does not exist,
which falls back to the modules of the release: what a fresh site has, and
what CI already ran against.
.
docs(migrations): the source of a module is its alias
Three things the module work changed about migrations, none of them written
down until now: the source is the alias rather than the directory, a
switched-off module keeps its source because its tables are still there,
and one that was never installed has none at all. Plus the rollback that
undoes a whole source, which uninstalling a module with --purge is built
on.

Also bumps the documentation submodule: the two module pages.
.
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.