diff --git a/README.md b/README.md index 0560ec3dd68..3be539f4ce8 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ volumes: ### Docker container -Find [here](doc/docker.adoc) information on running Etherpad in a container. +Find [here](doc/docker.md) information on running Etherpad in a container. ## Plugins @@ -247,8 +247,8 @@ git -P tag --list "v*" --merged ``` 4. Select the version ```sh -git checkout v2.2.5 -git switch -c v2.2.5 +git checkout v3.2.0 +git switch -c v3.2.0 ``` 5. Upgrade Etherpad ```sh diff --git a/bin/compactAllPads.ts b/bin/compactAllPads.ts index c7c03ac1212..84333b2cf15 100644 --- a/bin/compactAllPads.ts +++ b/bin/compactAllPads.ts @@ -4,9 +4,9 @@ * Compact every pad on the instance to reclaim database space. * * Usage: - * node bin/compactAllPads.js # collapse all history on every pad - * node bin/compactAllPads.js --keep N # keep last N revisions per pad - * node bin/compactAllPads.js --dry-run # list pads + rev counts, no writes + * pnpm run --filter bin compactAllPads # collapse all history on every pad + * pnpm run --filter bin compactAllPads --keep N # keep last N revisions per pad + * pnpm run --filter bin compactAllPads --dry-run # list pads + rev counts, no writes * * Composes the existing `listAllPads` and `compactPad` HTTP APIs — there is * deliberately no instance-wide HTTP endpoint, because doing this over a @@ -170,9 +170,9 @@ export const parseArgs = (argv: string[]): CompactAllOpts | null => { // so the test harness can use `runCompactAll` directly without network. const usage = () => { console.error('Usage:'); - console.error(' node bin/compactAllPads.js'); - console.error(' node bin/compactAllPads.js --keep '); - console.error(' node bin/compactAllPads.js --dry-run'); + console.error(' pnpm run --filter bin compactAllPads'); + console.error(' pnpm run --filter bin compactAllPads --keep '); + console.error(' pnpm run --filter bin compactAllPads --dry-run'); process.exit(2); }; diff --git a/bin/compactPad.ts b/bin/compactPad.ts index 69f521ba2c5..0f9529a46f5 100644 --- a/bin/compactPad.ts +++ b/bin/compactPad.ts @@ -4,8 +4,8 @@ * Compact a pad's revision history to reclaim database space. * * Usage: - * node bin/compactPad.js # collapse all history - * node bin/compactPad.js --keep N # keep only the last N revisions + * pnpm run --filter bin compactPad # collapse all history + * pnpm run --filter bin compactPad --keep N # keep only the last N revisions * * Wraps the existing Cleanup helper (src/node/utils/Cleanup.ts) via the * compactPad HTTP API so admins can trigger it from the CLI without @@ -41,8 +41,8 @@ const apiPost = async (p: string): Promise => { const usage = () => { console.error('Usage:'); - console.error(' node bin/compactPad.js '); - console.error(' node bin/compactPad.js --keep '); + console.error(' pnpm run --filter bin compactPad '); + console.error(' pnpm run --filter bin compactPad --keep '); process.exit(2); }; diff --git a/bin/compactStalePads.ts b/bin/compactStalePads.ts index df52cac3a35..f768518b702 100644 --- a/bin/compactStalePads.ts +++ b/bin/compactStalePads.ts @@ -4,9 +4,9 @@ * Compact every pad on the instance that has not been edited recently. * * Usage: - * node bin/compactStalePads.js --older-than 90 # collapse history on pads not edited in 90 days - * node bin/compactStalePads.js --older-than 90 --keep 50 # keep last 50 revisions - * node bin/compactStalePads.js --older-than 90 --dry-run # list, don't write + * pnpm run --filter bin compactStalePads --older-than 90 # collapse history on pads not edited in 90 days + * pnpm run --filter bin compactStalePads --older-than 90 --keep 50 # keep last 50 revisions + * pnpm run --filter bin compactStalePads --older-than 90 --dry-run # list, don't write * * Composes `listAllPads` → `getLastEdited` → `compactPad`. Same shape as * `bin/compactAllPads` (per-pad error tolerance, dry-run, tally), but @@ -255,9 +255,9 @@ export const parseArgs = (argv: string[]): CompactStaleOpts | null => { const usage = () => { console.error('Usage:'); - console.error(' node bin/compactStalePads.js --older-than '); - console.error(' node bin/compactStalePads.js --older-than --keep '); - console.error(' node bin/compactStalePads.js --older-than --dry-run'); + console.error(' pnpm run --filter bin compactStalePads --older-than '); + console.error(' pnpm run --filter bin compactStalePads --older-than --keep '); + console.error(' pnpm run --filter bin compactStalePads --older-than --dry-run'); process.exit(2); }; diff --git a/bin/package.json b/bin/package.json index d2982c36818..3f2b3246d01 100644 --- a/bin/package.json +++ b/bin/package.json @@ -24,6 +24,7 @@ "checkAllPads": "node --import tsx checkAllPads.ts", "compactPad": "node --import tsx compactPad.ts", "compactAllPads": "node --import tsx compactAllPads.ts", + "compactStalePads": "node --import tsx compactStalePads.ts", "createUserSession": "node --import tsx createUserSession.ts", "deletePad": "node --import tsx deletePad.ts", "repairPad": "node --import tsx repairPad.ts", diff --git a/doc/.vitepress/config.mts b/doc/.vitepress/config.mts index 1b63dd4652b..85c00f5aa5d 100644 --- a/doc/.vitepress/config.mts +++ b/doc/.vitepress/config.mts @@ -26,6 +26,7 @@ export default defineConfig({ text: 'About', items: [ { text: 'Docker', link: '/docker.md' }, + { text: 'Configuration', link: '/configuration.md' }, { text: 'Localization', link: '/localization.md' }, { text: 'Cookies', link: '/cookies.md' }, { text: 'Plugins', link: '/plugins.md' }, diff --git a/doc/admin/updates.md b/doc/admin/updates.md index 55ae41d371f..b9932e664f4 100644 --- a/doc/admin/updates.md +++ b/doc/admin/updates.md @@ -29,13 +29,22 @@ In `settings.json`: "requireSignature": false, "trustedKeysPath": null }, - "adminEmail": null + "adminEmail": null, + // SMTP transport for the admin notification emails. host=null keeps + // log-only behaviour ("(would send email)"); set host+from to deliver. + "mail": { + "host": null, + "port": 587, + "secure": false, + "from": null, + "auth": null + } } ``` | Setting | Default | Notes | | --- | --- | --- | -| `updates.tier` | `"notify"` | One of `"off"`, `"notify"`, `"manual"`, `"auto"`, `"autonomous"`. Higher tiers are silently downgraded if the install method does not allow them. PR 1 only honors `"notify"` and `"off"`. | +| `updates.tier` | `"notify"` | One of `"off"`, `"notify"`, `"manual"`, `"auto"`, `"autonomous"`. All tiers are implemented. Higher tiers are silently downgraded if the install method does not allow them (only `"git"` installs can run the write tiers `manual` / `auto` / `autonomous`). | | `updates.source` | `"github"` | Reserved for future alternative sources. Only `"github"` is implemented. | | `updates.channel` | `"stable"` | Reserved. Stable releases only. | | `updates.installMethod` | `"auto"` | One of `"auto"`, `"git"`, `"docker"`, `"npm"`, `"managed"`. Auto-detects via filesystem heuristics. Set explicitly to override. | @@ -49,6 +58,11 @@ In `settings.json`: | `updates.requireSignature` | `false` | When `true`, refuse updates whose tag is not signed by a trusted key. Verification is done via `git verify-tag ` against the user's GPG keyring. Default `false` because Etherpad's release process does not yet sign tags consistently — turning the check on by default would block every Tier 2 update. Set `true` if you run your own builds or have imported a fork's keys. | | `updates.trustedKeysPath` | `null` | Override the keyring location passed to `git verify-tag` via the `$GNUPGHOME` env var. Useful when the trusted keys live in a dedicated keyring outside the Etherpad user's home. Only meaningful when `requireSignature: true`. | | `adminEmail` | `null` | Top-level. Contact for admin notifications. Setting it enables the email nudges below. | +| `mail.host` | `null` | Top-level SMTP host. **`null` keeps log-only behaviour** — notifications are logged as `(would send email)` and never delivered. Set a host (and `mail.from`) to deliver over SMTP via nodemailer. The `nodemailer` dependency is lazy-loaded, so installs that leave `mail.host` unset pay no runtime cost. | +| `mail.port` | `587` | SMTP port. | +| `mail.secure` | `false` | `true` for an implicit-TLS connection (typically port 465); `false` uses STARTTLS upgrade when offered. | +| `mail.from` | `null` | Envelope/From address. **Required for delivery** — if `mail.from` is unset (even with a host) the updater falls back to log-only `(would send email)`. | +| `mail.auth` | `null` | SMTP credentials object `{ "user": "...", "pass": "..." }`, passed through to nodemailer. Leave `null` for unauthenticated relays. | ## What "outdated" means @@ -56,14 +70,26 @@ In `settings.json`: ## Email cadence (when `adminEmail` is set) +These are the "nudge" emails sent by the periodic checker when the instance is behind: + | Trigger | First send | Repeat | | --- | --- | --- | -| Outdated (minor or more behind) detected | Immediate | Monthly while still outdated | +| Outdated (minor or more behind) detected | Immediate | Every 30 days while still outdated (`SEVERE_INTERVAL`) | | Up to date | No email | — | +The write tiers also email about **apply outcomes** so admins learn about failures without watching the UI: + +| Outcome | When | Dedupe | +| --- | --- | --- | +| `update-preflight-failed` | An auto/autonomous apply was blocked at preflight (e.g. `node-engine-mismatch`, dirty tree, low disk). Subject: *Auto-update to `` blocked at preflight*. | Deduped on `:` — one email per outcome per target tag. | +| `update-rolled-back` | An apply failed mid-flow and Etherpad auto-recovered to the previous version. Subject: *Auto-update to `` rolled back*. | Deduped on `:`. | +| `update-rollback-failed` | **Terminal.** The apply failed *and* the rollback failed — manual intervention required. Subject: *Auto-update FAILED and could not be rolled back — manual intervention required*. | **Always sends**, bypassing dedupe, because the admin must learn about it even if a transient failure shared the same key. | + +A different outcome or a different target tag resets the dedupe key and fires a fresh email. Manual (Tier 2) failures surface in the admin UI banner; the outcome emails are tied to the auto/autonomous flows. + If `adminEmail` is unset, the updater never sends mail. The admin UI banner and the pad-side notice still work without it. -PR 1 ships the cadence machinery but does not yet wire a real SMTP transport — emails are logged with `(would send email)` until a future PR adds the transport. The dedupe state still advances correctly so admins are not bombarded once SMTP is wired. +SMTP delivery is wired via [nodemailer](https://nodemailer.com/) (lazy-loaded). When `mail.host` and `mail.from` are both set, emails are delivered over SMTP. When either is unset the updater falls back to logging each message as `(would send email)` — the dedupe state still advances correctly, so admins are not bombarded once SMTP is configured. An SMTP send failure is caught and logged (`email send failed: …`) and never disrupts the updater state machine. ## Pad-side notice @@ -93,7 +119,7 @@ The version check sends no telemetry. Etherpad fetches the public GitHub Release Set the value explicitly if the heuristics get it wrong (e.g., a docker container that bind-mounts a writable git checkout). -In PR 1 (notify only) the install method does not change behavior — every install method gets the banner. From PR 2 onward the install method gates whether the manual-click and automatic tiers can run; only `"git"` is initially supported for write tiers. +Every install method gets the Tier 1 banner. The install method gates whether the write tiers (manual click, auto, autonomous) can run: only `"git"` installs are supported for the write tiers — other methods are silently downgraded to notify. ## Tier 2 — manual click @@ -110,7 +136,7 @@ Etherpad applies an update by **exiting with code 75** so a process supervisor r ### What clicking "Apply update" does 1. **Lock acquire** — `var/update.lock` (PID-based, stale locks reaped automatically). -2. **Pre-flight checks** — install method writable, working tree clean, free disk ≥ `diskSpaceMinMB`, `pnpm` on `PATH`, target tag exists at the configured remote, signature verifies (if `requireSignature: true`). On failure, state goes to `preflight-failed` with a typed reason; the admin sees a banner and clicks **Acknowledge** to clear it. No filesystem mutation has happened — nothing to roll back. +2. **Pre-flight checks** — install method writable, working tree clean, free disk ≥ `diskSpaceMinMB`, `pnpm` on `PATH`, no lock held, target tag exists at the configured remote, signature verifies (if `requireSignature: true`), and the target's Node engine matches the running Node. The Node-engine check runs *after* signature verification (so the `engines.node` range comes from a trusted tag): Etherpad reads `engines.node` from the target tag's `package.json` via `git show :package.json` and refuses the update via `semver.satisfies` if the running Node does not satisfy it. On failure, state goes to `preflight-failed` with a typed reason; the admin sees a banner and clicks **Acknowledge** to clear it. No filesystem mutation has happened — nothing to roll back. 3. **Drain** — `drainSeconds` window during which T-60 / T-30 / T-10 announcements broadcast to every connected pad and new socket connections are refused. Click **Cancel** during this window to abort cleanly. 4. **Execute** — `git fetch --tags origin`, `git checkout `, `pnpm install --frozen-lockfile`, `pnpm run build:ui`. Output streams to `var/log/update.log` (rotated 10 MB × 5). 5. **Exit 75** — the supervisor restarts on the new version. @@ -121,6 +147,7 @@ Etherpad applies an update by **exiting with code 75** so a process supervisor r | What went wrong | Resulting state | Admin action | | --- | --- | --- | | Pre-flight check fails | `preflight-failed` | Click **Acknowledge** after fixing the underlying issue (free up disk, clean working tree, etc.). | +| Target tag requires a newer (or different) Node than the one running | `preflight-failed` (reason `node-engine-mismatch`) | Fails cleanly at preflight with a detail like *"target requires Node >=X, running Y"*. No drain, no `git checkout`, no restart, nothing to roll back — the install is untouched. Upgrade Node to a version that satisfies the target's `engines.node`, then **Acknowledge** and retry. | | `git fetch` / `git checkout` fails mid-flow | `rolled-back` | Informational. The working tree is back where it started; click **Acknowledge** to clear. | | `pnpm install` or `pnpm run build:ui` fails | `rolled-back` | Same as above. The lockfile and SHA are restored. | | `/health` doesn't come up within `rollbackHealthCheckSeconds` | `rolled-back` | Same — RollbackHandler restores the previous SHA + lockfile and exits 75 again. | @@ -181,7 +208,7 @@ A single `grace-start` notification fires per scheduled tag: > [Etherpad] Auto-update scheduled for 2.7.2 -with the `scheduledFor` timestamp. Etherpad core does not yet wire SMTP; the message logs as `(would send email)` until a future PR adds a transport. Cadence and dedupe still update correctly. +with the `scheduledFor` timestamp. Delivery follows the same SMTP path as every other notification: when `mail.host` and `mail.from` are set the message is sent via nodemailer, otherwise it logs as `(would send email)`. Cadence and dedupe update correctly either way. The right way to give docker admins an in-product Apply button is to delegate to the orchestrator rather than mutate the container. Two patterns to consider in a follow-up PR: diff --git a/doc/api/hooks_client-side.md b/doc/api/hooks_client-side.md index d6b36681c26..6e75c5f083c 100644 --- a/doc/api/hooks_client-side.md +++ b/doc/api/hooks_client-side.md @@ -465,6 +465,20 @@ also use this to handle existing types. `collab_client.js` has a pretty extensive list of message types, if you want to take a look. +## handleClientTimesliderMessage_`name` + +Called from: `src/static/js/broadcast.ts` + +Things in context: + +1. payload - the data that got sent with the message (use it for custom message + content) + +This is the timeslider analog of `handleClientMessage_name`. It gets called +every time the timeslider receives a message of type `name`. Use it to handle +custom message types (or react to existing ones) while a user is viewing the +timeslider rather than editing the pad. + ## aceStartLineAndCharForPoint-aceEndLineAndCharForPoint Called from: src/static/js/ace2_inner.js @@ -496,6 +510,34 @@ Things in context: This hook is provided to allow a plugin to handle key events. The return value should be true if you have handled the event. +## acePaste + +Called from: `src/static/js/ace2_inner.ts` + +Things in context: + +1. editorInfo - information about the user who is making the change +2. rep - information about where the change is being made +3. documentAttributeManager - information about attributes in the document +4. e - the fired paste event + +This hook is called when content is pasted into the editor, before Etherpad +processes the pasted content. Use it to inspect or react to paste events. + +## aceDrop + +Called from: `src/static/js/ace2_inner.ts` + +Things in context: + +1. editorInfo - information about the user who is making the change +2. rep - information about where the change is being made +3. documentAttributeManager - information about attributes in the document +4. e - the fired drop event + +This hook is called when content is dropped into the editor via drag-and-drop. +Use it to inspect or react to drop events. + ## collectContentLineText Called from: `src/static/js/contentcollector.js` diff --git a/doc/api/hooks_server-side.md b/doc/api/hooks_server-side.md index 05a66209f10..dc94c306993 100644 --- a/doc/api/hooks_server-side.md +++ b/doc/api/hooks_server-side.md @@ -856,6 +856,39 @@ exports.exportHTMLAdditionalContent = async (hookName, {padId}) => { }; ``` +## exportHTMLSend + +Called from: `src/node/handler/ExportHandler.ts` + +Things in context: + +1. html - the full HTML body of the pad that is about to be sent to the client + as an HTML export. + +This hook is invoked via `aCallFirst` for HTML exports, just before the HTML is +sent in the response. It lets a plugin rewrite or replace the exported HTML +body. Return the modified HTML string; if a non-empty value is returned, it +replaces the HTML that would otherwise be sent. + +## exportConvert + +Called from: `src/node/handler/ExportHandler.ts` + +Things in context: + +1. srcFile - the path to the source file (the intermediate file Etherpad has + produced) that needs to be converted. +2. destFile - the path to the destination file Etherpad expects the converted + output to be written to. +3. req - the express request object. +4. res - the express response object. + +This hook is invoked via `aCallAll` for non-HTML export formats. It lets a +plugin handle the export format conversion itself instead of letting Etherpad +fall back to its bundled LibreOffice converter. If any plugin returns a value +(making the hook result non-empty), Etherpad assumes the conversion was handled +by the plugin and skips the built-in converter. + ## stylesForExport Called from: `src/node/utils/ExportHtml.js` @@ -1101,3 +1134,66 @@ Context properties: value with the user's actual author ID before this hook runs). * `padId`: The pad's real (not read-only) identifier. * `pad`: The pad's Pad object. + +## ccRegisterBlockElements + +Called from: `src/node/handler/ImportHandler.ts`, +`src/node/utils/ImportEtherpad.ts`, and `src/static/js/contentcollector.ts` + +Things in context: None + +This is the **server-side companion** to the client-only +`aceRegisterBlockElements` hook (see the client-side hooks documentation), and +it is the half that plugin authors commonly forget to implement. Registering a +block element only on the client means imports and server-side content +collection do not treat your element as block-level. + +The return value of this hook should be an array of tag names. Those tags are +added to the set of block-level elements that the content collector and the +import path (`.etherpad` and HTML/document imports) recognise, so the +collected/imported content is segmented into the correct lines. Plugins that +declare block elements via `aceRegisterBlockElements` should declare the same +elements here too. + +```js +exports.ccRegisterBlockElements = () => ['pre', 'blockquote']; +``` + +## createServer + +Called from: `src/node/server.ts` + +Things in context: None + +Invoked via `aCallAll` once during Etherpad startup, after the HTTP server has +been created. Use it to run any plugin initialisation that needs to happen when +the server comes up. + +## restartServer + +Called from: `src/node/hooks/express/adminsettings.ts` (and the plugin +installer) + +Things in context: None + +Invoked via `aCallAll` when the server is restarted from the admin interface +(for example after settings are saved or a plugin is installed/uninstalled). Use +it to re-initialise plugin state that needs to survive an admin-triggered +restart. + +## clientReady + +> **Deprecated:** use the `userJoin` hook instead. This hook is fired with an +> awkward context (the raw `CLIENT_READY` message rather than a structured set +> of context properties) and is kept only for backwards compatibility. + +Called from: `src/node/handler/PadMessageHandler.ts` + +Things in context: + +1. message - the raw `CLIENT_READY` message sent by the client as it joins a + pad. + +Invoked via `aCallAll` while a client is joining a pad, before the author and +session are fully set up. Because the context is just the raw client message, +new plugins should use `userJoin` instead. diff --git a/doc/api/http_api.md b/doc/api/http_api.md index 32c0f8424f7..7167754b9e9 100644 --- a/doc/api/http_api.md +++ b/doc/api/http_api.md @@ -306,6 +306,18 @@ Returns the Author Name of the author -> can't be deleted cause this would involve scanning all the pads where this author was +#### anonymizeAuthor(authorID) +* API >= 1.3.1 + +Erases an author's identity across all pads they contributed to (GDPR Article 17, the "right to erasure"). The author's name and external/token mappings are removed and their chat messages are cleared, so the author can no longer be re-identified from pad data. + +This endpoint is **disabled by default** and is gated on `gdprAuthorErasure.enabled = true` in `settings.json`. If GDPR author erasure is not enabled, the call returns an error and performs no changes. See [doc/privacy.md](../privacy.md) for the privacy/data-retention context. + +*Example returns:* +* `{code: 0, message:"ok", data: {affectedPads: 3, removedTokenMappings: 1, removedExternalMappings: 1, clearedChatMessages: 7}}` +* `{code: 1, message:"anonymizeAuthor is disabled — set gdprAuthorErasure.enabled = true in settings.json to enable GDPR Art. 17 erasure", data: null}` +* `{code: 1, message:"authorID is required", data: null}` + ### Session Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID will be set as a cookie to the client and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. Only users with a valid session for this group, can access group pads. You can create a session after you authenticated the user at your web application, to give them access to the pads. You should save the sessionID of this session and delete it after the user logged out. @@ -609,7 +621,7 @@ token is ignored. * `{code: 1, message:"invalid deletionToken", data: null}` #### copyPad(sourceID, destinationID[, force=false]) -* API >= 1.2.8 +* API >= 1.2.9 copies a pad with full history and chat. If force is true and the destination pad exists, it will be overwritten. @@ -629,7 +641,7 @@ Note that all the revisions will be lost! In most of the cases one should use `c * `{code: 1, message:"padID does not exist", data: null}` #### movePad(sourceID, destinationID[, force=false]) -* API >= 1.2.8 +* API >= 1.2.9 moves a pad. If force is true and the destination pad exists, it will be overwritten. @@ -646,7 +658,7 @@ collapses the pad's revision history to reclaim database space (issue #6194). Wr When `keepRevisions` is omitted (or null), all history is collapsed into a single base revision that reproduces the current pad text — equivalent to a freshly-imported pad. When set to a positive integer N, the pad keeps only its last N revisions. -Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. **This operation is destructive — export the pad first via `getEtherpad` if you need a backup.** +Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. **This operation is destructive — export the pad first (for example via the `/p/:padID/export/etherpad` export endpoint) if you need a full-history backup.** *Example returns:* * `{code: 0, message:"ok", data: {ok: true, mode: "all"}}` @@ -664,10 +676,10 @@ returns the read only link of a pad * `{code: 0, message:"ok", data: {readOnlyID: "r.s8oes9dhwrvt0zif"}}` * `{code: 1, message:"padID does not exist", data: null}` -#### getPadID(readOnlyID) +#### getPadID(roID) * API >= 1.2.10 -returns the id of a pad which is assigned to the readOnlyID +returns the id of the pad mapped to the given read-only id. The query parameter is named `roID` (the read-only id returned by `getReadOnlyID`). *Example returns:* * `{code: 0, message:"ok", data: {padID: "p.s8oes9dhwrvt0zif"}}` diff --git a/doc/cli.md b/doc/cli.md index aa46bb65a17..12209fe6d92 100644 --- a/doc/cli.md +++ b/doc/cli.md @@ -1,68 +1,151 @@ # CLI -You can find different tools for migrating things, checking your Etherpad health in the bin directory. -One of these is the migrateDB command. It takes two settings.json files and copies data from one source to another one. -In this example we migrate from the old dirty db to the new rustydb engine. So we copy these files to the root of the etherpad-directory. +Etherpad ships a set of operator tools in the `bin/` directory for migrating +data, reclaiming database space, repairing damaged pads, managing sessions and +managing plugins. They are TypeScript scripts run through `tsx`, and each one +is registered as a pnpm script in `bin/package.json`. Invoke them from the +Etherpad root with: + +``` +pnpm run --filter bin