8 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 19bbbab907 publishing a plugin is pushing a tag
Three mechanisms where there should be one. The catalogue was filled by hand, and each host separately
polled twenty-one repositories with git to work out whether a release existed — a second source of
truth for a fact one place already knew. Shipping photos 2026.09.03.02 correctly and watching alpha
not notice for ten minutes is what started this.

The timer. scripts/marketplace-cron.sh, every minute, on the machine that publishes. Safe now only
because the previous commit made the sync read tags instead of working trees; over the old one this
would have published somebody's afternoon on a schedule. Nothing is written unless the release commit
differs from the one already published, so a tick with nothing to do is one query, one fetch per repo
and no writes — about 2.7s. One timestamped summary line per tick regardless, because a fresh timestamp
is the only thing that separates "nothing to publish" from "the timer died"; a named line only for a
plugin that moved.

The badge. `updateable` now compares the tag at HEAD with the tag the catalogue says is newest —
difference, not ordering, since parsing 2026.08.27.01 works right up until somebody tags v2 and then
fails silently. refreshRemote() and its two-minute window are gone with the polling they existed to
paper over. The git rule stays as the fallback for a plugin the store cannot speak for: an unregistered
checkout, or an unreachable store. That case now reads whatever refs the last pull left, which is the
accepted cost of not making every machine poll every repository forever.

fetchCatalogue()'s cache went from five minutes to one. It only drew tiles before; now the wait for a
release to appear would have been the sync's minute plus its five.

Also: the Update button names its destination whenever a release is known, not only on `new-release`.
An untagged checkout reports `untagged` and used to lose the tag with it, so the button said "Update"
and the owner had to guess where it landed — the marketplace knows, even for a checkout that has never
fetched it.

Verified: forced a stale row, the timer republished it inside 60s and logged one line. Badge decisions
checked against the live catalogue for seven plugins, including the two shapes that matter — at a tag
the store agrees with, and untagged with a release known only to the store. 9 release tests pass, 100%
of release.ts covered, four of them new and all against real repositories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 13:13:43 +01:00
pastilhasandClaude Opus 5 78e89ebcec the marketplace publishes releases, not working trees
The sync read `platform/plugins/` — the directories this machine has installed — and imported each
manifest off the working tree. So publishing meant "get officer-dev's checkout to the release, then
run the script", and nothing showed when that had not happened. Live proof at the time of writing:
av-club 2026.08.29.01, kanban 2026.08.29.02, openseo 0.1.0 and fofos-admin 2026.08.29.26 were all in
the store, and none of the four has ever been tagged.

It is also why the job could not go on a timer. A cron over it published whatever was in a working
tree, on a schedule.

Now every registered plugin is mirrored bare into ~/.cache/officer-marketplace/mirrors and read AT ITS
NEWEST TAG — manifest, README and file list all out of `git cat-file`. No working tree in the path, so
publishing something uncommitted stops being a mistake anyone can make.

docs/marketplace.md proposed reading manifests over Gitea's API, which needed a sandbox or a committed
manifest.json. Git gives the same guarantee with neither, and the trust model is unchanged: we import
our own repositories' code. Third-party submissions still need one of those two.

The list of plugins now comes from the `plugins` registry table, which the docs always called the
authoritative half and which was in fact a by-product of what happened to be installed. That is what
lets `finances` be published without being installed here, and it spans orgs — fofos-admin lives under
fofos/, which an org listing would have missed. Adding one is `--add <clone url>`.

A plugin with no tag is left alone and named in the report, not deleted. It has made no release, so
there is nothing true to say about it; dropping its row would be a decision about somebody's plugin
dressed up as a refresh.

Also: `release_tag` on plugin_metadata, because the tag is the fact when a manifest version disagrees
with it; the sync warns on a mismatch. And icon URLs pin to the tag — `/raw/HEAD/` served whatever was
on the default branch, via a 303, so a plugin could show one icon and ship another.

