Compare commits

...
Author SHA1 Message Date
pastilhasandClaude Opus 5 8d39586de5 two minutes, not ten
The way this gets used is: ship a plugin, open the page, look for the badge. Ten
minutes of that is indistinguishable from the feature not working — a release
tagged at 12:42 was invisible at 12:45 because the last fetch was at 12:37, which
is correct and no comfort.

Bounded by the page being open: nothing fetches when nobody is looking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:47:09 +01:00
pastilhasandClaude Opus 5 eaa92738dc give :section back — the splat took it from fifteen plugins
`/<route>` and `/<route>/:section` are what every plugin has always had, and
fifteen of them read useParams().section. Replacing the pair with a splat took
that parameter away from all of them at once: the screens still mounted and every
one lost its selection, which is worse than a 404 because nothing says so.

The splat stays, for depth. React-router ranks a static or dynamic segment above
it, so a plugin with one segment gets `section` again and only deeper paths fall
through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:37:14 +01:00
pastilhasandClaude Opus 5 b2349525fd the update badge stops going stale
releaseState compares the tag at HEAD with the newest tag on origin/<branch> — a
remote-tracking ref, and nothing refreshed it. Tags arrived at clone and at the
fetch pullPlugin does, so you only learned an update existed BY UPDATING.

alpha had photos at 2026.08.27.01, last fetched 27 August, comparing this
morning's release against a snapshot from before it existed. Truthfully at the
latest, and useless.

refreshRemote fetches in the background: the request answers from the refs it has
and the next one is current. Rate-limited per checkout, one at a time, silent on
failure — the list still has a true answer without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:31:30 +01:00
pastilhasandClaude Opus 5 898209725a a plugin owns the routing under its mount
Reverts the manifest route table. Declaring a plugin's URLs to the platform put
the platform in the middle of a decision that is not its own, and it was more
code than what it replaced.

One splat where there were two routes. A splat matches zero segments, so the
bare route still works, and the plugin reads its own position off the location.
The platform owns where a plugin starts and nothing after it — the same line
mountPrefix already draws around its API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 07:17:33 +01:00
pastilhasandClaude Opus 5 1fe767ea77 a plugin screen can declare its routes
Every screen got `/<route>` and `/<route>/:section` and nothing deeper, so a
section had to live in the query string. A screen may now name its routes in
the manifest, and give one its own arrangement of the screen's panels.

Absent, the old pair is unchanged — which is every plugin but fofos-admin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 04:57:18 +01:00
pastilhasandClaude Opus 5 43f32a7440 export useAuthoredLayout, for a workspace inside a panel
A nested workspace wants the same bargain PluginScreen makes: structure from source every render, only
sizes and zoom persisted. `useDashboardState` was the only export, and it persists the whole tree —
right for a dashboard somebody arranged, wrong for a layout its author ships, because the structure
would freeze at whatever it was the first time a screen was opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 03:02:37 +01:00
pastilhasandClaude Opus 5 cdbf72c7fe the file browser says when something is a link
An arrow beside the name, and the target in its tooltip.

`stat` follows a symlink, so the listing has always described what the link POINTS AT and never that
there was a link in the way. That is right for behaviour — a link to a folder must still say
`directory` so opening it works — and it is why `stat` cannot answer this question at all.

`readdir({ withFileTypes: true })` can, without a second syscall per entry: the kind comes off the
directory entry itself. `readlink` is asked only for the entries that are links, which in an ordinary
folder is none of them.

A BROKEN link now gets a row, and did not before. `stat` failed on it, the entry returned null, and it
disappeared from the listing — nothing said it was there, nothing could delete it, and the folder
quietly had fewer things in it than `ls` showed. It is drawn in the destructive colour with its dead
target in the tooltip.

The target is stored exactly as written rather than resolved. `../originals` moves with the folder and
`/home/…/originals` does not, and rewriting one into the other would hide that difference at the
moment somebody is looking in order to understand it.

An arrow, not a badge or a different icon: the icon says what the thing IS, and a symlink to a folder
is still a folder to whoever uses it. What the arrow adds is the indirection — which is what explains
why deleting it freed no space, or why two places show the same file.

Verified against real links: to a file, to a directory, absolute, and broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:10:21 +01:00
pastilhasandClaude Opus 5 ab2336516f dev-proxy: a stylesheet's url() is a URL
Two bugs, one line apart, both silent.

The `url(/…)` rule only matched an UNQUOTED target, so `url("/fonts/x.woff2")` — the form every
generator emits — went through untouched. And it only ran on HTML, so a linked stylesheet got the
narrow `/_bun/` rule and nothing else.

Together that meant a site's own @font-face rules kept pointing at `/fonts/…`, which inside the frame
is the platform's root. Every face 404'd and the preview rendered in a system fallback: nothing looked
broken, it just looked like a different site than the one being previewed.

JavaScript keeps the narrow rule — an absolute path in JS may not be a URL. In CSS it always is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 06:42:14 +01:00
pastilhasandClaude Opus 5 9f243f569f native controls follow the theme
Nothing set `color-scheme`, so the browser drew every control it renders itself as if the page were
light. On a dark theme a <select>'s dropdown opened as a white sheet with the page's own pale text on
it — unreadable, and unreachable by any utility class, because that list is painted by the browser and
is not in the document.

`color-scheme` is the only thing that reaches it. It also fixes, for free, the other controls with no
styleable interior: date and colour pickers, the spinner on a number input, and default scrollbars
where a theme has not replaced them.

The `dark` class decides it, because that is the signal that exists — 203 of the 231 themes set
applyDarkClass, and the provider puts it on <html> beside data-theme, so the two always agree about the
same element. Both are specificity 0-1-0 and themes import after this file, so a theme that disagrees
can say so. This is a default, not a decision taken away.

Explicit colours on select/option/optgroup underneath, because color-scheme gives the browser's generic
dark rather than the theme's. Where a browser honours them — Firefox, Chrome on Linux and Windows — the
list becomes the theme's own popover surface. Where it does not, Safari and Chrome on macOS use a
genuinely native menu and the scheme carries it, which is a great deal better than white on white.

Base-layer rules, so a utility still wins and `bg-transparent` on a select behaves as it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:38:59 +01:00
pastilhasandClaude Opus 5 160788780b the dev proxy did not rewrite srcset
Responsive images 404'd in every preview, and did it silently.

The attribute rule matches a whole attribute value, so it could never have handled srcset — that is a
comma-separated LIST of "url descriptor" pairs, and prefixing it as one value would mangle the first
descriptor onto the first URL. It did not match srcset at all.

Silently is the operative word. An <img> with a srcset also has a src, and the src WAS prefixed — but a
browser given a valid srcset chooses from it and never looks at src. So the markup read correctly, one
attribute pointed at the platform root, and the image was just missing. Found by a logo that would not
load in the fofos layout preview.

imagesrcset is rewritten too. That is the one on <link rel="preload" as="image">, easy to miss and
expensive to miss: a preload that 404s costs a request and leaves the real image undiscovered until the
body is parsed, which is the opposite of why anybody adds one.

Six tests, including the case that found it — src prefixed, srcset not, image missing — and a
protocol-relative candidate left alone, which is the rule everywhere else in this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:10:58 +01:00
pastilhasandClaude Opus 5 41f54a292b the constraint goes on the grid child, not inside it
The previous fix put `min-w-0` on the scroll container, which was one level too low to stop anything.
DialogContent is a grid; the element that expands is its DIRECT child, and between the two sat a
`flex flex-col gap-3` wrapper with no constraint at all. It grew to fit the title and took the card out
of the dialog with it, exactly as before.

