from dataclasses import asdict, dataclass
from typing import TypedDict

from features.plugins.declared import Manifest, command_text


@dataclass(frozen=True)
class PreviewRow:
    kind: str
    label: str
    command: str


def preview_rows(manifest: Manifest) -> list[PreviewRow]:
    rows = [PreviewRow("needs", wanted.tool, command_text(wanted.check)) for wanted in manifest.requires]
    rows += [PreviewRow("setup", step.name, command_text(step.run)) for step in manifest.setup]
    rows += [PreviewRow("service", service.name, command_text(service.run)) for service in manifest.services]
    rows += [PreviewRow("on", handler.pattern, handler.command) for handler in manifest.handlers]
    rows += [PreviewRow("refuse", "may refuse a write", command_text(manifest.refuse))] if manifest.refuse else []
    rows += [PreviewRow("page", page.title, f"{page.service}{page.path}") for page in manifest.pages]
    rows += [PreviewRow("setting", setting.key, setting.summary) for setting in manifest.settings]
    return rows


class Previewed(TypedDict):
    name: str
    title: str
    source: str
    commit: str
    description: str
    rows: list[dict]


def previewed(manifest: Manifest, source: str, commit: str) -> Previewed:
    return {"name": manifest.name, "title": f"{manifest.heading} {manifest.version}".strip(),
            "source": source, "commit": commit, "description": manifest.description, "rows": [asdict(row) for row in preview_rows(manifest)]}


def preview(manifest: Manifest, source: str, commit: str) -> str:
    lines = [f"{manifest.heading} {manifest.version}".strip(), f"from {source}" + (f" at {commit[:12]}" if commit else ""), manifest.description, "",
             "It runs as you, with your files and your network. These are its commands:"]
    lines += [f"  {row.kind} {row.label}: {row.command}" for row in preview_rows(manifest)]
    return "\n".join(lines)
import fcntl
import re
import secrets
import shutil
from pathlib import Path
from typing import NamedTuple

from features.plugins.declared import Manifest
from features.plugins.manifest import read
from features.plugins.paths import busy_file, home
from install import fetch
from resources.base import Refused

REPOSITORY = re.compile(r"[\w.-]+/[\w.-]+$")
REMOTE = ("http://", "https://", "git@", "file://", "ssh://")


def remote(address: str) -> bool:
    return address.startswith(REMOTE)


def address(source: str) -> str:
    given = str(source).strip()
    local = Path(given).expanduser()
    if local.exists():
        return str(local.resolve())
    if remote(given):
        return given
    if REPOSITORY.fullmatch(given):
        return f"https://github.com/{given}"
    raise Refused(f"{given!r} is neither a repository URL, an owner/repo, nor a folder on this machine")


def said_version(where: Path, manifest: Manifest) -> str:
    if manifest.version:
        return manifest.version
    kept = Path(where) / "VERSION"
    try:
        return kept.read_text().strip()[:32]
    except OSError:
        return ""


class Staged(NamedTuple):
    where: Path
    manifest: Manifest
    commit: str
    linked: bool


def staged(root: Path, source: str, revision: str, version: str) -> Staged:
    where = address(source)
    linked = not remote(where)
    if linked:
        return Staged(Path(where), read(Path(where), version), "", True)
    staging = home(root) / f".staging-{secrets.token_hex(4)}"
    commit, failed = fetch(staging, where, revision)
    if failed:
        shutil.rmtree(staging, ignore_errors=True)
        raise Refused(f"{where} could not be fetched: {failed}")
    try:
        return Staged(staging, read(staging, version), commit, False)
    except Refused:
        shutil.rmtree(staging, ignore_errors=True)
        raise


def alone(root: Path, name: str):
    lock = busy_file(root, name)
    lock.parent.mkdir(parents=True, exist_ok=True)
    held = lock.open("w")
    try:
        fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError as error:
        held.close()
        raise Refused(f"{name} is being installed already; wait for that to finish") from error
    return held


