MishkaCmsCore.Install.Extensions (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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 needthe 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

Types

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.

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

entry()

@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

active?(site_id, app)

@spec active?(String.t(), atom() | String.t()) :: boolean()

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.

active_key?(key)

@spec active_key?(String.t()) :: boolean()

Whether the settings key key is an extension's active?/2 switch, as settings/2 declares it.

event()

@spec event() :: String.t()

The event an extension subscribes to in order to be installable.

install(site_id, key, answers \\ %{}, actor \\ nil)

@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".

installable()

@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.

installed?(site_id, app)

@spec installed?(String.t(), atom() | String.t()) :: boolean()

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.

installed_version(site_id, app)

@spec installed_version(String.t(), atom() | String.t()) :: String.t() | nil

The version site_id was set up with, or nil for a site that never was.

mark_installed!(site_id, app, version, actor \\ nil)

@spec mark_installed!(String.t(), atom() | String.t(), String.t() | nil, map() | nil) ::
  :ok

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.

offered()

@spec offered() :: [entry()]

The extensions a picker should draw — installable/0 without the ones nothing would reach.

settings(app, category)

@spec settings(atom(), String.t()) :: [map()]

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>.installed is 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!/4 writes it.
  • <app>.active is 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.