Found by mapping the element chain from DialogContent down rather than reasoning about which element
"should" be the one — the wrapper does not appear in any of the code you read while thinking about the
card, which is why two attempts missed it.

Also confirmed the previous fix was genuinely live before blaming it: the served chunk on fofos carried
the new class and not the old one. The code was right and the diagnosis was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 23:00:14 +01:00
pastilhasandClaude Opus 5 ae70dd5ea8 the download dialog stops growing to fit its titles
A long video title pushed the modal wider than itself and ran out of the card. The title had `truncate`
and its parents had `min-w-0 flex-1`, and none of it bit.

DialogContent is a GRID. A grid item defaults to `min-width: auto` — it refuses to shrink below its
content — so the list container expanded past the dialog's own `max-w-lg`, the card grew with it, and
`truncate` never had a width to truncate against. Every constraint below that point was measured against
a box that had already given up.

`82d24a8f` made the card a column, which was a real fix for a real half of this: the title, thumbnail
and button used to share a line and the title won. But a column still cannot be narrower than the
widest thing the grid above it will allow, so the fix held only until a title long enough came along.

Worth knowing beyond this dialog: every DialogContent in the app is that same grid, so any dialog
holding unbreakable content has the same trap waiting. Fixed here rather than in the shared component,
because changing the layout of every dialog in the platform to fix one modal is not a change to make
without asking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 22:40:57 +01:00
pastilhas 060033322d deploy 2026.08.30.03: 7 of 7 hosts, fofos plugins:update back to 5/5 2026-08-30 11:45:49 +01:00
pastilhasandClaude Opus 5 c97da0292a release 2026.08.30.03
One commit — 537cc29c, fixing what the 2026.08.30.01 rollout found on fofos: a plugin declaring
`@tiptap/pm` was reported as a failed install. Nothing was broken on disk. That package has no root
entry point by design — it is tiptap's single pinned copy of ProseMirror and you import
`@tiptap/pm/state`, never the bare name — so the checker asked a question it could not answer.

Worth its own release rather than waiting: `bun run plugins:update` exits 1 on fofos every time until
this lands, so the plugin step of every future deploy fails there and reads as a new problem each time.

No schema change and no dependency change, with the two commands that prove it in the plan. docs:check
green. Only fofos has fofos-admin installed, so everywhere else this is a no-op in behaviour.

── Why .03 ──

2026.08.30.02 was tagged against a commit that was not on master: the rollout's own report had just been
pushed and the release was cut without pulling first. The branch push was rejected and the tag was not,
so a tag pointing off-branch existed for about ten seconds. The watcher claimed the version inside that
window, and a claim is permanent by design — so .02 is spent and carries a claim with no .done, which is
what "attempted and did not succeed" should look like.

No host moved. Every one stayed on 816f8695 throughout.

The lesson is in the ordering, and it is now the practice: pull, then push the branch, then tag only
once the branch has landed. Pushing both in one command means a rejected branch still publishes a tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:38:06 +01:00
pastilhasandClaude Opus 5 d2c07a9fd2 deploy 2026.08.30.01: 7 of 7 hosts, officer-dev skipped as the host it ran on
fofos-admin failed its resolvability check on fofos — @tiptap/pm has no root
entry point, so the install was perfect and the checker was wrong. Fixed by
537cc29c, which is one commit past this tag and therefore deployed nowhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:23:23 +01:00
pastilhasandClaude Opus 5 537cc29c3e a package with no root entry point is not a broken install
`unresolvableDependencies` asked `Bun.resolveSync(name, pluginDir)` of every declared package. That is
the right question for almost all of them and unanswerable for a few.

