Which extensions can install themselves onto a site, and the one call that asks one to.
There is no registry here, because there already is one
An earlier draft of this invented a second event to ask "who is installable?". It was not needed:
MishkaInstaller.Event.Event IS that registry. A plugin subscribing to installer:extension:install
has already told the installer it exists, under which OTP app, at which priority, depending on
which other events, and whether an operator has switched it off — all persisted, all read back
with one call.
So SUBSCRIBING IS DECLARING. installable/0 reads the records; nothing is kept in sync by hand,
and an extension that is not installed cannot appear in a list it never registered with.
What each record carries, and what we do NOT write ourselves:
| we need | the record's field |
|---|---|
| which extension | :extension, the OTP app |
| what to call it on screen | :extra, below |
| the order to offer them in | :priority |
| switched off everywhere | :status |
| one extension before another | :depends, with cycles already refused |
:extra is the only shape we define
Event.register/3 takes it from the plugin's own initial: and re-reads it on every upgrade, so
a label changed in a new release reaches installs that registered under the old one:
use MishkaInstaller.Event.Hook,
event: "installer:extension:install",
initial: %{
priority: 50,
extra: [%{label: "Blog", summary: "Posts, categories, tags and their pages", fields: []}]
}fields is [] for an extension that needs nothing asked, and a list of
%{name:, label:, type:, required?:} for one that does — the installer draws them and hands the
answers back in install/4.
Installing
install/4 fires the event once. Every subscribed plugin sees it and ignores a key that is not
its own; the one whose key it is appends its report to :reports. That is the plain
MishkaInstaller transform chain — the payload goes out, comes back, and what came back is what
the screen shows.
The payload
%{
site_id: "…", # the site to install onto
key: "…", # whose install this is; a plugin ignores any other
answers: %{}, # what the operator typed for this extension's own `fields`
reports: [], # where the plugin whose key it is appends its report
ask: :install, # what is asked: an install, the only question there is
actor: user # OR `nil`
}:actor IS PART OF THE CONTRACT, not a convenience. A plugin must hand it to whatever writes —
MishkaCmsCore.Runtime.ExtensionProvision.ensure!/4 takes it as actor: — so the rows land under
the resource's own policy and the version history says who asked for them. nil means there is no
session behind the call, which is true of the installer wizard and of the dev seeds, and those
write as the system. The extension's permission names are the one exception: a name is shared by
every site and creating one is a master's action, so the system writes them whoever asked.
Summary
Functions
Whether app is switched on for site_id — true unless somebody turned it off.
Whether the settings key key is an extension's active?/2 switch, as settings/2 declares it.
The event an extension subscribes to in order to be installable.
Asks the extension named by key to install itself onto site_id, and returns what it did.
Every extension registered against the event, whatever its status, in the order they are offered.
Whether site_id already has app — which is what sends an admin to the dashboard rather than
the installer.
The version site_id was set up with, or nil for a site that never was.
Records that site_id has been set up with app, at version.
The extensions a picker should draw — installable/0 without the ones nothing would reach.
The settings an extension's settings:register plugin should add so its own tab carries both of
the per-site answers this module reads.
Types
@type entry() :: %{ key: String.t(), app: atom(), plugin: module(), label: String.t(), summary: String.t(), fields: [map()], status: atom(), priority: integer() }
One extension that can install itself. key is the OTP app as a string, which is what
install/4 is given back and what a plugin matches on.
Functions
Whether app is switched on for site_id — true unless somebody turned it off.
A site that has never been set up is still ACTIVE: the switch says whether the extension is wanted here, and a site that wants it but has not run the installer is exactly who the installer screen is for.
Whether the settings key key is an extension's active?/2 switch, as settings/2 declares it.
@spec event() :: String.t()
The event an extension subscribes to in order to be installable.
@spec install(String.t(), String.t() | atom(), map(), map() | nil) :: {:ok, MishkaCmsCore.Runtime.PageProvision.report() | map()} | {:error, term()}
Asks the extension named by key to install itself onto site_id, and returns what it did.
answers are the values the operator typed for the extension's own fields, empty for one that
asked for none. The return is the extension's report — %{permissions:, components:, pages:, snippets:, renamed_pages:, skipped_pages:} — or {:error, reason} when no plugin answered to that key, so
a caller can say "nothing installed it" rather than "it worked and did nothing".
@spec installable() :: [entry()]
Every extension registered against the event, whatever its status, in the order they are offered.
Use offered/0 for the list a picker should draw; this one is for a screen that has to explain
why something is missing from it.
Whether site_id already has app — which is what sends an admin to the dashboard rather than
the installer.
The record mark_installed!/4 writes after an install is the whole answer: a site carrying no
record has never been set up with app, whatever pages it holds.
The version site_id was set up with, or nil for a site that never was.
Records that site_id has been set up with app, at version.
Written after the install rather than before it, so a run that failed halfway leaves the site looking like what it is — not set up — and the menu keeps offering to finish the job.
@spec offered() :: [entry()]
The extensions a picker should draw — installable/0 without the ones nothing would reach.
The settings an extension's settings:register plugin should add so its own tab carries both of
the per-site answers this module reads.
Settings.definitions() ++ Extensions.settings(:mishka_blog, "blog")There are two of them and they are not the same question:
<app>.installedis STATE — the version this site was set up at, or blank for a site that never was. It is what decides whether the extension's menu opens on its dashboard or on its installer, so it is not editable: an admin typing a version into it would claim an install that never happened.mark_installed!/4writes it.<app>.activeis a PREFERENCE — the switch that hides the extension from this site. Off is not an uninstall: nothing is deleted, the menu is simply not drawn, and switching it back on brings everything back.
Per-site, because both answers are: one site may run the blog and another never want it.
The switch takes the setting area, as a setting declaring no permission of its own does. The
install record is written by the admin who ran the install, and setting a site up takes the site
area (MishkaCmsCore.Runtime.ExtensionProvision.required/1), so it takes the site area or the
setting area; the settings page shows it read-only, to them alone.