a kickoff prompt for the next extraction, and a runbook that is true again

`plugins/EXTRACT-A-PLUGIN-PROMPT.md` is meant to be pasted into a fresh agent with
one word changed. It deliberately does not restate the runbook — it orders the
reading, says what "done" means, and sets the three behaviours that matter: map the
import graph before moving anything, ask rather than assume, and verify on a live
server after a purge.

── The runbook needed correcting before it could be handed to anyone ──

Its verification table told the reader to run `bun db:push` while the plugin was
uninstalled and expect "No changes detected". Following that today would hang on
drizzle-kit's interactive rename prompt, or — with `--force` — drop the tables of
every plugin not currently on disk. Struck out, with the reasoning, and the property
it was checking is now checked by reinstalling and finding the rows still there.

Also folded in: installs create tables through `pushPluginSchema` and verify them
afterwards; `purge` is the only honest way to test an install twice, because
uninstall keeps the directory and the tables on purpose; orphan tables are now
reported on screen; and the marketplace is part of the job — a public repo that
clones anonymously, a README that renders in both the store and the install pane, a
catalogue row, and a `BUNDLED` entry for machines with no internet.

Two rules from today that generalise beyond extraction: the platform must not carry
the plugin's VOCABULARY, not just its code — ten comments explained what Headscale
was months after it left, each correct and in the wrong repository — and nothing in
the install path may trust `$PATH`, which cost an install with
`Executable not found in $PATH: "bunx"`.

The stale "757 pass, 10 failures" is now "record the baseline before you start",
which does not go out of date.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 12:14:20 +00:00
co-authored by Claude Opus 5
parent 74f2e59069
commit 83cd7af156
2 changed files with 163 additions and 2 deletions
+79 -2
View File
@@ -1,5 +1,8 @@
# Extracting a feature into a plugin
> **Handing this to a fresh agent?** `plugins/EXTRACT-A-PLUGIN-PROMPT.md` is the kickoff prompt — paste it,
> replace the feature name, and it points back here. This file is the authority; that one is the way in.
The runbook, written the day offscale became the first one. Follow it for music, then for the rest.
**This directory holds documentation, not plugins.** Every plugin is its own repository as of
@@ -91,7 +94,7 @@ on uninstall. Only deleting a plugin's source can lose its data.
## Verification — run all of it
```
bun test # 757 pass, 10 pre-existing failures. Any 11th is yours
bun test # record the baseline BEFORE you start. Any new failure is yours
pm2 restart officer
```
@@ -107,7 +110,7 @@ Then through `/plugins`, watching PM2 and the browser at each step:
| disable | route 404s, sidecar stops, **tables and rows survive** |
| enable | comes back |
| uninstall | route gone, `pm2 list` loses it, **data still there** |
| `bun db:push` while uninstalled | `No changes detected` — data survives |
| `bun db:push` while uninstalled | **do not run this — see "What changed on 2026-08-16"** |
| install again | identical to the first install |
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
@@ -173,6 +176,80 @@ Reopen this only for a feature where the overlay is the whole product, and expec
---
## What changed on 2026-08-16. Read this before following the steps above
A day of installing plugins onto a machine that had never had one found four things. The runbook above is
still right about the shape of an extraction; these change how you verify it.
**The install no longer runs `bun db:push`, and you should not either.** A plugin's tables are created by
`pushPluginSchema`: `drizzle-kit generate` against the plugin's schema alone, into an empty directory,
then the statements applied one at a time with "already exists" skipped. It cannot emit a DROP of
anything.
The global push is a trap on any machine holding tables the current schema does not declare — a plugin
that is not on disk, anything left from a rename. drizzle-kit sees a new table appearing while others are
due to be dropped and asks, interactively, whether it is a rename. **That prompt needs a TTY.** `--force`
does not answer it, `< /dev/null` does not answer it, piping newlines does not either — all measured. With
no terminal it applies NOTHING and exits 0, so an install reports success and the plugin fails its first
query with 42P01. `--force` would be worse if it worked: the destructive statements are `drop table` for
every plugin whose directory is absent.
So: the row in the verification table that says to run `bun db:push` while uninstalled is struck out. The
property it was checking — uninstall does not destroy data — is now checked by reinstalling and finding
your rows still there.
**Use `purge` to test the install twice.** Uninstall deliberately keeps the directory and the tables, so a
second install is not a fresh one: the marketplace hides a plugin whose directory exists, and the tables
are already there. The Marketplace screen has "Delete <plugin> and all its data" — it drops the tables,
deletes the directory and regenerates the barrel, and afterwards the plugin installs from the catalogue
exactly as a stranger's machine would. **That is the only honest test of an install.**
**The install now verifies its own tables.** After the push it asks Postgres whether each declared table
exists and fails the install naming what is missing. If your extraction reports "schema push reported
success but did not create …", the push is the problem, not your schema.
**Tables with no owner are now reported.** The Marketplace screen lists any table no code on the machine
claims, with a row estimate. During an extraction this is how you notice you renamed a table and left the
old one behind — it is exactly the state that produced the trap above.
---
## The marketplace is part of the job now
An extracted plugin is not finished when it installs from disk. It has to be installable from
`marketplace.officer.dev`, which is three things:
1. **A public repository that clones anonymously over https.** Check it with
`GIT_TERMINAL_PROMPT=0 git clone --depth 1 <url> /tmp/probe` — an SSH remote only works on the machine
that published it, and the catalogue rewrites SSH to HTTPS precisely so this cannot be got wrong.
2. **A README.** It renders on the plugin's page in the store AND in the platform's install pane, so it is
the shop window as much as the documentation. `plugins/example/README.md` is the model, and the house
voice applies: state what is missing as plainly as what works.
3. **A catalogue row.** `bun --env-file=.env scripts/marketplace-sync.ts` reads every plugin directory's
`manifest.ts` and README and upserts them. Add a matching entry to `BUNDLED` in
`src/servers/plugins/marketplace.ts` as well — that is the offline fallback, and a machine with no
internet shows it.
`docs/marketplace.md` is the design; `docs/marketplace-todo.md` is what it does not do yet.
**The screen is `/marketplace` now, not `/plugins`.** The API kept its name.
---
## Two more rules, learned the same day
**The platform must not know what your plugin is about.** Not just its code — its VOCABULARY. Ten
comments in `src/` still explained what Headscale was, months after the code left, and each one was
correct and in the wrong repository. When you finish, grep the platform for your feature's domain words,
not only its identifiers, and keep only dated history: "X was here until <date>" is a record; explaining
what X does is a leak.
**Do not add a spawn that trusts `$PATH`.** An install died on `Executable not found in $PATH: "bunx"`,
because PM2 starts the platform with a PATH that has `bun` and not `bunx`. Resolve a path
(`node_modules/.bin/…`, `process.execPath`) as `pushPluginSchema` now does.
---
## Which one next
No decision has been made. What the tree says, for whoever picks it: