From 9b1b201f65e4b542bcb322ef5c453df3ced10f13 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 8 Nov 2026 20:39:49 +0100 Subject: [PATCH] Split conflict detection out of plugin.ts and the CI/CD section out of the README --- .gitea/workflows/release.yml | 2 +- AGENTS.md | 2 +- README.md | 110 +------------------------- docs/ci-cd.md | 105 ++++++++++++++++++++++++ docs/reading-cost.md | 18 +++++ release-tooling/dockerhub-overview.ts | 4 +- release-tooling/next-version.ts | 2 +- renovate.json | 2 +- src/plugin-host/conflicts.test.ts | 73 +++++++++++++++++ src/plugin-host/conflicts.ts | 104 ++++++++++++++++++++++++ src/plugin-host/discovery.ts | 3 +- src/plugin-host/plugin.test.ts | 70 ---------------- src/plugin-host/plugin.ts | 102 +----------------------- todo.md | 8 -- 14 files changed, 314 insertions(+), 291 deletions(-) create mode 100644 docs/ci-cd.md create mode 100644 src/plugin-host/conflicts.test.ts create mode 100644 src/plugin-host/conflicts.ts diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 0ca879a..3e7d544 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -57,7 +57,7 @@ jobs: COMMIT=$(git rev-parse 'HEAD^{commit}') VERSION=${GIT_TAG#v} [ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \ - || { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; } + || { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (docs/ci-cd.md)"; exit 1; } printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin TAGS="$VERSION ${VERSION%.*} latest" if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi diff --git a/AGENTS.md b/AGENTS.md index b1156b7..b1752fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,7 +107,7 @@ Revisit only if the stated reason stops holding. design: the prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`, because a plugin id `db` with key `url` would otherwise name the host's own `PLUGIN_DB_URL`; keys are camelCase so the `camelCase → SNAKE_CASE` mapping is total and no two keys collide, with the residual cross-plugin - collision caught by `findConflicts`; `required` and `default` are mutually exclusive, which is what + collision caught by `findConflicts` (`plugin-host/conflicts.ts`); `required` and `default` are mutually exclusive, which is what lets `SettingsOf` type a declared key as present rather than `T | undefined`, so no plugin author casts; and a secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings` — not even as a mask or a length. An author mistake is refused at discovery, a bad operator value diff --git a/README.md b/README.md index f27b87a..474c9d9 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,6 @@ docker compose up -d - [end-to-end](#end-to-end-playwright) - [the full gate](#the-full-gate-one-command) - [CI/CD](#cicd) - - [one-time setup](#one-time-ci-setup) - [Production & deployment](#production--deployment) - [Upgrading](#upgrading) - [Observability](#observability) @@ -1585,109 +1584,8 @@ gate runs them serially, one stack up/down per suite. ## CI/CD -Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs -[`ci.sh`](#the-full-gate-one-command) — the exact gate you run locally: - -| Workflow | Trigger | Does | -| --- | --- | --- | -| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image | -| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `latest` (plus `X` once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger | -| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) | -| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head | -| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged | - -`main` is not re-tested on push — its commits are meant to arrive already green from a -gated branch, so the status check to gate a merge on is `CI / full-gate (push)`. - -**Merge gate on `main`** (Gitea branch protection + repo merge settings, set via the API — -no repo files involved): direct pushes are blocked, changes land via PR only, the -`CI / full-gate (push)` status must be green (admins included), and the only merge style is -**fast-forward-only** — history stays linear and `main`'s head is the exact commit hash of -the merged branch, which is why the branch's push-triggered status carries over. - -**Container images** — after a green gate, `ci.yml` builds the app image and pushes it as -`gitea.larvit.se/larvit/plainpages:`. Because merges are fast-forward-only, the -image for any `main` commit already exists from that commit's branch gate — build once, promote by -re-tagging. The build runs inside the required gate, so a missing/expired token or a registry outage -blocks **all** merges until restored; use a non-expiring token or track its expiry. Hash tags -accumulate one image per gated push, so nightly `registry-cleanup.yml` prunes them -([`registry-cleanup/cleanup.ts`](registry-cleanup/cleanup.ts) defines what survives). Don't add a -pattern-based org cleanup rule for this package — its age/count heuristics can't see branch heads or -release tags and would delete images the workflow protects. - -**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs -`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and — -once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the -gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract -break and a moving `:0` would carry one. It fails loud if no -hash image exists — release tags must point at a commit that went through the gate. The same four -tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only. - -The [contract check](#contract-versioning) guards the tag before anything is published, refusing one -whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set. - -**The Docker Hub overview** is published by a separate `publish-overview` job from -[`release-tooling/dockerhub-overview.md.tmpl`](release-tooling/dockerhub-overview.md.tmpl), with -`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go -stale. The same job sets the page's short description. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the -quick start stays a topology CI has actually run. -It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub -outage leaves the promotion green and the images untouched; and the page has its own door — run the -workflow manually with an `overview_version` input to republish it without cutting a release. That -input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor` -disagrees with the tree being published, is refused. It uses -the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope. - -**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is -read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift. -Tags go with `--prune`, so deleting one here deletes it there on the next run, and a tag *created* on -GitHub is swept away — cut releases on Gitea, never on the mirror. Branches other than `main` match -no refspec and are left alone. - -**Dependency updates** — `renovate.yml` runs [Renovate](https://docs.renovatebot.com) nightly -(self-hosted, this repo only) against [`renovate.json`](renovate.json), bumping npm deps, Docker base -images, Gitea action versions, and the image tags pinned inside workflow `run:` steps (a custom regex -manager, so nothing pinned drifts unmanaged). Version-locked sets move together in one PR — the Ory -images, and the Playwright runner + its browser image — and every bump keeps the **exact semver pin** -exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once -`CI / full-gate (push)` is green; only a red gate needs a human. - -**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one** -`vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and -**skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a -trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a -`Release-Bump: ` trailer onto the updates that reach a running Plainpages — the rules in -[`renovate.json`](renovate.json) name them — and -[`release-tooling/next-version.ts`](release-tooling/next-version.ts) turns the highest one into the next -version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release -version, an update big enough to reach a **minor** stops the job rather than tagging: bump -`HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since -`nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump, -so the trailer is an allowlist: an update outside those rules carries none and rides the next patch -release instead of escalating it. It is **tag-only**: the tag hands off to -`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by -the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here. - -### One-time CI setup - -| Actions var / secret | Value | -| --- | --- | -| `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. | -| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a **read/write/delete** token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. | -| `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. | -| `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. | -| `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. | - -Gitea rejects secret names prefixed `GITEA_`/`GITHUB_`. Each job fails loud until its secret exists. -The container package is **org-owned**, so it lists under `larvit/-/packages` — link it to the repo's -Packages tab once: `POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages`. - -**The runner** — register one -[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode labelled `docker-host` -(`labels: ["docker-host:host"]`) on a machine with Docker Engine + Compose, git, and Node + -github.com access. Runs must **never overlap** — the e2e stacks use fixed compose project names, the -devstack suite uses host networking, and the workflows share the Docker daemon's registry login — so -keep exactly one runner at capacity 1, with host ports 3000/4433 free. +The pipeline, merge gate, releases, GitHub mirror, Renovate and one-time CI setup are in +[`docs/ci-cd.md`](docs/ci-cd.md). ## Production & deployment @@ -1884,7 +1782,7 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime · english · view-locals · locales/ (the core en-US + sv-SE catalogs) plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts - (ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) · + (ctx.system) · discovery · conflicts · router · hooks · view-resolver · storage (the rules) · storage-provisioning (the DDL; bootstrap-only, holds the driver) ui/ chrome (`ctx.chrome`: menu, brand, user) · nav (composeNav) · menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate @@ -1911,7 +1809,7 @@ release-tooling/ Everything the release runs: next-version (the bump math), registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts) CHANGELOG.md What changed per release, and how to upgrade a plugin across a minor ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack -.gitea/workflows/ Gitea Actions — see CI/CD +.gitea/workflows/ Gitea Actions — see docs/ci-cd.md ``` ## Extending the core diff --git a/docs/ci-cd.md b/docs/ci-cd.md new file mode 100644 index 0000000..64d4332 --- /dev/null +++ b/docs/ci-cd.md @@ -0,0 +1,105 @@ +# CI/CD + +Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs +[`ci.sh`](../README.md#the-full-gate-one-command) — the exact gate you run locally: + +| Workflow | Trigger | Does | +| --- | --- | --- | +| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image | +| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `latest` (plus `X` once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger | +| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) | +| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head | +| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged | + +`main` is not re-tested on push — its commits are meant to arrive already green from a +gated branch, so the status check to gate a merge on is `CI / full-gate (push)`. + +**Merge gate on `main`** (Gitea branch protection + repo merge settings, set via the API — +no repo files involved): direct pushes are blocked, changes land via PR only, the +`CI / full-gate (push)` status must be green (admins included), and the only merge style is +**fast-forward-only** — history stays linear and `main`'s head is the exact commit hash of +the merged branch, which is why the branch's push-triggered status carries over. + +**Container images** — after a green gate, `ci.yml` builds the app image and pushes it as +`gitea.larvit.se/larvit/plainpages:`. Because merges are fast-forward-only, the +image for any `main` commit already exists from that commit's branch gate — build once, promote by +re-tagging. The build runs inside the required gate, so a missing/expired token or a registry outage +blocks **all** merges until restored; use a non-expiring token or track its expiry. Hash tags +accumulate one image per gated push, so nightly `registry-cleanup.yml` prunes them +([`registry-cleanup/cleanup.ts`](registry-cleanup/cleanup.ts) defines what survives). Don't add a +pattern-based org cleanup rule for this package — its age/count heuristics can't see branch heads or +release tags and would delete images the workflow protects. + +**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs +`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and — +once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the +gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract +break and a moving `:0` would carry one. It fails loud if no +hash image exists — release tags must point at a commit that went through the gate. The same four +tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only. + +The [contract check](../README.md#contract-versioning) guards the tag before anything is published, refusing one +whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set. + +**The Docker Hub overview** is published by a separate `publish-overview` job from +[`release-tooling/dockerhub-overview.md.tmpl`](release-tooling/dockerhub-overview.md.tmpl), with +`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go +stale. The same job sets the page's short description. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the +quick start stays a topology CI has actually run. +It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub +outage leaves the promotion green and the images untouched; and the page has its own door — run the +workflow manually with an `overview_version` input to republish it without cutting a release. That +input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor` +disagrees with the tree being published, is refused. It uses +the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope. + +**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is +read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift. +Tags go with `--prune`, so deleting one here deletes it there on the next run, and a tag *created* on +GitHub is swept away — cut releases on Gitea, never on the mirror. Branches other than `main` match +no refspec and are left alone. + +**Dependency updates** — `renovate.yml` runs [Renovate](https://docs.renovatebot.com) nightly +(self-hosted, this repo only) against [`renovate.json`](renovate.json), bumping npm deps, Docker base +images, Gitea action versions, and the image tags pinned inside workflow `run:` steps (a custom regex +manager, so nothing pinned drifts unmanaged). Version-locked sets move together in one PR — the Ory +images, and the Playwright runner + its browser image — and every bump keeps the **exact semver pin** +exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once +`CI / full-gate (push)` is green; only a red gate needs a human. + +**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one** +`vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and +**skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a +trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a +`Release-Bump: ` trailer onto the updates that reach a running Plainpages — the rules in +[`renovate.json`](renovate.json) name them — and +[`release-tooling/next-version.ts`](release-tooling/next-version.ts) turns the highest one into the next +version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release +version, an update big enough to reach a **minor** stops the job rather than tagging: bump +`HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since +`nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump, +so the trailer is an allowlist: an update outside those rules carries none and rides the next patch +release instead of escalating it. It is **tag-only**: the tag hands off to +`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by +the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here. + +### One-time CI setup + +| Actions var / secret | Value | +| --- | --- | +| `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. | +| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a **read/write/delete** token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. | +| `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. | +| `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. | +| `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. | + +Gitea rejects secret names prefixed `GITEA_`/`GITHUB_`. Each job fails loud until its secret exists. +The container package is **org-owned**, so it lists under `larvit/-/packages` — link it to the repo's +Packages tab once: `POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages`. + +**The runner** — register one +[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode labelled `docker-host` +(`labels: ["docker-host:host"]`) on a machine with Docker Engine + Compose, git, and Node + +github.com access. Runs must **never overlap** — the e2e stacks use fixed compose project names, the +devstack suite uses host networking, and the workflows share the Docker daemon's registry login — so +keep exactly one runner at capacity 1, with host ports 3000/4433 free. diff --git a/docs/reading-cost.md b/docs/reading-cost.md index 9676b31..c978366 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -405,3 +405,21 @@ Read most (opens and searches): - `src/plugin-host/plugin-api.ts` 3 - `src/ui/chrome.test.ts` 3 - `CHANGELOG.md` 2 + +## Reading cost, chunk f4e8ff50cb2a, finished 2026-11-08T19:37:35Z + +73698 new input tokens before the first commit. + +Read most (opens and searches): + +- `src/http/app.ts` 7 +- `src/function-limits.test.ts` 2 +- `src/http/context.ts` 2 +- `src/http/static.ts` 2 +- `src/plugin-host/plugin.ts` 2 +- `todo.md` 2 +- `AGENTS.md` 1 +- `src/http/builtin-route.ts` 1 +- `src/plugin-host/hooks.ts` 1 + +## Reading cost reset, 2026-11-08T19:39:42Z diff --git a/release-tooling/dockerhub-overview.ts b/release-tooling/dockerhub-overview.ts index 5f95e40..9dd489d 100644 --- a/release-tooling/dockerhub-overview.ts +++ b/release-tooling/dockerhub-overview.ts @@ -56,7 +56,7 @@ async function main(): Promise { if (!version || !repo || !user || !token) { return fail( "usage: dockerhub-overview.ts ; needs DOCKERHUB_REPO, DOCKERHUB_USER and " + - "DOCKERHUB_TOKEN (README -> CI/CD)", + "DOCKERHUB_TOKEN (docs/ci-cd.md)", ); } // The page is public, so never render a version that resolves to no image. @@ -94,7 +94,7 @@ async function main(): Promise { `Docker Hub description and overview PATCH failed: ${res.status} ${res.text}` + (res.status === 403 ? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " + - "read/write/delete, which pushing images alone does not (README -> CI/CD)." + "read/write/delete, which pushing images alone does not (docs/ci-cd.md)." : ""), ); } diff --git a/release-tooling/next-version.ts b/release-tooling/next-version.ts index 2f8fb7f..eeeceea 100644 --- a/release-tooling/next-version.ts +++ b/release-tooling/next-version.ts @@ -1,5 +1,5 @@ // Pure release-version math for the Renovate auto-release (see renovate.yml → auto-release job, -// README → CI/CD). Renovate stamps each commit with a `Release-Bump: ` trailer; the +// docs/ci-cd.md). Renovate stamps each commit with a `Release-Bump: ` trailer; the // workflow feeds those values here to pick the next `vX.Y.Z` tag. Kept side-effect-free and unit // tested (next-version.test.ts) — the git/tag/push side lives in the workflow shell. diff --git a/renovate.json b/renovate.json index 86d345a..cf4f9c5 100644 --- a/renovate.json +++ b/renovate.json @@ -6,7 +6,7 @@ "automerge": true, "packageRules": [ { - "description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD): updateType rates the dependency's own jump, not whether it reaches a running Plainpages", + "description": "The host's own runtime deps. Release-Bump is opt-in per surface (docs/ci-cd.md): updateType rates the dependency's own jump, not whether it reaches a running Plainpages", "matchDepTypes": ["dependencies"], "matchFileNames": ["package.json"], "matchManagers": ["npm"], diff --git a/src/plugin-host/conflicts.test.ts b/src/plugin-host/conflicts.test.ts new file mode 100644 index 0000000..2929d4f --- /dev/null +++ b/src/plugin-host/conflicts.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { findConflicts } from "./conflicts.ts"; +import type { Plugin } from "./plugin.ts"; + +// A minimal discovered plugin (id = folder name; mount path is the derived `/`), per case. +const p = (over: Partial & Pick): Plugin => ({ apiVersion: "1.0.0", ...over }); + +test("findConflicts: a clean set has none", () => { + assert.deepEqual(findConflicts([p({ id: "a" }), p({ id: "b" })]), []); +}); + +test("findConflicts: a duplicate id and a colliding route are loud errors", () => { + const dupId = findConflicts([p({ id: "a" }), p({ id: "a" })]); + assert.ok(dupId.some((c) => c.kind === "id" && c.level === "error")); + + // Cross-plugin routes can't collide (unique `/` prefix); two identical routes in one can. + const noop = () => {}; + const dupRoute = findConflicts([p({ + id: "a", + routes: [{ handler: noop, method: "GET", path: "/t" }, { handler: noop, method: "GET", path: "/t" }], + })]); + assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t"))); +}); + +test("findConflicts: duplicate nav id is an error, a shared permission name only warns", () => { + const navDup = findConflicts([ + p({ id: "a", nav: [{ id: "dup", label: "A" }] }), + p({ id: "b", nav: [{ id: "dup", label: "B" }] }), + ]); + assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b"))); + + // Sharing a permission across plugins is legitimate → warn, not error. + const permissionDup = findConflicts([ + p({ id: "a", permissions: [{ name: "shared:read" }] }), + p({ id: "b", permissions: [{ name: "shared:read" }] }), + ]); + assert.ok(permissionDup.some((c) => c.kind === "permission" && c.level === "warn")); +}); + +const noopHandler = () => {}; + +test("findConflicts: a route, nav node or profile entry gating on a permission no plugin declares warns once per plugin and name, sorted", () => { + const gated = findConflicts([ + p({ id: "a", nav: [{ children: [{ id: "a:x", label: "X", permission: "navonly:read" }], id: "a:root", label: "A", session: true }], routes: [ + { handler: noopHandler, method: "GET", path: "/x", permission: "routeonly:read" }, + { handler: noopHandler, method: "GET", path: "/y", permission: "both:read" }, + { handler: noopHandler, method: "POST", path: "/y", permission: "both:read" }, + { handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" }, + ], permissions: [], profileMenu: [{ href: "/p", label: "P", permission: "profileonly:read" }, { href: "/q", label: "Q", session: true }, { href: "/r", label: "R", public: true }] }), + p({ id: "b", nav: [{ id: "b:y", label: "Y", permission: "both:read" }], permissions: [{ name: "declared:write" }] }), + ]); + assert.ok(gated.every((c) => c.kind === "permission" && c.level === "warn")); + assert.deepEqual(gated.map((c) => c.plugins), [["a"], ["a"], ["a"], ["a"], ["b"]]); + assert.deepEqual(gated.map((c) => /"([^"]+:[a-z]+)"/.exec(c.message)?.[1]), ["both:read", "navonly:read", "profileonly:read", "routeonly:read", "both:read"]); +}); + +test("findConflicts: a gate on a permission another plugin declares, or on none, stays quiet", () => { + assert.deepEqual(findConflicts([ + p({ id: "a", permissions: [{ name: "a:read" }], routes: [{ handler: noopHandler, method: "GET", path: "/x", permission: "a:read" }] }), + p({ id: "b", routes: [{ handler: noopHandler, method: "GET", path: "/y", permission: "a:read" }, { handler: noopHandler, method: "GET", path: "/z", session: true }] }), + ]), []); +}); + +test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => { + const handler = () => ({ html: "x" }); + const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]); + assert.ok(homeDup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b"))); + const dashDup = findConflicts([p({ id: "a", dashboard: handler }), p({ id: "b", dashboard: handler })]); + assert.ok(dashDup.some((c) => c.kind === "dashboard" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b"))); + // One owner of each (even both on one plugin) is fine. + assert.deepEqual(findConflicts([p({ id: "a", dashboard: handler, home: handler }), p({ id: "b" })]).filter((c) => c.kind === "home" || c.kind === "dashboard"), []); +}); diff --git a/src/plugin-host/conflicts.ts b/src/plugin-host/conflicts.ts new file mode 100644 index 0000000..71a2cba --- /dev/null +++ b/src/plugin-host/conflicts.ts @@ -0,0 +1,104 @@ +// Cross-plugin conflict detection over the discovered set; discovery throws on any "error" and logs +// every "warn". The per-plugin rules live in plugin.ts. + +import type { NavNode } from "../ui/nav.ts"; +import { declaredPermissions, fullPath, type Plugin } from "./plugin.ts"; +import { envName } from "./settings.ts"; + +export interface PluginConflict { + kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting"; + level: "error" | "warn"; + message: string; + plugins: string[]; // unique ids involved +} + +// Loud resolution, never last-write-wins: discovery throws on any "error" and logs every "warn". +// Mount-path uniqueness needs no rule of its own — it follows from the id check. Shared permission +// names are the one intentional overlap, so they warn rather than error. +export function findConflicts(plugins: Plugin[]): PluginConflict[] { + const out: PluginConflict[] = []; + + const idCounts = new Map(); + for (const plugin of plugins) idCounts.set(plugin.id, (idCounts.get(plugin.id) ?? 0) + 1); + for (const [id, n] of idCounts) { + if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] }); + } + + // The landing pages are single slots: "/" (home) and "/dashboard" (dashboard) take one owner + // each — two plugins claiming either is a loud error, not a race. + for (const slot of ["home", "dashboard"] as const) { + const owners = plugins.filter((plugin) => plugin[slot]).map((plugin) => plugin.id); + if (owners.length > 1) out.push({ kind: slot, level: "error", message: `${owners.length} plugins claim "${slot}" (${owners.join(", ")}); only one may own that page`, plugins: uniq(owners) }); + } + + collect(plugins, (plugin, push) => { + for (const route of plugin.routes ?? []) push(`${route.method} ${fullPath(plugin.id, route.path)}`); + }).forEach((owners, key) => { + if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) }); + }); + + collect(plugins, (plugin, push) => collectNavIds(plugin.nav, push)).forEach((owners, id) => { + if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `nav id "${id}" used ${owners.length}×; override targets ids, so they must be unique`, plugins: uniq(owners) }); + }); + + collect(plugins, (plugin, push) => { + for (const decl of plugin.permissions ?? []) push(decl.name); + }).forEach((owners, name) => { + if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "" unless shared on purpose`, plugins: uniq(owners) }); + }); + + out.push(...undeclaredGates(plugins)); + + // Both the id's dashes and the key's camel humps become underscores, so plugin "a-b" key "c" and + // plugin "a" key "bC" name one variable — one plugin would silently read the other's value. + collect(plugins, (plugin, push) => { + for (const decl of plugin.settings ?? []) push(envName(plugin.id, decl.key)); + }).forEach((owners, name) => { + if (owners.length > 1) out.push({ kind: "setting", level: "error", message: `${owners.length} settings resolve to "${name}"; rename a key or a plugin folder`, plugins: uniq(owners) }); + }); + + return out; +} + +// Declaring is optional, but the admin screens offer only declared names: a route gated on one nobody +// declares answers 403 (a nav node is hidden) until the Keto tuple is written by hand or +// ADMIN_PERMISSIONS seeds it. +function undeclaredGates(plugins: Plugin[]): PluginConflict[] { + const declared = new Set(declaredPermissions(plugins).map((decl) => decl.name)); + const out: PluginConflict[] = []; + for (const plugin of plugins) { + const gated = new Set(); + for (const route of plugin.routes ?? []) if (route.permission != null) gated.add(route.permission); + collectNavPermissions(plugin.nav, (name) => gated.add(name)); + for (const entry of plugin.profileMenu ?? []) if (entry.permission != null) gated.add(entry.permission); + for (const name of [...gated].sort()) { + if (!declared.has(name)) out.push({ kind: "permission", level: "warn", message: `plugin "${plugin.id}" gates on "${name}", which no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's \`permissions\``, plugins: [plugin.id] }); + } + } + return out; +} + +function collectNavPermissions(nodes: NavNode[] | undefined, push: (name: string) => void): void { + for (const node of nodes ?? []) { + if (node.permission != null) push(node.permission); + collectNavPermissions(node.children, push); + } +} + +// Map each emitted key → the plugin ids that emitted it (repeats kept, so within-plugin dups count). +function collect(plugins: Plugin[], emit: (plugin: Plugin, push: (key: string) => void) => void): Map { + const owners = new Map(); + for (const plugin of plugins) emit(plugin, (key) => owners.set(key, [...(owners.get(key) ?? []), plugin.id])); + return owners; +} + +function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void): void { + for (const node of nodes ?? []) { + if (node.id != null) push(node.id); + collectNavIds(node.children, push); + } +} + +function uniq(xs: string[]): string[] { + return [...new Set(xs)]; +} diff --git a/src/plugin-host/discovery.ts b/src/plugin-host/discovery.ts index 0ce2b8d..60fedc3 100644 --- a/src/plugin-host/discovery.ts +++ b/src/plugin-host/discovery.ts @@ -8,7 +8,8 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { type Gate, gatesSet } from "../auth/gate.ts"; -import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts"; +import { findConflicts } from "./conflicts.ts"; +import { checkApiVersion, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts"; import { settingsDeclError } from "./settings.ts"; import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts"; diff --git a/src/plugin-host/plugin.test.ts b/src/plugin-host/plugin.test.ts index f03ae01..7426626 100644 --- a/src/plugin-host/plugin.test.ts +++ b/src/plugin-host/plugin.test.ts @@ -4,7 +4,6 @@ import { checkApiVersion, declaredPermissions, definePlugin, - findConflicts, HOST_API_VERSION, isValidPermissionName, isValidPluginId, @@ -94,75 +93,6 @@ test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newe } }); -// A minimal discovered plugin (id = folder name; mount path is the derived `/`), per case. -const p = (over: Partial & Pick): Plugin => ({ apiVersion: "1.0.0", ...over }); - -test("findConflicts: a clean set has none", () => { - assert.deepEqual(findConflicts([p({ id: "a" }), p({ id: "b" })]), []); -}); - -test("findConflicts: a duplicate id and a colliding route are loud errors", () => { - const dupId = findConflicts([p({ id: "a" }), p({ id: "a" })]); - assert.ok(dupId.some((c) => c.kind === "id" && c.level === "error")); - - // Cross-plugin routes can't collide (unique `/` prefix); two identical routes in one can. - const noop = () => {}; - const dupRoute = findConflicts([p({ - id: "a", - routes: [{ handler: noop, method: "GET", path: "/t" }, { handler: noop, method: "GET", path: "/t" }], - })]); - assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t"))); -}); - -test("findConflicts: duplicate nav id is an error, a shared permission name only warns", () => { - const navDup = findConflicts([ - p({ id: "a", nav: [{ id: "dup", label: "A" }] }), - p({ id: "b", nav: [{ id: "dup", label: "B" }] }), - ]); - assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b"))); - - // Sharing a permission across plugins is legitimate → warn, not error. - const permissionDup = findConflicts([ - p({ id: "a", permissions: [{ name: "shared:read" }] }), - p({ id: "b", permissions: [{ name: "shared:read" }] }), - ]); - assert.ok(permissionDup.some((c) => c.kind === "permission" && c.level === "warn")); -}); - -const noopHandler = () => {}; - -test("findConflicts: a route, nav node or profile entry gating on a permission no plugin declares warns once per plugin and name, sorted", () => { - const gated = findConflicts([ - p({ id: "a", nav: [{ children: [{ id: "a:x", label: "X", permission: "navonly:read" }], id: "a:root", label: "A", session: true }], routes: [ - { handler: noopHandler, method: "GET", path: "/x", permission: "routeonly:read" }, - { handler: noopHandler, method: "GET", path: "/y", permission: "both:read" }, - { handler: noopHandler, method: "POST", path: "/y", permission: "both:read" }, - { handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" }, - ], permissions: [], profileMenu: [{ href: "/p", label: "P", permission: "profileonly:read" }, { href: "/q", label: "Q", session: true }, { href: "/r", label: "R", public: true }] }), - p({ id: "b", nav: [{ id: "b:y", label: "Y", permission: "both:read" }], permissions: [{ name: "declared:write" }] }), - ]); - assert.ok(gated.every((c) => c.kind === "permission" && c.level === "warn")); - assert.deepEqual(gated.map((c) => c.plugins), [["a"], ["a"], ["a"], ["a"], ["b"]]); - assert.deepEqual(gated.map((c) => /"([^"]+:[a-z]+)"/.exec(c.message)?.[1]), ["both:read", "navonly:read", "profileonly:read", "routeonly:read", "both:read"]); -}); - -test("findConflicts: a gate on a permission another plugin declares, or on none, stays quiet", () => { - assert.deepEqual(findConflicts([ - p({ id: "a", permissions: [{ name: "a:read" }], routes: [{ handler: noopHandler, method: "GET", path: "/x", permission: "a:read" }] }), - p({ id: "b", routes: [{ handler: noopHandler, method: "GET", path: "/y", permission: "a:read" }, { handler: noopHandler, method: "GET", path: "/z", session: true }] }), - ]), []); -}); - -test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => { - const handler = () => ({ html: "x" }); - const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]); - assert.ok(homeDup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b"))); - const dashDup = findConflicts([p({ id: "a", dashboard: handler }), p({ id: "b", dashboard: handler })]); - assert.ok(dashDup.some((c) => c.kind === "dashboard" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b"))); - // One owner of each (even both on one plugin) is fine. - assert.deepEqual(findConflicts([p({ id: "a", dashboard: handler, home: handler }), p({ id: "b" })]).filter((c) => c.kind === "home" || c.kind === "dashboard"), []); -}); - // Drift guard: every mount but the auth flows is listed by hand, so a new top-level built-in route // needs a line here too. test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field) and `admin` (a plugin) are NOT reserved", () => { diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index e0fa6db..fa66b3c 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -8,7 +8,7 @@ import type { Gate } from "../auth/gate.ts"; import type { RequestContext } from "../http/context.ts"; import type { ProfileMenuEntry } from "../ui/chrome.ts"; import type { NavNode } from "../ui/nav.ts"; -import { envName, type SettingDecl, type SettingsOf } from "./settings.ts"; +import type { SettingDecl, SettingsOf } from "./settings.ts"; import type { StorageCredentials } from "./storage.ts"; // The Plainpages release this contract ships in — see README → Contract versioning. @@ -179,106 +179,8 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO return { level: "ok", message: `apiVersion ${pluginVersion}` }; } -export interface PluginConflict { - kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting"; - level: "error" | "warn"; - message: string; - plugins: string[]; // unique ids involved -} - -// Loud resolution, never last-write-wins: discovery throws on any "error" and logs every "warn". -// Mount-path uniqueness needs no rule of its own — it follows from the id check. Shared permission -// names are the one intentional overlap, so they warn rather than error. -export function findConflicts(plugins: Plugin[]): PluginConflict[] { - const out: PluginConflict[] = []; - - const idCounts = new Map(); - for (const plugin of plugins) idCounts.set(plugin.id, (idCounts.get(plugin.id) ?? 0) + 1); - for (const [id, n] of idCounts) { - if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] }); - } - - // The landing pages are single slots: "/" (home) and "/dashboard" (dashboard) take one owner - // each — two plugins claiming either is a loud error, not a race. - for (const slot of ["home", "dashboard"] as const) { - const owners = plugins.filter((plugin) => plugin[slot]).map((plugin) => plugin.id); - if (owners.length > 1) out.push({ kind: slot, level: "error", message: `${owners.length} plugins claim "${slot}" (${owners.join(", ")}); only one may own that page`, plugins: uniq(owners) }); - } - - collect(plugins, (plugin, push) => { - for (const route of plugin.routes ?? []) push(`${route.method} ${fullPath(plugin.id, route.path)}`); - }).forEach((owners, key) => { - if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) }); - }); - - collect(plugins, (plugin, push) => collectNavIds(plugin.nav, push)).forEach((owners, id) => { - if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `nav id "${id}" used ${owners.length}×; override targets ids, so they must be unique`, plugins: uniq(owners) }); - }); - - collect(plugins, (plugin, push) => { - for (const decl of plugin.permissions ?? []) push(decl.name); - }).forEach((owners, name) => { - if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "" unless shared on purpose`, plugins: uniq(owners) }); - }); - - out.push(...undeclaredGates(plugins)); - - // Both the id's dashes and the key's camel humps become underscores, so plugin "a-b" key "c" and - // plugin "a" key "bC" name one variable — one plugin would silently read the other's value. - collect(plugins, (plugin, push) => { - for (const decl of plugin.settings ?? []) push(envName(plugin.id, decl.key)); - }).forEach((owners, name) => { - if (owners.length > 1) out.push({ kind: "setting", level: "error", message: `${owners.length} settings resolve to "${name}"; rename a key or a plugin folder`, plugins: uniq(owners) }); - }); - - return out; -} - -// Declaring is optional, but the admin screens offer only declared names: a route gated on one nobody -// declares answers 403 (a nav node is hidden) until the Keto tuple is written by hand or -// ADMIN_PERMISSIONS seeds it. -function undeclaredGates(plugins: Plugin[]): PluginConflict[] { - const declared = new Set(declaredPermissions(plugins).map((decl) => decl.name)); - const out: PluginConflict[] = []; - for (const plugin of plugins) { - const gated = new Set(); - for (const route of plugin.routes ?? []) if (route.permission != null) gated.add(route.permission); - collectNavPermissions(plugin.nav, (name) => gated.add(name)); - for (const entry of plugin.profileMenu ?? []) if (entry.permission != null) gated.add(entry.permission); - for (const name of [...gated].sort()) { - if (!declared.has(name)) out.push({ kind: "permission", level: "warn", message: `plugin "${plugin.id}" gates on "${name}", which no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's \`permissions\``, plugins: [plugin.id] }); - } - } - return out; -} - -function collectNavPermissions(nodes: NavNode[] | undefined, push: (name: string) => void): void { - for (const node of nodes ?? []) { - if (node.permission != null) push(node.permission); - collectNavPermissions(node.children, push); - } -} - -// Map each emitted key → the plugin ids that emitted it (repeats kept, so within-plugin dups count). -function collect(plugins: Plugin[], emit: (plugin: Plugin, push: (key: string) => void) => void): Map { - const owners = new Map(); - for (const plugin of plugins) emit(plugin, (key) => owners.set(key, [...(owners.get(key) ?? []), plugin.id])); - return owners; -} - -function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void): void { - for (const node of nodes ?? []) { - if (node.id != null) push(node.id); - collectNavIds(node.children, push); - } -} - // A route's full path = the plugin's mount path `/` + the route path. The single source of -// truth for both conflict detection (here) and the router, so they can't disagree. +// truth for both conflict detection and the router, so they can't disagree. export function fullPath(id: string, path: string): string { return `/${id}${path.startsWith("/") ? path : `/${path}`}`; } - -function uniq(xs: string[]): string[] { - return [...new Set(xs)]; -} diff --git a/todo.md b/todo.md index 9c2746f..c532a9b 100644 --- a/todo.md +++ b/todo.md @@ -12,7 +12,6 @@ | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| -| 59 | 0.5.0 | | | **Lower the reading cost of the files a chunk reads most before its first commit.** | 2 | 2 | 1 | 3 | 4 | code health by 3 | 9.0 | | 2 | 0.5.0 | | | **Rename the admin example plugin to a name that says what it administers.** | 3 | 1 | 2 | 4 | 6 | 9 by 3, code health by 2 | 2.6 | | 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 | | 41 | 0.5.0 | | | **Apply a permission save as one Keto call, so it cannot stop part-way.** | 2 | 2 | 2 | 4 | 3 | 2 by 2 | 2.1 | @@ -28,13 +27,6 @@ ## Details -### 59. Lower the reading cost of the files a chunk reads most before its first commit. - -`reading-cost` reports a rise: the last 5 chunks' median is 87251 new input tokens before their -first commit, the 5 before them 70583. The files the last 5 chunks read most: `todo.md` 34 times, -`README.md` 24, `src/plugin-host/plugin.ts` 11, `src/plugin-host/plugin.test.ts` 10, `AGENTS.md` 6. -Cut or split what a chunk reads without needing it, then run `reading-cost --reset`. - ### 2. Rename the admin example plugin to a name that says what it administers. The new name is `auth-admin`, chosen by the maintainer. The id is also the mount path, so the compose files,