def token() -> str:
    return secrets.token_hex(16)
from pathlib import Path

from engine.proc import ran, streamed
from engine.wording import fill
from features.plugins.declared import Manifest, command_text, shell_args
from resources.base import Refused

SETUP_SECONDS = 900
CHECK_SECONDS = 60
SHOWN_LINES = 40


def checked(manifest: Manifest, where: Path, env: dict) -> None:
    for wanted in manifest.requires:
        done = ran(shell_args(wanted.check), where, CHECK_SECONDS, env=env)
        if done is None or done.returncode:
            raise Refused(f"{manifest.name} needs {wanted.tool}: {wanted.hint_text}")


def prepared(manifest: Manifest, where: Path, env: dict, record_log: Path) -> None:
    record_log.parent.mkdir(parents=True, exist_ok=True)
    for step in manifest.setup:
        command = fill(step.run, env)
        with record_log.open("a") as f:
            f.write(f"$ {command_text(command)}\n")
        written = [0]

        def append(output: str) -> None:
            with record_log.open("a") as f:
                f.write(output[written[0]:])
            written[0] = len(output)
        code, out = streamed(shell_args(command), where / step.cwd,
                             SETUP_SECONDS, append, env)
        append(f"{out}\n")
        if code != 0:
            tail = "\n".join(out.strip().splitlines()[-SHOWN_LINES:])
            raise Refused(f"setup step {step.name!r} failed ({code}): {command_text(command)}\n{tail}\nthe whole output is in {record_log}")
from dataclasses import dataclass, field, replace

from engine.fields import Loaded
from resources.base import Refused

REFUSE_SECONDS = 1.5
LONGEST_REFUSE = 3.0


def command_text(command) -> str:
    return command if isinstance(command, str) else " ".join(command)


def shell_args(command) -> list[str]:
    return ["/bin/sh", "-c", command] if isinstance(command, str) else list(command)


@dataclass(frozen=True)
class Requirement(Loaded):
    keyed_by = "tool"
    tool: str
    check: object
    hint: str = ""

    @property
    def hint_text(self) -> str:
        return self.hint if self.hint else command_text(self.check)


@dataclass(frozen=True)
class Step(Loaded):
    run: object
    name: str = ""
    cwd: str = ""


@dataclass(frozen=True)
class Ready(Loaded):
    path: str = ""


@dataclass(frozen=True)
class Service(Loaded):
    keyed_by = "name"
    name: str
    run: object
    cwd: str = ""
    env: dict = field(default_factory=dict)
    port: object = None
    ready: Ready = Ready()
    restart: str = "on-failure"
    grace: float = 5.0
    show: dict = field(default_factory=dict)
    when: str = ""


@dataclass(frozen=True)
class Handler(Loaded):
    keyed_by = "pattern"
    pattern: str
    run: object = None
    post: str = ""

    @property
    def command(self) -> str:
        return self.post if self.post else command_text(self.run)


@dataclass(frozen=True)
class ChatRule(Loaded):
    aliases = {"replacement": ("as",)}
    find: str
    replacement: str


@dataclass(frozen=True)
class Dashboard(Loaded):
    name: str
    title: str
    icon: str = ""


@dataclass(frozen=True)
class Page(Loaded):
    name: str
    title: str
    service: str
    path: str
    icon: str = ""
    status: str = ""


@dataclass(frozen=True)
class Setting(Loaded):
    keyed_by = "key"
    aliases = {"kind": ("type",)}
    key: str
    title: str = ""
    default: object = ""
    env: str = ""
    kind: str = "text"
    options: tuple = ()

    @property
    def summary(self) -> str:
        if self.env:
            return f"reads {self.env}"
        return self.title if self.title else self.key

    def check(self, value: str) -> None:
        if self.kind == "flag" and value not in ("true", "false"):
            raise Refused(f"{self.key} is a switch: true or false")
        if self.kind == "number" and not value.lstrip("-").replace(".", "", 1).isdigit():
            raise Refused(f"{self.key} is a number, not {value!r}")
        if self.kind == "options" and value not in [str(option) for option in self.options]:
            raise Refused(f"{self.key} is one of {', '.join(map(str, self.options))}")


@dataclass(frozen=True)
class Card(Loaded):
    label: str = ""
    icon: str = ""
    tone: str = ""
    color: str = ""
    collapsed: bool = False


@dataclass(frozen=True)
class DeclaredEvent(Loaded):
    keyed_by = "name"
    name: str
    title: str = ""
    tone: str = ""
    card: Card | None = None

    @property
    def collapsed(self) -> bool:
        return self.card is not None and self.card.collapsed


@dataclass(frozen=True)
class Manifest(Loaded):
    aliases = {"handlers": ("on",)}
    stored: dict = field(default_factory=dict)
    name: str = ""
    version: str = ""
    title: str = ""
    description: str = ""
    requires: tuple[Requirement, ...] = ()
    env: dict = field(default_factory=dict)
    setup: tuple[Step, ...] = ()
    services: tuple[Service, ...] = ()
    handlers: tuple[Handler, ...] = ()
    refuse: object = None
    reads: bool = False
    refuse_seconds: float = 0.0
    refuse_socket: str = ""
    chat: tuple[ChatRule, ...] = ()
    pages: tuple[Page, ...] = ()
    dashboards: tuple[Dashboard, ...] = ()
    settings: tuple[Setting, ...] = ()
    skills: str = ""
    installed: str = ""
    events: tuple[DeclaredEvent, ...] = ()
    cancels: dict = field(default_factory=dict)
    load: dict = field(default_factory=dict)

    @classmethod
    def of(cls, raw) -> "Manifest":
        given = raw if isinstance(raw, dict) else {}
        return replace(cls.from_json(given), stored=given)

    @property
    def heading(self) -> str:
        return self.title if self.title else self.name

    @property
    def refuse_budget(self) -> float:
        return min(self.refuse_seconds if self.refuse_seconds else REFUSE_SECONDS, LONGEST_REFUSE)

    def event(self, name: str) -> DeclaredEvent | None:
        return next((event for event in self.events if event.name == name), None)

    def setting(self, key: str) -> Setting | None:
        return next((setting for setting in self.settings if setting.key == key), None)

    def skills_for(self, known) -> list[str]:
        return list(dict.fromkeys(skill for pattern in known if pattern in self.load for skill in self.load[pattern]))

    def listening(self, known) -> list[Handler]:
        return [handler for handler in self.handlers if handler.pattern in known]


MANIFESTS: dict[tuple, Manifest] = {}


def declared(row) -> Manifest:
    key = (row.title, row.n, row.updated)
    if key not in MANIFESTS:
        MANIFESTS[key] = Manifest.of(row.manifest)
    return MANIFESTS[key]


def called(row) -> str:
    return declared(row).name


def named(plugins, name: str):
    return next((row for row in plugins._standing() if called(row) == name), None)


@dataclass(frozen=True)
class PluginSettings(Loaded):
    ports: dict = field(default_factory=dict)
    chosen: dict = field(default_factory=dict)
    kept: dict = field(default_factory=dict)

    @classmethod
    def of(cls, raw) -> "PluginSettings":
        given = raw if isinstance(raw, dict) else {}
        return replace(cls.from_json(given), kept=given)

    def to_json(self) -> dict:
        return {**self.kept, "ports": self.ports, "chosen": self.chosen}


def settings_of(row) -> PluginSettings:
    return PluginSettings.of(row.settings)


def settings_with(row, **changes) -> dict:
    return replace(settings_of(row), **changes).to_json()
diff --git a/src/features/plugins/commands.py b/src/features/plugins/commands.py
index 4ec5b01a..16305a84 100644
--- a/src/features/plugins/commands.py
+++ b/src/features/plugins/commands.py
@@ -1,41 +1,28 @@
-import json
 import shutil
 
 from engine.version import version
 from features.parts import Command, Context
-from features.plugins.answer import Posting, apply, raised
-from features.plugins.declared import Manifest, Setting, declared, settings_of
-from features.plugins.lifecycle import called, difference, drop, place, reread, restarted
-from features.plugins.manifest import fill, read
-from features.plugins.run import SECONDS, call
-from features.plugins.source import alone, checked, data, environment, folder, log, logged, ports_for, prepared, preview, said_version, staged, token
+from features.plugins.answer import Posting, raised
+from features.plugins.declared import called, declared, named, settings_of, settings_with
+from features.plugins.environment import environment, ports_for
+from features.plugins.lifecycle import difference, fetched, install_staged, reread, restarted
+from features.plugins.manifest import read
+from features.plugins.paths import data, folder, log
+from features.plugins.preview import preview
+from features.plugins.setup import prepared
+from features.plugins.staging import alone, token
 from resources.base import Refused
-from dataclasses import replace
 
 VERSION = version()
 
 
-def welcomed(journal, plugins, manifest: Manifest, env: dict) -> None:
-    step = manifest.installed
-    if not step:
-        return
-    root, name = plugins.record.root, manifest.name
-    ok, reply = call(fill(step, env), folder(root, name), env, {"event": "plugin.installed"}, SECONDS)
-    logged(root, name, f"installed {json.dumps(reply, ensure_ascii=False) if ok else reply}")
-    if ok and isinstance(reply, dict):
-        apply(plugins.record, journal, name, "", reply)
-
-
 class Preview(Command):
     name = "preview"
     network = True
 
     def run(self, context: Context, plugins, source: str, ref: str = "") -> str:
-        where, manifest, commit, linked = staged(plugins.record.root, source, ref, VERSION)
-        try:
-            return preview(manifest, source, commit)
-        finally:
-            drop(where, linked)
+        with fetched(plugins.record.root, source, ref) as stage:
+            return preview(stage.manifest, source, stage.commit)
 
 
 class Install(Command):
@@ -44,31 +31,16 @@ class Install(Command):
 
     def run(self, context: Context, plugins, source: str, ref: str = "", yes: bool = False):
         root = plugins.record.root
-        where, manifest, commit, linked = staged(root, source, ref, VERSION)
-        name, kept, held = manifest.name, False, None
-        try:
-            taken = next((r for r in plugins._standing() if called(r) == name), None)
+        with fetched(root, source, ref) as stage:
+            name = stage.manifest.name
+            taken = named(plugins, name)
             if taken:
                 raise Refused(f"a plugin named {name} is installed from {taken.source}: remove it first")
             if not yes:
-                return f"{preview(manifest, source, commit)}\n\nNothing is installed yet. To install exactly this, run it again with --yes" + (f" --ref {commit}" if commit else "")
-            held = alone(root, name)
-            secret = token()
-            ports = ports_for(root, manifest)
-            env = environment(root, name, manifest, secret, ports)
-            checked(manifest, where, env)
-            data(root, name).mkdir(parents=True, exist_ok=True)
-            prepared(manifest, where, env, log(root, name))
-            made = place(plugins, where, linked, manifest, source, ref, commit, secret, ports=ports)
-            welcomed(context.journal, plugins, manifest, env)
-            restarted(root, manifest)
-            kept = True
-        finally:
-            if held:
-                held.close()
-            if not kept:
-                drop(where, linked)
-        context.journal.log("installed", name=name, source=source, commit=f" at {commit[:12]}" if commit else "", about=made.ref)
+                return f"{preview(stage.manifest, source, stage.commit)}\n\nNothing is installed yet. To install exactly this, run it again with --yes" + (f" --ref {stage.commit}" if stage.commit else "")
+            with alone(root, name):
+                made = install_staged(context.journal, plugins, stage, source, ref, token(), ports_for(root, stage.manifest))
+        context.journal.log("installed", name=name, source=source, commit=f" at {stage.commit[:12]}" if stage.commit else "", about=made.ref)
         return made
 
 