Verified: 21/25 published from a release, 4 named as untagged, all 14 tag-pinned icons answer 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 13:05:50 +01:00
pastilhasandClaude Opus 5 f4685eeafe one plugins directory, because the second one was symlinks
`officer-dev/plugins/` is gone. it was created to hold every repo in the org side
by side, and by today it held 22 symlinks into platform/plugins, one duplicate
clone of a plugin that is installed here, and a README describing a state that
had already been fixed.

it could not have been more than that. a plugin cannot live outside the platform:
bun resolves a symlink to its real path, so from there `@@/sidecar/create-proxy`
and every relative import fail. tested both, plus `--preserve-symlinks`, which
bun 1.3.14 accepts and ignores.

WHICH MATTERS BEYOND TIDYING, because marketplace-sync leaned on the distinction.
its comment said the second directory was "where a release is actually made", so
publishing from it would advertise released versions rather than installed ones.
that was never true — symlinks meant both paths read the same working copy. the
bug it describes (21 plugins tagged, the store still showing 1.0.0) was never
addressed by the thing built to address it.

the honest fix is in the comment now: each plugin directory IS a git repo with
its own tags, so the released manifest can be read out of the tag —
`git show <tag>:manifest.ts` — without a second checkout. not written yet, and
said plainly rather than left implied.

finances is deleted, at the owner's request: not installed, no pm2 entry, no
ecosystem entry, no plugin_installs row, no tables. both clones were clean and
pushed. the gitea repo is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 22:23:59 +01:00
pastilhasandClaude Opus 5 b4cf1c6427 publish the catalogue from the release clones, and make the cards look like cards
three small things.

marketplace-sync reads PLUGINS_DIR now instead of assuming the directory it sits
next to. platform/plugins/ is what a machine has INSTALLED — updated by
plugins:update, which is a git pull — while the release is made in the workspace
clones. publishing from the install directory means the catalogue advertises the
version this machine happens to be running: 21 plugins were tagged and the store
went on showing 1.0.0 until this changed.

the grid cards used bg-white/40. this app's surfaces are a DARKENING of the
panel behind them — every other card and section here is bg-duck-dark/5 — so a
translucent white read as a pale box floating on a dark theme. picking a colour
instead of a token, and it only looked right against a background this app does
not have. hover now moves the BORDER and nothing else: a background swap across
twenty-one cards makes the whole grid twitch as the pointer crosses it.

the tag pills were the same value as the card surface, so they rendered as text
with no chip at all.

and the marketplace todo said the UI was not started. it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 08:43:46 +00:00
pastilhasandClaude Opus 5 d381140d54 plugins say what they are for, and their checkouts stop lying about themselves
two things, and the second is why the first was painful.

TAGS. the marketplace is going to be browsed rather than scrolled: tags on the
left, the plugins carrying one on the right. a plugin declares its own in the
manifest and the catalogue caches it on sync, exactly as it already does for
label and summary — the alternative is editing a table every time a plugin
ships, and a plugin that cannot say what it is.

the vocabulary is CLOSED (PLUGIN_TAGS) because free text becomes homelab,
home-lab, Home Lab and selfhosted inside a month, and a filter list of synonyms
is worse than no filter. an unknown tag is refused at discovery BY NAME, so the
author is told which tag rather than which array index.

all 21 are tagged, each with a one-line note saying why, so a later disagreement
is with a stated reason rather than a mystery. every tag has at least three
plugins behind it — no filter opens onto an empty page.

PLUGINS:CHECK. getting one commit into 21 repositories failed seven times and
none of the reasons were about the commit: six remotes were HTTPS and cannot
authenticate unattended, three checkouts were behind their remote, one URL had
no .git suffix so the catalogue published a clone URL unlike every other, and
offscale's fetch refspec named a branch that does not exist.

that last one is why this is a script and not a habit. every command reported on
a phantom — `git push` said "Everything up-to-date" while the branch looked
diverged — and the repository was fine the whole time. twenty minutes spent in a
repo with nothing wrong with it.

none of it is visible until you try to push, which is the worst moment to learn
it: mid-task, across 21 repositories, and every failure looks like it is about
your change.

so it checks the facts with one right answer — remote URL, fetch refspec,
branch, upstream, dirty tree, ahead/behind — and exits non-zero. `--fix` is a
separate flag so running the check can never be what changes your tree, and
divergence is never auto-repaired because it needs a decision about content.

