58f50a5240c7686b9c0b9a5568a1bcfbab71fc59
25
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3d936309a5 |
the list refreshes before the message, and the tab follows the result
reported as "the list does not update without a refresh". the counts were in fact correct, which is the more interesting half: fofos-admin was ALREADY INSTALLED on that host, so the fetch put it in Installed while the confirmation said "click it to install" and pointed at an empty Available tab. right numbers, wrong story. three fixes, and only one of them is the refresh. AWAIT THE REFETCH. `invalidateQueries` is fire-and-forget, so the confirmation appeared while the grid was still rendering the previous answer and the plugin turned up a moment later. that reads as "nothing happened" and sends somebody looking for a refresh button — which is exactly what happened. SAY WHICH THING HAPPENED. a directory and an install row are separate facts: deleting a plugin's source leaves the row, so a re-fetch can land on something the platform still considers installed. the route now reports it, and the message is either "added — click it to install" or "added — already installed". FOLLOW IT TO ITS TAB. if it came back installed, the overview switches to Installed rather than leaving somebody staring at the tab it is not in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
cac58ea044 |
the git-repo button FETCHES, and the card does the installing
it installed, and using it showed why that was wrong: the button is on the grid, the progress log is in the detail panel — so a URL install ran to completion with nothing on screen and the plugin simply appeared, already installed. no feedback, and no moment to change your mind about running somebody's code. cloning alone is enough to make a plugin real. `/api/plugins` lists what is ON DISK, so a fetched plugin shows up with `installed: false` — Available, exactly like a catalogue entry — and the existing card, its Install button and its streamed progress do the rest. one install path, and it is the one people have already used. so `install-from-url` and its streaming twin are gone, replaced by `fetch-from-url`, which clones and stops. the streaming route existed only to carry progress into a view that could not render it. the outcome is REPORTED, since that was the complaint: "fofos-admin added — click it to install", naming what the URL resolved to. the name is derived rather than typed — appName IS the directory, deciding the route, the table prefix and the process name — so being told what it became is the only way to know which card is yours. it stays on screen until the next attempt rather than being a toast that is gone before you look up. discovery is re-run after the clone: fetchPlugin checks the manifest EXISTS, discovery checks it parses. a directory failing that still renders in the grid as broken, which is honest, but saying so at fetch time reaches the person while they are still looking at the field they pasted into. verified: fetch 200 with the derived name, the plugin listed installed=false, a second fetch refused with "already on disk" rather than clobbering it, and the icon present in the clone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
5c6c5b31f8 |
"Install from Git repository", in the marketplace
a button beside the plugin count that becomes a URL field. everything after it is the machinery that was already there: same installPlugin, same step and log frames, same progress panel, same result. STREAMED, because that is what "works exactly like it already does" means here. an install writes an ecosystem entry, starts a process and rebuilds the router; answering once at the end is a spinner that stops, with no way to tell "started the sidecar" from "could not". the frame reader in usePlugins was split out rather than copied — it never cared which URL it read from, and two copies of an incomplete-frame parser is two places to get it subtly wrong. A TWIN ROUTE, not a case inside the verb map. every other verb names an INSTALLED plugin in its path; this one names something the machine has never seen, and its subject arrives in the body. folding it in would mean a route whose `:appName` is ignored for exactly one value of `:verb`. NO NAME FIELD, deliberately. appName IS the directory a plugin sits in — never declared in a manifest — so it decides the route segment, the table prefix and the pm2 process name. a box for it would invite a name that disagrees with the address. it is derived server-side and reported as the FIRST streamed step, which is the moment it can still be cancelled. collapsed until asked for: a permanent URL box at the top of a catalogue reads as the primary way in, and it is the exception — for somebody who already has an address. verified end to end against fofos/fofos-admin: 30 frames, every step from clone to mount, `assets: published to /plugins/fofos-admin/` now that it carries an icon, and the icon served at 256x256 with alpha intact. purged afterwards — officer-dev is not that website's machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
d273dc6a14 |
install a plugin from a git URL, with no registry involved
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 |
||
|
|
68d6dd942e |
update means something now: it depends on tags
"update" was `git pull` and re-install, unconditionally, for every plugin.
nothing could say whether there was anything to pull, so the button was always
there and usually did nothing — and "update all" was twenty-one no-ops with a
spinner on it. the button's own comment admitted it: gating on `outdated` would
hide it for ever, because nothing bumped a version.
versions are dated and tagged now, so the rule the owner asked for is possible:
· a checkout NOT at a tag is always updateable — it is between releases, no
claim is being made about it, pulling is always reasonable
· a checkout AT a tag is updateable only when a NEWER tag exists
the part worth defending is how "newer" is decided. the obvious version-string
comparison is wrong the first time a plugin is tagged `v2` or `1.0.0-rc1`, and
wrong SILENTLY — a plugin that never offers an update looks like one nobody has
released. so newness is asked of git: the newest tag reachable from the REMOTE
branch, compared against the tag at HEAD. that holds for any names anyone
chooses, and there is a test with a tag called `beta` that sorts before `v1` to
keep it honest.
it never fetches. every answer comes from refs already on disk, because this
runs once per plugin on a request that renders a list and twenty-one network
round trips would make the screen feel broken. tags arrive at clone and at the
fetch `pullPlugin` already does.
the VERB refuses too, not just the UI. a plugins list is fetched once and a tag
can be pushed a second later, so the copy someone clicks is routinely older than
the truth — and `updateAll` iterates a list rather than asking per plugin.
"up to date · <tag>" is said out loud rather than shown as an absent button. no
button and up-to-date look identical, and only one of them is information.
verified on the real installs: example pulled to its tag and flipped to
up-to-date, caldav on an older commit stayed updateable, and updatePlugin on
example answered "already at 2026.08.27.01, the newest release" without pulling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
d58f58813c |
the marketplace is browsed by tag, not scrolled
/marketplace was a list of every plugin on the left and a preview on the right. that is a fine control panel and a poor shop: twenty-one rows sorted by nothing anybody cares about, where "something for my calendar" means reading all of them. now: tags down the left with counts, cards in a grid on the right, and clicking a card takes over the panel with a back link. three things about the shape. BOTH the filter and the selection are in the URL — ?tag= and ?selected=. a filter in component state is lost on Back, so filtering, opening a plugin and pressing Back would drop you in an unfiltered grid having lost your place. browser Back works for free for the same reason. selecting a tag also CLEARS the selection, because a detail for a plugin outside the new filter is a page the grid you are now looking at cannot return to. the detail REPLACES the grid rather than sitting beside it. a grid needs the width and a detail beside one gets half of it; neither reads well. the back link drops ?selected= and keeps ?tag=, which is the entire point of putting the filter there. the panel KEYS are unchanged though both components were replaced. a stored layout names them, and renaming would drop everyone back to the default arrangement for nothing — the key identifies a slot, not an implementation. two things found on the way. PluginItem and AvailablePlugin had no tags, so the api now carries them on both branches — from the manifest for a plugin that is here, from the catalogue for one that is not, same field either way. and the screen used useDashboardState, which persists the whole tree: the panels this ships would never have reached anyone who had opened it before. useAuthoredLayout takes the structure from source and persists only sizes, which is what an authored screen wants. 21/22 -> 22/78 as well: the left panel holds seven tags now, not twenty-one rows, and the width is worth more to the grid. verified: bundle contains the new panels, api returns tags for all 21 with none missing the field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
c427cb5b61 |
plugins: a backend plugin can have settings
A plugin with no web/ has no route, no dock tile and no panel — so it had nowhere to put a setting. transcribe shipped exactly like that: installed, running, and configurable only by an HTTP call somebody had to already know about. The plugin DECLARES its fields and the platform renders them, on the plugin detail page — the one screen that exists for every plugin whether or not it has a frontend. A component would have survived settings too complicated to describe, at the cost of every backend plugin carrying a frontend to draw one text field, each looking slightly different. The platform stores nothing. It is a form over the plugin's own /config: GET to populate, PUT to save. So a plugin that validates before saving still does, and one that keeps a credential keeps it where it already kept it. A `secret` field is write-only across the boundary — never read back, rendered as a password, and left out of the PUT when untouched so an untyped box is not read as "clear it". The plugin reports whether it HAS one, never the value. Eight strings across all six locales, because the key type refuses anything else — which is the localisation work from earlier tonight doing its job. 1426 pass, tsgo clean, docs:check green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
16dd67a3ca |
plugins: enabling pulls first, so a plugin off for a week comes back current
A disabled plugin keeps its directory, and the directory is what the next start runs. The longer it stays off the more certainly it is stale, and enabling would quietly run whatever was current on the day it was switched off — with every step reporting success, which is exactly how the jellyfin install on alpha re-applied a two-day-old checkout. Deleting no longer has that problem, since it takes the directory with it and installing is always a fresh clone. That left disable/enable as the one remaining way to run old code without being told. The pull is best-effort and the enable is not. `updatePlugin` returns ok:false when a pull is refused, and it is refused for reasons that say nothing about whether the plugin can run: a dirty working tree — every plugin directory on this machine is one — an unreachable forge, or a directory copied in rather than cloned. Enable is the reversible half of a pair with disable, and a switch that will not turn back on because a network is down is worse than one running last week's code. So the refresh reports which of those happened and the enable proceeds regardless; a re-apply that fails falls back to the code already on disk, which was running before it was disabled. Tested by the refusal path rather than the happy one: a directory that is not a checkout comes back having reported "not refreshed", and the error that surfaces is about not being installed rather than about git — which is only possible if the refusal was stepped past instead of returned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
287cec39d0 |
plugins: enable, disable, delete — and uninstall is gone
Three states all read as "stopped": disabled, uninstalled, and uninstalled-with-the-directory-still- there. Nothing on screen distinguished them, and the one that mattered was invisible — an uninstalled plugin kept its directory, which silently decided what the next install would run. That is how a jellyfin install on alpha re-applied a checkout from two days earlier while reporting success at every step. So uninstall stops being a verb. What it did survives as the first half of deleting: it stops the sidecar, removes the install row and the PM2 entry, unpublishes the copies, and revokes grants naming permissions that are about to stop existing. Delete is offered only on a plugin that is not running — disable is one reversible click, so a live plugin is always two deliberate steps from gone. It opens a confirmation carrying a checkbox, "also delete all its data", unticked and never inherited from last time. Unticked, the directory goes and the rows stay, and they appear in the orphan list further down the same screen. Ticked, the tables go with it, which is the only way to a machine that has never had the plugin. The checkbox picks between two ROUTES, not a body flag on one: a flag can be added to a request by anything that already knows how to call the safe verb, and the difference here is somebody's rows. Grants are revoked either way, which is load-bearing until the permissions work tomorrow: a grant naming a key the platform no longer recognises made the whole permissions screen unsaveable, because the page submits every grant it loaded. Also corrected two rationales that the change made false — the runbook's "uninstall never destroys data" verification table, and npm-deps.ts explaining directory survival in terms of uninstall. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4b2c896523 |
ship the capabilities repository, and update it from the same button as plugins
capabilities/ is the file-based item store, and most of it belongs to the owner: the six item directories are created by the platform at boot and extensions/tool-loader is written from platform source, so neither is tracked anywhere. What IS tracked is the small set the platform ships — today one affordance, telling an agent that markdown images render and that the USER'S DEVICE fetches the URL, which it cannot discover from the filesystem. That now lives in officerdev/capabilities, public so a fresh install can clone it without credentials. Two ways in. The setup script syncs it during the layout step — non-fatal, and it says what an install without it costs rather than failing: agents go back to guessing, which is what they did before this existed. And "Update all" on the marketplace screen carries it alongside the plugins, because it is the same question — get me what has been published since I installed — and a second different gesture for it is one nobody remembers. Three states in both places, and the third is the normal one on an existing machine: a checkout is pulled, an empty directory is cloned into, and a directory that already holds the platform's generated items gets a repository initialised around them. UNTRACKED files never block a pull, because the owner's own skills and tools living here is the ordinary state — only tracked changes do. `cloned` and `adopted` are reported in the UI where `pulled` is not: they mean this machine had no affordances at all until the button was pressed. Verified against the real checkout: sync reports pulled, and the loader finds officer/core/image-rendering afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3ef078210a |
update a plugin, or all of them, from the marketplace
There was no update path. fetchPlugin REFUSES an existing directory — cloning over one is how uncommitted work disappears — so install was a one-way door, and the answer to "my plugin changed" was uninstall and reinstall, which drops its tables on the way past. An update is a git pull and then the ORDINARY install path. installPlugin already skips the clone when the directory is present and re-runs everything else: os dependencies, schema push, assets, the ecosystem entry, the sidecar, the remount and the frontend rebuild. So there is no second implementation of "apply a plugin" here to drift from the first. Two refusals, both about not destroying work. A dirty working tree is refused rather than stashed or force-pulled — every plugin directory on a developer's machine is a live checkout, and this repository's own are edited in place. Non-fast-forward is refused for the same reason: a diverged local commit is somebody's work and merging it automatically is not the platform's decision. The per-plugin button replaces one that was quietly broken: it appeared only when `outdated` and called `install`, which cannot fetch, so it re-applied stale code. Nothing bumps a version today either, so with everything pinned at 1.0.0 that flag never fires and the button never appeared. Now always offered, and it pulls. Update-all is a plain POST rather than the streaming door: twelve interleaved step streams is not a readable log, and the useful answer is the summary. It runs sequentially — each update pushes schema, republishes assets, rewrites the ecosystem file and rebuilds the frontend, and two of those at once is a race over one file each. One failure does not stop the rest, and failures are named with their reason, because "2 failed" sends someone to the terminal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0230e62a71 |
the platform stops explaining what Headscale is
Correcting my own reasoning from the last commit. I left "Headscale's API", "the Headscale admin credential" and similar alone on the grounds that they name the thing the plugin drives, so renaming them would make the sentences false. True as far as it goes, and it misses the point: those sentences are in the PLATFORM. It has no business knowing what Headscale is at all. Correct language in the wrong repository is still a remnant. Ten of them, each replaced with the platform's own vocabulary rather than the plugin's domain: - `crypto.ts` and `secret-store.ts` listed "the Headscale admin credential" among what one key used to protect. It is "the tailnet's admin credential" — the purpose, which is a word this repository owns. - `catalogue.ts` and `availability.ts` explained `alsoServes` — a LIVE mechanism — entirely through a Headscale example that left the repository. Restated as the general shape, with one parenthesis saying which case produced it. - The photos types file explained another plugin's sidecar. - `App.tsx` cited `/headscale/:section` as the example of a route pair; that route does not exist. `/photos/:section` does. - `plugins/router.ts` pointed at `useCompanionLogStream`, a hook that lives in a plugin now. - `registry.ts` said in the present tense that `headscale` is `admin` — the permission is the plugin's and it declares its own, so a member-facing enrolment surface would be the PLUGIN's to add, not this file's. - `proxy.sh` cited `headscale/schema.ts` as a style reference. That file is not in this repository. - machine-setup said "Its Headscale app" — the app is the Offscale plugin. `machine-setup/lib/tailscale.sh` keeps all ten of its mentions: it exists to explain tailscale, headscale and offscale to somebody choosing between them, on a core install, before any plugin exists. That is machine setup's subject, not the plugin's domain leaking upward. What survives elsewhere is only dated history — "was here until 2026-08-15", "It was: music, vault, slskd, headscale, …" — plus the notes about the real `headscale_servers` orphan and the secret-store key removed yesterday. Those record that something called headscale used to be here, which is a different claim from explaining what it does. tsgo clean, frontend builds, 873 pass / 7 fail unchanged, setup scripts parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e0d3f59b9b |
name the tables nothing on this machine owns
The marketplace screen now lists tables in the database that no code here claims, with a row estimate and, where the prefix says so, which absent plugin they look like they belong to. This is the information that cost an evening. `bun db:push` saw tables the schema did not declare, asked an interactive question no non-TTY caller could answer, applied nothing and exited 0 — and the install reported success while the plugin was unusable. The cause was two tables from before an extraction, found by reading `\dt` by hand. Nothing in the platform would ever have mentioned them. ── What counts as an owner ── The core schema, or a plugin DIRECTORY — not a plugin INSTALL. A plugin that is present but uninstalled still owns its tables, because that is the promise uninstall makes: keep every row so a reinstall puts you back where you were. So this reports only what no code on this machine describes at all, which on a healthy install is nothing, and the block does not render. A plugin whose schema will not import has its prefix treated as claimed rather than having every one of its tables reported as unowned — a broken manifest should not read as data with no home. ── No delete button, deliberately ── Dropping a table is not something to offer beside a list of plugins, and the right action differs per row: `music_favorites` wants the music plugin installed, `headscale_servers` wants dropping. Naming them is the part that was missing. Verified by planting `headscale_servers` and `music_playlists_old` and reading them back through the same call the route makes: the first attributed to nothing, the second to music, which is not on this machine. Row counts come from `pg_class.reltuples` — an exact count on a large table is not worth blocking a page render for a number that only answers "is there anything in here". tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c98b5643aa |
show what the commands are actually saying
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> |
||
|
|
3c03e61c5e |
delete a plugin and everything it owns, and stop trusting exit codes
Two halves of the same problem: a plugin installed onto a fresh machine, reported success at every step, mounted, started its sidecar, registered — and then failed its first query with 42P01, because `offscale_servers` had never been created. Nothing said so, and there was no way back to a clean machine to try again. ── The install now asks Postgres ── `pushSchema` only checked the exit code, and `db:push` is a compound command: generate the barrel, cd, run drizzle-kit. It can exit 0 having created nothing. So the install now reads the plugin's schema for the tables it declares and verifies they exist, failing the install with a message naming what is missing instead of leaving a stack trace to appear in a sidecar log minutes later. Tables that are NOT prefixed with the app name are reported as a warning rather than a failure — they still get created, but `purge` will not remove them and a reinstall would find them already there. ── `purge`, next to an uninstall that deliberately keeps everything ── Uninstall is careful on purpose: every table and row survives so a reinstall puts you back where you were. That is what makes it safe to try, and it left no way to get to a CLEAN machine. A second install finds the directory (so the catalogue hides the plugin) and the tables (so push has nothing to do, and a broken schema stays broken). So a different verb, a different word, its own confirmation. It reads the table list from the schema BEFORE deleting the source, uninstalls (stopping the sidecar — nothing may be running against tables about to vanish), drops, deletes the directory, then regenerates the barrel unconditionally, since a barrel naming a directory that no longer exists is what stopped a server booting yesterday. The table list comes from the plugin's own schema, not from `like '<app>_%'` — that query eventually drops somebody else's table, because the prefix is a convention nothing validates and `music` is a prefix of `music-videos`. The prefix is used as a GUARD instead: a schema module could export `users`, and anything outside the plugin's own namespace is refused rather than dropped. ── Verified as a full loop on this machine ── purge offscale → table dropped, directory deleted, barrel regenerated → the marketplace offers it again → install → cloned, ssh checked, "schema: applied in 2072ms (1 table verified)", dock pinned, mounted, sidecar started. I left offscale INSTALLED here rather than restoring the half-state it was in (no install row, table present, sidecar running). tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ba20155a09 |
the store's README shows in the platform, not just on the website
A plugin's page in the Marketplace screen now renders the README the marketplace holds — the same text the public site shows, in the pane where somebody actually decides whether to install it. Fetched by the SERVER and proxied, though CORS would have allowed the browser to ask directly. An Officer client is routinely somewhere its server is not: a phone on the tailnet, a laptop with the store blocked. The server already had to reach the marketplace for the catalogue, so routing through it means the README appears in exactly the cases the plugin list did — rather than in a strictly smaller set, which is the kind of difference nobody reports and everybody notices. Separate request from the list, because the list endpoint deliberately carries no READMEs: three of them is ~38KB of markdown on the request that draws the tiles. This one fires when a plugin is opened. Absence renders nothing at all — no error, no empty state. A plugin without a README is normal and a store that is down must not put a failure message on a page whose job is installing things. Under `/plugins` so the existing permission covers it: reading the store and installing from it are the same privilege, and a second key would be a second thing to grant for no separate decision. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dd26e4a688 |
example leaves too; plugins/ is documentation now
Done with `tea` as admin: plugins/offscale made public, plugins/example created public, both verified anonymously over https — which is the check that matters, because fetch.ts clones with no credentials and a private plugin simply cannot be installed from the marketplace. plugins/example 8 files, cloned back and diffed identical plugins/offscale now public (was private, so was uninstallable) `.gitignore` is one line now — `/plugins/*/`. The trailing slash is the whole design: it ignores DIRECTORIES, so files at the top of plugins/ stay tracked. The documentation about plugins belongs to the platform; the plugins do not. All three are in the marketplace catalogue, which is what makes this reversible: before this, untracking offscale meant a fresh install could never get it back. Now the store is the way in for all three. CatalogueEntry gains `icon` (a lucide name) alongside `iconUrl`. Both new entries ship glyphs rather than artwork, and without it they drew the generic puzzle piece in the store and their real icon after install — which reads as the icon changing rather than the store not knowing it. EXTRACTING-A-PLUGIN.md pointed at four directories a fresh checkout no longer has. It now gives repository URLs, says up front that this directory holds documentation rather than plugins, and carries a table of where each one went. Checked first that no tracked source statically imports a plugin directory — none does, so a fresh clone still builds. tsgo clean, frontend builds, 787 pass / 7 fail (unchanged). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
29170dd922 |
installing a plugin pins its dock tile, with no reload
Installing Music left the dock unchanged. The tile existed server-side the moment the plugin was enabled, and the only way to see it was Profile → Dock, by hand, after a page refresh. Installing something is already the decision to use it; saying so twice is the bug. Three separate causes, which is why it looked like one stubborn one: 1. `dock_configs.paths` is a COMPLETE ordered list, not a diff — useDock renders the saved list rather than everything available, so anything not named in it is invisible. Nothing in the plugin system had ever written to that table. installPlugin now appends the new route, for the caller (threaded through the route) rather than a guessed owner id. Only for a user who HAS a row. A user without one is left alone deliberately: there is nothing to append to, and writing one would freeze today's defaults into their account permanently. That case is (2). 2. The fallback for a user who never customised was DEFAULT_DOCK_PATHS, which is core-only — so a plugin tile was hidden there too. `defaultDockPaths(plugins)` is now that list plus a tile per installed plugin, and the dock and its settings screen both call it so they cannot disagree about what "default" means. 3. useDock holds ['DOCK'] at staleTime: Infinity and the plugin verbs invalidated only ['plugins'] and ['self-permissions']. So even once the server wrote the path, nothing asked again — that is the part that made a reload necessary. Also fixes a guard I broke reaching for this: typing VERBS as Record<string, VerbFn> widens keyof to `string`, which makes `isVerb` prove nothing and lets an unknown verb index the map. `satisfies` instead, and there is now a check that Verb is still the four-member union. Uninstall deliberately does not remove the path. A stale path with no item behind it is dropped by useDock, and keeping it means disable/enable and reinstall put the tile back where the user had it rather than at the end. tsgo clean, frontend builds. 787 pass / 7 fail — 6 identical to before; the 7th is plugins/music/cliamp, which install pulled into test discovery and which the manifest documents as parked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dadfdc26af |
plugins page becomes a marketplace: catalogue, clone-on-install
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> |
||
|
|
2e8ec845c8 |
step 1/4: the permission engine is called permissions, not capabilities
The word meant four different things in this repo, not the three the offscale
doc records:
1. the permission registry → RENAMED here
2. $OFFICER_ROOT/capabilities/ items → kept; this is what capabilities are
3. sidecar routing keys → step 3, becoming `handles`
4. Lightning wallet features → kept; a domain term, and on the wire
to the mobile apps
The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.
Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.
Three breaks the typechecker could not see, all found by exercising it live.
The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.
The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.
And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.
The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.
The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.
Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.
tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
|
||
|
|
f78abbe05a |
a plugin ships its own dock icon as a file, not a lucide name
The manifest's `icon` was a lucide NAME, resolved by `resolveIcon` — which knows 106 glyphs out of lucide's ~1,500. A plugin naming one outside that set silently rendered a neutral box, and a plugin from a marketplace had no way to see the ceiling coming. So the icon is a FILE now: `plugins/<name>/assets/icon.png`, discovered by presence like everything else here. `manifest.icon` stays as an optional fallback for a plugin with no artwork — example and offscale still use it — and the file wins when both exist. No new mechanism was needed. The app store already published sidecar assets: `<dir>/assets/` → `public/plugins/<id>/`, served by a dynamic `/plugins/*` route, with `DockItem.image` rendering an <img>. `pluginDockManifests()` simply never emitted `image`. Install now publishes and uninstall unpublishes — the one thing uninstall is allowed to delete, because these are copies whose originals are still in the plugin's source. Base64 in the manifest was considered and dropped. It would ride in every /api/user/capabilities response for every user on every page load, can't be cached separately, and puts a 5KB string literal in a source file — against the rule this manifest keeps: what a directory listing can say, it says. It also needs no support: `image` goes straight into <img src>, so a data: URL already works for anyone who wants one. Music ships OffMusic.png, resized 1254² → 256² (1.6MB → 108KB) with alpha intact, sized for 3× DPI at the dock's 32px render. It also drops `icon` — the artwork is a voxel duck in headphones, not a glyph. The plugins page showed a generic Puzzle for every plugin; the list and detail header now show the plugin's own icon when it has one. Two bugs found while testing. Dock.tsx imported 26 lucide icons and used 14. The twelve dead ones — Music, Bitcoin, Receipt, Images, CalendarDays, Contact, Clapperboard, Mail, Network, ArrowDownUp, FileText, FolderKanban, MonitorSmartphone — were residue from when every feature had a hardcoded tile. Deleted. And uninstalling a plugin left its icon URL answering 500, not 404. server.tsx globs ./public at BOOT into one exact route per file, each holding a Bun.file handle, spread into the route table AHEAD of the /plugins/* wildcard. So an icon present at boot got an exact route that outlived the file, returning ENOENT on every dock render with the error logged each time — exactly what the wildcard's own comment says it exists to prevent. The comment covered the ADD case; this is its mirror. `plugins/` is now excluded from the boot glob, so the wildcard owns that prefix alone. Verified: 200 installed, 404 uninstalled, 200 reinstalled. [open] A plugin's icon cannot be seen BEFORE installing it, which is the one place an app store most wants to — assets are published at install by design, and an authenticated icon route is no use to an <img>. |
||
|
|
e930586878 |
plugins declare the host binaries they need, and the installer checks
Offscale was self-sufficient. Music is not — it shells out to ffmpeg and ffprobe — and the way it fails without them is the reason this is a check rather than a line in a README. It does not fail. Missing ffprobe means the indexer catches the spawn error and returns a track carrying its filename and nothing else: no title, artist, album, duration or embedded lyrics. It then walks the whole library, writes a complete cache tree and reports success. Five swallowed catches, no log, no counter, and the only tell is coversSaved: 0 in a report nobody reads. So `osDependencies` is a manifest field: the binary to probe on PATH, why it is needed, and a package name per package manager. The shape is taken from scripts/setup-old/setup.sh rather than invented — probe the binary, case on $PM — and the names are per-manager rather than canonical-with-overrides because lib/packages.sh already recorded why that indirection was rejected. Probing the binary is what makes "built-in on this OS" free: on PATH means the package map is never consulted. Four decisions worth naming. Missing and uninstallable REFUSES the install, first, before a table is created or a row written — so there is nothing to undo, and the alternative is a plugin that installs, answers 200 and quietly produces nothing. The status is on GET /api/plugins and rendered before the button, because the owner is deciding whether to let the server run a package manager as root and that needs answering first. Installing by hand and watching it flip to present is the escape hatch on a machine without passwordless sudo. Package names get a deliberately narrow regex and reach Bun.spawn as an argv ARRAY, never a shell. Both halves are load-bearing: the regex means a metacharacter cannot get there, argv means it would be an argument rather than syntax if it did. Narrower than package managers actually accept — no `:`, no `+` version pins — because a plugin needing one wants a conversation. Success is OBSERVED, not inferred: after installing, the binaries are re-probed. A package manager exiting 0 having installed something that does not provide the binary is exactly the failure this exists to catch. installCommand mirrors lib/packages.sh's pkg_install_now exactly, including apt's non-interactive environment, so there is one definition of "install a package" rather than two that drift. sudo always gets -n: under PM2 a password prompt is not a slow path, it is a hang. brew never escalates. Verified live. ffmpeg and ffprobe were absent on this machine all evening; the page showed both missing with the exact root command, the install streamed `dependencies: installing ffmpeg with apt` then `ffprobe, ffmpeg now on PATH`, and X-Audio-Duration appeared on a stream response for the first time. The refusal path was exercised against a temporary probe dependency: HTTP 400, steps: [], reason named. THIS CHANGED THE MACHINE: ffmpeg 6.1.1-3ubuntu5 is now installed via apt. Found on the way: a manifest is read once per process. Discovery does `await import()` and the module cache holds it, so editing a manifest changes nothing until pm2 restart officer — including `outdated`. Cost ten minutes and is now in the runbook. bunx tsgo clean. 797 tests, 787 pass, 7 fail — the same seven, +25 new. |
||
|
|
a220342b22 |
stream the install, so it reads like a log instead of a spinner
each verb now reports its steps as they complete, over server-sent events, and the detail panel renders them arriving. POST rather than GET, so EventSource is unavailable — it sends no Authorization header and these routes are owner-only. The client reads the body and parses frames by hand, which is what useCompanionLogStream already does for the headscale container logs; the parser only has to understand what our own endpoint emits. the runner does not know whether anyone is listening. it takes an optional onStep and calls it, so the non-streaming path is the same code with no callback rather than a second implementation of the same four verbs. there is a 220ms beat between steps and it is cosmetic — worth saying out loud. pm2 start genuinely takes ~770ms, measured, but writing a row and rebuilding the router do not, and four lines landing in one frame look like a stall followed by a jump. small enough not to matter to a script, long enough to follow. writing to a closed stream is caught rather than fatal: navigating away mid-install must not abort the install, because by then it is the server's work and half an install is the one outcome the ordering was designed to avoid. verified over the wire with timestamps — frames arrive incrementally, the sidecar step showing its real duration rather than the beat. afterwards pm2 holds the five core apps, plugin_installs is zero, and ecosystem.config.cjs is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2634df7a04 |
the install runner: ecosystem entry, sidecar, row, mounts
closes the hole app-store/pm2.ts has carried since 2026-08-13 — "installing a
plugin has to append its entry here before starting it, that is the plugin
system's job and it is not built". this is that job, and it is why nothing in
the app-store catalogue installs end to end either.
verified against a running server with a sidecar in the tree:
install ecosystem added · sidecar started · recorded · mounted /example
route 200, pm2 online
disable sidecar stopped · unmounted
route 404, pm2 stopped
enable sidecar started · mounted
route 200, pm2 online
uninstall record removed · unmounted · sidecar stopped, deleted, entry gone
route 404, not in pm2, tables untouched
afterwards ecosystem.config.cjs is byte-identical to before, pm2 holds the same
five core apps, and plugin_installs is back to zero rows.
the ecosystem file is edited rather than regenerated: the core entries come from
officer-setup's shell array, so the platform does not know that list and a copy
here would be a second thing to drift. the header above module.exports is
preserved verbatim too — officer-setup's explains that bun auto-loads .env from
the working directory and that data-path derives the install root from its
PARENT, so a wrong cwd relocates the whole install rather than failing. losing
that to a plugin install would be a poor trade.
order is the design. bringing up goes outside-in, taking down goes inside-out,
so the worst intermediate state is "recorded but not running" — visible, and
fixed by a retry — never "running but forgotten", which nothing can see.
each verb returns what it actually did, in order, and the detail panel shows it.
an install that mounted routes but could not start a sidecar is a different
outcome from one that worked, and a spinner that stops cannot say which.
the schema push is still deliberately not wired, and the reason is now in the
code: db:push DROPS tables absent from the schema it is given, so an uninstall
that regenerated the barrel would delete a plugin's data as a side effect of
stopping it. offscale does not need it — headscale_servers already ships in the
platform schema.
720 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
282a64a637 |
plugins install, enable, disable and uninstall at runtime
the rest of the mechanism, and it works end to end. against a real server, with
no restart at any point:
/api/example/ping BEFORE install 404
AFTER install 200 {"plugin":"example","ok":true}
AFTER disable 404
AFTER enable 200
AFTER uninstall 404
core route throughout 200
plugin_installs is a new table rather than a reuse of sidecar_installs. that one
belongs to the app store's model, where installing means provisioning a
container or pointing at a remote instance, and it carries mode, compose_dir and
completed_steps to say so. a plugin install has none of those, and reusing it
would have meant a `mode` that lies about every plugin. the two models coexist
until the app store is rebuilt on this one.
the row is needed because presence is not installation: plugins live in the
repository, so a developer writing one has the directory there and has installed
nothing. the tree says what could run, the table says what does.
mount.ts joins the two and rebuilds. an install row whose directory has gone is
dropped from the snapshot rather than reported — but the row is left in the
database, because deleting it there would turn "somebody moved the checkout"
into silent data loss. a plugin whose router will not load stays unmounted and
says why, rather than taking the other nine down with it.
/api/plugins is owner-only in its own right, like /api/app-store, and its
capability guards the MANAGEMENT surface only — a plugin's own permissions come
from its manifest, so a member can hold one at read without being able to
install anything.
plugins/example is the reference implementation and is meant to be read: the
smallest thing that is still a real plugin, with the directory layout as its own
documentation.
not wired yet, and marked [open] in the router: the schema push and the
sidecar's pm2 entry. a plugin with db/schema.ts or sidecar/ needs both before it
works end to end.
full suite: 719 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|