@@ -87,24 +59,14 @@ class Upgrade(Command):
                 ports = {**ports_for(root, manifest), **settings.ports}
                 prepared(manifest, where, environment(root, manifest.name, manifest, row.token, ports, settings.chosen), log(root, manifest.name))
             return reread(plugins, row)
-        where, manifest, commit, linked = staged(root, row.source, row.revision if ref is None else ref, VERSION)
-        kept = False
-        try:
+        with fetched(root, row.source, row.revision if ref is None else ref) as stage:
+            manifest, commit = stage.manifest, stage.commit
             if commit == row.commit and ref is None and not again:
                 return f"{called(row)} is already at {commit[:12]}; to run its setup again anyway, run it with --again --yes"
             if not yes:
                 return f"{preview(manifest, row.source, commit)}\n\n{difference(declared(row), manifest)}\nNothing has changed yet. To upgrade to exactly this, run it again with --yes --ref {commit}"
             ports = {**ports_for(root, manifest), **settings.ports}
-            env = environment(root, manifest.name, manifest, row.token, ports, settings.chosen)
-            checked(manifest, where, env)
-            prepared(manifest, where, env, log(root, manifest.name))
-            place(plugins, where, linked, manifest, row.source, "" if ref is None else ref, commit, row.token, row=row, ports=ports)
-            welcomed(context.journal, plugins, manifest, env)
-            restarted(root, manifest)
-            kept = True
-        finally:
-            if not kept:
-                drop(where, linked)
+            install_staged(context.journal, plugins, stage, row.source, "" if ref is None else ref, row.token, ports, settings.chosen, row)
         return plugins.load(n)
 
 
@@ -122,16 +84,6 @@ class Disable(Command):
         return plugins.update(n, enabled=False)
 
 
-def allowed(key: str, setting: Setting, value: str) -> None:
-    kind = setting.kind
-    if kind == "flag" and value not in ("true", "false"):
-        raise Refused(f"{key} is a switch: true or false")
-    if kind == "number" and not value.lstrip("-").replace(".", "", 1).isdigit():
-        raise Refused(f"{key} is a number, not {value!r}")
-    if kind == "options" and value not in [str(option) for option in setting.options]:
-        raise Refused(f"{key} is one of {', '.join(map(str, setting.options))}")
-
-
 class Configure(Command):
     name = "configure"
 
@@ -142,9 +94,8 @@ class Configure(Command):
         if setting is None:
             names = ", ".join(s.key for s in manifest.settings)
             raise Refused(f"{called(row)} has no setting {key!r}; it has {names if names else 'none'}")
-        allowed(key, setting, value)
-        settings = settings_of(row)
-        updated = plugins.update(row.n, settings=replace(settings, chosen={**settings.chosen, key: value}).to_json())
+        setting.check(value)
+        updated = plugins.update(row.n, settings=settings_with(row, chosen={**settings_of(row).chosen, key: value}))
         restarted(plugins.record.root, manifest)
         return updated
 
diff --git a/src/features/plugins/manifest.py b/src/features/plugins/manifest.py
index e1abe4dc..fb397214 100644
--- a/src/features/plugins/manifest.py
+++ b/src/features/plugins/manifest.py
@@ -14,7 +14,6 @@ MANIFEST = Path(".journal-plugin") / "plugin.json"
 KEYS = ("name", "version", "title", "description", "journal", "requires", "env", "setup", "services", "on", "refuse", "reads", "refuse_seconds", "refuse_socket", "chat", "pages", "dashboards", "settings", "skills", "load", "installed", "events", "cancels")
 NAME = re.compile(r"[a-z0-9][a-z0-9-]{1,31}$")
 WORD = re.compile(r"[a-z][a-z0-9_-]*$")