verified by breaking each case on purpose and watching it catch them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 06:55:03 +00:00
pastilhasandClaude Opus 5 0a1f1ce6df publish desktop, and stop counting node_modules as a plugin
The catalogue row and the bundled offline fallback, so the plugin installs from the marketplace like
every other. Listed honestly: its permission is owner-only at every path, and its websocket is not
mounted yet — the README and BLOCKERS.md say so rather than letting an install discover it.

marketplace-sync globs plugins/ itself rather than going through discoverPlugins, so the node_modules
skip added there did not cover it. Same fix, second place: it was reporting 15/16 and 16/17.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:45:05 +00:00
pastilhasandClaude Opus 5 5b486ed019 build marketplace icon URLs from HEAD, not from a branch name
Every available plugin drew a broken image on any server reading the remote
catalogue. The URLs said /raw/branch/main/, and four plugin repositories were
created on `master` — so those 404'd, and the others only worked by accident of
which branch somebody happened to use.

The comment above the builder already said "a repo whose default branch is
`master` gets a 404", marked [open]. It then happened. A warning in a comment
is not a guard.

Gitea resolves /raw/HEAD/ to whatever the default branch is, so the question
stops being asked. Verified against both spellings, and all 12 icons in the
live catalogue now answer 200.

It did not show on alpha because every plugin there is INSTALLED, and an
installed plugin's tile is served locally from /plugins/<name>/ rather than
from the catalogue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:55:10 +00:00
pastilhasandClaude Opus 5 921fbff9fe marketplace.officer.dev: the catalogue becomes a service
The plugins page read a TypeScript constant. It now reads a public API backed by
its own database, and falls back to that constant when it cannot.

── Four pieces ──

`src/databases/marketplace_db` — a second Postgres database, `officer_marketplace`.
Two tables, split by owner and lifetime: `plugins` is the registry (app name,
developer, clone URL) and is authoritative; `plugin_metadata` is a CACHE of what
each repository says about itself, so the website can render without cloning three
repos per page load. Keeping them apart is what makes the cache safe to be wrong —
a refresh can never lose the fact that a plugin exists.

`scripts/marketplace-sync.ts` — fills it from the plugin repositories. Reads each
`manifest.ts` (imported, since a manifest is TypeScript), derives `has` from the
directory rather than trusting a declaration, and rewrites SSH remotes to HTTPS:
`example` and `offscale` had `git@gitea…` origins, which would have handed every
installer a URL that only works on the machine that published it.

`src/servers/marketplace/server.tsx` — its own process, its own port, two GET
routes and no others. Not part of the platform server: it has no users, no
sessions and no request it answers differently per caller, and mounting a public
unauthenticated surface inside the process holding mail, a wallet and passkeys
means writing permission-gate exemptions that later turn out to be too wide.

`src/apps/marketplace` — the public site. A window, deliberately: nothing here
installs anything, because installing means cloning a repo and starting a process
on someone else's machine. Every plugin page ends at "install it from your own
Officer".

── The platform side is the part that had to be careful ──

`fetchCatalogue()` now answers live → cached → bundled, in that order, and never
throws. The plugins page is where an owner manages plugins they ALREADY have, so
the store being down must not take that page with it. Network responses are
validated per entry — not against a hostile marketplace, which has already won if
the owner installs what it lists, but against a wrong one: a proxy serving HTML,
a half-deployed API, a field renamed on one side. A non-https `source` is refused
outright because that string reaches `git clone`.

8 tests on the degradation path. Verified end to end against the running service:
live catalogue, cached answer surviving the store dying, bundled fallback.

── One thing found on the way ──

`bunfig.toml` and CLAUDE.md both document `bun install --no-frozen-lockfile` as
the way to change a dependency. Bun 1.3.14 has no such flag — only
`--frozen-lockfile` — so the documented escape hatch does not work. Sidestepped by
not making marketplace_db a workspace member (relative imports, zero lockfile
change), and `bun install` is frozen-clean again. Worth fixing the docs.

tsgo clean, 873 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 04:07:49 +00:00