the catalogue was the only way a remote plugin could arrive, so "build one and
give it to a friend" had no route. the first real case is a plugin that must
never be listed: fofos/fofos-admin is one website's admin panel, useful to
exactly one person.
MUCH smaller than the todo assumed. fetchPlugin already cloned, validated the
URL, refused to overwrite an existing directory, checked for a manifest and
removed a failed clone. exactly one line tied it to the marketplace — where the
string came from. so this is a source parameter threaded through, and a route.
THE NAME COMES FROM THE REPOSITORY, because appName is the DIRECTORY name and is
never declared in a manifest — the id cannot disagree with where the code sits.
that decision is inherited here rather than remade: the repo name becomes the
directory, the route segment, the table prefix and the sidecar suffix. so it is
checked against ^[a-z0-9]+(-[a-z0-9]+)*$ BEFORE anything is cloned; a half-clone
under an unusable name reads as a broken plugin rather than an absent one.
the URL is validated identically whether it came from the catalogue or from a
person, because the catalogue is not a trust boundary — it is just where a string
came from, and the same string reaches the same argv. what differs is who may
ask: this route is owner-only, and the owner is choosing what code runs rather
than choosing from a list somebody curated. validation is not review, and nothing
here reads what it cloned.
caught by testing: the source reached installPlugin and stopped there — the
fetchPlugin call still had the old two arguments, so a URL install fell back to
the catalogue and failed with "no catalogue entry". the guard tests are what
surfaced it, because the error named the wrong cause.
verified end to end against fofos-admin — cloned, schema applied, mounted, dock
pinned — then purged, since officer-dev is not that website's machine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
Found chasing a real install failure: music's marketplace row was hand-
entered as https://gitea.officer.dev/plugins/music with no .git suffix, and
fetchPlugin's URL check refused it outright with a message ("https URLs
only, no credentials") that named neither actual reason.
The suffix was never a security property — https-only, safe character set,
no embedded credentials are the real checks, and Gitea/GitHub both clone
identically with or without .git. Made the suffix optional rather than
required, so a future hand-entered row missing it doesn't hit the same wall.
Also fixed the row itself in officer_marketplace (added .git, matching every
other plugin's entry) for consistency, and confirmed the gitea repo itself
was never the problem — plugins/music already exists there with both
commits, unaffected by this bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Install, uninstall and purge narrated what the platform was doing and hid what it
was running. Every spawn captured stdout and stderr, held them, and surfaced the
last 600 characters only if the exit code was non-zero.
That is enough to explain a crash and useless for the two things people want while
watching an install: knowing a slow step is still moving, and seeing the warning a
command prints on its way to exiting 0. The second is not hypothetical — `db:push`
reported success on a machine where it created no tables. Its output was captured,
discarded because the exit code was fine, and the failure surfaced minutes later as
42P01 in a sidecar log. Whatever drizzle said about that push, nobody ever read it.
`runLogged` streams both pipes as they arrive, so they interleave the way they do
in a terminal. Following only stdout would have been silent for exactly the
commands worth watching: git writes everything to stderr, drizzle-kit writes
progress there and results to stdout.
Two pieces of noise control, because a log nobody can read is not an improvement:
`git clone` gets `--progress` instead of `--quiet` (git says nothing when it thinks
it has no terminal, and that is the step where a slow link looks like a hang) with
per-percent rewrites collapsed to the last fragment per chunk; and drizzle's
spinner, which is eight lines saying the same thing with a different braille glyph,
collapses to the first — otherwise "Changes applied" scrolls past underneath it.
Steps and output share ONE ordered list rather than two. Their order relative to
each other is the whole information: "which step was this output from" is the only
question the log exists to answer. Output renders dimmer and indented under the
step that produced it.
Verified by running a real purge and reinstall: the clone's object counts, the
barrel generation naming both plugins, drizzle's push, then "schema: applied in
2029ms (1 table verified)".
tsgo clean, frontend builds, 873 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The /plugins route showed only what was already on disk, so a plugin nobody had
copied in by hand did not exist. It now shows a third list — what a remote
catalogue offers — and installing one fetches its source.
- plugins/marketplace.ts — a stand-in for the remote store, a JSON array in
process. One entry: music, pointing at gitea.officer.dev/plugins/music. It is
deliberately a module and not a fetch, so the shape of a catalogue entry gets
settled before anything depends on a server answering.
- plugins/fetch.ts — git clone --depth 1 into plugins/<appName>/, INSIDE the
platform repo because bun resolves the workspace links by walking up and a
plugin cloned beside it cannot import anything the platform provides
(measured, not assumed). https only, no credentials, GIT_TERMINAL_PROMPT=0 so
a repo that turned private fails instead of hanging. A failed clone removes
the directory: a half-written one reads as a broken plugin rather than an
absent one, which is the worse of the two.
- install.ts fetches first when the directory is missing, then RE-DISCOVERS
rather than assuming what it cloned. Everything after is the existing path,
including the frontend rebuild — so a cloned plugin's panels reach the SPA
without a restart.
- GET /api/plugins returns `available`, catalogue minus what is on disk. Each
entry gets the same live PATH probe a local plugin gets, so music's ffmpeg and
ffprobe are answered for this machine BEFORE the clone rather than after.
- The detail panel is a separate component for a catalogue entry, not a mode of
the installed one: there is no sidecar status, no enabled state and no route
to open, and what there IS — the source URL — the installed panel has no
reason to show. The streaming log is shared, because the marketplace install
is the case that needs it most: it clones before it does anything, and a slow
clone behind a blank panel looks like a hang.
The catalogue's icon is an absolute URL to the store's copy, which is the
[open] question the previous commit left: assets only publish at install, so a
plugin you have never installed had no artwork to draw.
Not done: no update verb, no signature, no private repos, and removePluginSource
has no caller — deleting source drops tables on the next push, and that wants a
verb that says so.
tsgo clean. 767 pass / 6 fail, all 6 identical on HEAD without this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>