-PLACEHOLDER = re.compile(r"\{([a-z][a-z0-9_.]*)\}")
 PATTERNS = {"*", *TYPES, *ACTIONS, *(f"{t}.{a}" for t in TYPES for a in ACTIONS), "hook.*", *(f"hook.{e}" for e in (*EVENTS, DISPLAYED))}
 STEP = ("name", "run", "cwd")
 SERVICE = ("run", "cwd", "env", "port", "ready", "restart", "grace", "show", "when")
@@ -36,9 +35,7 @@ def read(folder: Path, version: str = "") -> Manifest:
         raise Refused(f"plugin.json is not JSON: {error}") from error
     if not isinstance(given, dict):
         raise Refused("plugin.json holds one object, with a name and what the plugin listens to")
-    for key in given:
-        if key not in KEYS:
-            raise Refused(f"plugin.json: unknown key {key!r}; known: {', '.join(KEYS)}")
+    unknown_keys(given, KEYS, "plugin.json")
     name = given.get("name")
     if not isinstance(name, str) or not NAME.fullmatch(name):
         raise Refused(f"plugin.json: name must be 2-32 lowercase letters, digits or dashes, got {name!r}")
@@ -51,30 +48,30 @@ def read(folder: Path, version: str = "") -> Manifest:
     if wanted and version and newer(str(wanted), version):
         raise Refused(f"{name} needs journal {wanted} or newer; this is {version} — run journal upgrade")
     checked = {key: given[key] for key in KEYS if key in given}
-    checked["requires"] = shaped(name, given.get("requires") or {}, "requires", ("check", "hint"), ("check",))
-    checked["env"] = texts(name, given.get("env") or {}, "env")
-    checked["setup"] = steps(name, given.get("setup") or [])
-    checked["services"] = services(name, given.get("services") or {})
-    checked["on"] = handlers(name, given.get("on") or {})
+    checked["requires"] = shaped(given.get("requires") or {}, "requires", ("check", "hint"), ("check",))
+    checked["env"] = texts(given.get("env") or {}, "env")
+    checked["setup"] = steps(given.get("setup") or [])
+    checked["services"] = services(given.get("services") or {})
+    checked["on"] = handlers(given.get("on") or {})
     if given.get("refuse_socket") and given["refuse_socket"] not in checked["services"]:
         raise Refused(f"plugin.json: refuse_socket names one of its services, not {given['refuse_socket']!r}")
-    checked["chat"] = chat(name, given.get("chat") or [])
+    checked["chat"] = chat(given.get("chat") or [])
     checked["pages"] = pages(name, given.get("pages") or [], checked["services"])
     checked["dashboards"] = dashboards(given.get("dashboards") or [])
-    checked["settings"] = typed(shaped(name, given.get("settings") or {}, "settings", SETTING, ()))
-    checked["events"] = shaped(name, given.get("events") or {}, "events", ("title", "tone", "card"), ("title",))
+    checked["settings"] = typed(shaped(given.get("settings") or {}, "settings", SETTING, ()))
+    checked["events"] = shaped(given.get("events") or {}, "events", ("title", "tone", "card"), ("title",))
     for event, fields in checked["events"].items():
         if "card" in fields:
-            shaped(name, {event: fields["card"]}, "events.card", ("label", "color", "icon", "collapsed"), ())
+            shaped({event: fields["card"]}, "events.card", ("label", "color", "icon", "collapsed"), ())
         if fields.get("tone", "") not in TONES:
             raise Refused(f"plugin.json: events.{event}.tone is one of {', '.join(t for t in TONES if t)}")
     checked["load"] = loads(given.get("load") or {}, checked["events"])
     if "cancels" in checked:
         if not isinstance(checked["cancels"], dict) or any(event not in CANCELABLE for event in checked["cancels"]):
             raise Refused(f"plugin.json: cancels names events that can be cancelled: {', '.join(CANCELABLE)}")
-        checked["cancels"] = {event: command(name, f"cancels.{event}", run) for event, run in checked["cancels"].items()}
+        checked["cancels"] = {event: command(f"cancels.{event}", run) for event, run in checked["cancels"].items()}
     if "refuse" in checked:
-        checked["refuse"] = command(name, "refuse", checked["refuse"])
+        checked["refuse"] = command("refuse", checked["refuse"])
     checked["reads"] = bool(given.get("reads"))
     if "installed" in checked and not isinstance(checked["installed"], str):
         raise Refused("plugin.json: installed is one command, run right after the plugin is installed or upgraded; its answer fills the settings")
@@ -85,7 +82,13 @@ def read(folder: Path, version: str = "") -> Manifest:
     return Manifest.of(checked)
 
 
-def command(name: str, where: str, given) -> str | list:
+def unknown_keys(given: dict, known, where: str) -> None:
+    for key in given:
+        if key not in known:
+            raise Refused(f"{where} has unknown key {key!r}; known: {', '.join(known)}")
+
+
+def command(where: str, given) -> str | list:
     if isinstance(given, str) and given.strip():
         return given
     if isinstance(given, list) and given and all(isinstance(part, str) and part for part in given):
@@ -93,21 +96,19 @@ def command(name: str, where: str, given) -> str | list:
     raise Refused(f"plugin.json: {where} is a command, a line or a list of words, not {given!r}")
 
 
-def texts(name: str, given, where: str) -> dict:
+def texts(given, where: str) -> dict:
     if not isinstance(given, dict) or not all(isinstance(v, str) for v in given.values()):
         raise Refused(f"plugin.json: {where} names values, each one text")
     return dict(given)
 
 
-def shaped(name: str, given, where: str, keys: tuple, needed: tuple) -> dict:
+def shaped(given, where: str, keys: tuple, needed: tuple) -> dict:
     if not isinstance(given, dict):
         raise Refused(f"plugin.json: {where} names one entry each")
     for key, value in given.items():
         if not isinstance(value, dict):
             raise Refused(f"plugin.json: {where}.{key} is an object with {', '.join(keys)}")
-        for field in value:
-            if field not in keys:
-                raise Refused(f"plugin.json: {where}.{key} has unknown key {field!r}; known: {', '.join(keys)}")
+        unknown_keys(value, keys, f"plugin.json: {where}.{key}")
         for field in needed:
             if not value.get(field):
                 raise Refused(f"plugin.json: {where}.{key} needs {field}")
@@ -132,7 +133,7 @@ def typed(settings: dict) -> dict:
     return settings
 
 
-def steps(name: str, given) -> list[dict]:
+def steps(given) -> list[dict]:
     if not isinstance(given, list):
         raise Refused("plugin.json: setup is a list of steps, run in order")
     out = []
@@ -141,17 +142,15 @@ def steps(name: str, given) -> list[dict]:
             step = {"run": step}
         if not isinstance(step, dict):
             raise Refused(f"plugin.json: setup step {i} is a command or an object with {', '.join(STEP)}")
-        for field in step:
-            if field not in STEP:
-                raise Refused(f"plugin.json: setup step {i} has unknown key {field!r}; known: {', '.join(STEP)}")
-        made = {"name": str(step.get("name") or f"step {i}"), "run": command(name, f"setup step {i}", step.get("run"))}
+        unknown_keys(step, STEP, f"plugin.json: setup step {i}")
+        made = {"name": str(step.get("name") or f"step {i}"), "run": command(f"setup step {i}", step.get("run"))}
         if step.get("cwd"):
             made["cwd"] = str(step["cwd"])
         out.append(made)
     return out
 
 
-def services(name: str, given) -> dict:
+def services(given) -> dict:
     if not isinstance(given, dict):
         raise Refused("plugin.json: services names one service each")
     out = {}