`@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no "." — it exists only as
subpaths. You import `@tiptap/pm/state`, never `@tiptap/pm`. Asked to resolve the bare name Bun
correctly says it cannot, and this read that as a failed install.

It cost a deploy. fofos-admin declares `@tiptap/pm` because tiptap needs it present; the install was
perfect — the directory, its dist and thirteen prosemirror packages all there — and the plugin was
reported as failed on every host that has it.

The strong check stays first, because it is the one that catches a package which installed but cannot
be loaded on this machine — `sharp` resolving to a platform-specific binary is the shape it was written
for. Only a package that fails it is asked the weaker question: is it there. Present-but-unimportable
still fails, since it never reaches the second line.

Verified on the host rather than reasoned about: of the five packages fofos-admin declares, four
resolve and @tiptap/pm alone does not, and it is present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:20:25 +01:00
16 changed files with 703 additions and 32 deletions
+114
View File
@@ -0,0 +1,114 @@
# 2026.08.30.01 — rollout report
**7 of 7 targets succeeded. `officer-dev` skipped by design — it is the host the rollout ran on.**
One plugin failed on one host (`fofos-admin` on `fofos`) and it is a false alarm in the checker, not a
broken install. The owner fixed it mid-rollout in `537cc29c`, which is NOT in this tag. Detail below.
No schema change, no `bun install`, no `bun db:push` — the plan says so and shows the two
`git diff --name-only` commands behind the claim. Nothing was improvised.
## Per host
| Host | HEAD before | HEAD after | plugins:update | `officer` restart_time | /api/health | file-browser `ls` |
| --- | --- | --- | --- | --- | --- | --- |
| `officer-dev` | — | — | — | — | — | **SKIPPED — ran here** |
| `alpha` | `e9b71522` | `816f8695` | 15/15, exit 0 | 6 → 7 | 401 | 200, 77 entries |
| `fofos` | `601849be` | `816f8695` | **4/5, exit 1** | 3 → 4 | 401 | 200, 39 entries |
| `macbookpro` | `e9b71522` | `816f8695` | no plugins installed | 6 → 7 | 401 | 200, 62 entries |
| `macbruno` | `e9b71522` | `816f8695` | 1/1, exit 0 | 9 → 10 | 401 | 200, 41 entries |
| `production-pertento-bridge` | `e9b71522` | `816f8695` | 1/1, exit 0 | 7 → 8 | 401 | 200, 53 entries |
| `edge-pertento-bridge` | `e9b71522` | `816f8695` | 2/2, exit 0 | 8 → 9 | 401 | 200, 29 entries |
| `deadpool-rezio-bridge` | `e9b71522` | `816f8695` | 2/2, exit 0 | 25 → 26 | 401 | 200, 49 entries |
Every host landed on `816f8695`, exactly the tag. `fofos` started ahead of the previous tag and behind
this one, as the plan predicted, so its fast-forward moved 12 files rather than 23.
`401` on `/api/health` is healthy — the plan says so and all seven agreed. Ports were read from each
host's own `.env`, never assumed: 9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere.
## The file browser check
This release touched the listing loader, the clipboard and the row markup, so the plan asked for one
signed `/api/file-browser/ls` per host. **Seven hosts, seven `200`s**, every one with a plausible count
and none of them zero. The counts differ because the homes differ.
Signed as that host's own owner, read from its own `users` table rather than assumed —
`conceiba@gmail.com` on `macbruno`, `brunorezio@gmail.com` on `deadpool-rezio-bridge`,
`pastilhas@officer.dev` on the rest.
## Schema
**No schema verification was performed, because this release changes no schema.** No `db:push` step
exists in the plan and none was run, so there is nothing to verify by query. The clipboard is a key in
`user_state`, a JSONB column that already existed.
The plugin sidecars applied their OWN schemas during `plugins:update`, which belongs to the plugin
updater and not to this release: `music` on `alpha` (4 tables), `proxy-manager` on `fofos` (3) and on
`macbruno` (3), `offscale` on `edge-pertento-bridge` (1), `email` on `deadpool-rezio-bridge` (1).
## The one failure: `fofos-admin` on `fofos`
`bun run plugins:update` exited 1 with:
```
FAILED: packages installed, but @tiptap/pm cannot be resolved from …/plugins/fofos-admin
```
**The install is fine and the plugin is not broken.** Verified on the host rather than assumed:
- `plugins/node_modules/@tiptap/pm` is present, with its `dist` and the prosemirror packages.
- `@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no `"."` — it exists only as
subpaths. Code imports `@tiptap/pm/state`, never the bare name.
- `Bun.resolveSync('@tiptap/pm', pluginDir)` therefore fails, while `@tiptap/react` and
`@tiptap/starter-kit` both resolve. `unresolvableDependencies` reads that as a failed install.
- `fofos-admin`'s own checkout is clean, so this is not the usual uncommitted-change cause.
Also verified there, and it is this release working as intended: `react` resolves out of the PLATFORM's
`node_modules`, not the plugins tree. The eviction added in `9719e367` did its job.
**The owner shipped the fix at 11:20 while the rollout was in flight — `537cc29c`, "a package with no
root entry point is not a broken install".** It is one commit PAST this tag and is therefore deployed
nowhere. `fofos` will keep failing that step on every `plugins:update` until a release carries it.
What this cost on `fofos`: `fofos-admin` did not finish its update — no assets publish, no access grant,
no `recorded at`. Its code was already at the latest commit, and the platform mounted `/fofos-admin`
normally. The other four plugins updated.
## A commit landed on master mid-rollout, and the tag absorbed it
`alpha`, `fofos`, `macbookpro`, `macbruno` and `production-pertento-bridge` fetched `origin/master` at
`816f8695`. `edge-pertento-bridge` and `deadpool-rezio-bridge` fetched it at `537cc29c`.
Because every host merged `--ff-only deploy/2026.08.30.01` and not the branch, **all seven are on the
tag and none carries the extra commit.** This is the exact failure of 2026-08-23, and merging the tag is
what prevented it.
## More restarted than `officer`
`plugins:update` restarts a sidecar whenever it moves a plugin, before the plan's own restart. Naming
only `officer` would understate what moved, so: `officer-music` on `alpha`, `officer-av-club` on
`fofos`, `officer-gitea` on `production-pertento-bridge`, `officer-gitea` and `officer-offscale` on
`edge-pertento-bridge`, `officer-email` on `deadpool-rezio-bridge`. `macbruno` moved `proxy-manager`,
which declares no sidecar, so nothing extra bounced there.
After every restart, `pm2 jlist` showed **every `officer*` process `online` on all seven hosts**. None
was `errored` and none failed to come back.
`ecosystem.config.cjs` was never restarted anywhere. No vault was touched. On
`production-pertento-bridge` the eight Pertento-owned processes — `Dashboard`, `Tracking`, `Uptime` and
the five `Crons *` — were filtered out of every read and never named in a restart.
## What I did not do
- **`officer-dev` — skipped entirely.** Not pulled, not restarted. The rollout ran inside its
`officer-claude-code` sidecar, and restarting it would have ended the session writing this report. Its
working tree already held this code. The owner restarts it deliberately, as the last step.
- **Did not fix the `fofos-admin` checker.** The fix exists in `537cc29c` and belongs to a release, not
to a rollout editing five hosts.
- **Did not re-run `plugins:update` on `fofos`** after diagnosing it. It would fail identically — the
code that decides is in the tag.
- **Did not run `bun run docs:check`.** Not the commander's job.
- **One untracked file noted, not touched:** `scripts/chk.ts` on `fofos`. Untracked is a note, not a
skip, and a fast-forward could never have touched it. Every host was clean on
`--untracked-files=no`.
+99
View File
@@ -0,0 +1,99 @@
# 2026.08.30.03 — a package with no root entry point is not a broken install
**One commit.** `537cc29c`, fixing what the 2026.08.30.01 rollout found on `fofos`.
**Why .03 and not .02.** `2026.08.30.02` was tagged against a commit that was not on `master` — the
rollout's own report had just been pushed and the release was cut without pulling first. The branch push
was rejected, the tag was not, and a tag pointing off-branch was published for about ten seconds before
being withdrawn. The watcher claimed the version inside that window.
A claim is permanent by design and that is correct: `.02` was attempted, so `.02` is spent and cannot be
deployed. It has a claim and no `.done`, which is exactly what "attempted and did not succeed" should
look like in the record. Nothing reached any host — every one stayed on `816f8695` throughout.
**No schema change and no dependency change.** Verified rather than assumed:
```bash
git diff --name-only deploy/2026.08.30.01..deploy/2026.08.30.03 | grep -iE 'schema|/db/|databases' # nothing
git diff --name-only deploy/2026.08.30.01..deploy/2026.08.30.03 -- package.json bun.lock # nothing
```
So **no `bun db:push` and no `bun install` step exists in this plan, and neither should be improvised.**
## What it fixes, and what it does not
`unresolvableDependencies` asked `Bun.resolveSync(name, pluginDir)` of every package a plugin declares.
That is the right question for almost all of them and unanswerable for a few.
`@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no `"."` — it exists only as
subpaths, because it is tiptap's single pinned copy of the ProseMirror packages and you import
`@tiptap/pm/state`, never `@tiptap/pm`. Asked to resolve the bare name, Bun correctly says it cannot,
and the checker read that as a failed install.
**Nothing was ever broken on disk.** The install on `fofos` is complete — the directory, its `dist` and
thirteen prosemirror packages. The editor works. What failed was the verification, which then failed
`plugins:update`, which exits non-zero if any plugin failed.
The consequence worth shipping for: **`bun run plugins:update` exits 1 on `fofos` every time** until
this lands, so the plugin step of every future deploy fails there and looks like a new problem.
The strong check stays first, so a package that installed but cannot be loaded on this machine still
fails — `sharp` resolving to a platform-specific binary is the shape it was written for. Only a package
that fails it is asked the weaker question: is it there.
## Every host — the commands
The standard steps in `prompts/deploy-commander.md` are the whole rollout (dirty check,
`git fetch origin --tags`, `git merge --ff-only deploy/2026.08.30.03`, `bun run plugins:update`), plus:
```bash
pm2 restart officer
```
`officer` only — `plugins:update` has already restarted the plugin sidecars.
**`fofos` is the host this release is for.** Its `plugins:update` failed 4/5 on the last rollout and
should now be 5/5, exit 0. That is the check that matters:
```
→ fofos-admin
packages: installed and resolvable
```
If `fofos` still reports 4/5 with `@tiptap/pm cannot be resolved`, this release did not work and that
is the finding — report it rather than working around it.
Everywhere else this is a no-op in behaviour: no other host has `fofos-admin` installed, so no other
host was failing and none should start.
### Verify it came back
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are healthy.
No file-browser check this time — this release touches one function in the plugin installer and
nothing a browser reaches.
## Per host
Every host went to `816f8695` an hour ago and all seven came back online, so they start from there.
| Host | Note |
| --- | --- |
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here. It already holds this commit and its `officer` was restarted by hand at 11:23. |
| `alpha` | Ordinary target. No `fofos-admin`, so nothing changes in behaviour. |
| `fofos` | **The reason for this release.** Expect `plugins:update` to go 4/5 → 5/5. |
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note, not a failure. |
| `macbruno` | macOS, login **`brunoalcadapadezmonteirodaconceicao`**. |
| `production-pertento-bridge` | Port **9010**. |
| `edge-pertento-bridge` | Ordinary target. |
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
Note for whoever reads the report: `scripts/chk.ts` is untracked on `fofos` and was noted last time. It
is still a note, not a skip.
## What "done" looks like
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
`restart_time`. And for `fofos` specifically, the `fofos-admin` line reading
`packages: installed and resolvable`.
+55
View File
@@ -0,0 +1,55 @@
# 2026.08.30.03 — report
**7 of 7 target hosts succeeded.** `officer-dev` skipped entirely, as the host the rollout ran on.
Every target went `816f8695` → `c97da029` by `git merge --ff-only deploy/2026.08.30.03`. No host was
dirty in tracked files, none had to be skipped, and no merge was anything other than a fast-forward.
**The release did what it was cut for.** `fofos` went 4/5 → **5/5, exit 0**, with `fofos-admin` reading
`packages: installed and resolvable`. That was the one check that mattered and it passed.
**No schema step and no `bun install` step** — the plan has neither, and neither was improvised. So there
is no per-host schema query in this report, because this release introduced nothing to query for.
## Per host
| Host | HEAD before → after | `plugins:update` | `officer` restart_time | health |
| --- | --- | --- | --- | --- |
| `officer-dev` | — | — | — | **SKIPPED** — the host this ran on |
| `alpha` | `816f8695` → `c97da029` | 15/15 | 7 → 8, online | `401` (9000) |
| `fofos` | `816f8695` → `c97da029` | **5/5, exit 0** | 4 → 5, online | `401` (9000) |
| `macbookpro` | `816f8695` → `c97da029` | no plugins installed | 7 → 8, online | `401` (9010) |
| `macbruno` | `816f8695` → `c97da029` | 1/1 | 10 → 11, online | `401` (9000) |
| `production-pertento-bridge` | `816f8695` → `c97da029` | 1/1 | 8 → 9, online | `401` (9010) |
| `edge-pertento-bridge` | `816f8695` → `c97da029` | 2/2 | 9 → 10, online | `401` (9000) |
| `deadpool-rezio-bridge` | `816f8695` → `c97da029` | 2/2 | 26 → 27, online | `401` (9000) |
`401` on `/api/health` is healthy — the endpoint is behind auth.
## What is worth reading
- **`fofos` is fixed.** `plugins:update` there had failed 4/5 on every rollout since `@tiptap/pm` was
installed, and it now exits 0. The plugin step of the next deploy should be unremarkable on that host.
- **`macbookpro` needed a fetch retry.** The first `git fetch` failed with
`Connection closed by 77.42.39.155 port 22` — gitea's public SSH, rate-limited at the proxy. That host
resolves `gitea.officer.dev` to the public address where `alpha` reaches it over the tailnet. The first
retry succeeded and the rollout continued normally. A later probe was refused again, so the window is
intermittent rather than closed. **Nothing to fix in the deploy; the remote on that host is the thing
that is wrong**, and pointing it at the tailnet address would remove a class of failure that costs a
retry on every release.
- **`scripts/chk.ts` is still untracked on `fofos`.** Noted, not a skip, exactly as the plan said. It has
survived two rollouts now; somebody may want to commit it or delete it.
- **`plugins:update` restarted sidecars on several hosts** before the `pm2 restart officer` the plan
asked for — `officer-music` on `alpha`, `officer-gitea` on `production-pertento-bridge`,
`officer-offscale` on `edge-pertento-bridge`, `officer-email` on `deadpool-rezio-bridge`. That is the
documented behaviour of the plugins step, not something this rollout chose.
- **On `production-pertento-bridge` only `officer` was restarted.** Pertento's eight production processes
were not touched.
## What was not done
- `officer-dev` was not pulled, not updated and not restarted. It already holds `c97da029` and its
`officer` was restarted by hand at 11:23. **The owner restarts this host deliberately, as the last
step**, once happy with the rest.
- No `bun db:push`, no `bun install`, no direct SQL. The plan called for none.
- No file-browser check, per the plan.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "officer", "name": "officer",
"version": "2026.08.30.01", "version": "2026.08.30.03",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
+20 -6
View File
@@ -97,20 +97,34 @@ export function App() {
404 on a route that worked yesterday reads as the feature having been removed. */} 404 on a route that worked yesterday reads as the feature having been removed. */}
<Route path="/plugins" element={<PluginsRedirect />} /> <Route path="/plugins" element={<PluginsRedirect />} />
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from {/* Installed plugins. Core routes above stay hand-written; everything below is generated from
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard what is installed, because a bundler cannot follow a runtime import specifier. */}
hands the whole subtree to the plugin's own router, which react-router nests natively. */} {/* One route per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
{/* One pair per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
screens (caldav: Calendar and Contacts) contributes two entries sharing one `appName`. */} screens (caldav: Calendar and Contacts) contributes two entries sharing one `appName`. */}
{/*
Three routes, and the third is the only new one.
`/<route>` and `/<route>/:section` are what every plugin has always had, and fifteen of
them read `useParams().section`. A splat ALONE replaced that for a day and took the
parameter away from all of them at once — the screens still mounted and every one of them
lost its selection, which is a worse failure than a 404 because nothing says so.
The splat is added for depth rather than instead of them: `/fofos-admin/layout/header` was
not expressible before, and a plugin that wants its own subtree reads the location.
React-router ranks a static or dynamic segment above a splat, so a plugin with one segment
still gets `section` and only deeper paths fall through.
*/}
{installedPlugins.flatMap((plugin) => [ {installedPlugins.flatMap((plugin) => [
<Route key={plugin.screenId} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />, <Route key={plugin.screenId} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
// The section pair, exactly as the core screens do it (`/soulseek/:section`): the plugin's
// panels read `useParams` themselves, so which section is open is the URL rather than state
// passed between them.
<Route <Route
key={`${plugin.screenId}-section`} key={`${plugin.screenId}-section`}
path={`${plugin.route}/:section`} path={`${plugin.route}/:section`}
element={<Dashboard.PluginScreen {...plugin} />} element={<Dashboard.PluginScreen {...plugin} />}
/>, />,
<Route
key={`${plugin.screenId}-deep`}
path={`${plugin.route}/*`}
element={<Dashboard.PluginScreen {...plugin} />}
/>,
])} ])}
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} /> <Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} /> <Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
+77 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { proxyOverrideScript, rewriteAssetPaths, rewriteHtml } from './html-rewrite'; import { proxyOverrideScript, rewriteAssetPaths, rewriteCss, rewriteHtml } from './html-rewrite';
const BASE = '/api/dev-proxy/3001'; const BASE = '/api/dev-proxy/3001';
const attrs = (html: string) => [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1]!); const attrs = (html: string) => [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1]!);
@@ -58,6 +58,43 @@ describe('rewriteHtml', () => {
expect(out.indexOf('<script>')).toBeLessThan(out.indexOf('<title>')); expect(out.indexOf('<script>')).toBeLessThan(out.indexOf('<title>'));
}); });
test('every candidate in a srcset is prefixed, descriptors intact', () => {
expect(rewriteHtml('<img srcset="/a.webp 96w, /b.webp 320w">', BASE)).toContain(
`srcset="${BASE}/a.webp 96w, ${BASE}/b.webp 320w"`,
);
});
/*
The failure this was found by. An <img> with a srcset also has a src, and the src WAS prefixed — but
a browser given a valid srcset picks from it and never reads src. So the logo was missing while the
markup looked right.
*/
test('a srcset is chosen over src by the browser, so prefixing src alone is not enough', () => {
const out = rewriteHtml('<img src="/logo.webp" srcset="/logo.webp 96w">', BASE);
expect(out).toContain(`src="${BASE}/logo.webp"`);
expect(out).toContain(`srcset="${BASE}/logo.webp 96w"`);
});
test('a candidate with no descriptor is prefixed too', () => {
expect(rewriteHtml('<img srcset="/only.webp">', BASE)).toContain(`srcset="${BASE}/only.webp"`);
});
test('imagesrcset on a preload link is prefixed — the one that is easy to miss', () => {
expect(rewriteHtml('<link rel="preload" as="image" imagesrcset="/hero.webp 1280w">', BASE)).toContain(
`imagesrcset="${BASE}/hero.webp 1280w"`,
);
});
test('a protocol-relative candidate is left alone, like everywhere else here', () => {
expect(rewriteHtml('<img srcset="//cdn.example.com/a.webp 96w, /b.webp 320w">', BASE)).toContain(
`srcset="//cdn.example.com/a.webp 96w, ${BASE}/b.webp 320w"`,
);
});
test('`..` is collapsed in a srcset candidate as well', () => {
expect(rewriteHtml('<img srcset="/../../x.webp 96w">', BASE)).toContain(`srcset="${BASE}/x.webp 96w"`);
});
test('a query string and hash survive normalisation', () => { test('a query string and hash survive normalisation', () => {
expect(attrs(rewriteHtml('<script src="/../x.js?v=2#frag">', BASE))).toEqual(['/api/dev-proxy/3001/x.js?v=2#frag']); expect(attrs(rewriteHtml('<script src="/../x.js?v=2#frag">', BASE))).toEqual(['/api/dev-proxy/3001/x.js?v=2#frag']);
}); });
@@ -175,3 +212,42 @@ describe('the injected location reporter', () => {
expect(r.posted).toHaveLength(2); expect(r.posted).toHaveLength(2);
}); });
}); });
describe('stylesheets', () => {
test('a quoted url is prefixed — the form every generator emits', () => {
expect(rewriteCss('@font-face{src:url("/fonts/Averta.woff2") format("woff2")}', BASE)).toContain(
`url("${BASE}/fonts/Averta.woff2")`,
);
});
test('single quotes and bare urls too', () => {
expect(rewriteCss("a{background:url('/bg.png')}", BASE)).toContain(`url('${BASE}/bg.png')`);
expect(rewriteCss('a{background:url(/bg.png)}', BASE)).toContain(`url(${BASE}/bg.png)`);
});
test('the quote style is preserved, not normalised', () => {
expect(rewriteCss('a{background:url("/bg.png")}', BASE)).not.toContain("url('");
});
test('a url that is already absolute is left alone', () => {
const external = 'a{background:url("https://cdn.example.com/bg.png")}';
expect(rewriteCss(external, BASE)).toBe(external);
const protocolRelative = 'a{background:url("//cdn.example.com/bg.png")}';
expect(rewriteCss(protocolRelative, BASE)).toBe(protocolRelative);
});
test('a relative url is left alone — it already resolves against the stylesheet', () => {
const relative = 'a{background:url("./bg.png")}';
expect(rewriteCss(relative, BASE)).toBe(relative);
});
test('a stylesheet still gets the /_bun/ rule', () => {
expect(rewriteCss('@import "/_bun/x.css";', BASE)).toContain(`${BASE}/_bun/x.css`);
});
test('inline CSS in HTML gets the same treatment, quotes included', () => {
expect(rewriteHtml('<div style=\'background:url("/a.png")\'></div>', BASE)).toContain(
`url("${BASE}/a.png")`,
);
});
});
+74 -3
View File
@@ -89,7 +89,38 @@ const normalisePath = (path: string): string => {
/** `/x` → `<base>/x`, with `..` already collapsed. Never touches `//host` or a full URL. */ /** `/x` → `<base>/x`, with `..` already collapsed. Never touches `//host` or a full URL. */
const prefix = (base: string, path: string): string => `${base}${normalisePath(path)}`; const prefix = (base: string, path: string): string => `${base}${normalisePath(path)}`;
// HTML: rewrite src/href/action attributes with absolute paths + inject fetch/XHR override /**
* A `srcset`, which is a LIST and cannot be prefixed as one value.
*
* `srcset="/a.webp 96w, /b.webp 2x"` — comma-separated candidates, each a URL plus an optional
* descriptor. The attribute rule below matches a whole attribute value, so it can only ever have
* rewritten the first URL and left the descriptor mangled onto it; it does not match `srcset` at all,
* which is why responsive images 404'd silently.
*
* Silently is the operative word. A `<picture>` or an `<img srcset>` still has a `src`, and that one WAS
* prefixed — but a browser given a valid srcset chooses from it and never looks at src. So the markup
* appeared correct, one attribute pointed at the platform root, and the image was simply missing.
*
* Split on commas rather than parsed: a URL cannot contain an unescaped comma in this position, and the
* descriptor never does.
*/
const prefixSrcset = (base: string, list: string): string =>
list
.split(',')
.map((candidate) => {
const trimmed = candidate.trim();
if (!trimmed) return '';
// The URL is everything up to the first space; the rest is `96w`, `2x`, or nothing.
const space = trimmed.search(/\s/);
const url = space === -1 ? trimmed : trimmed.slice(0, space);
const descriptor = space === -1 ? '' : trimmed.slice(space);
// Same rule as everywhere else here: root-absolute only, never protocol-relative.
return url.startsWith('/') && !url.startsWith('//') ? `${prefix(base, url)}${descriptor}` : trimmed;
})
.filter(Boolean)
.join(', ');
// HTML: rewrite src/href/action/srcset attributes with absolute paths + inject fetch/XHR override
export const rewriteHtml = (html: string, base: string) => export const rewriteHtml = (html: string, base: string) =>
html html
.replace(/<head([^>]*)>/i, `<head$1>${proxyOverrideScript(base)}`) .replace(/<head([^>]*)>/i, `<head$1>${proxyOverrideScript(base)}`)
@@ -97,12 +128,52 @@ export const rewriteHtml = (html: string, base: string) =>
/(href|src|action)=("|')(\/(?!\/)[^"']*)\2/gi, /(href|src|action)=("|')(\/(?!\/)[^"']*)\2/gi,
(_match, attr: string, quote: string, path: string) => `${attr}=${quote}${prefix(base, path)}${quote}`, (_match, attr: string, quote: string, path: string) => `${attr}=${quote}${prefix(base, path)}${quote}`,
) )
.replace(/url\((\/(?!\/)[^)"']*)\)/g, (_match, path: string) => `url(${prefix(base, path)})`); /*
`srcset` and `imagesrcset`, which the attribute rule above cannot do — see prefixSrcset.
// JS/CSS: only `/_bun/` paths. `imagesrcset` is the one on `<link rel="preload" as="image">`. It is easy to miss and expensive
to miss: a preload that 404s costs a request AND leaves the real image undiscovered until the
body is parsed, which is the opposite of what the preload was added for.
*/
.replace(
/(imagesrcset|srcset)=("|')([^"']*)\2/gi,
(_match, attr: string, quote: string, list: string) => `${attr}=${quote}${prefixSrcset(base, list)}${quote}`,
)
.replace(CSS_URL, cssUrlPrefixer(base));
/*
`url(/…)` — a font, a background image, an `@import`. CSS allows the target bare, single-quoted or
double-quoted, and all three mean the same thing, so all three have to match. The quote used is
captured and put back rather than normalised: rewriting is not the place to have an opinion about
someone else's stylesheet.
This rule missed every QUOTED url until 2026-08-31, which is a bad way to fail. `url(/x.png)` was
rewritten and `url("/x.png")` was not, so whether a stylesheet worked through the proxy came down to
the formatting preference of whatever generated it.
*/
const CSS_URL = /url\(\s*(["']?)(\/(?!\/)[^)"']*)\1\s*\)/g;
const cssUrlPrefixer =
(base: string) =>
(_match: string, quote: string, path: string): string =>
`url(${quote}${prefix(base, path)}${quote})`;
// JS: only `/_bun/` paths.
// //
// Deliberately narrow. A blanket rewrite of every absolute path inside JavaScript hits strings that are // Deliberately narrow. A blanket rewrite of every absolute path inside JavaScript hits strings that are
// not URLs — a regex in the source, a route table, anything containing a slash — and corrupts the file it // not URLs — a regex in the source, a route table, anything containing a slash — and corrupts the file it
// is trying to fix. Bun's dev server emits its chunk imports under `/_bun/`, which is specific enough to // is trying to fix. Bun's dev server emits its chunk imports under `/_bun/`, which is specific enough to
// match safely. A Vite app instead wants `base` set in its own config; nothing here can guess that. // match safely. A Vite app instead wants `base` set in its own config; nothing here can guess that.
export const rewriteAssetPaths = (text: string, base: string) => text.replace(/\/_bun\//g, `${base}/_bun/`); export const rewriteAssetPaths = (text: string, base: string) => text.replace(/\/_bun\//g, `${base}/_bun/`);
/*
A STYLESHEET gets more than that, because a stylesheet's absolute paths are unambiguous: `url(/…)` is
a URL and nothing else, so the argument for being narrow in JavaScript does not apply here.
This was the whole of it for a long time, and the symptom was specific: a site's own `@font-face` rules
kept pointing at `/fonts/…`, which inside the frame is the PLATFORM's root, so every face 404'd and the
preview quietly rendered in a system fallback. Nothing looked broken — it just looked like a different
site than the one being previewed, which is the one thing a preview must never do.
*/
export const rewriteCss = (text: string, base: string) =>
rewriteAssetPaths(text, base).replace(CSS_URL, cssUrlPrefixer(base));
+8 -3
View File
@@ -1,7 +1,7 @@
import { createRouter } from '../../create-router'; import { createRouter } from '../../create-router';
import { resolveAuthToken } from '../../auth-token'; import { resolveAuthToken } from '../../auth-token';
import { isSuperAdmin } from '../../super-admin'; import { isSuperAdmin } from '../../super-admin';
import { rewriteAssetPaths, rewriteHtml } from './html-rewrite'; import { rewriteAssetPaths, rewriteCss, rewriteHtml } from './html-rewrite';
/** /**
* See a dev server running on THIS machine's loopback, from a browser that is somewhere else. * See a dev server running on THIS machine's loopback, from a browser that is somewhere else.
@@ -145,7 +145,8 @@ devProxyRouter.all('/:port/*', async (ctx) => {
const type = res.headers.get('content-type') ?? ''; const type = res.headers.get('content-type') ?? '';
const isHtml = type.includes('text/html'); const isHtml = type.includes('text/html');
const rewritable = isHtml || type.includes('javascript') || type.includes('text/css'); const isCss = type.includes('text/css');
const rewritable = isHtml || isCss || type.includes('javascript');
const headers = new Headers(res.headers); const headers = new Headers(res.headers);
// Anything framed here is the owner's own dev server; the platform frames it deliberately. // Anything framed here is the owner's own dev server; the platform frames it deliberately.
@@ -169,7 +170,11 @@ devProxyRouter.all('/:port/*', async (ctx) => {
} }
const text = await res.text(); const text = await res.text();
const body = isHtml ? rewriteHtml(text, prefix) : rewriteAssetPaths(text, prefix); const body = isHtml
? rewriteHtml(text, prefix)
: isCss
? rewriteCss(text, prefix)
: rewriteAssetPaths(text, prefix);
// Both are wrong after a rewrite: the length changed, and the body was decoded on the way through. // Both are wrong after a rewrite: the length changed, and the body was decoded on the way through.
headers.delete('content-length'); headers.delete('content-length');
headers.delete('content-encoding'); headers.delete('content-encoding');
+43 -8
View File
@@ -1,6 +1,7 @@
import { createRouter } from '@@/create-router'; import { createRouter } from '@@/create-router';
import { resolve, dirname, join, sep, parse as parsePath } from 'node:path'; import { resolve, dirname, join, sep, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises'; import { readdir, lstat, readlink, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import type { Dirent } from 'node:fs';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path'; import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
import { resolveHomeDir } from '@@/user-home'; import { resolveHomeDir } from '@@/user-home';
@@ -188,9 +189,17 @@ router.get('/ls', async (ctx) => {
await cleanOldCacheDirs(rootDir); await cleanOldCacheDirs(rootDir);
} }
let names: string[]; /*
`withFileTypes`, so a symlink can be spotted without a second syscall per entry.
The kind comes off the directory entry itself on every filesystem that reports it, and where it
does not Node falls back to an lstat it would otherwise have had to make anyway. `stat` below
FOLLOWS the link — which is right, and is why it cannot answer this question: a symlink to a folder
must still say `directory` so that opening it works.
*/
let names: Dirent[];
try { try {
names = await readdir(absPath); names = await readdir(absPath, { withFileTypes: true });
} catch (ex) { } catch (ex) {
// A missing directory resets the browser to the root, which is the right answer for a stale path. // A missing directory resets the browser to the root, which is the right answer for a stale path.
// //
@@ -213,18 +222,44 @@ router.get('/ls', async (ctx) => {
const claimed = await pluginFileTypes(); const claimed = await pluginFileTypes();
const entries = await Promise.all( const entries = await Promise.all(
names.map(async (name) => { names.map(async (entry) => {
const name = entry.name;
const fullPath = resolve(absPath, name); const fullPath = resolve(absPath, name);
// Skip entries that escape the home dir (shouldn't happen but be safe) // Skip entries that escape the home dir (shouldn't happen but be safe)
if (!isInside(rootDir, fullPath)) return null; if (!isInside(rootDir, fullPath)) return null;
/*
Where a link POINTS, exactly as it was written.
Not resolved to an absolute path: a relative target is a fact about the link, and rewriting it
would hide that `../originals` moves with the folder while `/home/…/originals` does not. Only
asked for the entries that are links, which in an ordinary folder is none of them.
*/
const target = entry.isSymbolicLink() ? await readlink(fullPath).catch(() => null) : null;
const s = await stat(fullPath).catch(() => null); const s = await stat(fullPath).catch(() => null);
if (!s) return null;
const isDirectory = s.isDirectory(); /*
A BROKEN link still gets a row, and did not before.
`stat` follows, so a link whose target is gone fails here and the entry returned null — the file
vanished from the listing entirely. Nothing said it was there, nothing could delete it, and the
folder simply had fewer things in it than `ls` showed. `lstat` describes the link itself, which
exists whatever it points at.
*/
const own = s ?? (target !== null ? await lstat(fullPath).catch(() => null) : null);
if (!own) return null;
const isDirectory = s?.isDirectory() ?? false;
return { return {
name, name,
type: isDirectory ? 'directory' : 'file', type: isDirectory ? 'directory' : 'file',
size: s.size, size: own.size,
modifiedAt: s.mtimeMs, modifiedAt: own.mtimeMs,
/** Where it points, or absent when it is an ordinary file. See the note above. */
symlink: target,
/** A link whose target is gone. `type` is `file` for want of anything truer to say. */
broken: target !== null && s === null ? true : undefined,
/* /*
Whether `/thumb` will produce something, decided HERE so there is one list of extensions Whether `/thumb` will produce something, decided HERE so there is one list of extensions
rather than one on each side of the wire. rather than one on each side of the wire.
+20 -1
View File
@@ -181,12 +181,31 @@ const body = {
export function unresolvableDependencies(plugin: DiscoveredPlugin): string[] { export function unresolvableDependencies(plugin: DiscoveredPlugin): string[] {
const names = Object.keys(plugin.manifest.npmDependencies ?? {}); const names = Object.keys(plugin.manifest.npmDependencies ?? {});
return names.filter((name) => { return names.filter((name) => {
// The strong check: it imports. This is what catches a package that installed but cannot be loaded
// on this machine — `sharp` resolving to a platform-specific binary is the shape it was written for.
try { try {
Bun.resolveSync(name, plugin.dir); Bun.resolveSync(name, plugin.dir);
return false; return false;
} catch { } catch {
return true; // fall through
} }
/*
Some packages have NO root entry point, and that is not a fault.
`@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no `"."` — it exists only as
subpaths, and you import `@tiptap/pm/state`, never `@tiptap/pm`. Asked to resolve the bare name,
Bun correctly says it cannot, and the check above read that as a broken install.
It cost a deploy: fofos-admin declares `@tiptap/pm` because tiptap needs it present, the install
was perfect — the directory, its dist and thirteen prosemirror packages all there — and the plugin
was reported as failed on every host that had it.
So a package that does not resolve is asked the weaker question instead: is it THERE. Present but
unimportable still fails, because the strong check ran first and only a package with no root entry
reaches this line.
*/
return !existsSync(join(PLUGINS_DIR, 'node_modules', name, 'package.json'));
}); });
} }
+66 -5
View File
@@ -29,12 +29,18 @@ import { join } from 'node:path';
* is that tag, this checkout is at the latest release. If it is a different one, the remote has moved on. * is that tag, this checkout is at the latest release. If it is a different one, the remote has moved on.
* That holds for any tag names anyone ever chooses, including ones that do not sort. * That holds for any tag names anyone ever chooses, including ones that do not sort.
* *
* ── What this does NOT do ── * ── The answer is never awaited on the network ──
* *
* It never fetches. Every answer comes from refs already on disk, because this is called once per plugin * Every answer comes from refs already on disk. This is called once per plugin on a request that renders
* on a request that renders a list — twenty-one network round trips would make the plugins screen feel * a list, and twenty-one network round trips would make the plugins screen feel broken.
* broken. Tags arrive at clone and at the fetch `pullPlugin` already does, so "check for updates" is a *
* consequence of updating rather than a separate poll. * But refs on disk go stale, and for a long time nothing refreshed them: tags arrived at clone and at
* the fetch `pullPlugin` does, so you only learned an update existed BY UPDATING. A machine that had not
* updated a plugin since August compared this morning's release against a snapshot from before it
* existed and said, truthfully and uselessly, that it was at the latest.
*
* So `refreshRemote` runs a fetch in the background and this request answers from what is there now. The
* NEXT read is current. Rate-limited per checkout and run one at a time — see the note on it.
*/ */
export type ReleaseState = { export type ReleaseState = {
@@ -47,6 +53,58 @@ export type ReleaseState = {
reason: 'untagged' | 'new-release' | 'at-latest' | 'not-a-checkout'; reason: 'untagged' | 'new-release' | 'at-latest' | 'not-a-checkout';
}; };
/**
* A background `git fetch --tags`, at most one per checkout per REFRESH_MS.
*
* ── Not awaited, on purpose ──
*
* The caller is rendering a list. Waiting would put the network on the critical path of a page that has
* to feel instant, which is the reason this file never fetched in the first place. The request answers
* from the refs it has; the fetch it starts is for the next one.
*
* The cost of that is one stale view after a release, corrected by a refresh. The alternative — never
* fetching — is a badge that is wrong until somebody updates for an unrelated reason.
*
* ── One at a time ──
*
* Twenty-one plugins would otherwise spawn twenty-one gits at once, every ten minutes, on machines that
* are not all fast. Chained rather than pooled because there is no hurry: nothing is waiting on it.
*
* Failure is silence. A checkout with no remote, an unreachable host, a repository that has moved — none
* of those should surface as anything, because the list still has a true answer without them.
*/
/*
Two minutes, and the number is chosen for how releases are actually watched.
It was ten. The way this gets used is: ship a plugin, open the page, look for the badge — and ten
minutes of that is indistinguishable from the feature not working. A release tagged at 12:42 was
invisible at 12:45 because the last fetch happened at 12:37, which is correct behaviour and no comfort.
The cost is bounded by the page being open: nothing fetches when nobody is looking, and the fetches are
chained, so the worst case is one small round trip per plugin every two minutes while somebody watches
the plugins screen.
*/
const REFRESH_MS = 2 * 60 * 1000;
const fetchedAt = new Map<string, number>();
let queue: Promise<unknown> = Promise.resolve();
export function refreshRemote(dir: string): void {
const last = fetchedAt.get(dir) ?? 0;
if (Date.now() - last < REFRESH_MS) return;
// Stamped BEFORE the fetch runs, so a slow one cannot be started again by the next twenty requests.
fetchedAt.set(dir, Date.now());
queue = queue.then(async () => {
const proc = Bun.spawn(['git', '-C', dir, 'fetch', '--tags', '--quiet', 'origin'], {
stdout: 'ignore',
stderr: 'ignore',
// A host that hangs must not hold the queue: every plugin behind it would stop refreshing too.
signal: AbortSignal.timeout(20_000),
});
await proc.exited.catch(() => {});
});
}
const run = async (dir: string, args: string[]): Promise<string | null> => { const run = async (dir: string, args: string[]): Promise<string | null> => {
const proc = Bun.spawn(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'ignore' }); const proc = Bun.spawn(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'ignore' });
const out = await new Response(proc.stdout).text(); const out = await new Response(proc.stdout).text();
@@ -61,6 +119,9 @@ export async function releaseState(dir: string): Promise<ReleaseState> {
return { ...none, updateable: false, reason: 'not-a-checkout' }; return { ...none, updateable: false, reason: 'not-a-checkout' };
} }
// Started, not awaited: this answer comes from the refs on disk and the fetch is for the next one.
refreshRemote(dir);
// `--exact-match`, so this is "the tag AT this commit", not "the last tag before it". Fails, and // `--exact-match`, so this is "the tag AT this commit", not "the last tag before it". Fails, and
// therefore returns null, when HEAD is between releases — which is the first half of the rule. // therefore returns null, when HEAD is between releases — which is the first half of the rule.
const tagAtHead = await run(dir, ['describe', '--exact-match', '--tags', 'HEAD']); const tagAtHead = await run(dir, ['describe', '--exact-match', '--tags', 'HEAD']);
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { import {
Folder, Folder,
Link2,
Trash2, Trash2,
Pencil, Pencil,
MoreVertical, MoreVertical,
@@ -631,6 +632,21 @@ export const FileItem = ({
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDir = entry.type === 'directory'; const isDir = entry.type === 'directory';
/*
A link, and where it goes.
Drawn as an arrow beside the name rather than as a badge or a different icon: the icon already says
what the thing IS — a folder, a photograph — and a symlink to a folder is still a folder to anyone
using it. What the arrow adds is that there is an indirection in the way, which is the thing that
explains why deleting it did not free any space, or why two places show the same file.
The target goes in `title`. It is often long and absolute, so it belongs in a tooltip rather than in
a row that has to fit a name, a type, a size and a date.
*/
const linkLabel = entry.symlink
? `${entry.broken ? 'Broken symlink' : 'Symlink'} → ${entry.symlink}`
: undefined;
const icon = isDir ? ( const icon = isDir ? (
<Folder className="h-5 w-5 text-duck-yellow fill-duck-yellow/30" /> <Folder className="h-5 w-5 text-duck-yellow fill-duck-yellow/30" />
) : ( ) : (
@@ -820,7 +836,18 @@ export const FileItem = ({
onCancel={() => setRenaming(false)} onCancel={() => setRenaming(false)}
/> />
) : ( ) : (
<span className="text-sm font-medium text-duck-dark truncate block">{entry.name}</span> <span className="flex min-w-0 items-center gap-1">
<span className="truncate text-sm font-medium text-duck-dark">{entry.name}</span>
{linkLabel && (
<span
title={linkLabel}
aria-label={linkLabel}
className={`shrink-0 leading-none ${entry.broken ? 'text-destructive' : 'text-duck-dark/45'}`}
>
<Link2 className="h-3.5 w-3.5" />
</span>
)}
</span>
)} )}
</div> </div>
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right"> <span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right">
@@ -886,7 +913,18 @@ export const FileItem = ({
onCancel={() => setRenaming(false)} onCancel={() => setRenaming(false)}
/> />
) : ( ) : (
<span className="w-full truncate text-center text-sm font-medium text-duck-dark">{entry.name}</span> <span className="flex w-full min-w-0 items-center justify-center gap-1">
<span className="truncate text-center text-sm font-medium text-duck-dark">{entry.name}</span>
{linkLabel && (
<span
title={linkLabel}
aria-label={linkLabel}
className={`shrink-0 leading-none ${entry.broken ? 'text-destructive' : 'text-duck-dark/45'}`}
>
<Link2 className="h-3.5 w-3.5" />
</span>
)}
</span>
)} )}
<span className="text-xs text-duck-dark/50"> <span className="text-xs text-duck-dark/50">
@@ -213,7 +213,11 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
</div> </div>
</form> </form>
) : ( ) : (
<div className="flex flex-col gap-3"> // `min-w-0` on the DIRECT grid child. DialogContent is a grid, and a grid item defaults to
// `min-width: auto` — so this is the element that grows to fit a long title and takes the
// card out of the dialog with it. The constraint has to be here; on the scroll container
// inside it was one level too low to stop anything.
<div className="flex min-w-0 flex-col gap-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<button <button
type="button" type="button"
@@ -248,7 +252,19 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
</div> </div>
)} )}
<div className="flex max-h-[52vh] flex-col gap-2 overflow-y-auto pr-1"> {/*
`min-w-0` is the one that makes `truncate` work, three levels down.
DialogContent is a GRID, and a grid item defaults to `min-width: auto` — it refuses to
shrink below its content. So a long title pushed this container wider than the dialog's
own `max-w-lg`, the card grew with it, and the `truncate` on the title never had a width
to truncate against. The text simply ran out of the modal.
Making the card a column fixed a different half of this and was not enough: a column still
cannot be narrower than the widest thing a grid will let it be. The constraint has to come
from above, and this is the highest point where it was missing.
*/}
<div className="flex max-h-[52vh] min-w-0 flex-col gap-2 overflow-y-auto pr-1">
{entries.map((e, i) => ( {entries.map((e, i) => (
<div <div
key={`${e.url}-${i}`} key={`${e.url}-${i}`}
@@ -205,6 +205,20 @@ export type DirEntry = {
* response, which simply means no thumbnail — the file icon, exactly as before. * response, which simply means no thumbnail — the file icon, exactly as before.
*/ */
thumb?: 'image' | 'video' | null; thumb?: 'image' | 'video' | null;
/**
* Where this points, when it is a symlink — exactly as the link was written.
*
* Relative targets stay relative. `../originals` and `/home/…/originals` behave differently when the
* folder moves, and resolving one into the other would hide the difference at the moment somebody is
* looking to understand it.
*
* `type` still describes the TARGET, so a link to a folder opens like a folder. This says only that
* there is a link in the way. Absent on an older response, which reads as "not a link" — the same
* thing every entry said before.
*/
symlink?: string | null;
/** The target is gone. The row is drawn anyway, because the link itself is really there. */
broken?: boolean;
}; };
type ListDirResponse = { type ListDirResponse = {
+9
View File
@@ -2,6 +2,15 @@ export { useSettings, DEFAULT_SETTINGS } from './useSettings';
export type { UseSettingsType, UserSettings, UserState } from './useSettings'; export type { UseSettingsType, UserSettings, UserState } from './useSettings';
export { useUserState } from './useUserState'; export { useUserState } from './useUserState';
export { useDashboardState } from './useDashboardState'; export { useDashboardState } from './useDashboardState';
/*
Exported for a workspace nested INSIDE a panel — see plugins/fofos-admin/web/WorkPanel.tsx.
`useDashboardState` persists the whole tree, which is right for a dashboard somebody arranged and
wrong for a layout its author ships: the structure would be frozen at whatever it was the first time
a screen was opened. `useAuthoredLayout` keeps the structure in source and persists only sizes and
zoom, which is what a nested authored layout wants for the same reason `PluginScreen` does.
*/
export { useAuthoredLayout } from './useAuthoredLayout';
export { useModels, useVisibleModels, useUserVisibleModels, useEnabledModels, modelKey } from './useModels'; export { useModels, useVisibleModels, useUserVisibleModels, useEnabledModels, modelKey } from './useModels';
export type { ModelOption } from './useModels'; export type { ModelOption } from './useModels';
export { useAccessPolicy } from './useAccessPolicy'; export { useAccessPolicy } from './useAccessPolicy';
+45
View File
@@ -204,6 +204,51 @@
background-attachment: fixed; background-attachment: fixed;
background-size: cover; background-size: cover;
} }
/*
NATIVE CONTROLS FOLLOW THE THEME.
------------------------------------------------------------------------------------------
Nothing here set `color-scheme`, so the browser drew every control it renders itself as if the page
were light — on a dark theme, a `<select>`'s dropdown opened as a white sheet with the page's own
pale text on it. Unreadable, and unreachable by any utility class, because that list is painted by
the browser and is not in the document.
`color-scheme` is the only thing that reaches it. It also fixes, for free, the other controls with
no styleable interior: date and colour pickers, the spinner on a number input, and default
scrollbars where a theme has not replaced them.
── Why the `dark` class decides it ──
It is the signal that exists. 203 of the 231 themes set `applyDarkClass`, and the provider puts the
class on <html> beside `data-theme`, so the two are always in agreement about the same element.
Both are specificity 0-1-0 and themes are imported after this file, so a theme that disagrees can
simply say so — this is a default, not a decision taken away.
── And the explicit colours underneath ──
`color-scheme` gives the browser's generic dark, not the theme's. Where a browser honours colours on
`<option>` — Firefox, and Chrome on Linux and Windows — these make the list the theme's own popover
surface instead. Where it does not (Safari, Chrome on macOS, which use a genuinely native menu), the
scheme above is what carries it, and generic dark is a great deal better than white-on-white.
A utility still wins: these are base-layer rules, so `bg-transparent` on a select behaves exactly as
it did.
*/
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
select,
option,
optgroup {
background-color: var(--popover);
color: var(--popover-foreground);
}
} }
/* /*