@@ -160,9 +159,7 @@ def services(name: str, given) -> dict:
             raise Refused(f"plugin.json: service names are lowercase words; {service!r} is not")
         if not isinstance(value, dict):
             raise Refused(f"plugin.json: service {service!r} is an object with {', '.join(SERVICE)}")
-        for field in value:
-            if field not in SERVICE:
-                raise Refused(f"plugin.json: service {service!r} has unknown key {field!r}; known: {', '.join(SERVICE)}")
+        unknown_keys(value, SERVICE, f"plugin.json: service {service!r}")
         port = value.get("port")
         if port is not None and port != "auto" and not isinstance(port, int):
             raise Refused(f"plugin.json: service {service!r} takes a port number or \"auto\", not {port!r}")
@@ -172,11 +169,11 @@ def services(name: str, given) -> dict:
         when = value.get("when") or ""
         if not isinstance(when, str):
             raise Refused(f"plugin.json: service {service!r} takes \"when\" as one shell command whose exit 0 means it is needed here")
-        out[service] = {**value, "run": command(name, f"service {service!r}", value.get("run")), "restart": restart, "when": when}
+        out[service] = {**value, "run": command(f"service {service!r}", value.get("run")), "restart": restart, "when": when}
     return out
 
 
-def chat(name: str, given) -> list:
+def chat(given) -> list:
     if not isinstance(given, list):
         raise Refused("plugin.json: chat is a list of {\"find\": \"<regex>\", \"as\": \"<markdown>\"}")
     out = []
@@ -202,7 +199,7 @@ def loads(given, events: dict) -> dict:
     return dict(given)
 
 
-def handlers(name: str, given) -> dict:
+def handlers(given) -> dict:
     if not isinstance(given, dict):
         raise Refused("plugin.json: on names an event pattern for each handler")
     out = {}
@@ -214,7 +211,7 @@ def handlers(name: str, given) -> dict:
                 raise Refused(f"plugin.json: on {pattern!r} is a command, or {{\"post\": \"<url>\"}}")
             out[pattern] = dict(handler)
             continue
-        out[pattern] = {"run": command(name, f"on {pattern!r}", handler)}
+        out[pattern] = {"run": command(f"on {pattern!r}", handler)}
     return out
 
 
@@ -225,9 +222,7 @@ def pages(name: str, given, declared: dict) -> list[dict]:
     for page in given:
         if not isinstance(page, dict):
             raise Refused(f"plugin.json: each page is an object with {', '.join(PAGE)}")
-        for field in page:
-            if field not in PAGE:
-                raise Refused(f"plugin.json: page has unknown key {field!r}; known: {', '.join(PAGE)}")
+        unknown_keys(page, PAGE, "plugin.json: page")
         service = page.get("service")
         if service not in declared:
             raise Refused(f"plugin.json: page {page.get('title') or page.get('name')!r} names service {service!r}, which is not declared")
@@ -242,18 +237,6 @@ def dashboards(given) -> list[dict]:
     for board in given:
         if not isinstance(board, dict) or not board.get("name"):
             raise Refused(f"plugin.json: each dashboard is an object with a name, and may have {', '.join(DASHBOARD[1:])}")
-        for field in board:
-            if field not in DASHBOARD:
-                raise Refused(f"plugin.json: dashboard has unknown key {field!r}; known: {', '.join(DASHBOARD)}")
+        unknown_keys(board, DASHBOARD, "plugin.json: dashboard")
         out.append({**board, "name": str(board["name"]), "title": str(board.get("title") or board["name"]).strip()})
     return out
-
-
-def fill(value, values: dict):
-    if isinstance(value, str):
-        return PLACEHOLDER.sub(lambda m: str(values.get(m.group(1), m.group(0))), value)
-    if isinstance(value, list):
-        return [fill(part, values) for part in value]
-    if isinstance(value, dict):
-        return {key: fill(part, values) for key, part in value.items()}
-    return value
